Compare commits
309 Commits
eb8dd0368f
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e419f0a5e | ||
|
|
ad4b52c9ce | ||
|
|
b5dbc8cf0a | ||
|
|
60f5f77146 | ||
|
|
d767d0abca | ||
|
|
1d5b97c65f | ||
|
|
f2901ef366 | ||
|
|
02e0134794 | ||
|
|
eb360da910 | ||
|
|
ca6bd4ab2a | ||
|
|
a8b4342099 | ||
|
|
293f32cff1 | ||
|
|
7c2669def6 | ||
|
|
90f79d9abe | ||
|
|
7d9ce5746a | ||
|
|
90a85b2727 | ||
|
|
368291e562 | ||
|
|
ec13c2ad6e | ||
|
|
9a2229641d | ||
|
|
0dc2e1df96 | ||
|
|
cb6afbb3fd | ||
|
|
dcbb5de98c | ||
|
|
d83917da51 | ||
|
|
4ee5cebf2a | ||
|
|
484f1f85fc | ||
|
|
edc8ae9774 | ||
|
|
50ac40868d | ||
|
|
b7903db714 | ||
|
|
3ad5bec76b | ||
|
|
37df01d43c | ||
|
|
3161421d5c | ||
|
|
36cb06b9d0 | ||
|
|
0f7dc6ed94 | ||
|
|
a7879f1141 | ||
|
|
09bca0a903 | ||
|
|
2a963c9793 | ||
|
|
1429d402e5 | ||
|
|
d44a2cbb44 | ||
|
|
96adbff9ea | ||
|
|
63eff96c5f | ||
|
|
0dcf11d471 | ||
|
|
430e081841 | ||
|
|
5d06f49d2b | ||
|
|
111f51c06c | ||
|
|
8950fafba2 | ||
|
|
578cf315e2 | ||
|
|
a03b45d4a9 | ||
|
|
830aa7a4cc | ||
|
|
4f67aad4d8 | ||
|
|
96d0d52b07 | ||
|
|
5208e035a4 | ||
|
|
d3e9caf0fb | ||
|
|
308c24f029 | ||
|
|
b73c3323e1 | ||
|
|
877f5b6180 | ||
|
|
230351b38a | ||
|
|
88df09640c | ||
|
|
a7dff75e58 | ||
|
|
620a557a89 | ||
|
|
ba0b55bf10 | ||
|
|
52e4b68789 | ||
|
|
4e5de5df90 | ||
|
|
8c7072da07 | ||
|
|
19108002ca | ||
|
|
96b16b9ff9 | ||
|
|
398e658d16 | ||
|
|
d1ba864fc0 | ||
|
|
58b0a2f882 | ||
|
|
a53bcd0b81 | ||
|
|
94eb256598 | ||
|
|
b4e5fc5040 | ||
|
|
da79eec077 | ||
|
|
91a9bef3d7 | ||
|
|
8494ea26de | ||
|
|
25c864db61 | ||
|
|
b03f08d3a0 | ||
|
|
18ace46ea9 | ||
|
|
2e27c8c5de | ||
|
|
91af5c76c2 | ||
|
|
c6074e9c74 | ||
|
|
e06f76b9ff | ||
|
|
f1a4dbd7e7 | ||
|
|
37a525cb4f | ||
|
|
b8e47e2623 | ||
|
|
f90004142c | ||
|
|
0a0668000b | ||
|
|
a1270dcda3 | ||
|
|
4beb8cf99f | ||
|
|
161b463367 | ||
|
|
a2a35981a1 | ||
|
|
5fb4c10ff6 | ||
|
|
254508fe1a | ||
|
|
095676e3b1 | ||
|
|
67d34aba41 | ||
|
|
b46da6b2dc | ||
|
|
7036bcfdb8 | ||
|
|
b03855b3d1 | ||
|
|
9ba65ea6bb | ||
|
|
b844a9182f | ||
|
|
8bef1c6da6 | ||
|
|
2d021b15c3 | ||
|
|
9a15a4c618 | ||
|
|
747a5bed08 | ||
|
|
9d31b19ec6 | ||
|
|
c43bd0a6c6 | ||
|
|
a3c4af3573 | ||
|
|
fb0d947af6 | ||
|
|
bd06779fe5 | ||
|
|
ce571a93fa | ||
|
|
7ff2abc7e0 | ||
|
|
61a58a62b1 | ||
|
|
904c318532 | ||
|
|
645f48036d | ||
|
|
0f922c91f5 | ||
|
|
dbc004a949 | ||
|
|
e3e5fc138c | ||
|
|
b44f74b249 | ||
|
|
da52cf168b | ||
|
|
1ec92bdf79 | ||
|
|
a52be1b792 | ||
|
|
8845350c0b | ||
|
|
f157c10480 | ||
|
|
0c4de4e5b9 | ||
|
|
9cf7708909 | ||
|
|
c5525c729f | ||
|
|
f960e08307 | ||
|
|
cb638b9302 | ||
|
|
2ebc48f813 | ||
|
|
7b25640557 | ||
|
|
fff7fdb7c4 | ||
|
|
d9ecc70980 | ||
|
|
7c63fed296 | ||
|
|
e295e55770 | ||
|
|
c9149babd3 | ||
|
|
649c9e9510 | ||
|
|
fc0761fb2a | ||
|
|
66834ddfa6 | ||
|
|
3b4d54dcc7 | ||
|
|
739249a244 | ||
|
|
ba1cdcbee4 | ||
|
|
52bedcf67d | ||
|
|
b78597fdda | ||
|
|
08f1b6741a | ||
|
|
d984716f69 | ||
|
|
eb425a26f2 | ||
|
|
44a06b6288 | ||
|
|
27323fd77a | ||
|
|
49d0aa68a2 | ||
|
|
a17c22d475 | ||
|
|
1426c0e153 | ||
|
|
7490ac3d70 | ||
|
|
d89d8b1e3a | ||
|
|
30f16a14c2 | ||
|
|
851ec2b637 | ||
|
|
64327af8e0 | ||
|
|
a5184ec42f | ||
|
|
ab80d13ecb | ||
|
|
0d2839d6db | ||
|
|
d54579e3b1 | ||
|
|
5b383e9028 | ||
| 459f33d55c | |||
|
|
8a3a49806b | ||
| a3afd392d3 | |||
|
|
b35f292295 | ||
| e44cd013f4 | |||
|
|
6471467bc5 | ||
|
|
ba53fe5bfc | ||
| 87fc9b41cf | |||
|
|
335c413232 | ||
|
|
bcb05d4896 | ||
|
|
405e832f8d | ||
| b022311dec | |||
|
|
e8eb867a2a | ||
| 7711640a4b | |||
|
|
349a15e96a | ||
|
|
6129677756 | ||
|
|
6cacf523a2 | ||
|
|
44cd7f986a | ||
|
|
53288d130a | ||
|
|
abe4255f88 | ||
|
|
55ccf06627 | ||
|
|
0ca1bed0fd | ||
|
|
6d6341cde8 | ||
| e2f77eda14 | |||
|
|
02f0ab66e4 | ||
|
|
3595b24f3b | ||
|
|
5ca5eab1d8 | ||
|
|
343f2f9847 | ||
|
|
b20cb6e0f9 | ||
|
|
fffe6c7185 | ||
|
|
ed88ef8910 | ||
|
|
91ee8a6adf | ||
|
|
bf3b10ca50 | ||
|
|
e54eb1dfd9 | ||
|
|
1e0300dd7e | ||
|
|
e686bc1d2c | ||
|
|
6743224cc5 | ||
|
|
03ebc6246d | ||
|
|
efc6e54f0e | ||
|
|
6074535d96 | ||
|
|
2eeab58c8e | ||
|
|
93193fd5dc | ||
|
|
f3a7b0c64f | ||
|
|
b66b0109b9 | ||
|
|
dede8d604d | ||
|
|
2c884e2135 | ||
|
|
342483b885 | ||
|
|
9aceb628aa | ||
|
|
89fb4f1924 | ||
|
|
5593397fd3 | ||
|
|
9d37efb991 | ||
|
|
afee62ba4e | ||
|
|
e35110cb87 | ||
|
|
542cd9a73c | ||
|
|
8114ec5e63 | ||
|
|
c52946a4ce | ||
|
|
69c979b967 | ||
|
|
30585c9779 | ||
|
|
e96bb75b87 | ||
|
|
5b94f8baec | ||
|
|
e4d4fee24d | ||
|
|
16fc6a065e | ||
|
|
cc674900cc | ||
|
|
8960576ee8 | ||
|
|
50a0ca95f4 | ||
|
|
1d04790f28 | ||
|
|
5f96a4cf01 | ||
|
|
4875c31338 | ||
|
|
bf7373fbfe | ||
|
|
3940f6d45c | ||
|
|
a943829c40 | ||
|
|
40b2ff2ff9 | ||
|
|
c6ca68b263 | ||
|
|
0edf9226cb | ||
|
|
b1e124416d | ||
|
|
1735f77863 | ||
|
|
5d7d7e2823 | ||
|
|
f6e239aa8d | ||
|
|
c928e2234d | ||
|
|
1dfab8c7a1 | ||
|
|
568b584748 | ||
|
|
3cd8e44d37 | ||
|
|
5a66cfa63f | ||
|
|
bc0966e4c4 | ||
|
|
638c5444be | ||
|
|
09f1d2de68 | ||
|
|
bd7d6a1b99 | ||
|
|
faf948d75b | ||
|
|
e085068069 | ||
|
|
9ca4b4c577 | ||
|
|
ffe80679cc | ||
|
|
6cf14ed25b | ||
|
|
a07f5d57cb | ||
|
|
b8515f30df | ||
|
|
d8dbb284fd | ||
|
|
43b594c8d5 | ||
|
|
6447666cee | ||
|
|
7f3b0957cc | ||
|
|
e3776226a4 | ||
|
|
d8221e3169 | ||
|
|
ee4355f17f | ||
|
|
70d306961a | ||
|
|
ea2e33972c | ||
|
|
6dc14764d0 | ||
|
|
301f20612f | ||
|
|
f48a7aad61 | ||
|
|
e33207f3fa | ||
|
|
014c6f193b | ||
|
|
ccb2a41e22 | ||
|
|
22bca9cd9e | ||
|
|
0c93f493a4 | ||
|
|
93f883f5e0 | ||
|
|
a02e11ee13 | ||
|
|
f8e1eed127 | ||
|
|
efc2cbfded | ||
|
|
5c868083af | ||
|
|
b4c58156f0 | ||
|
|
dc0e37e580 | ||
|
|
0814b1d8e8 | ||
|
|
ee27f4e3fd | ||
|
|
906eeb1609 | ||
|
|
477e94f321 | ||
|
|
d3fd8b139a | ||
|
|
d5dcf33e13 | ||
|
|
a1aa6acf61 | ||
|
|
fd06811753 | ||
|
|
3b33594354 | ||
|
|
3da738e7e9 | ||
|
|
f0dfcefd54 | ||
|
|
5919b6caf3 | ||
|
|
9ff7c96c22 | ||
|
|
0e5227eb5b | ||
|
|
3c9916f2c0 | ||
|
|
d7e6226962 | ||
|
|
f7d3162eb7 | ||
|
|
3066a82265 | ||
|
|
0ba72c7704 | ||
|
|
71d8405cee | ||
|
|
015c81a8cb | ||
|
|
4e81a36371 | ||
|
|
b50041cfc7 | ||
|
|
44ab3783ce | ||
|
|
a65d134682 | ||
|
|
74ffa5c3e7 | ||
|
|
f714cab0ab | ||
|
|
64221f701a | ||
|
|
806274cf59 | ||
|
|
25090a9d95 | ||
|
|
b8b3de2b70 |
63
.claude/settings.json
Normal file
63
.claude/settings.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"permissions": {
|
||||
"defaultMode": "acceptEdits",
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"mcp__postgres-ems__query",
|
||||
"Skill(update-config)",
|
||||
"Bash(claude mcp *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(python *)",
|
||||
"Bash(pytest *)",
|
||||
"Bash(EMS_DB_DSN=*)",
|
||||
"Bash(GOLDEN_UPDATE=*)",
|
||||
"Bash(git *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(ls)",
|
||||
"Bash(cat *)",
|
||||
"Bash(cat)",
|
||||
"Bash(grep *)",
|
||||
"Bash(rg *)",
|
||||
"Bash(find *)",
|
||||
"Bash(sed *)",
|
||||
"Bash(awk *)",
|
||||
"Bash(head *)",
|
||||
"Bash(tail *)",
|
||||
"Bash(wc *)",
|
||||
"Bash(sort *)",
|
||||
"Bash(uniq *)",
|
||||
"Bash(diff *)",
|
||||
"Bash(du *)",
|
||||
"Bash(mkdir *)",
|
||||
"Bash(cp *)",
|
||||
"Bash(mv *)",
|
||||
"Bash(touch *)",
|
||||
"Bash(echo *)",
|
||||
"Bash(which *)",
|
||||
"Bash(pwd)",
|
||||
"Bash(cd *)",
|
||||
"Bash(export *)",
|
||||
"Bash(env *)",
|
||||
"Bash(docker ps*)",
|
||||
"Bash(docker logs *)",
|
||||
"Bash(jq *)",
|
||||
"Bash(curl http://localhost*)",
|
||||
"Bash(curl http://127.0.0.1*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push*)",
|
||||
"Bash(docker compose down*)",
|
||||
"Bash(docker compose rm*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git clean*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
63
.claude/skills/ems-delta-triage/SKILL.md
Normal file
63
.claude/skills/ems-delta-triage/SKILL.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: ems-delta-triage
|
||||
description: Triáž neekonomického chování plánovače po nasazení — vysvětlit PROČ plán udělal co udělal, porovnat v1 vs v2 (shadow), vyčíslit ztrátu proti oracle. Použít když uživatel hlásí "divné/neekonomické chování", "proč to v X hodin nabíjelo/exportovalo", nebo chce vyhodnotit shadow data v1 vs v2.
|
||||
---
|
||||
|
||||
# EMS delta-triáž (v1 vs v2 vs realita vs oracle)
|
||||
|
||||
Cíl: z konkrétního dne/situace vyrobit vysvětlení s čísly, ne dojmy. Vždy
|
||||
pracuj v pořadí: (1) co se REÁLNĚ stalo, (2) co chtěl plán, (3) co chtěl peer
|
||||
(shadow), (4) co bylo optimum, (5) proč se liší.
|
||||
|
||||
## 0. Vstupy od uživatele
|
||||
site code (home-01/BA81/KV1/…), den či časové okno (Prague), co je „divné".
|
||||
|
||||
## 1. Realita (audit) — MCP `query` na `user-postgres-ems`
|
||||
```sql
|
||||
select interval_start, actual_grid_power_w, actual_battery_power_w,
|
||||
actual_battery_soc_pct, actual_pv_power_w, actual_load_power_w,
|
||||
actual_cost_czk, deviation_cost_czk, planning_run_id
|
||||
from ems.audit_interval
|
||||
where site_id = :id and interval_start >= :od and interval_start < :do
|
||||
order by interval_start;
|
||||
```
|
||||
+ efektivní ceny: `ems.vw_site_effective_price` (stejné okno). Hledej sloty,
|
||||
kde tok jde PROTI ceně (import za draho při nabité baterii, export při sell<0…).
|
||||
|
||||
## 2. Plán a jeho zdůvodnění
|
||||
- Aktivní run pro slot: `audit_interval.planning_run_id` → `ems.planning_run`
|
||||
(`solver_params`: `version`, `relax_chain`, `neg_sell_*`, `evening_push_ts`…)
|
||||
a `ems.planning_interval` (setpointy, expected_cost).
|
||||
- `ems.fn_plan_explain_bundle` + skill `.cursor/skills/ems-plan-explain`.
|
||||
- v1 vs v2 shadow diff: `planning_run.solver_params->'comparison'`
|
||||
(`diff.total_expected_cost_czk`, `slot_diffs` — kde se verze rozcházejí).
|
||||
|
||||
## 3. Replay lokálně (přesná rekonstrukce)
|
||||
```bash
|
||||
python3 scripts/harness/extract_fixtures.py --site-code <code> --day <YYYY-MM-DD> --tag triage_<duvod>
|
||||
cd backend && python3 ../scripts/harness/solver_v2_eval.py # v1 (golden) vs v2 na fixture
|
||||
```
|
||||
Pozor: context = AKTUÁLNÍ konfigurace; pro historickou věrnost srovnej
|
||||
`planning_run.solver_params.inputs` (battery parametry tehdy).
|
||||
|
||||
## 4. Optimum (kolik se nechalo na stole)
|
||||
```bash
|
||||
EMS_DB_DSN=… python3 scripts/harness/economics_report.py --site-code <code> --from <den> --to <den>
|
||||
```
|
||||
GAP = forecast error + neefektivita dispatche. Pro oddělení: porovnej plán
|
||||
(forecast vstupy) vs oracle (skutečné PV/load) — velký rozdíl plán/oracle při
|
||||
malém rozdílu plán/realita ⇒ chyba forecastu, ne dispatche.
|
||||
|
||||
## 5. Verdikt — vždy jedna z kategorií + číslo v Kč
|
||||
- **forecast error** (PV/load se netrefil; plán byl na svá data racionální),
|
||||
- **heuristika v1** (penalty/maska vynutila neekonomický tok — ukaž kterou:
|
||||
vypni ji přes `penalty_audit.py --only NAZEV` na fixture dne),
|
||||
- **tvrdé pravidlo** (block_export, arb floor, breaker, režim — správné chování),
|
||||
- **chyba modelu v2** (jen pokud aktivní v2; ověř `solver_v2_eval.py` + unit testy),
|
||||
- **exekuce** (plán dobrý, zařízení neposlechlo — `ems.modbus_command` journal,
|
||||
skill ems-planner-bug-triage).
|
||||
|
||||
## Zásady
|
||||
- Žádné závěry bez čísel ze SQL/harnessu; vždy uveď sloty a Kč.
|
||||
- Nikdy neměnit plánovač bez golden gate (viz docs/refactor-clean-planner.md).
|
||||
- Nálezy zapsat do docs/planning-changelog.md (formát: datum · problém · příčina · ověření).
|
||||
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal file
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal 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.
|
||||
97
.cursor/plans/unify_pv_correction_source_95b01fce.plan.md
Normal file
97
.cursor/plans/unify_pv_correction_source_95b01fce.plan.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: Unify PV correction source
|
||||
status: draft
|
||||
owner: cursor-agent
|
||||
---
|
||||
|
||||
## Cíl
|
||||
|
||||
Uděláme **single source of truth** pro PV forecast používaný v plánování tak, aby:
|
||||
|
||||
- **solver** i **UI** četly *stejnou* PV řadu (žádné „dvě korekce dvěma cestami“),
|
||||
- kanonický výpočet byl v **PostgreSQL**,
|
||||
- výsledky byly auditovatelné (raw vs delta vs rolling-factor + decay).
|
||||
|
||||
## Zjištěný problém (dnes)
|
||||
|
||||
- Solver používá PV z DB + **multiplikativní rolling faktor** v Pythonu (`compute_correction_factor` + `apply_forecast_correction` v `backend/services/planning_engine.py`).
|
||||
- UI (Planning tabulka) zobrazuje PV přes endpoint **delta-korekce** (`/forecast/pv-slots-corrected` → `ems.fn_forecast_pv_slots_range_corrected`), což může být jiné číslo než PV, se kterým solver počítal.
|
||||
- Důsledek: v tabulce slotů nesedí výkonová bilance (UI ukáže např. 5.9 kW, ale plán implicitně pracuje s ~10.4 kW).
|
||||
|
||||
## Cílové chování (nová kanonická DB řada)
|
||||
|
||||
Kanonické PV pro plánování definujeme jako kombinaci obou korekcí:
|
||||
|
||||
1. **Delta-korekce (aditivní)** per PV array (odečíst `delta_profile[slot_of_day]`, clamp na 0)
|
||||
2. Agregace do **PV-A / PV-B** podle `ems.asset_pv_array.controllable`
|
||||
3. **Rolling faktor (multiplikativní)** z `ems.fn_pv_forecast_correction_factor(...)` aplikovaný na PV-A i PV-B
|
||||
4. **Decay (lineární útlum)** faktoru podle offsetu slotu od `now` (stejná logika jako dnes v `apply_forecast_correction`)
|
||||
|
||||
Výstup této kanonické řady se musí propsat do:
|
||||
|
||||
- `ems.fn_load_planning_slots_full` (vstup pro solver),
|
||||
- `ems.fn_plan_current_bundle` (výstup pro UI),
|
||||
- a do uložených sloupců `planning_interval.pv_*_forecast_solver_w` (audit).
|
||||
|
||||
## Návrh DB API (kanonická funkce)
|
||||
|
||||
Přidat novou repeatable rutinu, např.:
|
||||
|
||||
- `db/routines/R__0xx_fn_forecast_pv_slots_range_canonical_ab.sql`
|
||||
|
||||
Funkce vrátí JSON pole slotů pro `[from, to)` s minimálně:
|
||||
|
||||
- `interval_start`
|
||||
- `pv_a_forecast_raw_w`, `pv_b_forecast_raw_w`
|
||||
- `pv_a_forecast_delta_w`, `pv_b_forecast_delta_w` (po delta-korekci)
|
||||
- `rolling_factor` (globální faktor) + `rolling_effective_factor` (po decay pro slot)
|
||||
- `pv_a_forecast_canonical_w`, `pv_b_forecast_canonical_w` (delta × rolling_effective_factor)
|
||||
|
||||
Poznámky:
|
||||
|
||||
- Delta profil už dnes existuje (`ems.fn_pv_forecast_delta_profile`) a `fn_forecast_pv_slots_range_corrected` už umí per-array delty; tu logiku zrecyklujeme.
|
||||
- Rolling faktor už dnes existuje v DB (`ems.fn_pv_forecast_correction_factor`), jen se dnes aplikuje v Pythonu.
|
||||
- Decay parametrizovat (např. `p_decay_slots int default 16`, `p_min_clamp numeric`, `p_max_clamp numeric`, `p_window_h numeric`).
|
||||
|
||||
## Změny solveru (Python)
|
||||
|
||||
V `backend/services/planning_engine.py`:
|
||||
|
||||
- Přepnout loader PV slotů na kanonickou DB řadu (A/B corrected).
|
||||
- **Odstranit** aplikaci PV korekce v Pythonu (nebo ji dočasně nechat za feature flagem jen jako fallback při chybě DB funkce).
|
||||
- Uložit do `planning_run` diagnostiku (např. `forecast_correction_factor` nahradit/rozšířit o `pv_forecast_method = 'canonical_db_delta+rolling'` + `rolling_factor`).
|
||||
|
||||
## Změny DB pro plánovací sloty a current bundle
|
||||
|
||||
- `db/routines/R__063_fn_load_planning_slots_full.sql`:
|
||||
- zdroj PV A/B musí být `pv_*_forecast_canonical_w` z nové funkce.
|
||||
- zachovat raw/solver sloupce pro audit a UI.
|
||||
- `ems.fn_plan_current_bundle` (repeatable rutina ve `db/routines/`, dohledat a upravit):
|
||||
- pro intervaly z `planning_interval` vracet explicitně:
|
||||
- `pv_a_forecast_solver_w`, `pv_b_forecast_solver_w`, a `pv_forecast_total_w = pva+pvb` (aby UI nemuselo „domýšlet“ přes jiné endpointy),
|
||||
- pro sloty za horizontem (forecast extension) vracet `pv_forecast_total_w` jako **kanonický součet** (canonical A+B) z nové funkce.
|
||||
|
||||
## Změny UI
|
||||
|
||||
V `frontend/src/pages/Planning.tsx`:
|
||||
|
||||
- Pro tabulku slotů a graf použít **jen** PV z `/sites/{id}/plan/current`:
|
||||
- pro plánované sloty: `pv_a_forecast_solver_w + pv_b_forecast_solver_w` (ne `pv-slots-corrected`),
|
||||
- pro forecast-only sloty: `pv_forecast_total_w` (které už bude kanonické z DB).
|
||||
- Endpoint `/forecast/pv-slots-corrected` ponechat pro stránku forecastu a diagnostiku, ale **ne** jako zdroj pro Planning tabulku.
|
||||
|
||||
## Ověření
|
||||
|
||||
- Pro konkrétní slot (např. `home-01`, `10:15` Prague) musí sedět:
|
||||
- UI PV (z `/plan/current`) == PV v solver vstupu == uložené `planning_interval.pv_*_forecast_solver_w`.
|
||||
- Výkonová bilance v tabulce slotů: `PV - load - EV - HP = battery + export(+/- import)` bez „magické energie“.
|
||||
- Doplnit regresní test: UI zobrazuje stejné PV jako `planning_interval` (alespoň na DTO úrovni / snapshot).
|
||||
|
||||
## Dokumentace
|
||||
|
||||
Aktualizovat:
|
||||
|
||||
- `docs/04-modules/forecast.md` (kde vzniká kanonické PV: delta + rolling factor + decay),
|
||||
- `docs/04-modules/planning.md` (solver čte kanonický PV z DB; UI používá stejné sloupce z `/plan/current`),
|
||||
- případně krátká poznámka do `docs/02-architecture.md` k „read-model = single point of truth“ pro plán.
|
||||
|
||||
15
.cursor/rules/documentation-update-discipline.mdc
Normal file
15
.cursor/rules/documentation-update-discipline.mdc
Normal 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 it’s 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/`.
|
||||
37
.cursor/rules/ems-planning-agent-discipline.mdc
Normal file
37
.cursor/rules/ems-planning-agent-discipline.mdc
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: EMS plánování — doptat se, ekonomický zisk, bez mikrocyklů
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EMS agent — plánování a ekonomika
|
||||
|
||||
## Doptat se
|
||||
|
||||
- Pokud zadání **není exaktní** (lokalita, časové okno, cílové SoC, co je bug vs. záměr), **vždy se doptat** před větší změnou kódu/SQL.
|
||||
- Nehádat záměr uživatele (příklad: večerní export za ~3 Kč při buy ~5 Kč může být **správně** pro vyprázdnění před neg dnem).
|
||||
|
||||
## Ekonomický cíl
|
||||
|
||||
- Návrhy a implementace směřuj k **provoznímu zisku** (arbitráž, FVE, neg-sell okno, večerní špičky).
|
||||
- **Výjimka:** neoptimalizovat **mikrocyklování** (souběžný import + export / zbytečné cykly v jednom slotu).
|
||||
|
||||
## Dvě podlahy SoC (home-01, sloupce v `ems.asset_battery`)
|
||||
|
||||
| Sloupec | % | Role |
|
||||
|--------|---|------|
|
||||
| **`reserve_soc_percent`** | 20 | **Export / strategie:** večerní push, ranní peak před `sell<0`, kotvy `neg_evening_reserve_soc_anchors` — cíl „ráno ~20 % před FVE“. Pod tímto plánovač **neplánuje zbytečný export** (V027 komentář). |
|
||||
| **`min_soc_percent`** | 10 | **Spotřeba domu (Deye PASSIVE):** LP a exekuce smí vybíjet baterii pro load až sem — rezerva na **nenadálou spotřebu**, aby se nekupovalo ze sítě za draho. |
|
||||
| **`planner_discharge_floor_percent`** | 5 | Jen **LP relaxace** pod `min_soc` (ne provozní cíl). |
|
||||
|
||||
**Nesplést:** vybít kvůli **prodeji** → podlaha **reserve**; vybít kvůli **domu v noci** → může jít k **min_soc**.
|
||||
|
||||
## Neg okno vs. `buy < 0`
|
||||
|
||||
- **`sell < 0`:** export zakázán; **headroom** = místo v baterii pro FVE v okně (v44 `neg_day_no_grid_before_neg_sell`, prep rampa). **Ne** totéž co „vyčerpat před sell<0“ u **`buy < 0`**.
|
||||
- **`buy < 0`:** levné **nabíjení ze sítě** (priorita importu), ne strategie „vyprázdnit před neg výkupen“.
|
||||
|
||||
Před implementací změny exportních podlah: **zeptat se**, jestli cíl je „k 20 % před svítáním“ vs. „ještě níž pro headroom v sell<0“.
|
||||
|
||||
## Komunikace
|
||||
|
||||
- Bez ritualního „máš pravdu“; konkrétní fakta z DB/MCP, co změnit, jak ověřit.
|
||||
13
.cursor/rules/mcp-postgres-ems.mdc
Normal file
13
.cursor/rules/mcp-postgres-ems.mdc
Normal 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`**.
|
||||
33
.cursor/rules/plan-explain-bundle.mdc
Normal file
33
.cursor/rules/plan-explain-bundle.mdc
Normal 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 0–36h / 36–72h / 72–96h 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ý.
|
||||
14
.cursor/rules/postgres-sql-drop-comment.mdc
Normal file
14
.cursor/rules/postgres-sql-drop-comment.mdc
Normal 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“.
|
||||
26
.cursor/rules/timescale-continuous-aggregate.mdc
Normal file
26
.cursor/rules/timescale-continuous-aggregate.mdc
Normal 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).
|
||||
98
.cursor/skills/ems-plan-explain/SKILL.md
Normal file
98
.cursor/skills/ems-plan-explain/SKILL.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
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`.
|
||||
- **~60 % SoC ve slunci (BA81/KV1)** nebo **ranní export před sell<0 (home-01):** často **v58** (`bc_pv=0` při `sell > min+0,20`) nebo **v33 pre-neg cushion** — plánovaná náhrada: `docs/04-modules/planning-charge-slot-budget.md` (zatím ne v produkci).
|
||||
4. **Mezery modelu** (upozornit jednou větu, když je to relevantní):
|
||||
- LP používá horní strop **`max_charge_power_w`** bez závislosti na SoC → u vysokého SoC může reálný proud být nižší než plán.
|
||||
|
||||
### 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)
|
||||
|
||||
## Související skill
|
||||
|
||||
- **Bug / incident plánovače** (422 Infeasible, degradovaný relaxed solve, večerní export, multi-site): [ems-planner-bug-triage](../ems-planner-bug-triage/SKILL.md) — triáž a návrh fix větve; tento skill zůstává pro vysvětlení slotů.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Hromadná analýza** (`fn_plan_explain_bundle`, `planning_interval` pro více `site_id`) jen proto, že uživatel **neřekl kterou** lokalitu — vždy se **nejprve** zeptat.
|
||||
- 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“.
|
||||
104
.cursor/skills/ems-plan-explain/reference.md
Normal file
104
.cursor/skills/ems-plan-explain/reference.md
Normal 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
|
||||
106
.cursor/skills/ems-planner-bug-triage/SKILL.md
Normal file
106
.cursor/skills/ems-planner-bug-triage/SKILL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: ems-planner-bug-triage
|
||||
description: >-
|
||||
Triages EMS planner bugs from live Postgres (MCP): degraded Infeasible retry chain,
|
||||
evening export vs battery hold, neg-buy/neg-sell days, fixed-tariff sites (BA81/KV1),
|
||||
failed replan vs stale active plan. Use when the user reports planner errors, wrong
|
||||
evening export, battery held while buy ~5 Kč/kWh, relaxed_neg_prep_window,
|
||||
evening_push_hard_suppressed, or multi-site planner comparison. Complements
|
||||
ems-plan-explain — use this skill for incident/bug classification and fix-branch hints.
|
||||
---
|
||||
|
||||
# EMS — triáž bugů plánovače
|
||||
|
||||
## Kdy použít (vs. ems-plan-explain)
|
||||
|
||||
| Situace | Skill |
|
||||
|---------|--------|
|
||||
| „Proč v tom slotu nabíjí / neexportuje?“ | [ems-plan-explain](../ems-plan-explain/SKILL.md) |
|
||||
| „Plánovač blbne / neprodává večer / 422 Infeasible / BA81 vs home-01“ | **tento skill** |
|
||||
| Degradovaný plán (relaxed solve) ale run `active` | **tento skill** |
|
||||
| Porovnání více lokalit uživatel **explicitně** jmenoval | **tento skill** (jinak jedna site) |
|
||||
|
||||
Typické spouštěče: *neprodává večer*, *drží baterku @ 5 Kč*, *nepřeplánovalo po OTE*, *Solver: Infeasible*, *evening_push prázdný*, *sell<0 a výroba do site*.
|
||||
|
||||
## Tvrdá pravidla
|
||||
|
||||
1. **MCP first** — server `user-postgres-ems`, nástroj `query`, jen `SELECT`. Po chybě: přesný text + VPN/MCP zapnutý.
|
||||
2. **Selhání plánu není v `planning_run`** — status je jen `active` / `superseded` / `comparison` / `draft`. API **422** a scheduler exception = logy backendu; aktivní run může být **starý** nebo **degradovaný**.
|
||||
3. Rozliš: **plán v DB** vs **exekuce** — `site_operating_mode != AUTO` → rolling replan se **přeskakuje** (ne nutně solver bug).
|
||||
4. **Dvě podlahy SoC:** export / strategie → **`reserve_soc_percent`** (~20 %); dům v noci (Deye PASSIVE) → může jít k **`min_soc_percent`** (~10 %). Před návrhem večerního vývoje se u exportu domluv **`reserve`**, ne `min_soc`, pokud uživatel neřekne jinak.
|
||||
5. **`neg sell` ≠ `buy < 0`:** vyprázdnit před **`sell < 0`** (headroom FVE) není totéž co strategie před **`buy < 0`** (levný import).
|
||||
|
||||
## Checklist triáže
|
||||
|
||||
```
|
||||
- [ ] site_id (kód / id / výběr ze seznamu — viz ems-plan-explain reference §0)
|
||||
- [ ] site_operating_mode + poslední řádky site_operating_mode_log
|
||||
- [ ] active planning_run: id, created_at, run_type, triggered_by, soc_at_replan_wh
|
||||
- [ ] solver_params.inputs: relaxed_*, evening_push_hard_suppressed, neg_evening_*,
|
||||
pre_neg_pv_export_forecast_ok, charge_acquisition_buy_czk_kwh
|
||||
- [ ] Večerní okno: planning_interval (battery/grid setpoint, soc target, ceny)
|
||||
- [ ] Zítra: neg sloty ve vw_site_effective_price; snap neg_sell_day_pv_usable_wh
|
||||
- [ ] Klasifikace bug typu A–E (reference.md §1)
|
||||
- [ ] Návrh fix větve + ověření po fixu
|
||||
```
|
||||
|
||||
## Rozhodovací strom
|
||||
|
||||
```
|
||||
Replan 422 / žádný nový run po OTE?
|
||||
├─ mode != AUTO → provozní (MANUAL/SELF_SUSTAIN), plán zastaralý
|
||||
├─ poslední run any_relaxed + evening_push_hard_suppressed
|
||||
│ └─ BUG typ A/B: degradovaný solve — Branch 1 + 2
|
||||
└─ žádný řádek v planning_run → backend log „Infeasible“ (Branch 1)
|
||||
|
||||
Večer neprodává, drží vysoké SoC?
|
||||
├─ evening_push_hard_suppressed = true → tvrdý push vypnutý (Branch 2)
|
||||
├─ grid import @ drahý buy + vysoké SoC → terminal SoC + pos_sell_pre_neg_buy (Branch 2/5)
|
||||
├─ zítra buy<0 + vysoká FVE, pre_neg ok = false
|
||||
│ └─ měl jít večer k reserve, ne držet ~80 % (Branch 2)
|
||||
└─ fixed tarif + evening_push_ts = [] → v58 / charge-slot-budget (Branch 3)
|
||||
|
||||
BA81: sell<0, výroba „do site“ / export?
|
||||
└─ deye_gen_microinverter_cutoff + pole B — exekuce GEN (Branch 4)
|
||||
```
|
||||
|
||||
## Retry řetězec (solve_dispatch)
|
||||
|
||||
Při Infeasible solver postupně zapíná (viz `planning_engine.py` ~4216):
|
||||
|
||||
1. `relaxed_expensive_import`
|
||||
2. `relaxed_neg_buy_charge`
|
||||
3. `relaxed_neg_prep_window` → **vypne** neg-evening push, kotvy, prep hold; **`evening_push_hard_suppressed = true`**
|
||||
4. `neg_sell_phases_fallback` (prep_soc = 100 %)
|
||||
|
||||
**Symptom degradace:** run `active`, ale ve špičce **import ze sítě** místo exportu baterie; `neg_evening_push_ts` prázdné; plán drží SoC nad očekávání před neg dnem.
|
||||
|
||||
## Výstup pro uživatele (šablona)
|
||||
|
||||
1. **Fakta z DB** — run id, čas, mode, 3–5 klíčových flags, 2–3 večerní sloty (W, SoC %, buy/sell).
|
||||
2. **Root cause** — jedna věta: degradovaný retry / konfigurace site / režim / exekuce.
|
||||
3. **Bug typ** — A–E z [reference.md](reference.md).
|
||||
4. **Doporučená větev opravy** — Branch 1–5 + soubor v repu.
|
||||
5. **Ověření** — MCP dotaz nebo `pytest backend/tests/test_planning_dispatch_milp.py -k "…"`.
|
||||
|
||||
## Kód a dokumentace
|
||||
|
||||
| Téma | Soubor |
|
||||
|------|--------|
|
||||
| LP + retry | `backend/services/planning_engine.py` — `solve_dispatch` |
|
||||
| `evening_push_hard_suppressed` | ~2810 |
|
||||
| `pos_sell_pre_neg_buy` → `ge=0` | ~3929 |
|
||||
| SQL masky | `db/routines/R__063_fn_load_planning_slots_full.sql` |
|
||||
| Charge-slot budget (plán) | `docs/04-modules/planning-charge-slot-budget.md` |
|
||||
| Changelog v55–v57 | `docs/planning-changelog.md` |
|
||||
| Bisect fixture | `scripts/diagnose_home01_infeasible.py` |
|
||||
|
||||
## SQL a archetypy site
|
||||
|
||||
→ [reference.md](reference.md)
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Diagnostikovat Infeasible **bez** `solver_params.inputs` z posledního úspěšného runu — může být právě ten degradovaný.
|
||||
- Zaměnit „plán neexportuje“ s „exporter neběží“ — v MANUAL/SELF_SUSTAIN se plán nemusí aktualizovat.
|
||||
- U fixního tarifu očekávat stejné `evening_push_ts` jako u home-01 — BA81/KV1 mají jinou větev (viz reference §3).
|
||||
202
.cursor/skills/ems-planner-bug-triage/reference.md
Normal file
202
.cursor/skills/ems-planner-bug-triage/reference.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# EMS planner bug triage — reference (MCP)
|
||||
|
||||
Všechno jen **read-only** `SELECT`. Server: **`user-postgres-ems`**, nástroj **`query`**.
|
||||
|
||||
Seznam lokalit a `fn_plan_explain_bundle` → [ems-plan-explain/reference.md](../ems-plan-explain/reference.md).
|
||||
|
||||
---
|
||||
|
||||
## 1) Bug typy (klasifikace)
|
||||
|
||||
| Typ | Popis | Typické flags / signály | Fix větev |
|
||||
|-----|--------|-------------------------|-----------|
|
||||
| **A** | Degradovaný solve — Infeasible retry krok 3+ | `any_relaxed_solve`, `relaxed_neg_prep_window`, `evening_push_hard_suppressed` | Branch 1: granulární relaxace + failed-run journal |
|
||||
| **B** | Večerní export chybí před neg dnem | Vysoké SoC ve špičce, `grid_setpoint_w > 0` @ ~5 Kč buy, `neg_evening_push_ts` prázdné, zítra `buy<0` / vysoká FVE | Branch 2: neg-večer k `reserve_soc` |
|
||||
| **C** | Fixní tarif — špatné nabíjení / žádný večerní push | `evening_push_ts: []`, SoC ~60–80 % ve slunci, v58 `bc_pv=0` | Branch 3: charge-slot-budget |
|
||||
| **D** | Provoz / exekuce, ne LP | `mode != AUTO`, starý `active` run, ruční MANUAL | Návrat do AUTO, ruční replan po fixu |
|
||||
| **E** | GEN port / pole B při sell<0 | BA81 + `deye_gen_microinverter_cutoff`, audit export v sell<0 | Branch 4: cutoff exekuce |
|
||||
|
||||
---
|
||||
|
||||
## 2) MCP — aktivní run + relax flags
|
||||
|
||||
Nahraď `$site_id` (např. 2 = home-01):
|
||||
|
||||
```sql
|
||||
select pr.id,
|
||||
pr.created_at,
|
||||
pr.run_type,
|
||||
pr.triggered_by,
|
||||
pr.soc_at_replan_wh,
|
||||
pr.solver_params->'inputs'->>'any_relaxed_solve' as relaxed,
|
||||
pr.solver_params->'inputs'->>'relaxed_expensive_import' as r_exp_import,
|
||||
pr.solver_params->'inputs'->>'relaxed_neg_buy_charge' as r_neg_buy,
|
||||
pr.solver_params->'inputs'->>'relaxed_neg_prep_window' as r_neg_prep,
|
||||
pr.solver_params->'inputs'->>'neg_sell_phases_fallback' as r_phases_off,
|
||||
pr.solver_params->'inputs'->>'evening_push_hard_suppressed' as push_suppressed,
|
||||
pr.solver_params->'inputs'->>'pre_neg_pv_export_forecast_ok' as pre_neg_ok,
|
||||
pr.solver_params->'inputs'->>'neg_evening_push_ts' as neg_eve_push,
|
||||
pr.solver_params->'inputs'->>'evening_push_ts' as evening_push,
|
||||
pr.solver_params->'inputs'->>'charge_acquisition_buy_czk_kwh' as acq,
|
||||
pr.solver_params->'inputs'->>'neg_sell_day_pv_usable_wh' as neg_pv_wh,
|
||||
pr.solver_params->>'planner_build_tag' as tag
|
||||
from ems.planning_run pr
|
||||
where pr.site_id = $site_id
|
||||
and pr.status = 'active'
|
||||
order by pr.created_at desc
|
||||
limit 1;
|
||||
```
|
||||
|
||||
Poslední běhy (včetně comparison) za 48 h:
|
||||
|
||||
```sql
|
||||
select pr.id, pr.status, pr.run_type, pr.created_at,
|
||||
pr.solver_params->'inputs'->>'relaxed_neg_prep_window' as r3,
|
||||
pr.solver_params->'inputs'->>'evening_push_hard_suppressed' as push_sup
|
||||
from ems.planning_run pr
|
||||
where pr.site_id = $site_id
|
||||
and pr.created_at >= now() - interval '48 hours'
|
||||
order by pr.created_at desc
|
||||
limit 20;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Site archetypy (orientace)
|
||||
|
||||
| | **home-01** | **BA81** | **KV1** |
|
||||
|---|-------------|----------|---------|
|
||||
| Buy | spot | fixed ~2,55 NT | fixed 5,25 |
|
||||
| `block_export_on_negative_sell` | false | false | **true** |
|
||||
| Neg sell fáze | 80 % prep | default | **100 % (off)** |
|
||||
| `deye_gen_microinverter_cutoff` | false | **true** | false |
|
||||
| `planner_terminal_soc_value_factor` | **0,9** | 0,2 | 0,2 |
|
||||
| Typický večerní bug | A + B (relaxed + drží SoC) | C (no evening_push) | občas A, jinak OK |
|
||||
|
||||
Konfigurace z DB:
|
||||
|
||||
```sql
|
||||
select s.code,
|
||||
smc.purchase_pricing_mode,
|
||||
sgc.block_export_on_negative_sell,
|
||||
ai.deye_gen_microinverter_cutoff_enabled,
|
||||
ab.reserve_soc_percent,
|
||||
ab.min_soc_percent,
|
||||
ab.planner_neg_sell_prep_soc_percent,
|
||||
ab.planner_terminal_soc_value_factor
|
||||
from ems.site s
|
||||
left join ems.site_market_config smc
|
||||
on smc.site_id = s.id and smc.valid_to is null
|
||||
left join ems.site_grid_connection sgc on sgc.site_id = s.id
|
||||
left join ems.asset_inverter ai
|
||||
on ai.site_id = s.id and ai.code = 'deye-main'
|
||||
left join ems.asset_battery ab
|
||||
on ab.site_id = s.id and ab.code = 'bat-main'
|
||||
where s.code in ('home-01', 'BA81', 'KV1');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Provozní režim a log
|
||||
|
||||
```sql
|
||||
select mode_code from ems.site_operating_mode where site_id = $site_id;
|
||||
|
||||
select activated_at at time zone 'Europe/Prague' as ts_prague,
|
||||
mode_code,
|
||||
activated_by
|
||||
from ems.site_operating_mode_log
|
||||
where site_id = $site_id
|
||||
order by activated_at desc
|
||||
limit 8;
|
||||
```
|
||||
|
||||
Rolling replan běží jen v **AUTO**.
|
||||
|
||||
---
|
||||
|
||||
## 5) Večerní okno — planning_interval
|
||||
|
||||
```sql
|
||||
select pi.interval_start at time zone 'Europe/Prague' as slot_prague,
|
||||
pi.battery_setpoint_w,
|
||||
pi.grid_setpoint_w,
|
||||
pi.battery_soc_target_pct,
|
||||
pi.effective_buy_price,
|
||||
pi.effective_sell_price
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = (
|
||||
select id from ems.planning_run
|
||||
where site_id = $site_id and status = 'active'
|
||||
order by created_at desc limit 1
|
||||
)
|
||||
and pi.interval_start >= (current_timestamp at time zone 'Europe/Prague')::date
|
||||
+ interval '17 hours'
|
||||
and pi.interval_start < (current_timestamp at time zone 'Europe/Prague')::date
|
||||
+ interval '1 day' + interval '6 hours'
|
||||
order by pi.interval_start;
|
||||
```
|
||||
|
||||
**Červená vlajka:** `battery_setpoint_w = 0` a `grid_setpoint_w > 0` při sell ~3 Kč a SoC > reserve — import místo exportu baterie.
|
||||
|
||||
---
|
||||
|
||||
## 6) Zítřejší neg ceny
|
||||
|
||||
```sql
|
||||
select interval_start at time zone 'Europe/Prague' as slot_prague,
|
||||
effective_buy_price_czk_kwh as buy,
|
||||
effective_sell_price_czk_kwh as sell
|
||||
from ems.vw_site_effective_price
|
||||
where site_id = $site_id
|
||||
and interval_start >= (current_timestamp at time zone 'Europe/Prague')::date
|
||||
+ interval '1 day'
|
||||
and interval_start < (current_timestamp at time zone 'Europe/Prague')::date
|
||||
+ interval '2 days'
|
||||
and (effective_buy_price_czk_kwh < 0 or effective_sell_price_czk_kwh < 0)
|
||||
order by interval_start;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Slovník solver_params.inputs (výběr)
|
||||
|
||||
| Klíč | Význam |
|
||||
|------|--------|
|
||||
| `evening_push_hard_suppressed` | true = **bez** tvrdého `ge_bat` push (typicky retry krok 3) |
|
||||
| `relaxed_neg_prep_window` | Vypnuty neg-evening kotvy, prep hold, neg evening push |
|
||||
| `pre_neg_pv_export_forecast_ok` | Cushion FVE v sell<0 okně — false → nemá exportovat ranní PV před neg |
|
||||
| `neg_evening_push_ts` | Sloty D−1 večer pro vývoj před neg oknem |
|
||||
| `charge_acquisition_buy_czk_kwh` | Účinná cena energie v baterii pro arbitráž exportu |
|
||||
| `neg_sell_day_pv_usable_wh` | Forecast Wh do baterie v sell<0 okně zítřka |
|
||||
| `morning_pre_neg_export_hard` | Tvrdý ranní export před sell<0 |
|
||||
|
||||
Plný snap: `select ems.fn_planning_run_debug(<run_id>);`
|
||||
|
||||
---
|
||||
|
||||
## 8) Fix větve (implementační plán v repu)
|
||||
|
||||
| Branch | Obsah | Priorita |
|
||||
|--------|--------|----------|
|
||||
| **1** | Failed-run journal, bisect Infeasible, granulární relaxace | P0 |
|
||||
| **2** | home-01 neg-večer → `reserve_soc`, oddělit push od prep relax | P0 |
|
||||
| **3** | charge-slot-budget v R__063, BA81/KV1 večerní export | P1 |
|
||||
| **4** | BA81 GEN cutoff audit | P1 |
|
||||
| **5** | Dynamický terminal SoC při future neg buy | P2 |
|
||||
|
||||
Detail: plán v `.cursor/plans/` nebo `docs/planning-changelog.md` + `docs/04-modules/planning-charge-slot-budget.md`.
|
||||
|
||||
---
|
||||
|
||||
## 9) Ověření po fixu
|
||||
|
||||
```bash
|
||||
pytest backend/tests/test_planning_dispatch_milp.py -k "evening or NegSell or Infeasible"
|
||||
```
|
||||
|
||||
```bash
|
||||
python scripts/diagnose_home01_infeasible.py
|
||||
```
|
||||
|
||||
MCP po deployi — nový active run **bez** `relaxed_neg_prep_window` nebo s `evening_push_hard_suppressed: false` a večerní sloty s `grid_setpoint_w < 0`.
|
||||
@@ -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,9 @@ 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
|
||||
PLANNING_ENGINE_VERSION=v1 # v1 = původní planner, v2 = nová policy větev
|
||||
PLANNING_ENGINE_COMPARE_ENABLED=false # true = spočítat i druhou verzi a uložit comparison do solver_params
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ dist/
|
||||
*.tsbuildinfo
|
||||
frontend/vendor/
|
||||
frontend/scripts/.native-tmp/
|
||||
.claude/settings.local.json
|
||||
|
||||
15
.idea/data_source_mapping.xml
generated
15
.idea/data_source_mapping.xml
generated
@@ -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
2
.idea/sqldialects.xml
generated
@@ -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>
|
||||
80
CLAUDE.md
80
CLAUDE.md
@@ -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,10 +44,13 @@ 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_*` |
|
||||
| `backend/services/` | Python služby (v repozitáři zatím hlavně plánování) |
|
||||
| `backend/services/planning/` | Moduly plánovače: `constants` (vč. všech ekonomických penalt), `types`, `forecast`, `db_io`, `heuristics`; `planning_engine.py` = solver + orchestrace + fasáda (re-export, importy beze změny) |
|
||||
| `backend/tests/golden/` + `scripts/harness/` | Ekonomický regresní harness: golden replay gate (`test_golden_replay.py`), `extract_fixtures.py`, `economics_report.py`, `penalty_audit.py` — viz `scripts/harness/README.md`; **při změně plánovače musí projít golden gate** |
|
||||
|
||||
---
|
||||
|
||||
@@ -52,11 +66,11 @@ 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).
|
||||
|
||||
8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků – dle docs).
|
||||
8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků – dle docs). **Ekonomika slotů:** masky + guardy v `solve_dispatch` — viz `docs/04-modules/planning.md`. **Arbitráž baterie:** neúčtovat `buy[t]`/`sell[t]` ve stejném 15min slotu jako nákup/prodej téže kWh; `min(buy)` horizontu ≠ cena nabití (home-01 nabíjí hodiny, ne jednu čtvrthodinu). Povinné: `docs/04-modules/planning-arbitrage-accounting.md`.
|
||||
|
||||
9. **Zelený bonus je na `asset_pv_array`** (sloupce `green_bonus_*`), **nikdy** v `site_market_config`. Výpočet přes `fn_green_bonus_revenue()`. Bonus se nepočítá v solveru – pouze v audit_filler (`fn_fill_audit_interval`).
|
||||
|
||||
@@ -64,26 +78,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 **36–96h** 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** (0–36h), **0,7** (36–72h), **0,4** (72–96h). 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[T−1]` (Kč; SoC v Wh), kde **`planner_terminal_soc_value_factor`** je **`ems.asset_battery.planner_terminal_soc_value_factor`** načtené přes **`ems.fn_planning_site_context`** (žádný skrytý faktor v Pythonu). **Fázované SoC v okně `sell < 0` (v32):** `planner_neg_sell_prep_soc_percent`, `planner_neg_sell_full_soc_tail_slots`, `planner_neg_sell_vent_min_sell_czk_kwh` na **`asset_battery`**; curtail A → reg 340, plná baterie = solar sell off bez zápisu 340. `market_price_stats` / `fn_get_predicted_price` zůstávají pro statistiky; detail: `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`.
|
||||
|
||||
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (č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ů 60–499:** 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 **3–6** 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 **3–6** = `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 62–64:** před zařazením do fronty **čtení** 62–64; 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** 62–64 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 62–64 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ů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **`export_mode=PV_SURPLUS`** → **108=0**, **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10–100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** reg **108** EMS **nezapisuje** (selling first = **142**), **109**=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, 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 11–12 %, migrace V029 + komentář sloupce).
|
||||
|
||||
@@ -96,7 +123,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 +136,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 +155,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 +174,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 +193,45 @@ 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`, `docs/planning-changelog.md`, `planning_engine.py` |
|
||||
| Rozpočet nabíjecích slotů (Wh × ceny × forecast; plánováno) | `docs/04-modules/planning-charge-slot-budget.md` — náhrada v58 + pre-neg cushion |
|
||||
| Záporný výkup, bod T, termika, bazén (home-01 strategie) | `docs/04-modules/planning-neg-sell-strategy.md` |
|
||||
| Arbitráž baterie (mezi sloty ≠ buy/sell v jednom 15min) | `docs/04-modules/planning-arbitrage-accounting.md` |
|
||||
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` |
|
||||
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
|
||||
| Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` |
|
||||
| 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`**. |
|
||||
| **Refaktor „Čistý plánovač“ (fáze, stav, nasazení v2)** | **`docs/refactor-clean-planner.md`**; verze enginu v1/v2 + env flagy: `docs/04-modules/planning.md` (sekce Verze enginu); changelog 2026-06-11 |
|
||||
| **Čisté jádro plánovače v2** | `backend/services/planning/solver_v2.py`, testy `backend/tests/test_solver_v2.py`, eval `scripts/harness/solver_v2_eval.py` |
|
||||
| **Delta-triáž neekonomického chování (agent skill)** | **`.claude/skills/ems-delta-triage/`** — realita vs plán vs shadow peer vs oracle, verdikt s Kč |
|
||||
| **Vysvětlení plánu (agent skill)** | **`.cursor/skills/ems-plan-explain/`** — `fn_plan_explain_bundle`, sloty, proč nabíjí/exportuje |
|
||||
| **Triáž bugů plánovače (agent skill)** | **`.cursor/skills/ems-planner-bug-triage/`** — Infeasible/relaxed solve, večerní export, neg den, BA81/KV1; MCP SQL v `reference.md` |
|
||||
|
||||
---
|
||||
|
||||
## Konvence (krátce)
|
||||
|
||||
- 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`.
|
||||
|
||||
@@ -45,6 +45,8 @@ class Settings(BaseSettings):
|
||||
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)
|
||||
planning_cheap_price_threshold: float = Field(default=0.85)
|
||||
planning_expensive_price_threshold: float = Field(default=1.15)
|
||||
planning_engine_version: str = Field(default="v1")
|
||||
planning_engine_compare_enabled: bool = Field(default=False)
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -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
543
backend/app/lifespan.py
Normal 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()
|
||||
1219
backend/app/main.py
1219
backend/app/main.py
File diff suppressed because it is too large
Load Diff
22
backend/app/refresh_negative_prices.py
Normal file
22
backend/app/refresh_negative_prices.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
192
backend/app/routers/energy_flows.py
Normal file
192
backend/app/routers/energy_flows.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
33
backend/app/routers/me.py
Normal 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]
|
||||
@@ -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
|
||||
@@ -40,58 +41,107 @@ class PlanningIntervalDto(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CurrentPlanResponseModel(BaseModel):
|
||||
class PlanningBundleDto(BaseModel):
|
||||
run: dict[str, Any]
|
||||
intervals: list[PlanningIntervalDto]
|
||||
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,
|
||||
class CurrentPlanResponseModel(PlanningBundleDto):
|
||||
pass
|
||||
|
||||
|
||||
class ComparisonSlotDiffDto(BaseModel):
|
||||
interval_start: str
|
||||
active: dict[str, Any]
|
||||
comparison: dict[str, Any]
|
||||
|
||||
|
||||
class PlanningCompareResponseModel(BaseModel):
|
||||
active: PlanningBundleDto
|
||||
comparison: PlanningBundleDto
|
||||
diff: dict[str, Any]
|
||||
slot_diffs: list[ComparisonSlotDiffDto]
|
||||
|
||||
|
||||
def _bundle_from_payload(payload: dict[str, Any], *, run_key: str) -> PlanningBundleDto:
|
||||
run_raw = payload.get(run_key) or {}
|
||||
if not isinstance(run_raw, dict):
|
||||
run_raw = {}
|
||||
intervals_raw = payload.get("intervals") or []
|
||||
if not isinstance(intervals_raw, list):
|
||||
intervals_raw = []
|
||||
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
|
||||
summary = payload.get("summary") or {}
|
||||
if not isinstance(summary, dict):
|
||||
summary = {}
|
||||
return PlanningBundleDto(run=run_raw, intervals=intervals, summary=summary)
|
||||
|
||||
|
||||
def _bundle_from_current(payload: dict[str, Any]) -> PlanningBundleDto:
|
||||
return _bundle_from_payload(payload, run_key="run")
|
||||
|
||||
|
||||
def _bundle_from_debug(payload: dict[str, Any]) -> PlanningBundleDto:
|
||||
return _bundle_from_payload(payload, run_key="planning_run")
|
||||
|
||||
|
||||
def _build_plan_diff(
|
||||
active: PlanningBundleDto,
|
||||
comparison: PlanningBundleDto,
|
||||
) -> tuple[dict[str, Any], list[ComparisonSlotDiffDto]]:
|
||||
active_by_ts = {i.interval_start: i for i in active.intervals}
|
||||
compare_by_ts = {i.interval_start: i for i in comparison.intervals}
|
||||
diffs: list[ComparisonSlotDiffDto] = []
|
||||
interesting_keys = (
|
||||
"battery_setpoint_w",
|
||||
"battery_soc_target_pct",
|
||||
"grid_setpoint_w",
|
||||
"export_limit_w",
|
||||
"export_mode",
|
||||
"deye_physical_mode",
|
||||
"deye_gen_cutoff_enabled",
|
||||
"pv_a_curtailed_w",
|
||||
"expected_cost_czk",
|
||||
)
|
||||
for ts, a in active_by_ts.items():
|
||||
b = compare_by_ts.get(ts)
|
||||
if b is None:
|
||||
continue
|
||||
active_payload = a.model_dump()
|
||||
comparison_payload = b.model_dump()
|
||||
if any(active_payload.get(k) != comparison_payload.get(k) for k in interesting_keys):
|
||||
diffs.append(
|
||||
ComparisonSlotDiffDto(
|
||||
interval_start=ts,
|
||||
active={k: active_payload.get(k) for k in interesting_keys},
|
||||
comparison={k: comparison_payload.get(k) for k in interesting_keys},
|
||||
)
|
||||
)
|
||||
|
||||
def _summary_num(bundle: PlanningBundleDto, key: str) -> float:
|
||||
raw = bundle.summary.get(key)
|
||||
try:
|
||||
return float(raw) if raw is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
active_cost = _summary_num(active, "total_expected_cost_czk")
|
||||
compare_cost = _summary_num(comparison, "total_expected_cost_czk")
|
||||
diff = {
|
||||
"active_total_expected_cost_czk": active_cost,
|
||||
"comparison_total_expected_cost_czk": compare_cost,
|
||||
"total_expected_cost_czk": round(active_cost - compare_cost, 4),
|
||||
"absolute_total_expected_cost_czk": round(abs(active_cost - compare_cost), 4),
|
||||
"active_charge_slots": int(_summary_num(active, "charge_slots")),
|
||||
"comparison_charge_slots": int(_summary_num(comparison, "charge_slots")),
|
||||
"active_discharge_slots": int(_summary_num(active, "discharge_slots")),
|
||||
"comparison_discharge_slots": int(_summary_num(comparison, "discharge_slots")),
|
||||
"active_export_slots": int(_summary_num(active, "export_slots")),
|
||||
"comparison_export_slots": int(_summary_num(comparison, "export_slots")),
|
||||
"changed_slots": len(diffs),
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
return diff, diffs
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentPlanResponseModel)
|
||||
@@ -100,72 +150,69 @@ async def get_current_plan(
|
||||
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]
|
||||
plan = _bundle_from_current(bundle)
|
||||
return CurrentPlanResponseModel(
|
||||
run=record_to_dict(run_row),
|
||||
intervals=intervals,
|
||||
summary=summary,
|
||||
run=plan.run,
|
||||
intervals=plan.intervals,
|
||||
summary=plan.summary,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/compare", response_model=PlanningCompareResponseModel)
|
||||
async def get_plan_compare(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> PlanningCompareResponseModel:
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
payload = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_plan_compare_bundle($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
payload = json.loads(payload)
|
||||
err = payload.get("error")
|
||||
if err == "no_active_plan":
|
||||
raise HTTPException(status_code=404, detail="No active plan")
|
||||
if err == "no_comparison_plan":
|
||||
raise HTTPException(status_code=404, detail="No comparison plan")
|
||||
|
||||
active_raw = payload.get("active") or {}
|
||||
compare_raw = payload.get("comparison")
|
||||
if not isinstance(active_raw, dict):
|
||||
active_raw = {}
|
||||
if not isinstance(compare_raw, dict):
|
||||
raise HTTPException(status_code=404, detail="No comparison plan")
|
||||
|
||||
active = _bundle_from_current(active_raw)
|
||||
diff, slot_diffs = _build_plan_diff(active, comparison)
|
||||
return PlanningCompareResponseModel(
|
||||
active=active,
|
||||
comparison=comparison,
|
||||
diff=diff,
|
||||
slot_diffs=slot_diffs,
|
||||
)
|
||||
|
||||
|
||||
@@ -176,18 +223,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 +242,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 +258,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(
|
||||
|
||||
209
backend/app/routers/site_configuration.py
Normal file
209
backend/app/routers/site_configuration.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""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",
|
||||
)
|
||||
await conn.execute(
|
||||
"select ems.fn_refresh_site_pv_delta_profile_cache($1::int)",
|
||||
site_id,
|
||||
)
|
||||
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"),
|
||||
}
|
||||
811
backend/app/routers/sites.py
Normal file
811
backend/app/routers/sites.py
Normal 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]}
|
||||
221
backend/scripts/backfill_ote_prices.py
Normal file
221
backend/scripts/backfill_ote_prices.py
Normal 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()
|
||||
@@ -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))
|
||||
|
||||
3
backend/services/control/__init__.py
Normal file
3
backend/services/control/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Deye / Modbus control export modules."""
|
||||
|
||||
from .exporter_monolith import * # noqa: F401,F403
|
||||
264
backend/services/control/deye_helpers.py
Normal file
264
backend/services/control/deye_helpers.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""Čisté Deye konstanty a helpery pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.control.models import InverterConfig
|
||||
|
||||
PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
# Hodiny Deye 62-64: po zápisu sekundy na zařízení dál běží, verify musí být toleranční.
|
||||
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120
|
||||
# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund.
|
||||
DEYE_CLOCK_DRIFT_OK_SEC = 60
|
||||
# A zároveň neuplynul tento interval od posledního syncu / potvrzení driftu.
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24
|
||||
|
||||
# Deye LV baterie: převod výkon -> proud pro registry 108/109.
|
||||
BATT_VOLTAGE_V = 51.2
|
||||
|
||||
# Reg 178 - bitové pole: bity 4-5 (peak shaving switch) a bity 0-1 (MI export cutoff).
|
||||
REG178_SELL = 0b00100000
|
||||
REG178_PASSIVE = 0b00110000
|
||||
REG178_VERIFY_MASK = 0x0030
|
||||
REG178_MI_EXPORT_MASK = 0x0003
|
||||
REG178_MI_EXPORT_DISABLE = 0b10
|
||||
REG178_MI_EXPORT_ENABLE = 0b11
|
||||
REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_MI_EXPORT_MASK
|
||||
|
||||
DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145})
|
||||
DEYE_TOU_POWER_REGS = frozenset(range(154, 160))
|
||||
DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350
|
||||
|
||||
# Neaktivní TOU bloky (3-6): Deye často 23:59 (2359) neuloží, 23:55 je stabilní.
|
||||
DEYE_TOU_INACTIVE_HHMM = 2355
|
||||
|
||||
_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
|
||||
[
|
||||
150,
|
||||
151,
|
||||
152,
|
||||
153,
|
||||
156,
|
||||
157,
|
||||
158,
|
||||
159,
|
||||
168,
|
||||
169,
|
||||
170,
|
||||
171,
|
||||
174,
|
||||
175,
|
||||
176,
|
||||
177,
|
||||
]
|
||||
)
|
||||
|
||||
DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64})
|
||||
|
||||
DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
108: "max_charge_a (max nabíjecí proud baterie)",
|
||||
109: "max_discharge_a (max vybíjecí proud baterie)",
|
||||
141: "energy_mode (0, EMS nemění)",
|
||||
142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)",
|
||||
143: "export_limit_w (max export do sítě)",
|
||||
145: "solar_sell (0=disabled, 1=enabled)",
|
||||
340: "max_solar_power_w (strop DC PV A v W; cap z fn_inverter_pv_a_max_w / deye_reg340_max_solar_w)",
|
||||
178: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3; bits4-5 peak shaving 32/48)",
|
||||
148: "time_point_1_time",
|
||||
149: "time_point_2_time",
|
||||
154: "time_point_1_power_w",
|
||||
155: "time_point_2_power_w",
|
||||
166: "time_point_1_soc_min_pct",
|
||||
167: "time_point_2_soc_min_pct",
|
||||
172: "time_point_1_grid_charge",
|
||||
173: "time_point_2_grid_charge",
|
||||
62: "system_time_year_month",
|
||||
63: "system_time_day_hour",
|
||||
64: "system_time_min_sec",
|
||||
}
|
||||
for _tp_i in range(6):
|
||||
_n = _tp_i + 1
|
||||
DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time")
|
||||
DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w")
|
||||
DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct")
|
||||
DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge")
|
||||
|
||||
|
||||
def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
|
||||
return (int(expected_i) & REG178_VERIFY_MASK_COMBINED) == (
|
||||
int(actual_i) & REG178_VERIFY_MASK_COMBINED
|
||||
)
|
||||
|
||||
|
||||
def deye_mi_export_cutoff_want_enabled(
|
||||
*,
|
||||
gen_microinverter_cutoff_enabled: bool,
|
||||
deye_gen_cutoff_enabled: bool,
|
||||
export_ban: bool,
|
||||
deye_mode: str,
|
||||
) -> bool:
|
||||
"""
|
||||
True = zapnout MI export cut-off (reg 178 bits 0–1 = 11b).
|
||||
|
||||
Plán může mít z_gen_cutoff=0 (PV B jen do domu v LP), ale bez cut-off na GEN portu
|
||||
mikroinvertory fyzicky exportují do sítě — při export_ban (záporná vykupní, grid≥0)
|
||||
cut-off vynutit i bez solver flagu.
|
||||
"""
|
||||
if not gen_microinverter_cutoff_enabled:
|
||||
return False
|
||||
if deye_mode == "SELL":
|
||||
return False
|
||||
return bool(deye_gen_cutoff_enabled) or bool(export_ban)
|
||||
|
||||
|
||||
def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool:
|
||||
"""True = po 3x mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr)."""
|
||||
return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN
|
||||
|
||||
|
||||
def _deye_tou_power_verify_match(
|
||||
expected_i: int, actual_i: int, inv: InverterConfig
|
||||
) -> bool:
|
||||
"""Firmware často clampne TOU power W na max z reg. 108/109 x 51.2 V."""
|
||||
if int(actual_i) == int(expected_i):
|
||||
return True
|
||||
max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V)
|
||||
max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
a = int(actual_i)
|
||||
return a == max_w_charge or a == max_w_discharge
|
||||
|
||||
|
||||
def _deye_reg178_verify_with_double_read(
|
||||
expected_i: int, actual_first: int, actual_second: int | None
|
||||
) -> tuple[bool, int]:
|
||||
"""
|
||||
Vrátí (shoda, hodnota_pro_journal).
|
||||
Druhé čtení použít jen když první neprojde maskou (RS485 / glitch).
|
||||
"""
|
||||
if _deye_reg178_verify_match(expected_i, actual_first):
|
||||
return True, actual_first
|
||||
if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second):
|
||||
return True, int(actual_second)
|
||||
return False, actual_first
|
||||
|
||||
|
||||
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
|
||||
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,
|
||||
*,
|
||||
min_w: int = 0,
|
||||
) -> int:
|
||||
"""Hodnota pro Deye reg 340 (max solar power, W) z capu a plánovaného curtailmentu pole A."""
|
||||
if curtail_w <= 0:
|
||||
raw = int(cap_w)
|
||||
else:
|
||||
raw = max(0, min(int(cap_w), int(forecast_w) - int(curtail_w)))
|
||||
if raw > 0 and int(min_w) > 0:
|
||||
return max(int(min_w), raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _prague_minute_start_utc() -> datetime:
|
||||
"""UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague."""
|
||||
p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
|
||||
return p.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None:
|
||||
"""Dekódování reg 62-64 (Deye system time v Europe/Prague)."""
|
||||
try:
|
||||
year = (int(r62) >> 8) + 2000
|
||||
month = int(r62) & 0xFF
|
||||
day = int(r63) >> 8
|
||||
hour = int(r63) & 0xFF
|
||||
minute = int(r64) >> 8
|
||||
second = int(r64) & 0xFF
|
||||
if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23):
|
||||
return None
|
||||
if not (0 <= minute <= 59 and 0 <= second <= 59):
|
||||
return None
|
||||
return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _deye_clock_registers_verify_match(
|
||||
w62: int,
|
||||
w63: int,
|
||||
w64: int,
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
) -> bool:
|
||||
w_dt = _deye_registers_to_prague_datetime(w62, w63, w64)
|
||||
a_dt = _deye_registers_to_prague_datetime(a62, a63, a64)
|
||||
if w_dt is None or a_dt is None:
|
||||
return False
|
||||
return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC
|
||||
|
||||
|
||||
def _deye_should_skip_time_sync_after_read(
|
||||
inv: InverterConfig,
|
||||
r62: int,
|
||||
r63: int,
|
||||
r64: int,
|
||||
) -> bool:
|
||||
"""
|
||||
True = nezařazovat zápis 62-64: drift je malý a od posledního úspěšného zápisu
|
||||
nebo tolerančního ověření neuplynulo 24h.
|
||||
"""
|
||||
dev = _deye_registers_to_prague_datetime(r62, r63, r64)
|
||||
if dev is None:
|
||||
return False
|
||||
wall = datetime.now(PRAGUE_TZ)
|
||||
drift = abs((wall - dev).total_seconds())
|
||||
if drift > DEYE_CLOCK_DRIFT_OK_SEC:
|
||||
return False
|
||||
last_write = inv.deye_last_system_time_sync_at
|
||||
if last_write is None:
|
||||
return False
|
||||
if last_write.tzinfo is None:
|
||||
last_write = last_write.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
last_write = last_write.astimezone(timezone.utc)
|
||||
age = datetime.now(timezone.utc) - last_write
|
||||
if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS):
|
||||
return False
|
||||
return True
|
||||
82
backend/services/control/exporter_monolith.py
Normal file
82
backend/services/control/exporter_monolith.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""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_mi_export_cutoff_want_enabled,
|
||||
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_export_plan_guard,
|
||||
_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,
|
||||
)
|
||||
376
backend/services/control/inverter.py
Normal file
376
backend/services/control/inverter.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""Deye inverter writer and live register reader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.deye_helpers import (
|
||||
BATT_VOLTAGE_V,
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_REGS,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
DEYE_TOU_INACTIVE_HHMM,
|
||||
PRAGUE_TZ,
|
||||
REG178_MI_EXPORT_DISABLE,
|
||||
REG178_MI_EXPORT_ENABLE,
|
||||
REG178_MI_EXPORT_MASK,
|
||||
REG178_PASSIVE,
|
||||
REG178_SELL,
|
||||
REG178_VERIFY_MASK,
|
||||
REG178_VERIFY_MASK_COMBINED,
|
||||
_DEYE_INACTIVE_TOU_REGISTERS,
|
||||
_deye_should_skip_time_sync_after_read,
|
||||
deye_mi_export_cutoff_want_enabled,
|
||||
_prague_minute_start_utc,
|
||||
current_slot_hhmm,
|
||||
next_slot_hhmm,
|
||||
)
|
||||
from services.control.modbus_journal import (
|
||||
_drop_registers_matching_last_verified,
|
||||
_fetch_last_verified_inverter_registers,
|
||||
create_modbus_commands,
|
||||
execute_modbus_commands,
|
||||
)
|
||||
from services.control.models import ControlSetpoints
|
||||
from services.control.repository import _get_current_soc, _load_inverter_config
|
||||
from services.control.setpoints import (
|
||||
_deye_reg143_export_w,
|
||||
_deye_system_time_register_rows,
|
||||
_deye_time_point_rows,
|
||||
_deye_tou_min_soc_pct,
|
||||
_deye_tou_params,
|
||||
_deye_tou_reserve_soc_pct,
|
||||
deye_battery_charge_discharge_amps,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def write_inverter_setpoints(
|
||||
site_id: int,
|
||||
setpoints_now: ControlSetpoints,
|
||||
setpoints_next: ControlSetpoints | None,
|
||||
db: asyncpg.Connection,
|
||||
planning_run_id: int | None = None,
|
||||
) -> str:
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
return "FAIL inverter: no controllable Modbus endpoint"
|
||||
|
||||
unit_id = int(inv.unit_id if inv.unit_id is not None else 1)
|
||||
raw_bat = setpoints_now.battery_w
|
||||
grid_w = int(setpoints_now.grid_setpoint_w or 0)
|
||||
no_export = inv.no_export
|
||||
export_lim_hw = _deye_reg143_export_w(no_export, inv.max_export_power_w)
|
||||
export_lim = export_lim_hw
|
||||
if int(setpoints_now.grid_export_limit or 0) > 0:
|
||||
export_lim = min(export_lim_hw, int(setpoints_now.grid_export_limit))
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge
|
||||
tou_min_pct = _deye_tou_min_soc_pct(inv)
|
||||
tou_reserve_pct = _deye_tou_reserve_soc_pct(inv)
|
||||
|
||||
try:
|
||||
soc_telemetry = await _get_current_soc(site_id, db)
|
||||
deye_mode = get_deye_mode(setpoints_now)
|
||||
|
||||
bat_w = int(raw_bat) if raw_bat is not None else 0
|
||||
charge_a, discharge_a = deye_battery_charge_discharge_amps(
|
||||
lock_battery=setpoints_now.lock_battery,
|
||||
deye_mode=deye_mode,
|
||||
self_sustain_local_use=setpoints_now.self_sustain_local_use,
|
||||
bat_w=bat_w,
|
||||
grid_w=grid_w,
|
||||
max_charge_a=int(inv.max_charge_a),
|
||||
max_discharge_a=int(inv.max_discharge_a),
|
||||
export_mode=setpoints_now.export_mode,
|
||||
export_ban=bool(setpoints_now.export_ban),
|
||||
)
|
||||
|
||||
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
|
||||
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
|
||||
solar_sell = 0 if (setpoints_now.export_ban and deye_mode != "SELL") else 1
|
||||
export_limit = export_lim
|
||||
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
|
||||
|
||||
charge_a_log = charge_a if charge_a is not None else "skip"
|
||||
logger.info(
|
||||
f"[control] site={site_id} fyzický režim Deye: {deye_mode} | "
|
||||
f"battery_w={raw_bat!r} grid_w={grid_w} | "
|
||||
f"charge_a={charge_a_log} discharge_a={discharge_a} | "
|
||||
f"reg142={selling_mode} reg145={solar_sell} reg143={export_limit}W reg178={reg178_val}"
|
||||
)
|
||||
|
||||
now_cet, time_rows = _deye_system_time_register_rows()
|
||||
skip_time = False
|
||||
try:
|
||||
mb_clock = await get_modbus_client(inv.host, inv.port)
|
||||
tvals = await mb_clock.read_holding_registers(
|
||||
62, 3, int(inv.unit_id if inv.unit_id is not None else 1)
|
||||
)
|
||||
if len(tvals) == 3:
|
||||
skip_time = _deye_should_skip_time_sync_after_read(
|
||||
inv, int(tvals[0]), int(tvals[1]), int(tvals[2])
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Deye clock read: expected 3 registers, got %s; will sync 62-64",
|
||||
len(tvals),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Deye clock read failed (will sync 62-64): %s", e)
|
||||
|
||||
if skip_time:
|
||||
logger.info(
|
||||
"Deye clock 62-64 skipped (drift <= %ss, last sync < %sh ago): %s CET",
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
else:
|
||||
logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows)
|
||||
|
||||
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
|
||||
hh_cur = current_slot_hhmm()
|
||||
hh_nxt = next_slot_hhmm()
|
||||
p1, s1, g1 = _deye_tou_params(setpoints_now, inv)
|
||||
p2, s2, g2 = _deye_tou_params(sp_tp2, inv)
|
||||
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
|
||||
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
|
||||
|
||||
prague_date = datetime.now(PRAGUE_TZ).date()
|
||||
inactive_sig = (
|
||||
f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}"
|
||||
)
|
||||
need_inactive_tou = (
|
||||
inv.deye_last_tou_inactive_write_prague_date != prague_date
|
||||
or inv.deye_tou_inactive_signature != inactive_sig
|
||||
)
|
||||
if need_inactive_tou:
|
||||
for idx in range(2, 6):
|
||||
registers.extend(
|
||||
_deye_time_point_rows(
|
||||
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Deye TOU rows 3-6 skipped (already written today, signature unchanged)"
|
||||
)
|
||||
|
||||
amp_regs: list[tuple[int, str, int]] = []
|
||||
if charge_a is not None:
|
||||
amp_regs.append((108, "", charge_a))
|
||||
amp_regs.append((109, "", discharge_a))
|
||||
registers.extend(
|
||||
amp_regs
|
||||
+ [
|
||||
(141, "energy_mode (0)", 0),
|
||||
(142, "limit_control", selling_mode),
|
||||
(143, "", export_limit),
|
||||
(145, "solar_sell", solar_sell),
|
||||
]
|
||||
)
|
||||
|
||||
if (
|
||||
bool(inv.deye_reg340_pv_a_control_enabled)
|
||||
and int(inv.pv_a_cap_w) > 0
|
||||
and setpoints_now.pv_a_allowed_w is not None
|
||||
):
|
||||
registers.append((340, "max_solar_power_w", int(setpoints_now.pv_a_allowed_w)))
|
||||
|
||||
try:
|
||||
mb178 = await get_modbus_client(inv.host, inv.port)
|
||||
r178 = await mb178.read_holding_registers(178, 1, unit_id)
|
||||
if not r178 or len(r178) < 1:
|
||||
raise OSError(f"reg178 read returned {len(r178) if r178 is not None else None} values")
|
||||
current_178 = int(r178[0])
|
||||
peak_bits = int(reg178_val) & int(REG178_VERIFY_MASK)
|
||||
if inv.deye_gen_microinverter_cutoff_enabled:
|
||||
want_cutoff = deye_mi_export_cutoff_want_enabled(
|
||||
gen_microinverter_cutoff_enabled=True,
|
||||
deye_gen_cutoff_enabled=bool(setpoints_now.deye_gen_cutoff_enabled),
|
||||
export_ban=bool(setpoints_now.export_ban),
|
||||
deye_mode=deye_mode,
|
||||
)
|
||||
mi_bits = REG178_MI_EXPORT_ENABLE if want_cutoff else REG178_MI_EXPORT_DISABLE
|
||||
else:
|
||||
mi_bits = int(current_178) & int(REG178_MI_EXPORT_MASK)
|
||||
|
||||
new_178 = (
|
||||
(int(current_178) & ~int(REG178_VERIFY_MASK_COMBINED))
|
||||
| int(peak_bits)
|
||||
| int(mi_bits)
|
||||
)
|
||||
registers.append((178, "control_board_special_1", int(new_178)))
|
||||
logger.info(
|
||||
"[control] %s: reg178 (control_board_special_1) old=%s new=%s peak_bits=0x%04X mi_bits=%s",
|
||||
inv.code,
|
||||
current_178,
|
||||
new_178,
|
||||
int(peak_bits),
|
||||
int(mi_bits),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[control] %s: reg178 RMW failed (skip reg178 write): %s", inv.code, e)
|
||||
|
||||
logger.info(
|
||||
"[control] %s: deye_mode=%s charge=%sA discharge=%sA "
|
||||
"reg142=%s reg145=%s export=%sW "
|
||||
"tp1=%s tp2=%s soc=%s%% (batt=%r grid=%sW)",
|
||||
inv.code,
|
||||
deye_mode,
|
||||
charge_a,
|
||||
discharge_a,
|
||||
selling_mode,
|
||||
solar_sell,
|
||||
export_limit,
|
||||
hh_cur,
|
||||
hh_nxt,
|
||||
soc_telemetry,
|
||||
raw_bat,
|
||||
grid_w,
|
||||
)
|
||||
|
||||
last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db)
|
||||
registers, skipped_unchanged = _drop_registers_matching_last_verified(
|
||||
registers, last_verified
|
||||
)
|
||||
if skipped_unchanged:
|
||||
logger.info(
|
||||
"[control] %s: skip %s registers (value equals last verified): %s",
|
||||
inv.code,
|
||||
len(skipped_unchanged),
|
||||
skipped_unchanged[:24],
|
||||
)
|
||||
if not registers:
|
||||
logger.info(
|
||||
"[control] %s: all Deye holding regs match last verified, no Modbus write",
|
||||
inv.code,
|
||||
)
|
||||
if need_inactive_tou:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_tou_inactive_write_prague_date = $1,
|
||||
deye_tou_inactive_signature = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
prague_date,
|
||||
inactive_sig,
|
||||
inv.id,
|
||||
)
|
||||
return (
|
||||
f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)"
|
||||
)
|
||||
|
||||
will_write_inactive = any(
|
||||
int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers
|
||||
)
|
||||
|
||||
cmd_ids = await create_modbus_commands(
|
||||
site_id,
|
||||
planning_run_id,
|
||||
"inverter",
|
||||
inv.id,
|
||||
inv.code,
|
||||
inv.host,
|
||||
inv.port,
|
||||
inv.unit_id,
|
||||
registers,
|
||||
db,
|
||||
deye_physical_mode=deye_mode,
|
||||
)
|
||||
if not await execute_modbus_commands(cmd_ids, db):
|
||||
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
|
||||
logger.info("[control] Inverter %s journal write OK", inv.code)
|
||||
|
||||
will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers)
|
||||
if will_write_time:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1,
|
||||
deye_last_system_time_sync_at = now()
|
||||
WHERE id = $2
|
||||
""",
|
||||
_prague_minute_start_utc(),
|
||||
inv.id,
|
||||
)
|
||||
|
||||
if need_inactive_tou or will_write_inactive:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_tou_inactive_write_prague_date = $1,
|
||||
deye_tou_inactive_signature = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
prague_date,
|
||||
inactive_sig,
|
||||
inv.id,
|
||||
)
|
||||
except Exception as e:
|
||||
return f"FAIL inverter: {inv.code}: {e}"
|
||||
|
||||
return (
|
||||
f"OK inverter: batt_w={raw_bat!r} "
|
||||
f"(time points + FC 0x10: 108/109/141/142/178/143/145/340 dle plánu)"
|
||||
)
|
||||
|
||||
|
||||
async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]:
|
||||
"""
|
||||
Živé čtení holding registrů Deye 108, 109, 141-145, 178, 191 a volitelně 340.
|
||||
"""
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
raise ValueError("no controllable Modbus inverter for site")
|
||||
|
||||
uid = int(inv.unit_id)
|
||||
client = await get_modbus_client(inv.host, inv.port)
|
||||
read_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with client.batch(uid) as mb:
|
||||
b108 = await mb.read_holding_registers(108, 2)
|
||||
b141 = await mb.read_holding_registers(141, 5)
|
||||
r178 = await mb.read_holding_registers(178, 1)
|
||||
r191 = await mb.read_holding_registers(191, 1)
|
||||
if inv.deye_reg340_pv_a_control_enabled:
|
||||
r340 = await mb.read_holding_registers(340, 1)
|
||||
else:
|
||||
r340 = None
|
||||
r108, r109 = b108[0], b108[1]
|
||||
r141, r142, r143 = b141[0], b141[1], b141[2]
|
||||
r145 = b141[4]
|
||||
r178 = r178[0]
|
||||
r191 = r191[0]
|
||||
r340v = int(r340[0]) if r340 is not None and len(r340) >= 1 else None
|
||||
except Exception:
|
||||
logger.exception("read_deye_registers_live site=%s failed", site_id)
|
||||
raise
|
||||
|
||||
return {
|
||||
"reg108_charge_a": int(r108),
|
||||
"reg109_discharge_a": int(r109),
|
||||
"reg141_energy_mode": int(r141),
|
||||
"reg142_limit_control": int(r142),
|
||||
"reg143_export_limit_w": int(r143),
|
||||
"reg145_solar_sell": int(r145),
|
||||
"reg178_peak_shaving_switch": int(r178),
|
||||
"reg178_control_board_special_1": int(r178),
|
||||
"reg178_mi_export_cutoff_bits": int(r178) & int(REG178_MI_EXPORT_MASK),
|
||||
"reg178_mi_export_cutoff_is_on": (int(r178) & int(REG178_MI_EXPORT_MASK))
|
||||
== int(REG178_MI_EXPORT_ENABLE),
|
||||
"reg191_peak_shaving_w": int(r191),
|
||||
"reg340_max_solar_power_w": r340v,
|
||||
"read_at": read_at.isoformat(),
|
||||
}
|
||||
243
backend/services/control/modbus_journal.py
Normal file
243
backend/services/control/modbus_journal.py
Normal 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
|
||||
78
backend/services/control/models.py
Normal file
78
backend/services/control/models.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Datové modely pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class InverterConfig:
|
||||
id: int
|
||||
code: str
|
||||
host: str
|
||||
port: int
|
||||
unit_id: int
|
||||
max_export_power_w: int | None
|
||||
max_import_power_w: int | None
|
||||
no_export: bool
|
||||
max_battery_charge_w: int | None
|
||||
max_battery_discharge_w: int | None
|
||||
min_soc_percent: int | None
|
||||
reserve_soc_percent: int | None
|
||||
max_soc_percent: int | None
|
||||
usable_capacity_wh: int | None
|
||||
max_charge_a: int
|
||||
max_discharge_a: int
|
||||
deye_last_system_time_sync_minute: datetime | None = None
|
||||
deye_last_system_time_sync_at: datetime | None = None
|
||||
deye_last_tou_inactive_write_prague_date: date | None = None
|
||||
deye_tou_inactive_signature: str | None = None
|
||||
deye_zero_export_mode: int = 1
|
||||
deye_gen_microinverter_cutoff_enabled: bool = False
|
||||
#: Strop reg 340 (W) z fn_inverter_pv_a_max_w; 0 = EMS nezapisuje reg 340.
|
||||
pv_a_cap_w: int = 0
|
||||
#: Minimální hodnota reg 340 přijatá firmwarem (0 nebo např. 400 u staršího Deye).
|
||||
pv_a_reg340_min_w: int = 0
|
||||
#: True = EMS smí řídit Deye reg 340 (max solar power); z SQL `fn_site_has_active_green_bonus_pv(site_id)`.
|
||||
deye_reg340_pv_a_control_enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlSetpoints:
|
||||
battery_w: int | None
|
||||
#: Tvrdý limit exportu do sítě v daném slotu (W), ne forecastová cílová hodnota.
|
||||
grid_export_limit: int
|
||||
ev1_current_a: int
|
||||
ev2_current_a: int
|
||||
heat_pump_enable: bool
|
||||
grid_setpoint_w: int
|
||||
ev1_power_w: int
|
||||
ev2_power_w: int
|
||||
target_soc_pct: int | None = None
|
||||
#: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE).
|
||||
deye_physical_mode: str | None = None
|
||||
#: Záměr exportu z LP: NONE / PV_SURPLUS / BATTERY_SELL.
|
||||
export_mode: str | None = None
|
||||
#: True = zákaz exportu (BLOCK_EXPORT) pro daný slot.
|
||||
export_ban: bool = False
|
||||
#: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 178 bits0-1).
|
||||
deye_gen_cutoff_enabled: bool = False
|
||||
#: Efektivní vykupní cena slotu (Kč/kWh z plánu).
|
||||
effective_sell_price_czk_kwh: float | None = None
|
||||
#: True = reg 108/109 na 0 (PRESERVE - Deye baterii nepoužívá).
|
||||
lock_battery: bool = False
|
||||
#: Režim SELF_SUSTAIN.
|
||||
self_sustain_local_use: bool = False
|
||||
#: Deye reg 340 (max solar power, W). None = EMS reg 340 v tomto ticku neřeší.
|
||||
pv_a_allowed_w: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperatingModeInfo:
|
||||
mode_code: str
|
||||
battery_mode: str
|
||||
grid_mode: str
|
||||
ev_enabled: bool
|
||||
heat_pump_enabled_def: bool
|
||||
loxone_mode_value: int
|
||||
165
backend/services/control/orchestrator.py
Normal file
165
backend/services/control/orchestrator.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Top-level control export orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.inverter import write_inverter_setpoints
|
||||
from services.control.models import ControlSetpoints
|
||||
from services.control.outputs import (
|
||||
send_loxone_setpoints,
|
||||
write_ev_setpoints,
|
||||
write_heat_pump_setpoint,
|
||||
)
|
||||
from services.control.repository import (
|
||||
_fetch_max_charge_power_w,
|
||||
_fetch_operating_mode,
|
||||
_fetch_plan_row_for_slot_offset,
|
||||
_load_inverter_config,
|
||||
)
|
||||
from services.control.setpoints import (
|
||||
_apply_export_plan_guard,
|
||||
_apply_price_failsafe_guard,
|
||||
_build_setpoints,
|
||||
)
|
||||
from services.signal_service import enqueue_site_signals
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
||||
mode = await _fetch_operating_mode(site_id, db)
|
||||
if mode is None:
|
||||
logger.warning("control export site=%s: no operating mode row", site_id)
|
||||
return
|
||||
|
||||
if mode.mode_code == "MANUAL":
|
||||
logger.info("control export site=%s: MANUAL, skip writes", site_id)
|
||||
return
|
||||
|
||||
try:
|
||||
inv_for_pv = await _load_inverter_config(site_id, db)
|
||||
cap_pv = int(inv_for_pv.pv_a_cap_w) if inv_for_pv is not None else 0
|
||||
min_pv = int(inv_for_pv.pv_a_reg340_min_w) if inv_for_pv is not None else 0
|
||||
reg340_en = (
|
||||
bool(inv_for_pv.deye_reg340_pv_a_control_enabled)
|
||||
if inv_for_pv is not None
|
||||
else False
|
||||
)
|
||||
pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0)
|
||||
pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1)
|
||||
sp_now = _build_setpoints(
|
||||
mode,
|
||||
pi_now,
|
||||
pv_a_cap_w=cap_pv,
|
||||
pv_a_reg340_min_w=min_pv,
|
||||
reg340_pv_a_control_enabled=reg340_en,
|
||||
)
|
||||
sp_next = _build_setpoints(
|
||||
mode,
|
||||
pi_next,
|
||||
pv_a_cap_w=cap_pv,
|
||||
pv_a_reg340_min_w=min_pv,
|
||||
reg340_pv_a_control_enabled=reg340_en,
|
||||
)
|
||||
|
||||
if mode.mode_code == "AUTO" and sp_now is None:
|
||||
if pi_now is None:
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO but no planning_interval for current slot, skip",
|
||||
site_id,
|
||||
)
|
||||
return
|
||||
|
||||
if sp_now is None:
|
||||
logger.warning(
|
||||
"control export site=%s: no setpoints for mode %s, skip",
|
||||
site_id,
|
||||
mode.mode_code,
|
||||
)
|
||||
return
|
||||
|
||||
if mode.mode_code == "CHARGE_CHEAP":
|
||||
max_ch = await _fetch_max_charge_power_w(site_id, db)
|
||||
pw = max(1, int(max_ch))
|
||||
sp_now = ControlSetpoints(
|
||||
battery_w=pw,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=pw,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
effective_sell_price_czk_kwh=None,
|
||||
)
|
||||
sp_next = sp_now
|
||||
else:
|
||||
sp_now = _apply_export_plan_guard(site_id, mode, pi_now, sp_now)
|
||||
sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now)
|
||||
if sp_next is not None:
|
||||
sp_next = _apply_export_plan_guard(site_id, mode, pi_next, sp_next)
|
||||
sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next)
|
||||
|
||||
planning_run_id = await db.fetchval(
|
||||
"""
|
||||
SELECT id FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if planning_run_id is not None:
|
||||
planning_run_id = int(planning_run_id)
|
||||
|
||||
try:
|
||||
inv_res = await write_inverter_setpoints(
|
||||
site_id, sp_now, sp_next, db, planning_run_id=planning_run_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("inverter write failed: %s", e)
|
||||
inv_res = f"FAIL inverter: {e}"
|
||||
|
||||
try:
|
||||
ev_res = await write_ev_setpoints(site_id, sp_now, db)
|
||||
except Exception as e:
|
||||
logger.error("ev write failed: %s", e)
|
||||
ev_res = f"FAIL ev: {e}"
|
||||
|
||||
try:
|
||||
hp_res = await write_heat_pump_setpoint(site_id, sp_now, db)
|
||||
except Exception as e:
|
||||
logger.error("hp write failed: %s", e)
|
||||
hp_res = f"FAIL heat pump: {e}"
|
||||
|
||||
try:
|
||||
lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db)
|
||||
except Exception as e:
|
||||
logger.error("loxone write failed: %s", e)
|
||||
lox_res = f"FAIL Loxone: {e}"
|
||||
|
||||
results = list(
|
||||
zip(
|
||||
("inverter", "ev", "heat_pump", "loxone"),
|
||||
(inv_res, ev_res, hp_res, lox_res),
|
||||
)
|
||||
)
|
||||
|
||||
for name, res in results:
|
||||
if isinstance(res, Exception):
|
||||
logger.error("control export site=%s %s: FAIL %s", site_id, name, res)
|
||||
elif isinstance(res, str) and res.startswith("FAIL"):
|
||||
logger.error("control export site=%s %s: %s", site_id, name, res)
|
||||
else:
|
||||
logger.info("control export site=%s %s: %s", site_id, name, res)
|
||||
finally:
|
||||
try:
|
||||
await enqueue_site_signals(site_id, db)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"control export site=%s: signal enqueue failed: %s", site_id, e
|
||||
)
|
||||
149
backend/services/control/outputs.py
Normal file
149
backend/services/control/outputs.py
Normal 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"
|
||||
217
backend/services/control/repository.py
Normal file
217
backend/services/control/repository.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""DB načítání pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.deye_helpers import DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A
|
||||
from services.control.models import InverterConfig, OperatingModeInfo
|
||||
from services.control.setpoints import _DictRecord
|
||||
|
||||
|
||||
async def _fetch_operating_mode(
|
||||
site_id: int, db: asyncpg.Connection
|
||||
) -> OperatingModeInfo | None:
|
||||
sql = """
|
||||
SELECT som.mode_code, omd.battery_mode, omd.grid_mode,
|
||||
omd.ev_enabled, omd.heat_pump_enabled, omd.loxone_mode_value,
|
||||
som.valid_until
|
||||
FROM ems.site_operating_mode som
|
||||
JOIN ems.operating_mode_def omd ON omd.code = som.mode_code
|
||||
WHERE som.site_id = $1
|
||||
"""
|
||||
row = await db.fetchrow(sql, site_id)
|
||||
if row is None:
|
||||
return None
|
||||
vu = row["valid_until"]
|
||||
if vu is not None:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if vu.tzinfo is None:
|
||||
vu = vu.replace(tzinfo=timezone.utc)
|
||||
if vu <= now_utc:
|
||||
exp_rows = await db.fetch("SELECT * FROM ems.fn_expire_modes()")
|
||||
from services.notification_service import notify_operating_mode_changed
|
||||
|
||||
for er in exp_rows:
|
||||
await notify_operating_mode_changed(
|
||||
str(er["site_code"]),
|
||||
str(er["old_mode"]),
|
||||
str(er["new_mode"]),
|
||||
"system:expiry",
|
||||
"Automatické vypršení dočasného režimu",
|
||||
)
|
||||
row = await db.fetchrow(sql, site_id)
|
||||
if row is None:
|
||||
return None
|
||||
return OperatingModeInfo(
|
||||
mode_code=row["mode_code"],
|
||||
battery_mode=row["battery_mode"],
|
||||
grid_mode=row["grid_mode"],
|
||||
ev_enabled=bool(row["ev_enabled"]),
|
||||
heat_pump_enabled_def=bool(row["heat_pump_enabled"]),
|
||||
loxone_mode_value=int(row["loxone_mode_value"]),
|
||||
)
|
||||
|
||||
|
||||
async def _get_current_soc(site_id: int, db: asyncpg.Connection) -> int:
|
||||
soc = await db.fetchval(
|
||||
"""
|
||||
SELECT battery_soc_percent
|
||||
FROM ems.telemetry_inverter
|
||||
WHERE site_id = $1 AND battery_soc_percent IS NOT NULL
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
return int(soc) if soc is not None else 50
|
||||
|
||||
|
||||
async def _load_inverter_config(
|
||||
site_id: int, db: asyncpg.Connection
|
||||
) -> InverterConfig | None:
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
ai.id, ai.code,
|
||||
coalesce(ems.fn_inverter_pv_a_max_w(ai.id), 0) AS pv_a_cap_w,
|
||||
coalesce(ai.deye_reg340_min_solar_w, 0) AS pv_a_reg340_min_w,
|
||||
se.host, se.port, se.unit_id,
|
||||
sgc.max_export_power_w,
|
||||
sgc.max_import_power_w,
|
||||
sgc.no_export,
|
||||
ai.max_battery_charge_w,
|
||||
ai.max_battery_discharge_w,
|
||||
ab.min_soc_percent,
|
||||
ab.reserve_soc_percent,
|
||||
ab.max_soc_percent,
|
||||
ab.usable_capacity_wh,
|
||||
ai.deye_last_system_time_sync_minute,
|
||||
ai.deye_last_system_time_sync_at,
|
||||
ai.deye_last_tou_inactive_write_prague_date,
|
||||
ai.deye_tou_inactive_signature,
|
||||
COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode,
|
||||
COALESCE(ai.deye_gen_microinverter_cutoff_enabled, false) AS deye_gen_microinverter_cutoff_enabled,
|
||||
coalesce(ems.fn_site_has_active_green_bonus_pv(ai.site_id), false)
|
||||
AS deye_reg340_pv_a_control_enabled,
|
||||
COALESCE(
|
||||
ai.deye_register_max_charge_a,
|
||||
FLOOR(
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
|
||||
ai.max_battery_charge_w
|
||||
)::numeric / 51.2
|
||||
)::int
|
||||
) AS max_charge_a,
|
||||
COALESCE(
|
||||
ai.deye_register_max_discharge_a,
|
||||
FLOOR(
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w),
|
||||
ai.max_battery_discharge_w
|
||||
)::numeric / 51.2
|
||||
)::int
|
||||
) AS max_discharge_a
|
||||
FROM ems.asset_inverter ai
|
||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||
JOIN ems.asset_battery ab ON ab.inverter_id = ai.id
|
||||
LEFT JOIN ems.site_grid_connection sgc ON sgc.site_id = ai.site_id
|
||||
WHERE ai.site_id = $1
|
||||
AND ai.active = true
|
||||
AND ai.controllable = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
ORDER BY ai.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
mc = row["max_charge_a"]
|
||||
md = row["max_discharge_a"]
|
||||
max_charge_a = int(mc) if mc is not None else 0
|
||||
max_discharge_a = int(md) if md is not None else 0
|
||||
max_charge_a = min(max_charge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
|
||||
max_discharge_a = min(max_discharge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
|
||||
port = int(row["port"] or 502)
|
||||
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||
return InverterConfig(
|
||||
id=int(row["id"]),
|
||||
code=row["code"],
|
||||
host=row["host"],
|
||||
port=port,
|
||||
unit_id=uid,
|
||||
max_export_power_w=int(row["max_export_power_w"])
|
||||
if row["max_export_power_w"] is not None
|
||||
else None,
|
||||
max_import_power_w=int(row["max_import_power_w"])
|
||||
if row["max_import_power_w"] is not None
|
||||
else None,
|
||||
no_export=bool(row["no_export"] or False),
|
||||
max_battery_charge_w=int(row["max_battery_charge_w"])
|
||||
if row["max_battery_charge_w"] is not None
|
||||
else None,
|
||||
max_battery_discharge_w=int(row["max_battery_discharge_w"])
|
||||
if row["max_battery_discharge_w"] is not None
|
||||
else None,
|
||||
min_soc_percent=int(round(float(row["min_soc_percent"])))
|
||||
if row["min_soc_percent"] is not None
|
||||
else None,
|
||||
reserve_soc_percent=int(row["reserve_soc_percent"])
|
||||
if row["reserve_soc_percent"] is not None
|
||||
else None,
|
||||
max_soc_percent=int(row["max_soc_percent"])
|
||||
if row["max_soc_percent"] is not None
|
||||
else None,
|
||||
usable_capacity_wh=int(row["usable_capacity_wh"])
|
||||
if row["usable_capacity_wh"] is not None
|
||||
else None,
|
||||
max_charge_a=max_charge_a,
|
||||
max_discharge_a=max_discharge_a,
|
||||
deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"],
|
||||
deye_last_system_time_sync_at=row["deye_last_system_time_sync_at"],
|
||||
deye_last_tou_inactive_write_prague_date=row[
|
||||
"deye_last_tou_inactive_write_prague_date"
|
||||
],
|
||||
deye_tou_inactive_signature=row["deye_tou_inactive_signature"],
|
||||
deye_zero_export_mode=int(row["deye_zero_export_mode"]),
|
||||
deye_gen_microinverter_cutoff_enabled=bool(
|
||||
row["deye_gen_microinverter_cutoff_enabled"] or False
|
||||
),
|
||||
pv_a_cap_w=int(row["pv_a_cap_w"] or 0),
|
||||
pv_a_reg340_min_w=int(row["pv_a_reg340_min_w"] or 0),
|
||||
deye_reg340_pv_a_control_enabled=bool(
|
||||
row["deye_reg340_pv_a_control_enabled"] or False
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_plan_row_for_slot_offset(
|
||||
site_id: int, db: asyncpg.Connection, slot_offset: int
|
||||
) -> asyncpg.Record | None:
|
||||
"""Řádek plánu pro slot z ems.fn_planning_interval_at_offset (jsonb -> Record-like dict)."""
|
||||
raw = await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_interval_at_offset($1::int, $2::int)
|
||||
""",
|
||||
site_id,
|
||||
slot_offset,
|
||||
)
|
||||
if raw is None:
|
||||
return None
|
||||
data = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
if not data:
|
||||
return None
|
||||
return _DictRecord(data)
|
||||
|
||||
|
||||
async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int:
|
||||
v = await db.fetchval(
|
||||
"select ems.fn_planning_max_effective_charge_w($1::int)",
|
||||
site_id,
|
||||
)
|
||||
return int(v or 0)
|
||||
484
backend/services/control/setpoints.py
Normal file
484
backend/services/control/setpoints.py
Normal file
@@ -0,0 +1,484 @@
|
||||
"""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 plan_skips_deye_reg340_write(
|
||||
*,
|
||||
battery_setpoint_w: int,
|
||||
grid_setpoint_w: int,
|
||||
export_mode: str | None,
|
||||
export_limit_w: int,
|
||||
pv_a_curtailed_w: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Nezapisovat reg 340: plán neexportuje, nenabíjí baterii a neškrtí pole A.
|
||||
Deye sám řídí PV A přes 108/109/142 (zero export + 0 A nabíjení).
|
||||
"""
|
||||
em = (export_mode or "").strip().upper()
|
||||
if em == "NONE":
|
||||
no_export = True
|
||||
elif int(grid_setpoint_w) < 0 or int(export_limit_w) > 0:
|
||||
no_export = False
|
||||
else:
|
||||
no_export = True
|
||||
return (
|
||||
no_export
|
||||
and int(battery_setpoint_w) <= 0
|
||||
and int(pv_a_curtailed_w) <= 0
|
||||
)
|
||||
|
||||
|
||||
def _build_setpoints(
|
||||
mode: OperatingModeInfo,
|
||||
pi: Any | None,
|
||||
*,
|
||||
pv_a_cap_w: int = 0,
|
||||
pv_a_reg340_min_w: int = 0,
|
||||
reg340_pv_a_control_enabled: bool = False,
|
||||
) -> ControlSetpoints | None:
|
||||
code = mode.mode_code
|
||||
if code == "MANUAL":
|
||||
return None
|
||||
|
||||
if code == "AUTO":
|
||||
if pi is None:
|
||||
return None
|
||||
grid_sp = int(pi["grid_setpoint_w"] or 0)
|
||||
export_limit_raw = pi.get("export_limit_w")
|
||||
export_limit = int(export_limit_raw) if export_limit_raw is not None else abs(min(grid_sp, 0))
|
||||
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
|
||||
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
|
||||
hp_en = bool(pi["heat_pump_enabled"])
|
||||
tgt = pi["battery_soc_target_pct"]
|
||||
target_soc = int(round(float(tgt))) if tgt is not None else None
|
||||
pm_raw = pi.get("deye_physical_mode")
|
||||
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
|
||||
export_mode_raw = pi.get("export_mode")
|
||||
export_mode = str(export_mode_raw).strip().upper() if export_mode_raw is not None else None
|
||||
if export_mode == "NONE":
|
||||
export_limit = 0
|
||||
elif export_limit <= 0 and grid_sp < 0:
|
||||
export_limit = abs(grid_sp)
|
||||
# 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
|
||||
bat_w = int(pi["battery_setpoint_w"] or 0)
|
||||
pv_a_allowed: int | None = None
|
||||
if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0:
|
||||
forecast = int(pi.get("pv_a_forecast_solver_w") or 0)
|
||||
curtail = int(pi.get("pv_a_curtailed_w") or 0)
|
||||
buy_raw = pi.get("effective_buy_price")
|
||||
buy_f: float | None = float(buy_raw) if buy_raw is not None else None
|
||||
pv_b = int(pi.get("pv_b_forecast_solver_w") or 0)
|
||||
# Slabý úsvit: neposílat reg 340 — forecast nepřesný, Deye řídí sám (108/109/142).
|
||||
_low_pv_no_reg340_w = 1500
|
||||
if (
|
||||
forecast < _low_pv_no_reg340_w
|
||||
and curtail <= 0
|
||||
and pv_b > 0
|
||||
):
|
||||
pv_a_allowed = None
|
||||
elif (
|
||||
buy_f is not None
|
||||
and sell_f is not None
|
||||
and float(buy_f) < 0.0
|
||||
and float(sell_f) < 0.0
|
||||
and pv_b > 0
|
||||
):
|
||||
pv_a_allowed = 0
|
||||
elif plan_skips_deye_reg340_write(
|
||||
battery_setpoint_w=bat_w,
|
||||
grid_setpoint_w=grid_sp,
|
||||
export_mode=export_mode,
|
||||
export_limit_w=max(0, export_limit),
|
||||
pv_a_curtailed_w=curtail,
|
||||
):
|
||||
pv_a_allowed = None
|
||||
else:
|
||||
pv_a_allowed = compute_pv_a_reg340_max_solar_w(
|
||||
int(pv_a_cap_w),
|
||||
forecast,
|
||||
curtail,
|
||||
min_w=int(pv_a_reg340_min_w),
|
||||
)
|
||||
return ControlSetpoints(
|
||||
battery_w=bat_w,
|
||||
grid_export_limit=max(0, export_limit),
|
||||
ev1_current_a=watts_to_amps(ev1_w, phases=3),
|
||||
ev2_current_a=watts_to_amps(ev2_w, phases=1),
|
||||
heat_pump_enable=hp_en,
|
||||
grid_setpoint_w=grid_sp,
|
||||
ev1_power_w=ev1_w,
|
||||
ev2_power_w=ev2_w,
|
||||
target_soc_pct=target_soc,
|
||||
deye_physical_mode=pm,
|
||||
export_mode=export_mode,
|
||||
export_ban=bool(export_ban),
|
||||
deye_gen_cutoff_enabled=bool(gen_cutoff),
|
||||
effective_sell_price_czk_kwh=sell_f,
|
||||
pv_a_allowed_w=pv_a_allowed,
|
||||
)
|
||||
|
||||
if code == "SELF_SUSTAIN":
|
||||
return ControlSetpoints(
|
||||
battery_w=None,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
self_sustain_local_use=True,
|
||||
)
|
||||
|
||||
if code == "CHARGE_CHEAP":
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
)
|
||||
|
||||
if code == "PRESERVE":
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
lock_battery=True,
|
||||
)
|
||||
|
||||
logger.warning("Unknown mode_code %s for site export, skipping", code)
|
||||
return None
|
||||
|
||||
|
||||
def _passive_no_export_guard(sp: ControlSetpoints) -> ControlSetpoints:
|
||||
"""PASSIVE, žádný vývoz do sítě; vybíjení baterie do sítě vynulováno (reg 109 přes export_ban)."""
|
||||
bat = int(sp.battery_w or 0)
|
||||
if bat < 0:
|
||||
bat = 0
|
||||
return ControlSetpoints(
|
||||
battery_w=bat,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=sp.ev1_current_a,
|
||||
ev2_current_a=sp.ev2_current_a,
|
||||
heat_pump_enable=sp.heat_pump_enable,
|
||||
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
|
||||
ev1_power_w=sp.ev1_power_w,
|
||||
ev2_power_w=sp.ev2_power_w,
|
||||
target_soc_pct=sp.target_soc_pct,
|
||||
deye_physical_mode="PASSIVE",
|
||||
export_mode="NONE",
|
||||
export_ban=True,
|
||||
deye_gen_cutoff_enabled=True,
|
||||
effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh,
|
||||
pv_a_allowed_w=sp.pv_a_allowed_w,
|
||||
lock_battery=sp.lock_battery,
|
||||
self_sustain_local_use=sp.self_sustain_local_use,
|
||||
)
|
||||
|
||||
|
||||
def _apply_export_plan_guard(
|
||||
site_id: int,
|
||||
mode: OperatingModeInfo,
|
||||
pi: Any | None,
|
||||
sp: ControlSetpoints,
|
||||
) -> ControlSetpoints:
|
||||
"""
|
||||
Exekuční pojistka: plán zakazuje vývoz (záporná vykupní nebo export_mode NONE),
|
||||
ale Deye může zůstat v SELL — vynutit PASSIVE a export_ban před zápisem Modbus.
|
||||
"""
|
||||
if mode.mode_code != "AUTO" or pi is None:
|
||||
return sp
|
||||
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
sell_f: float | None = (
|
||||
float(sell_raw) if sell_raw is not None else sp.effective_sell_price_czk_kwh
|
||||
)
|
||||
export_mode_raw = pi.get("export_mode")
|
||||
export_mode = (
|
||||
str(export_mode_raw).strip().upper()
|
||||
if export_mode_raw is not None
|
||||
else (sp.export_mode or "")
|
||||
)
|
||||
grid_sp = int(pi.get("grid_setpoint_w") or sp.grid_setpoint_w or 0)
|
||||
|
||||
neg_sell = sell_f is not None and float(sell_f) < 0
|
||||
plan_no_export = export_mode == "NONE" and grid_sp >= 0
|
||||
if not neg_sell and not plan_no_export:
|
||||
return sp
|
||||
|
||||
reason = "neg_sell" if neg_sell else "export_mode_none"
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO export plan guard (%s) -> PASSIVE no-export",
|
||||
site_id,
|
||||
reason,
|
||||
)
|
||||
return _passive_no_export_guard(sp)
|
||||
|
||||
|
||||
def _apply_price_failsafe_guard(
|
||||
site_id: int,
|
||||
mode: OperatingModeInfo,
|
||||
pi: Any | None,
|
||||
sp: ControlSetpoints,
|
||||
) -> ControlSetpoints:
|
||||
if mode.mode_code != "AUTO" or pi is None:
|
||||
return sp
|
||||
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
|
||||
return sp
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
|
||||
site_id,
|
||||
)
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=sp.ev1_current_a,
|
||||
ev2_current_a=sp.ev2_current_a,
|
||||
heat_pump_enable=sp.heat_pump_enable,
|
||||
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
|
||||
ev1_power_w=sp.ev1_power_w,
|
||||
ev2_power_w=sp.ev2_power_w,
|
||||
target_soc_pct=sp.target_soc_pct,
|
||||
effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh,
|
||||
pv_a_allowed_w=sp.pv_a_allowed_w,
|
||||
)
|
||||
|
||||
|
||||
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
|
||||
"""Reg 143 - max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
|
||||
if no_export:
|
||||
return 0
|
||||
return max(0, int(max_export_power_w or 0))
|
||||
|
||||
|
||||
def _is_passive_pv_surplus_export(
|
||||
*,
|
||||
deye_mode: str,
|
||||
export_mode: str | None,
|
||||
export_ban: bool,
|
||||
grid_w: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Přetok FVE do sítě v PASSIVE (ne SELL z baterie): reg 142 zůstane zero-export (1/2),
|
||||
nabíjení se blokuje přes **108 = 0** — baterie nemá kam brát přebytek → jde do sítě (145).
|
||||
"""
|
||||
if deye_mode != "PASSIVE" or export_ban:
|
||||
return False
|
||||
em = (export_mode or "").strip().upper()
|
||||
if em == "PV_SURPLUS":
|
||||
return True
|
||||
if em in {"NONE", "BATTERY_SELL"}:
|
||||
return False
|
||||
return grid_w < 0
|
||||
|
||||
|
||||
def _clamp_deye_tou_soc_pct(pct: int) -> int:
|
||||
return max(5, min(95, pct))
|
||||
|
||||
|
||||
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
|
||||
if inv.min_soc_percent is not None:
|
||||
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
|
||||
return 10
|
||||
|
||||
|
||||
def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
|
||||
if inv.reserve_soc_percent is not None:
|
||||
return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent))
|
||||
return 20
|
||||
|
||||
|
||||
def _deye_passive_tou_battery_soc_pct(
|
||||
inv: InverterConfig, _setpoints: ControlSetpoints
|
||||
) -> int:
|
||||
"""Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE."""
|
||||
return _deye_tou_min_soc_pct(inv)
|
||||
|
||||
|
||||
def _deye_zero_export_amps_for_passive(
|
||||
grid_w: int,
|
||||
bat_w: int,
|
||||
max_charge_a: int,
|
||||
max_discharge_a: int,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
PASSIVE (zero export k CT/zátěži): asymetrie jen tam, kde dává smysl pro import.
|
||||
|
||||
Přetok FVE do sítě řeší větev ``_is_passive_pv_surplus_export`` (**108 = 0**). Zde jen import
|
||||
bez nabíjení → vypnout vybíjení (**109 = 0**).
|
||||
"""
|
||||
if grid_w > 0 and bat_w <= 0:
|
||||
return max_charge_a, 0
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
|
||||
|
||||
def deye_battery_charge_discharge_amps(
|
||||
*,
|
||||
lock_battery: bool,
|
||||
deye_mode: str,
|
||||
self_sustain_local_use: bool,
|
||||
bat_w: int,
|
||||
grid_w: int,
|
||||
max_charge_a: int,
|
||||
max_discharge_a: int,
|
||||
export_mode: str | None = None,
|
||||
export_ban: bool = False,
|
||||
) -> tuple[int | None, int]:
|
||||
"""
|
||||
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
|
||||
|
||||
**PV_SURPLUS** (PASSIVE, export FVE): **108 = 0**, **109 = max** — baterie se přes limit
|
||||
nabíjení neplní, přebytek jde do sítě (142 = zero-export dle instalace, 145 = 1).
|
||||
|
||||
PASSIVE + nabíjení bez exportního záměru (`battery_w > 0`, export_mode NONE): **108 = max**.
|
||||
**CHARGE** ze sítě: 108 z `battery_w`.
|
||||
|
||||
**SELL** (selling first, reg 142 = 0): vrací ``(None, max_discharge)`` — reg **108 se nezapisuje**
|
||||
(export řídí 142/178; nulování 108 a obnova po návratu jsou zbytečné zápisy do paměti).
|
||||
"""
|
||||
if lock_battery:
|
||||
return 0, 0
|
||||
if deye_mode == "CHARGE":
|
||||
return battery_watts_to_amps(bat_w, max_charge_a), 0
|
||||
if deye_mode == "SELL":
|
||||
return None, int(max_discharge_a)
|
||||
if self_sustain_local_use:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
if _is_passive_pv_surplus_export(
|
||||
deye_mode=deye_mode,
|
||||
export_mode=export_mode,
|
||||
export_ban=export_ban,
|
||||
grid_w=grid_w,
|
||||
):
|
||||
return 0, int(max_discharge_a)
|
||||
if bat_w > 0:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
return _deye_zero_export_amps_for_passive(
|
||||
grid_w, bat_w, int(max_charge_a), int(max_discharge_a)
|
||||
)
|
||||
|
||||
|
||||
def get_deye_mode(setpoints: ControlSetpoints) -> str:
|
||||
"""Fyzický režim Deye: SELL | CHARGE | PASSIVE."""
|
||||
pm = (setpoints.deye_physical_mode or "").strip().upper()
|
||||
if pm in {"PASSIVE", "SELL", "CHARGE"}:
|
||||
return pm
|
||||
|
||||
grid_w = int(setpoints.grid_setpoint_w or 0)
|
||||
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
|
||||
|
||||
if bat_w > 0 and grid_w > 0:
|
||||
return "CHARGE"
|
||||
|
||||
if grid_w < 0 and bat_w < 0:
|
||||
return "SELL"
|
||||
|
||||
return "PASSIVE"
|
||||
|
||||
|
||||
def _deye_tou_params(
|
||||
setpoints: ControlSetpoints,
|
||||
inv: InverterConfig,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge."""
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
|
||||
tou_min = _deye_tou_min_soc_pct(inv)
|
||||
tou_reserve = _deye_tou_reserve_soc_pct(inv)
|
||||
if setpoints.lock_battery:
|
||||
return tp_discharge_w, tou_min, False
|
||||
deye_mode = get_deye_mode(setpoints)
|
||||
if deye_mode == "CHARGE":
|
||||
raw_bat = setpoints.battery_w
|
||||
battery_w = int(raw_bat) if raw_bat is not None else 0
|
||||
cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95
|
||||
target_soc = max(10, min(100, cap))
|
||||
tp_charge_w = (
|
||||
battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V)
|
||||
)
|
||||
return tp_charge_w, target_soc, True
|
||||
if deye_mode == "SELL":
|
||||
return tp_discharge_w, tou_reserve, False
|
||||
tou_soc = _deye_passive_tou_battery_soc_pct(inv, setpoints)
|
||||
return tp_discharge_w, tou_soc, False
|
||||
476
backend/services/control/verify.py
Normal file
476
backend/services/control/verify.py
Normal 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
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 | Δ (act−plan): **{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} Kč "
|
||||
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")
|
||||
|
||||
119
backend/services/plan_actual_slot_guard.py
Normal file
119
backend/services/plan_actual_slot_guard.py
Normal 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"),
|
||||
)
|
||||
1
backend/services/planning/__init__.py
Normal file
1
backend/services/planning/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""EMS plánovač – moduly (Fáze 1 dekompozice planning_engine.py)."""
|
||||
118
backend/services/planning/constants.py
Normal file
118
backend/services/planning/constants.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# backend/services/planning/constants.py
|
||||
#
|
||||
# EMS plánovač – konstanty (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
# POZOR: ekonomické penalty/váhy jsou kandidáti na přesun do DB ve Fázi 2
|
||||
# (CLAUDE.md pravidlo 16: žádný skrytý faktor v Pythonu).
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# ============================================================
|
||||
# Konstanty
|
||||
# ============================================================
|
||||
|
||||
# Když DB vrátí NULL (skoro žádná OTE data), denní plán použije krátký fallback (soulad s min hodinami ve fn_planning_horizon_end).
|
||||
_DAILY_FALLBACK_HORIZON_HOURS = 1.0
|
||||
# Shadow cena zbytkové energie na konci horizontu: - (avg_buy * FACTOR / 1000) * soc[T-1] (Kč; soc v Wh).
|
||||
INTERVAL_H = 0.25 # 15 minut v hodinách
|
||||
CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A
|
||||
SOLVER_TIME_LIMIT = 10 # sekund
|
||||
# MILP: významný export ge (W) ⇒ koncové soc[t] ≥ podlaha; mimo arbitrážní relax je to arb_base_wh
|
||||
# (rezerva z DB). Při relaxaci spodku před extrémně záporným buy je podlaha soc_panel_min[t]
|
||||
# (planner floor), jinak by šlo jen do zátěže a nešlo by „vypustit do sítě“ před levným nákupem.
|
||||
GE_MIN_EXPORT_W = 1.0
|
||||
# Dvouprůchodové solve: stop když acquisition z pass1 vs pass2 se liší méně než (Kč/kWh).
|
||||
ACQUISITION_TWO_PASS_EPS_KWH = 0.05
|
||||
# Load-first (Deye): PV nejdřív pokryje load+EV+TČ; bc_pv/ge_pv jen z pv_sp (přebytek).
|
||||
LOAD_FIRST_INCENTIVE_CZK_KWH = 0.05
|
||||
# Dokud je kotva pro hluboký dump (první sell < 0 v horizontu, jinak první extrémní buy) dál než
|
||||
# tento počet 15min slotů, držíme plánovací spodek na rezervě (arb_base_wh) místo planner floor —
|
||||
# priorita: beze „ztráty na prodeji“ (sell >= 0) držet buffer, hluboký vývoz až těsně před záporným prodejem.
|
||||
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8
|
||||
# Měkká kotva: chceme být u planner floor už v posledním slotu před prvním sell < 0.
|
||||
# Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila
|
||||
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
|
||||
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
|
||||
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail).
|
||||
PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
|
||||
# Curtailment při sell<0 + allow_charge: nesmí být téměř zdarma oproti nabíjení (BA81).
|
||||
NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0
|
||||
# Odměna v objective za FVE→baterie při sell<0 (doplňuje shortfall; BA81 fixed tarif).
|
||||
NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8
|
||||
# Měkký tlak: v okně sell<0 dobít na soc_max (ne zastavit na ~94 % kvůli curtail).
|
||||
NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35
|
||||
# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek.
|
||||
NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0
|
||||
# Fáze sell<0 (v32): ASAP na prep_soc %, tail rampa na soc_max.
|
||||
NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.85
|
||||
NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH = 60.0
|
||||
# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž).
|
||||
EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12
|
||||
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
|
||||
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
|
||||
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
|
||||
PLANNER_BUILD_TAG = "2026-06-06-home01-strict-late-replan-v5"
|
||||
SOLVER_RELAX_STEPS: tuple[str, ...] = (
|
||||
"strict",
|
||||
"relaxed_expensive_import",
|
||||
"relaxed_neg_buy_charge",
|
||||
"relaxed_neg_prep_hold_only",
|
||||
"relaxed_neg_prep_window",
|
||||
"neg_sell_phases_fallback",
|
||||
"relaxed_pos_sell_ge_block",
|
||||
"relaxed_solver_masks",
|
||||
)
|
||||
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
|
||||
DAWN_LOW_PV_NO_CURTAIL_W = 1500
|
||||
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
|
||||
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
|
||||
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
|
||||
NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0
|
||||
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
|
||||
NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
|
||||
# Kotva: SoC na konci večera D−1 a těsně před 1. sell<0 ráno D ≤ reserve_soc.
|
||||
NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH = 400.0
|
||||
NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 55.0
|
||||
# Terminal SoC shadow price: effective_factor = base × (1 − w_neg); w_neg roste s blízkostí a záporností buy<0.
|
||||
TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS = int(36 / INTERVAL_H)
|
||||
TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK = 1.0
|
||||
TERMINAL_NEG_BUY_MAGNITUDE_FLOOR = 0.25
|
||||
TERMINAL_NEG_BUY_WEIGHT_CAP = 0.95
|
||||
# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl.
|
||||
PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15
|
||||
PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0
|
||||
PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 55.0
|
||||
PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH = 90.0
|
||||
POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30
|
||||
PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25
|
||||
PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
EVENING_PEAK_SELL_EPS_CZK_KWH = 0.05
|
||||
# Rolling replan: držet evening_push_ts při malé změně peak sell / SoC.
|
||||
EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH = 0.5
|
||||
EVENING_PUSH_HYSTERESIS_SOC_PCT = 5.0
|
||||
# Noční výprodej baterie: večer (≥17h) + ráno do východu FVE (0–5h Prague), jedna špička přes půlnoc.
|
||||
NIGHT_EXPORT_EVENING_START_HOUR = 17
|
||||
NIGHT_EXPORT_MORNING_END_HOUR = 5
|
||||
NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W = 500.0
|
||||
# Převáží terminal SoC shadow price při krátkém večerním horizontu (home-01).
|
||||
EVENING_PUSH_Z_EXPORT_BONUS_CZK = 2500.0
|
||||
# buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B).
|
||||
PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0
|
||||
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
|
||||
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
|
||||
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
||||
# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast
|
||||
CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0
|
||||
# Dynamická ekonomická podlaha (MILP w_arb): lookahead FVE energie v dalších slotech
|
||||
ARB_LOOKAHEAD_SLOTS = 32 # 8 h při INTERVAL_H=0.25
|
||||
ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..1)
|
||||
|
||||
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
|
||||
# --- Konstanty původně roztroušené mezi funkcemi planning_engine.py (Fáze 1) ---
|
||||
MORNING_PRENEG_START_HOUR = 5
|
||||
MORNING_PRENEG_END_HOUR = 11
|
||||
450
backend/services/planning/db_io.py
Normal file
450
backend/services/planning/db_io.py
Normal file
@@ -0,0 +1,450 @@
|
||||
# backend/services/planning/db_io.py
|
||||
#
|
||||
# EMS plánovač – DB vrstva: načtení site contextu a slotů, uložení běhu
|
||||
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
# Jediné SQL: select ems.fn_* (SQL-first pravidlo CLAUDE.md).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
|
||||
from services.planning.constants import (
|
||||
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
|
||||
PLANNER_BUILD_TAG,
|
||||
)
|
||||
from services.planning.types import (
|
||||
PlannerSolverError,
|
||||
PlanningSlot,
|
||||
_parse_json_dt,
|
||||
_slot_float_nullable,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
|
||||
if obj is None or obj == []:
|
||||
return None
|
||||
if isinstance(obj, str):
|
||||
obj = json.loads(obj)
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
td = _parse_json_dt(obj.get("target_deadline"))
|
||||
if td is None:
|
||||
return None
|
||||
return SimpleNamespace(
|
||||
target_deadline=td,
|
||||
energy_needed_wh=float(obj["energy_needed_wh"]),
|
||||
)
|
||||
|
||||
async def _load_site_context(site_id: int, db):
|
||||
"""
|
||||
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV, režim a TUV statistiky (SQL).
|
||||
"""
|
||||
raw = await db.fetchval(
|
||||
"select ems.fn_planning_site_context($1::int)",
|
||||
site_id,
|
||||
)
|
||||
ctx = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
if ctx.get("error") == "unknown_site":
|
||||
raise RuntimeError(f"Site not found: {site_id}")
|
||||
|
||||
b = ctx["battery"]
|
||||
ec_i = int(b["max_charge_power_w"])
|
||||
ed_i = int(b["max_discharge_power_w"])
|
||||
planner_soc_max = float(b.get("planner_soc_max_wh", b["soc_max_wh"]))
|
||||
floor_pct = b.get("planner_discharge_floor_percent")
|
||||
buy_thr = b.get("planner_extreme_buy_threshold_czk_kwh")
|
||||
relax_prewin = b.get("planner_discharge_relax_prewindow_slots")
|
||||
battery = SimpleNamespace(
|
||||
usable_capacity_wh=float(b["usable_capacity_wh"]),
|
||||
min_soc_wh=float(b["min_soc_wh"]),
|
||||
arb_floor_wh=float(b["arb_floor_wh"]),
|
||||
reserve_soc_wh=float(b["reserve_soc_wh"]),
|
||||
soc_max_wh=planner_soc_max,
|
||||
charge_efficiency=float(b["charge_efficiency"]),
|
||||
discharge_efficiency=float(b["discharge_efficiency"]),
|
||||
degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]),
|
||||
max_charge_power_w=ec_i,
|
||||
max_discharge_power_w=ed_i,
|
||||
charge_slot_buffer=float(b["charge_slot_buffer"])
|
||||
if b.get("charge_slot_buffer") is not None
|
||||
else 0,
|
||||
discharge_slot_buffer=float(b["discharge_slot_buffer"])
|
||||
if b.get("discharge_slot_buffer") is not None
|
||||
else 0,
|
||||
planner_extreme_buy_threshold_czk_kwh=float(buy_thr) if buy_thr is not None else -5.0,
|
||||
planner_discharge_floor_percent=float(floor_pct) if floor_pct is not None else None,
|
||||
planner_discharge_relax_prewindow_slots=int(relax_prewin)
|
||||
if relax_prewin is not None
|
||||
else DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
|
||||
planner_terminal_soc_value_factor=float(b["planner_terminal_soc_value_factor"]),
|
||||
planner_daytime_charge_target_enabled=bool(
|
||||
b.get("planner_daytime_charge_target_enabled", True)
|
||||
),
|
||||
planner_night_baseload_buffer_percent=float(
|
||||
b.get("planner_night_baseload_buffer_percent") or 20.0
|
||||
),
|
||||
planner_daytime_charge_price_quantile=float(
|
||||
b.get("planner_daytime_charge_price_quantile") or 0.70
|
||||
),
|
||||
planner_charge_commitment_penalty_czk_kwh=float(
|
||||
b.get("planner_charge_commitment_penalty_czk_kwh") or 0.20
|
||||
),
|
||||
planner_neg_sell_prep_soc_percent=float(
|
||||
b.get("planner_neg_sell_prep_soc_percent") or 80.0
|
||||
),
|
||||
planner_neg_sell_full_soc_tail_slots=int(
|
||||
b.get("planner_neg_sell_full_soc_tail_slots") or 4
|
||||
),
|
||||
planner_neg_sell_vent_min_sell_czk_kwh=(
|
||||
float(b["planner_neg_sell_vent_min_sell_czk_kwh"])
|
||||
if b.get("planner_neg_sell_vent_min_sell_czk_kwh") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
hpj = ctx["heat_pump"]
|
||||
heat_pump = SimpleNamespace(
|
||||
rated_heating_power_w=int(hpj["rated_heating_power_w"]),
|
||||
tuv_min_temp_c=float(hpj["tuv_min_temp_c"]),
|
||||
tuv_target_temp_c=float(hpj["tuv_target_temp_c"]),
|
||||
)
|
||||
|
||||
g = ctx["grid"]
|
||||
m = ctx.get("market") or {}
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=int(g["max_import_power_w"]),
|
||||
max_export_power_w=int(g["max_export_power_w"]),
|
||||
block_export_on_negative_sell=bool(g.get("block_export_on_negative_sell") or False),
|
||||
deye_gen_microinverter_cutoff_enabled=bool(g.get("deye_gen_microinverter_cutoff_enabled") or False),
|
||||
purchase_pricing_mode=str(m.get("purchase_pricing_mode") or "spot").strip().lower(),
|
||||
sale_pricing_mode=str(m.get("sale_pricing_mode") or "spot").strip().lower(),
|
||||
)
|
||||
|
||||
vehicles: list[SimpleNamespace] = []
|
||||
for v in ctx.get("vehicles") or []:
|
||||
vehicles.append(
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=int(v["max_charge_power_w"]),
|
||||
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
|
||||
default_target_soc_pct=float(v["default_target_soc_pct"]),
|
||||
)
|
||||
)
|
||||
while len(vehicles) < 2:
|
||||
vehicles.append(
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
)
|
||||
)
|
||||
|
||||
ev_raw = ctx.get("ev_sessions") or []
|
||||
ev_sessions = [
|
||||
_ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None,
|
||||
_ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None,
|
||||
]
|
||||
|
||||
soc_wh = float(ctx["soc_wh"])
|
||||
tuv_temp = float(ctx["tuv_temp"])
|
||||
operating_mode = ctx.get("operating_mode")
|
||||
|
||||
tuv_stats: dict[tuple[int, int], float] = {}
|
||||
for row in ctx.get("tuv_delta_stats") or []:
|
||||
tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"])
|
||||
|
||||
return (
|
||||
battery,
|
||||
heat_pump,
|
||||
grid,
|
||||
vehicles,
|
||||
ev_sessions,
|
||||
soc_wh,
|
||||
tuv_temp,
|
||||
operating_mode,
|
||||
tuv_stats,
|
||||
)
|
||||
|
||||
async def _load_previous_plan_charge_commitment_prev_w(
|
||||
site_id: int,
|
||||
slots: list[PlanningSlot],
|
||||
db,
|
||||
) -> list[Optional[float]]:
|
||||
"""
|
||||
Pro rolling replan: z aktivního plánu načte battery_setpoint_w pro shodné sloty.
|
||||
Kotva měkkého commitmentu jen když předchozí plán chtěl nabíjet z PV přebytku (viz podmínky).
|
||||
"""
|
||||
if not slots:
|
||||
return []
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
select pi.interval_start,
|
||||
pi.battery_setpoint_w,
|
||||
pi.grid_setpoint_w,
|
||||
coalesce(pi.pv_a_forecast_solver_w, 0) as pva,
|
||||
coalesce(pi.pv_b_forecast_solver_w, 0) as pvb,
|
||||
coalesce(pi.load_baseline_w, 0) as lb
|
||||
from ems.planning_interval pi
|
||||
inner join ems.planning_run pr on pr.id = pi.run_id
|
||||
where pr.site_id = $1::int
|
||||
and pr.status = 'active'
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
by_start = {r["interval_start"]: r for r in rows}
|
||||
out: list[Optional[float]] = []
|
||||
for s in slots:
|
||||
r = by_start.get(s.interval_start)
|
||||
if r is None:
|
||||
out.append(None)
|
||||
continue
|
||||
bw = int(r["battery_setpoint_w"] or 0)
|
||||
gw = int(r["grid_setpoint_w"] or 0)
|
||||
pva = int(r["pva"] or 0)
|
||||
pvb = int(r["pvb"] or 0)
|
||||
lb = int(r["lb"] or 0)
|
||||
# Commitment má kotvit jen „nabíjení z PV přebytku“, ne situace kdy plán současně
|
||||
# výrazně exportuje do sítě (typicky charge while exporting). To by stabilizovalo špatný cyklus.
|
||||
if bw > 500 and (pva + pvb) > lb and gw <= 0 and gw >= -500:
|
||||
out.append(float(bw))
|
||||
else:
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
async def _load_slots(
|
||||
site_id: int,
|
||||
from_dt: datetime,
|
||||
to_dt: datetime,
|
||||
db,
|
||||
*,
|
||||
soc_wh: float,
|
||||
) -> list[PlanningSlot]:
|
||||
"""15min sloty z ems.fn_load_planning_slots_full."""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
|
||||
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
|
||||
ev1_connected, ev2_connected, allow_charge, allow_discharge_export,
|
||||
night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh,
|
||||
future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh,
|
||||
is_daytime_pv_surplus_slot,
|
||||
charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at,
|
||||
min_buy_before_cutoff_czk_kwh, pv_charge_wh_ahead, neg_buy_wh_ahead,
|
||||
grid_charge_suppressed_reason,
|
||||
charge_target_wh, pre_window_wh, in_window_wh,
|
||||
charge_slot_wh, charge_cum_wh, charge_layer, charge_slot_reason
|
||||
from ems.fn_load_planning_slots_full(
|
||||
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
from_dt,
|
||||
to_dt,
|
||||
soc_wh,
|
||||
)
|
||||
out: list[PlanningSlot] = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
out.append(
|
||||
PlanningSlot(
|
||||
interval_start=d["interval_start"],
|
||||
buy_price=float(d["buy_price"]),
|
||||
sell_price=float(d["sell_price"]),
|
||||
pv_a_forecast_w=int(d["pv_a_forecast_w"] or 0),
|
||||
pv_b_forecast_w=int(d["pv_b_forecast_w"] or 0),
|
||||
load_baseline_w=int(d["load_baseline_w"] or 0),
|
||||
ev1_connected=bool(d["ev1_connected"]),
|
||||
ev2_connected=bool(d["ev2_connected"]),
|
||||
is_predicted_price=bool(d.get("is_predicted_price")),
|
||||
allow_charge=bool(d.get("allow_charge", True)),
|
||||
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
|
||||
night_baseload_target_wh=_slot_float_nullable(d, "night_baseload_target_wh"),
|
||||
night_baseload_buffer_wh=_slot_float_nullable(d, "night_baseload_buffer_wh"),
|
||||
safety_soc_target_wh=_slot_float_nullable(d, "safety_soc_target_wh"),
|
||||
future_avoided_buy_czk_kwh=_slot_float_nullable(d, "future_avoided_buy_czk_kwh"),
|
||||
future_sell_opportunity_czk_kwh=_slot_float_nullable(
|
||||
d, "future_sell_opportunity_czk_kwh"
|
||||
),
|
||||
is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)),
|
||||
charge_acquisition_buy_czk_kwh=_slot_float_nullable(
|
||||
d, "charge_acquisition_buy_czk_kwh"
|
||||
),
|
||||
charge_acquisition_cutoff_at=d.get("charge_acquisition_cutoff_at"),
|
||||
min_buy_before_cutoff_czk_kwh=_slot_float_nullable(
|
||||
d, "min_buy_before_cutoff_czk_kwh"
|
||||
),
|
||||
pv_charge_wh_ahead=_slot_float_nullable(d, "pv_charge_wh_ahead"),
|
||||
neg_buy_wh_ahead=_slot_float_nullable(d, "neg_buy_wh_ahead"),
|
||||
grid_charge_suppressed_reason=d.get("grid_charge_suppressed_reason"),
|
||||
charge_target_wh=_slot_float_nullable(d, "charge_target_wh"),
|
||||
pre_window_wh=_slot_float_nullable(d, "pre_window_wh"),
|
||||
in_window_wh=_slot_float_nullable(d, "in_window_wh"),
|
||||
charge_slot_wh=_slot_float_nullable(d, "charge_slot_wh"),
|
||||
charge_cum_wh=_slot_float_nullable(d, "charge_cum_wh"),
|
||||
charge_layer=d.get("charge_layer"),
|
||||
charge_slot_reason=d.get("charge_slot_reason"),
|
||||
)
|
||||
)
|
||||
if not out:
|
||||
raise RuntimeError(
|
||||
"No planning slots available – check market prices and horizon settings"
|
||||
)
|
||||
if any(s.is_predicted_price for s in out):
|
||||
logger.warning(
|
||||
"[site=%s] Unexpected predicted-price slots in planning horizon",
|
||||
site_id,
|
||||
)
|
||||
return out
|
||||
|
||||
def _build_slot_inputs(
|
||||
slots_raw_pv: list[PlanningSlot],
|
||||
slots_solver: list[PlanningSlot],
|
||||
) -> list[tuple[int, int, int, int, int]]:
|
||||
"""(load_baseline_w, pv_a_raw, pv_b_raw, pv_a_solver, pv_b_solver) pro každý slot."""
|
||||
if len(slots_raw_pv) != len(slots_solver):
|
||||
raise ValueError("slots_raw_pv and slots_solver length mismatch")
|
||||
out: list[tuple[int, int, int, int, int]] = []
|
||||
for raw, sol in zip(slots_raw_pv, slots_solver):
|
||||
out.append(
|
||||
(
|
||||
int(raw.load_baseline_w),
|
||||
int(raw.pv_a_forecast_w),
|
||||
int(raw.pv_b_forecast_w),
|
||||
int(sol.pv_a_forecast_w),
|
||||
int(sol.pv_b_forecast_w),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
async def _save_planning_run(
|
||||
site_id, results, horizon_from, horizon_to,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_wh, duration_ms, correction, db,
|
||||
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
||||
*,
|
||||
activate_run: bool = True,
|
||||
solver_snapshot: Optional[dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
|
||||
if slot_inputs is not None and len(slot_inputs) != len(results):
|
||||
raise ValueError("slot_inputs and results length mismatch")
|
||||
run_meta: dict[str, Any] = {
|
||||
"run_type": run_type,
|
||||
"triggered_by": triggered_by,
|
||||
"replan_from": replan_from.isoformat() if replan_from else None,
|
||||
"soc_at_replan_wh": soc_wh,
|
||||
"solver_duration_ms": duration_ms,
|
||||
"forecast_correction_factor": correction,
|
||||
}
|
||||
if solver_snapshot is not None:
|
||||
run_meta["solver_params"] = solver_snapshot
|
||||
intervals: list[dict] = []
|
||||
for i, r in enumerate(results):
|
||||
row: dict = {
|
||||
"interval_start": r.interval_start.isoformat()
|
||||
if hasattr(r.interval_start, "isoformat")
|
||||
else r.interval_start,
|
||||
"battery_setpoint_w": r.battery_setpoint_w,
|
||||
"battery_soc_target_pct": r.battery_soc_target,
|
||||
"grid_setpoint_w": r.grid_setpoint_w,
|
||||
"export_limit_w": r.export_limit_w,
|
||||
"export_mode": r.export_mode,
|
||||
"deye_physical_mode": r.deye_physical_mode,
|
||||
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
|
||||
"ev1_setpoint_w": r.ev1_setpoint_w,
|
||||
"ev2_setpoint_w": r.ev2_setpoint_w,
|
||||
"ev1_via_bat_w": r.ev1_via_bat_w,
|
||||
"ev2_via_bat_w": r.ev2_via_bat_w,
|
||||
"heat_pump_enabled": r.heat_pump_enabled,
|
||||
"heat_pump_setpoint_w": r.heat_pump_setpoint_w,
|
||||
"pv_a_curtailed_w": r.pv_a_curtailed_w,
|
||||
"expected_cost_czk": float(r.expected_cost_czk),
|
||||
"cashflow_czk": float(r.cashflow_czk),
|
||||
"battery_arbitrage_czk": float(r.battery_arbitrage_czk),
|
||||
"penalty_czk": float(r.penalty_czk),
|
||||
"green_bonus_czk": float(r.green_bonus_czk),
|
||||
"effective_buy_price": float(r.effective_buy_price),
|
||||
"effective_sell_price": float(r.effective_sell_price),
|
||||
"is_predicted_price": r.is_predicted_price,
|
||||
}
|
||||
if slot_inputs is not None:
|
||||
si = slot_inputs[i]
|
||||
row["load_baseline_w"] = si[0]
|
||||
row["pv_a_forecast_raw_w"] = si[1]
|
||||
row["pv_b_forecast_raw_w"] = si[2]
|
||||
row["pv_a_forecast_solver_w"] = si[3]
|
||||
row["pv_b_forecast_solver_w"] = si[4]
|
||||
intervals.append(row)
|
||||
|
||||
return int(
|
||||
await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_run_commit(
|
||||
$1::int, $2::timestamptz, $3::timestamptz,
|
||||
$4::jsonb, $5::jsonb, $6::boolean
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
horizon_from,
|
||||
horizon_to,
|
||||
json.dumps(run_meta, default=str),
|
||||
json.dumps(intervals, default=str),
|
||||
activate_run,
|
||||
)
|
||||
)
|
||||
|
||||
async def _save_failed_planning_run(
|
||||
site_id: int,
|
||||
horizon_from: datetime,
|
||||
horizon_to: datetime,
|
||||
*,
|
||||
run_type: str,
|
||||
triggered_by: str,
|
||||
replan_from: datetime | None,
|
||||
soc_wh: float,
|
||||
correction: float,
|
||||
db,
|
||||
error: PlannerSolverError,
|
||||
slot_count: int | None = None,
|
||||
) -> int:
|
||||
"""Uloží neúspěšný běh plánovače (status=failed); aktivní plán nemění."""
|
||||
run_meta: dict[str, Any] = {
|
||||
"run_type": run_type,
|
||||
"triggered_by": triggered_by,
|
||||
"replan_from": replan_from.isoformat() if replan_from else None,
|
||||
"soc_at_replan_wh": soc_wh,
|
||||
"solver_duration_ms": 0,
|
||||
"forecast_correction_factor": correction,
|
||||
"error_text": str(error),
|
||||
"solver_params": {
|
||||
"status": "failed",
|
||||
"planner_build_tag": PLANNER_BUILD_TAG,
|
||||
"solver_status": error.solver_status,
|
||||
"relax_chain": error.relax_chain,
|
||||
"slot_count": slot_count,
|
||||
},
|
||||
}
|
||||
run_id = int(
|
||||
await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_run_fail(
|
||||
$1::int, $2::timestamptz, $3::timestamptz, $4::jsonb
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
horizon_from,
|
||||
horizon_to,
|
||||
json.dumps(run_meta, default=str),
|
||||
)
|
||||
)
|
||||
logger.error(
|
||||
"[site=%s] Planning solver failed run_id=%s: %s relax_chain=%s",
|
||||
site_id,
|
||||
run_id,
|
||||
error,
|
||||
error.relax_chain,
|
||||
)
|
||||
return run_id
|
||||
97
backend/services/planning/forecast.py
Normal file
97
backend/services/planning/forecast.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# backend/services/planning/forecast.py
|
||||
#
|
||||
# EMS plánovač – korekce FVE forecastu podle skutečné výroby
|
||||
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from services.planning.constants import (
|
||||
CORRECTION_DECAY_SLOTS,
|
||||
CORRECTION_MAX_CLAMP,
|
||||
CORRECTION_MIN_CLAMP,
|
||||
CORRECTION_WINDOW_H,
|
||||
)
|
||||
from services.planning.types import PlanningSlot, _parse_json_dt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def compute_correction_factor(
|
||||
site_id: int,
|
||||
now: datetime,
|
||||
db,
|
||||
window_h: float = CORRECTION_WINDOW_H,
|
||||
) -> tuple[float, dict]:
|
||||
"""
|
||||
Spočítá korekční faktor FVE forecastu z posledních window_h hodin.
|
||||
|
||||
Vrátí (factor, log_data) kde factor je v rozsahu [CORRECTION_MIN_CLAMP, CORRECTION_MAX_CLAMP].
|
||||
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
|
||||
"""
|
||||
window_start = now - timedelta(hours=window_h)
|
||||
raw = await db.fetchval(
|
||||
"""
|
||||
select ems.fn_pv_forecast_correction_factor(
|
||||
$1::int, $2::timestamptz, $3::timestamptz,
|
||||
$4::numeric, $5::numeric
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
window_start,
|
||||
now,
|
||||
CORRECTION_MIN_CLAMP,
|
||||
CORRECTION_MAX_CLAMP,
|
||||
)
|
||||
j = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
factor = float(j.get("correction_factor", 1.0))
|
||||
# JSON z DB má často ISO řetězce; asyncpg u $2/$3 vyžaduje datetime
|
||||
ws = _parse_json_dt(j.get("window_start")) or window_start
|
||||
we = _parse_json_dt(j.get("window_end")) or now
|
||||
log_data = {
|
||||
"window_start": ws,
|
||||
"window_end": we,
|
||||
"actual_pv_wh": j.get("actual_pv_wh"),
|
||||
"forecast_pv_wh": j.get("forecast_pv_wh"),
|
||||
"correction_factor": factor,
|
||||
"reason": j.get("reason", "ok"),
|
||||
}
|
||||
if j.get("raw_factor") is not None:
|
||||
log_data["raw_factor"] = j["raw_factor"]
|
||||
return factor, log_data
|
||||
|
||||
def apply_forecast_correction(
|
||||
slots: list[PlanningSlot],
|
||||
now: datetime,
|
||||
factor: float,
|
||||
decay_slots: int = CORRECTION_DECAY_SLOTS,
|
||||
) -> list[PlanningSlot]:
|
||||
"""
|
||||
Aplikuje korekční faktor na FVE forecast zbývajících slotů.
|
||||
Korekce se lineárně utlumuje: na 1. slotu plná korekce,
|
||||
na decay_slots-tém slotu žádná korekce.
|
||||
|
||||
Příklad: factor=0.85, slot 0 → pv_a *= 0.85, slot 8 → pv_a *= 0.925, slot 16+ → žádná korekce
|
||||
"""
|
||||
corrected = []
|
||||
for i, slot in enumerate(slots):
|
||||
if factor == 1.0 or i >= decay_slots:
|
||||
corrected.append(slot)
|
||||
continue
|
||||
|
||||
# Lineární útlum: weight klesá od 1.0 (slot 0) do 0.0 (slot decay_slots)
|
||||
weight = 1.0 - (i / decay_slots)
|
||||
effective_factor = 1.0 + (factor - 1.0) * weight
|
||||
|
||||
corrected.append(
|
||||
replace(
|
||||
slot,
|
||||
pv_a_forecast_w=max(0, int(slot.pv_a_forecast_w * effective_factor)),
|
||||
pv_b_forecast_w=max(0, int(slot.pv_b_forecast_w * effective_factor)),
|
||||
)
|
||||
)
|
||||
|
||||
return corrected
|
||||
1981
backend/services/planning/heuristics.py
Normal file
1981
backend/services/planning/heuristics.py
Normal file
File diff suppressed because it is too large
Load Diff
400
backend/services/planning/solver_v2.py
Normal file
400
backend/services/planning/solver_v2.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# backend/services/planning/solver_v2.py
|
||||
#
|
||||
# EMS plánovač v2 — ČISTÉ ekonomické jádro (Fáze 3).
|
||||
#
|
||||
# Filozofie: objective = reálné peníze (nákup − prodej + degradace − terminal
|
||||
# hodnota energie). Žádné heuristické penalty z constants.py, žádné pre-solver
|
||||
# fáze/okna/kotvy. Chování (neg-sell příprava, evening export, arbitráž) má
|
||||
# VYPLYNOUT z cen a fyziky, ne z ručně laděných vah.
|
||||
#
|
||||
# Co zůstává (tvrdá pravidla — fyzika, HW, CLAUDE.md):
|
||||
# - bilance sběrnice, SoC dynamika s účinnostmi, výkonové stropy
|
||||
# - curtailment jen pole A (pravidlo 5); GEN cutoff binárka pole B (pravidlo 6)
|
||||
# - block_export_on_negative_sell → ge == 0 při sell < 0 (pravidlo 6, KV1)
|
||||
# - buy < 0 → ge == 0 (žádná pumpa import−export přes jeden elektroměr; import
|
||||
# je omezen breakerem — pravidlo 7)
|
||||
# - export z BATERIE ⇒ koncové SoC ≥ arb floor (pravidlo 19; PV export floor nevynucuje)
|
||||
# - zákaz současného importu a exportu (binárka)
|
||||
# - load-first Deye: bc_pv + ge_pv jen z PV přebytku nad zátěží
|
||||
# - EV deadline, TUV look-ahead, provozní režimy (legitimní constraints)
|
||||
#
|
||||
# Vědomé odchylky od v1 (změří harness):
|
||||
# - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to
|
||||
# výstupy charge-slot-budget heuristik, ne fyzika)
|
||||
# - EV náklady jen přes bilanci (v1 je účtuje navíc v objective — dvojí započtení)
|
||||
# - import breaker je tvrdý strop (v1 měkký s 10 Kč/kWh)
|
||||
# - nedodaná EV energie má explicitní cenu místo infeasibility
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import pulp
|
||||
|
||||
from services.planning.constants import (
|
||||
INTERVAL_H,
|
||||
SOLVER_TIME_LIMIT,
|
||||
)
|
||||
from services.planning.types import (
|
||||
DispatchResult,
|
||||
PlanningSlot,
|
||||
_prague_dow_hour,
|
||||
)
|
||||
from services.planning.heuristics import _dispatch_grid_setpoint_w
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
V2_BUILD_TAG = "v2-clean-2026-06-11"
|
||||
|
||||
# Cena za vypnutí GEN portu (mikroinvertory pole B): reálné riziko/opotřebení
|
||||
# cyklování stykače — drobná, ale nenulová, aby cutoff platil jen při sell < 0.
|
||||
V2_GEN_CUTOFF_CZK_KWH = 2.0
|
||||
# SELF_SUSTAIN: export je nežádoucí, ale tvrdé ge=0 by s neřiditelným polem B
|
||||
# a plnou baterií bylo infeasible — vysoká cena funguje jako ventil.
|
||||
V2_SELF_SUSTAIN_EXPORT_CZK_KWH = 100.0
|
||||
# Cena nedodané EV energie do deadline (Kč/kWh) — místo tvrdé infeasibility.
|
||||
V2_EV_UNMET_CZK_KWH = 50.0
|
||||
# Nepatrný tie-break proti zbytečnému curtailu při cenové indiferenci (Kč/kWh).
|
||||
V2_CURTAIL_TIEBREAK_CZK_KWH = 0.001
|
||||
|
||||
|
||||
def _terminal_value_czk_per_wh(slots: list[PlanningSlot], battery: Any) -> float:
|
||||
"""Shadow cena zbytkové energie: průměrný buy prvních 24 h × DB faktor (pravidlo 16)."""
|
||||
n24 = min(len(slots), int(24 / INTERVAL_H))
|
||||
avg_buy = sum(float(s.buy_price) for s in slots[:n24]) / max(1, n24)
|
||||
factor = float(getattr(battery, "planner_terminal_soc_value_factor", 1.0) or 1.0)
|
||||
return max(0.0, avg_buy) * factor / 1000.0
|
||||
|
||||
|
||||
def _arb_floor_wh(battery: Any) -> float:
|
||||
"""Podlaha SoC pro export z baterie (pravidlo 19): ekonomická rezerva z DB."""
|
||||
floor = getattr(battery, "arb_floor_wh", None)
|
||||
if floor is None:
|
||||
floor = getattr(battery, "reserve_soc_wh", None)
|
||||
return max(float(floor or 0.0), float(battery.min_soc_wh))
|
||||
|
||||
|
||||
def solve_dispatch_v2(
|
||||
slots: list[PlanningSlot],
|
||||
battery: Any,
|
||||
heat_pump: Any,
|
||||
grid: Any,
|
||||
ev_sessions: list,
|
||||
vehicles: list,
|
||||
current_soc_wh: float,
|
||||
current_tuv_temp_c: float,
|
||||
*,
|
||||
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
|
||||
operating_mode: str = "AUTO",
|
||||
planner_version: str | None = None,
|
||||
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
|
||||
"""Čistý ekonomický MILP; rozhraní kompatibilní se solve_dispatch (v1)."""
|
||||
if not slots:
|
||||
raise RuntimeError("solve_dispatch_v2 requires at least one slot")
|
||||
t0 = time.monotonic()
|
||||
T = len(slots)
|
||||
om = (operating_mode or "AUTO").upper()
|
||||
EV = min(len(vehicles), 2)
|
||||
|
||||
max_imp = float(grid.max_import_power_w)
|
||||
max_exp = float(grid.max_export_power_w)
|
||||
max_chg = float(battery.max_charge_power_w)
|
||||
max_dis = float(battery.max_discharge_power_w)
|
||||
eff_c = float(battery.charge_efficiency)
|
||||
eff_d = float(battery.discharge_efficiency)
|
||||
deg = float(battery.degradation_cost_czk_kwh)
|
||||
soc_min = float(battery.min_soc_wh)
|
||||
soc_max = float(battery.soc_max_wh)
|
||||
usable = float(battery.usable_capacity_wh)
|
||||
arb_floor = _arb_floor_wh(battery)
|
||||
terminal = _terminal_value_czk_per_wh(slots, battery)
|
||||
block_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
|
||||
gen_cutoff_avail = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False))
|
||||
soc0 = min(max(float(current_soc_wh), soc_min), soc_max)
|
||||
|
||||
prob = pulp.LpProblem("dispatch_v2", pulp.LpMinimize)
|
||||
|
||||
gi = [pulp.LpVariable(f"gi_{t}", 0, max_imp) for t in range(T)]
|
||||
ge_pv = [pulp.LpVariable(f"gepv_{t}", 0, max_exp) for t in range(T)]
|
||||
ge_bat = [pulp.LpVariable(f"gebat_{t}", 0, max_exp) for t in range(T)]
|
||||
bc_pv = [pulp.LpVariable(f"bcpv_{t}", 0, max_chg) for t in range(T)]
|
||||
bc_gi = [pulp.LpVariable(f"bcgi_{t}", 0, max_chg) for t in range(T)]
|
||||
bd = [pulp.LpVariable(f"bd_{t}", 0, max_dis) for t in range(T)]
|
||||
ca = [pulp.LpVariable(f"ca_{t}", 0, max(0, int(slots[t].pv_a_forecast_w))) for t in range(T)]
|
||||
soc = [pulp.LpVariable(f"soc_{t}", soc_min, soc_max) for t in range(T)]
|
||||
hp = [pulp.LpVariable(f"hp_{t}", 0, float(heat_pump.rated_heating_power_w)) for t in range(T)]
|
||||
y_imp = [pulp.LpVariable(f"yimp_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
z_exp = [pulp.LpVariable(f"zexp_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
z_gen = (
|
||||
[pulp.LpVariable(f"zgen_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
if gen_cutoff_avail
|
||||
else None
|
||||
)
|
||||
ev_direct = [
|
||||
[
|
||||
pulp.LpVariable(f"evd_{e}_{t}", 0, min(float(vehicles[e].max_charge_power_w), max_imp))
|
||||
for t in range(T)
|
||||
]
|
||||
for e in range(EV)
|
||||
]
|
||||
ev_via_bat = [
|
||||
[
|
||||
pulp.LpVariable(f"evb_{e}_{t}", 0, float(vehicles[e].max_charge_power_w))
|
||||
for t in range(T)
|
||||
]
|
||||
for e in range(EV)
|
||||
]
|
||||
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
|
||||
|
||||
def _connected(e: int, t: int) -> bool:
|
||||
return bool(slots[t].ev1_connected if e == 0 else slots[t].ev2_connected)
|
||||
|
||||
for t in range(T):
|
||||
s = slots[t]
|
||||
pv_a = max(0.0, float(s.pv_a_forecast_w))
|
||||
pv_b = max(0.0, float(s.pv_b_forecast_w))
|
||||
pv_a_net = pv_a - ca[t]
|
||||
pv_b_eff = pv_b - (pv_b * z_gen[t] if z_gen is not None else 0.0)
|
||||
|
||||
ev_total_t = pulp.lpSum(
|
||||
ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV)
|
||||
)
|
||||
load_site = float(s.load_baseline_w) + ev_total_t + hp[t]
|
||||
|
||||
# bilance sběrnice (W)
|
||||
prob += (
|
||||
pv_a_net + pv_b_eff + gi[t] + bd[t]
|
||||
== load_site + bc_pv[t] + bc_gi[t] + ge_pv[t] + ge_bat[t]
|
||||
), f"balance_{t}"
|
||||
|
||||
# SoC dynamika (Wh)
|
||||
prev = soc0 if t == 0 else soc[t - 1]
|
||||
prob += (
|
||||
soc[t]
|
||||
== prev
|
||||
+ (bc_pv[t] + bc_gi[t]) * eff_c * INTERVAL_H
|
||||
- bd[t] / eff_d * INTERVAL_H
|
||||
), f"soc_{t}"
|
||||
|
||||
# výkonové stropy
|
||||
prob += bc_pv[t] + bc_gi[t] <= max_chg, f"chg_cap_{t}"
|
||||
prob += ge_pv[t] + ge_bat[t] <= max_exp, f"exp_cap_{t}"
|
||||
|
||||
# PV cesty omezené dostupnou výrobou (load-first vynucuje HW; bilance účtuje energii)
|
||||
prob += bc_pv[t] + ge_pv[t] <= pv_a_net + pv_b_eff, f"pv_src_{t}"
|
||||
# bc_gi jen ze sítě:
|
||||
prob += bc_gi[t] <= gi[t], f"bcgi_src_{t}"
|
||||
# vybíjení kryje dům + EV-via-bat + export z baterie
|
||||
prob += ge_bat[t] + pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t], f"bd_split_{t}"
|
||||
|
||||
# zákaz současného importu a exportu
|
||||
prob += gi[t] <= max_imp * y_imp[t], f"imp_excl_{t}"
|
||||
prob += ge_pv[t] + ge_bat[t] <= max_exp * (1 - y_imp[t]), f"exp_excl_{t}"
|
||||
|
||||
# pravidlo 19: export z baterie ⇒ SoC ≥ arb floor
|
||||
prob += ge_bat[t] <= max_exp * z_exp[t], f"zexp_link_{t}"
|
||||
prob += soc[t] >= arb_floor - (soc_max - soc_min) * (1 - z_exp[t]), f"zexp_floor_{t}"
|
||||
|
||||
# tvrdá cenová pravidla
|
||||
if float(s.buy_price) < 0.0:
|
||||
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}"
|
||||
if float(s.sell_price) < 0.0 and block_neg_sell:
|
||||
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_sell_block_{t}"
|
||||
|
||||
# EV dostupnost
|
||||
for e in range(EV):
|
||||
if not _connected(e, t):
|
||||
prob += ev_direct[e][t] == 0
|
||||
prob += ev_via_bat[e][t] == 0
|
||||
else:
|
||||
prob += ev_direct[e][t] + ev_via_bat[e][t] <= float(
|
||||
vehicles[e].max_charge_power_w
|
||||
)
|
||||
|
||||
# provozní režimy (tvrdé constraints dle operating-modes.md)
|
||||
if om == "SELF_SUSTAIN":
|
||||
prob += gi[t] <= float(s.load_baseline_w), f"ss_gi_{t}"
|
||||
elif om == "PRESERVE":
|
||||
prob += bc_pv[t] == 0
|
||||
prob += bc_gi[t] == 0
|
||||
prob += bd[t] == 0
|
||||
elif om == "CHARGE_CHEAP":
|
||||
prob += ge_pv[t] + ge_bat[t] == 0
|
||||
prob += bd[t] == 0
|
||||
|
||||
# EV deadline (s placeným slackem místo infeasibility)
|
||||
for e in range(EV):
|
||||
sess = ev_sessions[e] if e < len(ev_sessions) else None
|
||||
if sess is None or not getattr(sess, "energy_needed_wh", 0):
|
||||
continue
|
||||
t_dl = next(
|
||||
(t for t in range(T) if slots[t].interval_start >= sess.target_deadline),
|
||||
T - 1,
|
||||
)
|
||||
unmet = pulp.LpVariable(f"ev_unmet_{e}", 0, float(sess.energy_needed_wh))
|
||||
ev_unmet.append(unmet)
|
||||
prob += (
|
||||
pulp.lpSum(
|
||||
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
|
||||
for t in range(t_dl + 1)
|
||||
if _connected(e, t)
|
||||
)
|
||||
+ unmet
|
||||
>= float(sess.energy_needed_wh)
|
||||
), f"ev_deadline_{e}"
|
||||
|
||||
# TUV look-ahead (převzato z v1 — komfortní constraint, ne heuristika)
|
||||
rated_hp = float(heat_pump.rated_heating_power_w)
|
||||
if tuv_delta_stats and rated_hp > 0 and getattr(heat_pump, "tuv_min_temp_c", None):
|
||||
tuv_pred = float(current_tuv_temp_c)
|
||||
tgt = float(getattr(heat_pump, "tuv_target_temp_c", 55.0) or 55.0)
|
||||
thr = float(heat_pump.tuv_min_temp_c) + 5.0
|
||||
for t in range(T):
|
||||
dow, hour = _prague_dow_hour(slots[t].interval_start)
|
||||
delta = tuv_delta_stats.get((dow, hour), -0.1)
|
||||
tuv_pred += float(delta) * INTERVAL_H
|
||||
if tuv_pred < thr:
|
||||
prob += (
|
||||
pulp.lpSum(hp[s_] for s_ in range(max(0, t - 8), t + 1))
|
||||
>= rated_hp * 0.5
|
||||
), f"tuv_heat_{t}"
|
||||
tuv_pred = tgt
|
||||
if float(current_tuv_temp_c) < float(heat_pump.tuv_min_temp_c):
|
||||
prob += hp[0] >= rated_hp * 0.8, "tuv_emergency"
|
||||
|
||||
# ---------------- objective: jen reálné peníze ----------------
|
||||
wh = INTERVAL_H / 1000.0 # W → kWh za slot
|
||||
cash = pulp.lpSum(
|
||||
gi[t] * float(slots[t].buy_price) * wh
|
||||
- (ge_pv[t] + ge_bat[t]) * float(slots[t].sell_price) * wh
|
||||
for t in range(T)
|
||||
)
|
||||
degradation = pulp.lpSum(
|
||||
0.5 * (bc_pv[t] + bc_gi[t] + bd[t]) * deg * wh for t in range(T)
|
||||
)
|
||||
extras = pulp.lpSum(ca[t] * V2_CURTAIL_TIEBREAK_CZK_KWH * wh for t in range(T))
|
||||
if z_gen is not None:
|
||||
extras += pulp.lpSum(
|
||||
max(0.0, float(slots[t].pv_b_forecast_w)) * z_gen[t] * V2_GEN_CUTOFF_CZK_KWH * wh
|
||||
for t in range(T)
|
||||
)
|
||||
if om == "SELF_SUSTAIN":
|
||||
extras += pulp.lpSum(
|
||||
(ge_pv[t] + ge_bat[t]) * V2_SELF_SUSTAIN_EXPORT_CZK_KWH * wh for t in range(T)
|
||||
)
|
||||
if ev_unmet:
|
||||
extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in ev_unmet)
|
||||
|
||||
prob += cash + degradation + extras - terminal * soc[T - 1]
|
||||
|
||||
solver = (
|
||||
pulp.HiGHS_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
|
||||
if pulp.HiGHS_CMD().available()
|
||||
else pulp.PULP_CBC_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
|
||||
)
|
||||
status = prob.solve(solver)
|
||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||
status_str = pulp.LpStatus[status]
|
||||
if status_str != "Optimal":
|
||||
# v2 nemá relax řetězec — model je navržen tak, aby byl feasible
|
||||
# (placené slacky místo tvrdých kotev). Ne-Optimal je skutečná chyba.
|
||||
raise RuntimeError(f"solver_v2: {status_str}")
|
||||
|
||||
# ---------------- DispatchResult assembly (parita s v1) ----------------
|
||||
def _val(var) -> float:
|
||||
v = pulp.value(var)
|
||||
return float(v) if v is not None else 0.0
|
||||
|
||||
results: list[DispatchResult] = []
|
||||
for t in range(T):
|
||||
s = slots[t]
|
||||
bc_tot = _val(bc_pv[t]) + _val(bc_gi[t])
|
||||
bd_v = _val(bd[t])
|
||||
batt_w = round(bc_tot - bd_v)
|
||||
ge_pv_w = round(_val(ge_pv[t]))
|
||||
ge_bat_w = round(_val(ge_bat[t]))
|
||||
gi_w = _val(gi[t])
|
||||
ge_w = float(ge_pv_w + ge_bat_w)
|
||||
grid_w, export_mode = _dispatch_grid_setpoint_w(
|
||||
gi_w=gi_w,
|
||||
ge_w=ge_w,
|
||||
ge_bat_w=float(ge_bat_w),
|
||||
ge_pv_w=float(ge_pv_w),
|
||||
max_export_power_w=int(max_exp),
|
||||
)
|
||||
if batt_w < 0 and grid_w < 0:
|
||||
deye_mode = "SELL"
|
||||
elif batt_w > 0 and grid_w > 0:
|
||||
deye_mode = "CHARGE"
|
||||
else:
|
||||
deye_mode = "PASSIVE"
|
||||
gen_cut = bool(round(_val(z_gen[t]))) if z_gen is not None else None
|
||||
hp_v = _val(hp[t])
|
||||
hp_on = hp_v > rated_hp * 0.5 if rated_hp > 0 else False
|
||||
cash_t = gi_w * float(s.buy_price) * wh - ge_w * float(s.sell_price) * wh
|
||||
pen_t = 0.0
|
||||
if gen_cut:
|
||||
pen_t += max(0.0, float(s.pv_b_forecast_w)) * V2_GEN_CUTOFF_CZK_KWH * wh
|
||||
results.append(
|
||||
DispatchResult(
|
||||
interval_start=s.interval_start,
|
||||
battery_setpoint_w=batt_w,
|
||||
battery_soc_target=round(_val(soc[t]) / usable * 100.0, 2),
|
||||
grid_setpoint_w=grid_w,
|
||||
export_limit_w=int(max_exp) if grid_w < 0 else 0,
|
||||
export_mode=export_mode,
|
||||
deye_physical_mode=deye_mode,
|
||||
deye_gen_cutoff_enabled=gen_cut,
|
||||
ev1_setpoint_w=(
|
||||
round(_val(ev_direct[0][t]) + _val(ev_via_bat[0][t]))
|
||||
if EV > 0 and s.ev1_connected
|
||||
else None
|
||||
),
|
||||
ev2_setpoint_w=(
|
||||
round(_val(ev_direct[1][t]) + _val(ev_via_bat[1][t]))
|
||||
if EV > 1 and s.ev2_connected
|
||||
else None
|
||||
),
|
||||
ev1_via_bat_w=round(_val(ev_via_bat[0][t])) if EV > 0 else 0,
|
||||
ev2_via_bat_w=round(_val(ev_via_bat[1][t])) if EV > 1 else 0,
|
||||
heat_pump_enabled=hp_on,
|
||||
heat_pump_setpoint_w=int(rated_hp) if hp_on else 0,
|
||||
pv_a_curtailed_w=round(_val(ca[t])),
|
||||
expected_cost_czk=round(cash_t, 4),
|
||||
effective_buy_price=float(s.buy_price),
|
||||
effective_sell_price=float(s.sell_price),
|
||||
is_predicted_price=bool(s.is_predicted_price),
|
||||
cashflow_czk=round(cash_t, 4),
|
||||
battery_arbitrage_czk=0.0,
|
||||
penalty_czk=round(pen_t, 4),
|
||||
green_bonus_czk=float(getattr(s, "green_bonus_czk_per_slot", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
snapshot: dict[str, Any] = {
|
||||
"version": planner_version or "v2-clean",
|
||||
"planner_build_tag": V2_BUILD_TAG,
|
||||
"inputs": {
|
||||
"operating_mode": om,
|
||||
"current_soc_wh": soc0,
|
||||
"terminal_czk_per_wh": round(terminal, 8),
|
||||
"arb_floor_wh": arb_floor,
|
||||
"block_export_on_negative_sell": block_neg_sell,
|
||||
"gen_cutoff_available": gen_cutoff_avail,
|
||||
"slot_count": T,
|
||||
"ev_sessions": sum(1 for x in ev_sessions if x is not None),
|
||||
"masks_ignored": True,
|
||||
},
|
||||
"objective_terms": {
|
||||
"cash_czk": round(float(pulp.value(cash)), 3),
|
||||
"degradation_czk": round(float(pulp.value(degradation)), 3),
|
||||
"extras_czk": round(float(pulp.value(extras)), 3) if not isinstance(extras, float) else 0.0,
|
||||
"terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
|
||||
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet],
|
||||
},
|
||||
"solver_duration_ms": duration_ms,
|
||||
"solver_status": status_str,
|
||||
}
|
||||
return results, duration_ms, snapshot
|
||||
140
backend/services/planning/types.py
Normal file
140
backend/services/planning/types.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# backend/services/planning/types.py
|
||||
#
|
||||
# EMS plánovač – datové typy a čisté časové utility
|
||||
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.planning.constants import _PRAGUE_TZ
|
||||
|
||||
|
||||
class PlannerSolverError(RuntimeError):
|
||||
"""Solver selhal po vyčerpání retry řetězce (typicky Infeasible)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
solver_status: str,
|
||||
*,
|
||||
relax_chain: list[str] | None = None,
|
||||
) -> None:
|
||||
self.solver_status = solver_status
|
||||
self.relax_chain = list(relax_chain or [])
|
||||
super().__init__(f"Solver: {solver_status}")
|
||||
|
||||
def _timestamptz_from_db(val: object) -> Optional[datetime]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
|
||||
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||
|
||||
def _slot_float_nullable(d: dict[str, Any], key: str) -> float | None:
|
||||
v = d.get(key)
|
||||
if v is None:
|
||||
return None
|
||||
return float(v)
|
||||
|
||||
def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
|
||||
"""DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So."""
|
||||
dt = interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
loc = dt.astimezone(_PRAGUE_TZ)
|
||||
return (loc.weekday() + 1) % 7, loc.hour
|
||||
|
||||
@dataclass
|
||||
class PlanningSlot:
|
||||
interval_start: datetime
|
||||
buy_price: float # Kč/kWh
|
||||
sell_price: float # Kč/kWh
|
||||
pv_a_forecast_w: int # W – pole A (řiditelné)
|
||||
pv_b_forecast_w: int # W – pole B (zelený bonus, pevné)
|
||||
load_baseline_w: int # W – predikce bazální spotřeby
|
||||
ev1_connected: bool
|
||||
ev2_connected: bool
|
||||
is_predicted_price: bool = False
|
||||
allow_charge: bool = True
|
||||
allow_discharge_export: bool = True
|
||||
#: Měkké LP vstupy z `ems.fn_load_planning_slots_full` (mimo masky allow_*).
|
||||
night_baseload_target_wh: float | None = None
|
||||
night_baseload_buffer_wh: float | None = None
|
||||
safety_soc_target_wh: float | None = None
|
||||
future_avoided_buy_czk_kwh: float | None = None
|
||||
future_sell_opportunity_czk_kwh: float | None = None
|
||||
is_daytime_pv_surplus_slot: bool = False
|
||||
#: Vážená nákupní / opportunity cena zásoby před prvním exportním oknem (SQL odhad z masek).
|
||||
charge_acquisition_buy_czk_kwh: float | None = None
|
||||
charge_acquisition_cutoff_at: datetime | None = None
|
||||
min_buy_before_cutoff_czk_kwh: float | None = None
|
||||
pv_charge_wh_ahead: float | None = None
|
||||
neg_buy_wh_ahead: float | None = None
|
||||
grid_charge_suppressed_reason: str | None = None
|
||||
charge_target_wh: float | None = None
|
||||
pre_window_wh: float | None = None
|
||||
in_window_wh: float | None = None
|
||||
charge_slot_wh: float | None = None
|
||||
charge_cum_wh: float | None = None
|
||||
charge_layer: str | None = None
|
||||
charge_slot_reason: str | None = None
|
||||
#: Pomocny atribut pro green_bonus v planning_interval (Kc/slot); lite default 0.
|
||||
green_bonus_czk_per_slot: float = 0.0
|
||||
|
||||
SOC_MIN_RELAX_LOOKAHEAD_SLOTS = 144
|
||||
|
||||
@dataclass
|
||||
class DispatchResult:
|
||||
interval_start: datetime
|
||||
battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení
|
||||
battery_soc_target: float # % SoC na konci intervalu
|
||||
grid_setpoint_w: int # kladné = import, záporné = export
|
||||
export_limit_w: int # tvrdý limit exportu do sítě; 0 = bez exportu
|
||||
export_mode: str # NONE / PV_SURPLUS / BATTERY_SELL
|
||||
#: Explicitní fyzický režim Deye pro control exporter (PASSIVE / SELL / CHARGE).
|
||||
#: Cíl: odstranit heuristiky z exporteru a nést záměr přímo v plánu.
|
||||
deye_physical_mode: str
|
||||
#: True = v daném slotu odpojit GEN port (MI export cutoff) přes reg 178 bits0–1 (0-based; v UI často jako "register 179").
|
||||
#: None = lokalita tuto funkci nemá / nepoužívá.
|
||||
deye_gen_cutoff_enabled: bool | None
|
||||
ev1_setpoint_w: Optional[int]
|
||||
ev2_setpoint_w: Optional[int]
|
||||
ev1_via_bat_w: int
|
||||
ev2_via_bat_w: int
|
||||
heat_pump_enabled: bool
|
||||
heat_pump_setpoint_w: int
|
||||
pv_a_curtailed_w: int
|
||||
expected_cost_czk: float
|
||||
effective_buy_price: float
|
||||
effective_sell_price: float
|
||||
is_predicted_price: bool # shodné s PlanningSlot (chybí OTE v efektivní ceně → fn_get_predicted_price)
|
||||
cashflow_czk: float
|
||||
battery_arbitrage_czk: float
|
||||
penalty_czk: float
|
||||
green_bonus_czk: float
|
||||
|
||||
def _prague_calendar_date(slot: PlanningSlot):
|
||||
dt = slot.interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(ZoneInfo("Europe/Prague")).date()
|
||||
|
||||
def _prague_hour(slot: PlanningSlot) -> int:
|
||||
dt = slot.interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(ZoneInfo("Europe/Prague")).hour
|
||||
|
||||
def _parse_json_dt(val: object) -> Optional[datetime]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
|
||||
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||
|
||||
def _current_slot_start(dt: datetime) -> datetime:
|
||||
"""Zaokrouhlí čas dolů na začátek aktuálního 15min slotu."""
|
||||
minute = (dt.minute // 15) * 15
|
||||
return dt.replace(minute=minute, second=0, microsecond=0)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}"
|
||||
|
||||
718
backend/services/signal_service.py
Normal file
718
backend/services/signal_service.py
Normal 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")
|
||||
@@ -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 bits0–1 == 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:
|
||||
|
||||
4933
backend/tests/golden/fixtures/BA81_2026-06-09_normal.json
Normal file
4933
backend/tests/golden/fixtures/BA81_2026-06-09_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
5662
backend/tests/golden/fixtures/KV1_2026-06-09_fixed_normal.json
Normal file
5662
backend/tests/golden/fixtures/KV1_2026-06-09_fixed_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
5673
backend/tests/golden/fixtures/home-01_2026-05-25_evening_push.json
Normal file
5673
backend/tests/golden/fixtures/home-01_2026-05-25_evening_push.json
Normal file
File diff suppressed because it is too large
Load Diff
5673
backend/tests/golden/fixtures/home-01_2026-06-07_neg_sell_deep.json
Normal file
5673
backend/tests/golden/fixtures/home-01_2026-06-07_neg_sell_deep.json
Normal file
File diff suppressed because it is too large
Load Diff
5673
backend/tests/golden/fixtures/home-01_2026-06-09_normal.json
Normal file
5673
backend/tests/golden/fixtures/home-01_2026-06-09_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
3181
backend/tests/golden/snapshots/BA81_2026-06-09_normal.json
Normal file
3181
backend/tests/golden/snapshots/BA81_2026-06-09_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
3181
backend/tests/golden/snapshots/KV1_2026-06-09_fixed_normal.json
Normal file
3181
backend/tests/golden/snapshots/KV1_2026-06-09_fixed_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"solver_error": "Infeasible",
|
||||
"relax_chain": [
|
||||
"strict",
|
||||
"relaxed_expensive_import",
|
||||
"relaxed_neg_buy_charge",
|
||||
"relaxed_neg_prep_hold_only",
|
||||
"relaxed_neg_prep_window",
|
||||
"neg_sell_phases_fallback",
|
||||
"relaxed_pos_sell_ge_block",
|
||||
"relaxed_solver_masks"
|
||||
]
|
||||
}
|
||||
3181
backend/tests/golden/snapshots/home-01_2026-05-25_evening_push.json
Normal file
3181
backend/tests/golden/snapshots/home-01_2026-05-25_evening_push.json
Normal file
File diff suppressed because it is too large
Load Diff
3181
backend/tests/golden/snapshots/home-01_2026-06-07_neg_sell_deep.json
Normal file
3181
backend/tests/golden/snapshots/home-01_2026-06-07_neg_sell_deep.json
Normal file
File diff suppressed because it is too large
Load Diff
3181
backend/tests/golden/snapshots/home-01_2026-06-09_normal.json
Normal file
3181
backend/tests/golden/snapshots/home-01_2026-06-09_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
101
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
101
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""PASSIVE + PV_SURPLUS: 108=0 (nepoužívat baterii), 109=max; 142 zůstává zero-export (1/2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.control.setpoints import deye_battery_charge_discharge_amps
|
||||
|
||||
|
||||
class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
|
||||
def test_pv_surplus_export_zeros_charge_amps(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=-177,
|
||||
grid_w=-2851,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=90,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 90)
|
||||
|
||||
def test_pv_surplus_even_if_lp_shows_positive_battery_w(self) -> None:
|
||||
"""Plán může mít kladný battery_w; exportní záměr je PV_SURPLUS → 108=0."""
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=5000,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_passive_charge_without_export_mode_uses_max_108(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=5000,
|
||||
grid_w=0,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="NONE",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 100)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_legacy_negative_grid_infers_pv_surplus(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=0,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode=None,
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_charge_mode_still_scales_108_from_battery_w(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="CHARGE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=2000,
|
||||
grid_w=3000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
)
|
||||
self.assertLess(ch, 100)
|
||||
self.assertGreater(ch, 0)
|
||||
self.assertEqual(dis, 0)
|
||||
|
||||
def test_sell_skips_charge_amp_write(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="SELL",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=-3000,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=80,
|
||||
)
|
||||
self.assertIsNone(ch)
|
||||
self.assertEqual(dis, 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
107
backend/tests/test_control_export_plan_guard.py
Normal file
107
backend/tests/test_control_export_plan_guard.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Exekuční pojistka exportu podle plánu (Plan 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.control.exporter_monolith import (
|
||||
ControlSetpoints,
|
||||
_apply_export_plan_guard,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.control.models import OperatingModeInfo
|
||||
from services.control.setpoints import _DictRecord
|
||||
|
||||
|
||||
def _auto_mode() -> OperatingModeInfo:
|
||||
return OperatingModeInfo(
|
||||
mode_code="AUTO",
|
||||
battery_mode="AUTO",
|
||||
grid_mode="AUTO",
|
||||
ev_enabled=True,
|
||||
heat_pump_enabled_def=True,
|
||||
loxone_mode_value=0,
|
||||
)
|
||||
|
||||
|
||||
def _sp(**kwargs: object) -> ControlSetpoints:
|
||||
base = dict(
|
||||
battery_w=0,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
deye_physical_mode="SELL",
|
||||
export_mode="BATTERY_SELL",
|
||||
export_ban=False,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return ControlSetpoints(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class ExportPlanGuardTests(unittest.TestCase):
|
||||
def test_neg_sell_forces_passive_no_export(self) -> None:
|
||||
sp = _sp()
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": -8000,
|
||||
"effective_sell_price": -0.5,
|
||||
"export_mode": "NONE",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertEqual(get_deye_mode(out), "PASSIVE")
|
||||
self.assertTrue(out.export_ban)
|
||||
self.assertEqual(out.grid_export_limit, 0)
|
||||
self.assertGreaterEqual(out.grid_setpoint_w, 0)
|
||||
self.assertEqual(out.export_mode, "NONE")
|
||||
self.assertTrue(out.deye_gen_cutoff_enabled)
|
||||
|
||||
def test_export_mode_none_with_non_negative_grid(self) -> None:
|
||||
sp = _sp(grid_setpoint_w=0, battery_w=-5000, export_mode="BATTERY_SELL")
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": 0,
|
||||
"effective_sell_price": 2.5,
|
||||
"export_mode": "NONE",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertEqual(get_deye_mode(out), "PASSIVE")
|
||||
self.assertEqual(out.battery_w, 0)
|
||||
self.assertTrue(out.export_ban)
|
||||
|
||||
def test_profitable_export_unchanged(self) -> None:
|
||||
sp = _sp()
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": -8000,
|
||||
"effective_sell_price": 9.5,
|
||||
"export_mode": "BATTERY_SELL",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertIs(out, sp)
|
||||
self.assertEqual(get_deye_mode(out), "SELL")
|
||||
|
||||
def test_non_auto_mode_skipped(self) -> None:
|
||||
sp = _sp()
|
||||
pi = _DictRecord({"effective_sell_price": -1.0, "export_mode": "NONE"})
|
||||
mode = OperatingModeInfo(
|
||||
mode_code="SELF_SUSTAIN",
|
||||
battery_mode="PASSIVE",
|
||||
grid_mode="PASSIVE",
|
||||
ev_enabled=False,
|
||||
heat_pump_enabled_def=False,
|
||||
loxone_mode_value=1,
|
||||
)
|
||||
out = _apply_export_plan_guard(1, mode, pi, sp)
|
||||
self.assertIs(out, sp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
213
backend/tests/test_control_exporter_reg340.py
Normal file
213
backend/tests/test_control_exporter_reg340.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""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,
|
||||
)
|
||||
from services.control.setpoints import plan_skips_deye_reg340_write
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def test_min_clamp_when_positive(self) -> None:
|
||||
self.assertEqual(
|
||||
compute_pv_a_reg340_max_solar_w(32_000, 5000, 4600, min_w=400),
|
||||
400,
|
||||
)
|
||||
|
||||
def test_min_not_applied_when_curtail_to_zero(self) -> None:
|
||||
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 1000, 5000, min_w=400), 0)
|
||||
|
||||
|
||||
class BuildSetpointsReg340Tests(unittest.TestCase):
|
||||
def test_with_cap_sets_pv_a_allowed(self) -> None:
|
||||
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_low_pv_forecast_with_mi_no_curtail(self) -> None:
|
||||
"""BA81 úsvit: slabý forecast, bez curtail — EMS neposílá reg 340."""
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(
|
||||
pv_a_forecast_solver_w=405,
|
||||
pv_b_forecast_solver_w=49,
|
||||
pv_a_curtailed_w=0,
|
||||
grid_setpoint_w=-100,
|
||||
battery_setpoint_w=0,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_limit_w=100,
|
||||
),
|
||||
pv_a_cap_w=32_000,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertIsNone(sp.pv_a_allowed_w)
|
||||
|
||||
def test_skipped_when_no_export_no_charge_no_curtail(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(
|
||||
grid_setpoint_w=0,
|
||||
battery_setpoint_w=0,
|
||||
export_mode="NONE",
|
||||
export_limit_w=0,
|
||||
pv_a_curtailed_w=0,
|
||||
),
|
||||
pv_a_cap_w=10_000,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertIsNone(sp.pv_a_allowed_w)
|
||||
|
||||
def test_writes_reg340_when_curtail_planned(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(
|
||||
grid_setpoint_w=0,
|
||||
battery_setpoint_w=0,
|
||||
export_mode="NONE",
|
||||
pv_a_curtailed_w=3000,
|
||||
),
|
||||
pv_a_cap_w=10_000,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertEqual(sp.pv_a_allowed_w, 5000)
|
||||
|
||||
def test_writes_reg340_when_battery_charging_without_export(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(
|
||||
grid_setpoint_w=0,
|
||||
battery_setpoint_w=5000,
|
||||
export_mode="NONE",
|
||||
pv_a_curtailed_w=0,
|
||||
),
|
||||
pv_a_cap_w=10_000,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertEqual(sp.pv_a_allowed_w, 10_000)
|
||||
|
||||
def test_plan_skips_helper(self) -> None:
|
||||
self.assertTrue(
|
||||
plan_skips_deye_reg340_write(
|
||||
battery_setpoint_w=0,
|
||||
grid_setpoint_w=0,
|
||||
export_mode="NONE",
|
||||
export_limit_w=0,
|
||||
pv_a_curtailed_w=0,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
plan_skips_deye_reg340_write(
|
||||
battery_setpoint_w=0,
|
||||
grid_setpoint_w=-2000,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_limit_w=2000,
|
||||
pv_a_curtailed_w=0,
|
||||
)
|
||||
)
|
||||
|
||||
def test_skipped_when_reg340_control_disabled(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_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))
|
||||
@@ -3,13 +3,23 @@
|
||||
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_mi_export_cutoff_want_enabled,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.control.models import OperatingModeInfo
|
||||
from services.control.setpoints import (
|
||||
_build_setpoints,
|
||||
_deye_zero_export_amps_for_passive,
|
||||
)
|
||||
|
||||
|
||||
def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterConfig:
|
||||
@@ -33,15 +43,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 +85,120 @@ 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_mi_export_cutoff_on_export_ban_without_plan_flag(self) -> None:
|
||||
self.assertTrue(
|
||||
deye_mi_export_cutoff_want_enabled(
|
||||
gen_microinverter_cutoff_enabled=True,
|
||||
deye_gen_cutoff_enabled=False,
|
||||
export_ban=True,
|
||||
deye_mode="PASSIVE",
|
||||
)
|
||||
)
|
||||
|
||||
def test_mi_export_cutoff_off_when_sell_mode(self) -> None:
|
||||
self.assertFalse(
|
||||
deye_mi_export_cutoff_want_enabled(
|
||||
gen_microinverter_cutoff_enabled=True,
|
||||
deye_gen_cutoff_enabled=True,
|
||||
export_ban=True,
|
||||
deye_mode="SELL",
|
||||
)
|
||||
)
|
||||
|
||||
def test_mi_export_cutoff_off_without_feature_flag(self) -> None:
|
||||
self.assertFalse(
|
||||
deye_mi_export_cutoff_want_enabled(
|
||||
gen_microinverter_cutoff_enabled=False,
|
||||
deye_gen_cutoff_enabled=True,
|
||||
export_ban=True,
|
||||
deye_mode="PASSIVE",
|
||||
)
|
||||
)
|
||||
|
||||
def test_build_setpoints_uses_explicit_export_limit(self) -> None:
|
||||
mode = OperatingModeInfo(
|
||||
mode_code="AUTO",
|
||||
battery_mode="AUTO",
|
||||
grid_mode="AUTO",
|
||||
ev_enabled=False,
|
||||
heat_pump_enabled_def=False,
|
||||
loxone_mode_value=1,
|
||||
)
|
||||
pi = {
|
||||
"battery_setpoint_w": 0,
|
||||
"grid_setpoint_w": -3000,
|
||||
"export_limit_w": 13_500,
|
||||
"export_mode": "PV_SURPLUS",
|
||||
"ev1_setpoint_w": 0,
|
||||
"ev2_setpoint_w": 0,
|
||||
"heat_pump_enabled": False,
|
||||
"battery_soc_target_pct": 50,
|
||||
"effective_sell_price": 1.0,
|
||||
}
|
||||
sp = _build_setpoints(mode, pi)
|
||||
self.assertIsNotNone(sp)
|
||||
self.assertEqual(sp.grid_export_limit, 13_500)
|
||||
|
||||
def test_pv_led_export_with_small_battery_is_sell(self) -> None:
|
||||
"""Obě záporné → SELL (bez porovnání |bat| vs |grid|)."""
|
||||
sp = ControlSetpoints(
|
||||
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 +210,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 +272,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,
|
||||
|
||||
28
backend/tests/test_db_json_fetch_json.py
Normal file
28
backend/tests/test_db_json_fetch_json.py
Normal 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())
|
||||
@@ -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,
|
||||
|
||||
24
backend/tests/test_drop_registers_matching_last_verified.py
Normal file
24
backend/tests/test_drop_registers_matching_last_verified.py
Normal 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 (bits4–5 and, if present, bits0–1).
|
||||
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 == []
|
||||
|
||||
53
backend/tests/test_full_status_heartbeat_parsing.py
Normal file
53
backend/tests/test_full_status_heartbeat_parsing.py
Normal 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
|
||||
|
||||
205
backend/tests/test_golden_replay.py
Normal file
205
backend/tests/test_golden_replay.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Fáze 0 – golden replay gate plánovače (bez DB).
|
||||
|
||||
Pro každou fixture v tests/golden/fixtures/ (kompletní vstupy solveru zmrazené
|
||||
z reálné DB skriptem scripts/harness/extract_fixtures.py) spustí
|
||||
solve_dispatch_two_pass a porovná normalizovaný výstup s golden snapshotem
|
||||
v tests/golden/snapshots/.
|
||||
|
||||
Účel: regresní brána pro dekompozici planning_engine.py — identity refactor
|
||||
musí držet výstupy bit-perfektně (floaty zaokrouhleny na 4 d.m.).
|
||||
|
||||
Regenerace snapshotů (vědomá změna chování):
|
||||
GOLDEN_UPDATE=1 python3 -m pytest tests/test_golden_replay.py -q
|
||||
|
||||
Replay jde STEJNOU cestou jako produkce: _load_site_context + _load_slots nad
|
||||
fixture stubem DB → žádná duplikace mapování DB → objekty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from services import planning_engine as pe
|
||||
|
||||
GOLDEN_DIR = Path(__file__).resolve().parent / "golden"
|
||||
FIXTURES_DIR = GOLDEN_DIR / "fixtures"
|
||||
SNAPSHOTS_DIR = GOLDEN_DIR / "snapshots"
|
||||
|
||||
_DT_SLOT_KEYS = ("interval_start", "charge_acquisition_cutoff_at")
|
||||
|
||||
|
||||
class _FixtureDB:
|
||||
"""Stub asyncpg connection: vrací zmrazený context a sloty z fixture."""
|
||||
|
||||
def __init__(self, fixture: dict):
|
||||
self._fixture = fixture
|
||||
|
||||
async def fetchval(self, query: str, *args):
|
||||
assert "fn_planning_site_context" in query, f"Nečekaný fetchval: {query!r}"
|
||||
return json.dumps(self._fixture["context_json"])
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
assert "fn_load_planning_slots_full" in query, f"Nečekaný fetch: {query!r}"
|
||||
rows: list[dict] = []
|
||||
for raw in self._fixture["slot_rows"]:
|
||||
d = dict(raw)
|
||||
for key in _DT_SLOT_KEYS:
|
||||
if d.get(key):
|
||||
d[key] = datetime.fromisoformat(d[key])
|
||||
rows.append(d)
|
||||
return rows
|
||||
|
||||
|
||||
def _round(val: float, places: int = 4) -> float:
|
||||
out = round(float(val), places)
|
||||
return 0.0 if out == 0.0 else out # normalizace -0.0
|
||||
|
||||
|
||||
def _normalize_results(results: list) -> dict:
|
||||
rows = []
|
||||
for r in results:
|
||||
rows.append(
|
||||
{
|
||||
"interval_start": r.interval_start.isoformat(),
|
||||
"battery_setpoint_w": int(r.battery_setpoint_w),
|
||||
"battery_soc_target": _round(r.battery_soc_target, 2),
|
||||
"grid_setpoint_w": int(r.grid_setpoint_w),
|
||||
"export_limit_w": int(r.export_limit_w),
|
||||
"export_mode": r.export_mode,
|
||||
"deye_physical_mode": r.deye_physical_mode,
|
||||
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
|
||||
"ev1_setpoint_w": r.ev1_setpoint_w,
|
||||
"ev2_setpoint_w": r.ev2_setpoint_w,
|
||||
"ev1_via_bat_w": int(r.ev1_via_bat_w),
|
||||
"ev2_via_bat_w": int(r.ev2_via_bat_w),
|
||||
"heat_pump_enabled": bool(r.heat_pump_enabled),
|
||||
"heat_pump_setpoint_w": int(r.heat_pump_setpoint_w),
|
||||
"pv_a_curtailed_w": int(r.pv_a_curtailed_w),
|
||||
"expected_cost_czk": _round(r.expected_cost_czk),
|
||||
"cashflow_czk": _round(r.cashflow_czk),
|
||||
"battery_arbitrage_czk": _round(r.battery_arbitrage_czk),
|
||||
"penalty_czk": _round(r.penalty_czk),
|
||||
"green_bonus_czk": _round(r.green_bonus_czk),
|
||||
}
|
||||
)
|
||||
totals = {
|
||||
"slots": len(rows),
|
||||
"expected_cost_czk": _round(sum(r["expected_cost_czk"] for r in rows), 3),
|
||||
"cashflow_czk": _round(sum(r["cashflow_czk"] for r in rows), 3),
|
||||
"penalty_czk": _round(sum(r["penalty_czk"] for r in rows), 3),
|
||||
"grid_import_slots": sum(1 for r in rows if r["grid_setpoint_w"] > 0),
|
||||
"grid_export_slots": sum(1 for r in rows if r["grid_setpoint_w"] < 0),
|
||||
"curtail_slots": sum(1 for r in rows if r["pv_a_curtailed_w"] > 0),
|
||||
}
|
||||
return {"totals": totals, "slots": rows}
|
||||
|
||||
|
||||
def _replay_fixture(fixture: dict) -> dict:
|
||||
async def _run() -> dict:
|
||||
db = _FixtureDB(fixture)
|
||||
meta = fixture["meta"]
|
||||
(
|
||||
battery,
|
||||
heat_pump,
|
||||
grid,
|
||||
vehicles,
|
||||
ev_sessions,
|
||||
soc_wh,
|
||||
tuv_temp,
|
||||
operating_mode,
|
||||
tuv_stats,
|
||||
) = await pe._load_site_context(int(meta["site_id"]), db)
|
||||
slots = await pe._load_slots(
|
||||
int(meta["site_id"]),
|
||||
datetime.fromisoformat(meta["window_from"]),
|
||||
datetime.fromisoformat(meta["window_to"]),
|
||||
db,
|
||||
soc_wh=soc_wh,
|
||||
)
|
||||
try:
|
||||
results, _ms, _snap = pe.solve_dispatch_two_pass(
|
||||
slots,
|
||||
battery,
|
||||
heat_pump,
|
||||
grid,
|
||||
ev_sessions,
|
||||
vehicles,
|
||||
soc_wh,
|
||||
tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=operating_mode or "AUTO",
|
||||
planner_version=pe._planner_engine_version(),
|
||||
)
|
||||
except pe.PlannerSolverError as exc:
|
||||
# Selhání solveru je taky chování k zafixování (např. home-01 2026-05-01:
|
||||
# Infeasible po celém relax řetězci). Až ho Fáze 2/3 opraví, golden diff
|
||||
# to zviditelní a snapshot se vědomě zregeneruje.
|
||||
return {
|
||||
"solver_error": exc.solver_status,
|
||||
"relax_chain": list(exc.relax_chain),
|
||||
}
|
||||
return _normalize_results(results)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def _fixture_paths() -> list[Path]:
|
||||
return sorted(FIXTURES_DIR.glob("*.json"))
|
||||
|
||||
|
||||
class GoldenReplayTests(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def test_fixtures_exist(self) -> None:
|
||||
self.assertTrue(
|
||||
_fixture_paths(),
|
||||
f"Žádné fixtures v {FIXTURES_DIR} – spusť scripts/harness/extract_fixtures.py",
|
||||
)
|
||||
|
||||
|
||||
def _make_test(path: Path):
|
||||
def test(self: GoldenReplayTests) -> None:
|
||||
fixture = json.loads(path.read_text(encoding="utf-8"))
|
||||
actual = _replay_fixture(fixture)
|
||||
snap_path = SNAPSHOTS_DIR / path.name
|
||||
if os.environ.get("GOLDEN_UPDATE") == "1":
|
||||
SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
snap_path.write_text(
|
||||
json.dumps(actual, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
|
||||
)
|
||||
return
|
||||
self.assertTrue(
|
||||
snap_path.exists(),
|
||||
f"Chybí snapshot {snap_path.name} – vygeneruj přes GOLDEN_UPDATE=1",
|
||||
)
|
||||
expected = json.loads(snap_path.read_text(encoding="utf-8"))
|
||||
if "solver_error" in expected or "solver_error" in actual:
|
||||
self.assertEqual(expected, actual, f"{path.name}: změna výsledku/selhání solveru")
|
||||
return
|
||||
self.assertEqual(
|
||||
expected["totals"],
|
||||
actual["totals"],
|
||||
f"{path.name}: změna agregátů plánu (totals)",
|
||||
)
|
||||
self.assertEqual(
|
||||
expected["slots"],
|
||||
actual["slots"],
|
||||
f"{path.name}: změna plánu per slot",
|
||||
)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
for _path in _fixture_paths():
|
||||
_name = "test_golden_" + _path.stem.replace("-", "_").replace(".", "_")
|
||||
setattr(GoldenReplayTests, _name, _make_test(_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1158
backend/tests/test_planning_charge_slot_selection.py
Normal file
1158
backend/tests/test_planning_charge_slot_selection.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
27
backend/tests/test_planning_economics_columns.py
Normal file
27
backend/tests/test_planning_economics_columns.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""DispatchResult: nove ekonomicke sloupce (cashflow/arbitraz/penalty/bonus)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import fields
|
||||
|
||||
from services.planning_engine import DispatchResult
|
||||
|
||||
|
||||
class DispatchResultEconomicsFieldsTests(unittest.TestCase):
|
||||
def test_has_new_economics_fields(self) -> None:
|
||||
names = {f.name for f in fields(DispatchResult)}
|
||||
for required in (
|
||||
"cashflow_czk",
|
||||
"battery_arbitrage_czk",
|
||||
"penalty_czk",
|
||||
"green_bonus_czk",
|
||||
):
|
||||
self.assertIn(required, names, f"DispatchResult missing field {required}")
|
||||
|
||||
def test_legacy_expected_cost_czk_kept(self) -> None:
|
||||
names = {f.name for f in fields(DispatchResult)}
|
||||
self.assertIn("expected_cost_czk", names)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
286
backend/tests/test_planning_safety_commitment.py
Normal file
286
backend/tests/test_planning_safety_commitment.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Měkké safety SoC a rolling charge commitment v solve_dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.planning_engine import PlanningSlot, solve_dispatch
|
||||
|
||||
|
||||
def _bat(**kwargs: object) -> SimpleNamespace:
|
||||
base = dict(
|
||||
usable_capacity_wh=20_000.0,
|
||||
min_soc_wh=2000.0,
|
||||
arb_floor_wh=4000.0,
|
||||
reserve_soc_wh=4000.0,
|
||||
soc_max_wh=19_000.0,
|
||||
charge_efficiency=0.95,
|
||||
discharge_efficiency=0.95,
|
||||
degradation_cost_czk_kwh=0.1,
|
||||
max_charge_power_w=5000,
|
||||
max_discharge_power_w=5000,
|
||||
planner_terminal_soc_value_factor=0.2,
|
||||
planner_extreme_buy_threshold_czk_kwh=-5.0,
|
||||
planner_discharge_floor_percent=None,
|
||||
planner_discharge_relax_prewindow_slots=8,
|
||||
planner_daytime_charge_target_enabled=True,
|
||||
planner_charge_commitment_penalty_czk_kwh=0.5,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _grid() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
max_import_power_w=11_000,
|
||||
max_export_power_w=11_000,
|
||||
block_export_on_negative_sell=False,
|
||||
deye_gen_microinverter_cutoff_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def _hp() -> SimpleNamespace:
|
||||
return SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
|
||||
|
||||
def _slot(
|
||||
t0: datetime,
|
||||
idx: int,
|
||||
*,
|
||||
buy: float = 3.0,
|
||||
sell: float = 2.5,
|
||||
pv_a: int = 0,
|
||||
load: int = 1500,
|
||||
safety: float | None = None,
|
||||
fut_buy: float | None = None,
|
||||
fut_sell: float | None = None,
|
||||
daytime_pv_surplus: bool = False,
|
||||
) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=t0 + timedelta(minutes=15 * idx),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=pv_a,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=load,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
safety_soc_target_wh=safety,
|
||||
future_avoided_buy_czk_kwh=fut_buy,
|
||||
future_sell_opportunity_czk_kwh=fut_sell,
|
||||
is_daytime_pv_surplus_slot=daytime_pv_surplus,
|
||||
)
|
||||
|
||||
|
||||
class PlanningSafetyCommitmentTests(unittest.TestCase):
|
||||
def test_solver_snapshot_has_version_and_masks(self) -> None:
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
slots = [_slot(t0, i, buy=2.0, sell=2.0, pv_a=6000, load=1500) for i in range(8)]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=5000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(res), 8)
|
||||
self.assertEqual(snap.get("version"), 1)
|
||||
self.assertIn("masks", snap)
|
||||
self.assertEqual(len(snap["masks"]), 8)
|
||||
|
||||
def test_charge_commitment_snapshot_populated(self) -> None:
|
||||
"""Rolling kotva: při předchozím nabíjení z PV se do snapshotu zapíše commitment."""
|
||||
t0 = datetime(2026, 5, 4, 10, 0, tzinfo=timezone.utc)
|
||||
slots = [_slot(t0, i, buy=1.5, sell=1.2, pv_a=8000, load=1000) for i in range(12)]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
prev = [None] * 12
|
||||
prev[0] = 4000.0
|
||||
_res1, _, snap1 = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=4000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
charge_commitment_prev_w=prev,
|
||||
)
|
||||
self.assertTrue(snap1["chosen_slots"]["charge_commitment"])
|
||||
_res2, _, snap2 = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=4000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
charge_commitment_prev_w=None,
|
||||
)
|
||||
self.assertEqual(snap2["chosen_slots"]["charge_commitment"], [])
|
||||
|
||||
def test_export_floor_uses_safety_target_in_non_high_sell_slot(self) -> None:
|
||||
"""Regrese: safety target nemá tlačit jen přes objective, ale chránit export floor."""
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
# Slot 0 není high-sell (future max sell je vyšší), ale safety target je nad arb_base.
|
||||
slots = [
|
||||
_slot(
|
||||
t0,
|
||||
0,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=8000,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0, # high-sell somewhere later, not this slot
|
||||
daytime_pv_surplus=True,
|
||||
),
|
||||
_slot(
|
||||
t0,
|
||||
1,
|
||||
buy=3.0,
|
||||
sell=6.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
_res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=8000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
b0 = snap["soc_bounds"][0]
|
||||
self.assertEqual(b0["export_floor_reason"], "safety_export_floor")
|
||||
self.assertEqual(float(b0["export_soc_floor_wh"]), 12_000.0)
|
||||
self.assertFalse(bool(b0["high_sell_slot"]))
|
||||
|
||||
def test_export_floor_keeps_arb_base_in_high_sell_slot(self) -> None:
|
||||
"""High-sell výjimka: v peak slotu nesmí safety floor blokovat arbitráž."""
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
# Slot 0 je high-sell (sell == future max), safety target je nad arb_base, ale nemá se aplikovat.
|
||||
slots = [
|
||||
_slot(
|
||||
t0,
|
||||
0,
|
||||
buy=3.0,
|
||||
sell=6.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
_slot(
|
||||
t0,
|
||||
1,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
_res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=8000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
b0 = snap["soc_bounds"][0]
|
||||
self.assertTrue(bool(b0["high_sell_slot"]))
|
||||
self.assertEqual(b0["export_floor_reason"], "arb_base")
|
||||
self.assertEqual(float(b0["export_soc_floor_wh"]), 4000.0)
|
||||
|
||||
def test_safety_penalty_only_active_in_daytime_pv_surplus_slots(self) -> None:
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
_slot(
|
||||
t0,
|
||||
0,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=8000,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=True,
|
||||
),
|
||||
_slot(
|
||||
t0,
|
||||
1,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
_res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=8000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
t0o = snap["objective_terms"][0]
|
||||
t1o = snap["objective_terms"][1]
|
||||
self.assertTrue(bool(t0o["safety_penalty_active"]))
|
||||
self.assertGreater(float(t0o["safety_deficit_penalty_czk_per_wh"]), 0.0)
|
||||
self.assertFalse(bool(t1o["safety_penalty_active"]))
|
||||
self.assertEqual(float(t1o["safety_deficit_penalty_czk_per_wh"]), 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
183
backend/tests/test_solver_v2.py
Normal file
183
backend/tests/test_solver_v2.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""solver_v2 (čisté jádro): tvrdá pravidla, režimy, EV deadline, arbitráž (bez DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.planning.solver_v2 import solve_dispatch_v2
|
||||
from services.planning.types import PlanningSlot
|
||||
|
||||
|
||||
def _slot(
|
||||
base: datetime,
|
||||
i: int,
|
||||
*,
|
||||
buy: float,
|
||||
sell: float,
|
||||
pv_a: int = 0,
|
||||
pv_b: int = 0,
|
||||
load: int = 1000,
|
||||
ev1: bool = False,
|
||||
) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=pv_a,
|
||||
pv_b_forecast_w=pv_b,
|
||||
load_baseline_w=load,
|
||||
ev1_connected=ev1,
|
||||
ev2_connected=False,
|
||||
)
|
||||
|
||||
|
||||
def _battery(uc_wh: float = 20_000.0) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
usable_capacity_wh=uc_wh,
|
||||
min_soc_wh=0.12 * uc_wh,
|
||||
arb_floor_wh=0.20 * uc_wh,
|
||||
reserve_soc_wh=0.20 * uc_wh,
|
||||
soc_max_wh=0.95 * uc_wh,
|
||||
charge_efficiency=0.95,
|
||||
discharge_efficiency=0.95,
|
||||
degradation_cost_czk_kwh=0.5,
|
||||
max_charge_power_w=8000,
|
||||
max_discharge_power_w=8000,
|
||||
planner_terminal_soc_value_factor=0.8,
|
||||
)
|
||||
|
||||
|
||||
def _grid(block_neg: bool = False, gen_cutoff: bool = False) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
max_import_power_w=17_000,
|
||||
max_export_power_w=13_500,
|
||||
block_export_on_negative_sell=block_neg,
|
||||
deye_gen_microinverter_cutoff_enabled=gen_cutoff,
|
||||
)
|
||||
|
||||
|
||||
_HP = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
_VEHICLES = [
|
||||
SimpleNamespace(max_charge_power_w=11_000, battery_capacity_kwh=60.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
_BASE = datetime(2026, 6, 10, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _solve(slots, *, battery=None, grid=None, ev_sessions=(None, None), soc0=None, mode="AUTO"):
|
||||
bat = battery or _battery()
|
||||
return solve_dispatch_v2(
|
||||
slots,
|
||||
bat,
|
||||
_HP,
|
||||
grid or _grid(),
|
||||
list(ev_sessions),
|
||||
_VEHICLES,
|
||||
soc0 if soc0 is not None else 0.5 * bat.usable_capacity_wh,
|
||||
50.0,
|
||||
operating_mode=mode,
|
||||
)
|
||||
|
||||
|
||||
class HardRulesTests(unittest.TestCase):
|
||||
def test_negative_buy_blocks_export(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=-2.0, sell=1.5, pv_a=6000, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots)
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.grid_setpoint_w, 0, "buy<0 → žádný export (pumpa)")
|
||||
|
||||
def test_block_export_on_negative_sell(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-0.5, pv_a=8000, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots, grid=_grid(block_neg=True))
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.grid_setpoint_w, 0, "KV1: sell<0 → ge=0")
|
||||
|
||||
def test_negative_sell_prefers_charge_or_curtail_over_paid_export(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-1.0, pv_a=8000, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots)
|
||||
paid_export = sum(-r.grid_setpoint_w for r in results if r.grid_setpoint_w < 0)
|
||||
self.assertEqual(paid_export, 0, "spot: za export při sell<0 se platí → ekonomika ho vyloučí")
|
||||
|
||||
def test_battery_export_requires_arb_floor(self) -> None:
|
||||
bat = _battery()
|
||||
slots = [_slot(_BASE, i, buy=1.0, sell=8.0, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.5 * bat.usable_capacity_wh)
|
||||
for r in results:
|
||||
if r.grid_setpoint_w < 0 and r.battery_setpoint_w < 0:
|
||||
self.assertGreaterEqual(
|
||||
r.battery_soc_target / 100.0 * bat.usable_capacity_wh,
|
||||
bat.arb_floor_wh - 1.0,
|
||||
"export z baterie nesmí podlézt arb floor",
|
||||
)
|
||||
|
||||
def test_curtailment_only_pv_a(self) -> None:
|
||||
# extrémně záporný sell bez block_export: pole B nelze omezit, A ano
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-3.0, pv_a=5000, pv_b=4000, load=300) for i in range(8)]
|
||||
bat = _battery(uc_wh=2000.0) # malá baterie, ať se přebytek nevejde
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.9 * 2000.0)
|
||||
self.assertTrue(any(r.pv_a_curtailed_w > 0 for r in results), "A se curtailuje")
|
||||
for r in results:
|
||||
self.assertLessEqual(r.pv_a_curtailed_w, 5000, "curtail max = výroba A")
|
||||
|
||||
|
||||
class ArbitrageTests(unittest.TestCase):
|
||||
def test_cheap_night_charge_expensive_evening_discharge(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=1.0, sell=0.5, load=1000) for i in range(16)]
|
||||
slots += [_slot(_BASE, 16 + i, buy=8.0, sell=7.0, load=1000) for i in range(16)]
|
||||
results, _, _ = _solve(slots)
|
||||
charged = sum(r.battery_setpoint_w for r in results[:16] if r.battery_setpoint_w > 0)
|
||||
discharged = sum(-r.battery_setpoint_w for r in results[16:] if r.battery_setpoint_w < 0)
|
||||
self.assertGreater(charged, 0, "levná noc → nabíjet")
|
||||
self.assertGreater(discharged, 0, "drahý večer → vybíjet")
|
||||
|
||||
|
||||
class OperatingModeTests(unittest.TestCase):
|
||||
def _slots(self):
|
||||
return [_slot(_BASE, i, buy=1.0, sell=6.0, pv_a=3000, load=1000) for i in range(8)]
|
||||
|
||||
def test_preserve_locks_battery(self) -> None:
|
||||
results, _, _ = _solve(self._slots(), mode="PRESERVE")
|
||||
for r in results:
|
||||
self.assertEqual(r.battery_setpoint_w, 0)
|
||||
|
||||
def test_charge_cheap_no_export_no_discharge(self) -> None:
|
||||
results, _, _ = _solve(self._slots(), mode="CHARGE_CHEAP")
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.grid_setpoint_w, 0)
|
||||
self.assertGreaterEqual(r.battery_setpoint_w, 0)
|
||||
|
||||
def test_self_sustain_import_capped_to_load(self) -> None:
|
||||
results, _, _ = _solve(self._slots(), mode="SELF_SUSTAIN")
|
||||
for r in results:
|
||||
self.assertLessEqual(r.grid_setpoint_w, 1000, "import ≤ baseline load")
|
||||
|
||||
|
||||
class EvDeadlineTests(unittest.TestCase):
|
||||
def test_ev_energy_delivered_before_deadline(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=4), # slot 16 → vše do konce
|
||||
energy_needed_wh=8000.0,
|
||||
)
|
||||
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||||
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||
self.assertGreaterEqual(delivered, 8000.0 - 1.0)
|
||||
self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0])
|
||||
# levné sloty (0–7) mají dodat většinu energie
|
||||
cheap = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[:8])
|
||||
self.assertGreater(cheap, 4000.0, "EV nabíjí přednostně v levných slotech")
|
||||
|
||||
def test_ev_unreachable_deadline_uses_paid_slack(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=1.0, ev1=(i == 0)) for i in range(8)]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(minutes=15),
|
||||
energy_needed_wh=50_000.0, # nesplnitelné za 1 slot
|
||||
)
|
||||
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||||
self.assertGreater(snap["objective_terms"]["ev_unmet_wh"][0], 0.0, "slack místo infeasible")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
28
backend/tests/test_telemetry_export_limit_flags.py
Normal file
28
backend/tests/test_telemetry_export_limit_flags.py
Normal 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
|
||||
@@ -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.';
|
||||
|
||||
38
db/migration/V040__energy_wh_columns.sql
Normal file
38
db/migration/V040__energy_wh_columns.sql
Normal 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ů.';
|
||||
13
db/migration/V041__audit_day_lock_grid_direction.sql
Normal file
13
db/migration/V041__audit_day_lock_grid_direction.sql
Normal 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č.';
|
||||
28
db/migration/V042__energy_flow_columns.sql
Normal file
28
db/migration/V042__energy_flow_columns.sql
Normal 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).';
|
||||
388
db/migration/V043__site_25a_fixed_buy_seed.sql
Normal file
388
db/migration/V043__site_25a_fixed_buy_seed.sql
Normal 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;
|
||||
$$;
|
||||
9
db/migration/V044__deye_register_max_current_a.sql
Normal file
9
db/migration/V044__deye_register_max_current_a.sql
Normal 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.';
|
||||
201
db/migration/V045__seed_site_kv1.sql
Normal file
201
db/migration/V045__seed_site_kv1.sql
Normal 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;
|
||||
$$;
|
||||
40
db/migration/V046__battery_slot_selection_and_registers.sql
Normal file
40
db/migration/V046__battery_slot_selection_and_registers.sql
Normal 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
|
||||
@@ -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.';
|
||||
11
db/migration/V049__planning_config.sql
Normal file
11
db/migration/V049__planning_config.sql
Normal 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, …).';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user