143 Commits

Author SHA1 Message Date
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
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
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
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
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
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
Dusan Vojacek
3595b24f3b fix
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-02 14:09:52 +02:00
Dusan Vojacek
5ca5eab1d8 sync reference days
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-05-02 14:05:09 +02:00
Dusan Vojacek
343f2f9847 rebuild consumpton baselaline
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-05-02 13:56:35 +02:00
Dusan Vojacek
b20cb6e0f9 fix soc v TOU (ne 100) pri ne-grid-charge
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-05-02 12:15:40 +02:00
Dusan Vojacek
fffe6c7185 fix rizeni pole pres reg340 jen home01
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-02 09:31:45 +02:00
Dusan Vojacek
ed88ef8910 oprava import/export kwh
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-01 14:58:29 +02:00
Dusan Vojacek
91ee8a6adf fix zaporne spot ceny v nakupu
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-01 14:27:08 +02:00
Dusan Vojacek
bf3b10ca50 fix rezani pv a
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-05-01 13:12:21 +02:00
Dusan Vojacek
e54eb1dfd9 rezani poole i kdyz je zlenenobonusove pole na stejnmstridaci
Some checks failed
CI and deploy / migration-check (push) Failing after 16s
CI and deploy / deploy (push) Has been skipped
2026-05-01 13:01:49 +02:00
Dusan Vojacek
1e0300dd7e register 340 -omezovani vyrkonu pv pole (home-01)
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-01 12:51:28 +02:00
Dusan Vojacek
e686bc1d2c fix solar sell pri male zaporne cene
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-01 10:38:40 +02:00
Dusan Vojacek
6743224cc5 ifx timeout u replannu
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-01 10:04:43 +02:00
Dusan Vojacek
03ebc6246d fxi ba81 maximum price sell
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-29 15:45:23 +02:00
Dusan Vojacek
efc6e54f0e fix lock charge on 100% SOC
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-04-29 15:27:54 +02:00
Dusan Vojacek
6074535d96 OTE informatin discord
Some checks failed
CI and deploy / migration-check (push) Failing after 25s
CI and deploy / deploy (push) Has been skipped
2026-04-29 14:17:24 +02:00
Dusan Vojacek
2eeab58c8e ote discord notifikace error
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-04-29 14:07:42 +02:00
Dusan Vojacek
93193fd5dc fix OTE fformat
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-29 13:48:13 +02:00
Dusan Vojacek
f3a7b0c64f FE cutoff vizsualize
Some checks failed
CI and deploy / deploy (push) Has been skipped
CI and deploy / migration-check (push) Failing after 11s
2026-04-29 13:47:57 +02:00
Dusan Vojacek
b66b0109b9 fix cutoff a grid peak shaving register
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-29 13:38:00 +02:00
Dusan Vojacek
dede8d604d fix cutoff a grid peak shaving register
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-04-29 13:36:38 +02:00
Dusan Vojacek
2c884e2135 fix forecsat accuracy
Some checks failed
CI and deploy / migration-check (push) Failing after 28s
CI and deploy / deploy (push) Has been skipped
2026-04-29 13:26:00 +02:00
Dusan Vojacek
342483b885 invert logic cutoff register
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-04-29 13:24:28 +02:00
Dusan Vojacek
9aceb628aa fix forecast
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-04-29 13:20:58 +02:00
Dusan Vojacek
89fb4f1924 fix idempotency gne port uctoff
Some checks failed
CI and deploy / migration-check (push) Failing after 19s
CI and deploy / deploy (push) Has been skipped
2026-04-29 13:09:43 +02:00
Dusan Vojacek
5593397fd3 fix cutoff
Some checks failed
CI and deploy / migration-check (push) Failing after 18s
CI and deploy / deploy (push) Has been skipped
2026-04-29 13:04:30 +02:00
Dusan Vojacek
9d37efb991 telemetrie per pv_array, fix predictinos
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped
2026-04-29 13:03:41 +02:00
Dusan Vojacek
afee62ba4e fix cutoff gen port
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-29 12:51:53 +02:00
Dusan Vojacek
e35110cb87 speedup srovnani
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-27 20:09:40 +02:00
Dusan Vojacek
542cd9a73c speedup ekonomics
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
2026-04-27 19:57:35 +02:00
Dusan Vojacek
8114ec5e63 FIX RYCHLOST EKONOMIKA
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-27 19:47:18 +02:00
Dusan Vojacek
c52946a4ce fix BA81 nevybijeni do site
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
2026-04-27 19:24:37 +02:00
Dusan Vojacek
69c979b967 fix notfications
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-27 19:13:16 +02:00
Dusan Vojacek
30585c9779 fix
Some checks failed
CI and deploy / migration-check (push) Failing after 18s
CI and deploy / deploy (push) Has been skipped
2026-04-27 18:48:04 +02:00
Dusan Vojacek
e96bb75b87 fix filename
Some checks failed
CI and deploy / migration-check (push) Failing after 18s
CI and deploy / deploy (push) Has been skipped
2026-04-27 18:44:44 +02:00
Dusan Vojacek
5b94f8baec fix prices reloading
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
2026-04-27 18:42:06 +02:00
Dusan Vojacek
e4d4fee24d fix reload pv on dashboard
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-04-27 18:39:13 +02:00
Dusan Vojacek
16fc6a065e zrychleni pv forecast per day
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-27 18:27:27 +02:00
Dusan Vojacek
cc674900cc fix azimut
Some checks failed
CI and deploy / migration-check (push) Failing after 19s
CI and deploy / deploy (push) Has been skipped
2026-04-27 18:12:05 +02:00
Dusan Vojacek
8960576ee8 skill vysvetlovac
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
2026-04-26 23:05:12 +02:00
Dusan Vojacek
50a0ca95f4 implementace LED loxone u zaporncyh cen
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-26 22:49:47 +02:00
Dusan Vojacek
1d04790f28 extend webhook per site
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-04-26 22:04:48 +02:00
Dusan Vojacek
5f96a4cf01 tuning BA81
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-04-26 20:12:28 +02:00
Dusan Vojacek
4875c31338 uprava solver gneport cutoff u ba81
Some checks failed
CI and deploy / migration-check (push) Failing after 25s
CI and deploy / deploy (push) Has been skipped
2026-04-26 19:55:35 +02:00
Dusan Vojacek
bf7373fbfe fix
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-26 04:36:12 +02:00
Dusan Vojacek
3940f6d45c next fix
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-26 02:07:11 +02:00
Dusan Vojacek
a943829c40 fix
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-26 01:59:03 +02:00
Dusan Vojacek
40b2ff2ff9 tune oversell before negative buy slots
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-04-26 01:54:24 +02:00
Dusan Vojacek
c6ca68b263 posun dovybijejiciho okna tesne pred zapornou cenu
Some checks failed
CI and deploy / migration-check (push) Failing after 8s
CI and deploy / deploy (push) Has been skipped
2026-04-26 01:39:48 +02:00
Dusan Vojacek
0edf9226cb posun dovybiti tesnep red zapornou cenu
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-26 01:30:28 +02:00
Dusan Vojacek
b1e124416d fix solver- vybiti do site pred zapornym nakupem
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-26 01:15:31 +02:00
Dusan Vojacek
1735f77863 oprava dynamickeho spodniho prahu
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-26 00:50:04 +02:00
Dusan Vojacek
5d7d7e2823 puldenni sltovoani , zruseni omemzeni na zakaz exportu pri zapornem sellu, hlubsi vybijeni ped zaporbnym nakupem
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-26 00:27:36 +02:00
Dusan Vojacek
f6e239aa8d prepnuti k planovani na kkorigovany forecast
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-23 00:16:23 +02:00
Dusan Vojacek
c928e2234d Implement telemetry enhancements: add reading of Deye registers 145 and 179 in telemetry collector to derive is_export_limited and pv_derating_flags. Update fn_telemetry_inverter_sample to store these flags, and adjust related documentation and API endpoints accordingly.
Some checks failed
CI and deploy / migration-check (push) Failing after 19s
CI and deploy / deploy (push) Has been skipped
2026-04-22 23:02:14 +02:00
Dusan Vojacek
1dfab8c7a1 dalsi uprava vypoctu delty (ignorujeme orezane vyroby)
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped
2026-04-22 22:42:12 +02:00
Dusan Vojacek
568b584748 kalibrace per pole
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
2026-04-22 22:17:28 +02:00
Dusan Vojacek
3cd8e44d37 2vzorky pro korekci predikce
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-22 21:34:44 +02:00
Dusan Vojacek
5a66cfa63f ladime a ladime
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-04-22 21:05:14 +02:00
Dusan Vojacek
bc0966e4c4 tune forecast correction parametersw
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
2026-04-22 20:47:32 +02:00
Dusan Vojacek
638c5444be tune korekce z clear sky
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-22 20:17:29 +02:00
Dusan Vojacek
09f1d2de68 fix ustrelenych dat z telemetrie, berem jen mladsi data
Some checks failed
CI and deploy / migration-check (push) Failing after 18s
CI and deploy / deploy (push) Has been skipped
2026-04-22 20:00:18 +02:00
Dusan Vojacek
bd7d6a1b99 fix graf v sql
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
2026-04-22 19:50:49 +02:00
Dusan Vojacek
faf948d75b fix max grid kw
Some checks failed
CI and deploy / migration-check (push) Failing after 8s
CI and deploy / deploy (push) Has been skipped
2026-04-22 19:41:11 +02:00
Dusan Vojacek
e085068069 fix forecast korekce
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-22 19:40:55 +02:00
Dusan Vojacek
9ca4b4c577 korkece fve predikce, grafy predikci
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-22 19:26:46 +02:00
Dusan Vojacek
ffe80679cc move OTE import na 13:25 a 13:12
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-04-20 13:20:05 +02:00
Dusan Vojacek
6cf14ed25b idempotence zapisu 178 a 179 grid peak shaveing a grid cuttoff
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-20 11:41:57 +02:00
Dusan Vojacek
a07f5d57cb fix 500
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-20 11:11:47 +02:00
Dusan Vojacek
b8515f30df implmemtace cuttoff genportu
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
2026-04-20 10:41:10 +02:00
Dusan Vojacek
d8dbb284fd FE implementace deye modu
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-04-20 08:50:20 +02:00
Dusan Vojacek
43b594c8d5 solver nastavuje stavy deye
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-04-20 08:33:56 +02:00
Dusan Vojacek
6447666cee fix MCP
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
2026-04-19 23:49:21 +02:00
Dusan Vojacek
7f3b0957cc fix discharge battery
Some checks failed
CI and deploy / migration-check (push) Failing after 18s
CI and deploy / deploy (push) Has been skipped
2026-04-19 23:46:16 +02:00
Dusan Vojacek
e3776226a4 implmentace plan guardu
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped
2026-04-19 23:10:25 +02:00
Dusan Vojacek
d8221e3169 prekopani SELL
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-04-19 22:48:51 +02:00
Dusan Vojacek
ee4355f17f fix FE 96h forecast
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped
2026-04-19 21:40:55 +02:00
Dusan Vojacek
70d306961a next fix
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-04-19 21:28:58 +02:00
Dusan Vojacek
ea2e33972c fix structure query
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-04-19 21:24:13 +02:00
Dusan Vojacek
6dc14764d0 refix planneru
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-04-19 21:20:09 +02:00
Dusan Vojacek
301f20612f fix drop tem[porary table
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
2026-04-19 21:16:26 +02:00
Dusan Vojacek
f48a7aad61 zkraceni intervalu planneru na max 35h
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-04-19 21:09:48 +02:00
Dusan Vojacek
e33207f3fa tune documentation
Some checks failed
CI and deploy / migration-check (push) Failing after 18s
CI and deploy / deploy (push) Has been skipped
2026-04-19 20:46:29 +02:00
Dusan Vojacek
014c6f193b refactor main.py
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped
2026-04-19 20:42:53 +02:00
Dusan Vojacek
ccb2a41e22 next cahgnes
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
2026-04-19 20:16:08 +02:00
Dusan Vojacek
22bca9cd9e fix repeatable migrations 2026-04-19 20:15:46 +02:00
Dusan Vojacek
0c93f493a4 fix lfywlay migration
Some checks failed
CI and deploy / migration-check (push) Successful in 3s
CI and deploy / deploy (push) Failing after 13s
2026-04-19 20:09:07 +02:00
Dusan Vojacek
93f883f5e0 sql first refactor
Some checks failed
CI and deploy / migration-check (push) Successful in 5s
CI and deploy / deploy (push) Failing after 20s
2026-04-19 20:02:20 +02:00
Dusan Vojacek
a02e11ee13 deye ridi maximalni flow do baterie hlavne z gridu
All checks were successful
CI and deploy / migration-check (push) Successful in 4s
CI and deploy / deploy (push) Successful in 22s
2026-04-19 15:54:14 +02:00
Dusan Vojacek
f8e1eed127 fix rs485 s eror self_sustain
All checks were successful
CI and deploy / migration-check (push) Successful in 6s
CI and deploy / deploy (push) Successful in 29s
2026-04-19 15:29:58 +02:00
Dusan Vojacek
efc2cbfded fix battery charge u self_sustain rezimu
All checks were successful
CI and deploy / migration-check (push) Successful in 3s
CI and deploy / deploy (push) Successful in 25s
2026-04-19 15:09:33 +02:00
Dusan Vojacek
5c868083af fix planning bat vs sit
All checks were successful
CI and deploy / migration-check (push) Successful in 2s
CI and deploy / deploy (push) Successful in 1m5s
2026-04-19 14:52:12 +02:00
Dusan Vojacek
b4c58156f0 planning rosireni o vlastni spotrebu
Some checks are pending
CI and deploy / deploy (push) Blocked by required conditions
CI and deploy / migration-check (push) Successful in 3s
2026-04-19 14:49:10 +02:00
Dusan Vojacek
dc0e37e580 Fix fixu gri charge
All checks were successful
CI and deploy / migration-check (push) Successful in 3s
CI and deploy / deploy (push) Successful in 26s
2026-04-19 14:30:31 +02:00
Dusan Vojacek
0814b1d8e8 fix hard limit pro nabijeni
All checks were successful
CI and deploy / migration-check (push) Successful in 4s
CI and deploy / deploy (push) Successful in 30s
2026-04-19 14:23:10 +02:00
Dusan Vojacek
ee27f4e3fd doc - plan explain
All checks were successful
CI and deploy / migration-check (push) Successful in 2s
CI and deploy / deploy (push) Successful in 13s
2026-04-19 14:17:14 +02:00
Dusan Vojacek
906eeb1609 flyway check
All checks were successful
CI and deploy / migration-check (push) Successful in 3s
CI and deploy / deploy (push) Successful in 14s
2026-04-19 14:11:57 +02:00
Dusan Vojacek
477e94f321 plan explain DB function
All checks were successful
deploy / deploy (push) Successful in 16s
test / smoke-test (push) Successful in 3s
2026-04-19 13:58:08 +02:00
Dusan Vojacek
d3fd8b139a Add TOU SOC handling for battery priority in passive mode
All checks were successful
deploy / deploy (push) Successful in 28s
test / smoke-test (push) Successful in 6s
- Introduced `effective_sell_price_czk_kwh` to `ControlSetpoints` for managing battery usage based on sell price.
- Implemented logic in `_deye_passive_tou_battery_soc_pct` to set TOU SOC to 100% when conditions favor battery usage.
- Updated tests to validate new behavior for negative sell prices and planned charging scenarios.
- Enhanced documentation to clarify TOU SOC behavior in passive mode.
2026-04-19 12:49:04 +02:00
Dusan Vojacek
d5dcf33e13 fix V044
All checks were successful
deploy / deploy (push) Successful in 16s
test / smoke-test (push) Successful in 6s
2026-04-19 12:19:12 +02:00
Dusan Vojacek
a1aa6acf61 Add support for inverter current caps in site configuration
Some checks failed
deploy / deploy (push) Failing after 55s
test / smoke-test (push) Successful in 3s
- Introduced `InverterModbusCurrentCapsBody` model for updating max charge and discharge currents.
- Updated SQL queries to utilize `COALESCE` for effective current limits.
- Modified relevant tests to reflect changes in battery current handling.
- Added new SQL migration for `deye_register_max_current_a` columns in the database.
2026-04-19 12:10:37 +02:00
Dusan Vojacek
fd06811753 tune microcycling
All checks were successful
deploy / deploy (push) Successful in 25s
test / smoke-test (push) Successful in 6s
2026-04-13 00:49:36 +02:00
Dusan Vojacek
3b33594354 fix enf load
All checks were successful
test / smoke-test (push) Successful in 3s
deploy / deploy (push) Successful in 14s
2026-04-12 22:31:18 +02:00
Dusan Vojacek
3da738e7e9 battery simulator
All checks were successful
deploy / deploy (push) Successful in 13s
test / smoke-test (push) Successful in 3s
2026-04-12 22:24:32 +02:00
Dusan Vojacek
f0dfcefd54 fix letni /zimni cas OTE
All checks were successful
deploy / deploy (push) Successful in 19s
test / smoke-test (push) Successful in 5s
2026-04-12 21:57:37 +02:00
Dusan Vojacek
5919b6caf3 new fix OTE
All checks were successful
deploy / deploy (push) Successful in 12s
test / smoke-test (push) Successful in 3s
2026-04-12 21:43:25 +02:00
Dusan Vojacek
9ff7c96c22 fix backfill
All checks were successful
deploy / deploy (push) Successful in 20s
test / smoke-test (push) Successful in 5s
2026-04-12 21:38:57 +02:00
Dusan Vojacek
0e5227eb5b OTE backkfill
All checks were successful
deploy / deploy (push) Successful in 24s
test / smoke-test (push) Successful in 6s
2026-04-12 21:32:27 +02:00
Dusan Vojacek
3c9916f2c0 KV1 seed
All checks were successful
deploy / deploy (push) Successful in 12s
test / smoke-test (push) Successful in 2s
2026-04-12 21:16:26 +02:00
Dusan Vojacek
d7e6226962 fix graf v sql
All checks were successful
deploy / deploy (push) Successful in 20s
test / smoke-test (push) Successful in 7s
2026-04-12 21:00:36 +02:00
Dusan Vojacek
f7d3162eb7 fix forecast graf
All checks were successful
deploy / deploy (push) Successful in 1m30s
test / smoke-test (push) Successful in 7s
2026-04-12 20:55:10 +02:00
Dusan Vojacek
3066a82265 fix min connection
All checks were successful
deploy / deploy (push) Successful in 27s
test / smoke-test (push) Successful in 5s
2026-04-12 20:54:23 +02:00
Dusan Vojacek
0ba72c7704 fix mismatch rs485
All checks were successful
deploy / deploy (push) Successful in 28s
test / smoke-test (push) Successful in 7s
2026-04-12 20:28:17 +02:00
Dusan Vojacek
71d8405cee new site BA81, tuyne forecast
All checks were successful
deploy / deploy (push) Successful in 23s
test / smoke-test (push) Successful in 5s
2026-04-12 20:11:50 +02:00
Dusan Vojacek
015c81a8cb template pro novou site
All checks were successful
deploy / deploy (push) Successful in 24s
test / smoke-test (push) Successful in 6s
2026-04-12 17:01:20 +02:00
Dusan Vojacek
4e81a36371 stranka configuration
Some checks failed
test / smoke-test (push) Has been cancelled
deploy / deploy (push) Has been cancelled
2026-04-12 16:56:44 +02:00
Dusan Vojacek
b50041cfc7 do flow pridana ekonomika
All checks were successful
deploy / deploy (push) Successful in 1m21s
test / smoke-test (push) Successful in 5s
2026-04-10 23:06:25 +02:00
Dusan Vojacek
44ab3783ce flow - pridana perspektiva loadu
All checks were successful
deploy / deploy (push) Successful in 1m16s
test / smoke-test (push) Successful in 3s
2026-04-10 22:54:20 +02:00
Dusan Vojacek
a65d134682 flwo - denni sankey graf
All checks were successful
deploy / deploy (push) Successful in 1m19s
test / smoke-test (push) Successful in 3s
2026-04-10 22:49:43 +02:00
Dusan Vojacek
74ffa5c3e7 fix sankey
All checks were successful
deploy / deploy (push) Successful in 1m17s
test / smoke-test (push) Successful in 3s
2026-04-10 22:42:10 +02:00
Dusan Vojacek
f714cab0ab nova stranka flow a obsluha
All checks were successful
deploy / deploy (push) Successful in 8m52s
test / smoke-test (push) Successful in 5s
2026-04-10 22:13:58 +02:00
Dusan Vojacek
64221f701a fix view
All checks were successful
deploy / deploy (push) Successful in 13s
test / smoke-test (push) Successful in 5s
2026-04-10 21:57:08 +02:00
Dusan Vojacek
806274cf59 uprava adutiu - nacitani dalsich registru, uprava ekonomiky
Some checks failed
deploy / deploy (push) Failing after 1m15s
test / smoke-test (push) Successful in 2s
2026-04-10 21:53:32 +02:00
Dusan Vojacek
25090a9d95 tak predchozi commit byl uprava dasbodu, toto je az fix te migrace
All checks were successful
deploy / deploy (push) Successful in 58s
test / smoke-test (push) Successful in 9s
2026-04-10 20:58:04 +02:00
Dusan Vojacek
b8b3de2b70 fix materialized view 2026-04-10 20:56:42 +02:00
249 changed files with 23605 additions and 5287 deletions

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,15 @@
---
description: When changing implementation, update relevant docs
alwaysApply: true
---
# Documentation update discipline
- When you make an **implementation change** (Python/SQL/frontend), you must also update the **relevant documentation**
in `docs/` (and/or `CLAUDE.md` if its normative guidance) in the same change set.
- The docs update must cover:
- what behavior changed (externally visible / operational impact),
- where it is implemented (file/function names),
- how to verify it (DB table/view, API endpoint, or operational check).
If there is no existing relevant document, add a short section to the closest module doc under `docs/04-modules/`.

View File

@@ -0,0 +1,13 @@
---
description: MCP PostgreSQL EMS — když uživatel napíše „použij MCP“ nebo chce živá data z DB
globs:
alwaysApply: true
---
# MCP → EMS Postgres (read-only)
- **Server ID** pro volání MCP nástroje: **`user-postgres-ems`** (v Cursor UI může být zobrazen jako **postgres-ems** — to je stejný server).
- **Nástroj:** **`query`**. **Argument:** `{"sql": "<SELECT …>"}` — pouze read-only.
- Při žádosti o živá data / „použij MCP“: **nejprve zavolej `query`**. Neargumentuj, že připojení „nejde“ nebo že MCP „neexistuje“, dokud volání reálně neskončí chybou.
- Po chybě: uveď text chyby a praktické kroky (VPN, MCP zapnutý v Cursoru, dostupnost DB z prostředí kde MCP běží).
- Detailní postup, příklady SQL a bezpečnost: **`docs/07-mcp-postgres-ems.md`**.

View File

@@ -0,0 +1,33 @@
---
description: Jak z DB vytáhnout snapshot plánu (vysvětlení „proč je plán takový“) bez zbytečných tokenů
globs:
alwaysApply: false
---
# Vysvětlení plánu z databáze (tokenová efektivita)
Když uživatel ptá na **důvod tvaru plánu** (např. nejbližších **6 hodin**, nabíjení/vybíjení, export, EV, TČ, ceny), **nejprve** si stáhni jeden balík z DB — **nevymýšlej dotazy znovu od nuly**.
## 1) Primární zdroj (doporučeno)
```sql
SELECT ems.fn_plan_explain_bundle(<site_id>, 6);
```
- Druhý argument = počet hodin od **začátku aktuálního 15min slotu** (UTC, stejně jako `planning_engine._current_slot_start`).
- Vrací **jeden JSONB**: aktivní `planning_run`, `planning_interval` jen v okně, `site_operating_mode`, `asset_battery`, `site_grid_connection`, `asset_heat_pump`, otevřené `ev_session`, poslední řádky `forecast_correction_log`, překrývající se `site_override`, metadata + krátký `ai_readme` s odkazy na kód.
Pokud `error = no_active_plan`, v odpovědi uveď že aktivní plán v DB není (404 i u API `/plan/current`).
## 2) Co z JSONu číst při odpovědi
- **Proč baterie / síť**: `intervals_next_window` → `battery_setpoint_w`, `grid_setpoint_w`, `effective_buy_price` / `effective_sell_price`, `is_predicted_price`, vstupy `load_baseline_w`, `pv_*_forecast_*_w`, výstup `pv_a_curtailed_w`.
- **Provozní rámec**: `operating_mode.mode_code` (AUTO vs CHARGE_CHEAP vs …) — LP constraints v `solve_dispatch()`.
- **Limity**: `site_grid_connection`, `asset_battery` (`min_soc_percent`, `reserve_soc_percent`, `usable_capacity_wh`, degradace).
- **EV deadline**: `ev_sessions_open` + sloupce `target_deadline` / `target_soc_pct` v kontextu intervalů (`ev1_setpoint_w`, `ev2_setpoint_w`).
- **Rolling vs daily**: `active_planning_run.run_type`, `triggered_by`, `forecast_correction_factor`, `replan_from`, `soc_at_replan_wh`.
- **Horizont a ceny**: produkční LP používá dynamický OTE horizont (`fn_planning_horizon_end`); u intervalu je `hours_from_plan_horizon_start` jen orientační. Váhy 036h / 3672h / 7296h jsou **historické** (viz `ai_readme` v JSONu a `docs/04-modules/planning-extended-horizon.md`).
## 3) Volitelně (UI stejné jako dashboard)
REST `GET /api/v1/sites/{site_id}/plan/current` vrací širší horizont než 6 h; pro **vysvětlování** preferuj `fn_plan_explain_bundle`, aby výstup byl úzký a jednorázový.

View File

@@ -0,0 +1,14 @@
---
description: Postgres DROP/COMMENT ON FUNCTION bez seznamu argumentů (jedna funkce na jméno)
globs: db/**/*.sql
alwaysApply: false
---
# Postgres: `DROP FUNCTION` a `COMMENT ON FUNCTION` bez parametrů
- U **`DROP FUNCTION`** (včetně schématu, např. `ems.fn_pv_forecast_delta_profile`) **nemusíme** uvádět signaturu argumentů, pokud platí předpoklad: **v DB existuje jen jedna funkce tohoto plného jména** (žádný jiný overload se stejným jménem).
- Stejně u **`COMMENT ON FUNCTION`** používej **`COMMENT ON FUNCTION ems.nazev_funkce IS '...'`** bez seznamu typů argumentů — za stejného předpokladu jedné funkce na jméno.
**Chyba při migraci je v pořádku:** pokud v DB existují **dvě (nebo víc) funkcí stejného jména** (overloady), `DROP FUNCTION` / `COMMENT ON FUNCTION` **bez** seznamu typů může Postgres **zamítnout jako nejednoznačné** — to je žádoucí: hned se detekuje **nechtěný stav**, který se má opravit **odstraněním jedné z funkcí** (nebo přejmenováním), ne obcházením přes dlouhou signaturu v migraci.
**Když overload záměrně chceme:** jednoznačná jména nebo v daném skriptu dočasně uvést signaturu — v tomto projektu je default „jedna funkce na jméno“.

View File

@@ -0,0 +1,26 @@
---
description: TimescaleDB continuous aggregates komentáře a Flyway (EMS)
globs: db/**/*.sql
alwaysApply: false
---
# Timescale continuous aggregate v EMS
## Komentáře u CA (kritické)
Continuous aggregate vytvořený jako `CREATE MATERIALIZED VIEW … WITH (timescaledb.continuous)` **není** v systémovém katalogu PostgreSQL evidovaný jako běžný **materialized view**.
- **Nepoužívat** `COMMENT ON MATERIALIZED VIEW ems.<název_ca> …` → chyba SQL state **42809** („is not a materialized view“).
- **Použít** `COMMENT ON VIEW ems.<název_ca> …` — stejný vzor jako u `telemetry_inverter_hourly` v migraci **V011**.
Samotné **wrapper view** nad CA (např. `vw_telemetry_15m_7d` v repeatable `R__071_vw_telemetry_15m_7d.sql`) komentovat standardně `COMMENT ON VIEW`.
## Struktura repa
- **Definice CA + `add_continuous_aggregate_policy`**: verzovaná migrace `db/migration/V0xx__*.sql` (po aplikaci na DB neměnit — nová V migrace).
- **Definice čtecího view nad CA**: raději **repeatable** `db/views/R__NNN_vw_*.sql` (číselný prefix kvůli pořadí Flyway), aby šla měnit jedna aktuální verze bez nové V migrace.
- **PostgREST**: `GRANT SELECT` na view v `db/views/R__072_z_postgrest_ems_anon_grants.sql`, ne na samotný CA.
## Odkaz v dokumentaci
Detailněji: `docs/04-modules/telemetry.md` (sekce o continuous aggregates a dashboardu).

View File

@@ -0,0 +1,93 @@
---
name: ems-plan-explain
description: >-
Explains EMS dispatch plans from live Postgres (MCP): why battery/grid/PV/curtailment
for a site and time window. If the user does not explicitly name a site (id, code, or
unambiguous name), query ems.site (with active plan hint), show a numbered list, and
ask which site to use — do not run plan analysis for multiple sites in one turn. Use when the
user asks why the plan looks a certain way, planning_interval rows, negative prices,
export zero, rolling replan, or says „vysvětli plán“, „proč nabíjí“, „proč škrtí FVE“.
---
# EMS — vysvětlení plánu (živá DB + kontext kódu)
## Kdy skill použít
- Otázky typu **proč** plán dělá X (nabíjení, export, curtailment, režim, ceny).
- Uživatel zmíní **kód lokality** (`BA81`, …) nebo „aktuální plán“.
- Porovnání **model vs realita** (záporná cena, nulový export, pole A/B).
## Tvrdá pravidla
1. **Nejdřív data z DB přes MCP** (`user-postgres-ems`, nástroj `query`, pouze `SELECT`). Nevysvětlovat konkrétní sloty „z hlavy“ bez dotazu.
2. Pokud MCP selže: uvést **přesnou chybu** a praktické kroky (VPN, MCP zapnutý, dostupnost DB).
3. **`site_id` jen po explicitní volbě uživatele** (kód, id, potvrzení jedné řádky), nebo když uživatel **lokalitu v dotazu sám pojmenoval**. Neuvedená lokalita → **nejprve jen dotaz na výběr** (viz Krok 1); **zakázáno** analyzovat plán pro více `site` v jedné odpovědi „preventivně“.
4. V odpovědi rozlišit: **co říká plán v DB** vs **co předpokládá LP model** vs **co omeží hardware** (např. taper nabíjení u vysokého SoC dnes v LP **není**).
## Postup (zkopíruj checklist)
```
- [ ] Zjistit site_id: uživatel ji v dotazu pojmenoval? → případně MCP lookup. Jinak MCP seznam + **zeptat se** (viz Krok 1); až po odpovědi → jedna `site_id`
- [ ] MCP: fn_plan_explain_bundle(site_id, hours) — default hours=6
- [ ] Z JSONu: operating_mode, grid limity, battery limity, intervals_next_window
- [ ] Potřebuji konkrétní čas? → doplnit SELECT na planning_interval (viz reference.md)
- [ ] Vysvětlit bilanci slotu + relevantní LP pravidla (solve_dispatch)
```
### Krok 1 — `site_id`
**Co znamená „lokalita explicitně zmíněná“:** v textu uživatele je **číselné `site_id`**, **kód lokality** (`BA81`, `home-01`, …), nebo **jednoznačný** název/fragment, ze kterého MCP vrátí **právě jednu** řádku `ems.site`.
- Pokud uživatel dal **`site_id` jako číslo**: ověřit MCP, že řádek v `ems.site` existuje → použít.
- Pokud dal **kód nebo část názvu** (`BA81`, …): MCP `select id, code, name from ems.site where code ilike … or name ilike …`.
- **0 řádků** → nabídnout seznam z [reference.md §0](reference.md) (všechny lokality) + **zeptat se**, kterou myslí.
- **1 řádek** → použít jeho `id`.
- **Více řádků** → číslovaný výpis + **zeptat se** na jednu (můžeš hintnout *kdo má aktivní plán*, ale **nepouštěj** analýzu dřív než výběr).
- Pokud **lokalita vůbec zmíněná není** („vysvětli plán“, „proč nabíjí“ bez kódu apod.):
1. MCP: SQL z **reference.md §0** (seřazený seznam `site` + `active_planning_run_id`).
2. V odpovědi uvést **číslovaný seznam** `id | code | name | má aktivní plán?`.
3. **Výslovně se zeptat uživatele**, kterou lokalitu myslí (číslo z výpisu, `code`, nebo `id`).
4. **`fn_plan_explain_bundle` ani rozšířený SELECT na `planning_interval` pro tuto otázku nespouštěj**, dokud uživatel **nevybere jednu** lokalitu (kód / číslo řádku / id / jednoznačné „tu s BA81“). **Nepředvybírej** „beru první řádek“ ani nespouštěj paralelně bundle pro všechny `site_id` — je to zbytečná zátěž a matoucí výstup.
5. Je v DB **jen jeden** záznam `ems.site`: stejně **nejdřív** napiš *která* lokalita to je a **zeptej se** na krátké potvrzení (např. *„Mám plán vysvětlit pro **CODE**?“* / stačí „ano“) — **bez** `fn_plan_explain_bundle` před odpovědí. Výjimku tvoří jen situace, kdy uživatel **v téže zprávě** současně explicitně odkáže na tuto jedinou lokalitu (pak není „neuvedená“).
### Krok 2 — balík pro vysvětlení
```sql
select ems.fn_plan_explain_bundle(<site_id>, <hours>);
```
- **`<hours>`**: default **6**. Jiná hodnota jen když uživatel explicitně chce delší/kratší okno.
- Výstup je **jeden JSONB** (`bundle`): viz `.cursor/rules/plan-explain-bundle.mdc` — které klíče číst.
### Krok 3 — interpretace (struktura odpovědi)
Krátce a v pořadí:
1. **Kontext**: `operating_mode.mode_code`, `active_planning_run` (`run_type`, `triggered_by`, `soc_at_replan_wh`, `forecast_correction_factor`).
2. **Slot(y)**: z `intervals_next_window` nebo z dodatečného SQL — pro každý relevantní interval:
- **Výkon**: `battery_setpoint_w` (+ nabíjení / vybíjení), `grid_setpoint_w` (+ import / export), `load_baseline_w`.
- **FVE**: `pv_a_forecast_solver_w`, `pv_b_forecast_solver_w`, `pv_a_curtailed_w` (useknuté W na **pole A**).
- **Ceny**: `effective_buy_price`, `effective_sell_price`, `is_predicted_price`.
- **Exekuce Deye** (pokud je ve sloupcích): `deye_physical_mode`, `deye_gen_cutoff_enabled`.
3. **Proč** (odkaz na logiku, ne dlouhá citace):
- 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`.
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.
### Kdy se zeptat uživatele
- **Lokalita neuvedená nebo nejednoznačná** — vždy **nejdřív** výběr / potvrzení (viz Krok 1); **nikdy** hned neanalyzovat všechny lokality najednou.
- **Čas bez časové zóny** („v 11:15“) — potvrdit **Europe/Prague** nebo explicitní offset.
- **Širší horizont** než pár hodin — domluvit `hours` nebo přesné `from`/`to` UTC pro doplnkový SELECT.
## Další SQL a šablony
→ [reference.md](reference.md)
## 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.
- Nevyhledávat plán přes desítky ad-hoc dotazů, když stačí **`fn_plan_explain_bundle`** a případně jeden doplnkový `SELECT` na časové okno.
- Nezaměňovat **`pv_a_curtailed_w`** (plán) s tím, **co je vždy zapsané na Modbus** — exekuce curtailmentu na Deye může být instalacně závislá; při pochybnostech říct „ověřit v `docs/05-todo.md` / modbus docs“.

View File

@@ -0,0 +1,104 @@
# EMS plan explain — reference SQL (MCP)
Všechno jen **read-only** `SELECT`. Server MCP: **`user-postgres-ems`**, nástroj **`query`**, argument `{"sql": "…"}`.
## 0) Lokalita neuvedená v dotazu — seznam pro výběr
Spusť jeden dotaz; výsledek **vyrenderuj uživateli jako číslovaný seznam** (`id`, `code`, `name`, příznaky).
```sql
select s.id,
s.code,
s.name,
coalesce(s.active, true) as site_active,
pr.id as active_planning_run_id,
pr.created_at as active_plan_created_at
from ems.site s
left join lateral (
select id, site_id, created_at
from ems.planning_run
where site_id = s.id
and status = 'active'
order by created_at desc
limit 1
) pr on true
order by (pr.id is not null) desc,
coalesce(s.active, true) desc,
s.id;
```
**Po seznamu vždy zeptej se uživatele** na jednu lokalitu (číslo řádku, `code`, nebo `id`). **Nespouštěj** `fn_plan_explain_bundle` pro více lokalit najednou ani „tiše“ pro první řádek — viz skill `ems-plan-explain` Krok 1. Volitelně můžeš v jedné větě upozornit, kdo má `active_planning_run_id`, ale **výběr nech na uživateli** (u jediného záznamu v tabulce stačí krátké potvrzení typu „ano“).
Až uživatel lokalitu vybere nebo potvrdí, pokračuj `fn_plan_explain_bundle(s.id, hours)`.
## 1) `site_id` z kódu lokality
Nahraď literál v uvozovkách (příklad `BA81`):
```sql
select id, code, name, timezone
from ems.site
where code ilike 'BA81'
or name ilike '%BA81%';
```
Pokud více řádků → **zeptat se uživatele**, kterou lokalitu myslí.
## 2) Primární balík (doporučeno pro vysvětlení)
Druhý argument = **počet hodin** od začátku aktuálního 15min slotu (UTC), stejně jako plánovač.
```sql
select ems.fn_plan_explain_bundle(3, 6) as bundle;
```
Typicky druhý argument **6**. Větší okno jen když uživatel chce delší výhled (více tokenů).
## 3) Konkrétní sloty v čase (Europe/Prague)
Intervaly v DB jsou **`timestamptz` (UTC)**. Pro „zítra 11:15“ převeď na UTC v dotazu nebo použij okno:
```sql
select pi.interval_start,
pi.battery_setpoint_w,
pi.grid_setpoint_w,
pi.load_baseline_w,
pi.pv_a_forecast_solver_w,
pi.pv_b_forecast_solver_w,
pi.pv_a_curtailed_w,
pi.effective_buy_price,
pi.effective_sell_price,
pi.deye_physical_mode,
pi.deye_gen_cutoff_enabled
from ems.planning_interval pi
where pi.run_id = (
select id from ems.planning_run
where site_id = 3 and status = 'active'
order by created_at desc
limit 1
)
and pi.interval_start >= '2026-04-27T08:00:00+00:00'
and pi.interval_start < '2026-04-27T14:00:00+00:00'
order by pi.interval_start;
```
Hodnoty `site_id` a časové meziráky nahraď podle kontextu.
## 4) Žádný aktivní plán
Když `fn_plan_explain_bundle` vrátí chybu / `no_active_plan`, ověř:
```sql
select id, status, run_type, created_at, horizon_start, horizon_end
from ems.planning_run
where site_id = 3
order by created_at desc
limit 5;
```
## 5) Dokumentace v repu
- `docs/07-mcp-postgres-ems.md` — MCP bezpečnost a příklady
- `.cursor/rules/plan-explain-bundle.mdc` — co číst z JSONu
- `backend/services/planning_engine.py``solve_dispatch` (omezení `sell < 0`, `buy < 0`, curtailment)
- `docs/04-modules/planning.md` — bilance, účelovka, edge cases

View File

@@ -16,10 +16,11 @@
# ---- PostgreSQL ----
DB_USER=ems_user
DB_PASSWORD=change_me_strong_password
# Limit současných připojení k DB (deploy/docker-compose + kořenové docker-compose). Výchozí v compose je 300.
# POSTGRES_MAX_CONNECTIONS=300
# ---- PostgREST ----
POSTGREST_JWT_SECRET=change_me_jwt_secret_min_32_chars
# PostgREST anonymní role (viz db/migration/V009__postgrest_roles.sql + R__z_postgrest_ems_anon_grants.sql).
# PostgREST anonymní role (viz db/migration/V009__postgrest_roles.sql + R__072_z_postgrest_ems_anon_grants.sql).
POSTGREST_ANON_ROLE=ems_anon
# ---- OTE CZ import ----
@@ -41,7 +42,7 @@ DISCORD_WEBHOOK_URL= # Discord webhook URL pro alerty, prázdné = vypnuto
TELEMETRY_POLL_INTERVAL_SEC=60
# ---- Plánování ----
PLANNING_HORIZON_HOURS=36
# Délka horizontu (strop OTE + min délka pro rolling): ems.fn_planning_horizon_end v DB, ne env.
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

View File

@@ -1,22 +1,73 @@
# Deploy na single server: deploy.sh volá hostovský Docker přes /var/run/docker.sock (bez DinD).
# CI: immutability + Flyway validate (JDBC na staging / sdílenou DB). Deploy na main až po úspěchu.
# Job bez container: — hostovský docker + git (stejně jako deploy).
# Gitea secrets: EMS_CI_FLYWAY_URL (jdbc:postgresql://…/ems). Volitelně EMS_CI_FLYWAY_USER, EMS_CI_FLYWAY_PASSWORD.
# Runner: container.valid_volumes pro /var/run/docker.sock (viz docs/deployment-self-hosted.md).
#
# Job běží v kontejneru — /opt/ems-deploy a sock musí být přimountované (viz container.volumes).
# V /opt/gitea-stack/runner/config.yaml nastav container.valid_volumes na stejné cesty.
# Sladit `runs-on` s labely registrace runneru (výchozí: self-hosted).
#
# Spuštění: push na větev main (včetně merge PR do main — merge v Gitea/Git je stále push na main).
# Nepřidávat paralelně pull_request:closed — při merge by běžel deploy dvakrát (push + PR).
# Spuštění deploye: push na main. Nepřidávat paralelně pull_request:closed — při merge by běžel deploy dvakrát.
name: deploy
name: CI and deploy
on:
push:
branches:
- main
- feature/**
pull_request:
workflow_dispatch:
jobs:
migration-check:
runs-on: self-hosted
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
run: |
set -eu
su="${{ github.server_url }}"
case "$su" in
https://*) clone_url="https://oauth2:${TOKEN}@${su#https://}" ;;
http://*) clone_url="http://oauth2:${TOKEN}@${su#http://}" ;;
*) echo "unknown github.server_url: $su"; exit 1 ;;
esac
clone_url="${clone_url}/${{ github.repository }}.git"
git init
git remote add origin "$clone_url"
git fetch --depth=64 origin "${{ github.sha }}"
git checkout -qf FETCH_HEAD
git remote set-branches origin 'main' || true
git fetch --depth=64 origin main:refs/remotes/origin/main || true
- name: Repo layout
run: |
test -f docker-compose.yml
test -f deploy/docker-compose.yml
test -x deploy/deploy.sh
test -x scripts/ci_check_migration_immutability.sh
test -x scripts/ci_flyway_validate_remote.sh
- name: Migration immutability (vs PR base or main)
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -eu
BASE='origin/main'
if [ -n "${PR_BASE_SHA:-}" ]; then
BASE="$PR_BASE_SHA"
git fetch --no-tags --depth=256 origin "$BASE" || true
fi
./scripts/ci_check_migration_immutability.sh "$BASE"
- name: Flyway validate (remote DB)
env:
EMS_CI_FLYWAY_URL: ${{ secrets.EMS_CI_FLYWAY_URL }}
EMS_CI_FLYWAY_USER: ${{ secrets.EMS_CI_FLYWAY_USER }}
EMS_CI_FLYWAY_PASSWORD: ${{ secrets.EMS_CI_FLYWAY_PASSWORD }}
run: ./scripts/ci_flyway_validate_remote.sh
deploy:
needs: migration-check
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: self-hosted
steps:
- name: Show execution context
@@ -27,9 +78,8 @@ jobs:
ls -ld /opt/ems-deploy
- name: Run deploy script
run: |
bash /opt/ems-deploy/deploy.sh
run: bash /opt/ems-deploy/deploy.sh
# Alternativa: runner v Dockeru bez přístupu k hostu — odkomentovat a upravit SERVER + secrets.
# deploy-ssh:
# runs-on: ubuntu-latest

View File

@@ -1,46 +0,0 @@
name: test
on:
push:
branches:
- main
- feature/**
pull_request:
jobs:
smoke-test:
# Stejný label jako deploy.yml — výchozí act_runner má typicky jen `self-hosted`.
runs-on: self-hosted
# Výchozí job image často nemá Node → `actions/checkout@v4` padá na „Cannot find: node“.
# alpine/git je malý a stačí na shallow clone přes token (Gitea = GitHub-kompatibilní kontext).
container:
image: alpine/git:latest
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
run: |
set -eu
su="${{ github.server_url }}"
case "$su" in
https://*) clone_url="https://oauth2:${TOKEN}@${su#https://}" ;;
http://*) clone_url="http://oauth2:${TOKEN}@${su#http://}" ;;
*) echo "unknown github.server_url: $su"; exit 1 ;;
esac
clone_url="${clone_url}/${{ github.repository }}.git"
git init
git remote add origin "$clone_url"
git fetch --depth=1 origin "${{ github.sha }}"
git checkout -qf FETCH_HEAD
- name: Repo layout
run: |
test -f docker-compose.yml
test -f deploy/docker-compose.yml
test -x deploy/deploy.sh
- name: Runner info
run: |
uname -a
pwd
ls -la

View File

@@ -1,8 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/debug-forecast.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/naplneni-base-line-ba81.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/porovnani-view-status.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/migration/V009__postgrest_roles.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/views/R__z_postgrest_ems_anon_grants.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/migration/V065__forecast_pv_interval_interval_start_index.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/migration/V066__latest_telemetry_distinct_on_indexes.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/migration/V070__forecast_accuracy_delta_profile_index.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/migration/V071__forecast_pv_interval_pv_array_interval.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/routines/R__023_fn_forecast_pv_split.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/routines/R__066_fn_site_notifications_context.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/routines/R__068_fn_economics_daily_month.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/routines/R__078_fn_pv_forecast_delta_profile.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/views/R__058_vw_latest_telemetry.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/db/views/R__072_z_postgrest_ems_anon_grants.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
<file url="file://$PROJECT_DIR$/scripts/analysis/ote_arbitrage_proxy.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
</component>
</project>

2
.idea/sqldialects.xml generated
View File

@@ -2,7 +2,5 @@
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/porovnani-view-status.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/db/migration/V009__postgrest_roles.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/db/views/R__z_postgrest_ems_anon_grants.sql" dialect="PostgreSQL" />
</component>
</project>

View File

@@ -21,6 +21,17 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
| Pole / zařízení | Modbus TCP (`pymodbus`), HTTP (Loxone, případně API vozidel) |
| Solver | PuLP + HiGHS (`HiGHS_CMD`) |
| Runtime | Docker Compose |
| **Živá DB přes MCP (Cursor)** | Server ID **`user-postgres-ems`**, nástroj **`query`**, `{ "sql": "…" }` — viz **`docs/07-mcp-postgres-ems.md`** a pravidlo **`.cursor/rules/mcp-postgres-ems.mdc`** |
---
## 2b. MCP — živá EMS databáze (read-only)
Když uživatel napíše **„použij MCP“** nebo potřebuje **aktuální řádky z Postgresu** (plán, telemetrie, journal):
1. Zavolej MCP nástroj **`query`** na serveru **`user-postgres-ems`** s argumentem `{"sql": "<SELECT …>"}`.
2. **Neodmlouvej** bez pokusu (typ „nepřipojím se“, „MCP neexistuje“). Po chybě popiš **skutečnou** chybu a co zkontrolovat.
3. Kanonický popis, příklady a bezpečnost: **`docs/07-mcp-postgres-ems.md`**.
---
@@ -33,6 +44,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
| `docs/04-modules/` | Modulové specifikace (ceny, forecast, spotřeba, TČ, telemetrie, řízení, plánování, režimy, EV) |
| `docs/loxone-integration.md` | Loxone watchdog, heartbeat, role exekutora |
| `docs/06-open-questions.md` | Nedokončené rozhodnutí doplňovat místo hádání |
| `docs/07-mcp-postgres-ems.md` | MCP read-only SQL na EMS DB (server `user-postgres-ems`, nástroj `query`) |
| `db/migration/` | Flyway versioned migrace `V00x__*.sql` (schéma, seed, alter) |
| `db/routines/` | Repeatable SQL: funkce `ems.fn_*` |
| `db/views/` | Repeatable SQL: view `ems.vw_*` |
@@ -52,7 +64,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
5. **FVE pole B (`controllable = false`, typicky ongrid GEN) žádný curtailment.** Curtailment jen pole A (Deye). Solver smí omezovat jen `pv_a`; pole B může mít zelený bonus na `asset_pv_array` (`green_bonus_*`), audit `pv_b_production_wh` / `green_bonus_czk`.
6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint).
6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint kde zapnuté): buď **`deye_gen_microinverter_cutoff_enabled`** na `deye-main`, nebo **`ems.site_grid_connection.block_export_on_negative_sell`** (default false). **home-01** kvůli neriťitelnému PV B často **bez** druhého přepínače — přebytek pole B nesmí dělat PL infeasible; **KV1** (bez pole B / fixní nákup) migrace **V074** nastavuje `block_export_on_negative_sell = true`.
7. **Záporná nákupní cena → omezit import** na realistický horní strop (viz `solve_dispatch` v `planning_engine.py` nesmí „nekonečný“ import).
@@ -64,26 +76,39 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
11. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`.
### SQL-first a read-model (Python jen tenká orchestrace)
Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a stabilní čtecí rozhraní patří do **PostgreSQL** (`ems.fn_*`, případně **`ems.vw_*`**). Python (FastAPI, joby) volá DB; neskladá vlastní dotazy nad schématem mimo výjimky níže.
**Formát SQL v repu (`db/migration`, `db/routines`, `db/views`):** odsazení **2 mezery** na úroveň vnoření; **rezervovaná klíčová slova PostgreSQL vždy malými písmeny** (`create table`, `select`, `where`, `references`, …). Identifikátory (`ems.*`, sloupce) **`snake_case`**; typy v deklaracích též malými (`int`, `text`, `timestamptz`, `jsonb`). Nový / upravený SQL v tomto stylu — nesmí se objevovat verzované migrace psané „ALL CAPS keywords“.
- **Preferuj:** novou nebo rozšířenou **`ems.fn_*(…)`** s jasnými parametry; potřebuješ často stejné sloupce z více tabulek → **`ems.vw_*`** (view zapouzdřuje joiny a strukturu DB; z Pythonu je `SELECT … FROM ems.vw_*` v pořádku).
- **Nechtěné:** skládání dotazů v Pythonu (**vlastní JOIN / WITH / poddotazy** nad `ems.*` tabulkami). Místo toho funkce nebo view v `db/routines/` / `db/views/` + jedno volání z aplikace.
- **Jediné SQL v `backend/services/*.py` a `backend/app/routers/*.py`:** `SELECT 1` / `EXISTS`; **`select ems.fn_*(…)`**; **`SELECT … FROM ems.vw_*`** (read přes view); žádné jiné ad-hoc **`SELECT`/`INSERT`/`UPDATE`**. IO (Modbus, HTTP); **PuLP**; orchestrace scheduleru.
- **Health a Loxone po změně režimu:** `fn_health_summary`, `fn_health_detailed_db`, `fn_vw_site_directory_active`, `fn_site_economics_yesterday_notification`, `fn_site_mode_loxone_bundle` v repeatable `db/routines/R__073_fn_health_site_jobs_mode_bundle.sql`; FastAPI je v [`app/main.py`](backend/app/main.py) + joby v [`app/lifespan.py`](backend/app/lifespan.py).
### Provozní režimy (operating_mode)
- Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**.
- Režim se načítá v `planning_engine._load_site_context()`; **dodatečné LP constraints** podle režimu jsou v **`solve_dispatch()`** (žádný export / limit importu / zákaz nabíjení nebo vybíjení baterie podle módu).
- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `control_exporter.get_deye_mode` a zapisují v `write_inverter_setpoints`.
- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `exporter_monolith.get_deye_mode` a zapisují v `write_inverter_setpoints`.
- **`lock_battery=True`** u `ControlSetpoints` (PRESERVE): registry **108/109 = 0** Deye baterii nepoužívá. Výjimka oproti obecnému pravidlu max A ve PASSIVE/SELL.
12. **`forecast_pv_run` a `forecast_pv_interval` se NESMÍ mazat** historické běhy zůstávají v DB pro tracking přesnosti (`forecast_accuracy`, `fn_fill_forecast_accuracy`).
13. **Endpoint `GET …/forecast/pv`** vrací `DISTINCT ON (interval_start, pv_array_id)` seřazené podle nejnovějšího `forecast_pv_run.created_at`, aby UI nemělo duplikáty slotů; plná historie běhů zůstává v tabulkách.
13a. **PV delta kalibrace:** `GET …/forecast/pv-delta-profile` vrací JSON z `fn_pv_forecast_delta_profile`; `GET …/configuration` obsahuje `pv_forecast_calibration` z `ems.site_pv_forecast_calibration`; `PATCH …/configuration/pv-forecast-calibration` mění cutoff / policy / přepsání parametrů delty. **Referenční dny** špičkové produkce zpětně: tabulka **`ems.site_pv_forecast_reference_day`** (V076) + volitelně sloupec **`reference_day_weight_mult`** v kalibraci — v `fn_pv_forecast_delta_profile` zvednou váhu řádků `forecast_accuracy` těchto kalendářních dní (datum ve `site.timezone` jako u slotů); doplňovat lze **`ems.fn_pv_forecast_sync_reference_days`**. Provozní mazání uložené predikce za den (hranice **Europe/Prague**, ne TZ site): **`ems.fn_delete_forecast_pv_prague_calendar_day`**. Telemetrie `telemetry_inverter.is_export_limited` / `pv_derating_flags` (V058) řídí vyloučení slotu z učení v `fn_fill_forecast_accuracy` (`telemetry_derating`); `telemetry_collector` je plní čtením Deye reg **145** a **179** při poll střídače.
14. **Příchod a odjezd EV** detekuje `telemetry_collector` z telemetrie nabíječky: přechod `available``preparing` / `charging` (resp. jakýkoli stav ≠ `available`) znamená příjezd; přechod na `available` uzavře `ev_session`. Tabulka `ev_arrival_stats` se při příjezdu doplňuje přes `fn_update_ev_arrival_stats` a **nemá se mazat** (dlouhodobá historická statistika).
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`. **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`.
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. **Rozšířený horizont plánování (96h):** denní plán pokrývá **96h** od začátku aktuálního 15min slotu. Sloty v prvních **36h** používají přesné efektivní ceny z `vw_site_effective_price` (OTE). Sloty **3696h** doplňuje **predikovaná cena** z `market_price_stats` přes `fn_get_predicted_price` (prodejní strana hrubý faktor 0,85 vs. nákupní predikce). V účelové funkci LP se uplatní **váhy nejistoty** podle vzdálenosti od začátku okna: **1,0** (036h), **0,7** (3672h), **0,4** (7296h). Statistiky cen plní `fn_update_market_price_stats` (job 14:45), TUV delta `fn_update_tuv_usage_stats` (job 00:45). Detail: `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). `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`.
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žimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True`**0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **36** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **36** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 6264:** před zařazením do fronty **čtení** 6264; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního syncu; `deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` po **úspěšném zápisu** 6264 a znovu po **úspěšné toleranční verifikaci**; při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verify **vždy** čte 6264 najednou — **reg 64 nesmí** do striktní větve; toleranční odchylka až **120 s**; po 3 neúspěších u hodin **bez** SELF_SUSTAIN (jen Discord). **SELL:** `grid_setpoint_w` < 200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `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):** **108/109** dle `deye_battery_charge_discharge_amps` a `_deye_zero_export_amps_for_passive` (jen asymetrie **import bez vybíjení****109 = 0**; export **108** nenuluje); **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`**.
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).
@@ -96,7 +121,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
| `site` | Lokalita (časová zóna, GPS, aktivita). |
| `site_endpoint` | Endpointy: Modbus, Loxone HTTP, atd. |
| `site_market_config` | Marže, režimy cenění; časová platnost (zelený bonus není zde viz `asset_pv_array`). |
| `site_grid_connection` | Limity import/export, no_export, rezervovaný výkon. |
| `site_grid_connection` | Limity import/export, **block_export_on_negative_sell** (LP při záporném sell), no_export, rezervovaný výkon. |
| `site_override` | Manuální přepisy nad plánem (JSON + platnost). |
| `site_operating_mode` | Aktuální provozní režim na site (1 řádek/site). |
| `site_operating_mode_log` | Historie přepnutí režimů. |
@@ -109,12 +134,13 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
| `asset_heat_pump` | TČ (výkon, COP ref, limity běhu, TUV parametry). |
| `asset_vehicle` | Vozidlo (kapacita, max AC výkon, default target SoC/deadline). |
| `market_interval_price` | Raw spot OTE (15min), bez marží. |
| `telemetry_inverter` | 1min telemetrie střídače (Timescale). |
| `telemetry_inverter` | 1min telemetrie střídače (Timescale); volitelně `is_export_limited`, `pv_derating_flags` pro vyloučení slotu z učení delty. |
| `telemetry_ev_charger` | 1min telemetrie nabíječky (Timescale). |
| `telemetry_heat_pump` | 1min telemetrie TČ (Timescale). |
| `forecast_pv_run` | Metadata běhu predikce FVE. |
| `forecast_pv_interval` | Predikovaný výkon FVE po 15min (Timescale). |
| `forecast_accuracy` | Řádky přesnosti predikce vs telemetrie po 15min (per run); doplňuje `fn_fill_forecast_accuracy`. |
| `site_pv_forecast_calibration` | Per site: cutoff učení delty, policy škrcení, přepsání parametrů `fn_pv_forecast_delta_profile`. |
| `forecast_weather_interval` | Počasí 15min pro site (Timescale). |
| `forecast_correction_log` | Log korekcí forecastu vs skutečnost při rolling replanu. |
| `planning_run` | Jeden běh plánovače (daily/rolling/manual, stav, parametry solveru). |
@@ -127,9 +153,13 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
| `ev_session` | Nabíjecí session na WB (deadline, energie, náklady). |
| `ev_arrival_stats` | Agregované počty příjezdů EV podle dne v týdnu a hodiny (Europe/Prague); plní se z detekce příjezdu v telemetrii. |
| `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). |
| `signal_def` | Katalog odchozích signálů (kód, typ hodnoty); seed `EXPORT_BAN_ACTIVE`. |
| `signal_route` | Mapování signál → cíl (`loxone_vi`, `http_rest`) per site + `endpoint_id` + volitelný `route_config_json` / `verify_config_json`. |
| `signal_outbound_journal` | Journal HTTP odeslání signálů (`queued``sent``verified` / retry / `abandoned`). |
| `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_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__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_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_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`.
**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`.
---
@@ -142,10 +172,11 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
| `telemetry_collector` | každých **60 s** | Smyčka polling Modbus (Deye, EV×2, TČ) viz `docs/04-modules/telemetry.md` |
| `price_importer` (scheduler) | **13:30 / 14:00 / 00:05** | Jeden globální zápis do `market_interval_price` za tick (ne cyklus per site); po importu obnova predikce záporných cen pro každou aktivní site. Viz `docs/04-modules/market-prices.md` |
| `forecast_service` | **14:30** + **06:00** denně | `docs/04-modules/forecast.md` |
| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` (horizont **96 h**, váhy slotů 1,0 / 0,7 / 0,4) |
| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` + `ems.fn_planning_horizon_end` (dynamický OTE horizont, terminal SoC) |
| `run_rolling_replan` | **každých 15 min** (`*/15`) | `planning_engine.py` přepočet od aktuálního slotu |
| `control_exporter` | **každých 15 min** (slot boundary) | `docs/04-modules/control.md` |
| `verify_modbus` | **každé 2 min** | Ověření `modbus_command` ve stavu `written` (posledních 10 min); viz `docs/04-modules/modbus-command-journal.md` |
| `signal_outbound_send` / `signal_outbound_verify` | **každých 15 s** | `services/signal_service.py` — odeslání fronty `signal_outbound_journal` a readback verify (Loxone / HTTP REST). |
| `audit_filler` / `fn_fill_audit_interval` | **každých 15 min** | `docs/02-architecture.md`, DB `fn_fill_audit_interval` |
| `forecast_accuracy` / `fn_fill_forecast_accuracy` | **každých 15 min** (min. 2,17,32,47) | Po audit filleru; doplní actual z telemetrie do `forecast_accuracy` |
| `fn_update_baseline_stats` | **00:30** denně | Aktualizace `consumption_baseline_stats` z telemetrie (30d lookback) |
@@ -160,34 +191,37 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|-------|-----|
| Pochopit systém end-to-end | `docs/01-overview.md`, `docs/02-architecture.md` |
| Tabulky, vazby, jednotky | `docs/03-data-model.md` |
| OTE ceny, marže, efektivní cena | `docs/04-modules/market-prices.md`, `db/views/R__vw_site_effective_price.sql`, `backend/services/price_importer.py` |
| OTE ceny, marže, efektivní cena | `docs/04-modules/market-prices.md`, `db/views/R__061_vw_site_effective_price.sql`, `backend/services/price_importer.py` |
| Multi-site UI (combobox), seznam aktivních lokalit | `GET /api/v1/me/sites` v `backend/app/main.py`, `frontend/src/context/SiteSelectionContext.tsx`, `useSiteStatus` (filtr `vw_site_status`) |
| FVE forecast, počasí | `docs/04-modules/forecast.md` |
| Bazální spotřeba | `docs/04-modules/consumption.md` |
| TČ, COP, TUV | `docs/04-modules/heat-pump.md`, `db/routines/R__fn_cop_estimate.sql` |
| TČ, COP, TUV | `docs/04-modules/heat-pump.md`, `db/routines/R__005_fn_cop_estimate.sql` |
| Modbus, telemetrie, agregace | `docs/04-modules/telemetry.md` |
| Dashboard přehled 15min grafy slotů, SoC vs. živá telemetrie | `docs/04-modules/telemetry.md` (CA `telemetry_inverter_15m`, view `vw_telemetry_15m_7d`), `frontend/src/hooks/useDashboardData.ts`, `frontend/src/components/charts/SocTuvChart.tsx` |
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` |
| Deye registry (FC 0x10, 108/109/141/142/178/143) | `docs/04-modules/modbus-registers.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, horizont 96h | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `R__fn_set_mode.sql` |
| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
| 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` |
| Rolling plán, forecast log | `db/migration/V007__rolling_replanning.sql` |
| Audit 15min | `db/routines/R__fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` |
| Audit 15min | `db/routines/R__019_fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` |
| Nové sloupce / tabulky | nový `db/migration/V00x__*.sql` + případně `db/routines` / `db/views` |
| JSONB read-model (`fn_*`, `fetch_json`) | `docs/02-architecture.md` sekce Read-model JSONB, `app/db_json.py` |
| Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` |
| 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** | Cursor MCP server **`postgres-ems`**, nástroj **`query`**. |
| **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`**. |
---
## Konvence (krátce)
- Python: `snake_case`, type hints, Pydantic pro API modely.
- SQL: `snake_case`, explicitní FK; Flyway pořadí `V###__` / repeatable `R__`.
- 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).
- 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.
- 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

@@ -1,7 +1,8 @@
"""asyncpg Record → JSON-serializovatelný dict."""
"""asyncpg Record → JSON-serializovatelný dict + helper pro jsonb z fn_*."""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any
@@ -33,3 +34,17 @@ def record_to_dict(r: asyncpg.Record) -> dict[str, Any]:
else:
out[k] = str(v)
return out
async def fetch_json(conn: asyncpg.Connection, query: str, *args: Any) -> Any:
"""fetchval pro dotazy vracející jsonb (např. select ems.fn_*(...))."""
v = await conn.fetchval(query, *args)
if v is None:
return None
if isinstance(v, (dict, list)):
return v
if isinstance(v, (bytes, memoryview)):
return json.loads(bytes(v).decode("utf-8"))
if isinstance(v, str):
return json.loads(v)
return v

543
backend/app/lifespan.py Normal file
View File

@@ -0,0 +1,543 @@
"""FastAPI lifespan: DB pool, APScheduler joby, telemetrie."""
from __future__ import annotations
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from datetime import date, datetime, timedelta, timezone
from typing import Any
import asyncpg
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI
from zoneinfo import ZoneInfo
from app.db_json import fetch_json
from app.deps import set_pg_pool
from app.refresh_negative_prices import refresh_negative_price_predictions
from app.ws_log_handler import WSLogHandler
from services.audit_filler import fill_audit_for_completed_intervals
from services.plan_actual_slot_guard import run_plan_actual_slot_guard_for_all_active_sites
from services.control_exporter import export_setpoints, verify_modbus_commands
from services.forecast_service import fetch_pv_forecast
from services.heartbeat_service import send_heartbeat
from services.notification_service import notify_operating_mode_changed
from services.price_importer import import_ote_prices, ote_prague_day_slots_look_complete
from services.telemetry_collector import run_telemetry_loop_wrapper
from services.signal_service import (
run_signal_outbound_send_for_active_sites,
run_signal_outbound_verify_for_active_sites,
)
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler(timezone=ZoneInfo("Europe/Prague"))
def _dsn() -> str:
host = os.getenv("DB_HOST", "localhost")
port = os.getenv("DB_PORT", "5432")
name = os.getenv("DB_NAME", "ems")
user = os.getenv("DB_USER", "ems_user")
password = os.getenv("DB_PASSWORD", "")
return f"postgresql://{user}:{password}@{host}:{port}/{name}"
async def _active_site_rows(conn: asyncpg.Connection) -> list[dict[str, Any]]:
raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()")
if not isinstance(raw, list):
return []
return [x for x in raw if isinstance(x, dict)]
@asynccontextmanager
async def lifespan(app: FastAPI):
pg_pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5)
set_pg_pool(pg_pool)
app.state.pg_pool = pg_pool
# Fail fast if Flyway routines are missing (otherwise heartbeat silently goes stale in FE).
async with pg_pool.acquire() as conn:
fn_ok = await conn.fetchval(
"""
select exists(
select 1
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'ems'
and p.proname = 'fn_update_heartbeat'
)
"""
)
if not fn_ok:
raise RuntimeError("Missing DB routine: ems.fn_update_heartbeat")
app.state.ws_log_handler = WSLogHandler()
app.state.ws_log_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(app.state.ws_log_handler)
from services.planning_engine import run_daily_plan, run_rolling_replan
async def scheduled_heartbeat() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
await send_heartbeat(int(site["id"]), conn)
except Exception:
logger.exception("scheduled_heartbeat site=%s failed", site["id"])
async def scheduled_audit_filler() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
await fill_audit_for_completed_intervals(int(site["id"]), conn)
except Exception:
logger.exception("scheduled_audit_filler site=%s failed", site["id"])
async def scheduled_plan_actual_slot_guard() -> None:
"""Po audit filleru: fatální odchylka plán vs. skutečnost (síť) → Discord (dedup v DB)."""
try:
await run_plan_actual_slot_guard_for_all_active_sites(app.state.pg_pool)
except Exception:
logger.exception("scheduled_plan_actual_slot_guard failed")
async def scheduled_forecast_accuracy() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
n = await conn.fetchval(
"SELECT ems.fn_fill_forecast_accuracy($1, 48)",
site["id"],
)
if n:
logger.info(
"forecast_accuracy filled %s slots for site %s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_forecast_accuracy site=%s failed", site["id"]
)
async def scheduled_expire_modes() -> None:
async with app.state.pg_pool.acquire() as conn:
try:
rows = await conn.fetch("SELECT * FROM ems.fn_expire_modes()")
for r in rows:
await notify_operating_mode_changed(
conn,
int(r["site_id"]) if r.get("site_id") is not None else None,
str(r["site_code"]),
str(r["old_mode"]),
str(r["new_mode"]),
"system:expiry",
"Automatické vypršení dočasného režimu",
)
except Exception:
logger.exception("scheduled_expire_modes failed")
async def scheduled_control_export() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
await export_setpoints(int(site["id"]), conn)
except Exception as e:
logger.exception(
"scheduled_control_export site=%s: %s", site["id"], e
)
async def scheduled_signal_outbound_send() -> None:
try:
await run_signal_outbound_send_for_active_sites(app.state.pg_pool)
except Exception:
logger.exception("scheduled_signal_outbound_send failed")
async def scheduled_signal_outbound_verify() -> None:
try:
await run_signal_outbound_verify_for_active_sites(app.state.pg_pool)
except Exception:
logger.exception("scheduled_signal_outbound_verify failed")
async def scheduled_verify_modbus() -> None:
"""
Ověří příkazy ve stavu written z posledních 20 minut.
Běží každé 2 minuty, nezávisle na control_exporter (delší okno kvůli zpoždění jobu).
"""
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
try:
id_json = await fetch_json(
conn,
"select ems.fn_modbus_written_command_ids($1::int, interval '20 minutes')",
site_id,
)
if not isinstance(id_json, list):
id_json = []
ids = [int(x) for x in id_json]
if ids:
await verify_modbus_commands(ids, conn, site_id)
except Exception:
logger.exception("scheduled_verify_modbus site=%s failed", site_id)
async def scheduled_daily_plan() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
try:
await run_daily_plan(site_id, conn)
await export_setpoints(site_id, conn)
except Exception:
logger.exception("scheduled_daily_plan site=%s failed", site_id)
async def scheduled_rolling_replan() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
try:
await run_rolling_replan(site_id, conn)
await export_setpoints(site_id, conn)
except Exception:
logger.exception("scheduled_rolling_replan site=%s failed", site_id)
async def scheduled_baseline_update() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
n = await conn.fetchval(
"SELECT ems.fn_update_baseline_stats($1, 30)",
site["id"],
)
logger.info(
"baseline_stats updated %s rows for site %s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_baseline_update site=%s failed", site["id"]
)
async def scheduled_market_price_stats() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
n = await conn.fetchval(
"SELECT ems.fn_update_market_price_stats($1, 90)",
site["id"],
)
logger.info(
"market_price_stats updated %s rows site=%s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_market_price_stats site=%s failed", site["id"]
)
async def scheduled_tuv_usage_stats() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
n = await conn.fetchval(
"SELECT ems.fn_update_tuv_usage_stats($1, 30)",
site["id"],
)
logger.info(
"tuv_usage_stats updated %s rows site=%s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_tuv_usage_stats site=%s failed", site["id"]
)
async def scheduled_forecast_refresh() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
try:
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
if intervals >= 0:
logger.info(
"scheduled_forecast_refresh site=%s intervals=%s arrays=%s",
site_id,
intervals,
pv_arrays,
)
await refresh_negative_price_predictions(conn, site_id)
else:
logger.warning(
"scheduled_forecast_refresh site=%s failed",
site_id,
)
except Exception:
logger.exception("scheduled_forecast_refresh site=%s failed", site_id)
async def _count_ote_slots_for_day(
conn: asyncpg.Connection, target_day: date
) -> int:
return int(
await conn.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND interval_start::date = $1::date
""",
target_day,
)
or 0
)
async def _refresh_negative_price_predictions_all_active(
conn: asyncpg.Connection,
) -> None:
for site in await _active_site_rows(conn):
await refresh_negative_price_predictions(conn, int(site["id"]))
async def _scheduled_ote_import_global(conn: asyncpg.Connection) -> None:
"""Jeden OTE fetch na chybějící den; market_interval_price je globální pro všechny site."""
prague_tz = ZoneInfo("Europe/Prague")
now_loc = datetime.now(prague_tz)
today = now_loc.date()
tomorrow = today + timedelta(days=1)
any_import_ok = False
for day in (today, tomorrow):
slots = await _count_ote_slots_for_day(conn, day)
if ote_prague_day_slots_look_complete(slots):
continue
n, imported_day, _, err = await import_ote_prices(
conn, site_id=None, target_date=day
)
if n < 0:
logger.warning(
"scheduled_ote_import_global day=%s failed (%s)",
day.isoformat(),
err,
)
continue
logger.info(
"scheduled_ote_import_global day=%s imported=%s slots",
imported_day,
n,
)
any_import_ok = True
if any_import_ok:
await _refresh_negative_price_predictions_all_active(conn)
async def scheduled_ote_import() -> None:
async with app.state.pg_pool.acquire() as conn:
try:
await _scheduled_ote_import_global(conn)
except Exception:
logger.exception("scheduled_ote_import_global failed")
scheduler.add_job(scheduled_heartbeat, "interval", seconds=60, id="heartbeat")
scheduler.add_job(
scheduled_audit_filler,
"cron",
minute="1,16,31,46",
second=0,
id="audit_filler",
)
scheduler.add_job(
scheduled_plan_actual_slot_guard,
"cron",
minute="5,20,35,50",
second=0,
id="plan_actual_slot_guard",
replace_existing=True,
)
scheduler.add_job(
scheduled_forecast_accuracy,
"cron",
minute="2,17,32,47",
id="forecast_accuracy",
replace_existing=True,
)
scheduler.add_job(scheduled_expire_modes, "interval", minutes=1, id="expire_modes")
scheduler.add_job(
scheduled_control_export,
"cron",
minute="14,29,44,59",
second=0,
id="control_export",
)
scheduler.add_job(
scheduled_verify_modbus,
"interval",
minutes=2,
id="verify_modbus",
replace_existing=True,
)
scheduler.add_job(
scheduled_signal_outbound_send,
"interval",
seconds=15,
id="signal_outbound_send",
replace_existing=True,
)
scheduler.add_job(
scheduled_signal_outbound_verify,
"interval",
seconds=15,
id="signal_outbound_verify",
replace_existing=True,
)
scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan")
scheduler.add_job(
scheduled_rolling_replan,
"cron",
minute="*/15",
id="rolling_replan",
)
scheduler.add_job(
scheduled_baseline_update,
"cron",
hour=0,
minute=30,
id="baseline_update",
replace_existing=True,
)
scheduler.add_job(
scheduled_market_price_stats,
"cron",
hour=14,
minute=45,
id="market_price_stats",
replace_existing=True,
)
scheduler.add_job(
scheduled_tuv_usage_stats,
"cron",
hour=0,
minute=45,
id="tuv_usage_stats",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour=13,
minute=25,
id="ote_import_preopen",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour="13,14",
minute=12,
id="ote_import_retry_early",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour="13,14",
minute=45,
id="ote_import_retry_late",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour=14,
minute=0,
id="ote_import_main",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour=0,
minute=5,
id="ote_import_backfill",
replace_existing=True,
)
scheduler.add_job(
scheduled_forecast_refresh,
"cron",
hour="*/2",
minute=5,
id="forecast_refresh_2h",
replace_existing=True,
)
async def scheduled_daily_economics_notification() -> None:
from services.notification_service import notify_daily_economics
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
site_id = int(site["id"])
site_code = str(site["code"])
try:
row = await fetch_json(
conn,
"select ems.fn_site_economics_yesterday_notification($1::int)",
site_id,
)
if row is None or not isinstance(row, dict) or not row:
continue
yesterday = (
datetime.now(ZoneInfo("Europe/Prague")) - timedelta(days=1)
).strftime("%Y-%m-%d")
await notify_daily_economics(
conn,
site_id,
site_code=site_code,
day=yesterday,
import_kwh=float(row.get("import_kwh") or 0),
import_cost=float(row.get("import_cost_czk") or 0),
export_kwh=float(row.get("export_kwh") or 0),
export_revenue=float(row.get("export_revenue_czk") or 0),
green_bonus=float(row.get("green_bonus_czk") or 0),
total_balance=float(row.get("total_balance_czk") or 0),
planned_balance=float(row["planned_balance_czk"])
if row.get("planned_balance_czk") is not None
else None,
)
except Exception:
logger.exception(
"scheduled_daily_economics_notification site=%s failed",
site_id,
)
scheduler.add_job(
scheduled_daily_economics_notification,
"cron",
hour=7,
minute=0,
id="daily_economics_notification",
replace_existing=True,
)
scheduler.start()
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
app.state.telemetry_task = telemetry_task
yield
ws_h = getattr(app.state, "ws_log_handler", None)
if ws_h is not None:
logging.getLogger().removeHandler(ws_h)
app.state.ws_log_handler = None
telemetry_task.cancel()
try:
await telemetry_task
except asyncio.CancelledError:
pass
scheduler.shutdown(wait=False)
set_pg_pool(None)
app.state.pg_pool = None
await pg_pool.close()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
"""Sdílený hook po importu cen / forecastu obnova cache predikce záporných cen."""
from __future__ import annotations
import logging
import asyncpg
logger = logging.getLogger(__name__)
async def refresh_negative_price_predictions(conn: asyncpg.Connection, site_id: int) -> None:
try:
await conn.fetch(
"SELECT * FROM ems.fn_predict_negative_price_windows($1, 7)", site_id
)
except Exception:
logger.warning(
"fn_predict_negative_price_windows failed for site %s",
site_id,
exc_info=True,
)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
from datetime import date, datetime
from typing import Annotated, Any
@@ -10,6 +11,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.db_json import fetch_json
from app.deps import get_pg_pool
router = APIRouter(
@@ -27,11 +29,13 @@ class DailyEconomics(BaseModel):
export_kwh: float
pv_kwh: float
load_kwh: float
self_consumption_kwh: float
pv_self_consumption_kwh: float
ev_kwh: float
hp_kwh: float
import_cost_czk: float
export_revenue_czk: float
grid_import_cashflow_czk: float
grid_export_revenue_czk: float
net_cost_czk: float
green_bonus_czk: float
total_balance_czk: float
@@ -50,6 +54,8 @@ class IntervalEconomics(BaseModel):
import_kwh: float
export_kwh: float
dynamic_cost_czk: float | None
grid_import_cashflow_czk: float | None
grid_export_revenue_czk: float | None
stored_cost_czk: float | None
green_bonus_czk: float | None
planned_cost_czk: float | None
@@ -68,7 +74,12 @@ class IntervalEconomics(BaseModel):
class ChartDayPoint(BaseModel):
day: date
daily_balance_czk: float
daily_grid_balance_czk: float
daily_green_bonus_czk: float
daily_import_cost_czk: float
daily_export_revenue_czk: float
cumulative_balance_czk: float
cumulative_grid_balance_czk: float
class LockResponse(BaseModel):
@@ -82,6 +93,12 @@ def _num(val: Any) -> float:
return float(val)
def _opt(val: Any) -> float | None:
if val is None:
return None
return float(val)
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
@@ -90,19 +107,14 @@ async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
raise HTTPException(status_code=404, detail="Site not found")
async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool:
return bool(
await conn.fetchval(
"""
SELECT EXISTS(
SELECT 1 FROM ems.asset_pv_array
WHERE site_id = $1
AND green_bonus_czk_kwh IS NOT NULL
)
""",
site_id,
)
)
def _parse_day(val: Any) -> date:
if isinstance(val, datetime):
return val.date()
if isinstance(val, date):
return val
if isinstance(val, str):
return date.fromisoformat(val[:10])
raise ValueError(val)
@router.get("/daily", response_model=DailyEconomicsResponse)
@@ -127,84 +139,47 @@ async def get_economics_daily(
async with db.acquire() as conn:
await _check_site(conn, site_id)
has_bonus = await _has_green_bonus(conn, site_id)
dyn_rows = await conn.fetch(
"""
SELECT * FROM ems.vw_economics_daily
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
ORDER BY day_local
""",
raw = await fetch_json(
conn,
"select ems.fn_economics_daily_month($1::int, $2::date, $3::date)",
site_id,
month_start,
month_end,
)
lock_rows = await conn.fetch(
"""
SELECT * FROM ems.audit_day_lock
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
""",
site_id,
month_start,
month_end,
)
locks = {r["day_local"]: r for r in lock_rows}
if not isinstance(raw, dict):
raw = json.loads(raw)
days_in: list[Any] = list(raw.get("days") or [])
days: list[DailyEconomics] = []
for r in dyn_rows:
d = r["day_local"]
lock = locks.get(d)
if lock:
days.append(
DailyEconomics(
day=d,
interval_count=r["interval_count"],
import_kwh=_num(r["import_kwh"]),
export_kwh=_num(r["export_kwh"]),
pv_kwh=_num(r["pv_kwh"]),
load_kwh=_num(r["load_kwh"]),
self_consumption_kwh=_num(r["self_consumption_kwh"]),
ev_kwh=_num(r["ev_kwh"]),
hp_kwh=_num(r["hp_kwh"]),
import_cost_czk=_num(lock["import_cost_czk"]),
export_revenue_czk=_num(lock["export_revenue_czk"]),
net_cost_czk=_num(lock["net_cost_czk"]),
green_bonus_czk=_num(lock["green_bonus_czk"]),
total_balance_czk=_num(lock["total_balance_czk"]),
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
is_locked=True,
)
for d in days_in:
if not isinstance(d, dict):
continue
days.append(
DailyEconomics(
day=_parse_day(d.get("day")),
interval_count=int(d.get("interval_count") or 0),
import_kwh=_num(d.get("import_kwh")),
export_kwh=_num(d.get("export_kwh")),
pv_kwh=_num(d.get("pv_kwh")),
load_kwh=_num(d.get("load_kwh")),
pv_self_consumption_kwh=_num(d.get("pv_self_consumption_kwh")),
ev_kwh=_num(d.get("ev_kwh")),
hp_kwh=_num(d.get("hp_kwh")),
import_cost_czk=_num(d.get("import_cost_czk")),
export_revenue_czk=_num(d.get("export_revenue_czk")),
grid_import_cashflow_czk=_num(d.get("grid_import_cashflow_czk")),
grid_export_revenue_czk=_num(d.get("grid_export_revenue_czk")),
net_cost_czk=_num(d.get("net_cost_czk")),
green_bonus_czk=_num(d.get("green_bonus_czk")),
total_balance_czk=_num(d.get("total_balance_czk")),
planned_balance_czk=_opt(d.get("planned_balance_czk")),
deviation_cost_czk=_opt(d.get("deviation_cost_czk")),
is_locked=bool(d.get("is_locked")),
)
else:
days.append(
DailyEconomics(
day=d,
interval_count=r["interval_count"],
import_kwh=_num(r["import_kwh"]),
export_kwh=_num(r["export_kwh"]),
pv_kwh=_num(r["pv_kwh"]),
load_kwh=_num(r["load_kwh"]),
self_consumption_kwh=_num(r["self_consumption_kwh"]),
ev_kwh=_num(r["ev_kwh"]),
hp_kwh=_num(r["hp_kwh"]),
import_cost_czk=_num(r["import_cost_czk"]),
export_revenue_czk=_num(r["export_revenue_czk"]),
net_cost_czk=_num(r["net_cost_czk"]),
green_bonus_czk=_num(r["green_bonus_czk"]),
total_balance_czk=_num(r["total_balance_czk"]),
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
is_locked=False,
)
)
return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus)
)
return DailyEconomicsResponse(
days=days,
has_green_bonus=bool(raw.get("has_green_bonus")),
)
@router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics])
@@ -232,20 +207,22 @@ async def get_economics_intervals(
interval_start=r["interval_start"].isoformat(),
import_kwh=_num(r["import_kwh"]),
export_kwh=_num(r["export_kwh"]),
dynamic_cost_czk=float(r["dynamic_cost_czk"]) if r["dynamic_cost_czk"] is not None else None,
stored_cost_czk=float(r["stored_cost_czk"]) if r["stored_cost_czk"] is not None else None,
green_bonus_czk=float(r["green_bonus_czk"]) if r["green_bonus_czk"] is not None else None,
planned_cost_czk=float(r["planned_cost_czk"]) if r["planned_cost_czk"] is not None else None,
dynamic_cost_czk=_opt(r["dynamic_cost_czk"]),
grid_import_cashflow_czk=_opt(r["grid_import_cashflow_czk"]),
grid_export_revenue_czk=_opt(r["grid_export_revenue_czk"]),
stored_cost_czk=_opt(r["stored_cost_czk"]),
green_bonus_czk=_opt(r["green_bonus_czk"]),
planned_cost_czk=_opt(r["planned_cost_czk"]),
planned_grid_w=int(r["planned_grid_w"]) if r["planned_grid_w"] is not None else None,
actual_grid_power_w=int(r["actual_grid_power_w"]) if r["actual_grid_power_w"] is not None else None,
effective_buy_price=float(r["effective_buy_price_czk_kwh"]) if r["effective_buy_price_czk_kwh"] is not None else None,
effective_sell_price=float(r["effective_sell_price_czk_kwh"]) if r["effective_sell_price_czk_kwh"] is not None else None,
planned_buy_price=float(r["planned_buy_price"]) if r["planned_buy_price"] is not None else None,
planned_sell_price=float(r["planned_sell_price"]) if r["planned_sell_price"] is not None else None,
effective_buy_price=_opt(r["effective_buy_price_czk_kwh"]),
effective_sell_price=_opt(r["effective_sell_price_czk_kwh"]),
planned_buy_price=_opt(r["planned_buy_price"]),
planned_sell_price=_opt(r["planned_sell_price"]),
actual_pv_power_w=int(r["actual_pv_power_w"]) if r["actual_pv_power_w"] is not None else None,
actual_load_power_w=int(r["actual_load_power_w"]) if r["actual_load_power_w"] is not None else None,
actual_battery_power_w=int(r["actual_battery_power_w"]) if r["actual_battery_power_w"] is not None else None,
actual_battery_soc_pct=float(r["actual_battery_soc_pct"]) if r["actual_battery_soc_pct"] is not None else None,
actual_battery_soc_pct=_opt(r["actual_battery_soc_pct"]),
)
for r in rows
]
@@ -259,44 +236,18 @@ async def lock_day(
) -> LockResponse:
async with db.acquire() as conn:
await _check_site(conn, site_id)
row = await conn.fetchrow(
"""
SELECT import_cost_czk, export_revenue_czk, net_cost_czk,
green_bonus_czk, total_balance_czk
FROM ems.vw_economics_daily
WHERE site_id = $1 AND day_local = $2
""",
raw = await fetch_json(
conn,
"select ems.fn_economics_lock_day($1::int, $2::date)",
site_id,
day,
)
if row is None:
raise HTTPException(
status_code=404,
detail=f"No economics data for {day.isoformat()}",
)
await conn.execute(
"""
INSERT INTO ems.audit_day_lock
(site_id, day_local, import_cost_czk, export_revenue_czk,
net_cost_czk, green_bonus_czk, total_balance_czk)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (site_id, day_local) DO UPDATE SET
import_cost_czk = EXCLUDED.import_cost_czk,
export_revenue_czk = EXCLUDED.export_revenue_czk,
net_cost_czk = EXCLUDED.net_cost_czk,
green_bonus_czk = EXCLUDED.green_bonus_czk,
total_balance_czk = EXCLUDED.total_balance_czk,
locked_at = now()
""",
site_id,
day,
row["import_cost_czk"],
row["export_revenue_czk"],
row["net_cost_czk"],
row["green_bonus_czk"],
row["total_balance_czk"],
if not isinstance(raw, dict):
raw = json.loads(raw)
if raw.get("locked") is not True:
raise HTTPException(
status_code=404,
detail=f"No economics data for {day.isoformat()}",
)
return LockResponse(locked=True, day=day)
@@ -310,8 +261,9 @@ async def unlock_day(
) -> LockResponse:
async with db.acquire() as conn:
await _check_site(conn, site_id)
await conn.execute(
"DELETE FROM ems.audit_day_lock WHERE site_id = $1 AND day_local = $2",
await fetch_json(
conn,
"select ems.fn_economics_unlock_day($1::int, $2::date)",
site_id,
day,
)
@@ -340,47 +292,29 @@ async def get_monthly_chart(
async with db.acquire() as conn:
await _check_site(conn, site_id)
rows = await conn.fetch(
"""
SELECT day_local, total_balance_czk
FROM ems.vw_economics_daily
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
ORDER BY day_local
""",
arr = await fetch_json(
conn,
"select ems.fn_economics_monthly_chart($1::int, $2::date, $3::date)",
site_id,
month_start,
month_end,
)
lock_rows = await conn.fetch(
"""
SELECT day_local, total_balance_czk
FROM ems.audit_day_lock
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
""",
site_id,
month_start,
month_end,
)
locks = {r["day_local"]: _num(r["total_balance_czk"]) for r in lock_rows}
if not isinstance(arr, list):
arr = json.loads(arr) if isinstance(arr, str) else []
points: list[ChartDayPoint] = []
cumulative = 0.0
for r in rows:
d = r["day_local"]
balance = locks.get(d, _num(r["total_balance_czk"]))
cumulative += balance
for r in arr:
if not isinstance(r, dict):
continue
points.append(
ChartDayPoint(
day=d,
daily_balance_czk=round(balance, 2),
cumulative_balance_czk=round(cumulative, 2),
day=_parse_day(r.get("day")),
daily_balance_czk=float(r.get("daily_balance_czk") or 0),
daily_grid_balance_czk=float(r.get("daily_grid_balance_czk") or 0),
daily_green_bonus_czk=float(r.get("daily_green_bonus_czk") or 0),
daily_import_cost_czk=float(r.get("daily_import_cost_czk") or 0),
daily_export_revenue_czk=float(r.get("daily_export_revenue_czk") or 0),
cumulative_balance_czk=float(r.get("cumulative_balance_czk") or 0),
cumulative_grid_balance_czk=float(r.get("cumulative_grid_balance_czk") or 0),
)
)
return points

View File

@@ -0,0 +1,192 @@
"""REST API analýza energetických toků (modelované toky z audit_interval)."""
from __future__ import annotations
import json
from datetime import date
from typing import Annotated, Any
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.db_json import fetch_json
from app.deps import get_pg_pool
router = APIRouter(
prefix="/sites/{site_id}/energy-flows",
tags=["energy-flows"],
)
class DailyEnergyFlows(BaseModel):
day: date
interval_count: int
pv_production_kwh: float
grid_import_kwh: float
grid_export_kwh: float
batt_charge_kwh: float
batt_discharge_kwh: float
load_kwh: float
pv_to_load_kwh: float
pv_to_batt_kwh: float
pv_to_grid_kwh: float
batt_to_load_kwh: float
batt_to_grid_kwh: float
grid_to_load_kwh: float
grid_to_batt_kwh: float
grid_import_cashflow_czk: float
grid_export_revenue_czk: float
grid_to_load_cost_czk: float
grid_to_batt_cost_czk: float
class DailyEnergyFlowsResponse(BaseModel):
days: list[DailyEnergyFlows]
class IntervalEnergyFlows(BaseModel):
interval_start: str
pv_production_kwh: float | None
grid_import_kwh: float | None
grid_export_kwh: float | None
batt_charge_kwh: float | None
batt_discharge_kwh: float | None
load_kwh: float | None
pv_to_load_kwh: float | None
pv_to_batt_kwh: float | None
pv_to_grid_kwh: float | None
batt_to_load_kwh: float | None
batt_to_grid_kwh: float | None
grid_to_load_kwh: float | None
grid_to_batt_kwh: float | None
def _num(val: Any) -> float:
if val is None:
return 0.0
return float(val)
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not ok:
raise HTTPException(status_code=404, detail="Site not found")
def _parse_day(val: Any) -> date:
from datetime import datetime as _dt
if isinstance(val, _dt):
return val.date()
if isinstance(val, date):
return val
if isinstance(val, str):
return date.fromisoformat(val[:10])
raise ValueError(val)
@router.get("/daily", response_model=DailyEnergyFlowsResponse)
async def get_energy_flows_daily(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
month: str = Query(
...,
description="YYYY-MM",
pattern=r"^\d{4}-\d{2}$",
),
) -> DailyEnergyFlowsResponse:
try:
year, mon = month.split("-")
month_start = date(int(year), int(mon), 1)
if int(mon) == 12:
month_end = date(int(year) + 1, 1, 1)
else:
month_end = date(int(year), int(mon) + 1, 1)
except (ValueError, IndexError):
raise HTTPException(status_code=400, detail="Invalid month, expected YYYY-MM")
async with db.acquire() as conn:
await _check_site(conn, site_id)
raw = await fetch_json(
conn,
"select ems.fn_energy_flows_daily_month($1::int, $2::date, $3::date)",
site_id,
month_start,
month_end,
)
if not isinstance(raw, dict):
raw = json.loads(raw)
rows = raw.get("days") or []
days: list[DailyEnergyFlows] = []
for r in rows:
if not isinstance(r, dict):
continue
days.append(
DailyEnergyFlows(
day=_parse_day(r.get("day")),
interval_count=int(r.get("interval_count") or 0),
pv_production_kwh=_num(r.get("pv_production_kwh")),
grid_import_kwh=_num(r.get("grid_import_kwh")),
grid_export_kwh=_num(r.get("grid_export_kwh")),
batt_charge_kwh=_num(r.get("batt_charge_kwh")),
batt_discharge_kwh=_num(r.get("batt_discharge_kwh")),
load_kwh=_num(r.get("load_kwh")),
pv_to_load_kwh=_num(r.get("pv_to_load_kwh")),
pv_to_batt_kwh=_num(r.get("pv_to_batt_kwh")),
pv_to_grid_kwh=_num(r.get("pv_to_grid_kwh")),
batt_to_load_kwh=_num(r.get("batt_to_load_kwh")),
batt_to_grid_kwh=_num(r.get("batt_to_grid_kwh")),
grid_to_load_kwh=_num(r.get("grid_to_load_kwh")),
grid_to_batt_kwh=_num(r.get("grid_to_batt_kwh")),
grid_import_cashflow_czk=_num(r.get("grid_import_cashflow_czk")),
grid_export_revenue_czk=_num(r.get("grid_export_revenue_czk")),
grid_to_load_cost_czk=_num(r.get("grid_to_load_cost_czk")),
grid_to_batt_cost_czk=_num(r.get("grid_to_batt_cost_czk")),
)
)
return DailyEnergyFlowsResponse(days=days)
@router.get("/daily/{day}/intervals", response_model=list[IntervalEnergyFlows])
async def get_energy_flows_intervals(
site_id: int,
day: date,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[IntervalEnergyFlows]:
async with db.acquire() as conn:
await _check_site(conn, site_id)
rows = await fetch_json(
conn,
"select ems.fn_energy_flows_intervals_day($1::int, $2::date)",
site_id,
day,
)
if not isinstance(rows, list):
rows = json.loads(rows) if isinstance(rows, str) else []
out: list[IntervalEnergyFlows] = []
for r in rows:
if not isinstance(r, dict):
continue
ist = r.get("interval_start")
out.append(
IntervalEnergyFlows(
interval_start=ist if isinstance(ist, str) else str(ist),
pv_production_kwh=r.get("pv_production_kwh"),
grid_import_kwh=r.get("grid_import_kwh"),
grid_export_kwh=r.get("grid_export_kwh"),
batt_charge_kwh=r.get("batt_charge_kwh"),
batt_discharge_kwh=r.get("batt_discharge_kwh"),
load_kwh=r.get("load_kwh"),
pv_to_load_kwh=r.get("pv_to_load_kwh"),
pv_to_batt_kwh=r.get("pv_to_batt_kwh"),
pv_to_grid_kwh=r.get("pv_to_grid_kwh"),
batt_to_load_kwh=r.get("batt_to_load_kwh"),
batt_to_grid_kwh=r.get("batt_to_grid_kwh"),
grid_to_load_kwh=r.get("grid_to_load_kwh"),
grid_to_batt_kwh=r.get("grid_to_batt_kwh"),
)
)
return out

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from datetime import date, datetime
from typing import Annotated, Any
@@ -9,7 +10,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, field_validator
from app.db_json import record_to_dict
from app.db_json import fetch_json
from app.deps import get_pg_pool
router = APIRouter(prefix="/sites/{site_id}/ev", tags=["ev"])
@@ -38,30 +39,19 @@ async def get_active_ev_sessions(
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[dict[str, Any]]:
async with pool.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
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")
rows = await conn.fetch(
"""
SELECT es.id, es.charger_id, es.vehicle_id,
es.session_start, es.energy_delivered_wh,
es.target_soc_pct, es.target_deadline,
av.make, av.model, av.battery_capacity_kwh,
av.default_target_soc_pct, av.default_deadline_hour,
ac.code AS charger_code,
COALESCE(
NULLIF(TRIM(CONCAT_WS(' ', ac.manufacturer, ac.model)), ''),
ac.code
) AS charger_name
FROM ems.ev_session es
LEFT JOIN ems.asset_vehicle av ON av.id = es.vehicle_id
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
WHERE es.site_id = $1 AND es.session_end IS NULL
ORDER BY es.session_start DESC
""",
rows = await fetch_json(
conn,
"select ems.fn_ev_sessions_active($1::int)",
site_id,
)
return [record_to_dict(r) for r in rows]
if not isinstance(rows, list):
rows = json.loads(rows) if isinstance(rows, str) else []
return [r for r in rows if isinstance(r, dict)]
@router.patch("/sessions/{session_id}", response_model=EvSessionPatchResponse)
@@ -72,25 +62,25 @@ async def patch_ev_session(
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> EvSessionPatchResponse:
async with pool.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
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")
row = await conn.fetchrow(
"""
UPDATE ems.ev_session
SET target_soc_pct = $1, target_deadline = $2
WHERE id = $3 AND site_id = $4
RETURNING id
""",
body.target_soc_pct,
body.target_deadline,
session_id,
patch = body.model_dump(exclude_unset=True)
raw = await fetch_json(
conn,
"select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
site_id,
session_id,
json.dumps(patch),
)
if row is None:
raise HTTPException(status_code=404, detail="Session not found")
return EvSessionPatchResponse(success=True, session_id=int(row["id"]))
if not isinstance(raw, dict):
raw = json.loads(raw)
if not raw.get("success"):
raise HTTPException(status_code=404, detail="Session not found")
return EvSessionPatchResponse(success=True, session_id=int(raw["session_id"]))
class ArrivalHourItem(BaseModel):
@@ -114,65 +104,48 @@ async def get_ev_arrival_prediction(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> EvArrivalPredictionResponse:
"""Top hodiny příjezdu z ems.fn_ev_expected_arrival; při <5 session celkem insufficient_data."""
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")
n_sessions = int(
await conn.fetchval(
"SELECT COUNT(*)::int FROM ems.ev_session WHERE site_id = $1",
site_id,
)
or 0
)
insufficient = n_sessions < 5
tomorrow = await conn.fetchval(
"""
SELECT (
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
NULLIF(TRIM(timezone), ''),
'Europe/Prague'
)
)::date + 1
FROM ems.site
WHERE id = $1
""",
raw = await fetch_json(
conn,
"select ems.fn_ev_arrival_prediction_bundle($1::int)",
site_id,
)
if tomorrow is None:
raise HTTPException(status_code=500, detail="Site date resolution failed")
tomorrow_d: date = tomorrow
if not isinstance(raw, dict):
raw = json.loads(raw)
if raw.get("error") == "site_not_found":
raise HTTPException(status_code=404, detail="Site not found")
chargers_rows = await conn.fetch(
"SELECT id, code FROM ems.asset_ev_charger WHERE site_id = $1 ORDER BY id",
site_id,
)
chargers: dict[str, ChargerTomorrowArrival] = {}
for ch in chargers_rows:
code = str(ch["code"])
preds = await conn.fetch(
"SELECT * FROM ems.fn_ev_expected_arrival($1, $2, $3::date)",
site_id,
ch["id"],
tomorrow_d,
)
chargers[code] = ChargerTomorrowArrival(
tomorrow=[
ArrivalHourItem(
hour=int(r["expected_hour"]),
confidence_pct=int(r["confidence_pct"]),
samples=int(r["sample_count"]),
chargers: dict[str, ChargerTomorrowArrival] = {}
ch_raw = raw.get("chargers") or {}
if isinstance(ch_raw, dict):
for code, v in ch_raw.items():
if not isinstance(v, dict):
continue
tlist = v.get("tomorrow") or []
items: list[ArrivalHourItem] = []
if isinstance(tlist, list):
for it in tlist:
if not isinstance(it, dict):
continue
items.append(
ArrivalHourItem(
hour=int(it.get("hour") or 0),
confidence_pct=int(it.get("confidence_pct") or 0),
samples=int(it.get("samples") or 0),
)
)
for r in preds
]
)
chargers[str(code)] = ChargerTomorrowArrival(tomorrow=items)
td = raw.get("tomorrow_date")
if isinstance(td, date):
td_s = td.isoformat()
elif isinstance(td, datetime):
td_s = td.date().isoformat()
else:
td_s = str(td or "")
return EvArrivalPredictionResponse(
insufficient_data=insufficient,
tomorrow_date=tomorrow_d.isoformat(),
insufficient_data=bool(raw.get("insufficient_data")),
tomorrow_date=td_s,
chargers=chargers,
)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Any, Literal
from zoneinfo import ZoneInfo
@@ -10,7 +11,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.db_json import record_to_dict
from app.db_json import fetch_json
from app.deps import get_pg_pool
from app.notifications_logic import (
EvSessionRow,
@@ -47,6 +48,16 @@ def _iso_utc(dt: datetime | None) -> str | None:
return dt.astimezone(timezone.utc).isoformat()
def _parse_ts(val: Any) -> datetime | None:
if val is None:
return None
if isinstance(val, datetime):
return val
if isinstance(val, str):
return datetime.fromisoformat(val.replace("Z", "+00:00"))
return None
def _age_seconds(at: datetime | None) -> int | None:
if at is None:
return None
@@ -81,174 +92,105 @@ async def get_site_status_full(
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> dict[str, Any]:
async with pool.acquire() as conn:
site = await conn.fetchrow(
"""
SELECT id, code, name, timezone
FROM ems.site
WHERE id = $1
""",
bundle = await fetch_json(
conn,
"select ems.fn_site_full_status($1::int)",
site_id,
)
if site is None:
raise HTTPException(status_code=404, detail="Site not found")
if not isinstance(bundle, dict):
bundle = json.loads(bundle)
if bundle.get("error") == "not_found":
raise HTTPException(status_code=404, detail="Site not found")
tz = site["timezone"] or "Europe/Prague"
site = bundle.get("site") or {}
mode_row = bundle.get("operating_mode") or {}
hb_row = bundle.get("heartbeat") or {}
inv_row = bundle.get("inverter_latest")
if not isinstance(inv_row, dict):
inv_row = None
ev_rows = bundle.get("ev_chargers") or []
if not isinstance(ev_rows, list):
ev_rows = []
hp_row = bundle.get("heat_pump_latest")
if not isinstance(hp_row, dict):
hp_row = None
reserve_row = bundle.get("battery_limits") or {}
run_row = bundle.get("active_plan")
if not isinstance(run_row, dict):
run_row = None
intervals: list[dict[str, Any]] = []
raw_iv = bundle.get("planning_intervals") or []
if isinstance(raw_iv, list):
intervals = [x for x in raw_iv if isinstance(x, dict)]
mode_row = await conn.fetchrow(
"""
SELECT m.mode_code, d.name AS mode_name, m.activated_at, m.activated_by
FROM ems.site_operating_mode m
JOIN ems.operating_mode_def d ON d.code = m.mode_code
WHERE m.site_id = $1
""",
site_id,
)
hb_row = await conn.fetchrow(
"""
SELECT last_seen, status
FROM ems.site_heartbeat
WHERE site_id = $1
""",
site_id,
)
inv_row = await conn.fetchrow(
"""
SELECT pv_power_w, battery_soc_percent, grid_power_w, measured_at
FROM ems.vw_latest_inverter
WHERE site_id = $1
ORDER BY measured_at DESC NULLS LAST
LIMIT 1
""",
site_id,
)
ev_rows = await conn.fetch(
"""
SELECT DISTINCT ON (charger_id)
charger_code AS code,
status,
power_w,
measured_at
FROM ems.vw_latest_ev_charger
WHERE site_id = $1
ORDER BY charger_id, measured_at DESC NULLS LAST
""",
site_id,
)
hp_row = await conn.fetchrow(
"""
SELECT power_w, tuv_tank_temp_c, measured_at
FROM ems.vw_latest_heat_pump
WHERE site_id = $1
ORDER BY measured_at DESC NULLS LAST
LIMIT 1
""",
site_id,
)
reserve_row = await conn.fetchrow(
"""
SELECT MIN(reserve_soc_percent)::float AS reserve_soc,
MIN(min_soc_percent)::float AS min_soc
FROM ems.asset_battery
WHERE site_id = $1
""",
site_id,
)
run_row = await conn.fetchrow(
"""
SELECT id, created_at
FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
intervals: list[dict[str, Any]] = []
if run_row:
int_rows = await conn.fetch(
"""
SELECT interval_start, battery_setpoint_w,
load_baseline_w,
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
pv_a_forecast_solver_w, pv_b_forecast_solver_w
FROM ems.planning_interval
WHERE run_id = $1
ORDER BY interval_start
""",
run_row["id"],
)
intervals = [record_to_dict(r) for r in int_rows]
tomorrow_slots = await conn.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.vw_site_effective_price v
WHERE v.site_id = $1
AND (v.interval_start AT TIME ZONE $2)::date =
((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date
""",
site_id,
tz,
)
tomorrow_slots = int(tomorrow_slots or 0)
tomorrow_slots = int(bundle.get("tomorrow_price_slot_count") or 0)
now_utc = datetime.now(timezone.utc)
hb_last = hb_row["last_seen"] if hb_row else None
hb_last = _parse_ts(hb_row.get("last_seen") if hb_row else None)
hb_age = _age_seconds(hb_last)
inv_measured = inv_row["measured_at"] if inv_row else None
inv_measured = _parse_ts(inv_row.get("measured_at") if inv_row else None)
inv_age = _age_seconds(inv_measured)
next_start, next_bat = _next_plan_interval(intervals, now_utc)
ev_list: list[dict[str, Any]] = []
for r in ev_rows:
if not isinstance(r, dict):
continue
ev_list.append(
{
"code": r["code"],
"status": r["status"],
"power_w": int(r["power_w"]) if r["power_w"] is not None else None,
"code": r.get("code"),
"status": r.get("status"),
"power_w": int(r["power_w"]) if r.get("power_w") is not None else None,
}
)
telemetry: dict[str, Any] = {
"inverter": {
"pv_power_w": int(inv_row["pv_power_w"]) if inv_row and inv_row["pv_power_w"] is not None else None,
"battery_soc_pct": float(inv_row["battery_soc_percent"])
if inv_row and inv_row["battery_soc_percent"] is not None
"pv_power_w": int(inv_row["pv_power_w"])
if inv_row and inv_row.get("pv_power_w") is not None
else None,
"battery_soc_pct": float(inv_row["battery_soc_percent"])
if inv_row and inv_row.get("battery_soc_percent") is not None
else None,
"grid_power_w": int(inv_row["grid_power_w"])
if inv_row and inv_row.get("grid_power_w") is not None
else None,
"grid_power_w": int(inv_row["grid_power_w"]) if inv_row and inv_row["grid_power_w"] is not None else None,
"measured_at": _iso_utc(inv_measured),
"age_seconds": inv_age,
},
"ev_chargers": ev_list,
"heat_pump": {
"power_w": int(hp_row["power_w"]) if hp_row and hp_row["power_w"] is not None else None,
"power_w": int(hp_row["power_w"]) if hp_row and hp_row.get("power_w") is not None else None,
"tank_temp_c": float(hp_row["tuv_tank_temp_c"])
if hp_row and hp_row["tuv_tank_temp_c"] is not None
if hp_row and hp_row.get("tuv_tank_temp_c") is not None
else None,
"measured_at": _iso_utc(hp_row["measured_at"]) if hp_row else None,
"measured_at": _iso_utc(hp_row.get("measured_at")) if hp_row else None,
},
}
has_plan = run_row is not None
planning = {
"has_active_plan": has_plan,
"plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None,
"plan_created_at": _iso_utc(run_row.get("created_at")) if run_row else None,
"next_interval_start": next_start,
"next_battery_setpoint_w": next_bat,
}
mode_code = (mode_row["mode_code"] if mode_row else None) or ""
reserve_soc = float(reserve_row["reserve_soc"]) if reserve_row and reserve_row["reserve_soc"] is not None else None
min_soc = float(reserve_row["min_soc"]) if reserve_row and reserve_row["min_soc"] is not None else None
soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None
mode_code = (mode_row.get("mode_code") if mode_row else None) or ""
reserve_soc = (
float(reserve_row["reserve_soc"])
if reserve_row and reserve_row.get("reserve_soc") is not None
else None
)
min_soc = (
float(reserve_row["min_soc"]) if reserve_row and reserve_row.get("min_soc") is not None else None
)
soc = (
float(inv_row["battery_soc_percent"])
if inv_row and inv_row.get("battery_soc_percent") is not None
else None
)
alerts: list[dict[str, str]] = []
@@ -281,17 +223,17 @@ async def get_site_status_full(
alerts.sort(key=lambda a: (0 if a["level"] == "error" else 1, a["message"]))
return {
"site": {"id": site["id"], "code": site["code"], "name": site["name"]},
"site": {"id": site.get("id"), "code": site.get("code"), "name": site.get("name")},
"operating_mode": {
"mode_code": mode_row["mode_code"] if mode_row else None,
"mode_name": mode_row["mode_name"] if mode_row else None,
"activated_at": _iso_utc(mode_row["activated_at"]) if mode_row else None,
"activated_by": mode_row["activated_by"] if mode_row else None,
"mode_code": mode_row.get("mode_code") if mode_row else None,
"mode_name": mode_row.get("mode_name") if mode_row else None,
"activated_at": _iso_utc(mode_row.get("activated_at")) if mode_row else None,
"activated_by": mode_row.get("activated_by") if mode_row else None,
},
"heartbeat": {
"last_seen": _iso_utc(hb_last),
"age_seconds": hb_age,
"status": hb_row["status"] if hb_row else None,
"status": hb_row.get("status") if hb_row else None,
},
"telemetry": telemetry,
"planning": planning,
@@ -395,156 +337,39 @@ async def get_site_notifications(
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> SiteNotificationsResponse:
async with pool.acquire() as conn:
site = await conn.fetchrow(
"SELECT id, timezone FROM ems.site WHERE id = $1",
ctx = await fetch_json(
conn,
"select ems.fn_site_notifications_context($1::int)",
site_id,
)
if site is None:
raise HTTPException(status_code=404, detail="Site not found")
tz = site["timezone"] or "Europe/Prague"
if not isinstance(ctx, dict):
ctx = json.loads(ctx)
if ctx.get("error") == "not_found":
raise HTTPException(status_code=404, detail="Site not found")
mode_row = await conn.fetchrow(
"""
SELECT m.mode_code
FROM ems.site_operating_mode m
WHERE m.site_id = $1
""",
site_id,
)
run_row = await conn.fetchrow(
"""
SELECT id FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
reserve_row = await conn.fetchrow(
"""
SELECT MIN(reserve_soc_percent)::float AS reserve_soc,
MIN(min_soc_percent)::float AS min_soc
FROM ems.asset_battery
WHERE site_id = $1
""",
site_id,
)
inv_row = await conn.fetchrow(
"""
SELECT battery_soc_percent, measured_at
FROM ems.vw_latest_inverter
WHERE site_id = $1
ORDER BY measured_at DESC NULLS LAST
LIMIT 1
""",
site_id,
)
hb_row = await conn.fetchrow(
"SELECT last_seen FROM ems.site_heartbeat WHERE site_id = $1",
site_id,
)
tomorrow_slots = await conn.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.vw_site_effective_price v
WHERE v.site_id = $1
AND (v.interval_start AT TIME ZONE $2)::date =
((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date
""",
site_id,
tz,
)
has_plan = bool(ctx.get("has_plan"))
mode_code = (ctx.get("mode_code") or "") or ""
reserve_soc = _float_or_none(ctx.get("reserve_soc"))
min_soc = _float_or_none(ctx.get("min_soc"))
soc = _float_or_none(ctx.get("soc_pct"))
inv_age = _age_seconds(_parse_ts(ctx.get("inv_measured_at")))
hb_age = _age_seconds(_parse_ts(ctx.get("hb_last_seen")))
tomorrow_slots = int(ctx.get("tomorrow_slots") or 0)
price_rows = await conn.fetch(
"""
SELECT interval_start,
effective_buy_price_czk_kwh,
effective_sell_price_czk_kwh
FROM ems.vw_site_effective_price
WHERE site_id = $1
AND interval_start >= now()
AND interval_start < now() + INTERVAL '48 hours'
ORDER BY interval_start
""",
site_id,
)
price_rows = ctx.get("price_slots") or []
if not isinstance(price_rows, list):
price_rows = []
avg_row = await conn.fetchrow(
"""
SELECT AVG(effective_buy_price_czk_kwh)::float AS avg_buy
FROM ems.vw_site_effective_price
WHERE site_id = $1
AND interval_start::date IN (CURRENT_DATE, CURRENT_DATE + INTERVAL '1 day')
""",
site_id,
)
avg_buy = _float_or_none(ctx.get("avg_buy"))
usable_wh = _float_or_none(ctx.get("usable_wh"))
bat_row = await conn.fetchrow(
"""
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float AS usable_wh
FROM ems.asset_battery ab
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id
WHERE ai.site_id = $1
""",
site_id,
)
ev_rows = ctx.get("ev_sessions") or []
if not isinstance(ev_rows, list):
ev_rows = []
ev_rows = await conn.fetch(
"""
SELECT DISTINCT ON (es.id)
es.id,
es.charger_id,
es.energy_delivered_wh,
es.target_soc_pct,
es.session_start,
es.soc_at_connect_pct,
COALESCE(av_id.battery_capacity_kwh, av_def.battery_capacity_kwh) AS battery_capacity_kwh,
COALESCE(av_id.make, av_def.make) AS make,
COALESCE(av_id.model, av_def.model) AS model,
COALESCE(av_id.default_target_soc_pct, av_def.default_target_soc_pct) AS default_target_soc_pct,
ac.code AS charger_code
FROM ems.ev_session es
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
LEFT JOIN ems.asset_vehicle av_id ON av_id.id = es.vehicle_id
LEFT JOIN ems.asset_vehicle av_def
ON av_def.default_charger_id = ac.id AND es.vehicle_id IS NULL
WHERE es.site_id = $1 AND es.session_end IS NULL
ORDER BY es.id, av_def.id NULLS LAST
""",
site_id,
)
neg_rows = await conn.fetch(
"""
SELECT predicted_date, window_start_hour, window_end_hour, probability_pct
FROM ems.predicted_negative_price_window
WHERE site_id = $1
AND predicted_date BETWEEN CURRENT_DATE AND CURRENT_DATE + 2
AND probability_pct >= 50
ORDER BY predicted_date, window_start_hour
""",
site_id,
)
has_plan = run_row is not None
mode_code = (mode_row["mode_code"] if mode_row else None) or ""
reserve_soc = (
float(reserve_row["reserve_soc"])
if reserve_row and reserve_row["reserve_soc"] is not None
else None
)
min_soc = (
float(reserve_row["min_soc"])
if reserve_row and reserve_row["min_soc"] is not None
else None
)
soc = (
float(inv_row["battery_soc_percent"])
if inv_row and inv_row["battery_soc_percent"] is not None
else None
)
inv_age = _age_seconds(inv_row["measured_at"] if inv_row else None)
hb_age = _age_seconds(hb_row["last_seen"] if hb_row else None)
neg_rows = ctx.get("neg_windows") or []
if not isinstance(neg_rows, list):
neg_rows = []
infra = _infrastructure_notification_items(
has_plan=has_plan,
@@ -559,11 +384,15 @@ async def get_site_notifications(
prices: list[PriceSlot] = []
for r in price_rows:
buy = _float_or_none(r["effective_buy_price_czk_kwh"])
if not isinstance(r, dict):
continue
buy = _float_or_none(r.get("effective_buy_price_czk_kwh"))
if buy is None:
continue
sell_v = _float_or_none(r["effective_sell_price_czk_kwh"])
istart = r["interval_start"]
sell_v = _float_or_none(r.get("effective_sell_price_czk_kwh"))
istart = r.get("interval_start")
if isinstance(istart, str):
istart = datetime.fromisoformat(istart.replace("Z", "+00:00"))
prices.append(
PriceSlot(
interval_start=istart,
@@ -572,43 +401,50 @@ async def get_site_notifications(
)
)
avg_buy = _float_or_none(avg_row["avg_buy"]) if avg_row else None
usable_wh = _float_or_none(bat_row["usable_wh"]) if bat_row else None
battery_kwh = (usable_wh / 1000.0) if usable_wh is not None else None
ev_sessions: list[EvSessionRow] = []
for er in ev_rows:
if not isinstance(er, dict):
continue
ss = er.get("session_start")
if isinstance(ss, str):
ss = datetime.fromisoformat(ss.replace("Z", "+00:00"))
ev_sessions.append(
EvSessionRow(
id=int(er["id"]),
charger_id=int(er["charger_id"]),
energy_delivered_wh=float(er["energy_delivered_wh"] or 0),
target_soc_pct=_float_or_none(er["target_soc_pct"]),
session_start=er["session_start"],
battery_capacity_kwh=_float_or_none(er["battery_capacity_kwh"]),
make=er["make"],
model=er["model"],
default_target_soc_pct=_float_or_none(er["default_target_soc_pct"]),
charger_code=str(er["charger_code"] or ""),
soc_at_connect_pct=_float_or_none(er["soc_at_connect_pct"]),
energy_delivered_wh=float(er.get("energy_delivered_wh") or 0),
target_soc_pct=_float_or_none(er.get("target_soc_pct")),
session_start=ss,
battery_capacity_kwh=_float_or_none(er.get("battery_capacity_kwh")),
make=er.get("make"),
model=er.get("model"),
default_target_soc_pct=_float_or_none(er.get("default_target_soc_pct")),
charger_code=str(er.get("charger_code") or ""),
soc_at_connect_pct=_float_or_none(er.get("soc_at_connect_pct")),
)
)
neg_windows: list[NegWindowRow] = []
for nr in neg_rows:
dr = nr["predicted_date"]
if not isinstance(nr, dict):
continue
dr = nr.get("predicted_date")
if isinstance(dr, datetime):
d_conv = dr.date()
elif isinstance(dr, date):
d_conv = dr
elif isinstance(dr, str):
d_conv = date.fromisoformat(dr[:10])
else:
d_conv = date.today()
neg_windows.append(
NegWindowRow(
predicted_date=d_conv,
window_start_hour=int(nr["window_start_hour"]),
window_end_hour=int(nr["window_end_hour"]),
probability_pct=int(nr["probability_pct"]),
window_start_hour=int(nr.get("window_start_hour") or 0),
window_end_hour=int(nr.get("window_end_hour") or 0),
probability_pct=int(nr.get("probability_pct") or 0),
)
)

33
backend/app/routers/me.py Normal file
View File

@@ -0,0 +1,33 @@
"""REST API /me (fáze bez auth)."""
from __future__ import annotations
from typing import Annotated, Any
import asyncpg
from fastapi import APIRouter, Depends
from app.db_json import record_to_dict
from app.deps import get_pg_pool
router = APIRouter(prefix="/api/v1/me", tags=["me"])
@router.get(
"/sites",
summary="Lokality přihlášeného uživatele (fáze bez auth)",
description="Aktuálně vrací všechny aktivní lokality z vw_site_directory; po zavedení autentizace se odfiltruje podle oprávnění.",
)
async def list_my_sites(
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[dict[str, Any]]:
async with db.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at
FROM ems.vw_site_directory
WHERE active = true
ORDER BY code
"""
)
return [record_to_dict(r) for r in rows]

View File

@@ -1,5 +1,6 @@
"""REST API aktivní plán a ruční přepočet."""
import json
import logging
from datetime import datetime, timezone
from typing import Annotated, Any, Literal
@@ -8,7 +9,7 @@ import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from app.db_json import record_to_dict
from app.db_json import fetch_json
from app.deps import get_pg_pool
from services.control_exporter import export_setpoints
from services.planning_engine import run_plan_api
@@ -46,126 +47,36 @@ class CurrentPlanResponseModel(BaseModel):
summary: dict[str, Any]
def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
total_cost = 0.0
total_curtailed_kwh = 0.0
charge_slots = 0
discharge_slots = 0
export_slots = 0
for row in intervals:
ec = row.get("expected_cost_czk")
if ec is not None:
total_cost += float(ec)
c = row.get("pv_a_curtailed_w") or 0
total_curtailed_kwh += int(c) * 0.25 / 1000.0
b = row.get("battery_setpoint_w")
if b is not None:
if int(b) > 0:
charge_slots += 1
elif int(b) < 0:
discharge_slots += 1
g = row.get("grid_setpoint_w")
if g is not None and int(g) < 0:
export_slots += 1
return {
"total_expected_cost_czk": round(total_cost, 4),
"total_pv_curtailed_kwh": round(total_curtailed_kwh, 6),
"charge_slots": charge_slots,
"discharge_slots": discharge_slots,
"export_slots": export_slots,
}
def _pv_scarcity_factor_from_intervals(
intervals: list[dict[str, Any]], battery_usable_wh: float | None
) -> float:
"""Stejná logika jako v solveru: 0.65..1.0 podle očekávané FVE energie na ~24h."""
if not intervals:
return 1.0
batt_kwh = max(1.0, float(battery_usable_wh or 0.0) / 1000.0)
horizon_slots = min(len(intervals), int(24 / 0.25))
pv_kwh = 0.0
for row in intervals[:horizon_slots]:
pv = row.get("pv_forecast_total_w")
if pv is not None:
pv_kwh += max(0.0, float(pv)) * 0.25 / 1000.0
coverage = pv_kwh / batt_kwh
coverage_clamped = max(0.0, min(1.0, coverage))
return round(0.65 + 0.35 * coverage_clamped, 4)
@router.get("/current", response_model=CurrentPlanResponseModel)
async def get_current_plan(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> CurrentPlanResponseModel:
async with pool.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
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")
run_row = await conn.fetchrow(
"""
SELECT pr.*
FROM ems.planning_run pr
WHERE pr.site_id = $1 AND pr.status = 'active'
ORDER BY pr.created_at DESC
LIMIT 1
""",
bundle = await fetch_json(
conn,
"select ems.fn_plan_current_bundle($1::int)",
site_id,
)
if not run_row:
raise HTTPException(status_code=404, detail="No active plan")
if not isinstance(bundle, dict):
bundle = json.loads(bundle)
if bundle.get("error") == "no_active_plan":
raise HTTPException(status_code=404, detail="No active plan")
run_id = run_row["id"]
int_rows = await conn.fetch(
"""
WITH latest_fc AS (
SELECT id
FROM ems.forecast_pv_run
WHERE site_id = $2 AND status = 'ok'
ORDER BY created_at DESC
LIMIT 1
),
fc_slot AS (
SELECT fpi.interval_start, COALESCE(SUM(fpi.power_w), 0)::BIGINT AS pv_forecast_total_w
FROM ems.forecast_pv_interval fpi
WHERE fpi.run_id = (SELECT id FROM latest_fc)
GROUP BY fpi.interval_start
)
SELECT
pi.*,
ai.actual_pv_power_w AS pv_power_w,
fs.pv_forecast_total_w AS pv_forecast_total_w
FROM ems.planning_interval pi
LEFT JOIN ems.audit_interval ai
ON ai.site_id = $2 AND ai.interval_start = pi.interval_start
LEFT JOIN fc_slot fs ON fs.interval_start = pi.interval_start
WHERE pi.run_id = $1
ORDER BY pi.interval_start
""",
run_id,
site_id,
)
battery_usable_wh = await conn.fetchval(
"""
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float
FROM ems.asset_battery ab
WHERE ab.site_id = $1
""",
site_id,
)
intervals_raw = [record_to_dict(r) for r in int_rows]
summary = _build_summary(intervals_raw)
summary["pv_scarcity_factor"] = _pv_scarcity_factor_from_intervals(
intervals_raw, float(battery_usable_wh or 0.0)
)
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw]
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)]
return CurrentPlanResponseModel(
run=record_to_dict(run_row),
run=bundle.get("run") or {},
intervals=intervals,
summary=summary,
summary=bundle.get("summary") or {},
)
@@ -176,18 +87,14 @@ async def post_run_plan(
plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
) -> RunPlanResponse:
async with pool.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
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")
days_with_prices = await conn.fetchval(
"""
SELECT COUNT(DISTINCT interval_start::date)::int AS days_with_prices
FROM ems.market_interval_price
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
AND interval_start >= now()
AND interval_start < now() + INTERVAL '48 hours'
"""
"select ems.fn_planning_future_price_days()",
)
if (days_with_prices or 0) < 1:
raise HTTPException(
@@ -199,14 +106,10 @@ async def post_run_plan(
run_id, solver_duration_ms = await run_plan_api(
site_id, plan_type, conn, triggered_by="api"
)
# Nový active run aplikuj hned; nečekej na periodický control_export job.
await export_setpoints(site_id, conn)
row = await conn.fetchrow(
"""
SELECT horizon_start, horizon_end
FROM ems.planning_run
WHERE id = $1
""",
row = await fetch_json(
conn,
"select ems.fn_planning_run_horizon($1::int)",
run_id,
)
except HTTPException:
@@ -219,7 +122,7 @@ async def post_run_plan(
logger.error("Plan run failed: %s", e, exc_info=True)
raise HTTPException(status_code=422, detail=str(e)) from e
if row is None:
if not isinstance(row, dict) or row.get("horizon_start") is None:
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
return RunPlanResponse(

View File

@@ -0,0 +1,205 @@
"""GET /sites/{site_id}/configuration read-only souhrn konfigurace lokality."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Annotated, Any
import asyncpg
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from app.db_json import fetch_json
from app.deps import get_pg_pool
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
class PvForecastCalibrationPatch(BaseModel):
"""Částečná úprava `ems.site_pv_forecast_calibration`. Vynechané klíče = beze změny."""
model_config = ConfigDict(extra="forbid")
delta_learn_min_ts: datetime | None = None
pv_curtailment_policy_effective_from: datetime | None = None
top_n_days: int | None = Field(default=None, ge=0, le=31)
non_top_day_factor: float | None = Field(default=None, ge=0, le=1)
day_weight_gamma: float | None = Field(default=None, ge=0.25, le=8)
half_life_days: float | None = Field(default=None, ge=1, le=90)
threshold_w: int | None = Field(default=None, ge=0, le=10_000)
class InverterModbusCurrentCapsBody(BaseModel):
"""Tvrdý strop proudu pro zápis Deye reg 108/109 (A); NULL ve JSONu = smaž strop v DB."""
deye_register_max_charge_a: int | None = Field(
default=None,
ge=0,
le=640,
description="None při vynechání klíče = nezměnit; explicitní null = smazat strop",
)
deye_register_max_discharge_a: int | None = Field(
default=None,
ge=0,
le=640,
description="Jako u nabíjení",
)
def _iso_utc_from_cfg(val: Any) -> str | None:
if val is None:
return None
if isinstance(val, str):
return val
if isinstance(val, datetime):
dt = val
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat()
return str(val)
@router.get("/configuration")
async def get_site_configuration(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> dict[str, Any]:
async with pool.acquire() as conn:
raw = await fetch_json(
conn,
"select ems.fn_site_configuration($1::int)",
site_id,
)
if raw is None:
raise HTTPException(status_code=404, detail="Site not found")
if not isinstance(raw, dict):
raw = json.loads(raw)
op = raw.get("operational")
if isinstance(op, dict):
op = dict(op)
op["heartbeat_last_seen"] = _iso_utc_from_cfg(op.get("heartbeat_last_seen"))
op["active_plan_created_at"] = _iso_utc_from_cfg(op.get("active_plan_created_at"))
raw["operational"] = op
lat = raw.get("site", {}).get("latitude") if isinstance(raw.get("site"), dict) else None
lon = raw.get("site", {}).get("longitude") if isinstance(raw.get("site"), dict) else None
if isinstance(raw.get("site"), dict):
site = dict(raw["site"])
site["latitude"] = float(lat) if lat is not None else None
site["longitude"] = float(lon) if lon is not None else None
raw["site"] = site
return raw
@router.patch("/configuration/pv-forecast-calibration")
async def patch_pv_forecast_calibration(
site_id: int,
body: PvForecastCalibrationPatch,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> dict[str, Any]:
"""Aktualizace kalibrace PV delty (`ems.site_pv_forecast_calibration`)."""
updates = body.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
if updates.get("delta_learn_min_ts") is None and "delta_learn_min_ts" in updates:
raise HTTPException(
status_code=422,
detail="delta_learn_min_ts cannot be null (column is NOT NULL)",
)
allowed = {
"delta_learn_min_ts",
"pv_curtailment_policy_effective_from",
"top_n_days",
"non_top_day_factor",
"day_weight_gamma",
"half_life_days",
"threshold_w",
}
bad = set(updates) - allowed
if bad:
raise HTTPException(status_code=400, detail=f"Unknown fields: {sorted(bad)}")
cols = list(updates.keys())
set_parts: list[str] = []
args: list[Any] = [site_id]
for i, col in enumerate(cols, start=2):
set_parts.append(f"{col} = ${i}")
args.append(updates[col])
set_sql = ", ".join(set_parts) + ", updated_at = now()"
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")
n = await conn.execute(
f"""
UPDATE ems.site_pv_forecast_calibration
SET {set_sql}
WHERE site_id = $1
""",
*args,
)
if n == "UPDATE 0":
raise HTTPException(
status_code=404,
detail="PV forecast calibration row missing; run migration V057",
)
row = await conn.fetchrow(
"""
SELECT to_jsonb(c.*) AS j
FROM ems.site_pv_forecast_calibration c
WHERE c.site_id = $1
""",
site_id,
)
raw = row["j"] if row else {}
if not isinstance(raw, dict):
raw = json.loads(raw)
return raw
@router.patch("/inverters/{inverter_id}/modbus-current-caps")
async def patch_inverter_modbus_current_caps(
site_id: int,
inverter_id: int,
body: InverterModbusCurrentCapsBody,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> dict[str, Any]:
"""
Nastavení `deye_register_max_charge_a` / `deye_register_max_discharge_a` na `ems.asset_inverter`.
"""
updates = body.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(
status_code=400,
detail="Send at least one of: deye_register_max_charge_a, deye_register_max_discharge_a",
)
patch: dict[str, Any] = {}
if "deye_register_max_charge_a" in updates:
patch["deye_register_max_charge_a"] = updates["deye_register_max_charge_a"]
if "deye_register_max_discharge_a" in updates:
patch["deye_register_max_discharge_a"] = updates["deye_register_max_discharge_a"]
async with pool.acquire() as conn:
raw = await fetch_json(
conn,
"select ems.fn_inverter_modbus_caps_patch($1::int, $2::int, $3::jsonb)",
site_id,
inverter_id,
json.dumps(patch),
)
if not isinstance(raw, dict):
raw = json.loads(raw)
if not raw.get("ok"):
if raw.get("error") == "not_found":
raise HTTPException(status_code=404, detail="Inverter not found for this site")
raise HTTPException(status_code=400, detail=raw.get("error", "patch_failed"))
return {
"inverter_id": int(raw["inverter_id"]),
"code": raw["code"],
"deye_register_max_charge_a": raw.get("deye_register_max_charge_a"),
"deye_register_max_discharge_a": raw.get("deye_register_max_discharge_a"),
}

View File

@@ -0,0 +1,811 @@
"""REST API lokality: ceny OTE, forecast, Modbus journal/verify."""
from __future__ import annotations
import json
import logging
from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Any
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.db_json import fetch_json, record_to_dict
from app.deps import get_pg_pool
from app.refresh_negative_prices import refresh_negative_price_predictions
from services.control_exporter import read_deye_registers_live, verify_modbus_commands
from services.forecast_service import fetch_pv_forecast
from services.price_importer import import_ote_prices
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/sites", tags=["sites"])
def _parse_ymd(s: str) -> date:
try:
return date.fromisoformat(s)
except ValueError:
raise HTTPException(
status_code=400, detail="Invalid date, expected YYYY-MM-DD"
) from None
@router.get("")
async def list_sites(
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[dict[str, Any]]:
async with db.acquire() as conn:
rows = await conn.fetch(
"""
select id, code, name, timezone, latitude, longitude, active, notes, created_at
from ems.vw_site_directory
order by id
"""
)
return [record_to_dict(r) for r in rows]
@router.get("/{site_id}/prices")
async def get_site_prices(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
date_str: str | None = Query(
None, alias="date", description="YYYY-MM-DD, default today"
),
) -> list[dict[str, Any]]:
if date_str is None:
date_str = date.today().isoformat()
d = _parse_ymd(date_str)
async with db.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")
rows = await fetch_json(
conn,
"select ems.fn_site_effective_prices_day_prague($1::int, $2::date)",
site_id,
d,
)
if not isinstance(rows, list):
rows = json.loads(rows) if isinstance(rows, str) else []
return [r for r in rows if isinstance(r, dict)]
@router.get("/{site_id}/prices/slots")
async def get_site_prices_slots_range(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
from_ts: datetime = Query(
...,
alias="from",
description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min",
),
to_ts: datetime = Query(
...,
alias="to",
description="Konec polouzavřeného intervalu (max. 14 dní za from)",
),
) -> dict[str, list[dict[str, Any]]]:
if to_ts <= from_ts:
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
if to_ts - from_ts > timedelta(days=14):
raise HTTPException(
status_code=422,
detail="Span between 'from' and 'to' must be at most 14 days",
)
async with db.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")
raw = await fetch_json(
conn,
"select ems.fn_site_effective_prices_slots_range($1::int, $2::timestamptz, $3::timestamptz)",
site_id,
from_ts,
to_ts,
)
rows = raw if isinstance(raw, list) else []
if not isinstance(rows, list):
rows = []
return {"slots": [r for r in rows if isinstance(r, dict)]}
class PricesImportResponse(BaseModel):
slots_imported: int
date: str
first_price_czk_kwh: float
class PricesLatestResponse(BaseModel):
latest_date: str
slots: int
min_price: float
max_price: float
avg_price: float
class ForecastRunResponse(BaseModel):
intervals_saved: int
pv_arrays: int
class ModbusCommandVerifyItem(BaseModel):
id: int
asset_code: str
register_name: str | None
value_to_write: int
value_verified: int | None
status: str
class ModbusVerifyResponse(BaseModel):
checked: int
verified: int
mismatch: int
commands: list[ModbusCommandVerifyItem]
@router.post(
"/{site_id}/prices/import",
response_model=PricesImportResponse,
summary="Import OTE cen (globální)",
description=(
"Zapíše do sdílené tabulky ems.market_interval_price (jedna sada dat pro všechny lokality). "
"site_id v cestě slouží ke kontrole existence lokality (kompatibilita s UI); po importu se "
"obnoví predikce záporných cen pro všechny aktivní lokality."
),
)
async def post_import_site_prices(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
date_str: str | None = Query(
None,
alias="date",
description="YYYY-MM-DD; výchozí = zítřek/dnes dle logiky OTE (Europe/Prague)",
),
) -> PricesImportResponse:
target: date | None = _parse_ymd(date_str) if date_str is not None else None
import_error: str | None = None
async with db.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")
n, day, first_price, import_error = await import_ote_prices(
conn, site_id=None, target_date=target
)
if n >= 0:
sites_raw = await fetch_json(
conn, "select ems.fn_vw_site_directory_active()"
)
sites_list = sites_raw if isinstance(sites_raw, list) else []
for site in sites_list:
if isinstance(site, dict):
await refresh_negative_price_predictions(conn, int(site["id"]))
if n < 0:
raise HTTPException(
status_code=422,
detail=f"OTE import selhal ({import_error or 'unknown'})",
)
return PricesImportResponse(
slots_imported=n,
date=day,
first_price_czk_kwh=first_price,
)
class NegPricePredictionItem(BaseModel):
predicted_date: str
window_start_hour: int
window_end_hour: int
probability_pct: float
expected_min_price: float | None
reason: str
class NegativePredictionsResponse(BaseModel):
predictions: list[NegPricePredictionItem]
insufficient_history: bool
@router.get(
"/{site_id}/prices/negative-predictions",
response_model=NegativePredictionsResponse,
)
async def get_site_negative_price_predictions(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> NegativePredictionsResponse:
"""Cache predikce záporných cen (per site) + informace, zda je dost historie OTE."""
async with db.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")
bundle = await fetch_json(
conn,
"select ems.fn_negative_price_predictions($1::int)",
site_id,
)
if not isinstance(bundle, dict):
bundle = json.loads(bundle)
rows = bundle.get("predictions") or []
if not isinstance(rows, list):
rows = []
predictions: list[NegPricePredictionItem] = []
for r in rows:
if not isinstance(r, dict):
continue
em = r.get("expected_min_price")
pd = r.get("predicted_date")
predictions.append(
NegPricePredictionItem(
predicted_date=pd.isoformat()
if hasattr(pd, "isoformat")
else str(pd),
window_start_hour=int(r.get("window_start_hour") or 0),
window_end_hour=int(r.get("window_end_hour") or 0),
probability_pct=float(r.get("probability_pct") or 0),
expected_min_price=float(em) if em is not None else None,
reason=str(r.get("reason") or ""),
)
)
return NegativePredictionsResponse(
predictions=predictions,
insufficient_history=bool(bundle.get("insufficient_history")),
)
@router.get("/{site_id}/prices/latest", response_model=PricesLatestResponse)
async def get_site_prices_latest(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> PricesLatestResponse:
async with db.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")
row = await fetch_json(conn, "select ems.fn_latest_ote_day_stats()")
if not isinstance(row, dict):
row = json.loads(row)
day = row.get("latest_date")
if day is None:
raise HTTPException(status_code=404, detail="Žádná tržní data v databázi")
latest_date = day.isoformat() if hasattr(day, "isoformat") else str(day)[:10]
return PricesLatestResponse(
latest_date=latest_date,
slots=int(row.get("slots") or 0),
min_price=float(row.get("min_price") or 0.0),
max_price=float(row.get("max_price") or 0.0),
avg_price=float(row.get("avg_price") or 0.0),
)
@router.get("/{site_id}/control/verify", response_model=ModbusVerifyResponse)
async def get_verify_modbus_commands(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
minutes: int = Query(10, ge=1, le=1440, description="Jak daleko zpět hledat written příkazy"),
) -> ModbusVerifyResponse:
"""
Ruční ověření Modbus zápisů (written) z posledních N minut.
Vhodné hned po manuálním exportu setpointů.
"""
async with db.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")
lookback = timedelta(minutes=minutes)
id_json = await fetch_json(
conn,
"select ems.fn_modbus_written_command_ids($1::int, $2::interval)",
site_id,
lookback,
)
if not isinstance(id_json, list):
id_json = json.loads(id_json) if isinstance(id_json, str) else []
ids = [int(x) for x in id_json]
checked = len(ids)
if ids:
await verify_modbus_commands(ids, conn, site_id)
detail_json = (
await fetch_json(
conn,
"select ems.fn_modbus_commands_by_ids($1::int[])",
ids,
)
if ids
else []
)
if ids and not isinstance(detail_json, list):
detail_json = json.loads(detail_json) if isinstance(detail_json, str) else []
detail_rows = detail_json if ids else []
commands = [
ModbusCommandVerifyItem(
id=int(r["id"]),
asset_code=str(r.get("asset_code") or ""),
register_name=r.get("register_name"),
value_to_write=int(r["value_to_write"]),
value_verified=int(r["value_verified"])
if r.get("value_verified") is not None
else None,
status=str(r.get("status") or ""),
)
for r in detail_rows
if isinstance(r, dict)
]
verified = sum(1 for c in commands if c.status == "verified")
mismatch = sum(1 for c in commands if c.status == "mismatch")
return ModbusVerifyResponse(
checked=checked,
verified=verified,
mismatch=mismatch,
commands=commands,
)
class DeyeRegistersLiveResponse(BaseModel):
reg108_charge_a: int
reg109_discharge_a: int
reg141_energy_mode: int
reg142_limit_control: int
reg143_export_limit_w: int
reg178_peak_shaving_switch: int
reg178_control_board_special_1: int
reg178_mi_export_cutoff_bits: int
reg178_mi_export_cutoff_is_on: bool
reg191_peak_shaving_w: int
read_at: str
@router.get(
"/{site_id}/control/registers",
response_model=DeyeRegistersLiveResponse,
)
async def get_control_registers_live(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> DeyeRegistersLiveResponse:
"""Živé hodnoty registrů Deye 108/109/141/142/143/178/191 přes sdílený Modbus klient."""
async with db.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")
try:
payload = await read_deye_registers_live(site_id, conn)
except ValueError:
raise HTTPException(
status_code=404,
detail="No controllable Modbus inverter for this site",
) from None
except Exception as e:
logger.warning("get_control_registers_live site=%s: %s", site_id, e)
raise HTTPException(
status_code=503,
detail=f"Modbus read failed: {e}",
) from e
return DeyeRegistersLiveResponse(**payload)
class ModbusJournalCommandRow(BaseModel):
id: int
register: int
register_name: str | None
value_to_write: int
value_written: int | None
value_verified: int | None
status: str
attempt_count: int
created_at: str
class ModbusJournalListResponse(BaseModel):
commands: list[ModbusJournalCommandRow]
@router.get(
"/{site_id}/control/journal",
response_model=ModbusJournalListResponse,
)
async def get_control_command_journal(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
limit: int = Query(50, ge=1, le=100),
) -> ModbusJournalListResponse:
async with db.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")
rows = await fetch_json(
conn,
"select ems.fn_modbus_journal_list($1::int, $2::int)",
site_id,
limit,
)
if not isinstance(rows, list):
rows = json.loads(rows) if isinstance(rows, str) else []
cmds: list[ModbusJournalCommandRow] = []
for r in rows:
d = r if isinstance(r, dict) else {}
ca = d["created_at"]
cmds.append(
ModbusJournalCommandRow(
id=int(d["id"]),
register=int(d["register"]),
register_name=d.get("register_name"),
value_to_write=int(d["value_to_write"]),
value_written=int(d["value_written"])
if d.get("value_written") is not None
else None,
value_verified=int(d["value_verified"])
if d.get("value_verified") is not None
else None,
status=str(d["status"]),
attempt_count=int(d["attempt_count"]),
created_at=ca if isinstance(ca, str) else str(ca),
)
)
return ModbusJournalListResponse(commands=cmds)
@router.post("/{site_id}/forecast/run", response_model=ForecastRunResponse)
async def post_run_site_forecast(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> ForecastRunResponse:
async with db.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")
try:
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
except Exception as e:
logger.error("Forecast failed: %s", e, exc_info=True)
raise HTTPException(status_code=422, detail=str(e)) from e
if intervals >= 0:
await refresh_negative_price_predictions(conn, site_id)
if intervals < 0:
raise HTTPException(
status_code=422,
detail="Forecast se nepodařilo stáhnout nebo zpracovat",
)
return ForecastRunResponse(intervals_saved=intervals, pv_arrays=pv_arrays)
@router.get("/{site_id}/forecast/pv")
async def get_site_forecast_pv(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
date_str: str | None = Query(
None, alias="date", description="YYYY-MM-DD, default tomorrow"
),
) -> dict[str, list[dict[str, Any]]]:
if date_str is None:
date_str = (date.today() + timedelta(days=1)).isoformat()
d = _parse_ymd(date_str)
async with db.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")
split = await fetch_json(
conn,
"select ems.fn_forecast_pv_split($1::int, $2::date)",
site_id,
d,
)
if not isinstance(split, dict):
split = json.loads(split) if isinstance(split, str) else {}
pv_a = split.get("pv_a") or []
pv_b = split.get("pv_b") or []
if not isinstance(pv_a, list):
pv_a = []
if not isinstance(pv_b, list):
pv_b = []
return {"pv_a": pv_a, "pv_b": pv_b}
@router.get("/{site_id}/forecast/pv-slots")
async def get_site_forecast_pv_slots_range(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
from_ts: datetime = Query(
...,
alias="from",
description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min",
),
to_ts: datetime = Query(
...,
alias="to",
description="Konec polouzavřeného intervalu (max. 60 dní za from)",
),
) -> dict[str, list[dict[str, Any]]]:
if to_ts <= from_ts:
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
if to_ts - from_ts > timedelta(days=60):
raise HTTPException(
status_code=422,
detail="Span between 'from' and 'to' must be at most 60 days",
)
async with db.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")
raw = await fetch_json(
conn,
"select ems.fn_forecast_pv_slots_range($1::int, $2::timestamptz, $3::timestamptz)",
site_id,
from_ts,
to_ts,
)
slots = raw if isinstance(raw, list) else []
if not isinstance(slots, list):
slots = []
return {"slots": slots}
@router.get("/{site_id}/forecast/pv-slots-corrected")
async def get_site_forecast_pv_slots_range_corrected(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
from_ts: datetime = Query(
...,
alias="from",
description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min",
),
to_ts: datetime = Query(
...,
alias="to",
description="Konec polouzavřeného intervalu (max. 60 dní za from)",
),
delta_from_ts: datetime | None = Query(
None,
alias="delta_from",
description="Začátek okna historie pro výpočet delta profilu (default: now-60d)",
),
delta_to_ts: datetime | None = Query(
None,
alias="delta_to",
description="Konec okna historie pro výpočet delta profilu (default: now)",
),
half_life_days: float = Query(
14,
ge=1,
le=90,
description="Half-life vážení (dny) pro delta profil",
),
threshold_w: int = Query(
150,
ge=0,
le=10_000,
description="Ignorovat sloty s nízkou výrobou (W) při odhadu profilu",
),
) -> dict[str, list[dict[str, Any]]]:
if to_ts <= from_ts:
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
if to_ts - from_ts > timedelta(days=60):
raise HTTPException(
status_code=422,
detail="Span between 'from' and 'to' must be at most 60 days",
)
now = datetime.now(tz=timezone.utc)
delta_to = delta_to_ts or now
delta_from = delta_from_ts or (delta_to - timedelta(days=60))
async with db.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")
raw = await fetch_json(
conn,
"""
select ems.fn_forecast_pv_slots_range_corrected(
$1::int,
$2::timestamptz,
$3::timestamptz,
$4::timestamptz,
$5::timestamptz,
$6::numeric,
$7::int
)
""",
site_id,
from_ts,
to_ts,
delta_from,
delta_to,
half_life_days,
threshold_w,
)
slots = raw if isinstance(raw, list) else []
if not isinstance(slots, list):
slots = []
return {"slots": [s for s in slots if isinstance(s, dict)]}
@router.get("/{site_id}/forecast/pv-delta-profile")
async def get_site_forecast_pv_delta_profile(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
from_ts: datetime = Query(
...,
alias="from",
description="Začátek okna historie pro výpočet delty [from, to)",
),
to_ts: datetime = Query(
...,
alias="to",
description="Konec okna (max. 120 dní za from; typicky now)",
),
half_life_days: float = Query(
14,
ge=1,
le=90,
description="Half-life vážení (dny) pro delta profil",
),
threshold_w: int = Query(
150,
ge=0,
le=10_000,
description="Ignorovat sloty s nízkou výrobou (W) při odhadu profilu",
),
top_n_days: int | None = Query(
None,
ge=0,
le=31,
description="Top N kalendářních dní podle day_score (NULL = z kalibrace / výchozí funkce)",
),
non_top_day_factor: float | None = Query(
None,
ge=0,
le=1,
description="Ztlumení vah mimo top N (NULL = z kalibrace / default)",
),
day_weight_gamma: float | None = Query(
None,
ge=0.25,
le=8,
description="Exponent na day_weight (NULL = z kalibrace / default)",
),
) -> dict[str, Any]:
"""JSON z `ems.fn_pv_forecast_delta_profile` (`deltas`, `deltas_by_array`, cutoff z DB)."""
if to_ts <= from_ts:
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
if to_ts - from_ts > timedelta(days=120):
raise HTTPException(
status_code=422,
detail="Span between 'from' and 'to' must be at most 120 days",
)
async with db.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")
raw = await fetch_json(
conn,
"""
select ems.fn_pv_forecast_delta_profile(
$1::int,
$2::timestamptz,
$3::timestamptz,
$4::numeric,
$5::int,
$6::int,
$7::numeric,
$8::numeric
)
""",
site_id,
from_ts,
to_ts,
half_life_days,
threshold_w,
top_n_days,
non_top_day_factor,
day_weight_gamma,
)
if not isinstance(raw, dict):
return {}
return raw
@router.get("/{site_id}/timeseries/telemetry-15m")
async def get_site_telemetry_15m_range(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
from_ts: datetime = Query(..., alias="from", description="Začátek okna [from, to)"),
to_ts: datetime = Query(..., alias="to", description="Konec okna [from, to)"),
) -> dict[str, list[dict[str, Any]]]:
if to_ts <= from_ts:
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
if to_ts - from_ts > timedelta(days=60):
raise HTTPException(
status_code=422,
detail="Span between 'from' and 'to' must be at most 60 days",
)
async with db.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")
rows = await conn.fetch(
"""
select
slot_start,
site_id,
avg_pv_w,
avg_load_w,
avg_grid_w,
avg_battery_w,
last_soc_pct,
sample_count
from ems.telemetry_inverter_15m
where site_id = $1
and slot_start >= $2::timestamptz
and slot_start < $3::timestamptz
order by slot_start asc
""",
site_id,
from_ts,
to_ts,
)
return {"slots": [record_to_dict(r) for r in rows]}
@router.get("/{site_id}/forecast/load-baseline-slots")
async def get_site_load_baseline_slots_range(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
from_ts: datetime = Query(..., alias="from", description="Začátek okna [from, to)"),
to_ts: datetime = Query(..., alias="to", description="Konec okna [from, to)"),
) -> dict[str, list[dict[str, Any]]]:
if to_ts <= from_ts:
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
if to_ts - from_ts > timedelta(days=60):
raise HTTPException(
status_code=422,
detail="Span between 'from' and 'to' must be at most 60 days",
)
async with db.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")
rows = await conn.fetch(
"""
select interval_start, forecast_w, confidence_w
from ems.fn_get_baseline_forecast($1::int, $2::timestamptz, $3::timestamptz)
""",
site_id,
from_ts,
to_ts,
)
return {"slots": [record_to_dict(r) for r in rows]}

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Doplnění ems.market_interval_price z veřejného OTE JSON endpointu (stejný jako price_importer).
Produkce (Docker závislosti v image backendu), z adresáře kde leží docker-compose.yml:
cd /opt/ems-deploy
docker compose exec -T backend python3 scripts/backfill_ote_prices.py --dry-run
Nebo z kořene stacku: bash app/deploy/run_backfill_ote_prices.sh --dry-run
Lokálně (venv s backend/requirements.txt):
cd /path/to/ems-cursor
PYTHONPATH=backend python3 backend/scripts/backfill_ote_prices.py --dry-run
Volby:
--days 730 posledních N kalendářních dní (Europe/Prague), výchozí 730 ≈ 2 roky
--from-date / --to-date pevný rozsah YYYY-MM-DD (má přednost před --days u konce rozsahu)
--force stáhnout znovu i dny s plným počtem slotů OTE (92/96/100)
--dry-run jen vypsat chybějící dny, bez HTTP
--delay SEC pauza mezi dny (výchozí 0.35)
--refresh-predictions po skončení zavolat fn_predict_negative_price_windows pro aktivní site
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
os.chdir(_BACKEND_ROOT)
try:
import asyncpg
except ModuleNotFoundError as e:
print(
"Chybí modul 'asyncpg' (závislost backendu).\n"
"\n"
"Na serveru s Docker stackem EMS spusťte skript uvnitř kontejneru backendu, např.:\n"
" cd /opt/ems-deploy\n"
" docker compose exec -T backend python3 scripts/backfill_ote_prices.py --dry-run\n"
"\n"
"Lokálně nainstalujte závislosti: pip install -r backend/requirements.txt\n",
file=sys.stderr,
)
raise SystemExit(1) from e
from app.config import get_settings # noqa: E402
from services.price_importer import ( # noqa: E402
OTE_FULL_DAY_SLOT_COUNTS,
backfill_ote_prices,
count_ote_slots_prague_day,
ote_prague_day_slots_look_complete,
)
PRAGUE = ZoneInfo("Europe/Prague")
def _parse_ymd(s: str) -> date:
y, m, d = (int(p) for p in s.split("-", 2))
return date(y, m, d)
async def _dry_run_missing(
conn: asyncpg.Connection,
start: date,
end: date,
today_prague: date,
) -> list[date]:
out: list[date] = []
d = start
while d <= end:
if d > today_prague:
break
n = await count_ote_slots_prague_day(conn, d)
if not ote_prague_day_slots_look_complete(n):
out.append(d)
d += timedelta(days=1)
return out
async def _refresh_predictions_all(conn: asyncpg.Connection) -> None:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for row in sites:
sid = int(row["id"])
try:
await conn.fetch("SELECT * FROM ems.fn_predict_negative_price_windows($1, 7)", sid)
logging.info("Predikce záporných cen obnovena pro site_id=%s", sid)
except Exception:
logging.exception("fn_predict_negative_price_windows selhalo pro site_id=%s", sid)
async def main_async(args: argparse.Namespace) -> int:
settings = get_settings()
pool = await asyncpg.create_pool(
host=settings.db_host,
port=settings.db_port,
user=settings.db_user,
password=settings.db_password,
database=settings.db_name,
min_size=1,
max_size=3,
)
try:
today_prague = datetime.now(PRAGUE).date()
if args.to_date:
end = _parse_ymd(args.to_date)
else:
end = today_prague
if args.from_date:
start = _parse_ymd(args.from_date)
else:
start = end - timedelta(days=max(0, int(args.days) - 1))
if start > end:
logging.error("--from-date je po --to-date")
return 2
logging.info(
"Rozsah backfillu: %s%s (kurz EUR/CZK z .env = %s)",
start.isoformat(),
end.isoformat(),
settings.eur_czk_rate,
)
async with pool.acquire() as conn:
if args.dry_run:
missing = await _dry_run_missing(conn, start, end, today_prague)
logging.info(
"Dry-run: %s chybějících nebo neúplných dní (plný den = jedna z %s)",
len(missing),
sorted(OTE_FULL_DAY_SLOT_COUNTS),
)
for md in missing[:50]:
n = await count_ote_slots_prague_day(conn, md)
logging.info(" %s (%s slotů)", md.isoformat(), n)
if len(missing) > 50:
logging.info(" … a dalších %s dní", len(missing) - 50)
return 0
stats = await backfill_ote_prices(
conn,
start_date=start,
end_date=end,
only_missing=not args.force,
pause_between_days_s=float(args.delay),
)
logging.info(
"Hotovo: zkontrolováno %s dní, importováno %s, přeskočeno (kompletní) %s, "
"přeskočeno (budoucnost) %s, selhalo %s",
stats.days_checked,
stats.days_imported,
stats.days_skipped_complete,
stats.days_skipped_future,
stats.days_failed,
)
for day_str, err in stats.failures[:20]:
logging.warning(" %s: %s", day_str, err)
if len(stats.failures) > 20:
logging.warning(" … dalších %s chyb v seznamu", len(stats.failures) - 20)
if args.refresh_predictions and stats.days_imported > 0:
await _refresh_predictions_all(conn)
return 1 if stats.days_failed else 0
finally:
await pool.close()
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(message)s",
)
parser = argparse.ArgumentParser(description="Backfill OTE cen do ems.market_interval_price")
parser.add_argument(
"--days",
type=int,
default=730,
help="Počet dní zpět od --to-date (výchozí 730)",
)
parser.add_argument("--from-date", type=str, default=None, help="YYYY-MM-DD začátek rozsahu")
parser.add_argument(
"--to-date",
type=str,
default=None,
help="YYYY-MM-DD konec rozsahu (výchozí dnes Europe/Prague)",
)
parser.add_argument(
"--force",
action="store_true",
help="Stáhnout znovu i dny s plným počtem slotů OTE (92/96/100)",
)
parser.add_argument("--dry-run", action="store_true", help="Jen vypsat chybějící dny")
parser.add_argument(
"--delay",
type=float,
default=0.35,
help="Sekundy pauzy mezi dny (výchozí 0.35)",
)
parser.add_argument(
"--refresh-predictions",
action="store_true",
help="Po importu obnovit fn_predict_negative_price_windows pro aktivní lokality",
)
ns = parser.parse_args()
raise SystemExit(asyncio.run(main_async(ns)))
if __name__ == "__main__":
main()

View File

@@ -3,51 +3,17 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
"""
Naplní audit_interval pro všechny dokončené 15min intervaly
za posledních 6 hodin které ještě nemají záznam.
Volá PostgreSQL funkci ems.fn_fill_audit_interval().
Naplní audit_interval pro dokončené 15min sloty přes ems.fn_fill_audit_for_site_window.
"""
now = datetime.now(timezone.utc)
last_complete = now.replace(
minute=(now.minute // 15) * 15, second=0, microsecond=0
)
rows = await db.fetch(
"""
SELECT gs.slot
FROM generate_series(
$1::timestamptz - interval '6 hours',
$1::timestamptz - interval '15 minutes',
interval '15 minutes'
) AS gs(slot)
WHERE NOT EXISTS (
SELECT 1 FROM ems.audit_interval ai
WHERE ai.site_id = $2 AND ai.interval_start = gs.slot
)
""",
last_complete,
n = await db.fetchval(
"select ems.fn_fill_audit_for_site_window($1::int, 6)",
site_id,
)
for row in rows:
slot = row["slot"]
await db.execute(
"SELECT ems.fn_fill_audit_interval($1, $2)",
site_id,
slot,
)
await db.execute(
"SELECT ems.fn_fill_baseline_load_forecast_accuracy($1, $2)",
site_id,
slot,
)
if rows:
logger.info("[site=%s] Filled %s missing audit intervals", site_id, len(rows))
if n:
logger.info("[site=%s] Filled %s missing audit intervals", site_id, int(n))

View File

@@ -0,0 +1,3 @@
"""Deye / Modbus control export modules."""
from .exporter_monolith import * # noqa: F401,F403

View File

@@ -0,0 +1,233 @@
"""Č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; součet nominal_power_wp řiditelných polí)",
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_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:
if not power_w or power_w <= 0:
return 0
return min(32, max(0, int(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) -> int:
"""Hodnota pro Deye reg 340 (max solar power, W) z capu a plánovaného curtailmentu pole A."""
if curtail_w <= 0:
return int(cap_w)
return max(0, min(int(cap_w), int(forecast_w) - int(curtail_w)))
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

View File

@@ -0,0 +1,80 @@
"""Zpětně kompatibilní fasáda pro původní control exporter importy."""
from __future__ import annotations
from services.control.deye_helpers import (
BATT_VOLTAGE_V,
DEYE_CLOCK_DRIFT_OK_SEC,
DEYE_CLOCK_REGS,
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC, # noqa: F401 - re-export for compatibility
DEYE_CRITICAL_REGS_SELF_SUSTAIN, # noqa: F401 - re-export for compatibility
DEYE_REGISTER_NAMES, # noqa: F401 - re-export for compatibility
DEYE_TOU_INACTIVE_HHMM,
DEYE_TOU_POWER_REGS,
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_clock_registers_verify_match,
_deye_reg178_verify_match,
_deye_reg178_verify_with_double_read,
_deye_registers_to_prague_datetime, # noqa: F401 - re-export for compatibility
_deye_should_skip_time_sync_after_read,
_deye_tou_power_verify_match,
_prague_minute_start_utc,
battery_watts_to_amps,
compute_pv_a_reg340_max_solar_w,
current_slot_hhmm,
deye_reg_triggers_self_sustain_after_verify_exhaust, # noqa: F401 - re-export
next_slot_hhmm,
watts_to_amps,
)
from services.control.inverter import read_deye_registers_live, write_inverter_setpoints
from services.control.models import ControlSetpoints, InverterConfig, OperatingModeInfo
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.outputs import (
_current_limit_for_charger,
send_loxone_setpoints,
write_ev_setpoints,
write_heat_pump_setpoint,
)
from services.control.orchestrator import export_setpoints
from services.control.repository import (
_fetch_max_charge_power_w,
_fetch_operating_mode,
_fetch_plan_row_for_slot_offset,
_get_current_soc,
_load_inverter_config,
)
from services.control.setpoints import (
_DictRecord,
_apply_price_failsafe_guard,
_build_setpoints,
_clamp_deye_tou_soc_pct,
_deye_passive_tou_battery_soc_pct,
_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,
get_deye_mode,
)
from services.control.verify import (
_deye_expected_clock_triplet_for_verify,
_modbus_cmd_register,
_switch_to_self_sustain,
_verify_deye_clock_written_bundle,
verify_modbus_commands,
)

View File

@@ -0,0 +1,361 @@
"""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,
_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 = _deye_reg143_export_w(no_export, inv.max_export_power_w)
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),
)
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
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} 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)"
)
registers.extend(
[
(108, "", charge_a),
(109, "", discharge_a),
(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 = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL"
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,243 @@
"""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_inverter_registers(
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
) -> 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)
""",
site_id,
inverter_asset_id,
)
data = raw if isinstance(raw, dict) else json.loads(raw)
return {int(k): int(v) for k, v in data.items()}
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, _ignored_name, val in registers:
register_name = DEYE_REGISTER_NAMES.get(reg, 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
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'.
"""
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)
all_ok = True
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]
for attempt in range(max_retries):
try:
await client.write_registers(start_reg, values, unit)
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,
)
logger.info(
"[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)",
cid,
cmd["asset_code"],
int(cmd["register"]),
val,
start_reg,
attempt + 1,
)
break
except Exception as 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,
e,
)
await asyncio.sleep(retry_delay)
await client.force_disconnect()
else:
for cmd in run:
await db.execute(
"""
UPDATE ems.modbus_command
SET status='failed', error_msg=$1,
attempt_count=attempt_count+1
WHERE id=$2
""",
str(e),
int(cmd["id"]),
)
logger.error(
"Modbus batch 0x%04X count=%s all %s attempts failed: %s",
start_reg,
len(values),
max_retries,
e,
)
all_ok = False
return all_ok

View File

@@ -0,0 +1,74 @@
"""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
#: Součet nominal_power_wp controllable PV na invertoru; 0 = EMS nezapisuje reg 340.
pv_a_cap_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
#: 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,156 @@
"""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_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
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,
reg340_pv_a_control_enabled=reg340_en,
)
sp_next = _build_setpoints(
mode,
pi_next,
pv_a_cap_w=cap_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_price_failsafe_guard(site_id, mode, pi_now, sp_now)
if sp_next is not None:
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,149 @@
"""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__)
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:
rows = await db.fetch(
"""
SELECT ec.code, se.host, se.port, se.unit_id
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"
for row in rows:
code = row["code"]
current_a = _current_limit_for_charger(code, setpoints)
logger.info(
"EV setpoint [%s]: %sA (TODO: Modbus registers)",
code,
current_a,
)
return f"OK EV: logged {len(rows)} charger(s) (Modbus TODO)"
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,215 @@
"""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,
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),
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,332 @@
"""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__)
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 _build_setpoints(
mode: OperatingModeInfo,
pi: Any | None,
*,
pv_a_cap_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
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá.
export_ban = sell_f is not None and float(sell_f) < 0 and grid_sp >= 0
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)
pv_a_allowed = compute_pv_a_reg340_max_solar_w(int(pv_a_cap_w), forecast, curtail)
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)
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
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
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_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 _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 _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.
Export do sítě (grid_w < 0) už směr toku řeší režim / 142 / 145 — **108** jako strop zbytečně
nenulovat na 0 (viz home-01). Jediná speciální větev: import bez nabíjení → vypnout vybíjení.
"""
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,
) -> tuple[int, int]:
"""
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
PASSIVE + plán chce nabíjet (typicky z FVE, i při exportu zbytku do sítě): **108 = max_charge_a**
z invertoru — reg. 108 je strop, ne příkaz k proudu; průměrný `battery_w` ze slotu nesmí špičku FVE
stíhat do baterie omezovat. **109 = max_discharge_a** (domácnost z baterie při výpadku PV).
**CHARGE** (import ze sítě + nabíjení): 108 dál z `battery_w` (řízený importní okamžik). **SELL** beze změny.
"""
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 0, int(max_discharge_a)
if self_sustain_local_use:
return int(max_charge_a), 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,476 @@
"""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:
if 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

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,11 @@ logger = logging.getLogger(__name__)
def _db_azimuth_to_pvlib(surface_azimuth_db_deg: float) -> float:
"""DB: 0=jih, 90=západ, -90=východ → pvlib (N=0, E=90, S=180, W=270)."""
return float((surface_azimuth_db_deg + 180) % 360)
"""
EMS DB používá standardní azimut (kompasové stupně):
N=0, E=90, S=180, W=270 (stejně jako pvlib).
"""
return float(surface_azimuth_db_deg % 360)
async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:

View File

@@ -61,7 +61,7 @@ async def send_heartbeat(
status = "ok" if (not endpoint or loxone_ok) else "degraded"
await db.execute(
"SELECT ems.fn_update_heartbeat($1, $2, $3)",
"select ems.fn_update_heartbeat($1, $2, $3)",
site_id,
status,
EMS_BACKEND_VERSION,

View File

@@ -2,8 +2,10 @@
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from datetime import datetime, timezone
import asyncpg
import httpx
@@ -12,6 +14,42 @@ from app.config import get_settings
logger = logging.getLogger(__name__)
_WEBHOOK_CACHE: dict[tuple[int, str], str] = {}
_OTE_IMPORT_ALERT_CACHE: dict[tuple[str, str], float] = {}
_OTE_IMPORT_OK_CACHE: dict[str, float] = {}
async def _get_site_webhook_url(
conn: asyncpg.Connection | None,
site_id: int | None,
kind: str,
) -> str:
"""
kind: 'daily' | 'error'
Fallback: settings.discord_webhook_url
"""
settings = get_settings()
if site_id is None:
return settings.discord_webhook_url
cache_key = (int(site_id), str(kind))
cached = _WEBHOOK_CACHE.get(cache_key)
if cached is not None:
return cached
if conn is None:
return settings.discord_webhook_url
col = "discord_webhook_daily_url" if kind == "daily" else "discord_webhook_error_url"
try:
url = await conn.fetchval(
f"select {col} from ems.site where id = $1::int",
int(site_id),
)
except Exception:
logger.exception("Failed to load site webhook url site_id=%s kind=%s", site_id, kind)
url = None
final = str(url or settings.discord_webhook_url or "")
_WEBHOOK_CACHE[cache_key] = final
return final
def _discord_level_for_mode_change(activated_by: str) -> str:
if activated_by == "system:mismatch":
@@ -22,6 +60,8 @@ def _discord_level_for_mode_change(activated_by: str) -> str:
async def notify_operating_mode_changed(
conn: asyncpg.Connection | None,
site_id: int | None,
site_code: str,
previous_mode: str,
new_mode: str,
@@ -37,7 +77,33 @@ async def notify_operating_mode_changed(
f"**{previous_mode}** → **{new_mode}**\n"
f"Aktivoval: `{activated_by}`{note_line}"
)
await send_discord(msg, level=lvl)
await send_discord(conn, site_id, msg, level=lvl)
async def _auto_rolling_replan_after_self_sustain_exit(site_id: int) -> None:
"""Po návratu z SELF_SUSTAIN do AUTO přepočítat rolling plán (nové DB spojení)."""
try:
from app.deps import get_pg_pool
from services.planning_engine import run_plan_api
pool = await get_pg_pool()
except Exception as e:
logger.warning("Auto replan after SELF_SUSTAIN→AUTO: pool unavailable: %s", e)
return
try:
async with pool.acquire() as replan_conn:
await run_plan_api(
site_id,
"rolling",
replan_conn,
triggered_by="mode:self_sustain_exit",
)
except Exception as e:
logger.warning(
"Auto rolling replan after SELF_SUSTAIN→AUTO failed: %s",
e,
exc_info=True,
)
async def run_fn_set_mode_with_discord(
@@ -51,32 +117,29 @@ async def run_fn_set_mode_with_discord(
notify_level: str | None = None,
) -> str:
"""
Zavolá ems.fn_set_mode. Při skutečné změně režimu pošle Discord (pokud je webhook).
Zavolá ems.fn_set_mode_with_context. Při skutečné změně režimu pošle Discord (pokud je webhook).
Vrátí aktuální mode_code z DB po volání.
"""
prev = await conn.fetchval(
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
site_id,
)
await conn.execute(
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
raw = await conn.fetchval(
"""
select ems.fn_set_mode_with_context($1::int, $2::text, $3::text, $4::timestamptz, $5::text)
""",
site_id,
mode_code,
activated_by,
valid_until,
notes,
)
new = await conn.fetchval(
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
site_id,
)
ctx = raw if isinstance(raw, dict) else json.loads(raw)
prev = ctx.get("previous_mode")
new = ctx.get("new_mode")
if new is None:
new = mode_code
site_code = ctx.get("site_code")
if prev is not None and prev != new:
site_code = await conn.fetchval(
"SELECT code FROM ems.site WHERE id = $1", site_id
)
await notify_operating_mode_changed(
conn,
site_id,
site_code or str(site_id),
str(prev),
str(new),
@@ -84,17 +147,54 @@ async def run_fn_set_mode_with_discord(
notes,
level=notify_level,
)
prev_u = str(prev).upper()
new_u = str(new).upper()
if prev_u == "SELF_SUSTAIN" and new_u == "AUTO":
try:
asyncio.get_running_loop().create_task(
_auto_rolling_replan_after_self_sustain_exit(site_id)
)
except RuntimeError:
logger.debug("No event loop; skip auto rolling replan")
return str(new)
async def send_discord(message: str, level: str = "info") -> bool:
async def notify_plan_vs_actual_fatal(
conn: asyncpg.Connection | None,
site_id: int | None,
site_code: str,
slot_label: str,
interval_start_utc: datetime,
plan_grid_w: int,
actual_grid_w: int,
deviation_grid_w: int,
reason_code: str,
detail: str,
) -> None:
"""Discord po fatální odchylce plán vs. audit (síť) pro uzavřený 15min slot."""
utc_label = interval_start_utc.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
msg = (
f"**Fatální odchylka plán vs. realita (síť)** `{site_code}`\n"
f"Slot: **{slot_label}** (`{utc_label}`)\n"
f"**{reason_code}**: {detail}\n"
f"Plán grid: **{plan_grid_w}** W | Skutečnost: **{actual_grid_w}** W | Δ (actplan): **{deviation_grid_w}** W"
)
await send_discord(conn, site_id, msg, level="critical")
async def send_discord(
conn: asyncpg.Connection | None,
site_id: int | None,
message: str,
level: str = "info",
) -> bool:
"""
Pošle notifikaci na Discord webhook.
level: 'info', 'warning', 'error', 'critical'
Vrátí True při úspěchu.
"""
settings = get_settings()
webhook_url = settings.discord_webhook_url
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
@@ -116,7 +216,108 @@ async def send_discord(message: str, level: str = "info") -> bool:
return False
def _should_send_ote_alert(date_str: str, signature: str, *, cooldown_s: float) -> bool:
now = datetime.now(timezone.utc).timestamp()
key = (str(date_str), str(signature))
last = _OTE_IMPORT_ALERT_CACHE.get(key)
if last is not None and (now - last) < cooldown_s:
return False
_OTE_IMPORT_ALERT_CACHE[key] = now
return True
async def notify_ote_import_format_changed(
conn: asyncpg.Connection | None,
*,
report_date: str,
error_detail: str,
url: str,
) -> None:
"""
Discord alert pro situaci, kdy OTE změnilo formát chart-data a import selže na parseru v DB.
Dedup: stejný report_date + stejná chyba se pošle max 1× za cooldown.
"""
signature = (error_detail or "").strip().splitlines()[0][:160]
if not _should_send_ote_alert(report_date, signature, cooldown_s=6 * 3600):
return
detail = (error_detail or "").strip()
if len(detail) > 1600:
detail = detail[:1600] + ""
msg = (
f"**OTE import selhal pravděpodobná změna formátu dat**\n"
f"Report date: `{report_date}`\n"
f"URL: `{url}`\n"
f"Chyba: {detail}\n"
f"Doporučení: zkontrolovat `ems.fn_ote_parse_15m_price_json` (tooltipy / struktura payloadu) "
f"a upravit parser."
)
await send_discord(conn, site_id=None, message=msg, level="critical")
def _should_send_ote_ok(report_date: str, *, cooldown_s: float) -> bool:
now = datetime.now(timezone.utc).timestamp()
key = str(report_date)
last = _OTE_IMPORT_OK_CACHE.get(key)
if last is not None and (now - last) < cooldown_s:
return False
_OTE_IMPORT_OK_CACHE[key] = now
return True
async def notify_ote_import_ok_brief(
conn: asyncpg.Connection | None,
*,
report_date: str,
brief: dict,
url: str,
) -> None:
"""
Info notifikace po úspěšném importu kompletního dne OTE (stručná analýza "co čekat zítra").
Dedup: 1× za cooldown na report_date.
"""
if not _should_send_ote_ok(report_date, cooldown_s=20 * 3600):
return
def _f(x, default: float = 0.0) -> float:
try:
if x is None:
return default
return float(x)
except Exception:
return default
min_p = _f(brief.get("min_price"))
max_p = _f(brief.get("max_price"))
raw_signals = brief.get("signals") or []
signals: list[str] = []
if isinstance(raw_signals, list):
for s in raw_signals[:6]:
if not isinstance(s, dict):
continue
title = str(s.get("title") or s.get("code") or "").strip()
detail = str(s.get("detail") or "").strip()
if title and detail:
signals.append(f"{title} ({detail})")
elif title:
signals.append(title)
if not signals:
signals.append("běžný den (bez extrémů)")
msg = (
f"OTE ceny staženy `{report_date}`\n"
f"URL: `{url}`\n"
f"Min: **{min_p:.3f}** | Max: **{max_p:.3f}** Kč/kWh\n"
f"Signály: " + "; ".join(f"**{s}**" for s in signals)
)
await send_discord(conn, site_id=None, message=msg, level="info")
async def notify_modbus_mismatch(
conn: asyncpg.Connection | None,
site_id: int | None,
asset_code: str,
register: int,
register_name: str,
@@ -130,18 +331,25 @@ async def notify_modbus_mismatch(
f"Zapsáno: `{value_written}` | Přečteno: `{value_verified}`\n"
f"Pokus č. {attempt}"
)
await send_discord(msg, level="error")
await send_discord(conn, site_id, msg, level="error")
async def notify_self_sustain_activated(site_code: str, reason: str) -> None:
async def notify_self_sustain_activated(
conn: asyncpg.Connection | None,
site_id: int | None,
site_code: str,
reason: str,
) -> None:
msg = (
f"Přepnutí na **SELF_SUSTAIN** lokalita `{site_code}`\n"
f"Důvod: {reason}"
)
await send_discord(msg, level="critical")
await send_discord(conn, site_id, msg, level="critical")
async def notify_modbus_clock_verify_exhausted(
conn: asyncpg.Connection | None,
site_id: int | None,
site_code: str,
asset_code: str,
written: tuple[int, int, int],
@@ -153,10 +361,12 @@ async def notify_modbus_clock_verify_exhausted(
f"Zapsáno: `{written}` | Přečteno: `{actual}`\n"
f"Doporučení: zkontrolovat firmware/RS485; režim EMS se nemění automaticky."
)
await send_discord(msg, level="critical")
await send_discord(conn, site_id, msg, level="critical")
async def notify_daily_economics(
conn: asyncpg.Connection | None,
site_id: int | None,
site_code: str,
day: str,
import_kwh: float,
@@ -183,4 +393,4 @@ async def notify_daily_economics(
f" Plán předpokládal: {planned_balance:+.2f}"
f"(odchylka {dev_sign}{dev:.2f} Kč)"
)
await send_discord("\n".join(lines), level="info")
await send_discord(conn, site_id, "\n".join(lines), level="info")

View File

@@ -0,0 +1,119 @@
"""
Kontrola plán vs. skutečnost po uzavření 15min slotu.
Pravidla a dedup INSERT drží ems.fn_plan_actual_slot_guard_site / fn_plan_actual_slot_guard_all_active
(repeatable R__076). Python jen zavolá funkci a pošle Discord podle vrácených alertů.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import asyncpg
from zoneinfo import ZoneInfo
from app.db_json import fetch_json
from services.notification_service import notify_plan_vs_actual_fatal
logger = logging.getLogger(__name__)
_PRAGUE = ZoneInfo("Europe/Prague")
def _interval_start_utc(value: Any) -> datetime:
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
if isinstance(value, str):
s = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
raise TypeError(f"expected datetime or str for interval_start, got {type(value)!r}")
def _slot_label_prague(interval_start: datetime) -> str:
loc = interval_start.astimezone(_PRAGUE)
return loc.strftime("%Y-%m-%d %H:%M") + " Europe/Prague"
async def _dispatch_site_result(site_payload: dict[str, Any]) -> None:
if site_payload.get("error") == "unknown_site":
logger.warning("plan_actual_slot_guard: unknown site_id=%s", site_payload.get("site_id"))
return
site_code = str(site_payload.get("site_code") or site_payload.get("site_id") or "")
site_id = int(site_payload.get("site_id") or 0) or None
alerts = site_payload.get("alerts")
if not isinstance(alerts, list):
return
for alert in alerts:
if not isinstance(alert, dict):
continue
if not alert.get("notify"):
continue
interval_start = _interval_start_utc(alert["interval_start"])
reason_code = str(alert.get("reason_code") or "")
detail = str(alert.get("detail") or "")
plan_grid_w = int(alert.get("plan_grid_w") or 0)
actual_grid_w = int(alert.get("actual_grid_w") or 0)
deviation_grid_w = int(alert.get("deviation_grid_w") or 0)
slot_label = _slot_label_prague(interval_start)
await notify_plan_vs_actual_fatal(
None,
site_id,
site_code=site_code,
slot_label=slot_label,
interval_start_utc=interval_start,
plan_grid_w=plan_grid_w,
actual_grid_w=actual_grid_w,
deviation_grid_w=deviation_grid_w,
reason_code=reason_code,
detail=detail,
)
logger.warning(
"[site=%s] plan_actual fatal %s slot=%s: %s",
site_payload.get("site_id"),
reason_code,
interval_start.isoformat(),
detail,
)
async def run_plan_actual_slot_guard_for_all_active_sites(
pool: asyncpg.Pool,
*,
now: datetime | None = None,
) -> None:
"""Scheduler: jeden dotaz přes aktivní lokality (SQL dedup + klasifikace)."""
async with pool.acquire() as conn:
try:
if now is not None:
raw = await fetch_json(
conn,
"SELECT ems.fn_plan_actual_slot_guard_all_active($1::timestamptz)",
now,
)
else:
raw = await fetch_json(conn, "SELECT ems.fn_plan_actual_slot_guard_all_active()")
except Exception:
logger.exception("plan_actual_slot_guard fn_plan_actual_slot_guard_all_active failed")
return
if raw is None:
return
if not isinstance(raw, list):
logger.warning("plan_actual_slot_guard: unexpected payload type %s", type(raw))
return
for site_payload in raw:
if not isinstance(site_payload, dict):
continue
try:
await _dispatch_site_result(site_payload)
except Exception:
logger.exception(
"plan_actual_slot_guard site=%s failed",
site_payload.get("site_id"),
)

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,32 @@ from __future__ import annotations
import asyncio
import json
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
import httpx
from app.config import get_settings
from app.db_json import fetch_json
from services.notification_service import (
notify_ote_import_format_changed,
notify_ote_import_ok_brief,
)
logger = logging.getLogger(__name__)
# Běžný kalendářní den na DAM = 96 čtvrthodin; 92 při přechodu na letní čas, 100 na zimní.
OTE_TYPICAL_SLOTS = 96
OTE_FULL_DAY_SLOT_COUNTS: frozenset[int] = frozenset({92, 96, 100})
# Zpětná kompatibilita ve starších importech
OTE_EXPECTED_SLOTS = OTE_TYPICAL_SLOTS
def ote_prague_day_slots_look_complete(slot_count: int) -> bool:
"""True, pokud počet řádků odpovídá celému obchodnímu dni OTE (včetně DST)."""
return slot_count in OTE_FULL_DAY_SLOT_COUNTS
OTE_URL = (
"https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/"
"@@chart-data?report_date={date}&time_resolution=PT15M"
@@ -93,6 +110,155 @@ async def _fetch_ote_json(date_str: str) -> tuple[dict | None, str | None]:
OTE_TZ = ZoneInfo("Europe/Prague")
async def _apply_ote_json_to_db(conn, payload: dict) -> int:
"""Zapíše JSON z OTE přes ems.fn_ote_import_from_json; vrátí ROW_COUNT z funkce."""
settings = get_settings()
eur_czk = float(settings.eur_czk_rate)
n = await conn.fetchval(
"SELECT ems.fn_ote_import_from_json($1::jsonb, $2)",
json.dumps(payload),
eur_czk,
)
return int(n)
async def count_ote_slots_prague_day(conn, target_day: date) -> int:
"""Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100)."""
stats = await fetch_json(
conn,
"select ems.fn_ote_day_slot_stats_prague($1::date)",
target_day,
)
if not isinstance(stats, dict):
stats = json.loads(stats)
return int(stats.get("count") or 0)
async def import_ote_prices_for_day(
conn,
target_day: date,
) -> tuple[int, str, float, str | None]:
"""
Stáhne OTE pro jeden konkrétní report_date a uloží přes fn_ote_import_from_json.
Stejný význam návratové hodnoty jako import_ote_prices().
"""
day_str = target_day.isoformat()
payload, fetch_error = await _fetch_ote_json(day_str)
if payload is None:
return -1, day_str, 0.0, fetch_error or "fetch_failed"
try:
n = await _apply_ote_json_to_db(conn, payload)
stats_after = await fetch_json(
conn,
"select ems.fn_ote_day_slot_stats_prague($1::date)",
target_day,
)
if not isinstance(stats_after, dict):
stats_after = json.loads(stats_after)
first_price = stats_after.get("first_price")
n_imported = int(stats_after.get("count") or 0)
is_complete = bool(stats_after.get("is_complete"))
if not ote_prague_day_slots_look_complete(n_imported):
logger.warning(
"OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)",
n_imported,
day_str,
sorted(OTE_FULL_DAY_SLOT_COUNTS),
)
if is_complete:
brief = await fetch_json(
conn,
"select ems.fn_ote_day_signals_prague($1::date, $2::int)",
target_day,
14,
)
if not isinstance(brief, dict):
brief = json.loads(brief)
await notify_ote_import_ok_brief(
conn,
report_date=day_str,
brief=brief if isinstance(brief, dict) else {},
url=OTE_URL.format(date=day_str),
)
logger.info(
"OTE import OK: %s slotů (upsert) pro %s, první cena %.4f Kč/kWh",
n,
day_str,
float(first_price or 0),
)
return n, day_str, float(first_price or 0.0), None
except Exception as e:
detail = str(e).strip() or e.__class__.__name__
logger.error("OTE import DB error pro %s: %s", day_str, detail, exc_info=True)
if (
"OTE price dataLine not found" in detail
or "OTE price series:" in detail
or "cannot parse date from graph.title" in detail
):
await notify_ote_import_format_changed(
conn,
report_date=day_str,
error_detail=detail,
url=OTE_URL.format(date=day_str),
)
short = detail[:200] if len(detail) > 200 else detail
return -1, day_str, 0.0, f"db_import:{e.__class__.__name__}: {short}"
@dataclass
class OteBackfillStats:
start_date: date
end_date: date
days_checked: int = 0
days_imported: int = 0
days_skipped_complete: int = 0
days_skipped_future: int = 0
days_failed: int = 0
failures: list[tuple[str, str]] = field(default_factory=list)
async def backfill_ote_prices(
conn,
*,
start_date: date,
end_date: date,
only_missing: bool = True,
pause_between_days_s: float = 0.35,
max_failures_logged: int = 80,
) -> OteBackfillStats:
"""
Projde rozsah [start_date, end_date] (kalendář Prague) a doplní chybějící dny z OTE.
only_missing: přeskočí dny, kde už je „plný“ počet slotů (92/96/100 dle OTE).
pause_between_days_s: krátká pauza mezi HTTP požadavky (ohleduplnost k OTE).
"""
stats = OteBackfillStats(start_date=start_date, end_date=end_date)
today_prague = datetime.now(OTE_TZ).date()
d = start_date
while d <= end_date:
stats.days_checked += 1
if d > today_prague:
stats.days_skipped_future += 1
d += timedelta(days=1)
continue
slots = await count_ote_slots_prague_day(conn, d)
if only_missing and ote_prague_day_slots_look_complete(slots):
stats.days_skipped_complete += 1
d += timedelta(days=1)
continue
n, day_str, _, err = await import_ote_prices_for_day(conn, d)
if n < 0:
stats.days_failed += 1
if len(stats.failures) < max_failures_logged:
stats.failures.append((day_str, err or "unknown"))
else:
stats.days_imported += 1
if pause_between_days_s > 0:
await asyncio.sleep(pause_between_days_s)
d += timedelta(days=1)
return stats
async def import_ote_prices(
db,
site_id: int | None = None,
@@ -105,11 +271,9 @@ async def import_ote_prices(
Returns: (počet_slotů, datum_str, první_cena_kč_kwh, error_code)
(-1, datum_str, 0.0, error_code) při chybě
"""
settings = get_settings()
if site_id is not None:
row = await db.fetchrow(
"SELECT timezone FROM ems.site WHERE id = $1", site_id
"select timezone from ems.vw_site_directory where id = $1", site_id
)
if row is None:
logger.error("OTE import: site id=%s nenalezen", site_id)
@@ -149,35 +313,19 @@ async def import_ote_prices(
date_str = target_day.isoformat()
# Vše ostatní řeší PostgreSQL funkce
eur_czk = float(settings.eur_czk_rate)
try:
n = await db.fetchval(
"SELECT ems.fn_ote_import_from_json($1::jsonb, $2)",
json.dumps(payload),
eur_czk,
)
first_price = await db.fetchval(
"""
SELECT buy_raw_price_czk_kwh
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND interval_start::date = $1::date
ORDER BY interval_start
LIMIT 1
""",
n = await _apply_ote_json_to_db(db, payload)
stats_after = await fetch_json(
db,
"select ems.fn_ote_day_slot_stats_prague($1::date)",
target_day,
)
n_imported = await db.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND interval_start::date = $1::date
""",
target_day,
)
incomplete = (n_imported or 0) < 96
if not isinstance(stats_after, dict):
stats_after = json.loads(stats_after)
first_price = stats_after.get("first_price")
n_imported = int(stats_after.get("count") or 0)
is_complete = bool(stats_after.get("is_complete"))
incomplete = not ote_prague_day_slots_look_complete(n_imported or 0)
if incomplete:
now_p = datetime.now(ZoneInfo("Europe/Prague"))
tomorrow_p = (now_p + timedelta(days=1)).date()
@@ -186,14 +334,47 @@ async def import_ote_prices(
target_day == tomorrow_p
and (now_p.hour, now_p.minute) < (14, 30)
):
logger.warning("OTE: jen %s/96 slotů pro %s", n_imported, date_str)
logger.warning(
"OTE: %s slotů pro %s (plný den = jedna z %s)",
n_imported,
date_str,
sorted(OTE_FULL_DAY_SLOT_COUNTS),
)
if is_complete:
brief = await fetch_json(
db,
"select ems.fn_ote_day_signals_prague($1::date, $2::int)",
target_day,
14,
)
if not isinstance(brief, dict):
brief = json.loads(brief)
await notify_ote_import_ok_brief(
db,
report_date=date_str,
brief=brief if isinstance(brief, dict) else {},
url=OTE_URL.format(date=date_str),
)
logger.info(
"OTE import OK: %s slotů pro %s, první cena %.4f Kč/kWh",
n, date_str, float(first_price or 0),
n,
date_str,
float(first_price or 0),
)
return int(n), date_str, float(first_price or 0.0), None
except Exception as e:
detail = str(e).strip() or e.__class__.__name__
logger.error("OTE import DB error: %s", detail, exc_info=True)
if (
"OTE price dataLine not found" in detail
or "OTE price series:" in detail
or "cannot parse date from graph.title" in detail
):
await notify_ote_import_format_changed(
db,
report_date=date_str,
error_detail=detail,
url=OTE_URL.format(date=date_str),
)
short = detail[:200] if len(detail) > 200 else detail
return -1, date_str, 0.0, f"db_import:{e.__class__.__name__}: {short}"

View File

@@ -0,0 +1,718 @@
"""
Odchozí signály EMS → Loxone / HTTP (journal, retry, readback verify).
Kritické řízení výkonu (Deye, EV, TČ) zůstává v Modbus exporteru a modbus_command.
"""
from __future__ import annotations
import json
import logging
import os
import re
from datetime import datetime, timedelta, timezone
from typing import Any
import asyncpg
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
SIGNAL_EXPORT_BAN_ACTIVE = "EXPORT_BAN_ACTIVE"
# Po úspěšném verify neposílat stejnou hodnotu znovu po tuto dobu (idempotence).
_IDEMPOTENCE_TTL = timedelta(minutes=10)
# Max pokusů před abandoned (odeslání + verify dohromady řídí attempt_count).
_MAX_ATTEMPTS = 12
_VERIFY_AFTER_SEND = timedelta(seconds=1)
def _loxone_auth() -> tuple[str, str] | None:
settings = get_settings()
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
return (user, password) if user else None
def _endpoint_base_url(proto: str | None, host: str, port: int | None) -> str:
p = (proto 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 _bool_to_text(v: bool, transform_json: dict[str, Any] | None) -> str:
if transform_json and "map_bool" in transform_json:
m = transform_json["map_bool"]
if isinstance(m, dict):
return str(m.get("true" if v else "false", "1" if v else "0"))
return "1" if v else "0"
def _parse_loxone_io_value(body: str) -> float | None:
"""Z odpovědi Loxone /dev/sps/io/… vytáhni číselnou hodnotu."""
if not body:
return None
s = body.strip()
# často XML nebo prostý text s číslem
nums = re.findall(r"-?\d+(?:\.\d+)?", s)
if not nums:
return None
try:
return float(nums[-1])
except ValueError:
return None
def _http_rest_write_url(
base: str, route_config_json: dict[str, Any] | None, value_text: str
) -> tuple[str, str]:
"""Vrátí (method, url) pro http_rest zápis."""
cfg = route_config_json or {}
method = str(cfg.get("method", "GET")).upper()
path = str(cfg.get("path_template", ""))
path = path.replace("{value}", value_text).replace("{v}", value_text)
if not path.startswith("/"):
path = "/" + path
return method, f"{base.rstrip('/')}{path}"
def _http_rest_verify_url(base: str, verify_cfg: dict[str, Any] | None) -> str | None:
if not verify_cfg:
return None
path = str(verify_cfg.get("read_path", ""))
if not path:
return None
if not path.startswith("/"):
path = "/" + path
return f"{base.rstrip('/')}{path}"
def _read_json_path(data: Any, path: str | None) -> Any:
if path is None or path == "" or path == "$":
return data
if path.startswith("$."):
path = path[2:]
cur: Any = data
for part in path.split("."):
if not part:
continue
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return None
return cur
async def compute_export_ban_active(site_id: int, conn: asyncpg.Connection) -> bool:
"""
Kanonický význam EXPORT_BAN_ACTIVE (LED varianta B).
True pokud EMS uplatňuje zákaz exportu: no_export, block_export override,
režimy bez exportu (SELF_SUSTAIN, CHARGE_CHEAP, PRESERVE), nebo AUTO se záporným
výkupem při grid_setpoint_w >= 0 (soulad s _build_setpoints / export_ban), včetně
price failsafe (predikovaná cena → pasivní ochrana).
"""
mode_row = await conn.fetchrow(
"""
SELECT som.mode_code
FROM ems.site_operating_mode som
WHERE som.site_id = $1::int
""",
site_id,
)
if mode_row is None:
return False
mode_code = str(mode_row["mode_code"] or "").upper()
if mode_code == "MANUAL":
return False
if mode_code in ("SELF_SUSTAIN", "CHARGE_CHEAP", "PRESERVE"):
return True
no_export = await conn.fetchval(
"""
SELECT COALESCE(sgc.no_export, false)
FROM ems.site_grid_connection sgc
WHERE sgc.site_id = $1::int
""",
site_id,
)
if bool(no_export):
return True
ov = await conn.fetchval(
"""
SELECT 1
FROM ems.site_override o
WHERE o.site_id = $1::int
AND o.override_type = 'block_export'
AND o.valid_from <= now()
AND (o.valid_to IS NULL OR o.valid_to > now())
LIMIT 1
""",
site_id,
)
if ov is not None:
return True
if mode_code != "AUTO":
return False
raw = await conn.fetchval(
"""
SELECT ems.fn_planning_interval_at_offset($1::int, 0)
""",
site_id,
)
if raw is None:
return False
pi = raw if isinstance(raw, dict) else json.loads(raw)
if not pi:
return False
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:
return False
try:
sell_f = float(sell_raw)
except (TypeError, ValueError):
return False
return sell_f < 0 and grid_sp >= 0
async def _should_skip_enqueue(
conn: asyncpg.Connection,
site_id: int,
signal_code: str,
destination_type: str,
destination_key: str,
desired_text: str,
) -> bool:
row = await conn.fetchrow(
"""
SELECT last_sent_value_text, last_verified_value_text, last_verified_at
FROM ems.signal_state
WHERE site_id = $1
AND signal_code = $2
AND destination_type = $3
AND destination_key = $4
""",
site_id,
signal_code,
destination_type,
destination_key,
)
if row is None:
return False
if row["last_sent_value_text"] != desired_text:
return False
if row["last_verified_value_text"] != desired_text:
return False
lv = row["last_verified_at"]
if lv is None:
return False
if lv.tzinfo is None:
lv = lv.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) - lv < _IDEMPOTENCE_TTL
async def enqueue_site_signals(site_id: int, conn: asyncpg.Connection) -> None:
"""Zařadí odchozí řádky pro všechny aktivní routy daného site (po výpočtu signálů)."""
export_ban = await compute_export_ban_active(site_id, conn)
desired = {SIGNAL_EXPORT_BAN_ACTIVE: export_ban}
routes = await conn.fetch(
"""
SELECT r.id, r.site_id, r.destination_type, r.endpoint_id, r.signal_code,
r.destination_key, r.transform_json, r.verify_readback, r.verify_config_json,
r.route_config_json, r.enabled
FROM ems.signal_route r
WHERE r.site_id = $1::int AND r.enabled = true
""",
site_id,
)
for r in routes:
sig = str(r["signal_code"])
if sig not in desired:
continue
dest_type = str(r["destination_type"])
dest_key = str(r["destination_key"])
tf = r["transform_json"]
tfd = tf if isinstance(tf, dict) else (json.loads(tf) if tf else None)
val_bool = bool(desired[sig])
value_text = _bool_to_text(val_bool, tfd)
if await _should_skip_enqueue(
conn, site_id, sig, dest_type, dest_key, value_text
):
continue
await conn.execute(
"""
INSERT INTO ems.signal_state (
site_id, signal_code, destination_type, destination_key,
last_desired_value_text, updated_at
)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (site_id, signal_code, destination_type, destination_key)
DO UPDATE SET
last_desired_value_text = EXCLUDED.last_desired_value_text,
updated_at = now()
""",
site_id,
sig,
dest_type,
dest_key,
value_text,
)
await conn.execute(
"""
INSERT INTO ems.signal_outbound_journal (
route_id, site_id, signal_code, value_text, value_num, status,
attempt_count, next_attempt_at
)
VALUES ($1, $2, $3, $4, $5, 'queued', 0, now())
""",
int(r["id"]),
site_id,
sig,
value_text,
1.0 if val_bool else 0.0,
)
async def process_signal_outbound_send(
conn: asyncpg.Connection, *, limit: int = 30
) -> int:
"""Odešle až `limit` řádků ve stavu queued. Vrátí počet zpracovaných."""
rows = await conn.fetch(
"""
SELECT j.id, j.route_id, j.site_id, j.signal_code, j.value_text, j.attempt_count
FROM ems.signal_outbound_journal j
WHERE j.status = 'queued'
AND j.next_attempt_at <= now()
ORDER BY j.id
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
limit,
)
n = 0
for j in rows:
jid = int(j["id"])
route = await conn.fetchrow(
"""
SELECT r.*, e.host, e.port, e.protocol, e.endpoint_type
FROM ems.signal_route r
JOIN ems.site_endpoint e ON e.id = r.endpoint_id
WHERE r.id = $1::int AND r.enabled = true
""",
int(j["route_id"]),
)
if route is None:
await conn.execute(
"""
UPDATE ems.signal_outbound_journal
SET status = 'abandoned', last_error = 'route missing or disabled'
WHERE id = $1::bigint
""",
jid,
)
n += 1
continue
dest_type = str(route["destination_type"])
base = _endpoint_base_url(
route.get("protocol"), str(route["host"]), route.get("port")
)
auth = _loxone_auth()
url: str
method = "GET"
cfg = route["route_config_json"]
rcfg = cfg if isinstance(cfg, dict) else (json.loads(cfg) if cfg else None)
try:
if dest_type == "loxone_vi":
io_name = str(route["destination_key"])
val = str(j["value_text"])
url = f"{base}/dev/sps/io/{io_name}/{val}"
elif dest_type == "http_rest":
method, url = _http_rest_write_url(base, rcfg, str(j["value_text"]))
else:
await conn.execute(
"""
UPDATE ems.signal_outbound_journal
SET status = 'abandoned',
last_error = $2,
attempt_count = attempt_count + 1
WHERE id = $1::bigint
""",
jid,
f"unknown destination_type: {dest_type}",
)
n += 1
continue
except Exception as e:
ac = int(j["attempt_count"]) + 1
delay = min(300, 2 ** min(ac, 8))
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
await conn.execute(
"""
UPDATE ems.signal_outbound_journal
SET status = $2::text,
last_error = $3::text,
attempt_count = $4::int,
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END
WHERE id = $1::bigint
""",
jid,
st,
str(e)[:500],
ac,
delay,
)
n += 1
continue
t0 = datetime.now(timezone.utc)
try:
async with httpx.AsyncClient(timeout=8.0) as client:
if method == "GET":
resp = await client.get(url, auth=auth)
elif method == "POST":
body = None
if rcfg and isinstance(rcfg.get("json_body"), dict):
body = json.dumps(rcfg["json_body"])
resp = await client.post(
url,
auth=auth,
content=body,
headers={"Content-Type": "application/json"} if body else None,
)
else:
raise ValueError(f"unsupported HTTP method {method}")
resp.raise_for_status()
body_txt = (resp.text or "")[:2000]
except Exception as e:
ac = int(j["attempt_count"]) + 1
delay = min(300, 2 ** min(ac, 8))
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
await conn.execute(
"""
UPDATE ems.signal_outbound_journal
SET status = $2::text,
attempt_count = $3::int,
last_error = $4::text,
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END,
http_method = $6::text,
request_url = $7::text
WHERE id = $1::bigint
""",
jid,
st,
ac,
str(e)[:500],
delay,
method,
url,
)
n += 1
continue
dt_ms = int(
(datetime.now(timezone.utc) - t0).total_seconds() * 1000
)
vr = bool(route["verify_readback"])
await conn.execute(
"""
UPDATE ems.signal_outbound_journal
SET status = $2::text,
http_method = $3::text,
request_url = $4::text,
http_status = $5::int,
latency_ms = $6::int,
response_body_trunc = $7::text,
sent_at = now(),
last_error = NULL,
verified_at = CASE WHEN $2::text = 'verified' THEN now() ELSE NULL END
WHERE id = $1::bigint
""",
jid,
"verified" if not vr else "sent",
method,
url,
200,
dt_ms,
(body_txt or "")[:500],
)
if not vr:
await conn.execute(
"""
INSERT INTO ems.signal_state (
site_id, signal_code, destination_type, destination_key,
last_sent_value_text, last_verified_value_text, last_sent_at, last_verified_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $5, now(), now(), now())
ON CONFLICT (site_id, signal_code, destination_type, destination_key)
DO UPDATE SET
last_sent_value_text = EXCLUDED.last_sent_value_text,
last_verified_value_text = EXCLUDED.last_verified_value_text,
last_sent_at = now(),
last_verified_at = now(),
updated_at = now()
""",
int(j["site_id"]),
str(j["signal_code"]),
dest_type,
str(route["destination_key"]),
str(j["value_text"]),
)
n += 1
return n
async def process_signal_outbound_verify(
conn: asyncpg.Connection, *, limit: int = 30
) -> int:
"""Ověří řádky ve stavu sent (readback). Vrátí počet zpracovaných."""
rows = await conn.fetch(
"""
SELECT j.id, j.route_id, j.site_id, j.signal_code, j.value_text
FROM ems.signal_outbound_journal j
WHERE j.status = 'sent'
AND j.verified_at IS NULL
AND j.sent_at IS NOT NULL
AND j.sent_at <= now() - $1::interval
ORDER BY j.id
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
_VERIFY_AFTER_SEND,
limit,
)
n = 0
for j in rows:
jid = int(j["id"])
route = await conn.fetchrow(
"""
SELECT r.*, e.host, e.port, e.protocol
FROM ems.signal_route r
JOIN ems.site_endpoint e ON e.id = r.endpoint_id
WHERE r.id = $1::int AND r.enabled = true
""",
int(j["route_id"]),
)
if route is None:
await conn.execute(
"""
UPDATE ems.signal_outbound_journal
SET status = 'abandoned', last_error = 'route missing', verified_at = now()
WHERE id = $1::bigint
""",
jid,
)
n += 1
continue
dest_type = str(route["destination_type"])
base = _endpoint_base_url(
route.get("protocol"), str(route["host"]), route.get("port")
)
auth = _loxone_auth()
vcfg_raw = route["verify_config_json"]
vcfg = (
vcfg_raw
if isinstance(vcfg_raw, dict)
else (json.loads(vcfg_raw) if vcfg_raw else {})
)
read_url: str | None = None
expected = str(j["value_text"])
try:
if dest_type == "loxone_vi":
io_read = vcfg.get("loxone_io_name") if vcfg else None
if not io_read:
io_read = str(route["destination_key"]) + "_FB"
read_url = f"{base}/dev/sps/io/{io_read}"
elif dest_type == "http_rest":
read_url = _http_rest_verify_url(base, vcfg)
else:
read_url = None
if not read_url:
raise ValueError("verify_config missing read URL")
async with httpx.AsyncClient(timeout=8.0) as client:
rresp = await client.get(read_url, auth=auth)
rresp.raise_for_status()
body = rresp.text or ""
ok = False
read_val: str | None = None
if dest_type == "loxone_vi":
fv = _parse_loxone_io_value(body)
if fv is not None:
read_val = str(int(round(fv)))
try:
ev = float(expected)
except ValueError:
ev = None
if ev is not None and abs(fv - ev) < 0.51:
ok = True
elif dest_type == "http_rest":
ct = (rresp.headers.get("content-type") or "").lower()
if "json" in ct:
data = rresp.json()
jpath = vcfg.get("json_path") or vcfg.get("json_key")
if isinstance(jpath, str) and jpath:
got = _read_json_path(data, jpath)
else:
got = data
if isinstance(got, bool):
read_val = "1" if got else "0"
elif isinstance(got, (int, float)):
read_val = "1" if float(got) >= 0.5 else "0"
elif got is not None:
read_val = str(got).strip().lower()
else:
read_val = None
exp_l = expected.strip().lower()
if read_val is not None:
if read_val in ("true", "on", "1"):
read_norm = "1"
elif read_val in ("false", "off", "0"):
read_norm = "0"
else:
read_norm = read_val
exp_norm = (
"1"
if exp_l in ("1", "true", "on")
else "0"
if exp_l in ("0", "false", "off")
else expected
)
ok = read_norm == exp_norm
else:
fv = _parse_loxone_io_value(body)
if fv is not None:
read_val = str(int(round(fv)))
try:
ev = float(expected)
except ValueError:
ev = None
ok = ev is not None and abs(fv - ev) < 0.51
if ok:
await conn.execute(
"""
UPDATE ems.signal_outbound_journal
SET status = 'verified', verified_at = now(), last_error = NULL
WHERE id = $1::bigint
""",
jid,
)
await conn.execute(
"""
INSERT INTO ems.signal_state (
site_id, signal_code, destination_type, destination_key,
last_sent_value_text, last_verified_value_text, last_sent_at, last_verified_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $5,
(SELECT sent_at FROM ems.signal_outbound_journal WHERE id = $6::bigint),
now(), now())
ON CONFLICT (site_id, signal_code, destination_type, destination_key)
DO UPDATE SET
last_sent_value_text = EXCLUDED.last_sent_value_text,
last_verified_value_text = EXCLUDED.last_verified_value_text,
last_sent_at = EXCLUDED.last_sent_at,
last_verified_at = now(),
updated_at = now()
""",
int(j["site_id"]),
str(j["signal_code"]),
dest_type,
str(route["destination_key"]),
str(j["value_text"]),
jid,
)
else:
ac_row = await conn.fetchrow(
"SELECT attempt_count FROM ems.signal_outbound_journal WHERE id = $1",
jid,
)
ac = int(ac_row["attempt_count"] or 0) + 1
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
delay = min(300, 2 ** min(ac, 8))
await conn.execute(
"""
UPDATE ems.signal_outbound_journal
SET status = $2::text,
attempt_count = $3::int,
last_error = $4::text,
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END,
sent_at = CASE WHEN $2::text = 'queued' THEN NULL ELSE sent_at END,
verified_at = CASE WHEN $2::text != 'queued' THEN now() ELSE NULL END
WHERE id = $1::bigint
""",
jid,
st,
ac,
f"verify mismatch read={read_val!r} expected={expected!r}"[:500],
delay,
)
except Exception as e:
ac_row = await conn.fetchrow(
"SELECT attempt_count FROM ems.signal_outbound_journal WHERE id = $1",
jid,
)
ac = int(ac_row["attempt_count"] or 0) + 1
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
delay = min(300, 2 ** min(ac, 8))
await conn.execute(
"""
UPDATE ems.signal_outbound_journal
SET status = $2::text,
attempt_count = $3::int,
last_error = $4::text,
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END,
sent_at = CASE WHEN $2::text = 'queued' THEN NULL ELSE sent_at END
WHERE id = $1::bigint
""",
jid,
st,
ac,
str(e)[:500],
delay,
)
n += 1
return n
async def run_signal_outbound_send_for_active_sites(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn:
try:
await process_signal_outbound_send(conn, limit=80)
except Exception:
logger.exception("signal_outbound_send failed")
async def run_signal_outbound_verify_for_active_sites(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn:
try:
await process_signal_outbound_verify(conn, limit=80)
except Exception:
logger.exception("signal_outbound_verify failed")

View File

@@ -22,8 +22,17 @@ DEYE_REG_BATTERY_POWER_FLOW = 590
DEYE_REG_GRID_TOTAL_POWER = 625
DEYE_REG_GEN_PORT_POWER = 667
DEYE_REG_LOAD_TOTAL_POWER = 653
DEYE_REG_GRID_IMPORT_TOTAL_LO = 522
DEYE_REG_GRID_IMPORT_TOTAL_HI = 523
DEYE_REG_GRID_EXPORT_TOTAL_LO = 524
DEYE_REG_GRID_EXPORT_TOTAL_HI = 525
DEYE_REG_PV1_POWER = 672
DEYE_REG_PV2_POWER = 673
# Solar sell (0 = přebytek řiditelné FVE nesmí do sítě) a GEN/MI cut-off (reg178 bits01 == 3 → cut-off ON).
# Pozn.: v některých manuálech/UI se uvádí "register 179" (1-based), ale Modbus adresa je 178 (0-based).
# Viz modbus-registers.md.
DEYE_REG_SOLAR_SELL = 145
DEYE_REG_CONTROL_BOARD_SPECIAL1 = 178
def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
@@ -34,16 +43,24 @@ def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
return max(0, int(pv1_w)) + max(0, int(pv2_w)) + max(0, int(gen_port_w))
def _export_limit_flags_from_deye_regs(reg145: int | None, reg179: int | None) -> tuple[bool | None, int | None]:
"""Odvoď is_export_limited / pv_derating_flags z přečtených holding registrů (NULL = neznámé)."""
if reg145 is None and reg179 is None:
return None, None
flags = 0
if reg145 is not None and int(reg145) == 0:
flags |= 1
if reg179 is not None and (int(reg179) & 3) == 3:
flags |= 2
return (flags != 0), flags
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
SELECT ai.id, ai.code, se.host, se.port, se.unit_id
FROM ems.asset_inverter ai
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
WHERE ai.site_id = $1
AND ai.active = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
select inverter_id as id, code, host, port, unit_id
from ems.vw_asset_inverter_modbus_poll
where site_id = $1
""",
site_id,
)
@@ -63,34 +80,24 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER)
load_power = await mb.read_register_signed(DEYE_REG_LOAD_TOTAL_POWER)
pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER)
pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER)
gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER)
grid_energy_regs = await mb.read_holding_registers(
DEYE_REG_GRID_IMPORT_TOTAL_LO, 4
)
reg145 = await mb.read_register(DEYE_REG_SOLAR_SELL)
reg179 = await mb.read_register(DEYE_REG_CONTROL_BOARD_SPECIAL1)
pv_power_w = aggregate_pv_production_w(pv1_power, pv2_power, gen_port_power)
grid_import_total_wh = (grid_energy_regs[1] << 16 | grid_energy_regs[0]) * 100
grid_export_total_wh = (grid_energy_regs[3] << 16 | grid_energy_regs[2]) * 100
is_export_limited, pv_derating_flags = _export_limit_flags_from_deye_regs(reg145, reg179)
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
await db.execute(
"""
INSERT INTO ems.telemetry_inverter (
site_id, inverter_id, measured_at,
pv_power_w, pv1_power_w, pv2_power_w, gen_port_power_w,
battery_soc_percent, battery_power_w,
batt_charge_today_wh, batt_discharge_today_wh,
grid_power_w, load_power_w,
run_state
)
VALUES (
$1, $2, $3,
$4, $5, $6, $7,
$8, $9,
$10, $11,
$12, $13,
$14
)
ON CONFLICT (inverter_id, measured_at) DO NOTHING
""",
"select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int, $17::boolean, $18::int)",
site_id,
inv_id,
measured_at,
@@ -104,7 +111,11 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
batt_discharge_today,
grid_power,
load_power,
grid_import_total_wh,
grid_export_total_wh,
run_state,
is_export_limited,
pv_derating_flags,
)
inv_temp: float | None = None
await manager.broadcast_telemetry(
@@ -119,6 +130,8 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
"load_power_w": load_power,
"gen_port_power_w": gen_port_power,
"inverter_temp_c": inv_temp,
"is_export_limited": is_export_limited,
"pv_derating_flags": pv_derating_flags,
}
)
except Exception as e:
@@ -128,12 +141,9 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
SELECT ec.id, ec.code, se.host, se.port, se.unit_id
FROM ems.asset_ev_charger ec
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
WHERE ec.site_id = $1
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
select charger_id as id, code, host, port, unit_id
from ems.vw_asset_ev_charger_modbus_poll
where site_id = $1
""",
site_id,
)
@@ -143,117 +153,52 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
code = row["code"]
charger_id = row["id"]
logger.info("TODO: EV charger Modbus registry pending | %s", code)
# Placeholder až do mapování Modbus: status zůstává available (bez falešných přechodů).
current_status = "available"
previous_status = await db.fetchval(
"""
SELECT status
FROM ems.telemetry_ev_charger
WHERE charger_id = $1 AND connector_id = $2
ORDER BY measured_at DESC
LIMIT 1
select status
from ems.telemetry_ev_charger
where charger_id = $1 and connector_id = $2
order by measured_at desc
limit 1
""",
charger_id,
connector_id,
)
await db.execute(
"""
INSERT INTO ems.telemetry_ev_charger (
site_id, charger_id, measured_at, connector_id,
status, power_w, energy_kwh
)
VALUES ($1, $2, $3, $4, $5, 0, 0)
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
""",
"select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8)",
site_id,
charger_id,
measured_at,
connector_id,
current_status,
0,
0.0,
)
if previous_status is not None:
await db.fetchval(
"select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
site_id,
charger_id,
str(previous_status),
current_status,
measured_at,
)
if previous_status == "available" and current_status != "available":
vehicle_id = await db.fetchval(
"""
SELECT av.id
FROM ems.asset_vehicle av
WHERE av.site_id = $1
AND av.default_charger_id = $2
AND av.active = true
ORDER BY av.id
LIMIT 1
""",
site_id,
charger_id,
)
await db.execute(
"SELECT ems.fn_update_ev_arrival_stats($1, $2, $3, $4)",
site_id,
charger_id,
vehicle_id,
measured_at,
)
logger.info("EV arrival detected on charger %s", code)
await db.execute(
"""
INSERT INTO ems.ev_session (
site_id, charger_id, vehicle_id, session_start,
target_soc_pct, target_deadline
)
SELECT
ac.site_id,
ac.id,
av.id,
now(),
av.default_target_soc_pct,
CASE
WHEN av.default_deadline_hour IS NOT NULL THEN
(
(timezone('Europe/Prague', now()))::date + interval '1 day'
+ make_interval(hours => av.default_deadline_hour)
)::timestamp AT TIME ZONE 'Europe/Prague'
END
FROM ems.asset_ev_charger ac
LEFT JOIN LATERAL (
SELECT v.id, v.default_target_soc_pct, v.default_deadline_hour
FROM ems.asset_vehicle v
WHERE v.default_charger_id = ac.id
AND v.site_id = ac.site_id
AND v.active = true
ORDER BY v.id
LIMIT 1
) av ON true
WHERE ac.id = $1 AND ac.site_id = $2
ON CONFLICT (charger_id) WHERE session_end IS NULL DO NOTHING
""",
charger_id,
site_id,
)
if previous_status != "available" and current_status == "available":
await db.execute(
"""
UPDATE ems.ev_session
SET session_end = now()
WHERE charger_id = $1 AND session_end IS NULL
""",
charger_id,
)
elif previous_status != "available" and current_status == "available":
logger.info("EV departure detected on charger %s", code)
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
SELECT hp.id, 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 se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
select heat_pump_id as id, code, host, port, unit_id
from ems.vw_asset_heat_pump_modbus_poll
where site_id = $1
""",
site_id,
)
@@ -262,18 +207,15 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
code = row["code"]
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
await db.execute(
"""
INSERT INTO ems.telemetry_heat_pump (
site_id, heat_pump_id, measured_at,
power_w, outdoor_temp_c, water_outlet_temp_c, tuv_tank_temp_c,
operating_mode
)
VALUES ($1, $2, $3, 0, 10.0, 45.0, 55.0, 'standby')
ON CONFLICT (heat_pump_id, measured_at) DO NOTHING
""",
"select ems.fn_telemetry_heat_pump_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8, $7::float8, $8::text)",
site_id,
row["id"],
measured_at,
0,
10.0,
45.0,
55.0,
"standby",
)
@@ -284,7 +226,9 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
"""
loop = asyncio.get_running_loop()
start = loop.time()
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
sites = await conn.fetch(
"select id from ems.vw_site_directory where active = true"
)
for site in sites:
sid = site["id"]
try:

View File

@@ -0,0 +1,67 @@
"""PASSIVE + plán chce nabíjet: 108 = plný strop z DB, 109 = max (PV špička + domácnost)."""
from __future__ import annotations
import unittest
from services.control.setpoints import deye_battery_charge_discharge_amps
class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
def test_passive_charge_while_exporting_grid_negative(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=-2000,
max_charge_a=100,
max_discharge_a=100,
)
self.assertEqual(ch, 100)
self.assertEqual(dis, 100)
def test_charge_mode_still_scales_108_from_battery_w(self) -> None:
"""CHARGE (síť + baterie): 108 podle plánu, ne vždy plný strop."""
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_passive_export_without_battery_charge_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=0,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=100,
)
self.assertEqual(ch, 100)
self.assertEqual(dis, 100)
def test_sell_unchanged(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.assertEqual(ch, 0)
self.assertEqual(dis, 80)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,118 @@
"""Deye reg 340 (max solar power) z plánu a capu z DB."""
from __future__ import annotations
import unittest
from services.control.exporter_monolith import (
OperatingModeInfo,
_DictRecord,
_build_setpoints,
compute_pv_a_reg340_max_solar_w,
deye_reg_triggers_self_sustain_after_verify_exhaust,
)
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 _pi_base(**kwargs: object) -> _DictRecord:
d: dict[str, object] = {
"grid_setpoint_w": 0,
"battery_setpoint_w": 0,
"battery_soc_target_pct": None,
"heat_pump_enabled": False,
"effective_sell_price": 1.0,
"pv_a_forecast_solver_w": 8000,
"pv_a_curtailed_w": 0,
}
d.update(kwargs)
return _DictRecord(d)
class ComputePvAReg340Tests(unittest.TestCase):
def test_full_cap_when_no_curtail(self) -> None:
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 8000, 0), 10_000)
def test_curtailed_value(self) -> None:
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 8000, 2000), 6000)
def test_clamped_to_cap_when_forecast_high(self) -> None:
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 12_000, 0), 10_000)
def test_curtail_floor_zero(self) -> None:
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 1000, 5000), 0)
class BuildSetpointsReg340Tests(unittest.TestCase):
def test_with_cap_sets_pv_a_allowed(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(pv_a_forecast_solver_w=8000, pv_a_curtailed_w=2000),
pv_a_cap_w=10_000,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertEqual(sp.pv_a_allowed_w, 6000)
def test_skipped_when_cap_zero(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(),
pv_a_cap_w=0,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertIsNone(sp.pv_a_allowed_w)
def test_self_sustain_no_pv_a_allowed(self) -> None:
mode = OperatingModeInfo(
mode_code="SELF_SUSTAIN",
battery_mode="x",
grid_mode="x",
ev_enabled=False,
heat_pump_enabled_def=False,
loxone_mode_value=0,
)
sp = _build_setpoints(mode, None, pv_a_cap_w=10_000)
assert sp is not None
self.assertIsNone(sp.pv_a_allowed_w)
def test_neg_buy_and_sell_with_pv_b_forces_pv_a_off(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(
effective_buy_price=-3.0,
effective_sell_price=-2.0,
pv_b_forecast_solver_w=5000,
pv_a_forecast_solver_w=0,
pv_a_curtailed_w=0,
),
pv_a_cap_w=3333,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertEqual(sp.pv_a_allowed_w, 0)
def test_skipped_when_reg340_control_disabled(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(pv_a_forecast_solver_w=8000, pv_a_curtailed_w=2000),
pv_a_cap_w=10_000,
reg340_pv_a_control_enabled=False,
)
assert sp is not None
self.assertIsNone(sp.pv_a_allowed_w)
class Reg340VerifyPolicyTests(unittest.TestCase):
def test_reg340_not_critical_for_self_sustain(self) -> None:
self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(340))

View File

@@ -3,13 +3,19 @@
from __future__ import annotations
import unittest
from dataclasses import replace
from services.control_exporter import (
from services.control.exporter_monolith import (
ControlSetpoints,
InverterConfig,
_deye_reg178_verify_with_double_read,
_deye_tou_params,
_deye_tou_power_verify_match,
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:
@@ -33,15 +39,39 @@ def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterC
)
def _inv_350a() -> InverterConfig:
"""350 A × 51.2 V = 17920 W — typický firmware clamp pro TOU power."""
return replace(_inv(), max_charge_a=350, max_discharge_a=350)
class ModbusVerifyPolicyTests(unittest.TestCase):
def test_tou_power_accepts_firmware_max_w_clamp(self) -> None:
inv = _inv_350a()
self.assertTrue(_deye_tou_power_verify_match(7752, 17920, inv))
self.assertTrue(_deye_tou_power_verify_match(16728, 17920, inv))
def test_reg178_double_read_recovers_from_glitch(self) -> None:
ok, v = _deye_reg178_verify_with_double_read(48, 12014, 48)
self.assertTrue(ok)
self.assertEqual(v, 48)
def test_reg178_not_critical_for_self_sustain(self) -> None:
self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(178))
def test_reg108_critical_for_self_sustain(self) -> None:
self.assertTrue(deye_reg_triggers_self_sustain_after_verify_exhaust(108))
class DeyeTouParamsTests(unittest.TestCase):
def test_sell_uses_reserve_soc(self) -> None:
"""SELL: záporný grid_setpoint_w i battery_w → selling first; TOU SOC = reserve."""
sp = ControlSetpoints(
battery_w=0,
grid_export_limit=5000,
battery_w=-8000,
grid_export_limit=8000,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=-500,
grid_setpoint_w=-8000,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=50,
@@ -51,6 +81,90 @@ class DeyeTouParamsTests(unittest.TestCase):
self.assertFalse(g)
self.assertEqual(s, 20)
def test_explicit_deye_physical_mode_from_plan_overrides_detection(self) -> None:
sp = ControlSetpoints(
battery_w=-8000,
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="PASSIVE",
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
def test_export_ban_does_not_change_deye_mode(self) -> None:
sp = 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=50,
export_ban=True,
)
self.assertEqual(get_deye_mode(sp), "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(
battery_w=-733,
grid_export_limit=1294,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=-1294,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=50,
)
self.assertEqual(get_deye_mode(sp), "SELL")
def test_large_export_small_battery_is_sell(self) -> None:
"""I když |bat| < |grid| — stále SELL při obou záporných setpointech."""
sp = ControlSetpoints(
battery_w=-1500,
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,
)
self.assertEqual(get_deye_mode(sp), "SELL")
def test_passive_uses_min_soc(self) -> None:
sp = ControlSetpoints(
battery_w=0,
@@ -62,12 +176,51 @@ class DeyeTouParamsTests(unittest.TestCase):
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
effective_sell_price_czk_kwh=None,
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
self.assertFalse(g)
self.assertEqual(s, 12)
def test_passive_negative_sell_tou_stays_min_soc(self) -> None:
"""PASSIVE: záporná vykupní nenastavuje TOU na 100 — zůstává min_soc (145/export_ban řeší síť)."""
sp = ControlSetpoints(
battery_w=-400,
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=14,
effective_sell_price_czk_kwh=-0.25,
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
self.assertFalse(g)
self.assertEqual(s, 12)
def test_passive_planned_pv_charge_tou_stays_min_soc(self) -> None:
"""PASSIVE s kladným battery_w bez grid importu: CHARGE to není — TOU je stále min_soc."""
sp = ControlSetpoints(
battery_w=800,
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=60,
effective_sell_price_czk_kwh=1.0,
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
self.assertFalse(g)
self.assertEqual(s, 12)
def test_charge_unchanged_grid_charge(self) -> None:
sp = ControlSetpoints(
battery_w=5000,
@@ -85,6 +238,74 @@ class DeyeTouParamsTests(unittest.TestCase):
self.assertTrue(g)
self.assertEqual(s, 95)
def test_charge_target_soc_respects_max_soc_100(self) -> None:
sp = ControlSetpoints(
battery_w=5000,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=5000,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=80,
)
self.assertEqual(get_deye_mode(sp), "CHARGE")
inv = replace(_inv(), max_soc_percent=100)
_p, s, g = _deye_tou_params(sp, inv)
self.assertTrue(g)
self.assertEqual(s, 100)
def test_charge_any_positive_pair_without_w_threshold(self) -> None:
sp = ControlSetpoints(
battery_w=50,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=80,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=50,
)
self.assertEqual(get_deye_mode(sp), "CHARGE")
def test_zero_export_amps_fve_overflow(self) -> None:
c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90)
self.assertEqual(c, 100)
self.assertEqual(d, 90)
def test_zero_export_amps_import_hold_discharge(self) -> None:
c, d = _deye_zero_export_amps_for_passive(500, 0, 100, 90)
self.assertEqual(c, 100)
self.assertEqual(d, 0)
def test_zero_export_amps_full_when_discharge_with_export(self) -> None:
"""Export + plánované vybíjení → plné proudy (SELL řeší režim 142 zvlášť)."""
c, d = _deye_zero_export_amps_for_passive(-2000, -500, 100, 90)
self.assertEqual(c, 100)
self.assertEqual(d, 90)
def test_self_sustain_tou_stays_min_soc_even_if_sell_negative(self) -> None:
"""SELF_SUSTAIN: nízké TOU (min_soc), ne 100 % z negativní vykupní — LP se nepoužívá."""
sp = 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,
effective_sell_price_czk_kwh=-0.48,
self_sustain_local_use=True,
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
self.assertFalse(g)
self.assertEqual(s, 12)
def test_lock_battery_uses_min_soc(self) -> None:
sp = ControlSetpoints(
battery_w=0,

View File

@@ -0,0 +1,28 @@
"""Smoke: fetch_json toleruje dict z asyncpg (bez reálné DB)."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock
from app.db_json import fetch_json
def test_fetch_json_returns_dict() -> None:
async def _run() -> None:
conn = AsyncMock()
conn.fetchval = AsyncMock(return_value={"a": 1})
out = await fetch_json(conn, "select ems.fn_x()", 1)
assert out == {"a": 1}
asyncio.run(_run())
def test_fetch_json_parses_str() -> None:
async def _run() -> None:
conn = AsyncMock()
conn.fetchval = AsyncMock(return_value='{"b": 2}')
out = await fetch_json(conn, "select 1")
assert out == {"b": 2}
asyncio.run(_run())

View File

@@ -6,7 +6,7 @@ import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from services.control_exporter import (
from services.control.exporter_monolith import (
DEYE_CLOCK_DRIFT_OK_SEC,
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC,

View File

@@ -0,0 +1,24 @@
from services.control.exporter_monolith import (
REG178_PASSIVE,
_drop_registers_matching_last_verified,
)
def test_drop_registers_skips_reg178_when_mask_matches():
# last_verified contains extra bits; reg178 is a bit field and exporter uses RMW.
# We want to skip if the relevant bits match (bits45 and, if present, bits01).
last_verified = {178: 12030} # real-world example from home-01 (bits4-5 still == 0b11)
expected_rmw = (int(last_verified[178]) & ~0x0030) | int(REG178_PASSIVE)
registers = [(178, "control_board_special_1", int(expected_rmw))]
out, skipped = _drop_registers_matching_last_verified(registers, last_verified)
assert out == []
assert skipped == [178]
def test_drop_registers_keeps_reg178_when_mask_differs():
registers = [(178, "grid_peak_shaving_switch", REG178_PASSIVE)]
last_verified = {178: 32} # SELL mask 0b10
out, skipped = _drop_registers_matching_last_verified(registers, last_verified)
assert out == registers
assert skipped == []

View File

@@ -0,0 +1,53 @@
import asyncio
class _FakeAcquire:
def __init__(self, conn):
self._conn = conn
async def __aenter__(self):
return self._conn
async def __aexit__(self, exc_type, exc, tb):
return False
class _FakePool:
def __init__(self, conn):
self._conn = conn
def acquire(self):
return _FakeAcquire(self._conn)
def test_status_full_parses_heartbeat_and_inverter_timestamps(monkeypatch):
# Regression: /status/full used to pass string timestamps into _age_seconds()
# which expects datetime and accesses .tzinfo.
from app.routers import full_status
async def _fake_fetch_json(conn, sql, *args):
assert "fn_site_full_status" in sql
return {
"site": {"code": "X"},
"operating_mode": {"mode_code": "AUTO"},
"heartbeat": {"last_seen": "2026-04-20T08:56:36.186Z"},
"inverter_latest": {"measured_at": "2026-04-20T08:56:31.165Z"},
"ev_chargers": [],
"heat_pump_latest": None,
"battery_limits": {},
"active_plan": None,
"planning_intervals": [],
"tomorrow_price_slot_count": 96,
}
monkeypatch.setattr(full_status, "fetch_json", _fake_fetch_json)
out = asyncio.run(
full_status.get_site_status_full(site_id=2, pool=_FakePool(conn=object()))
)
assert isinstance(out, dict)
assert out["heartbeat"]["last_seen"] is not None
assert out["heartbeat"]["age_seconds"] is not None
assert out["telemetry"]["inverter"]["measured_at"] is not None
assert out["telemetry"]["inverter"]["age_seconds"] is not None

View File

@@ -0,0 +1,194 @@
"""Pre-selection nabíjecích slotů (anti-micro-cycling) referenční Python.
Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu
pro rychlé unit testy bez PostgreSQL.
"""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from services.planning_engine import INTERVAL_H, PlanningSlot
def _select_charge_slots(
slots: list[PlanningSlot],
battery: SimpleNamespace,
current_soc_wh: float,
) -> set[int]:
"""Kopie logiky z ems.fn_load_planning_slots_full (charge mask)."""
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
if charge_buf <= 0:
return set(range(len(slots)))
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
if energy_to_fill <= 0:
return set()
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
per_slot_full_wh = max_p_w * eta * INTERVAL_H
selected: set[int] = set()
for t, s in enumerate(slots):
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
if pv_surplus_w > 0:
selected.add(t)
grid_target_wh = energy_to_fill * charge_buf
if grid_target_wh <= 0 or per_slot_full_wh <= 0:
return selected
grid_candidates = [
(t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected
]
grid_candidates.sort(key=lambda x: x[1])
cumulative = 0.0
for t, _price in grid_candidates:
if cumulative >= grid_target_wh:
break
selected.add(t)
cumulative += per_slot_full_wh
return selected
def _slot(*, buy: float, sell: float = 1.0, pv: int = 0, load: int = 2_000) -> PlanningSlot:
return PlanningSlot(
interval_start=datetime(2026, 4, 19, 12, 0, tzinfo=timezone.utc),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=0,
pv_b_forecast_w=pv,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
)
def _battery(
*,
charge_buf: float = 1.3,
uc_wh: float = 64_000.0,
soc_max_pct: float = 95.0,
max_charge_w: float = 18_000.0,
charge_eff: float = 0.95,
) -> SimpleNamespace:
return SimpleNamespace(
usable_capacity_wh=uc_wh,
soc_max_wh=soc_max_pct / 100.0 * uc_wh,
max_charge_power_w=max_charge_w,
charge_efficiency=charge_eff,
charge_slot_buffer=charge_buf,
)
class SelectChargeSlotsTests(unittest.TestCase):
def test_buffer_zero_returns_all_slots(self) -> None:
slots = [_slot(buy=3.0) for _ in range(4)]
battery = _battery(charge_buf=0.0)
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
self.assertEqual(out, set(range(4)))
def test_pv_surplus_slot_always_selected_regardless_of_buy_price(self) -> None:
"""Slot s PV-surplus má být in, i když má nejvyšší buy_price."""
slots = [
_slot(buy=0.5, pv=0, load=2_000), # bez PV, levný grid
_slot(buy=9.9, pv=8_000, load=2_000), # velký PV-surplus, drahý grid
]
battery = _battery()
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
self.assertIn(1, out)
def test_grid_filler_prefers_lowest_buy_price(self) -> None:
"""Mezi neprvními sloty se vybere ten s nejnižší buy_price."""
slots = [
_slot(buy=3.0, pv=0, load=2_000, sell=0.1),
_slot(buy=0.4, pv=0, load=2_000, sell=0.3),
_slot(buy=1.2, pv=0, load=2_000, sell=0.2),
]
battery = _battery(
charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
)
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
self.assertIn(1, out)
def test_does_not_exclude_slot_just_because_pv_below_load(self) -> None:
"""Regrese: dřívější logika vyřazovala sloty bez PV-surplus úplně."""
slots = [
_slot(buy=0.4, pv=3_320, load=3_747),
_slot(buy=0.42, pv=2_116, load=3_747),
_slot(buy=0.44, pv=1_649, load=3_747),
_slot(buy=0.47, pv=1_276, load=3_747),
_slot(buy=1.13, pv=1_286, load=523),
_slot(buy=1.60, pv=1_020, load=523),
]
battery = _battery()
out = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh)
for idx in (0, 1, 2, 3):
self.assertIn(
idx,
out,
msg=(
f"Slot {idx} (levný grid nákup ~0.4 Kč) musí být povolen pro "
"nabíjení i bez PV-surplus, jinak optimizer skončí s dražším "
"nákupem v pozdějších slotech (nelogická ekonomika)."
),
)
def test_long_horizon_pv_surplus_does_not_exhaust_grid_budget(self) -> None:
"""Regrese: v 96h horizontu nesmí PV-surplus sloty „vyžrat“ grid rozpočet.
V dřívější verzi se kumulativní PV budget odečítal od `charge_buf × headroom`,
takže v dlouhém horizontu s mnoha PV sloty zbylo 0 na grid filler a levné
grid sloty se nepovolily. Tento test simuluje realistický 96h profil.
"""
# 40 levných grid-only slotů (simulace noční / „inter-peak“ hodiny).
cheap_grid = [_slot(buy=0.4 + 0.01 * i, pv=0, load=2_000) for i in range(40)]
# 100 PV-surplus slotů s velkou výrobou (přes den, přes víc dní).
pv_days = [_slot(buy=1.5, pv=10_000, load=2_000) for _ in range(100)]
slots = cheap_grid + pv_days
battery = _battery(
charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
)
out = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh)
grid_selected = sum(1 for i in range(len(cheap_grid)) if i in out)
self.assertGreaterEqual(
grid_selected,
5,
msg=(
"V dlouhém horizontu s mnoha PV-surplus sloty musí zůstat dostatek "
"grid slotů povolených pro nabíjení z levného importu."
),
)
def test_energy_budget_is_charge_buf_times_headroom(self) -> None:
"""Součet uvolněné energie se pohybuje okolo charge_buf × (soc_max current_soc)."""
slots = [_slot(buy=float(i + 1), pv=0, load=2_000) for i in range(40)]
battery = _battery(
charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
)
current_soc_wh = 0.2 * battery.usable_capacity_wh
target = battery.charge_slot_buffer * (battery.soc_max_wh - current_soc_wh)
per_slot_wh = (
battery.max_charge_power_w * battery.charge_efficiency * INTERVAL_H
)
out = _select_charge_slots(slots, battery, current_soc_wh=current_soc_wh)
slots_picked = len(out)
self.assertLessEqual((slots_picked - 1) * per_slot_wh, target)
self.assertGreaterEqual(slots_picked * per_slot_wh, target)
def test_returns_empty_when_battery_is_full(self) -> None:
slots = [_slot(buy=0.1) for _ in range(3)]
battery = _battery()
out = _select_charge_slots(
slots, battery, current_soc_wh=battery.soc_max_wh + 1.0
)
self.assertEqual(out, set())
if __name__ == "__main__":
unittest.main()

View File

@@ -3,12 +3,16 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from services.planning_engine import (
PlanningSlot,
_dynamic_arb_floor_wh_series,
_prewindow_deferral_slots,
_slots_until_buy_le_threshold,
_slots_until_sell_lt,
_soc_panel_min_wh_series,
solve_dispatch,
)
@@ -40,6 +44,7 @@ def _battery(
min_pct: float = 10.0,
arb_pct: float = 20.0,
max_pct: float = 95.0,
terminal_soc_value_factor: float = 0.9,
) -> SimpleNamespace:
uc = uc_wh
min_wh = min_pct / 100.0 * uc
@@ -55,9 +60,114 @@ def _battery(
degradation_cost_czk_kwh=0.15,
max_charge_power_w=10_000,
max_discharge_power_w=10_000,
planner_terminal_soc_value_factor=terminal_soc_value_factor,
)
class SlotsUntilSellNegativeTests(unittest.TestCase):
def test_slots_until_first_negative_sell(self) -> None:
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(10):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=1.0,
sell_price=2.0 if i < 4 else -0.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
)
dist = _slots_until_sell_lt(slots, 0.0)
self.assertEqual(dist[0], 4)
self.assertEqual(dist[3], 1)
self.assertEqual(dist[4], 0)
def test_prewindow_deferral_prefers_sell_anchor(self) -> None:
"""Když existuje záporný prodej, kotva je vzdálenost k němu, ne k extrémnímu buy."""
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(8):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=-50.0,
sell_price=1.0 if i < 2 else -0.1,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
)
adv = _prewindow_deferral_slots(slots, -2.0)
self.assertEqual(adv[0], 2)
def test_prewindow_deferral_falls_back_to_buy_when_no_negative_sell(self) -> None:
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(10):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=3.0 if i < 7 else -10.0,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
)
adv = _prewindow_deferral_slots(slots, -2.0)
self.assertEqual(adv[0], 7)
class SlotsUntilBuyExtremeTests(unittest.TestCase):
def test_slots_until_first_extreme(self) -> None:
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(10):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=1.0,
sell_price=1.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
)
slots[-1] = PlanningSlot(
interval_start=slots[-1].interval_start,
buy_price=-10.0,
sell_price=0.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
dist = _slots_until_buy_le_threshold(slots, -2.0)
self.assertEqual(dist[0], 9)
self.assertEqual(dist[8], 1)
self.assertEqual(dist[9], 0)
def test_prewindow_clamps_relaxed_floor_until_close(self) -> None:
sm = [5000.0] * 10
dist = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] # obecná kotva (sell nebo buy)
panel = _soc_panel_min_wh_series(sm, dist, 10_000.0, 20_000.0, 2)
self.assertEqual(panel[0], 20_000.0)
self.assertEqual(panel[6], 20_000.0)
self.assertEqual(panel[7], 5000.0)
self.assertEqual(panel[9], 5000.0)
class DynamicArbFloorTests(unittest.TestCase):
def test_more_pv_ahead_lowers_floor(self) -> None:
"""Čím víc FVE ve lookahead, tím nižší ekonomická podlaha v prvním slotu."""
@@ -95,6 +205,96 @@ def replace_slot(
class PlanningDispatchMilpTests(unittest.TestCase):
def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None:
"""
Když:
- aktuální slot má sell < 0 (export je náklad),
- v horizontu existuje budoucí buy < 0,
- a zároveň existuje PV B (necurtailable) někde v horizontu,
solver preferuje curtail PV A (ca) místo placeného exportu ge.
"""
slots = [
_slot(load=0, buy=3.0, sell=-0.1, pv_a=5000, pv_b=0),
_slot(load=0, buy=-10.0, sell=1.0, pv_a=0, pv_b=5000),
]
battery = _battery(uc_wh=50_000.0)
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.50 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
# Slot 0: PV A se má raději uříznout než vyvážet za zápornou cenu.
self.assertEqual(int(results[0].pv_a_curtailed_w), 5000)
self.assertGreaterEqual(int(results[0].grid_setpoint_w), 0)
def test_pv_surplus_export_uses_hard_export_cap(self) -> None:
slots = [
_slot(load=0, buy=3.0, sell=2.5, pv_a=20_000, pv_b=0),
]
battery = _battery()
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = battery.soc_max_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].export_mode, "PV_SURPLUS")
self.assertEqual(results[0].export_limit_w, 13_500)
self.assertGreater(results[0].pv_a_curtailed_w, 0)
def test_two_tier_soc_solves_optimal(self) -> None:
slots = [_slot()]
battery = _battery()
@@ -117,7 +317,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.15 * battery.usable_capacity_wh
results, ms = solve_dispatch(
results, ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -128,7 +328,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
price_failsafe_active=False,
)
self.assertGreaterEqual(ms, 0)
self.assertEqual(len(results), 1)
@@ -158,7 +357,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.12 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -169,7 +368,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
price_failsafe_active=False,
)
self.assertEqual(len(results), 2)
@@ -195,7 +393,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -206,12 +404,11 @@ class PlanningDispatchMilpTests(unittest.TestCase):
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
price_failsafe_active=False,
)
self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
def test_export_implies_end_soc_at_least_reserve(self) -> None:
"""Při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB)."""
"""Bez arbitrážní relaxace: při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB)."""
slots = [
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
@@ -236,7 +433,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.22 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -247,7 +444,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
price_failsafe_active=False,
)
reserve_pct = 20.0
for r in results:
@@ -258,6 +454,555 @@ class PlanningDispatchMilpTests(unittest.TestCase):
msg="export slot must end at or above reserve SoC",
)
def test_export_before_extreme_negative_buy_can_end_below_reserve(self) -> None:
"""
Při relaxovaném soc_min (záporný buy v lookahead) smí významný export skončit u planner floor,
ne u provozní rezervy — jinak nejde ráno vypustit do sítě a nachystat kapacitu před levným nákupem.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
s0 = PlanningSlot(
interval_start=base,
buy_price=2.5,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
s1 = PlanningSlot(
interval_start=base + timedelta(minutes=15),
buy_price=-12.0,
sell_price=-0.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
slots = [s0, s1]
battery = _battery(uc_wh=10_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
battery.planner_discharge_floor_percent = 5.0
battery.max_charge_power_w = 50_000
battery.max_discharge_power_w = 50_000
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.88 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
if results[0].grid_setpoint_w < 0:
self.assertLess(
results[0].battery_soc_target,
19.0,
msg="with relaxed soc_min, first-slot export should be able to finish below reserve %",
)
def test_negative_sell_forbids_battery_export_arbitrage(self) -> None:
"""
Pokud sell < 0, solver nesmí vybíjet baterii do sítě pro arbitráž (dump musí proběhnout předtím).
V okně sell<0 smí export vzniknout jen z přebytku FVE; zde ale FVE=0, takže očekáváme grid_setpoint>=0.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
s0 = PlanningSlot(
interval_start=base,
buy_price=2.0,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
s1 = PlanningSlot(
interval_start=base + timedelta(minutes=15),
buy_price=2.0,
sell_price=-0.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
s2 = PlanningSlot(
interval_start=base + timedelta(minutes=30),
buy_price=-15.0,
sell_price=-1.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
slots = [s0, s1, s2]
battery = _battery(uc_wh=10_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
battery.planner_discharge_floor_percent = 5.0
battery.max_charge_power_w = 50_000
battery.max_discharge_power_w = 50_000
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 3)
# V sell<0 slotu bez FVE a bez zátěže nesmí být export (to by muselo být z baterie).
self.assertGreaterEqual(results[1].grid_setpoint_w, 0)
# A zároveň nesmí být baterie ve výboji (dump musí proběhnout předtím).
self.assertGreaterEqual(results[1].battery_setpoint_w, 0)
def test_anchor_hits_floor_before_first_negative_sell(self) -> None:
"""
Pokud se v horizontu objeví první sell<0 a současně existuje planner floor (relaxace),
solver má skončit už v předchozím slotu u planner floor (cca 5 %), ne na ~15 %.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
# Slot 0-1: sell >= 0; slot 2: první sell < 0; slot 3: extrémně záporný buy (motivace k bufferu).
slots = [
PlanningSlot(
interval_start=base,
buy_price=3.0,
sell_price=1.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=15),
buy_price=3.0,
sell_price=0.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=30),
buy_price=3.0,
sell_price=-0.2,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=45),
buy_price=-20.0,
sell_price=-1.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
]
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
battery.planner_discharge_floor_percent = 5.0
battery.max_charge_power_w = 50_000
battery.max_discharge_power_w = 50_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
# Slot index 1 je poslední před prvním sell<0 (index 2).
self.assertLessEqual(
results[1].battery_soc_target,
6.0,
msg="anchor should drive SoC close to planner floor before first negative sell",
)
def test_anchor_uses_planner_floor_even_without_extreme_buy(self) -> None:
"""
Regrese: pokud v horizontu není buy <= threshold (soc_min_series by se nerelaxovala),
kotva před sell<0 má stejně mířit na planner floor (5 %), ne na base min SoC.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=3.0,
sell_price=1.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=15),
buy_price=3.0,
sell_price=0.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=30),
buy_price=3.0,
sell_price=-0.2,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
]
battery = _battery(uc_wh=20_000.0, min_pct=12.0, arb_pct=20.0)
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
battery.planner_discharge_floor_percent = 5.0
battery.max_charge_power_w = 50_000
battery.max_discharge_power_w = 50_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
# Slot index 1 je poslední před prvním sell<0 (index 2).
self.assertLessEqual(results[1].battery_soc_target, 6.0)
def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None:
"""
Soft cap: solver může nominálně překročit breaker, ale jen pokud se to vyplatí.
Při běžné (nezáporné) nákupní ceně by měl držet import <= breaker.
"""
slots = [_slot(load=3700, buy=0.4, sell=-0.3, pv_a=0, pv_b=1500)]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_charge_power_w = 18_000
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.55 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 1)
self.assertLessEqual(
results[0].grid_setpoint_w,
grid.max_import_power_w,
msg="soft cap: for normal buy price, planned grid import should not exceed breaker",
)
def test_grid_import_soft_cap_allows_overdraw_when_extremely_negative(self) -> None:
"""
Regrese: při extrémně záporné nákupní ceně může solver překročit breaker (za cenu penalizace),
aby stihl krátké okno nabíjení. Překročení nesmí být 'zadarmo' (kontrolujeme alespoň, že existuje).
"""
# Dvouslotový scénář: v 1. slotu extrémně záporná cena, ve 2. slotu drahá.
# Terminal SoC kotva pak nepenalizuje držení energie (průměrná buy je ~0) a solver má motivaci
# v 1. slotu nabít na max, i kdyby to znamenalo malé překročení breakeru.
s0 = _slot(load=0, buy=-20.0, sell=-0.3, pv_a=0, pv_b=0)
s1 = replace_slot(s0, load=0)
s1 = PlanningSlot(
interval_start=s0.interval_start + timedelta(minutes=15),
buy_price=20.0,
sell_price=-0.3,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
slots = [s0, s1]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_charge_power_w = 18_000
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.55 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
self.assertGreater(
results[0].grid_setpoint_w,
grid.max_import_power_w,
msg="with very negative buy price, solver may choose to exceed breaker (soft cap)",
)
def test_block_export_on_negative_sell_no_grid_export_pv_surplus(self) -> None:
"""site_grid_connection.block_export_on_negative_sell → ge=0 při sell<0."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc),
buy_price=5.25,
sell_price=-0.5,
pv_a_forecast_w=7000,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=False,
)
]
battery = _battery(uc_wh=20_000.0, arb_pct=15.0, max_pct=95.0)
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=True,
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.34 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 1)
self.assertGreaterEqual(results[0].grid_setpoint_w, 0, "no grid export")
self.assertGreater(results[0].battery_setpoint_w, 0, "surplus PV should charge")
class TerminalSocShadowTests(unittest.TestCase):
"""Terminal SoC shadow price v objective drží konec horizontu nad holým minimem."""
def test_terminal_soc_shadow_price_prevents_drain(self) -> None:
base = datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc)
slots = []
for i in range(3):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=0.6,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=600,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
)
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=45),
buy_price=2.0,
sell_price=14.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=600,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
)
battery = _battery(uc_wh=12_000.0, min_pct=12.0, arb_pct=20.0)
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 4)
# Bez shadow price by solver mohl končit u min SoC; kotva drží znatelnou rezervu.
self.assertGreaterEqual(
results[-1].battery_soc_target,
15.0,
msg="terminal SoC shadow price should keep end-of-horizon SoC above bare minimum",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,140 @@
"""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,
) -> 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,
)
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"], [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,28 @@
"""Logika is_export_limited / pv_derating_flags z Deye reg 145 a 179."""
from services.telemetry_collector import _export_limit_flags_from_deye_regs
def test_both_none_unknown() -> None:
lim, flags = _export_limit_flags_from_deye_regs(None, None)
assert lim is None and flags is None
def test_solar_sell_disabled() -> None:
lim, flags = _export_limit_flags_from_deye_regs(0, None)
assert lim is True and flags == 1
def test_solar_sell_enabled_only() -> None:
lim, flags = _export_limit_flags_from_deye_regs(1, None)
assert lim is False and flags == 0
def test_gen_mi_cutoff_bits() -> None:
lim, flags = _export_limit_flags_from_deye_regs(None, 3)
assert lim is True and flags == 2
def test_combined_flags() -> None:
lim, flags = _export_limit_flags_from_deye_regs(0, 3)
assert lim is True and flags == 3

View File

@@ -27,7 +27,8 @@ SELECT add_continuous_aggregate_policy(
schedule_interval => INTERVAL '15 minutes'
);
COMMENT ON MATERIALIZED VIEW ems.telemetry_inverter_15m IS
-- Timescale CA není v katalogu „materialized view“ stejně jako V011 u telemetry_inverter_hourly.
COMMENT ON VIEW ems.telemetry_inverter_15m IS
'Čtvrthodinové agregáty telemetrie střídače. TimescaleDB continuous aggregate.
Refresh každých 15 minut. Dashboard přehled (sloty 15 min).
View vw_telemetry_15m_7d je v repeatable R__vw_telemetry_15m_7d.sql.';

View File

@@ -0,0 +1,38 @@
-- =============================================================
-- V040 Energy Wh columns
-- Přidává kumulativní čítače grid energie do telemetrie
-- a per-slot Wh sloupce do audit_interval pro přesné
-- import/export měření (Deye reg 522-525 + per-minute fallback).
-- =============================================================
-- 1. telemetry_inverter: kumulativní Deye lifetime čítače
ALTER TABLE ems.telemetry_inverter
ADD COLUMN IF NOT EXISTS grid_import_total_wh BIGINT,
ADD COLUMN IF NOT EXISTS grid_export_total_wh BIGINT;
COMMENT ON COLUMN ems.telemetry_inverter.grid_import_total_wh IS
'Kumulativní import ze sítě (Wh) z Deye reg 522+523 (32-bit × 0.1 kWh). Lifetime čítač, monotónně rostoucí.';
COMMENT ON COLUMN ems.telemetry_inverter.grid_export_total_wh IS
'Kumulativní export do sítě (Wh) z Deye reg 524+525 (32-bit × 0.1 kWh). Lifetime čítač, monotónně rostoucí.';
-- 2. audit_interval: 6 základních energetických veličin (Wh za 15min slot)
ALTER TABLE ems.audit_interval
ADD COLUMN IF NOT EXISTS actual_grid_import_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS actual_grid_export_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS actual_batt_charge_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS actual_batt_discharge_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS actual_pv_production_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS actual_load_consumption_wh NUMERIC(10,1);
COMMENT ON COLUMN ems.audit_interval.actual_grid_import_wh IS
'Import ze sítě za 15min slot (Wh). Primárně z delta Deye total counterů (reg 522+523), fallback per-minutový split z grid_power_w.';
COMMENT ON COLUMN ems.audit_interval.actual_grid_export_wh IS
'Export do sítě za 15min slot (Wh). Primárně z delta Deye total counterů (reg 524+525), fallback per-minutový split z grid_power_w.';
COMMENT ON COLUMN ems.audit_interval.actual_batt_charge_wh IS
'Nabití baterie za 15min slot (Wh). Per-minutový split z battery_power_w (záporné = nabíjení).';
COMMENT ON COLUMN ems.audit_interval.actual_batt_discharge_wh IS
'Vybití baterie za 15min slot (Wh). Per-minutový split z battery_power_w (kladné = vybíjení).';
COMMENT ON COLUMN ems.audit_interval.actual_pv_production_wh IS
'FVE výroba za 15min slot (Wh). SUM(pv_power_w) / 60 z minutových vzorků.';
COMMENT ON COLUMN ems.audit_interval.actual_load_consumption_wh IS
'Celková spotřeba za 15min slot (Wh). SUM(load_power_w) / 60 z minutových vzorků.';

View File

@@ -0,0 +1,13 @@
-- =============================================================
-- V041 audit_day_lock: směrové cashflow sloupce
-- Snapshot pro zamknuté dny rozšířen o cashflow podle směru energie.
-- =============================================================
ALTER TABLE ems.audit_day_lock
ADD COLUMN IF NOT EXISTS grid_import_cashflow_czk NUMERIC(12,2),
ADD COLUMN IF NOT EXISTS grid_export_revenue_czk NUMERIC(12,2);
COMMENT ON COLUMN ems.audit_day_lock.grid_import_cashflow_czk IS
'Snapshot: celková cena za import ze sítě v Kč (může být záporná při záporné spotové ceně).';
COMMENT ON COLUMN ems.audit_day_lock.grid_export_revenue_czk IS
'Snapshot: celkový příjem z exportu do sítě v Kč.';

View File

@@ -0,0 +1,28 @@
-- =============================================================
-- V042 Energy flow decomposition (7 directional flows per 15min)
-- Plní se v ems.fn_fill_audit_interval (prioritní alokace per minuta).
-- =============================================================
ALTER TABLE ems.audit_interval
ADD COLUMN IF NOT EXISTS flow_pv_to_load_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS flow_pv_to_batt_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS flow_pv_to_grid_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS flow_batt_to_load_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS flow_batt_to_grid_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS flow_grid_to_load_wh NUMERIC(10,1),
ADD COLUMN IF NOT EXISTS flow_grid_to_batt_wh NUMERIC(10,1);
COMMENT ON COLUMN ems.audit_interval.flow_pv_to_load_wh IS
'Modelovaný tok FVE → spotřeba (Wh/slot). Per-minutová prioritní alokace: PV nejdřív load.';
COMMENT ON COLUMN ems.audit_interval.flow_pv_to_batt_wh IS
'Modelovaný tok FVE → nabíjení baterie (Wh/slot).';
COMMENT ON COLUMN ems.audit_interval.flow_pv_to_grid_wh IS
'Modelovaný tok FVE → export do sítě (Wh/slot).';
COMMENT ON COLUMN ems.audit_interval.flow_batt_to_load_wh IS
'Modelovaný tok vybití baterie → spotřeba (Wh/slot).';
COMMENT ON COLUMN ems.audit_interval.flow_batt_to_grid_wh IS
'Modelovaný tok vybití baterie → export (Wh/slot).';
COMMENT ON COLUMN ems.audit_interval.flow_grid_to_load_wh IS
'Modelovaný tok import ze sítě → spotřeba (Wh/slot).';
COMMENT ON COLUMN ems.audit_interval.flow_grid_to_batt_wh IS
'Modelovaný tok import ze sítě → nabíjení baterie (Wh/slot).';

View File

@@ -0,0 +1,388 @@
-- =============================================================
-- V043__site_25a_fixed_buy_seed.sql
-- Sloupce pro fixní nákupní energii (NT + příplatek VT) a seed lokality site-25a.
--
-- Jedna verzovaná migrace: čtyři FVE pole (různá orientace), žádný mezikrok pv-a/pv-b.
--
-- Obnova / přepnutí checksum na DB, kde už běžela starší varianta V043 nebo V044:
-- DELETE FROM flyway_schema_history WHERE version IN ('043', '044');
-- Potom: flyway migrate
-- (Sloupce buy_fixed_* zůstanou díky ADD COLUMN IF NOT EXISTS; DO blok smaže legacy pv-a/pv-b
-- a doplní pv-str-*/pv-mi-* pokud chybí.)
-- =============================================================
-- Fixní složka nákupu bez DPH (k distribuci / poplatkům / marži / DPH dle fn_effective_buy_price)
ALTER TABLE ems.site_market_config
ADD COLUMN IF NOT EXISTS buy_fixed_energy_nt_czk_kwh NUMERIC(10,6),
ADD COLUMN IF NOT EXISTS buy_fixed_vt_surcharge_czk_kwh NUMERIC(10,6) NOT NULL DEFAULT 0;
COMMENT ON COLUMN ems.site_market_config.buy_fixed_energy_nt_czk_kwh IS
'Při purchase_pricing_mode = fixed: základní nákupní cena energie Kč/kWh bez DPH v NT hodinách. VT = tato hodnota + buy_fixed_vt_surcharge_czk_kwh podle HDO oken.';
COMMENT ON COLUMN ems.site_market_config.buy_fixed_vt_surcharge_czk_kwh IS
'Při purchase_pricing_mode = fixed: příplatek Kč/kWh bez DPH k NT ceně ve VT oknech dle hdo_code_id.';
-- =============================================================
-- Seed lokality (idempotentní DO blok)
-- Viz docs/new-site-setup-template.md ev-charger-1 pro planner/telemetrii.
-- FVE: čtyři záznamy asset_pv_array (forecast service běží per pole; planner sčítá controllable / !controllable).
-- =============================================================
DO $$
DECLARE
v_site_code TEXT := 'BA81';
v_host_modbus TEXT := '109.164.83.155';
v_port_modbus INT := 502;
v_host_loxone TEXT := '109.164.83.155';
v_port_loxone INT := 8080;
v_site_id INT;
v_ep_deye INT;
v_ep_ev INT;
v_ep_loxone INT;
v_inv_main INT;
v_inv_gen INT;
v_hdo_id INT;
v_ch_id INT;
BEGIN
SELECT hc.id INTO v_hdo_id
FROM ems.hdo_code hc
WHERE hc.distributor = 'EGD' AND hc.code = 'custom_fve_home01'
ORDER BY hc.valid_from DESC NULLS LAST
LIMIT 1;
INSERT INTO ems.site (code, name, timezone, latitude, longitude, active, notes)
VALUES (
v_site_code,
'Lokalita 25A / 17 kW příkon',
'Europe/Prague',
49.24368977130069,
17.425553019721196,
true,
'Připojení 3×25 A → import max 17 kW, export max 16 kW. '
'Při omezení exportu do DS nastavit v Deye SmartLoad: „MI export to Grid cutoff“ = enable; '
'po uvolnění exportu znovu disable. Veřejná IP tunelovaná z EMS serveru.'
)
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_modbus, v_port_modbus, 'modbus_tcp', 1, true,
'Deye 12kW LV Modbus TCP (Waveshare).'
)
RETURNING id INTO v_ep_deye;
END IF;
SELECT se.id INTO v_ep_ev
FROM ems.site_endpoint se
WHERE se.site_id = v_site_id
AND se.endpoint_type = 'modbus_tcp'
AND se.notes ILIKE '%Teltonika%'
ORDER BY se.id
LIMIT 1;
IF v_ep_ev 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_modbus, v_port_modbus, 'modbus_tcp', 2, true,
'Teltonika TeltoCharge 22kW stejná IP jako Deye, unit_id 2 (upřesni dle zapojení).'
)
RETURNING id INTO v_ep_ev;
END IF;
SELECT se.id INTO v_ep_loxone
FROM ems.site_endpoint se
WHERE se.site_id = v_site_id
AND se.endpoint_type = 'loxone_http'
ORDER BY se.id
LIMIT 1;
IF v_ep_loxone IS NULL THEN
INSERT INTO ems.site_endpoint (
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
)
VALUES (
v_site_id, 'loxone_http', v_host_loxone, v_port_loxone, 'http', NULL, true,
'Loxone Miniserver (HTTP Virtual Inputs).'
)
RETURNING id INTO v_ep_loxone;
END IF;
INSERT INTO ems.site_grid_connection (
site_id, max_import_power_w, max_export_power_w, no_export, reserved_capacity_w, notes
)
VALUES (
v_site_id, 17000, 16000, false, 0,
'Max 25 A přívod → cca 17 kW import; přetok / export povolen 16 kW.'
)
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,
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,
buy_fixed_energy_nt_czk_kwh, buy_fixed_vt_surcharge_czk_kwh
)
VALUES (
v_site_id,
'fixed', 'spot',
0, 0,
-0.020, 0,
'CZK', now(), NULL,
'Nákup fixní 3,67 Kč/kWh bez DPH (NT) + 0,52 Kč/kWh bez DPH ve VT (okna dle HDO jako home-01). '
'Prodej na spotu jako home-01. Distribuce v efektivní ceně 0 (tariff_id NULL) energie jen fix + DPH dle vat_rate výchozí.',
NULL,
v_hdo_id,
0,
0,
3.67,
0.52
);
END IF;
INSERT INTO ems.site_operating_mode (site_id, mode_code, activated_by, notes)
VALUES (
v_site_id,
'MANUAL',
'migration:V043_site_25a',
'Start MANUAL; po ověření 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,
controllable, active, notes
)
VALUES (
v_site_id,
'deye-main',
'Deye',
NULL,
v_ep_deye,
6250, 6250, 12000,
12000, 24000, 6250, 6250,
5000,
true, true,
'12kW LV hybrid. Baterie limit 0,5C ≈ 6,25 kW (280 A teoreticky vyšší plánování dle 6,25 kW). '
'GEN port max ~5 kW součet MI.'
)
RETURNING id INTO v_inv_main;
END IF;
SELECT ai.id INTO v_inv_gen
FROM ems.asset_inverter ai
WHERE ai.site_id = v_site_id AND ai.code = 'ongrid-gen'
LIMIT 1;
IF v_inv_gen IS NULL THEN
INSERT INTO ems.asset_inverter (
site_id, code, manufacturer, model, endpoint_id,
max_export_power_w, controllable, active, notes
)
VALUES (
v_site_id,
'ongrid-gen',
NULL, NULL, NULL,
5000, false, true,
'Mikroinvertory na GEN portu (2 skupiny panelů), EMS necurtailuje.'
)
RETURNING id INTO v_inv_gen;
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
)
VALUES (
v_site_id, v_inv_main, 'bat-main',
12500,
10, 15, 95,
0.95, 0.95,
0.50,
0.5, 0.5,
6250, 6250
);
END IF;
-- Odstranění starého agregovaného seedu (pv-a / pv-b), pokud na DB zůstal z dřívější verze.
DELETE FROM ems.forecast_accuracy fa
WHERE fa.pv_array_id IN (
SELECT id FROM ems.asset_pv_array
WHERE site_id = v_site_id AND code IN ('pv-a', 'pv-b')
);
DELETE FROM ems.forecast_pv_interval fpi
USING ems.asset_pv_array apa
WHERE apa.site_id = v_site_id
AND apa.code IN ('pv-a', 'pv-b')
AND fpi.pv_array_id = apa.id;
DELETE FROM ems.forecast_pv_run fpr
WHERE fpr.site_id = v_site_id
AND fpr.pv_array_id IN (
SELECT id FROM ems.asset_pv_array
WHERE site_id = v_site_id AND code IN ('pv-a', 'pv-b')
);
DELETE FROM ems.asset_pv_array
WHERE site_id = v_site_id AND code IN ('pv-a', 'pv-b');
-- String 1: 12×620 Wp @110° / 45° (Deye, řiditelné)
IF NOT EXISTS (
SELECT 1 FROM ems.asset_pv_array ap
WHERE ap.site_id = v_site_id AND ap.code = 'pv-str-1'
) THEN
INSERT INTO ems.asset_pv_array (
site_id, inverter_id, code, name,
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
controllable, telemetry_source, notes
)
VALUES (
v_site_id, v_inv_main, 'pv-str-1', 'String 1 12×620 Wp',
7440, 110, 45, 12, 1.0, true, 'pv_strings',
'Hlavní telemetrie stringů Deye (pv1+pv2); druhý string má telemetry_source NULL.'
);
END IF;
-- String 2: 8×620 Wp @200° / 10° (Deye, řiditelné)
IF NOT EXISTS (
SELECT 1 FROM ems.asset_pv_array ap
WHERE ap.site_id = v_site_id AND ap.code = 'pv-str-2'
) THEN
INSERT INTO ems.asset_pv_array (
site_id, inverter_id, code, name,
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
controllable, telemetry_source, notes
)
VALUES (
v_site_id, v_inv_main, 'pv-str-2', 'String 2 8×620 Wp',
4960, 200, 10, 8, 1.0, true, NULL,
'Vlastní predikce orientace; telemetrie sdílená se stringem 1.'
);
END IF;
-- MI 5×620 Wp @200° / 45° (GEN, neriditelné)
IF NOT EXISTS (
SELECT 1 FROM ems.asset_pv_array ap
WHERE ap.site_id = v_site_id AND ap.code = 'pv-mi-1'
) THEN
INSERT INTO ems.asset_pv_array (
site_id, inverter_id, code, name,
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
controllable, telemetry_source, notes
)
VALUES (
v_site_id, v_inv_gen, 'pv-mi-1', 'Mikroinvertory 5×620 Wp',
3100, 200, 45, 5, 1.0, false, 'gen_port',
'Souhrnná telemetrie GEN portu; druhá MI skupina má telemetry NULL.'
);
END IF;
-- MI 3×620 Wp @110° / 10° (GEN, neriditelné)
IF NOT EXISTS (
SELECT 1 FROM ems.asset_pv_array ap
WHERE ap.site_id = v_site_id AND ap.code = 'pv-mi-2'
) THEN
INSERT INTO ems.asset_pv_array (
site_id, inverter_id, code, name,
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
controllable, telemetry_source, notes
)
VALUES (
v_site_id, v_inv_gen, 'pv-mi-2', 'Mikroinvertory 3×620 Wp',
1860, 110, 10, 3, 1.0, false, NULL,
'Predikce samostatně; gen_port u pv-mi-1.'
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM ems.asset_ev_charger c
WHERE c.site_id = v_site_id AND c.code = 'ev-charger-1'
) THEN
INSERT INTO ems.asset_ev_charger (
site_id, code, manufacturer, model, endpoint_id,
max_power_w, min_power_w, phases, connector_count, schedulable, notes
)
VALUES (
v_site_id, 'ev-charger-1', 'Teltonika', 'TeltoCharge 22kW',
v_ep_ev,
22000, 1380, 3, 1, true,
'Jedna nabíječka; kód ev-charger-1 kvůli planneru / telemetrii.'
)
RETURNING id INTO v_ch_id;
ELSE
SELECT id INTO v_ch_id FROM ems.asset_ev_charger
WHERE site_id = v_site_id AND code = 'ev-charger-1'
LIMIT 1;
END IF;
INSERT INTO ems.asset_vehicle (
site_id, code, name, make, model,
battery_capacity_kwh, max_charge_power_w, default_charger_id, api_type,
default_target_soc_pct, default_deadline_hour, active
)
VALUES (
v_site_id,
'ev-default',
'EV (výchozí)',
NULL, NULL,
60.0,
11000,
v_ch_id,
'none',
80,
7,
true
)
ON CONFLICT (site_id, code) DO NOTHING;
END;
$$;

View File

@@ -0,0 +1,9 @@
-- Volitelný tvrdý strop proudu pro Modbus reg 108/109 (Deye může firmwarem oříznout pod W-odvozeným max, např. 351→350 A).
ALTER TABLE ems.asset_inverter
ADD COLUMN IF NOT EXISTS deye_register_max_charge_a INT NULL,
ADD COLUMN IF NOT EXISTS deye_register_max_discharge_a INT NULL;
COMMENT ON COLUMN ems.asset_inverter.deye_register_max_charge_a IS
'Optional cap for holding reg 108 (A); NULL = use only LEAST(W)/51.2 derived max.';
COMMENT ON COLUMN ems.asset_inverter.deye_register_max_discharge_a IS
'Optional cap for holding reg 109 (A); NULL = use only derived max.';

View File

@@ -0,0 +1,201 @@
-- =============================================================
-- V045__seed_site_kv1.sql
-- Idempotentní seed lokality KV1 (viz docs/new-site-setup-template.md).
-- 25 A přívod → import max 17 kW; přetok / export max 8 kW.
-- Nákup fixní 5,25 Kč/kWh bez DPH (jednotná sazba na místě není NT tarif; HDO NULL).
-- Prodej na spotu jako home-01 (marže sell -0,02 Kč/kWh).
-- Deye 12 kW LV, baterie 12,5 kWh, 0,5C; Waveshare 172.16.2.10. Bez Loxone.
-- Start: MANUAL (EMS nezapisuje setpointy); fyzicky Deye PASSIVE dle poznámky.
-- =============================================================
DO $$
DECLARE
v_site_code TEXT := 'KV1';
v_host_deye TEXT := '172.16.2.10';
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,
'KV1',
'Europe/Prague',
49.23988687187006,
17.47170575741328,
true,
'Připojení max 25 A → import cca 17 kW; povolený přetok / export 8 kW. '
'Waveshare RS485→TCP ' || v_host_deye || '. Loxone na instalaci není. '
'Provozní start: EMS režim MANUAL (bez zápisů); střídač nechat v PASSIVE do ověření.'
)
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, true,
'Deye 12kW LV Modbus TCP (Waveshare).'
)
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, notes
)
VALUES (
v_site_id, 17000, 8000, false, 0,
'Max 25 A přívod → cca 17 kW import; přetok do sítě max 8 kW.'
)
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,
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,
buy_fixed_energy_nt_czk_kwh, buy_fixed_vt_surcharge_czk_kwh
)
VALUES (
v_site_id,
'fixed', 'spot',
0, 0,
-0.020, 0,
'CZK', now(), NULL,
'Nákup fixní 5,25 Kč/kWh bez DPH (jednotná sazba; NT tarif na místě není bez HDO okna). '
'Prodej na spotu jako home-01 (sell_margin_fixed -0,02 Kč/kWh). '
'Distribuce v efektivní ceně 0 (tariff_id NULL).',
NULL,
NULL,
0,
0,
5.25,
0
);
END IF;
INSERT INTO ems.site_operating_mode (site_id, mode_code, activated_by, notes)
VALUES (
v_site_id,
'MANUAL',
'migration:V045_seed_site_kv1',
'Start MANUAL; střídač PASSIVE. Po ověření přepnout na AUTO a Deye dle plánu.'
)
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,
controllable, active, notes
)
VALUES (
v_site_id,
'deye-main',
'Deye',
NULL,
v_ep_deye,
6250, 6250, 8000,
12000, 15000, 6250, 6250,
NULL,
true, true,
'12kW LV hybrid. BMS max proud z/do baterie 280 A; plánování dle 0,5C ≈ 6,25 kW. '
'Export do DS max 8 kW dle site_grid_connection.'
)
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
)
VALUES (
v_site_id, v_inv_main, 'bat-main',
12500,
10, 15, 95,
0.95, 0.95,
0.50,
0.5, 0.5,
6250, 6250
);
END IF;
-- String 1: 9×460 Wp, sklon 50°, azimut 150° (řiditelné)
IF NOT EXISTS (
SELECT 1 FROM ems.asset_pv_array ap
WHERE ap.site_id = v_site_id AND ap.code = 'pv-str-1'
) THEN
INSERT INTO ems.asset_pv_array (
site_id, inverter_id, code, name,
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
controllable, telemetry_source, notes
)
VALUES (
v_site_id, v_inv_main, 'pv-str-1', 'String 1 9×460 Wp',
4140, 150, 50, 9, 1.0, true, 'pv_strings',
'Hlavní telemetrie stringů Deye; druhý string má telemetry_source NULL.'
);
END IF;
-- String 2: 7×620 Wp, sklon 50°, azimut 241° (řiditelné)
IF NOT EXISTS (
SELECT 1 FROM ems.asset_pv_array ap
WHERE ap.site_id = v_site_id AND ap.code = 'pv-str-2'
) THEN
INSERT INTO ems.asset_pv_array (
site_id, inverter_id, code, name,
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
controllable, telemetry_source, notes
)
VALUES (
v_site_id, v_inv_main, 'pv-str-2', 'String 2 7×620 Wp',
4340, 241, 50, 7, 1.0, true, NULL,
'Vlastní predikce orientace; telemetrie sdílená se stringem 1.'
);
END IF;
END;
$$;

View File

@@ -0,0 +1,40 @@
-- V046: Battery slot selection buffers + Deye zero-export mode + solar sell register
--
-- Solver: slot pre-selection eliminates battery micro-cycling.
-- Registers: reg 142 (zero export mode) per-inverter, reg 145 (solar sell) newly managed.
-- ============================================================
-- 1. Slot selection buffers on asset_battery
-- ============================================================
ALTER TABLE ems.asset_battery
ADD COLUMN IF NOT EXISTS charge_slot_buffer NUMERIC(3,1) DEFAULT 1.3,
ADD COLUMN IF NOT EXISTS discharge_slot_buffer NUMERIC(3,1) DEFAULT 1.5;
COMMENT ON COLUMN ems.asset_battery.charge_slot_buffer IS
'Buffer multiplier for charge slot count over minimum to fill battery (1.0 = exact, 1.3 = 30 % extra). NULL = no slot selection.';
COMMENT ON COLUMN ems.asset_battery.discharge_slot_buffer IS
'Buffer multiplier for discharge-export slot count over minimum to empty battery (1.0 = exact, 1.5 = 50 % extra). NULL = no slot selection.';
-- ============================================================
-- 2. Deye zero-export mode on asset_inverter
-- ============================================================
ALTER TABLE ems.asset_inverter
ADD COLUMN IF NOT EXISTS deye_zero_export_mode SMALLINT DEFAULT 1;
COMMENT ON COLUMN ems.asset_inverter.deye_zero_export_mode IS
'Deye reg 142 value for non-SELL modes: 1 = zero export to load (no CT), 2 = zero export to CT. Depends on physical installation.';
-- ============================================================
-- 3. Per-site seed values
-- ============================================================
-- BA81 (site_id=3, inverter_id=5): CT installed, bump degradation cost
UPDATE ems.asset_inverter SET deye_zero_export_mode = 2 WHERE id = 5;
UPDATE ems.asset_battery SET degradation_cost_czk_kwh = 1.00 WHERE site_id = 3;
-- KV1 (site_id=4, inverter_id=7): CT installed
UPDATE ems.asset_inverter SET deye_zero_export_mode = 2 WHERE id = 7;
-- home-01 (site_id=2, inverter_id=3): no CT — default 1 is correct

View File

@@ -0,0 +1,5 @@
-- Dříve upravené COMMENT v rámci V044; po pravidle Flyway jen nová migrace (checksum V044 nesmí měnit).
COMMENT ON COLUMN ems.asset_inverter.deye_register_max_charge_a IS
'Optional A for reg 108; EMS uses COALESCE(this, FLOOR(LEAST(W)/51.2)) in _load_inverter_config.';
COMMENT ON COLUMN ems.asset_inverter.deye_register_max_discharge_a IS
'Optional A for reg 109; EMS uses COALESCE(this, FLOOR(LEAST(W)/51.2)) in _load_inverter_config.';

View File

@@ -0,0 +1,11 @@
-- volitelné plánovací konstanty per site (horizont, decay, …) čte fn_planning_site_context
create table if not exists ems.planning_config (
site_id int not null references ems.site (id) on delete cascade,
config jsonb not null default '{}'::jsonb,
updated_at timestamptz not null default now(),
primary key (site_id)
);
comment on table ems.planning_config is
'JSON konfigurace pro budoucí přesun konstant z planning_engine.py (slot weights, correction decay, …).';

View File

@@ -0,0 +1,15 @@
-- Po přejmenování repeatable skriptů na R__040_vw_* / R__041_fn_* (pořadí závislostí
-- při řazení dle description) odstraníme záznamy pro staré názvy souborů, jinak
-- Flyway validate hlásí chybějící migrační skript.
DELETE FROM ems.flyway_schema_history
WHERE type = 'SQL'
AND version IS NULL
AND (
script IN (
'R__vw_modbus_last_verified.sql',
'R__fn_modbus_last_verified_map.sql'
)
OR script LIKE '%/R__vw_modbus_last_verified.sql'
OR script LIKE '%/R__fn_modbus_last_verified_map.sql'
);

View File

@@ -0,0 +1,7 @@
-- Po přejmenování všech repeatable na R__NNN_* (globální pořadí dle závislostí fn/vw)
-- odstraníme záznamy repeatable z flyway historie. Při dalším migrate se znovu aplikují
-- všechny R__ skripty (CREATE OR REPLACE / GRANT je idempotentní).
DELETE FROM ems.flyway_schema_history
WHERE type = 'SQL'
AND version IS NULL;

View File

@@ -0,0 +1,18 @@
-- Jednorázové potvrzení odeslání fatálního Discord alertu plán vs. skutečnost (deduplikace po slotu).
create table ems.plan_fatal_deviation_sent (
site_id int not null references ems.site (id),
interval_start timestamptz not null,
reason_code text not null,
sent_at timestamptz not null default now(),
primary key (site_id, interval_start)
);
create index idx_plan_fatal_deviation_sent_sent_at
on ems.plan_fatal_deviation_sent (sent_at desc);
comment on table ems.plan_fatal_deviation_sent is
'Backend job po uzavření 15min slotu: při fatální odchylce grid plán vs. audit jednou pošle Discord a zapíše řádek (PK site_id + interval_start).';
comment on column ems.plan_fatal_deviation_sent.reason_code is
'Kód z ems.fn_plan_actual_slot_guard_site (např. GRID_SIGN_MISMATCH, GRID_EXPORT_SPIKE).';

View File

@@ -0,0 +1,10 @@
-- Explicitní fyzický režim Deye přímo v plánu (Variant A):
-- PASSIVE / SELL / CHARGE. Exporter pak nemusí heuristicky mapovat z wattů.
ALTER TABLE ems.planning_interval
ADD COLUMN IF NOT EXISTS deye_physical_mode TEXT;
COMMENT ON COLUMN ems.planning_interval.deye_physical_mode IS
'Explicitní fyzický režim Deye pro tento slot (PASSIVE / SELL / CHARGE).
Zdroj: planning_engine.solve_dispatch() (záměr slotu), použití: control exporter (get_deye_mode).';

View File

@@ -0,0 +1,9 @@
-- Feature flag: řízení microinverter export cutoff přes Deye Modbus (GEN / AC coupling).
-- Použito pro instalace typu BA81, kde při BLOCK_EXPORT (sell_price < 0) musíme odpojit / zakázat export z MI na GEN portu.
alter table ems.asset_inverter
add column if not exists deye_gen_microinverter_cutoff_enabled boolean not null default false;
comment on column ems.asset_inverter.deye_gen_microinverter_cutoff_enabled is
'Pokud true, EMS při BLOCK_EXPORT přepíná Deye reg 179 (Control board special 1) bits01 pro MI export cutoff na GEN portu.';

View File

@@ -0,0 +1,10 @@
-- BA81: při BLOCK_EXPORT (sell_price < 0) je potřeba aktivovat „MI export to Grid cutoff“.
-- EMS to řeší přes Deye reg 179 bits 01 (masked RMW) pouze když je tento feature flag zapnutý.
update ems.asset_inverter ai
set deye_gen_microinverter_cutoff_enabled = true
from ems.site s
where s.id = ai.site_id
and s.code = 'BA81'
and ai.code = 'deye-main';

View File

@@ -0,0 +1,10 @@
-- Explicitní flag pro řízení odpojení GEN portu (mikroinvertory / AC coupling) v daném slotu.
-- Použito hlavně u BA81: při záporné výkupní ceně a očekávaném přebytku nechceme exportovat, takže solver může zvolit cut-off.
alter table ems.planning_interval
add column if not exists deye_gen_cutoff_enabled boolean;
comment on column ems.planning_interval.deye_gen_cutoff_enabled is
'True = v daném slotu odpojit GEN port (MI export cutoff) přes Deye reg 179 bits01.
NULL = lokalita / instalace GEN cut-off nepoužívá nebo flag není relevantní.';

View File

@@ -0,0 +1,41 @@
-- Kalibrace PV forecastu per site (cutoff učení, škrcení policy, volitelné přepsání parametrů delty).
-- forecast_accuracy: flagy pro učení (vyloučení škrcených slotů apod.).
CREATE TABLE ems.site_pv_forecast_calibration (
site_id int NOT NULL PRIMARY KEY REFERENCES ems.site (id) ON DELETE CASCADE,
-- Od tohoto okamžiku (UTC) brát řádky do učení delty / vážených statistik (>=).
delta_learn_min_ts timestamptz NOT NULL,
-- Od kdy platí agresivní export/škrcení policy (NULL = neaplikovat časový filtr u heuristiky škrcení).
pv_curtailment_policy_effective_from timestamptz NULL,
top_n_days int NULL,
non_top_day_factor numeric NULL,
day_weight_gamma numeric NULL,
half_life_days numeric NULL,
threshold_w int NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE ems.site_pv_forecast_calibration IS
'Per-site kalibrace PV delta profilu a pravidla učení. NULL v numerických sloupích = použít default z ems.fn_pv_forecast_delta_profile.';
COMMENT ON COLUMN ems.site_pv_forecast_calibration.delta_learn_min_ts IS
'Dolní mez interval_start pro učení delty z forecast_accuracy (UTC).';
COMMENT ON COLUMN ems.site_pv_forecast_calibration.pv_curtailment_policy_effective_from IS
'Od tohoto času bereme heuristiku škrcení (planning_interval): sloty po tomto datu s curtailment/cut-off se mohou vyloučit z učení.';
ALTER TABLE ems.forecast_accuracy
ADD COLUMN IF NOT EXISTS learning_eligible boolean NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS learning_exclude_reason text NULL;
COMMENT ON COLUMN ems.forecast_accuracy.learning_eligible IS
'false = řádek se nepoužívá pro učení delty (škrcení, před cutoffem, …); actual_power_w může být NULL pro audit.';
COMMENT ON COLUMN ems.forecast_accuracy.learning_exclude_reason IS
'Důvod vyloučení z učení, např. curtailment_or_gen_cutoff, before_delta_learn_min.';
-- Seed: všechny existující lokality — stejný cutoff jako dosud v R__078 (začátek 2026-04-12 Europe/Prague).
INSERT INTO ems.site_pv_forecast_calibration (site_id, delta_learn_min_ts, top_n_days)
SELECT s.id, timestamptz '2026-04-11T22:00:00Z', 3
FROM ems.site s
ON CONFLICT (site_id) DO NOTHING;

View File

@@ -0,0 +1,12 @@
-- Volitelné flagy pro vyloučení „škrcených“ slotů z učení PV delty (fáze 2 plánu kalibrace).
-- Plní collector podle režimu / registrů (145/179 apod.); dokud NULL, R__022 je ignoruje.
ALTER TABLE ems.telemetry_inverter
ADD COLUMN IF NOT EXISTS is_export_limited boolean NULL,
ADD COLUMN IF NOT EXISTS pv_derating_flags int NULL;
COMMENT ON COLUMN ems.telemetry_inverter.is_export_limited IS
'TRUE = interval indikuje omezení exportu / odpojení GEN (např. cut-off mikroinvertorů); fn_fill_forecast_accuracy může vyloučit slot z učení.';
COMMENT ON COLUMN ems.telemetry_inverter.pv_derating_flags IS
'Bitová maska nebo enum z režimu střídače (derating); <> 0 může vést k vyloučení slotu z učení delty.';

View File

@@ -0,0 +1,20 @@
-- Plánovač: vyšší strop SoC než provozní max, relaxované dno při extrémně záporném buy, práh z OTE horizontu.
ALTER TABLE ems.asset_battery
ADD COLUMN IF NOT EXISTS planner_max_soc_percent NUMERIC(5, 2),
ADD COLUMN IF NOT EXISTS planner_discharge_floor_percent NUMERIC(5, 2),
ADD COLUMN IF NOT EXISTS planner_extreme_buy_threshold_czk_kwh NUMERIC(10, 4) DEFAULT -5.0;
COMMENT ON COLUMN ems.asset_battery.planner_max_soc_percent IS
'Horní mez SoC (%) pro LP; NULL = použij max_soc_percent. Typicky 100 pro plné využití kapacity při silně záporném nákupu.';
COMMENT ON COLUMN ems.asset_battery.planner_discharge_floor_percent IS
'Dolní mez SoC (%) pro LP při aktivaci extrémně záporného nákupu v lookahead; NULL = použij min_soc_percent.';
COMMENT ON COLUMN ems.asset_battery.planner_extreme_buy_threshold_czk_kwh IS
'Prah effective buy (Kč/kWh): pokud min buy v lookahead <= prah, LP smí snížit SoC k planner_discharge_floor_percent.';
-- home-01: plánovat až na 100 % (provozní max_soc může zůstat 95 %)
UPDATE ems.asset_battery
SET planner_max_soc_percent = 100
WHERE site_id = 2 AND planner_max_soc_percent IS NULL;

View File

@@ -0,0 +1,12 @@
-- Plánovač: zpoždění hluboké relaxace SoC až do okna před prvním extrémně záporným nákupem (15min sloty).
ALTER TABLE ems.asset_battery
ADD COLUMN IF NOT EXISTS planner_discharge_relax_prewindow_slots integer;
COMMENT ON COLUMN ems.asset_battery.planner_discharge_relax_prewindow_slots IS
'Počet 15min slotů před prvním effective_sell < 0 (nebo před extrémním buy, pokud sell nikde není záporný); '
'viz také V061. NULL = 8.';
UPDATE ems.asset_battery
SET planner_discharge_relax_prewindow_slots = 8
WHERE planner_discharge_relax_prewindow_slots IS NULL;

View File

@@ -0,0 +1,6 @@
-- Upřesnění významu: prewindow je vůči prvnímu zápornému prodeji (sell), ne k extrémnímu nákupu.
COMMENT ON COLUMN ems.asset_battery.planner_discharge_relax_prewindow_slots IS
'Počet 15min slotů před prvním effective_sell < 0 v horizontu, od kdy platí hluboký planner floor; '
'dříve drží LP spodek na rezervě (arb). Pokud v horizontu není záporný prodej, použije se vzdálenost '
'k prvnímu buy <= planner_extreme_buy_threshold. NULL = 8.';

View File

@@ -0,0 +1,9 @@
alter table ems.asset_battery
add column if not exists planner_terminal_soc_value_factor numeric not null default 0.9;
comment on column ems.asset_battery.planner_terminal_soc_value_factor is
'Váha terminal SoC shadow price v LP solveru.
0 = solver nemá motivaci držet energii v baterii na konci horizontu (agresivnější arbitráž / vybití).
1 = odpovídá ~průměrné nákupní ceně (konzervativní držení energie).
Používá se v backend/services/planning_engine.py (terminal_soc_kcz_per_wh).';

View File

@@ -0,0 +1,10 @@
alter table ems.site
add column if not exists discord_webhook_daily_url text,
add column if not exists discord_webhook_error_url text;
comment on column ems.site.discord_webhook_daily_url is
'Discord webhook pro běžné denní zprávy (např. ranní ekonomický report). Per-site konfigurace.';
comment on column ems.site.discord_webhook_error_url is
'Discord webhook pro error/critical alerty (mismatch, fatal plan vs actual, clock verify exhausted, apod.). Per-site konfigurace.';

View File

@@ -0,0 +1,122 @@
-- Signály EMS → externí cíle (Loxone VI, HTTP REST), journal + idempotence + verify readback.
-- Kritické řízení výkonu (Deye, EV, TČ) zůstává v modbus_command / exporteru.
-- ------------------------------------------------------------
-- Definice signálů (globální katalog kódů)
-- ------------------------------------------------------------
CREATE TABLE ems.signal_def (
code TEXT PRIMARY KEY,
value_type TEXT NOT NULL,
description TEXT
);
COMMENT ON TABLE ems.signal_def IS
'Katalog signálů EMS (logické výstupy). Hodnotu pro route počítá backend dle doménové logiky.';
COMMENT ON COLUMN ems.signal_def.code IS
'Unikátní kód signálu, např. EXPORT_BAN_ACTIVE.';
COMMENT ON COLUMN ems.signal_def.value_type IS
'bool | int | float | string — očekávaný typ hodnoty po transformaci na cíl.';
INSERT INTO ems.signal_def (code, value_type, description)
VALUES (
'EXPORT_BAN_ACTIVE',
'bool',
'Pravda pokud EMS aktuálně uplatňuje zákaz exportu do sítě (LED varianta B): override block_export, no_export, režimy bez exportu, AUTO se záporným výkupem při ne-negativním grid setpointu.'
)
ON CONFLICT (code) DO NOTHING;
-- ------------------------------------------------------------
-- Směrování signál → cíl (per site)
-- ------------------------------------------------------------
CREATE TABLE ems.signal_route (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site (id),
destination_type TEXT NOT NULL,
endpoint_id INT NOT NULL REFERENCES ems.site_endpoint (id),
signal_code TEXT NOT NULL REFERENCES ems.signal_def (code),
destination_key TEXT NOT NULL,
route_config_json JSONB,
transform_json JSONB,
verify_readback BOOLEAN NOT NULL DEFAULT true,
verify_config_json JSONB,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_signal_route_unique UNIQUE (site_id, destination_type, signal_code, destination_key)
);
CREATE INDEX idx_signal_route_site_enabled
ON ems.signal_route (site_id, enabled)
WHERE enabled = true;
COMMENT ON TABLE ems.signal_route IS
'Mapování signálu na cíl (Loxone Virtual Input, HTTP REST atd.). endpoint_id ukazuje na ems.site_endpoint (loxone_http, budoucí shelly_http, …).';
COMMENT ON COLUMN ems.signal_route.destination_type IS
'loxone_vi = GET /dev/sps/io/{destination_key}/{value}; http_rest = šablona v route_config_json.';
COMMENT ON COLUMN ems.signal_route.destination_key IS
'U Loxone název Virtual Inputu. U HTTP REST stabilní klíč pro log (např. relay0).';
COMMENT ON COLUMN ems.signal_route.route_config_json IS
'Volitelná konfigurace pro http_rest (path_template, method, …). U loxone_vi typicky NULL.';
COMMENT ON COLUMN ems.signal_route.verify_config_json IS
'Readback: u Loxone např. {"loxone_io_name":"EMS_ExportBan_Active_FB"} pro GET /dev/sps/io/{name}. U HTTP JSON path atd.';
-- ------------------------------------------------------------
-- Odchozí journal
-- ------------------------------------------------------------
CREATE TABLE ems.signal_outbound_journal (
id BIGSERIAL PRIMARY KEY,
route_id INT NOT NULL REFERENCES ems.signal_route (id),
site_id INT NOT NULL REFERENCES ems.site (id),
signal_code TEXT NOT NULL,
value_text TEXT NOT NULL,
value_num NUMERIC,
status TEXT NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
http_method TEXT,
request_url TEXT,
http_status INT,
latency_ms INT,
response_body_trunc TEXT,
sent_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_signal_outbound_status CHECK (
status IN ('queued', 'sent', 'verified', 'failed', 'abandoned')
)
);
CREATE INDEX idx_signal_outbound_worker
ON ems.signal_outbound_journal (status, next_attempt_at);
CREATE INDEX idx_signal_outbound_site_debug
ON ems.signal_outbound_journal (site_id, signal_code, created_at DESC);
COMMENT ON TABLE ems.signal_outbound_journal IS
'Journal odchozích signálů (HTTP). Worker odesílá queued, po úspěchu sent, po readback verified nebo failed s retry.';
-- ------------------------------------------------------------
-- Poslední známý stav (idempotence)
-- ------------------------------------------------------------
CREATE TABLE ems.signal_state (
site_id INT NOT NULL REFERENCES ems.site (id),
signal_code TEXT NOT NULL,
destination_type TEXT NOT NULL,
destination_key TEXT NOT NULL,
last_desired_value_text TEXT,
last_sent_value_text TEXT,
last_verified_value_text TEXT,
last_sent_at TIMESTAMPTZ,
last_verified_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (site_id, signal_code, destination_type, destination_key)
);
COMMENT ON TABLE ems.signal_state IS
'Poslední požadovaná / odeslaná / ověřená hodnota signálu per cíl — idempotence a diagnostika verify.';

View File

@@ -0,0 +1,8 @@
-- ============================================================
-- Forecast PV: urychlení denních/range dotazů podle interval_start
-- (fn_forecast_pv_split, pv-slots* funkce)
-- ============================================================
create index if not exists idx_forecast_pv_interval_start_run
on ems.forecast_pv_interval (interval_start, run_id);

View File

@@ -0,0 +1,18 @@
-- =============================================================
-- V066__latest_telemetry_distinct_on_indexes.sql
-- Zrychlení view ems.vw_latest_* (PostgREST dashboard endpoints).
--
-- View používají DISTINCT ON (...) s ORDER BY ... measured_at desc.
-- Bez odpovídajících indexů může plán spadnout na scan+sort nad
-- velkými Timescale hypertabulkami (sekundy latency).
-- =============================================================
create index if not exists idx_telemetry_inverter_site_inverter_time_desc
on ems.telemetry_inverter (site_id, inverter_id, measured_at desc);
create index if not exists idx_telemetry_ev_site_charger_connector_time_desc
on ems.telemetry_ev_charger (site_id, charger_id, connector_id, measured_at desc);
create index if not exists idx_telemetry_hp_site_heat_pump_time_desc
on ems.telemetry_heat_pump (site_id, heat_pump_id, measured_at desc);

View File

@@ -0,0 +1,8 @@
-- =============================================================
-- V067__asset_heat_pump_site_index.sql
-- Zrychlení filtrování asset_heat_pump podle site_id (PostgREST).
-- =============================================================
create index if not exists idx_asset_heat_pump_site
on ems.asset_heat_pump (site_id);

View File

@@ -0,0 +1,8 @@
-- ============================================================
-- Site market config: urychlení lookupu platné konfigurace
-- (vw_site_effective_price, fn_effective_*_price)
-- ============================================================
create index if not exists idx_site_market_config_site_valid_from
on ems.site_market_config (site_id, valid_from desc);

View File

@@ -0,0 +1,12 @@
-- planner_terminal_soc_value_factor: LP terminal SoC shadow price (planning_engine).
-- V062 přidal sloupec NOT NULL default 0.9; tato migrace je idempotentní upevnění pro starší / ručně upravené DB.
update ems.asset_battery
set planner_terminal_soc_value_factor = 0.9
where planner_terminal_soc_value_factor is null;
alter table ems.asset_battery
alter column planner_terminal_soc_value_factor set default 0.9;
alter table ems.asset_battery
alter column planner_terminal_soc_value_factor set not null;

View File

@@ -0,0 +1,16 @@
-- Zrychlení fn_pv_forecast_delta_profile (volá ho pv-slots-corrected): range scan site + interval_start
-- s podmínkami učení bez sekvenčního full scanu větší historie.
create index if not exists idx_forecast_accuracy_site_interval_delta_profile
on ems.forecast_accuracy (
site_id,
interval_start desc,
pv_array_id,
forecast_created_at desc
)
where actual_power_w is not null
and coalesce(learning_eligible, true) = true
and forecast_created_at <= interval_start;
comment on index ems.idx_forecast_accuracy_site_interval_delta_profile is
'Partial index pro výběr posledního forecast runu na slot (DISTINCT ON interval_start, pv_array_id) v delta profilu.';

View File

@@ -0,0 +1,8 @@
-- Plán „nejnovější run na slot“ často sahá po forecast_pv_interval přes (run_id, interval).
-- Druhý pořádek (pole → čas) pomáhá alternativním plánům při filtru pv_array_id + časové okno.
create index if not exists idx_forecast_pv_interval_pv_array_interval_start
on ems.forecast_pv_interval (pv_array_id, interval_start desc);
comment on index ems.idx_forecast_pv_interval_pv_array_interval_start is
'Podpora dotazů s filtrem na pv_array_id a rozsah interval_start (pv-slots, DISTINCT ON).';

View File

@@ -0,0 +1,52 @@
-- =============================================================
-- V072 asset_pv_array.telemetry_group + rozšíření telemetry_source
--
-- Cíl:
-- - umožnit mapování PV pole → měřicí kanál (pv1/pv2/pv_strings/pv_total/gen_port),
-- - umožnit sdílené měření pro více polí (telemetry_group) a následnou alokaci (v routines).
-- =============================================================
alter table ems.asset_pv_array
add column if not exists telemetry_group text;
comment on column ems.asset_pv_array.telemetry_source is
'Který sloupec v telemetry_inverter odpovídá tomuto poli.
gen_port = gen_port_power_w (AC-coupled pole na GEN portu),
pv1 = pv1_power_w (DC string 1 / MPPT1),
pv2 = pv2_power_w (DC string 2 / MPPT2),
pv_strings = pv1_power_w + pv2_power_w (souhrn DC stringů, pokud nejde rozlišit),
pv_total = pv_power_w (souhrnné PV, pokud nejde rozlišit).
NULL = pole nemá přímou telemetrii (fallback na forecast).';
comment on column ems.asset_pv_array.telemetry_group is
'Volitelná skupina pro sdílené měření: pokud více pv_array sdílí jeden telemetrický kanál (např. GEN port rozdělený do více orientací),
pak mají shodné (site_id, telemetry_source, telemetry_group) a routines alokují actual proporčně podle forecastu.';
-- --- Seed / upgrade stávajících referenčních lokalit ---
-- home-01: dvě GEN pole sdílí jeden GEN port → stejné telemetry_group
update ems.asset_pv_array
set telemetry_source = 'gen_port',
telemetry_group = 'gen_port_1'
where site_id = (select id from ems.site where code = 'home-01')
and code in ('pv-b', 'pv-b-flat');
-- BA81: stringy mapujeme na PV1/PV2, mikroinvertory sdílí GEN port (alokace podle forecastu).
update ems.asset_pv_array
set telemetry_source = 'pv1',
telemetry_group = null
where site_id = (select id from ems.site where code = 'BA81')
and code = 'pv-str-1';
update ems.asset_pv_array
set telemetry_source = 'pv2',
telemetry_group = null
where site_id = (select id from ems.site where code = 'BA81')
and code = 'pv-str-2';
update ems.asset_pv_array
set telemetry_source = 'gen_port',
telemetry_group = 'gen_port_1'
where site_id = (select id from ems.site where code = 'BA81')
and code in ('pv-mi-1', 'pv-mi-2');

View File

@@ -0,0 +1,56 @@
-- =============================================================
-- V073 číselník PV telemetrie + FK na asset_pv_array.telemetry_source
--
-- Cíl: referenční integrita pro telemetry_source (povolené kódy),
-- aby se zabránilo překlepům a nekonzistentním datům.
-- =============================================================
create table if not exists ems.pv_telemetry_source_def (
code text primary key,
description text not null,
telemetry_inverter_expr text null,
active boolean not null default true
);
comment on table ems.pv_telemetry_source_def is
'Číselník zdrojů PV telemetrie (kanálů) pro asset_pv_array.telemetry_source.';
comment on column ems.pv_telemetry_source_def.code is
'Stabilní kód zdroje telemetrie (FK z asset_pv_array.telemetry_source).';
comment on column ems.pv_telemetry_source_def.telemetry_inverter_expr is
'Volitelně: lidsky čitelný výraz, jak se kanál počítá z telemetry_inverter (informativní; runtime logika je v routines).';
insert into ems.pv_telemetry_source_def (code, description, telemetry_inverter_expr) values
('gen_port', 'AC-coupled výroba na GEN portu (souhrn).', 'gen_port_power_w'),
('pv1', 'DC string/MPPT 1 (samostatně).', 'pv1_power_w'),
('pv2', 'DC string/MPPT 2 (samostatně).', 'pv2_power_w'),
('pv_strings', 'Součet DC stringů (pv1+pv2).', 'pv1_power_w + pv2_power_w'),
('pv_total', 'Souhrnná PV výroba (pokud nelze rozlišit).','pv_power_w')
on conflict (code) do update
set description = excluded.description,
telemetry_inverter_expr = excluded.telemetry_inverter_expr,
active = true;
-- FK (idempotentně): NULL povolen (pole bez přímé telemetrie / fallback na forecast).
do $$
begin
if not exists (
select 1
from pg_constraint c
join pg_class t on t.oid = c.conrelid
join pg_namespace n on n.oid = t.relnamespace
where n.nspname = 'ems'
and t.relname = 'asset_pv_array'
and c.conname = 'asset_pv_array_telemetry_source_fk'
) then
alter table ems.asset_pv_array
add constraint asset_pv_array_telemetry_source_fk
foreign key (telemetry_source)
references ems.pv_telemetry_source_def(code)
on update cascade
on delete restrict;
end if;
end;
$$;

View File

@@ -0,0 +1,13 @@
-- Tvrdý zákaz grid exportu při záporné efektivní prodejní ceně v LP (odděleně od GEN cut-off přepínače na invertoru).
alter table ems.site_grid_connection
add column if not exists block_export_on_negative_sell boolean not null default false;
comment on column ems.site_grid_connection.block_export_on_negative_sell is
'LP (solve_dispatch): při effective sell < 0 vynutit ge[t]=0. Nezávislé na deye_gen_microinverter_cutoff_enabled. Zapínat jen u lokalit bez nutnosti vést přebytek neriťitelného PV pole B do sítě (jinak hrozí infeasible); př. KV1 vs home-01.';
update ems.site_grid_connection sgc
set block_export_on_negative_sell = true
from ems.site s
where sgc.site_id = s.id
and s.code = 'KV1';

View File

@@ -0,0 +1,3 @@
-- buy_margin_percent: spot režim používá asymetrický faktor (R__011 fn_effective_buy_price).
comment on column ems.site_market_config.buy_margin_percent is
'Procentní nákupní marže za režimu spot: při kladné buy_raw složka OTE ×(1+p/100); při záporné ×(1p/100); buy_margin_fixed_czk se jen přičte. Za režimu FIXED stále fix + (uzavřená energická složka × p/100).';

View File

@@ -0,0 +1,21 @@
-- Kalendářní dny lokality označené jako referenční pro učení delty PV forecastu (dobrá obloha).
create table ems.site_pv_forecast_reference_day (
site_id int not null references ems.site (id) on delete cascade,
day_local date not null,
notes text null,
created_at timestamptz not null default now(),
primary key (site_id, day_local)
);
comment on table ems.site_pv_forecast_reference_day is
'Dny v kalendáři lokality podle jejího site.timezone (typicky datum ve zdi Europe/Prague), kterým se v ems.fn_pv_forecast_delta_profile zvýší váha řádků forecast_accuracy při počítání delta profilu.';
comment on column ems.site_pv_forecast_reference_day.day_local is
'Kalendářní datum v časové zóně lokality; porovnává se na (interval_start AT TIME ZONE site.timezone)::date ze slotů.';
alter table ems.site_pv_forecast_calibration
add column if not exists reference_day_weight_mult numeric null;
comment on column ems.site_pv_forecast_calibration.reference_day_weight_mult is
'Násobitel váhy učícího vzorku pro všechny sloty jejichž den spadá do site_pv_forecast_reference_day; NULL použije default v fn_pv_forecast_delta_profile (aktuálně 3).';

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,24 @@
-- map register -> value_verified z modbus_command (poslední verified řádek per register)
create or replace function ems.fn_modbus_last_verified_map(
p_site_id int,
p_asset_id int
)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_object_agg(register::text, to_jsonb(value_verified)),
'{}'::jsonb
)
from (
select
v.register,
v.value_verified
from ems.vw_modbus_last_verified v
where v.site_id = p_site_id
and v.asset_type = 'inverter'
and v.asset_id = p_asset_id
) t;
$fn$;

View File

@@ -78,7 +78,8 @@ $$;
COMMENT ON FUNCTION ems.fn_update_baseline_stats(INT, INT) IS
'Aktualizuje průměry bazální spotřeby z telemetrie posledních N dní.
Používá exponenciální klouzavý průměr (EMA 70/30) pro postupné zpřesňování.
Volat denně po půlnoci. Pro první naplnění: fn_update_baseline_stats(2, 90).';
Volat denně po půlnoci. Pro první naplnění: fn_update_baseline_stats(2, 90).
Pro úplný reset bucketů bez ocasu EMA smaž řádky a znovu volej, nebo ems.fn_rebuild_consumption_baseline_stats.';
CREATE OR REPLACE FUNCTION ems.fn_get_baseline_forecast(
@@ -101,8 +102,11 @@ AS $$
cbs.avg_power_w + 0.5 * COALESCE(cbs.stddev_power_w, 100),
550
)::INT AS confidence_w
FROM generate_series(p_from, p_to - INTERVAL '15 minutes',
INTERVAL '15 minutes') AS gs(slot)
FROM generate_series(
date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z'),
date_bin(interval '15 minutes', p_to, timestamptz '1970-01-01T00:00:00Z') - interval '15 minutes',
interval '15 minutes'
) AS gs(slot)
LEFT JOIN ems.consumption_baseline_stats cbs
ON cbs.site_id = p_site_id
AND cbs.day_of_week = EXTRACT(DOW FROM gs.slot AT TIME ZONE 'Europe/Prague')::INT

View File

@@ -0,0 +1,51 @@
-- audit „ekvivalent plných cyklů“ z 1min telemetrie battery_power_w (bez LP constraintu)
create or replace function ems.fn_battery_cycle_audit(
p_site_id int,
p_from timestamptz,
p_to timestamptz
)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_usable numeric;
v_throughput_wh numeric;
v_full_cycles numeric;
begin
select coalesce(sum(ab.usable_capacity_wh), 0)::numeric
into v_usable
from ems.asset_battery ab
where ab.site_id = p_site_id;
if v_usable is null or v_usable <= 0 then
return jsonb_build_object('error', 'no_battery', 'full_cycles', 0);
end if;
select coalesce(
sum(abs(ti.battery_power_w::numeric) / 60.0),
0
)
into v_throughput_wh
from ems.telemetry_inverter ti
where ti.site_id = p_site_id
and ti.measured_at >= p_from
and ti.measured_at < p_to
and ti.battery_power_w is not null;
v_full_cycles := case
when v_usable * 2 > 0 then v_throughput_wh / (v_usable * 2)
else 0
end;
return jsonb_build_object(
'full_cycles', round(v_full_cycles::numeric, 4),
'throughput_wh', round(v_throughput_wh, 2),
'throughput_vs_usable_ratio', round((v_throughput_wh / nullif(v_usable, 0))::numeric, 4),
'usable_capacity_wh', v_usable,
'window_start', p_from,
'window_end', p_to
);
end;
$fn$;

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