Compare commits
311 Commits
3595b24f3b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ebcc65af | ||
|
|
ab8ddf1fdf | ||
|
|
ce30dbd4a4 | ||
|
|
daf7ed4d4b | ||
|
|
17147ca412 | ||
|
|
c27e1cbe6d | ||
|
|
1479572569 | ||
|
|
b052c9c0e7 | ||
|
|
c03f9dd9d6 | ||
|
|
fc6d9833a7 | ||
|
|
a32839bf67 | ||
|
|
fd7012e23d | ||
|
|
a9a6a88a88 | ||
|
|
f70111f44b | ||
|
|
3e369606b4 | ||
|
|
8ffe5460f1 | ||
|
|
1ef8630302 | ||
|
|
f726188ec9 | ||
|
|
87a4f47666 | ||
|
|
8d23eb7dce | ||
|
|
1060bad57b | ||
|
|
74e156514a | ||
|
|
5bfea4457b | ||
|
|
2590eeb0a3 | ||
|
|
8452b34b25 | ||
|
|
521a3653d3 | ||
|
|
d81a150014 | ||
|
|
54288ee2fd | ||
|
|
6e89b044f5 | ||
|
|
03b7396676 | ||
|
|
c635f8f5dc | ||
|
|
7e9cd933b6 | ||
|
|
f81c2e4b71 | ||
|
|
c601438eea | ||
|
|
b168618332 | ||
|
|
710283f784 | ||
|
|
b08782525e | ||
|
|
8882fa0c91 | ||
|
|
fb9d0f107a | ||
|
|
042581681b | ||
|
|
a742c295b7 | ||
|
|
f531214dac | ||
|
|
7decfebdbd | ||
|
|
55deae984e | ||
|
|
a889950eba | ||
|
|
826c776c34 | ||
|
|
74dbe87018 | ||
|
|
26013e229b | ||
|
|
e2688bb899 | ||
|
|
4ff5f7c3eb | ||
|
|
406b6a7f8f | ||
|
|
287353b082 | ||
|
|
d8f6de77d5 | ||
|
|
5530253662 | ||
|
|
80623573ea | ||
|
|
73a665457d | ||
|
|
3b5f07b66e | ||
|
|
283443d6bd | ||
|
|
48f5a6b00b | ||
|
|
60eda46dd7 | ||
|
|
f0e81def5d | ||
|
|
815a233049 | ||
|
|
f71bc944b4 | ||
|
|
e41840cb7d | ||
|
|
8289e32a03 | ||
|
|
d63a85a2ea | ||
|
|
1406796a62 | ||
|
|
8554cd1bc1 | ||
|
|
62a5c64f77 | ||
|
|
dd3bd55c0e | ||
|
|
b66bb712e4 | ||
|
|
c7f595c587 | ||
|
|
a208cc627d | ||
|
|
9213d3544b | ||
|
|
18bf93a801 | ||
|
|
7da7205c07 | ||
|
|
11767dfdbd | ||
|
|
e490e8cd26 | ||
|
|
2122fa2035 | ||
|
|
ea4ca0e3de | ||
|
|
ca4340ffdd | ||
|
|
5ae6b609cc | ||
|
|
3d51176819 | ||
|
|
b651191fdb | ||
|
|
ee3581da02 | ||
|
|
de849e7e8b | ||
|
|
ce1ca8eecb | ||
|
|
5a10da57e9 | ||
|
|
315bd0ca46 | ||
|
|
85dff7f13e | ||
|
|
2325bbcbd6 | ||
|
|
15d47e8a80 | ||
|
|
f3eb16892f | ||
|
|
0e7f7b69ae | ||
|
|
08a43aa236 | ||
|
|
a7403227c1 | ||
|
|
29d854f23d | ||
|
|
5d2c09401a | ||
|
|
6671157e8e | ||
|
|
ab17e86900 | ||
|
|
e0410f9638 | ||
|
|
2932d48080 | ||
|
|
e464b114b9 | ||
|
|
4095f0f912 | ||
|
|
002566ae5f | ||
|
|
466c15fa84 | ||
|
|
02c35f8add | ||
|
|
60176fc7b2 | ||
|
|
21b3d12955 | ||
|
|
620cea8b9b | ||
|
|
0ed6f18e1a | ||
|
|
e7b87fbabd | ||
|
|
cf663ae417 | ||
|
|
733224d18d | ||
|
|
7f22311172 | ||
|
|
ccdca068a1 | ||
|
|
4d1313a3bc | ||
|
|
5239463699 | ||
|
|
53e9afb513 | ||
|
|
d47f5f8b87 | ||
|
|
847015fd48 | ||
|
|
e42569f629 | ||
|
|
c9409b0666 | ||
|
|
46d333d561 | ||
|
|
634b7d3fb3 | ||
|
|
c4fe0b713e | ||
|
|
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 |
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.
|
||||
|
||||
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.
|
||||
@@ -73,6 +73,7 @@ Krátce a v pořadí:
|
||||
- Záporná **prodejní** cena → export do sítě v LP **neekonomický** / u části instalací **tvrdě 0**; přebytek → nabíjení / curtailment **A** / GEN cutoff (viz `solve_dispatch` v `backend/services/planning_engine.py`).
|
||||
- **Pole B** je v modelu **nekontrolovatelné** — nelze ho `pv_a_curtailed` omezit.
|
||||
- **Zelený bonus** není v účelové funkci LP; počítá se v auditu (`fn_green_bonus_revenue`) — viz `docs/04-modules/planning.md`.
|
||||
- **~60 % SoC ve slunci (BA81/KV1)** nebo **ranní export před sell<0 (home-01):** často **v58** (`bc_pv=0` při `sell > min+0,20`) nebo **v33 pre-neg cushion** — plánovaná náhrada: `docs/04-modules/planning-charge-slot-budget.md` (zatím ne v produkci).
|
||||
4. **Mezery modelu** (upozornit jednou větu, když je to relevantní):
|
||||
- LP používá horní strop **`max_charge_power_w`** bez závislosti na SoC → u vysokého SoC může reálný proud být nižší než plán.
|
||||
|
||||
@@ -86,6 +87,10 @@ Krátce a v pořadí:
|
||||
|
||||
→ [reference.md](reference.md)
|
||||
|
||||
## Související skill
|
||||
|
||||
- **Bug / incident plánovače** (422 Infeasible, degradovaný relaxed solve, večerní export, multi-site): [ems-planner-bug-triage](../ems-planner-bug-triage/SKILL.md) — triáž a návrh fix větve; tento skill zůstává pro vysvětlení slotů.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Hromadná analýza** (`fn_plan_explain_bundle`, `planning_interval` pro více `site_id`) jen proto, že uživatel **neřekl kterou** lokalitu — vždy se **nejprve** zeptat.
|
||||
|
||||
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`.
|
||||
@@ -46,3 +46,5 @@ TELEMETRY_POLL_INTERVAL_SEC=60
|
||||
PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ
|
||||
PLANNING_CHEAP_PRICE_THRESHOLD=0.85
|
||||
PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15
|
||||
PLANNING_ENGINE_VERSION=v1 # v1 = původní planner, v2 = nová policy větev
|
||||
PLANNING_ENGINE_COMPARE_ENABLED=false # true = spočítat i druhou verzi a uložit comparison do solver_params
|
||||
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- feature/**
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
@@ -78,7 +79,15 @@ jobs:
|
||||
ls -ld /opt/ems-deploy
|
||||
|
||||
- name: Run deploy script
|
||||
run: bash /opt/ems-deploy/deploy.sh
|
||||
# Nejdřív aktualizovat checkout + ROOT kopii skriptu z repa (jinak by
|
||||
# opravy deploy.sh nikdy nedoputovaly na server — skript se spouští
|
||||
# z /opt/ems-deploy, ne z checkoutu). deploy.sh pak fetch/reset zopakuje
|
||||
# idempotentně.
|
||||
run: |
|
||||
git -c safe.directory=/opt/ems-deploy/app -C /opt/ems-deploy/app fetch origin
|
||||
git -c safe.directory=/opt/ems-deploy/app -C /opt/ems-deploy/app reset --hard origin/main
|
||||
install -m 0755 /opt/ems-deploy/app/deploy/deploy.sh /opt/ems-deploy/deploy.sh
|
||||
bash /opt/ems-deploy/deploy.sh
|
||||
|
||||
# Alternativa: runner v Dockeru bez přístupu k hostu — odkomentovat a upravit SERVER + secrets.
|
||||
# deploy-ssh:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,3 +13,5 @@ dist/
|
||||
*.tsbuildinfo
|
||||
frontend/vendor/
|
||||
frontend/scripts/.native-tmp/
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
27
CLAUDE.md
27
CLAUDE.md
@@ -49,6 +49,8 @@ Když uživatel napíše **„použij MCP“** nebo potřebuje **aktuální řá
|
||||
| `db/routines/` | Repeatable SQL: funkce `ems.fn_*` |
|
||||
| `db/views/` | Repeatable SQL: view `ems.vw_*` |
|
||||
| `backend/services/` | Python služby (v repozitáři zatím hlavně plánování) |
|
||||
| `backend/services/planning/` | Moduly plánovače: `constants` (vč. všech ekonomických penalt), `types`, `forecast`, `db_io`, `heuristics`; `planning_engine.py` = solver + orchestrace + fasáda (re-export, importy beze změny) |
|
||||
| `backend/tests/golden/` + `scripts/harness/` | Ekonomický regresní harness: golden replay gate (`test_golden_replay.py`), `extract_fixtures.py`, `economics_report.py`, `penalty_audit.py` — viz `scripts/harness/README.md`; **při změně plánovače musí projít golden gate** |
|
||||
|
||||
---
|
||||
|
||||
@@ -68,7 +70,7 @@ Když uživatel napíše **„použij MCP“** nebo potřebuje **aktuální řá
|
||||
|
||||
7. **Záporná nákupní cena → omezit import** na realistický horní strop (viz `solve_dispatch` v `planning_engine.py` – nesmí „nekonečný“ import).
|
||||
|
||||
8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků – dle docs).
|
||||
8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků – dle docs). **Ekonomika slotů:** masky + guardy v `solve_dispatch` — viz `docs/04-modules/planning.md`. **Arbitráž baterie:** neúčtovat `buy[t]`/`sell[t]` ve stejném 15min slotu jako nákup/prodej téže kWh; `min(buy)` horizontu ≠ cena nabití (home-01 nabíjí hodiny, ne jednu čtvrthodinu). Povinné: `docs/04-modules/planning-arbitrage-accounting.md`.
|
||||
|
||||
9. **Zelený bonus je na `asset_pv_array`** (sloupce `green_bonus_*`), **nikdy** v `site_market_config`. Výpočet přes `fn_green_bonus_revenue()`. Bonus se nepočítá v solveru – pouze v audit_filler (`fn_fill_audit_interval`).
|
||||
|
||||
@@ -104,13 +106,15 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
|
||||
|
||||
15. **Bazální spotřeba** = `load_power_w` minus řízené zátěže (součet EV z `telemetry_ev_charger`, TČ z `telemetry_heat_pump`). Tabulka `consumption_baseline_stats` se plní denně (APScheduler 00:30) přes `fn_update_baseline_stats`; **bez EMA „ocasu“** přepočítáš smaž+hromadný update přes **`ems.fn_rebuild_consumption_baseline_stats(site_id, lookback)`** (`site_id NULL` → všechny lokality). **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`.
|
||||
|
||||
16. **Dynamický horizont plánování (jen OTE):** konec okna z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (`min` posledního OTE konce a `start + 36 h`, NULL pokud je známé OTE kratší než **1 h** od startu – rolling se přeskočí; denní plán při NULL použije **1 h** fallback v Pythonu). Strop a práh měnit v SQL (defaultní argumenty funkce / repeatable migrace), ne přes env. Solver používá **výhradně** sloty s efektivní cenou z `vw_site_effective_price` (žádná predikce v LP). Účelová funkce má **terminal SoC shadow price**: `−(průměr buy v prvních 24 h slotů × planner_terminal_soc_value_factor / 1000) × soc[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). `market_price_stats` / `fn_get_predicted_price` zůstávají pro statistiky a budoucí rozšíření; detail historie: `docs/04-modules/planning-extended-horizon.md`.
|
||||
16. **Dynamický horizont plánování (jen OTE):** konec okna z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (`min` posledního OTE konce a `start + 36 h`, NULL pokud je známé OTE kratší než **1 h** od startu – rolling se přeskočí; denní plán při NULL použije **1 h** fallback v Pythonu). Strop a práh měnit v SQL (defaultní argumenty funkce / repeatable migrace), ne přes env. Solver používá **výhradně** sloty s efektivní cenou z `vw_site_effective_price` (žádná predikce v LP). Účelová funkce má **terminal SoC shadow price**: `−(průměr buy v prvních 24 h slotů × planner_terminal_soc_value_factor / 1000) × soc[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žim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `_deye_zero_export_amps_for_passive`; **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10–100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (lokalita se zeleným bonusem na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`; hodnota z `pv_a_forecast_solver_w` / `pv_a_curtailed_w` (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 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`**.
|
||||
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`** → reg **108 sleduje charge intent plánu** (fix 2026-06-16): `bat_w>0` → **108=max** (baterka nabere kolik fyzicky zvládne, přebytek **nad nabíjecí rychlost** do sítě — případ „výroba > rychlost baterky", BA81); SoC u maxima (`>= max_soc − BATTERY_CALIB_TOPOFF_MARGIN_PCT`) + přebytek → **108=max** (BMS kalibrace na 100 %); jen `bat_w<=0` daleko od maxima → **108=0** (prodej PV, drž baterku). **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 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).
|
||||
19. **HARD LIMIT exportu na fakturačním elektroměru — NIKDY nepřekročit.** Překročení rezervovaného exportního výkonu (home-01: 13.5 kW) byť o desetiny kW = smluvní pokuta v řádu desítek tisíc Kč za kW. Jediný bezpečný invariant: **reg 143 (limit na svorkách střídače) <= max_export_power_w (limit ulice) VŽDY** — v nejhorším případě (spotřeba mezi střídačem a CT odpadne) je ulice rovna svorkám. **ZAKÁZÁNO** jakékoli feed-forward navyšování terminálového limitu o měřenou spotřebu (výpadek spotřeby = přestřelení ulice). Vyšší vytěžení smí přinést jedině interní regulace střídače proti CT (firmware smyčka), nikdy náš software s 1min telemetrií a 15min ticky.
|
||||
|
||||
20. **Baterie – export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 11–12 %, migrace V029 + komentář sloupce).
|
||||
|
||||
---
|
||||
|
||||
@@ -159,7 +163,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
|
||||
| `signal_state` | Poslední požadovaná / odeslaná / ověřená hodnota na cíli (idempotence). |
|
||||
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
||||
|
||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`.
|
||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_ev_session_defaults` (kaskáda ev_weekly_requirement → forecast → defaulty), `fn_ev_session_planning_json` (EV session pro LP; nevyřazuje při needed=0), `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_modbus_device_state_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`.
|
||||
|
||||
---
|
||||
|
||||
@@ -201,7 +205,10 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
||||
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` |
|
||||
| Deye registry (FC 0x10, 108/109/141/142/178/143/145/340) | `docs/04-modules/modbus-registers.md` |
|
||||
| Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` |
|
||||
| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
|
||||
| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `docs/planning-changelog.md`, `planning_engine.py` |
|
||||
| Rozpočet nabíjecích slotů (Wh × ceny × forecast; plánováno) | `docs/04-modules/planning-charge-slot-budget.md` — náhrada v58 + pre-neg cushion |
|
||||
| Záporný výkup, bod T, termika, bazén (home-01 strategie) | `docs/04-modules/planning-neg-sell-strategy.md` |
|
||||
| Arbitráž baterie (mezi sloty ≠ buy/sell v jednom 15min) | `docs/04-modules/planning-arbitrage-accounting.md` |
|
||||
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` |
|
||||
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
|
||||
| Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` |
|
||||
@@ -213,15 +220,23 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
||||
| Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` |
|
||||
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |
|
||||
| **MCP read-only SQL na EMS DB** | **`docs/07-mcp-postgres-ems.md`** — server ID **`user-postgres-ems`**, nástroj **`query`**, `{"sql":"…"}`. Pravidlo **`.cursor/rules/mcp-postgres-ems.mdc`**. |
|
||||
| **Refaktor „Čistý plánovač“ (fáze, stav, nasazení v2)** | **`docs/refactor-clean-planner.md`**; verze enginu v1/v2 + env flagy: `docs/04-modules/planning.md` (sekce Verze enginu); changelog 2026-06-11 |
|
||||
| **Čisté jádro plánovače v2** | `backend/services/planning/solver_v2.py`, testy `backend/tests/test_solver_v2.py`, eval `scripts/harness/solver_v2_eval.py` |
|
||||
| **Delta-triáž neekonomického chování (agent skill)** | **`.claude/skills/ems-delta-triage/`** — realita vs plán vs shadow peer vs oracle, verdikt s Kč |
|
||||
| **Vysvětlení plánu (agent skill)** | **`.cursor/skills/ems-plan-explain/`** — `fn_plan_explain_bundle`, sloty, proč nabíjí/exportuje |
|
||||
| **Triáž bugů plánovače (agent skill)** | **`.cursor/skills/ems-planner-bug-triage/`** — Infeasible/relaxed solve, večerní export, neg den, BA81/KV1; MCP SQL v `reference.md` |
|
||||
|
||||
---
|
||||
|
||||
## Konvence (krátce)
|
||||
|
||||
- **Dokumentace 1:1 s implementací — POVINNÉ u každé změny.** Každý commit, který mění chování, nese i aktualizaci docs ve STEJNÉM commitu (nebo bezprostředně navazujícím): plánovač → `docs/planning-changelog.md` (formát: datum · problém · příčina/mechanismus · soubory · ověření) + dotčený `docs/04-modules/*.md`; nová zařízení/registry → modulový doc (vzor `modbus-registers-teltocharge.md`); deploy/CI → `docs/deployment-self-hosted.md`; nové tabulky/sloupce → `comment on` v migraci + zmínka v `docs/03-data-model.md` u větších celků; env flagy a defaulty → místo, kde jsou popsané (např. `planning.md` sekce Verze enginu). Zastaralé tvrzení v docs = bug se stejnou prioritou jako bug v kódu.
|
||||
- Python: `snake_case`, type hints, Pydantic pro API modely.
|
||||
- SQL: viz také odstavec **Formát SQL** u sekce SQL-first výše — **2 mezery** odsazení, **klíčová slova malými písmeny**, `snake_case` identifikátory, explicitní FK; Flyway pořadí `V###__` / repeatable `R__NNN_*.sql` (třímístný prefix = pořadí závislostí mezi fn/vw).
|
||||
- **PG funkce: žádné overloady — název `ems.fn_*` je vždy unikátní** (nikdy dvě funkce stejného jména s jinými parametry). Díky tomu se `drop function if exists` i `comment on function` píší **VŽDY bez závorky s parametry** — odkaz přes signaturu se rozbije při každé změně parametrů (42883 shodil deploy 2026-06-12, R__018: comment mířil na starou signaturu po přidání `p_force`).
|
||||
- Timescale **continuous aggregate** (CA): komentář k objektu CA je **`COMMENT ON VIEW`**, ne `COMMENT ON MATERIALIZED VIEW` (PG hlásí 42809). Viz `.cursor/rules/timescale-continuous-aggregate.mdc`.
|
||||
- Výkon **W**, energie **Wh**, ceny **Kč/kWh**; čas v DB **`TIMESTAMPTZ` (UTC)**.
|
||||
- NIKDY neupravuj existující V__ migrační soubory po jejich aplikaci na DB.
|
||||
- Pokud je potřeba opravit chybu ve verzované migraci, vytvoř novou V{N+1} migraci.
|
||||
- **Vývojová kadence (slabý server):** běžná práce na větvi **`dev`** (push = CI validace BEZ deploye); do `main` merge **1×/den v okně ~16:30–17:00** nebo při milníku (ne těsně před 15:00 — daily plán; OTE importy 13:25–14:00). Deploy zastavuje backend na ~10 min (vynechané rolling ticky kryje Loxone fallback). Hotfix smí na main okamžitě.
|
||||
- Deploy: `flyway validate` před `migrate` ([`deploy/deploy.sh`](deploy/deploy.sh)). Lokálně `./scripts/flyway_validate_local.sh`; CI viz [`docs/deployment-self-hosted.md`](docs/deployment-self-hosted.md) a `scripts/ci_check_migration_immutability.sh`.
|
||||
|
||||
@@ -45,6 +45,18 @@ class Settings(BaseSettings):
|
||||
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)
|
||||
planning_cheap_price_threshold: float = Field(default=0.85)
|
||||
planning_expensive_price_threshold: float = Field(default=1.15)
|
||||
# Discord bot — fáze B tlačítka (docs/discord-ev-interaction.md); prázdné = jen webhook
|
||||
discord_bot_token: str = Field(default="")
|
||||
discord_ev_channel_id: str = Field(default="")
|
||||
discord_allowed_user_ids: str = Field(default="")
|
||||
|
||||
# Tesla Fleet API (docs/tesla-fleet-api.md); prázdné = integrace vypnutá
|
||||
tesla_client_id: str = Field(default="")
|
||||
tesla_client_secret: str = Field(default="")
|
||||
tesla_refresh_token: str = Field(default="")
|
||||
|
||||
planning_engine_version: str = Field(default="v1")
|
||||
planning_engine_compare_enabled: bool = Field(default=False)
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -30,6 +30,7 @@ from services.signal_service import (
|
||||
run_signal_outbound_send_for_active_sites,
|
||||
run_signal_outbound_verify_for_active_sites,
|
||||
)
|
||||
from services.ev_presence_notify import run_ev_presence_nudge_for_all_active_sites
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -108,7 +109,7 @@ async def lifespan(app: FastAPI):
|
||||
for site in await _active_site_rows(conn):
|
||||
try:
|
||||
n = await conn.fetchval(
|
||||
"SELECT ems.fn_fill_forecast_accuracy($1, 48)",
|
||||
"SELECT ems.fn_fill_forecast_accuracy($1, 3)",
|
||||
site["id"],
|
||||
)
|
||||
if n:
|
||||
@@ -161,6 +162,34 @@ async def lifespan(app: FastAPI):
|
||||
except Exception:
|
||||
logger.exception("scheduled_signal_outbound_verify failed")
|
||||
|
||||
async def scheduled_ev_presence_nudge() -> None:
|
||||
"""Proaktivní "auto doma + nepíchnuté + levné/přebytek → píchni ho".
|
||||
|
||||
SQL-first rozhodnutí + dedup v ems.fn_ev_presence_nudge_due (insert do
|
||||
ev_presence_nudge_sent). Default-off per vozidlo (presence_nudge_enabled),
|
||||
takže job běží inertně, dokud se na nějakém vozidle nezapne.
|
||||
"""
|
||||
try:
|
||||
await run_ev_presence_nudge_for_all_active_sites(app.state.pg_pool)
|
||||
except Exception:
|
||||
logger.exception("scheduled_ev_presence_nudge failed")
|
||||
|
||||
async def scheduled_pool_control() -> None:
|
||||
# Bazén: SQL-first rozhodnutí (fn_pool_control_tick) — nejlevnější souvislé
|
||||
# okno denního runtime + dump-load při sell<=0; zařadí POOL_PUMP_ON (jen když
|
||||
# existuje signal_route). Doručení řeší signal_outbound_send. Žádné Modbus.
|
||||
try:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
rows = await conn.fetch("select * from ems.fn_pool_control_tick()")
|
||||
for r in rows:
|
||||
logger.info(
|
||||
"pool control site=%s pump=%s on=%s runtime_min=%s route=%s enq=%s",
|
||||
r["site_id"], r["pump_id"], r["desired_on"],
|
||||
r["runtime_min"], r["has_route"], r["enqueued"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("scheduled_pool_control failed")
|
||||
|
||||
async def scheduled_verify_modbus() -> None:
|
||||
"""
|
||||
Ověří příkazy ve stavu written z posledních 20 minut.
|
||||
@@ -257,6 +286,27 @@ async def lifespan(app: FastAPI):
|
||||
"scheduled_tuv_usage_stats site=%s failed", site["id"]
|
||||
)
|
||||
|
||||
async def scheduled_forecast_accuracy_catchup() -> None:
|
||||
"""Denní 48h catch-up (pozdní telemetrie) — 15min tick jede jen 3 h okno."""
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
try:
|
||||
await conn.fetchval(
|
||||
"SELECT ems.fn_fill_forecast_accuracy($1, 48)", site["id"]
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"forecast_accuracy catchup site=%s failed", site["id"]
|
||||
)
|
||||
|
||||
async def scheduled_ev_usage_stats() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
try:
|
||||
n = await conn.fetchval("select ems.fn_update_ev_usage_stats(60)")
|
||||
logger.info("ev_usage_stats updated %s rows", n)
|
||||
except Exception:
|
||||
logger.exception("scheduled_ev_usage_stats failed")
|
||||
|
||||
async def scheduled_forecast_refresh() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
@@ -392,6 +442,22 @@ async def lifespan(app: FastAPI):
|
||||
id="signal_outbound_verify",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_pool_control,
|
||||
"cron",
|
||||
minute="*/15",
|
||||
second=2,
|
||||
id="pool_control",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_ev_presence_nudge,
|
||||
"cron",
|
||||
minute="5,30,55",
|
||||
second=10,
|
||||
id="ev_presence_nudge",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan")
|
||||
scheduler.add_job(
|
||||
scheduled_rolling_replan,
|
||||
@@ -423,6 +489,22 @@ async def lifespan(app: FastAPI):
|
||||
id="tuv_usage_stats",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_forecast_accuracy_catchup,
|
||||
"cron",
|
||||
hour=5,
|
||||
minute=50,
|
||||
id="forecast_accuracy_catchup",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_ev_usage_stats,
|
||||
"cron",
|
||||
hour=0,
|
||||
minute=50,
|
||||
id="ev_usage_stats",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_ote_import,
|
||||
"cron",
|
||||
@@ -523,6 +605,11 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
|
||||
app.state.telemetry_task = telemetry_task
|
||||
from services.discord_bot import run_discord_bot, set_pool as discord_set_pool
|
||||
|
||||
discord_set_pool(app.state.pg_pool)
|
||||
discord_task = asyncio.create_task(run_discord_bot())
|
||||
app.state.discord_task = discord_task
|
||||
|
||||
yield
|
||||
|
||||
@@ -531,6 +618,11 @@ async def lifespan(app: FastAPI):
|
||||
logging.getLogger().removeHandler(ws_h)
|
||||
app.state.ws_log_handler = None
|
||||
|
||||
discord_task.cancel()
|
||||
try:
|
||||
await discord_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
telemetry_task.cancel()
|
||||
try:
|
||||
await telemetry_task
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
@@ -24,6 +25,7 @@ from app.ws_manager import manager
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
from services.control_exporter import export_setpoints
|
||||
from services.notification_service import run_fn_set_mode_with_discord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -248,6 +250,31 @@ async def set_site_mode(
|
||||
except Exception as e:
|
||||
logger.warning("Loxone EMS_Mode notify failed for site %s: %s", site_id, e)
|
||||
|
||||
# Okamžitá exekuce nového režimu: control exporter jinak běží jen v minutách
|
||||
# 14/29/44/59, takže by střídač až ~15 min jel podle starého plánu (např.
|
||||
# SELF_SUSTAIN má hned nastavit 108/109 na max A a vypnout přetoky).
|
||||
# Fire-and-forget — API neblokuje Modbus zápisy; chyby jen do logu.
|
||||
asyncio.create_task(_export_setpoints_after_mode_change(db, site_id, mode_code))
|
||||
|
||||
return SetSiteModeResponse(
|
||||
success=True, mode=mode_code, activated_at=activated_at
|
||||
)
|
||||
|
||||
|
||||
async def _export_setpoints_after_mode_change(
|
||||
pool: asyncpg.Pool, site_id: int, mode_code: str
|
||||
) -> None:
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await export_setpoints(site_id, conn)
|
||||
logger.info(
|
||||
"Immediate control export after mode change applied (site=%s, mode=%s)",
|
||||
site_id,
|
||||
mode_code,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Immediate control export after mode change failed (site=%s, mode=%s)",
|
||||
site_id,
|
||||
mode_code,
|
||||
)
|
||||
|
||||
@@ -40,7 +40,10 @@ HEARTBEAT_STALE_SEC = 300
|
||||
EXPECTED_TOMORROW_PRICE_SLOTS = 90
|
||||
|
||||
|
||||
def _iso_utc(dt: datetime | None) -> str | None:
|
||||
def _iso_utc(dt: datetime | str | None) -> str | None:
|
||||
# JSONB bundle z fn_site_full_status nese timestampy jako stringy — parsovat,
|
||||
# jinak .tzinfo na str = AttributeError → 500 celého /status/full.
|
||||
dt = _parse_ts(dt)
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
|
||||
@@ -41,12 +41,109 @@ class PlanningIntervalDto(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CurrentPlanResponseModel(BaseModel):
|
||||
class PlanningBundleDto(BaseModel):
|
||||
run: dict[str, Any]
|
||||
intervals: list[PlanningIntervalDto]
|
||||
summary: dict[str, Any]
|
||||
|
||||
|
||||
class CurrentPlanResponseModel(PlanningBundleDto):
|
||||
pass
|
||||
|
||||
|
||||
class ComparisonSlotDiffDto(BaseModel):
|
||||
interval_start: str
|
||||
active: dict[str, Any]
|
||||
comparison: dict[str, Any]
|
||||
|
||||
|
||||
class PlanningCompareResponseModel(BaseModel):
|
||||
active: PlanningBundleDto
|
||||
comparison: PlanningBundleDto
|
||||
diff: dict[str, Any]
|
||||
slot_diffs: list[ComparisonSlotDiffDto]
|
||||
|
||||
|
||||
def _bundle_from_payload(payload: dict[str, Any], *, run_key: str) -> PlanningBundleDto:
|
||||
run_raw = payload.get(run_key) or {}
|
||||
if not isinstance(run_raw, dict):
|
||||
run_raw = {}
|
||||
intervals_raw = payload.get("intervals") or []
|
||||
if not isinstance(intervals_raw, list):
|
||||
intervals_raw = []
|
||||
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
|
||||
summary = payload.get("summary") or {}
|
||||
if not isinstance(summary, dict):
|
||||
summary = {}
|
||||
return PlanningBundleDto(run=run_raw, intervals=intervals, summary=summary)
|
||||
|
||||
|
||||
def _bundle_from_current(payload: dict[str, Any]) -> PlanningBundleDto:
|
||||
return _bundle_from_payload(payload, run_key="run")
|
||||
|
||||
|
||||
def _bundle_from_debug(payload: dict[str, Any]) -> PlanningBundleDto:
|
||||
return _bundle_from_payload(payload, run_key="planning_run")
|
||||
|
||||
|
||||
def _build_plan_diff(
|
||||
active: PlanningBundleDto,
|
||||
comparison: PlanningBundleDto,
|
||||
) -> tuple[dict[str, Any], list[ComparisonSlotDiffDto]]:
|
||||
active_by_ts = {i.interval_start: i for i in active.intervals}
|
||||
compare_by_ts = {i.interval_start: i for i in comparison.intervals}
|
||||
diffs: list[ComparisonSlotDiffDto] = []
|
||||
interesting_keys = (
|
||||
"battery_setpoint_w",
|
||||
"battery_soc_target_pct",
|
||||
"grid_setpoint_w",
|
||||
"export_limit_w",
|
||||
"export_mode",
|
||||
"deye_physical_mode",
|
||||
"deye_gen_cutoff_enabled",
|
||||
"pv_a_curtailed_w",
|
||||
"expected_cost_czk",
|
||||
)
|
||||
for ts, a in active_by_ts.items():
|
||||
b = compare_by_ts.get(ts)
|
||||
if b is None:
|
||||
continue
|
||||
active_payload = a.model_dump()
|
||||
comparison_payload = b.model_dump()
|
||||
if any(active_payload.get(k) != comparison_payload.get(k) for k in interesting_keys):
|
||||
diffs.append(
|
||||
ComparisonSlotDiffDto(
|
||||
interval_start=ts,
|
||||
active={k: active_payload.get(k) for k in interesting_keys},
|
||||
comparison={k: comparison_payload.get(k) for k in interesting_keys},
|
||||
)
|
||||
)
|
||||
|
||||
def _summary_num(bundle: PlanningBundleDto, key: str) -> float:
|
||||
raw = bundle.summary.get(key)
|
||||
try:
|
||||
return float(raw) if raw is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
active_cost = _summary_num(active, "total_expected_cost_czk")
|
||||
compare_cost = _summary_num(comparison, "total_expected_cost_czk")
|
||||
diff = {
|
||||
"active_total_expected_cost_czk": active_cost,
|
||||
"comparison_total_expected_cost_czk": compare_cost,
|
||||
"total_expected_cost_czk": round(active_cost - compare_cost, 4),
|
||||
"absolute_total_expected_cost_czk": round(abs(active_cost - compare_cost), 4),
|
||||
"active_charge_slots": int(_summary_num(active, "charge_slots")),
|
||||
"comparison_charge_slots": int(_summary_num(comparison, "charge_slots")),
|
||||
"active_discharge_slots": int(_summary_num(active, "discharge_slots")),
|
||||
"comparison_discharge_slots": int(_summary_num(comparison, "discharge_slots")),
|
||||
"active_export_slots": int(_summary_num(active, "export_slots")),
|
||||
"comparison_export_slots": int(_summary_num(comparison, "export_slots")),
|
||||
"changed_slots": len(diffs),
|
||||
}
|
||||
return diff, diffs
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentPlanResponseModel)
|
||||
async def get_current_plan(
|
||||
site_id: int,
|
||||
@@ -69,14 +166,54 @@ async def get_current_plan(
|
||||
if bundle.get("error") == "no_active_plan":
|
||||
raise HTTPException(status_code=404, detail="No active plan")
|
||||
|
||||
intervals_raw = bundle.get("intervals") or []
|
||||
if not isinstance(intervals_raw, list):
|
||||
intervals_raw = []
|
||||
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
|
||||
plan = _bundle_from_current(bundle)
|
||||
return CurrentPlanResponseModel(
|
||||
run=bundle.get("run") or {},
|
||||
intervals=intervals,
|
||||
summary=bundle.get("summary") or {},
|
||||
run=plan.run,
|
||||
intervals=plan.intervals,
|
||||
summary=plan.summary,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/compare", response_model=PlanningCompareResponseModel)
|
||||
async def get_plan_compare(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> PlanningCompareResponseModel:
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
payload = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_plan_compare_bundle($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
payload = json.loads(payload)
|
||||
err = payload.get("error")
|
||||
if err == "no_active_plan":
|
||||
raise HTTPException(status_code=404, detail="No active plan")
|
||||
if err == "no_comparison_plan":
|
||||
raise HTTPException(status_code=404, detail="No comparison plan")
|
||||
|
||||
active_raw = payload.get("active") or {}
|
||||
compare_raw = payload.get("comparison")
|
||||
if not isinstance(active_raw, dict):
|
||||
active_raw = {}
|
||||
if not isinstance(compare_raw, dict):
|
||||
raise HTTPException(status_code=404, detail="No comparison plan")
|
||||
|
||||
active = _bundle_from_current(active_raw)
|
||||
comparison = _bundle_from_current(compare_raw)
|
||||
diff, slot_diffs = _build_plan_diff(active, comparison)
|
||||
return PlanningCompareResponseModel(
|
||||
active=active,
|
||||
comparison=comparison,
|
||||
diff=diff,
|
||||
slot_diffs=slot_diffs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +147,12 @@ async def patch_pv_forecast_calibration(
|
||||
status_code=404,
|
||||
detail="PV forecast calibration row missing; run migration V057",
|
||||
)
|
||||
await conn.execute(
|
||||
# p_force=true: uživatel právě změnil kalibraci — throttle 6 h nesmí
|
||||
# nechat starou cache (čtenář ji od HOTFIXu 2/2 vrací bez přepočtu)
|
||||
"select ems.fn_refresh_site_pv_delta_profile_cache($1::int, true)",
|
||||
site_id,
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT to_jsonb(c.*) AS j
|
||||
|
||||
@@ -414,6 +414,8 @@ class ModbusJournalCommandRow(BaseModel):
|
||||
status: str
|
||||
attempt_count: int
|
||||
created_at: str
|
||||
asset_code: str | None = None
|
||||
error_msg: str | None = None
|
||||
|
||||
|
||||
class ModbusJournalListResponse(BaseModel):
|
||||
|
||||
@@ -12,3 +12,4 @@ pvlib>=0.11.0
|
||||
pandas>=2.2.0
|
||||
numpy>=2.0.0
|
||||
httpx>=0.28.0
|
||||
discord.py>=2.4.0
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Deye / Modbus control export (monolith v exporter_monolith.py – postupný split)."""
|
||||
"""Deye / Modbus control export modules."""
|
||||
|
||||
from .exporter_monolith import * # noqa: F401,F403
|
||||
|
||||
266
backend/services/control/deye_helpers.py
Normal file
266
backend/services/control/deye_helpers.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Čisté Deye konstanty a helpery pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.control.models import InverterConfig
|
||||
|
||||
PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
# Hodiny Deye 62-64: po zápisu sekundy na zařízení dál běží, verify musí být toleranční.
|
||||
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120
|
||||
# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund.
|
||||
DEYE_CLOCK_DRIFT_OK_SEC = 60
|
||||
# A zároveň neuplynul tento interval od posledního syncu / potvrzení driftu.
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24
|
||||
|
||||
# Deye LV baterie: převod výkon -> proud pro registry 108/109.
|
||||
BATT_VOLTAGE_V = 51.2
|
||||
|
||||
# Reg 178 - bitové pole: bity 4-5 (peak shaving switch) a bity 0-1 (MI export cutoff).
|
||||
REG178_SELL = 0b00100000
|
||||
REG178_PASSIVE = 0b00110000
|
||||
REG178_VERIFY_MASK = 0x0030
|
||||
REG178_MI_EXPORT_MASK = 0x0003
|
||||
REG178_MI_EXPORT_DISABLE = 0b10
|
||||
REG178_MI_EXPORT_ENABLE = 0b11
|
||||
REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_MI_EXPORT_MASK
|
||||
|
||||
DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145})
|
||||
DEYE_TOU_POWER_REGS = frozenset(range(154, 160))
|
||||
DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350
|
||||
|
||||
# Neaktivní TOU bloky (3-6): Deye často 23:59 (2359) neuloží, 23:55 je stabilní.
|
||||
DEYE_TOU_INACTIVE_HHMM = 2355
|
||||
|
||||
_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
|
||||
[
|
||||
150,
|
||||
151,
|
||||
152,
|
||||
153,
|
||||
156,
|
||||
157,
|
||||
158,
|
||||
159,
|
||||
168,
|
||||
169,
|
||||
170,
|
||||
171,
|
||||
174,
|
||||
175,
|
||||
176,
|
||||
177,
|
||||
]
|
||||
)
|
||||
|
||||
DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64})
|
||||
|
||||
DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
108: "max_charge_a (max nabíjecí proud baterie)",
|
||||
109: "max_discharge_a (max vybíjecí proud baterie)",
|
||||
141: "energy_mode (0, EMS nemění)",
|
||||
142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)",
|
||||
143: "export_limit_w (max export do sítě)",
|
||||
145: "solar_sell (0=disabled, 1=enabled)",
|
||||
340: "max_solar_power_w (strop DC PV A v W; cap z fn_inverter_pv_a_max_w / deye_reg340_max_solar_w)",
|
||||
178: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3; bits4-5 peak shaving 32/48)",
|
||||
148: "time_point_1_time",
|
||||
149: "time_point_2_time",
|
||||
154: "time_point_1_power_w",
|
||||
155: "time_point_2_power_w",
|
||||
166: "time_point_1_soc_min_pct",
|
||||
167: "time_point_2_soc_min_pct",
|
||||
172: "time_point_1_grid_charge",
|
||||
173: "time_point_2_grid_charge",
|
||||
62: "system_time_year_month",
|
||||
63: "system_time_day_hour",
|
||||
64: "system_time_min_sec",
|
||||
}
|
||||
for _tp_i in range(6):
|
||||
_n = _tp_i + 1
|
||||
DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time")
|
||||
DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w")
|
||||
DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct")
|
||||
DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge")
|
||||
|
||||
|
||||
def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
|
||||
return (int(expected_i) & REG178_VERIFY_MASK_COMBINED) == (
|
||||
int(actual_i) & REG178_VERIFY_MASK_COMBINED
|
||||
)
|
||||
|
||||
|
||||
def deye_mi_export_cutoff_want_enabled(
|
||||
*,
|
||||
gen_microinverter_cutoff_enabled: bool,
|
||||
deye_gen_cutoff_enabled: bool,
|
||||
export_ban: bool,
|
||||
deye_mode: str,
|
||||
) -> bool:
|
||||
"""
|
||||
True = zapnout MI export cut-off (reg 178 bits 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:
|
||||
# round(), NE int(): 11 kW / (3×230) = 15.94 A → int useklo na 15 A (~10.35 kW,
|
||||
# −6 % výkonu); round dá správných 16 A (~11 kW). Strop 32 A drží horní mez.
|
||||
if not power_w or power_w <= 0:
|
||||
return 0
|
||||
return min(32, max(0, round(power_w / (phases * voltage))))
|
||||
|
||||
|
||||
def battery_watts_to_amps(power_w: int, max_amps: int) -> int:
|
||||
"""Proud z |výkonu| baterie; max_amps z DB."""
|
||||
derived = int(abs(power_w) / BATT_VOLTAGE_V)
|
||||
return min(max(0, max_amps), max(0, derived))
|
||||
|
||||
|
||||
def current_slot_hhmm() -> int:
|
||||
"""Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM."""
|
||||
now = datetime.now(PRAGUE_TZ)
|
||||
slot_min = (now.minute // 15) * 15
|
||||
return now.hour * 100 + slot_min
|
||||
|
||||
|
||||
def next_slot_hhmm() -> int:
|
||||
"""Začátek příštího 15min slotu v Europe/Prague, formát HHMM."""
|
||||
now = datetime.now(PRAGUE_TZ)
|
||||
minutes = now.minute
|
||||
slot_minutes = ((minutes // 15) + 1) * 15
|
||||
if slot_minutes >= 60:
|
||||
next_hour = (now.hour + 1) % 24
|
||||
next_min = 0
|
||||
else:
|
||||
next_hour = now.hour
|
||||
next_min = slot_minutes
|
||||
return next_hour * 100 + next_min
|
||||
|
||||
|
||||
def compute_pv_a_reg340_max_solar_w(
|
||||
cap_w: int,
|
||||
forecast_w: int,
|
||||
curtail_w: int,
|
||||
*,
|
||||
min_w: int = 0,
|
||||
) -> int:
|
||||
"""Hodnota pro Deye reg 340 (max solar power, W) z capu a plánovaného curtailmentu pole A."""
|
||||
if curtail_w <= 0:
|
||||
raw = int(cap_w)
|
||||
else:
|
||||
raw = max(0, min(int(cap_w), int(forecast_w) - int(curtail_w)))
|
||||
if raw > 0 and int(min_w) > 0:
|
||||
return max(int(min_w), raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _prague_minute_start_utc() -> datetime:
|
||||
"""UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague."""
|
||||
p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
|
||||
return p.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None:
|
||||
"""Dekódování reg 62-64 (Deye system time v Europe/Prague)."""
|
||||
try:
|
||||
year = (int(r62) >> 8) + 2000
|
||||
month = int(r62) & 0xFF
|
||||
day = int(r63) >> 8
|
||||
hour = int(r63) & 0xFF
|
||||
minute = int(r64) >> 8
|
||||
second = int(r64) & 0xFF
|
||||
if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23):
|
||||
return None
|
||||
if not (0 <= minute <= 59 and 0 <= second <= 59):
|
||||
return None
|
||||
return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _deye_clock_registers_verify_match(
|
||||
w62: int,
|
||||
w63: int,
|
||||
w64: int,
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
) -> bool:
|
||||
w_dt = _deye_registers_to_prague_datetime(w62, w63, w64)
|
||||
a_dt = _deye_registers_to_prague_datetime(a62, a63, a64)
|
||||
if w_dt is None or a_dt is None:
|
||||
return False
|
||||
return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC
|
||||
|
||||
|
||||
def _deye_should_skip_time_sync_after_read(
|
||||
inv: InverterConfig,
|
||||
r62: int,
|
||||
r63: int,
|
||||
r64: int,
|
||||
) -> bool:
|
||||
"""
|
||||
True = nezařazovat zápis 62-64: drift je malý a od posledního úspěšného zápisu
|
||||
nebo tolerančního ověření neuplynulo 24h.
|
||||
"""
|
||||
dev = _deye_registers_to_prague_datetime(r62, r63, r64)
|
||||
if dev is None:
|
||||
return False
|
||||
wall = datetime.now(PRAGUE_TZ)
|
||||
drift = abs((wall - dev).total_seconds())
|
||||
if drift > DEYE_CLOCK_DRIFT_OK_SEC:
|
||||
return False
|
||||
last_write = inv.deye_last_system_time_sync_at
|
||||
if last_write is None:
|
||||
return False
|
||||
if last_write.tzinfo is None:
|
||||
last_write = last_write.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
last_write = last_write.astimezone(timezone.utc)
|
||||
age = datetime.now(timezone.utc) - last_write
|
||||
if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS):
|
||||
return False
|
||||
return True
|
||||
File diff suppressed because it is too large
Load Diff
378
backend/services/control/inverter.py
Normal file
378
backend/services/control/inverter.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Deye inverter writer and live register reader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.deye_helpers import (
|
||||
BATT_VOLTAGE_V,
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_REGS,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
DEYE_TOU_INACTIVE_HHMM,
|
||||
PRAGUE_TZ,
|
||||
REG178_MI_EXPORT_DISABLE,
|
||||
REG178_MI_EXPORT_ENABLE,
|
||||
REG178_MI_EXPORT_MASK,
|
||||
REG178_PASSIVE,
|
||||
REG178_SELL,
|
||||
REG178_VERIFY_MASK,
|
||||
REG178_VERIFY_MASK_COMBINED,
|
||||
_DEYE_INACTIVE_TOU_REGISTERS,
|
||||
_deye_should_skip_time_sync_after_read,
|
||||
deye_mi_export_cutoff_want_enabled,
|
||||
_prague_minute_start_utc,
|
||||
current_slot_hhmm,
|
||||
next_slot_hhmm,
|
||||
)
|
||||
from services.control.modbus_journal import (
|
||||
_drop_registers_matching_last_verified,
|
||||
_fetch_last_verified_inverter_registers,
|
||||
create_modbus_commands,
|
||||
execute_modbus_commands,
|
||||
)
|
||||
from services.control.models import ControlSetpoints
|
||||
from services.control.repository import _get_current_soc, _load_inverter_config
|
||||
from services.control.setpoints import (
|
||||
_deye_reg143_export_w,
|
||||
_deye_system_time_register_rows,
|
||||
_deye_time_point_rows,
|
||||
_deye_tou_min_soc_pct,
|
||||
_deye_tou_params,
|
||||
_deye_tou_reserve_soc_pct,
|
||||
deye_battery_charge_discharge_amps,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def write_inverter_setpoints(
|
||||
site_id: int,
|
||||
setpoints_now: ControlSetpoints,
|
||||
setpoints_next: ControlSetpoints | None,
|
||||
db: asyncpg.Connection,
|
||||
planning_run_id: int | None = None,
|
||||
) -> str:
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
return "FAIL inverter: no controllable Modbus endpoint"
|
||||
|
||||
unit_id = int(inv.unit_id if inv.unit_id is not None else 1)
|
||||
raw_bat = setpoints_now.battery_w
|
||||
grid_w = int(setpoints_now.grid_setpoint_w or 0)
|
||||
no_export = inv.no_export
|
||||
export_lim_hw = _deye_reg143_export_w(no_export, inv.max_export_power_w)
|
||||
export_lim = export_lim_hw
|
||||
if int(setpoints_now.grid_export_limit or 0) > 0:
|
||||
export_lim = min(export_lim_hw, int(setpoints_now.grid_export_limit))
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge
|
||||
tou_min_pct = _deye_tou_min_soc_pct(inv)
|
||||
tou_reserve_pct = _deye_tou_reserve_soc_pct(inv)
|
||||
|
||||
try:
|
||||
soc_telemetry = await _get_current_soc(site_id, db)
|
||||
deye_mode = get_deye_mode(setpoints_now)
|
||||
|
||||
bat_w = int(raw_bat) if raw_bat is not None else 0
|
||||
charge_a, discharge_a = deye_battery_charge_discharge_amps(
|
||||
lock_battery=setpoints_now.lock_battery,
|
||||
deye_mode=deye_mode,
|
||||
self_sustain_local_use=setpoints_now.self_sustain_local_use,
|
||||
bat_w=bat_w,
|
||||
grid_w=grid_w,
|
||||
max_charge_a=int(inv.max_charge_a),
|
||||
max_discharge_a=int(inv.max_discharge_a),
|
||||
export_mode=setpoints_now.export_mode,
|
||||
export_ban=bool(setpoints_now.export_ban),
|
||||
current_soc_pct=soc_telemetry,
|
||||
max_soc_pct=inv.max_soc_percent,
|
||||
)
|
||||
|
||||
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
|
||||
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
|
||||
solar_sell = 0 if (setpoints_now.export_ban and deye_mode != "SELL") else 1
|
||||
export_limit = export_lim
|
||||
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
|
||||
|
||||
charge_a_log = charge_a if charge_a is not None else "skip"
|
||||
logger.info(
|
||||
f"[control] site={site_id} fyzický režim Deye: {deye_mode} | "
|
||||
f"battery_w={raw_bat!r} grid_w={grid_w} | "
|
||||
f"charge_a={charge_a_log} discharge_a={discharge_a} | "
|
||||
f"reg142={selling_mode} reg145={solar_sell} reg143={export_limit}W reg178={reg178_val}"
|
||||
)
|
||||
|
||||
now_cet, time_rows = _deye_system_time_register_rows()
|
||||
skip_time = False
|
||||
try:
|
||||
mb_clock = await get_modbus_client(inv.host, inv.port)
|
||||
tvals = await mb_clock.read_holding_registers(
|
||||
62, 3, int(inv.unit_id if inv.unit_id is not None else 1)
|
||||
)
|
||||
if len(tvals) == 3:
|
||||
skip_time = _deye_should_skip_time_sync_after_read(
|
||||
inv, int(tvals[0]), int(tvals[1]), int(tvals[2])
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Deye clock read: expected 3 registers, got %s; will sync 62-64",
|
||||
len(tvals),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Deye clock read failed (will sync 62-64): %s", e)
|
||||
|
||||
if skip_time:
|
||||
logger.info(
|
||||
"Deye clock 62-64 skipped (drift <= %ss, last sync < %sh ago): %s CET",
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
else:
|
||||
logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows)
|
||||
|
||||
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
|
||||
hh_cur = current_slot_hhmm()
|
||||
hh_nxt = next_slot_hhmm()
|
||||
p1, s1, g1 = _deye_tou_params(setpoints_now, inv)
|
||||
p2, s2, g2 = _deye_tou_params(sp_tp2, inv)
|
||||
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
|
||||
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
|
||||
|
||||
prague_date = datetime.now(PRAGUE_TZ).date()
|
||||
inactive_sig = (
|
||||
f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}"
|
||||
)
|
||||
need_inactive_tou = (
|
||||
inv.deye_last_tou_inactive_write_prague_date != prague_date
|
||||
or inv.deye_tou_inactive_signature != inactive_sig
|
||||
)
|
||||
if need_inactive_tou:
|
||||
for idx in range(2, 6):
|
||||
registers.extend(
|
||||
_deye_time_point_rows(
|
||||
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Deye TOU rows 3-6 skipped (already written today, signature unchanged)"
|
||||
)
|
||||
|
||||
amp_regs: list[tuple[int, str, int]] = []
|
||||
if charge_a is not None:
|
||||
amp_regs.append((108, "", charge_a))
|
||||
amp_regs.append((109, "", discharge_a))
|
||||
registers.extend(
|
||||
amp_regs
|
||||
+ [
|
||||
(141, "energy_mode (0)", 0),
|
||||
(142, "limit_control", selling_mode),
|
||||
(143, "", export_limit),
|
||||
(145, "solar_sell", solar_sell),
|
||||
]
|
||||
)
|
||||
|
||||
if (
|
||||
bool(inv.deye_reg340_pv_a_control_enabled)
|
||||
and int(inv.pv_a_cap_w) > 0
|
||||
and setpoints_now.pv_a_allowed_w is not None
|
||||
):
|
||||
registers.append((340, "max_solar_power_w", int(setpoints_now.pv_a_allowed_w)))
|
||||
|
||||
try:
|
||||
mb178 = await get_modbus_client(inv.host, inv.port)
|
||||
r178 = await mb178.read_holding_registers(178, 1, unit_id)
|
||||
if not r178 or len(r178) < 1:
|
||||
raise OSError(f"reg178 read returned {len(r178) if r178 is not None else None} values")
|
||||
current_178 = int(r178[0])
|
||||
peak_bits = int(reg178_val) & int(REG178_VERIFY_MASK)
|
||||
if inv.deye_gen_microinverter_cutoff_enabled:
|
||||
want_cutoff = deye_mi_export_cutoff_want_enabled(
|
||||
gen_microinverter_cutoff_enabled=True,
|
||||
deye_gen_cutoff_enabled=bool(setpoints_now.deye_gen_cutoff_enabled),
|
||||
export_ban=bool(setpoints_now.export_ban),
|
||||
deye_mode=deye_mode,
|
||||
)
|
||||
mi_bits = REG178_MI_EXPORT_ENABLE if want_cutoff else REG178_MI_EXPORT_DISABLE
|
||||
else:
|
||||
mi_bits = int(current_178) & int(REG178_MI_EXPORT_MASK)
|
||||
|
||||
new_178 = (
|
||||
(int(current_178) & ~int(REG178_VERIFY_MASK_COMBINED))
|
||||
| int(peak_bits)
|
||||
| int(mi_bits)
|
||||
)
|
||||
registers.append((178, "control_board_special_1", int(new_178)))
|
||||
logger.info(
|
||||
"[control] %s: reg178 (control_board_special_1) old=%s new=%s peak_bits=0x%04X mi_bits=%s",
|
||||
inv.code,
|
||||
current_178,
|
||||
new_178,
|
||||
int(peak_bits),
|
||||
int(mi_bits),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[control] %s: reg178 RMW failed (skip reg178 write): %s", inv.code, e)
|
||||
|
||||
logger.info(
|
||||
"[control] %s: deye_mode=%s charge=%sA discharge=%sA "
|
||||
"reg142=%s reg145=%s export=%sW "
|
||||
"tp1=%s tp2=%s soc=%s%% (batt=%r grid=%sW)",
|
||||
inv.code,
|
||||
deye_mode,
|
||||
charge_a,
|
||||
discharge_a,
|
||||
selling_mode,
|
||||
solar_sell,
|
||||
export_limit,
|
||||
hh_cur,
|
||||
hh_nxt,
|
||||
soc_telemetry,
|
||||
raw_bat,
|
||||
grid_w,
|
||||
)
|
||||
|
||||
last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db)
|
||||
registers, skipped_unchanged = _drop_registers_matching_last_verified(
|
||||
registers, last_verified
|
||||
)
|
||||
if skipped_unchanged:
|
||||
logger.info(
|
||||
"[control] %s: skip %s registers (value equals last verified): %s",
|
||||
inv.code,
|
||||
len(skipped_unchanged),
|
||||
skipped_unchanged[:24],
|
||||
)
|
||||
if not registers:
|
||||
logger.info(
|
||||
"[control] %s: all Deye holding regs match last verified, no Modbus write",
|
||||
inv.code,
|
||||
)
|
||||
if need_inactive_tou:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_tou_inactive_write_prague_date = $1,
|
||||
deye_tou_inactive_signature = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
prague_date,
|
||||
inactive_sig,
|
||||
inv.id,
|
||||
)
|
||||
return (
|
||||
f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)"
|
||||
)
|
||||
|
||||
will_write_inactive = any(
|
||||
int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers
|
||||
)
|
||||
|
||||
cmd_ids = await create_modbus_commands(
|
||||
site_id,
|
||||
planning_run_id,
|
||||
"inverter",
|
||||
inv.id,
|
||||
inv.code,
|
||||
inv.host,
|
||||
inv.port,
|
||||
inv.unit_id,
|
||||
registers,
|
||||
db,
|
||||
deye_physical_mode=deye_mode,
|
||||
)
|
||||
if not await execute_modbus_commands(cmd_ids, db):
|
||||
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
|
||||
logger.info("[control] Inverter %s journal write OK", inv.code)
|
||||
|
||||
will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers)
|
||||
if will_write_time:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1,
|
||||
deye_last_system_time_sync_at = now()
|
||||
WHERE id = $2
|
||||
""",
|
||||
_prague_minute_start_utc(),
|
||||
inv.id,
|
||||
)
|
||||
|
||||
if need_inactive_tou or will_write_inactive:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_tou_inactive_write_prague_date = $1,
|
||||
deye_tou_inactive_signature = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
prague_date,
|
||||
inactive_sig,
|
||||
inv.id,
|
||||
)
|
||||
except Exception as e:
|
||||
return f"FAIL inverter: {inv.code}: {e}"
|
||||
|
||||
return (
|
||||
f"OK inverter: batt_w={raw_bat!r} "
|
||||
f"(time points + FC 0x10: 108/109/141/142/178/143/145/340 dle plánu)"
|
||||
)
|
||||
|
||||
|
||||
async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]:
|
||||
"""
|
||||
Živé čtení holding registrů Deye 108, 109, 141-145, 178, 191 a volitelně 340.
|
||||
"""
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
raise ValueError("no controllable Modbus inverter for site")
|
||||
|
||||
uid = int(inv.unit_id)
|
||||
client = await get_modbus_client(inv.host, inv.port)
|
||||
read_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with client.batch(uid) as mb:
|
||||
b108 = await mb.read_holding_registers(108, 2)
|
||||
b141 = await mb.read_holding_registers(141, 5)
|
||||
r178 = await mb.read_holding_registers(178, 1)
|
||||
r191 = await mb.read_holding_registers(191, 1)
|
||||
if inv.deye_reg340_pv_a_control_enabled:
|
||||
r340 = await mb.read_holding_registers(340, 1)
|
||||
else:
|
||||
r340 = None
|
||||
r108, r109 = b108[0], b108[1]
|
||||
r141, r142, r143 = b141[0], b141[1], b141[2]
|
||||
r145 = b141[4]
|
||||
r178 = r178[0]
|
||||
r191 = r191[0]
|
||||
r340v = int(r340[0]) if r340 is not None and len(r340) >= 1 else None
|
||||
except Exception:
|
||||
logger.exception("read_deye_registers_live site=%s failed", site_id)
|
||||
raise
|
||||
|
||||
return {
|
||||
"reg108_charge_a": int(r108),
|
||||
"reg109_discharge_a": int(r109),
|
||||
"reg141_energy_mode": int(r141),
|
||||
"reg142_limit_control": int(r142),
|
||||
"reg143_export_limit_w": int(r143),
|
||||
"reg145_solar_sell": int(r145),
|
||||
"reg178_peak_shaving_switch": int(r178),
|
||||
"reg178_control_board_special_1": int(r178),
|
||||
"reg178_mi_export_cutoff_bits": int(r178) & int(REG178_MI_EXPORT_MASK),
|
||||
"reg178_mi_export_cutoff_is_on": (int(r178) & int(REG178_MI_EXPORT_MASK))
|
||||
== int(REG178_MI_EXPORT_ENABLE),
|
||||
"reg191_peak_shaving_w": int(r191),
|
||||
"reg340_max_solar_power_w": r340v,
|
||||
"read_at": read_at.isoformat(),
|
||||
}
|
||||
349
backend/services/control/modbus_journal.py
Normal file
349
backend/services/control/modbus_journal.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""Modbus command journal helpers pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.deye_helpers import DEYE_REGISTER_NAMES, _deye_reg178_verify_match
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _fetch_written_deye_clock_commands(
|
||||
site_id: int,
|
||||
asset_id: int,
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
db: asyncpg.Connection,
|
||||
) -> list[asyncpg.Record]:
|
||||
"""Všechny řádky journalu 62-64 ve stavu written pro daný invertor/endpoint."""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT * FROM ems.modbus_command
|
||||
WHERE site_id = $1
|
||||
AND asset_type = 'inverter'
|
||||
AND asset_id = $2
|
||||
AND device_host = $3
|
||||
AND device_port = $4
|
||||
AND device_unit_id = $5
|
||||
AND register IN (62, 63, 64)
|
||||
AND status = 'written'
|
||||
ORDER BY register
|
||||
""",
|
||||
site_id,
|
||||
asset_id,
|
||||
host,
|
||||
port,
|
||||
unit_id,
|
||||
)
|
||||
return list(rows)
|
||||
|
||||
|
||||
async def _fetch_last_verified_registers(
|
||||
site_id: int,
|
||||
asset_id: int,
|
||||
db: asyncpg.Connection,
|
||||
*,
|
||||
asset_type: str = "inverter",
|
||||
) -> dict[int, int]:
|
||||
"""
|
||||
Poslední hodnota na zařízení podle journalu (jen status verified).
|
||||
Slouží k přeskočení duplicitního zápisu stejné hodnoty.
|
||||
"""
|
||||
raw = await db.fetchval(
|
||||
"""
|
||||
select ems.fn_modbus_last_verified_map($1::int, $2::int, $3::text)
|
||||
""",
|
||||
site_id,
|
||||
asset_id,
|
||||
asset_type,
|
||||
)
|
||||
data = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
return {int(k): int(v) for k, v in data.items()}
|
||||
|
||||
|
||||
async def _fetch_device_state_registers(
|
||||
site_id: int,
|
||||
asset_id: int,
|
||||
db: asyncpg.Connection,
|
||||
*,
|
||||
asset_type: str,
|
||||
) -> dict[int, int]:
|
||||
"""
|
||||
Poslední známá hodnota na zařízení podle journalu — NEJNOVĚJŠÍ řádek per
|
||||
registr, hodnota jen pro status 'verified' nebo 'written' (zápis prošel,
|
||||
verify ještě nemusel doběhnout). Novější failed/mismatch => registr chybí
|
||||
=> volající zapíše znovu (obnova konfigurace po výpadku zařízení).
|
||||
|
||||
Pro write-on-change u EV wallboxů (EEPROM wear): na rozdíl od
|
||||
_fetch_last_verified_registers nevyžaduje úspěšný verify, takže se zápis
|
||||
neopakuje každý export tick, když verify čtení zaostává nebo selhává.
|
||||
"""
|
||||
raw = await db.fetchval(
|
||||
"""
|
||||
select ems.fn_modbus_device_state_map($1::int, $2::int, $3::text)
|
||||
""",
|
||||
site_id,
|
||||
asset_id,
|
||||
asset_type,
|
||||
)
|
||||
data = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
return {int(k): int(v) for k, v in data.items()}
|
||||
|
||||
|
||||
async def _fetch_last_verified_inverter_registers(
|
||||
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
|
||||
) -> dict[int, int]:
|
||||
"""Zpětně kompatibilní alias (Deye cesty)."""
|
||||
return await _fetch_last_verified_registers(
|
||||
site_id, inverter_asset_id, db, asset_type="inverter"
|
||||
)
|
||||
|
||||
|
||||
def _drop_registers_matching_last_verified(
|
||||
registers: list[tuple[int, str, int]],
|
||||
last_verified: dict[int, int],
|
||||
) -> tuple[list[tuple[int, str, int]], list[int]]:
|
||||
"""Vynechá položky s hodnotou shodnou s posledním ověřeným stavem."""
|
||||
out: list[tuple[int, str, int]] = []
|
||||
skipped: list[int] = []
|
||||
for reg, meta, val in registers:
|
||||
lv = last_verified.get(int(reg))
|
||||
if lv is not None:
|
||||
if int(reg) == 178 and _deye_reg178_verify_match(int(val), int(lv)):
|
||||
skipped.append(int(reg))
|
||||
continue
|
||||
if int(lv) == int(val):
|
||||
skipped.append(int(reg))
|
||||
continue
|
||||
out.append((reg, meta, val))
|
||||
return out, skipped
|
||||
|
||||
|
||||
async def create_modbus_commands(
|
||||
site_id: int,
|
||||
planning_run_id: int | None,
|
||||
asset_type: str,
|
||||
asset_id: int,
|
||||
asset_code: str,
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
registers: list[tuple[int, str, int]],
|
||||
db: asyncpg.Connection,
|
||||
deye_physical_mode: str | None = None,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Vytvoří záznamy v modbus_command pro sadu zápisů.
|
||||
Vrátí list command IDs.
|
||||
"""
|
||||
ids: list[int] = []
|
||||
for reg, given_name, val in registers:
|
||||
# Deye registry mají kanonická jména; pro ostatní zařízení (Teltonika…)
|
||||
# platí jméno dodané volajícím.
|
||||
register_name = (
|
||||
DEYE_REGISTER_NAMES.get(reg)
|
||||
if asset_type == "inverter"
|
||||
else None
|
||||
) or given_name or f"reg_{reg}"
|
||||
cmd_id = await db.fetchval(
|
||||
"""
|
||||
INSERT INTO ems.modbus_command
|
||||
(site_id, asset_type, asset_id, asset_code,
|
||||
device_host, device_port, device_unit_id,
|
||||
register, register_name, value_to_write,
|
||||
planning_run_id, status, deye_physical_mode)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12)
|
||||
RETURNING id
|
||||
""",
|
||||
site_id,
|
||||
asset_type,
|
||||
asset_id,
|
||||
asset_code,
|
||||
host,
|
||||
port,
|
||||
unit_id,
|
||||
reg,
|
||||
register_name,
|
||||
val,
|
||||
planning_run_id,
|
||||
deye_physical_mode,
|
||||
)
|
||||
if cmd_id is not None:
|
||||
ids.append(int(cmd_id))
|
||||
return ids
|
||||
|
||||
|
||||
def _modbus_command_contiguous_runs(cmds: list[asyncpg.Record]) -> list[list[asyncpg.Record]]:
|
||||
"""Seřadí podle adresy registru a rozdělí na souvislé úseky pro FC 0x10 / FC 3."""
|
||||
if not cmds:
|
||||
return []
|
||||
sorted_cmds = sorted(cmds, key=lambda c: int(c["register"]))
|
||||
runs: list[list[asyncpg.Record]] = []
|
||||
cur: list[asyncpg.Record] = [sorted_cmds[0]]
|
||||
for c in sorted_cmds[1:]:
|
||||
if int(c["register"]) == int(cur[-1]["register"]) + 1:
|
||||
cur.append(c)
|
||||
else:
|
||||
runs.append(cur)
|
||||
cur = [c]
|
||||
runs.append(cur)
|
||||
return runs
|
||||
|
||||
|
||||
def _modbus_error_text(e: BaseException) -> str:
|
||||
"""Text chyby pro error_msg — nikdy prázdný (TimeoutError() apod. má str '')."""
|
||||
return str(e).strip() or repr(e)
|
||||
|
||||
|
||||
async def _mark_commands_failed(
|
||||
db: asyncpg.Connection, cmd_ids: list[int], error_msg: str
|
||||
) -> None:
|
||||
for cid in cmd_ids:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='failed', error_msg=$1,
|
||||
attempt_count=attempt_count+1
|
||||
WHERE id=$2
|
||||
""",
|
||||
error_msg,
|
||||
cid,
|
||||
)
|
||||
|
||||
|
||||
async def execute_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
) -> bool:
|
||||
"""
|
||||
Zapíše příkazy z modbus_command do zařízení (FC 0x10 po souvislých blocích).
|
||||
Aktualizuje status na 'written' nebo 'failed'.
|
||||
|
||||
Invariant: žádný z předaných příkazů nesmí zůstat 'pending' — i při
|
||||
CancelledError / GatewayLockTimeout / chybě DB se zbylé řádky označí
|
||||
failed s neprázdným error_msg (safety net níže) a výjimka se propaguje.
|
||||
"""
|
||||
max_retries = 3
|
||||
retry_delay = 0.5
|
||||
|
||||
rows: list[asyncpg.Record] = []
|
||||
for cmd_id in command_ids:
|
||||
cmd = await db.fetchrow(
|
||||
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
if cmd is not None:
|
||||
rows.append(cmd)
|
||||
|
||||
if not rows:
|
||||
return True
|
||||
|
||||
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
|
||||
for cmd in rows:
|
||||
by_gw[
|
||||
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
|
||||
].append(cmd)
|
||||
|
||||
#: Ještě nerozhodnuté příkazy (pro safety net při výjimce mimo retry cyklus).
|
||||
unresolved: set[int] = {int(c["id"]) for c in rows}
|
||||
|
||||
all_ok = True
|
||||
try:
|
||||
for (host, port, unit), group in by_gw.items():
|
||||
client = await get_modbus_client(host, port)
|
||||
for run in _modbus_command_contiguous_runs(group):
|
||||
start_reg = int(run[0]["register"])
|
||||
values = [int(c["value_to_write"]) for c in run]
|
||||
write_err: Exception | None = None
|
||||
attempts_used = 0
|
||||
for attempt in range(max_retries):
|
||||
attempts_used = attempt + 1
|
||||
try:
|
||||
await client.write_registers(start_reg, values, unit)
|
||||
write_err = None
|
||||
break
|
||||
except Exception as e:
|
||||
write_err = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
"Modbus batch write 0x%04X count=%s attempt %s failed: %s, retrying...",
|
||||
start_reg,
|
||||
len(values),
|
||||
attempt + 1,
|
||||
_modbus_error_text(e),
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
try:
|
||||
await client.force_disconnect()
|
||||
except Exception as de:
|
||||
logger.warning(
|
||||
"Modbus force_disconnect %s:%s failed: %s",
|
||||
host,
|
||||
port,
|
||||
_modbus_error_text(de),
|
||||
)
|
||||
|
||||
if write_err is not None:
|
||||
err = _modbus_error_text(write_err)
|
||||
await _mark_commands_failed(db, [int(c["id"]) for c in run], err)
|
||||
for c in run:
|
||||
unresolved.discard(int(c["id"]))
|
||||
logger.error(
|
||||
"Modbus batch 0x%04X count=%s all %s attempts failed: %s",
|
||||
start_reg,
|
||||
len(values),
|
||||
max_retries,
|
||||
err,
|
||||
)
|
||||
all_ok = False
|
||||
continue
|
||||
|
||||
# Journal update mimo retry cyklus — chyba DB nesmí vyvolat
|
||||
# další zápis do zařízení; spadne do safety netu níže.
|
||||
for cmd, val in zip(run, values):
|
||||
cid = int(cmd["id"])
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='written', value_written=$1, written_at=now(),
|
||||
attempt_count=attempt_count+1, error_msg=NULL
|
||||
WHERE id=$2
|
||||
""",
|
||||
val,
|
||||
cid,
|
||||
)
|
||||
unresolved.discard(cid)
|
||||
logger.info(
|
||||
"[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)",
|
||||
cid,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
val,
|
||||
start_reg,
|
||||
attempts_used,
|
||||
)
|
||||
except BaseException as e:
|
||||
# Safety net: CancelledError (shutdown / zrušený task), GatewayLockTimeout
|
||||
# propadlý mimo retry cyklus, chyba DB v success větvi, … — nic nesmí
|
||||
# zůstat 'pending'. Best effort: označit a výjimku propagovat dál.
|
||||
err = f"execute aborted: {_modbus_error_text(e)}"
|
||||
try:
|
||||
await _mark_commands_failed(db, sorted(unresolved), err)
|
||||
except Exception as me:
|
||||
logger.error(
|
||||
"Modbus journal: nelze označit %s příkazů failed (%s): %s",
|
||||
len(unresolved),
|
||||
err,
|
||||
_modbus_error_text(me),
|
||||
)
|
||||
logger.error("execute_modbus_commands aborted: %s", err)
|
||||
raise
|
||||
|
||||
return all_ok
|
||||
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
|
||||
)
|
||||
340
backend/services/control/outputs.py
Normal file
340
backend/services/control/outputs.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""Non-Deye output writers for control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from services.control.models import ControlSetpoints, OperatingModeInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Teltonika TeltoCharge – zápisové registry (oficiální protokol rev 0.5;
|
||||
# docs/04-modules/modbus-registers-teltocharge.md). FC 16 přes journal.
|
||||
TELTO_REG_AMPS_TO_USE = 15 # 0 = stop, 6–32 A
|
||||
TELTO_REG_COMM_TIMEOUT_S = 19 # watchdog: bez komunikace → failsafe
|
||||
TELTO_REG_FAILSAFE_CURRENT_A = 20
|
||||
#: Výpadek EMS: po watchdog_comm_timeout_s bez komunikace wallbox přejde na
|
||||
#: failsafe proud — auto se přes noc nabije i bez EMS (pomalu), místo aby
|
||||
#: stálo na 0 A. Defaulty (fallback, když řádek chargeru nemá vlastní hodnoty).
|
||||
TELTO_WATCHDOG_TIMEOUT_S = 300
|
||||
TELTO_WATCHDOG_FAILSAFE_A = 8
|
||||
|
||||
|
||||
def _telto_setpoint_registers(
|
||||
current_a: int,
|
||||
*,
|
||||
comm_timeout_s: int = TELTO_WATCHDOG_TIMEOUT_S,
|
||||
failsafe_a: int = TELTO_WATCHDOG_FAILSAFE_A,
|
||||
) -> list[tuple[int, str, int]]:
|
||||
"""Registry pro jeden export tick: limit proudu + watchdog konfigurace.
|
||||
|
||||
**Reg 15 (amps to use) NENÍ write-on-change** — viz `_assert_amps_register`.
|
||||
Je to volatilní řídicí registr: TeltoCharge ho po výpadku komunikace sám
|
||||
přepíše na failsafe (reg 20), aniž by o tom vznikl journal řádek. Kdyby se
|
||||
reg 15 dropoval proti journalu (poslední „0 verified"), EMS by tichý drift
|
||||
0 → 8 A NIKDY nezahlédlo (verify čte zpět jen `written` řádky) a nikdy ho
|
||||
neopravilo. Proto se reg 15 re-asertuje KAŽDÝ export tick (≤ 8×/hod) —
|
||||
EEPROM wear se týká jen konfiguračních 19/20, které write-on-change zůstávají.
|
||||
|
||||
Watchdog timer TeltoCharge sytí jakákoli validní Modbus komunikace (i FC3
|
||||
čtení telemetrie každých 60 s), takže periodické zápisy k udržení spojení
|
||||
NEJSOU potřeba; failsafe/timeout (19/20) per charger z DB.
|
||||
"""
|
||||
a = int(current_a)
|
||||
if a < 6:
|
||||
a = 0
|
||||
return [
|
||||
(TELTO_REG_AMPS_TO_USE, "telto_amps_to_use", min(a, 32)),
|
||||
(TELTO_REG_COMM_TIMEOUT_S, "telto_comm_timeout_s", int(comm_timeout_s)),
|
||||
(TELTO_REG_FAILSAFE_CURRENT_A, "telto_failsafe_a", max(0, min(int(failsafe_a), 32))),
|
||||
]
|
||||
|
||||
|
||||
def _split_amps_and_watchdog(
|
||||
registers: list[tuple[int, str, int]],
|
||||
) -> tuple[list[tuple[int, str, int]], list[tuple[int, str, int]]]:
|
||||
"""Rozdělí registry na (reg 15 = vždy zapsat) a (19/20 = write-on-change)."""
|
||||
amps = [r for r in registers if r[0] == TELTO_REG_AMPS_TO_USE]
|
||||
watchdog = [r for r in registers if r[0] != TELTO_REG_AMPS_TO_USE]
|
||||
return amps, watchdog
|
||||
|
||||
|
||||
def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
|
||||
c = (charger_code or "").strip().lower()
|
||||
if c == "ev-charger-1":
|
||||
a = sp.ev1_current_a
|
||||
elif c == "ev-charger-2":
|
||||
a = sp.ev2_current_a
|
||||
elif c.endswith("-1") or c == "ev1":
|
||||
a = sp.ev1_current_a
|
||||
elif c.endswith("-2") or c == "ev2":
|
||||
a = sp.ev2_current_a
|
||||
else:
|
||||
a = 0
|
||||
if a < 6:
|
||||
a = 0
|
||||
return a
|
||||
|
||||
|
||||
async def write_ev_setpoints(
|
||||
site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection
|
||||
) -> str:
|
||||
from services.control.modbus_journal import (
|
||||
_drop_registers_matching_last_verified,
|
||||
_fetch_device_state_registers,
|
||||
create_modbus_commands,
|
||||
execute_modbus_commands,
|
||||
)
|
||||
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id,
|
||||
ec.watchdog_failsafe_a, ec.watchdog_comm_timeout_s
|
||||
FROM ems.asset_ev_charger ec
|
||||
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||
WHERE ec.site_id = $1
|
||||
AND ec.schedulable = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
ORDER BY ec.code
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not rows:
|
||||
return "OK EV: no schedulable chargers"
|
||||
|
||||
written = 0
|
||||
for row in rows:
|
||||
code = row["code"]
|
||||
asset_id = int(row["asset_id"])
|
||||
host = str(row["host"])
|
||||
port = int(row["port"] or 502)
|
||||
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||
current_a = _current_limit_for_charger(code, setpoints)
|
||||
|
||||
registers = _telto_setpoint_registers(
|
||||
current_a,
|
||||
comm_timeout_s=int(
|
||||
row["watchdog_comm_timeout_s"]
|
||||
if row["watchdog_comm_timeout_s"] is not None
|
||||
else TELTO_WATCHDOG_TIMEOUT_S
|
||||
),
|
||||
failsafe_a=int(
|
||||
row["watchdog_failsafe_a"]
|
||||
if row["watchdog_failsafe_a"] is not None
|
||||
else TELTO_WATCHDOG_FAILSAFE_A
|
||||
),
|
||||
)
|
||||
amps_regs, watchdog_regs = _split_amps_and_watchdog(registers)
|
||||
# Reg 15 = vždy (re-asert proti tichému watchdog failsafe driftu na
|
||||
# zařízení, který nemá journal řádek). Reg 19/20 = write-on-change
|
||||
# proti fn_modbus_device_state_map (poslední written/verified stav).
|
||||
device_state = await _fetch_device_state_registers(
|
||||
site_id, asset_id, db, asset_type="ev_charger"
|
||||
)
|
||||
watchdog_regs, skipped = _drop_registers_matching_last_verified(
|
||||
watchdog_regs, device_state
|
||||
)
|
||||
to_write = amps_regs + watchdog_regs
|
||||
if not to_write:
|
||||
logger.debug("EV setpoint [%s]: beze změny (%s A)", code, current_a)
|
||||
continue
|
||||
|
||||
cmd_ids = await create_modbus_commands(
|
||||
site_id,
|
||||
None,
|
||||
"ev_charger",
|
||||
asset_id,
|
||||
code,
|
||||
host,
|
||||
port,
|
||||
unit_id,
|
||||
to_write,
|
||||
db,
|
||||
)
|
||||
ok = await execute_modbus_commands(cmd_ids, db)
|
||||
written += 1
|
||||
logger.info(
|
||||
"EV setpoint [%s]: %s A (regs %s%s) -> %s",
|
||||
code,
|
||||
current_a,
|
||||
[r for r, _, _ in to_write],
|
||||
f", skip {skipped}" if skipped else "",
|
||||
"written" if ok else "FAILED",
|
||||
)
|
||||
return f"OK EV: {written}/{len(rows)} charger(s) written"
|
||||
|
||||
|
||||
async def write_ev_arrival_hold(
|
||||
site_id: int, charger_code: str, db: asyncpg.Connection
|
||||
) -> bool:
|
||||
"""Okamžitě po DETEKCI příjezdu zapsat 0 A na daný wallbox (přes journal).
|
||||
|
||||
TeltoCharge po připojení kabelu sám rozjede nabíjení svým defaultem —
|
||||
nabíjet smí až PLÁN (replan + export běží hned poté v _on_ev_arrival,
|
||||
takže držení trvá sekundy až ~1 min). Write-on-change: registry shodné
|
||||
s posledním written/verified stavem (typicky watchdog 19/20, často
|
||||
i 15=0) se přeskočí — žádný zbytečný zápis při každém píchnutí kabelu.
|
||||
"""
|
||||
from services.control.modbus_journal import (
|
||||
_drop_registers_matching_last_verified,
|
||||
_fetch_device_state_registers,
|
||||
create_modbus_commands,
|
||||
execute_modbus_commands,
|
||||
)
|
||||
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id,
|
||||
ec.watchdog_failsafe_a, ec.watchdog_comm_timeout_s
|
||||
FROM ems.asset_ev_charger ec
|
||||
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||
WHERE ec.site_id = $1
|
||||
AND ec.code = $2
|
||||
AND ec.schedulable = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
""",
|
||||
site_id,
|
||||
charger_code,
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
asset_id = int(row["asset_id"])
|
||||
registers = _telto_setpoint_registers(
|
||||
0,
|
||||
comm_timeout_s=int(
|
||||
row["watchdog_comm_timeout_s"]
|
||||
if row["watchdog_comm_timeout_s"] is not None
|
||||
else TELTO_WATCHDOG_TIMEOUT_S
|
||||
),
|
||||
failsafe_a=int(
|
||||
row["watchdog_failsafe_a"]
|
||||
if row["watchdog_failsafe_a"] is not None
|
||||
else TELTO_WATCHDOG_FAILSAFE_A
|
||||
),
|
||||
)
|
||||
amps_regs, watchdog_regs = _split_amps_and_watchdog(registers)
|
||||
# Reg 15 = 0 A se zapíše VŽDY (tvrdé zastavení po píchnutí kabelu; wallbox
|
||||
# po připojení sám rozjíždí nabíjení defaultem). Reg 19/20 write-on-change.
|
||||
device_state = await _fetch_device_state_registers(
|
||||
site_id, asset_id, db, asset_type="ev_charger"
|
||||
)
|
||||
watchdog_regs, skipped = _drop_registers_matching_last_verified(
|
||||
watchdog_regs, device_state
|
||||
)
|
||||
to_write = amps_regs + watchdog_regs
|
||||
cmd_ids = await create_modbus_commands(
|
||||
site_id,
|
||||
None,
|
||||
"ev_charger",
|
||||
asset_id,
|
||||
str(row["code"]),
|
||||
str(row["host"]),
|
||||
int(row["port"] or 502),
|
||||
int(row["unit_id"] if row["unit_id"] is not None else 1),
|
||||
to_write,
|
||||
db,
|
||||
)
|
||||
ok = await execute_modbus_commands(cmd_ids, db)
|
||||
logger.info(
|
||||
"EV arrival hold [%s]: 0 A (regs %s%s) %s",
|
||||
charger_code,
|
||||
[r for r, _, _ in to_write],
|
||||
f", skip {skipped}" if skipped else "",
|
||||
"written" if ok else "FAILED",
|
||||
)
|
||||
return bool(ok)
|
||||
|
||||
|
||||
async def write_heat_pump_setpoint(
|
||||
site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection
|
||||
) -> str:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT hp.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_heat_pump hp
|
||||
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||||
WHERE hp.site_id = $1
|
||||
AND hp.schedulable = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not rows:
|
||||
return "OK heat pump: no schedulable unit"
|
||||
for row in rows:
|
||||
logger.info(
|
||||
"HP setpoint [%s]: enable=%s (TODO: Modbus registers)",
|
||||
row["code"],
|
||||
setpoints.heat_pump_enable,
|
||||
)
|
||||
return "OK heat pump: logged (Modbus TODO)"
|
||||
|
||||
|
||||
async def send_loxone_setpoints(
|
||||
site_id: int,
|
||||
setpoints: ControlSetpoints,
|
||||
mode: OperatingModeInfo,
|
||||
db: asyncpg.Connection,
|
||||
) -> str:
|
||||
endpoint = await db.fetchrow(
|
||||
"""
|
||||
SELECT host, port, protocol
|
||||
FROM ems.site_endpoint
|
||||
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not endpoint:
|
||||
return "OK Loxone: no endpoint, skipped"
|
||||
|
||||
proto = (endpoint["protocol"] or "http").lower()
|
||||
if proto not in ("http", "https"):
|
||||
proto = "http"
|
||||
host = endpoint["host"]
|
||||
port = int(endpoint["port"] or (443 if proto == "https" else 80))
|
||||
base = f"{proto}://{host}:{port}/dev/sps/io"
|
||||
|
||||
settings = get_settings()
|
||||
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
|
||||
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
|
||||
auth = (user, password) if user else None
|
||||
|
||||
batt_display = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
|
||||
|
||||
paths: list[tuple[str, int]] = [
|
||||
(f"{base}/EMS_Mode/{mode.loxone_mode_value}", mode.loxone_mode_value),
|
||||
(f"{base}/EMS_Battery_Setpoint_W/{batt_display}", batt_display),
|
||||
(f"{base}/EMS_Grid_Setpoint_W/{setpoints.grid_setpoint_w}", setpoints.grid_setpoint_w),
|
||||
(f"{base}/EMS_EV1_Power_W/{setpoints.ev1_power_w}", setpoints.ev1_power_w),
|
||||
(f"{base}/EMS_EV2_Power_W/{setpoints.ev2_power_w}", setpoints.ev2_power_w),
|
||||
(
|
||||
f"{base}/EMS_HeatPump_Enable/{1 if setpoints.heat_pump_enable else 0}",
|
||||
1 if setpoints.heat_pump_enable else 0,
|
||||
),
|
||||
]
|
||||
|
||||
errs: list[str] = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
for url, _ in paths:
|
||||
try:
|
||||
r = await client.get(url, auth=auth)
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
errs.append(f"{url!s}: {e}")
|
||||
except Exception as e:
|
||||
return f"FAIL Loxone: client {e}"
|
||||
|
||||
if errs:
|
||||
return "FAIL Loxone: " + "; ".join(errs[:3])
|
||||
return "OK Loxone: all virtual inputs updated"
|
||||
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)
|
||||
539
backend/services/control/setpoints.py
Normal file
539
backend/services/control/setpoints.py
Normal file
@@ -0,0 +1,539 @@
|
||||
"""Výpočet control setpointů a Deye TOU parametrů."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from services.control.deye_helpers import (
|
||||
BATT_VOLTAGE_V,
|
||||
PRAGUE_TZ,
|
||||
battery_watts_to_amps,
|
||||
compute_pv_a_reg340_max_solar_w,
|
||||
watts_to_amps,
|
||||
)
|
||||
from services.control.models import ControlSetpoints, InverterConfig, OperatingModeInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Tolerance pod max SoC, v rámci níž se v PV přebytku nechá baterka dojet na max
|
||||
#: (reg 108 = max) kvůli BMS rekalibraci SoC (LiFePO4 potřebuje občas na 100 %).
|
||||
BATTERY_CALIB_TOPOFF_MARGIN_PCT = 3.0
|
||||
|
||||
|
||||
def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]:
|
||||
"""Hodnoty pro reg 62-64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis)."""
|
||||
now = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
|
||||
reg62 = ((now.year - 2000) << 8) | now.month
|
||||
reg63 = (now.day << 8) | now.hour
|
||||
reg64 = (now.minute << 8) | 0
|
||||
rows = [
|
||||
(62, "", reg62),
|
||||
(63, "", reg63),
|
||||
(64, "", reg64),
|
||||
]
|
||||
return now, rows
|
||||
|
||||
|
||||
def _deye_time_point_rows(
|
||||
slot_index: int,
|
||||
time_hhmm: int,
|
||||
power_w: int,
|
||||
soc_pct: int,
|
||||
grid_charge: bool,
|
||||
) -> list[tuple[int, str, int]]:
|
||||
g = 1 if grid_charge else 0
|
||||
return [
|
||||
(148 + slot_index, "", time_hhmm),
|
||||
(154 + slot_index, "", power_w),
|
||||
(166 + slot_index, "", soc_pct),
|
||||
(172 + slot_index, "", g),
|
||||
]
|
||||
|
||||
|
||||
class _DictRecord:
|
||||
"""Minimální asyncpg Record kompatibilita pro dict z jsonb."""
|
||||
|
||||
__slots__ = ("_d",)
|
||||
|
||||
def __init__(self, d: dict[str, Any]) -> None:
|
||||
self._d = d
|
||||
|
||||
def __getitem__(self, k: str) -> Any:
|
||||
return self._d[k]
|
||||
|
||||
def get(self, k: str, default: Any = None) -> Any:
|
||||
return self._d.get(k, default)
|
||||
|
||||
def __contains__(self, k: str) -> bool:
|
||||
return k in self._d
|
||||
|
||||
|
||||
def plan_skips_deye_reg340_write(
|
||||
*,
|
||||
battery_setpoint_w: int,
|
||||
grid_setpoint_w: int,
|
||||
export_mode: str | None,
|
||||
export_limit_w: int,
|
||||
pv_a_curtailed_w: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Nezapisovat reg 340: plán neexportuje, nenabíjí baterii a neškrtí pole A.
|
||||
Deye sám řídí PV A přes 108/109/142 (zero export + 0 A nabíjení).
|
||||
"""
|
||||
em = (export_mode or "").strip().upper()
|
||||
if em == "NONE":
|
||||
no_export = True
|
||||
elif int(grid_setpoint_w) < 0 or int(export_limit_w) > 0:
|
||||
no_export = False
|
||||
else:
|
||||
no_export = True
|
||||
return (
|
||||
no_export
|
||||
and int(battery_setpoint_w) <= 0
|
||||
and int(pv_a_curtailed_w) <= 0
|
||||
)
|
||||
|
||||
|
||||
def _build_setpoints(
|
||||
mode: OperatingModeInfo,
|
||||
pi: Any | None,
|
||||
*,
|
||||
pv_a_cap_w: int = 0,
|
||||
pv_a_reg340_min_w: int = 0,
|
||||
reg340_pv_a_control_enabled: bool = False,
|
||||
) -> ControlSetpoints | None:
|
||||
code = mode.mode_code
|
||||
if code == "MANUAL":
|
||||
return None
|
||||
|
||||
if code == "AUTO":
|
||||
if pi is None:
|
||||
return None
|
||||
grid_sp = int(pi["grid_setpoint_w"] or 0)
|
||||
export_limit_raw = pi.get("export_limit_w")
|
||||
export_limit = int(export_limit_raw) if export_limit_raw is not None else abs(min(grid_sp, 0))
|
||||
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
|
||||
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
|
||||
hp_en = bool(pi["heat_pump_enabled"])
|
||||
tgt = pi["battery_soc_target_pct"]
|
||||
target_soc = int(round(float(tgt))) if tgt is not None else None
|
||||
pm_raw = pi.get("deye_physical_mode")
|
||||
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
|
||||
export_mode_raw = pi.get("export_mode")
|
||||
export_mode = str(export_mode_raw).strip().upper() if export_mode_raw is not None else None
|
||||
if export_mode == "NONE":
|
||||
export_limit = 0
|
||||
elif export_limit <= 0 and grid_sp < 0:
|
||||
export_limit = abs(grid_sp)
|
||||
bat_w = int(pi["battery_setpoint_w"] or 0)
|
||||
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá.
|
||||
# A nesmí blokovat ani IMPORT na nabití baterie (CHARGE / grid>0 & bat>0) —
|
||||
# jinak MI cut-off (178) / 145=0 zbytečně odstaví pole B a Deye nenabije
|
||||
# ze sítě v záporných cenách (bug 2026-06-13). §6 blokuje jen export.
|
||||
is_grid_charge = pm == "CHARGE" or (grid_sp > 0 and bat_w > 0)
|
||||
export_ban = (
|
||||
sell_f is not None
|
||||
and float(sell_f) < 0
|
||||
and grid_sp >= 0
|
||||
and not is_grid_charge
|
||||
)
|
||||
gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled")
|
||||
gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False
|
||||
pv_a_allowed: int | None = None
|
||||
if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0:
|
||||
forecast = int(pi.get("pv_a_forecast_solver_w") or 0)
|
||||
curtail = int(pi.get("pv_a_curtailed_w") or 0)
|
||||
buy_raw = pi.get("effective_buy_price")
|
||||
buy_f: float | None = float(buy_raw) if buy_raw is not None else None
|
||||
pv_b = int(pi.get("pv_b_forecast_solver_w") or 0)
|
||||
# Záporný buy i sell + pole B: pole A = 0 MÁ PŘEDNOST před úsvitovou
|
||||
# výjimkou (při hluboce záporných cenách se reg 340 posílá vždy).
|
||||
_low_pv_no_reg340_w = 1500
|
||||
if (
|
||||
buy_f is not None
|
||||
and sell_f is not None
|
||||
and float(buy_f) < 0.0
|
||||
and float(sell_f) < 0.0
|
||||
and pv_b > 0
|
||||
):
|
||||
pv_a_allowed = 0
|
||||
elif (
|
||||
# Slabý úsvit: neposílat reg 340 — forecast nepřesný, Deye řídí sám (108/109/142).
|
||||
forecast < _low_pv_no_reg340_w
|
||||
and curtail <= 0
|
||||
and pv_b > 0
|
||||
):
|
||||
pv_a_allowed = None
|
||||
elif plan_skips_deye_reg340_write(
|
||||
battery_setpoint_w=bat_w,
|
||||
grid_setpoint_w=grid_sp,
|
||||
export_mode=export_mode,
|
||||
export_limit_w=max(0, export_limit),
|
||||
pv_a_curtailed_w=curtail,
|
||||
):
|
||||
pv_a_allowed = None
|
||||
else:
|
||||
pv_a_allowed = compute_pv_a_reg340_max_solar_w(
|
||||
int(pv_a_cap_w),
|
||||
forecast,
|
||||
curtail,
|
||||
min_w=int(pv_a_reg340_min_w),
|
||||
)
|
||||
return ControlSetpoints(
|
||||
battery_w=bat_w,
|
||||
grid_export_limit=max(0, export_limit),
|
||||
ev1_current_a=watts_to_amps(ev1_w, phases=3),
|
||||
ev2_current_a=watts_to_amps(ev2_w, phases=1),
|
||||
heat_pump_enable=hp_en,
|
||||
grid_setpoint_w=grid_sp,
|
||||
ev1_power_w=ev1_w,
|
||||
ev2_power_w=ev2_w,
|
||||
target_soc_pct=target_soc,
|
||||
deye_physical_mode=pm,
|
||||
export_mode=export_mode,
|
||||
export_ban=bool(export_ban),
|
||||
deye_gen_cutoff_enabled=bool(gen_cutoff),
|
||||
effective_sell_price_czk_kwh=sell_f,
|
||||
pv_a_allowed_w=pv_a_allowed,
|
||||
)
|
||||
|
||||
if code == "SELF_SUSTAIN":
|
||||
return ControlSetpoints(
|
||||
battery_w=None,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
self_sustain_local_use=True,
|
||||
)
|
||||
|
||||
if code == "CHARGE_CHEAP":
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
)
|
||||
|
||||
if code == "PRESERVE":
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
lock_battery=True,
|
||||
)
|
||||
|
||||
logger.warning("Unknown mode_code %s for site export, skipping", code)
|
||||
return None
|
||||
|
||||
|
||||
def _passive_no_export_guard(
|
||||
sp: ControlSetpoints, *, hard_ban: bool = True
|
||||
) -> ControlSetpoints:
|
||||
"""
|
||||
PASSIVE, žádný vývoz do sítě z plánu (143=0, grid_setpoint>=0, baterie nevybíjí do sítě).
|
||||
|
||||
``hard_ban=True`` (záporná vykupní): navíc export_ban (145=0) a MI cut-off na GEN
|
||||
portu (reg 178) — přebytek pole B NESMÍ do sítě.
|
||||
``hard_ban=False`` (kladná vykupní, plán jen nechce exportovat baterii/stringy):
|
||||
mikroinvertory NEodstavovat — jejich výroba se absorbuje do baterie/zátěže a
|
||||
případný fyzický přetok se při kladné ceně prodá (cut-off by výrobu zahodil).
|
||||
"""
|
||||
bat = int(sp.battery_w or 0)
|
||||
if bat < 0:
|
||||
bat = 0
|
||||
return ControlSetpoints(
|
||||
battery_w=bat,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=sp.ev1_current_a,
|
||||
ev2_current_a=sp.ev2_current_a,
|
||||
heat_pump_enable=sp.heat_pump_enable,
|
||||
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
|
||||
ev1_power_w=sp.ev1_power_w,
|
||||
ev2_power_w=sp.ev2_power_w,
|
||||
target_soc_pct=sp.target_soc_pct,
|
||||
deye_physical_mode="PASSIVE",
|
||||
export_mode="NONE",
|
||||
export_ban=bool(sp.export_ban) or hard_ban,
|
||||
deye_gen_cutoff_enabled=bool(sp.deye_gen_cutoff_enabled) or hard_ban,
|
||||
effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh,
|
||||
pv_a_allowed_w=sp.pv_a_allowed_w,
|
||||
lock_battery=sp.lock_battery,
|
||||
self_sustain_local_use=sp.self_sustain_local_use,
|
||||
)
|
||||
|
||||
|
||||
def _apply_export_plan_guard(
|
||||
site_id: int,
|
||||
mode: OperatingModeInfo,
|
||||
pi: Any | None,
|
||||
sp: ControlSetpoints,
|
||||
) -> ControlSetpoints:
|
||||
"""
|
||||
Exekuční pojistka: plán zakazuje vývoz (záporná vykupní nebo export_mode NONE),
|
||||
ale Deye může zůstat v SELL — vynutit PASSIVE a export_ban před zápisem Modbus.
|
||||
"""
|
||||
if mode.mode_code != "AUTO" or pi is None:
|
||||
return sp
|
||||
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
sell_f: float | None = (
|
||||
float(sell_raw) if sell_raw is not None else sp.effective_sell_price_czk_kwh
|
||||
)
|
||||
export_mode_raw = pi.get("export_mode")
|
||||
export_mode = (
|
||||
str(export_mode_raw).strip().upper()
|
||||
if export_mode_raw is not None
|
||||
else (sp.export_mode or "")
|
||||
)
|
||||
grid_sp = int(pi.get("grid_setpoint_w") or sp.grid_setpoint_w or 0)
|
||||
|
||||
# Carve-out: nabíjecí / importní slot NENÍ export. Guard řeší jen zákaz
|
||||
# exportu při sell<0 — když plán importuje na nabití baterie (CHARGE, nebo
|
||||
# grid_sp>0 & bat_sp>0), překlopení na PASSIVE by zařízlo grid charge
|
||||
# (bug 2026-06-13: baterie se nedobila v záporných cenách). §6 zakazuje
|
||||
# jen export, ne import (§7).
|
||||
pm = str(pi.get("deye_physical_mode") or "").strip().upper()
|
||||
bat_sp = int(pi.get("battery_setpoint_w") or 0)
|
||||
if pm == "CHARGE" or (grid_sp > 0 and bat_sp > 0):
|
||||
return sp
|
||||
|
||||
neg_sell = sell_f is not None and float(sell_f) < 0
|
||||
plan_no_export = export_mode == "NONE" and grid_sp >= 0
|
||||
if not neg_sell and not plan_no_export:
|
||||
return sp
|
||||
|
||||
reason = "neg_sell" if neg_sell else "export_mode_none"
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO export plan guard (%s) -> PASSIVE no-export",
|
||||
site_id,
|
||||
reason,
|
||||
)
|
||||
# MI cut-off / 145=0 jen při záporné vykupní; export_mode NONE s kladnou cenou
|
||||
# nesmí odstavit pole B (BA81 2026-06-12: cutoff při sell +1.36 → výroba MI zahozena).
|
||||
return _passive_no_export_guard(sp, hard_ban=neg_sell)
|
||||
|
||||
|
||||
def _apply_price_failsafe_guard(
|
||||
site_id: int,
|
||||
mode: OperatingModeInfo,
|
||||
pi: Any | None,
|
||||
sp: ControlSetpoints,
|
||||
) -> ControlSetpoints:
|
||||
if mode.mode_code != "AUTO" or pi is None:
|
||||
return sp
|
||||
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
|
||||
return sp
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
|
||||
site_id,
|
||||
)
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=sp.ev1_current_a,
|
||||
ev2_current_a=sp.ev2_current_a,
|
||||
heat_pump_enable=sp.heat_pump_enable,
|
||||
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
|
||||
ev1_power_w=sp.ev1_power_w,
|
||||
ev2_power_w=sp.ev2_power_w,
|
||||
target_soc_pct=sp.target_soc_pct,
|
||||
effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh,
|
||||
pv_a_allowed_w=sp.pv_a_allowed_w,
|
||||
)
|
||||
|
||||
|
||||
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
|
||||
"""Reg 143 - max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
|
||||
if no_export:
|
||||
return 0
|
||||
return max(0, int(max_export_power_w or 0))
|
||||
|
||||
|
||||
def _is_passive_pv_surplus_export(
|
||||
*,
|
||||
deye_mode: str,
|
||||
export_mode: str | None,
|
||||
export_ban: bool,
|
||||
grid_w: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Přetok FVE do sítě v PASSIVE (ne SELL z baterie): reg 142 zůstane zero-export (1/2),
|
||||
nabíjení se blokuje přes **108 = 0** — baterie nemá kam brát přebytek → jde do sítě (145).
|
||||
"""
|
||||
if deye_mode != "PASSIVE" or export_ban:
|
||||
return False
|
||||
em = (export_mode or "").strip().upper()
|
||||
if em == "PV_SURPLUS":
|
||||
return True
|
||||
if em in {"NONE", "BATTERY_SELL"}:
|
||||
return False
|
||||
return grid_w < 0
|
||||
|
||||
|
||||
def _clamp_deye_tou_soc_pct(pct: int) -> int:
|
||||
return max(5, min(95, pct))
|
||||
|
||||
|
||||
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
|
||||
if inv.min_soc_percent is not None:
|
||||
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
|
||||
return 10
|
||||
|
||||
|
||||
def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
|
||||
if inv.reserve_soc_percent is not None:
|
||||
return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent))
|
||||
return 20
|
||||
|
||||
|
||||
def _deye_passive_tou_battery_soc_pct(
|
||||
inv: InverterConfig, _setpoints: ControlSetpoints
|
||||
) -> int:
|
||||
"""Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE."""
|
||||
return _deye_tou_min_soc_pct(inv)
|
||||
|
||||
|
||||
def _deye_zero_export_amps_for_passive(
|
||||
grid_w: int,
|
||||
bat_w: int,
|
||||
max_charge_a: int,
|
||||
max_discharge_a: int,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
PASSIVE (zero export k CT/zátěži): asymetrie jen tam, kde dává smysl pro import.
|
||||
|
||||
Přetok FVE do sítě řeší větev ``_is_passive_pv_surplus_export`` (**108 = 0**). Zde jen import
|
||||
bez nabíjení → vypnout vybíjení (**109 = 0**).
|
||||
"""
|
||||
if grid_w > 0 and bat_w <= 0:
|
||||
return max_charge_a, 0
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
|
||||
|
||||
def deye_battery_charge_discharge_amps(
|
||||
*,
|
||||
lock_battery: bool,
|
||||
deye_mode: str,
|
||||
self_sustain_local_use: bool,
|
||||
bat_w: int,
|
||||
grid_w: int,
|
||||
max_charge_a: int,
|
||||
max_discharge_a: int,
|
||||
export_mode: str | None = None,
|
||||
export_ban: bool = False,
|
||||
current_soc_pct: float | None = None,
|
||||
max_soc_pct: int | None = None,
|
||||
) -> tuple[int | None, int]:
|
||||
"""
|
||||
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
|
||||
|
||||
**PV_SURPLUS** (PASSIVE, export FVE) — reg 108 SLEDUJE charge intent plánu (fix 2026-06-16):
|
||||
- `bat_w > 0` (plán chce nabíjet z přebytku) → **108 = max**: baterie nabere kolik fyzicky
|
||||
zvládne (nabíjecí rychlost), přebytek NAD ni jde do sítě (BA81: výroba 12 kW > rychlost
|
||||
6 kW → 6 do baterky, 6 ven). Dřív tvrdě 108=0 i při bat_w>0 → baterka nenabíjela ani
|
||||
levné ranní PV (control bug).
|
||||
- kalibrace: SoC u maxima (`>= max_soc − margin`) + přebytek → **108 = max**, ať dojede na
|
||||
100 % (BMS rekalibrace SoC). Strop drží Deye max_soc.
|
||||
- jen „prodej PV a drž baterku" daleko od maxima (`bat_w <= 0`) → **108 = 0**, přebytek ven.
|
||||
|
||||
PASSIVE + nabíjení bez exportního záměru (`battery_w > 0`, export_mode NONE): **108 = max**.
|
||||
**CHARGE** ze sítě: 108 z `battery_w`.
|
||||
|
||||
**SELL** (selling first, reg 142 = 0): vrací ``(None, max_discharge)`` — reg **108 se nezapisuje**
|
||||
(export řídí 142/178; nulování 108 a obnova po návratu jsou zbytečné zápisy do paměti).
|
||||
"""
|
||||
if lock_battery:
|
||||
return 0, 0
|
||||
if deye_mode == "CHARGE":
|
||||
return battery_watts_to_amps(bat_w, max_charge_a), 0
|
||||
if deye_mode == "SELL":
|
||||
return None, int(max_discharge_a)
|
||||
if self_sustain_local_use:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
if _is_passive_pv_surplus_export(
|
||||
deye_mode=deye_mode,
|
||||
export_mode=export_mode,
|
||||
export_ban=export_ban,
|
||||
grid_w=grid_w,
|
||||
):
|
||||
# reg 108 sleduje charge intent: nabíjet z přebytku (bat_w>0) nebo dojet na max
|
||||
# kvůli BMS kalibraci (SoC u maxima + přebytek) → 108 = max; jinak 108 = 0 (přebytek
|
||||
# ven). Strop SoC drží Deye max_soc, takže 108=max nepřebije nad povolené.
|
||||
near_full_calib = (
|
||||
current_soc_pct is not None
|
||||
and max_soc_pct is not None
|
||||
and float(current_soc_pct) >= float(max_soc_pct) - BATTERY_CALIB_TOPOFF_MARGIN_PCT
|
||||
)
|
||||
if bat_w > 0 or near_full_calib:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
return 0, int(max_discharge_a)
|
||||
if bat_w > 0:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
return _deye_zero_export_amps_for_passive(
|
||||
grid_w, bat_w, int(max_charge_a), int(max_discharge_a)
|
||||
)
|
||||
|
||||
|
||||
def get_deye_mode(setpoints: ControlSetpoints) -> str:
|
||||
"""Fyzický režim Deye: SELL | CHARGE | PASSIVE."""
|
||||
pm = (setpoints.deye_physical_mode or "").strip().upper()
|
||||
if pm in {"PASSIVE", "SELL", "CHARGE"}:
|
||||
return pm
|
||||
|
||||
grid_w = int(setpoints.grid_setpoint_w or 0)
|
||||
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
|
||||
|
||||
if bat_w > 0 and grid_w > 0:
|
||||
return "CHARGE"
|
||||
|
||||
if grid_w < 0 and bat_w < 0:
|
||||
return "SELL"
|
||||
|
||||
return "PASSIVE"
|
||||
|
||||
|
||||
def _deye_tou_params(
|
||||
setpoints: ControlSetpoints,
|
||||
inv: InverterConfig,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge."""
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
|
||||
tou_min = _deye_tou_min_soc_pct(inv)
|
||||
tou_reserve = _deye_tou_reserve_soc_pct(inv)
|
||||
if setpoints.lock_battery:
|
||||
return tp_discharge_w, tou_min, False
|
||||
deye_mode = get_deye_mode(setpoints)
|
||||
if deye_mode == "CHARGE":
|
||||
raw_bat = setpoints.battery_w
|
||||
battery_w = int(raw_bat) if raw_bat is not None else 0
|
||||
cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95
|
||||
target_soc = max(10, min(100, cap))
|
||||
tp_charge_w = (
|
||||
battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V)
|
||||
)
|
||||
return tp_charge_w, target_soc, True
|
||||
if deye_mode == "SELL":
|
||||
return tp_discharge_w, tou_reserve, False
|
||||
tou_soc = _deye_passive_tou_battery_soc_pct(inv, setpoints)
|
||||
return tp_discharge_w, tou_soc, False
|
||||
481
backend/services/control/verify.py
Normal file
481
backend/services/control/verify.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""Modbus verify workflow pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.deye_helpers import (
|
||||
DEYE_CLOCK_REGS,
|
||||
DEYE_TOU_POWER_REGS,
|
||||
REG178_VERIFY_MASK,
|
||||
_deye_clock_registers_verify_match,
|
||||
_deye_reg178_verify_match,
|
||||
_deye_reg178_verify_with_double_read,
|
||||
_deye_tou_power_verify_match,
|
||||
_prague_minute_start_utc,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||
)
|
||||
from services.control.modbus_journal import (
|
||||
_fetch_last_verified_inverter_registers,
|
||||
_fetch_written_deye_clock_commands,
|
||||
_modbus_command_contiguous_runs,
|
||||
execute_modbus_commands,
|
||||
)
|
||||
from services.control.repository import _load_inverter_config
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None:
|
||||
"""Přepne lokalitu na SELF_SUSTAIN, zaloguje důvod a při změně pošle Discord."""
|
||||
from services.notification_service import run_fn_set_mode_with_discord
|
||||
|
||||
await run_fn_set_mode_with_discord(
|
||||
db,
|
||||
site_id,
|
||||
"SELF_SUSTAIN",
|
||||
"system:mismatch",
|
||||
None,
|
||||
reason,
|
||||
)
|
||||
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
|
||||
|
||||
|
||||
def _modbus_cmd_register(cmd: Any) -> int:
|
||||
"""asyncpg.Record má __getitem__; objekty s atributem .register též (testy)."""
|
||||
try:
|
||||
return int(cmd["register"])
|
||||
except (KeyError, TypeError):
|
||||
return int(cmd.register)
|
||||
|
||||
|
||||
def _deye_expected_clock_triplet_for_verify(
|
||||
bundle: list[asyncpg.Record],
|
||||
last_verified: dict[int, int],
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
) -> tuple[int, int, int]:
|
||||
"""
|
||||
Sestaví očekávané (w62,w63,w64) pro toleranční verify.
|
||||
Chybějící registry doplní poslední verified nebo aktuálním přečtením ze zařízení.
|
||||
"""
|
||||
by_reg = {_modbus_cmd_register(c): c for c in bundle}
|
||||
|
||||
def _vtw(c: Any) -> int:
|
||||
try:
|
||||
return int(c["value_to_write"])
|
||||
except (KeyError, TypeError):
|
||||
return int(c.value_to_write)
|
||||
|
||||
w62 = _vtw(by_reg[62]) if 62 in by_reg else last_verified.get(62, a62)
|
||||
w63 = _vtw(by_reg[63]) if 63 in by_reg else last_verified.get(63, a63)
|
||||
w64 = _vtw(by_reg[64]) if 64 in by_reg else last_verified.get(64, a64)
|
||||
return (int(w62), int(w63), int(w64))
|
||||
|
||||
|
||||
async def _verify_deye_clock_written_bundle(
|
||||
site_id: int,
|
||||
bundle: list[asyncpg.Record],
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
db: asyncpg.Connection,
|
||||
) -> bool:
|
||||
"""
|
||||
Toleranční ověření pro jeden až tři řádky journalu 62-64 ve stavu written.
|
||||
Při mismatch retry společně; bez přepnutí do SELF_SUSTAIN po 3 pokusech.
|
||||
"""
|
||||
from services.notification_service import (
|
||||
notify_modbus_clock_verify_exhausted,
|
||||
notify_modbus_mismatch,
|
||||
)
|
||||
|
||||
cmds_s = sorted(bundle, key=_modbus_cmd_register)
|
||||
try:
|
||||
asset_id = int(cmds_s[0]["asset_id"])
|
||||
except (KeyError, TypeError):
|
||||
asset_id = int(cmds_s[0].asset_id)
|
||||
last_v = await _fetch_last_verified_inverter_registers(site_id, asset_id, db)
|
||||
w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last_v, a62, a63, a64)
|
||||
clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64)
|
||||
actual_by_reg = {62: a62, 63: a63, 64: a64}
|
||||
|
||||
for cmd in cmds_s:
|
||||
try:
|
||||
cid = int(cmd["id"])
|
||||
except (KeyError, TypeError):
|
||||
cid = int(cmd.id)
|
||||
r = _modbus_cmd_register(cmd)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET value_verified=$1::int, verified_at=now(),
|
||||
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
|
||||
WHERE id=$3::int
|
||||
""",
|
||||
actual_by_reg[r],
|
||||
clock_ok,
|
||||
cid,
|
||||
)
|
||||
|
||||
if clock_ok:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1,
|
||||
deye_last_system_time_sync_at = now()
|
||||
WHERE id = $2
|
||||
""",
|
||||
_prague_minute_start_utc(),
|
||||
asset_id,
|
||||
)
|
||||
for cmd in cmds_s:
|
||||
try:
|
||||
cid_l = int(cmd["id"])
|
||||
except (KeyError, TypeError):
|
||||
cid_l = int(cmd.id)
|
||||
try:
|
||||
code_l = str(cmd["asset_code"])
|
||||
except (KeyError, TypeError):
|
||||
code_l = str(cmd.asset_code)
|
||||
rr = _modbus_cmd_register(cmd)
|
||||
logger.info(
|
||||
"[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s",
|
||||
cid_l,
|
||||
code_l,
|
||||
rr,
|
||||
actual_by_reg[rr],
|
||||
)
|
||||
return True
|
||||
|
||||
cmd0 = cmds_s[0]
|
||||
try:
|
||||
ac0 = str(cmd0["asset_code"])
|
||||
except (KeyError, TypeError):
|
||||
ac0 = str(cmd0.asset_code)
|
||||
logger.error(
|
||||
"[cmd clock] MISMATCH %s 62-64: written=(%s,%s,%s) actual=(%s,%s,%s)",
|
||||
ac0,
|
||||
w62,
|
||||
w63,
|
||||
w64,
|
||||
a62,
|
||||
a63,
|
||||
a64,
|
||||
)
|
||||
|
||||
attempts = 0
|
||||
for cmd in cmds_s:
|
||||
try:
|
||||
cid_q = int(cmd["id"])
|
||||
except (KeyError, TypeError):
|
||||
cid_q = int(cmd.id)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cid_q
|
||||
)
|
||||
ac = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||
attempts = max(attempts, ac)
|
||||
|
||||
await notify_modbus_mismatch(
|
||||
db,
|
||||
site_id,
|
||||
ac0,
|
||||
62,
|
||||
"system_time_62_64",
|
||||
w62,
|
||||
a62,
|
||||
attempts,
|
||||
)
|
||||
|
||||
ids_ordered = []
|
||||
for c in cmds_s:
|
||||
try:
|
||||
ids_ordered.append(int(c["id"]))
|
||||
except (KeyError, TypeError):
|
||||
ids_ordered.append(int(c.id))
|
||||
if attempts < 3:
|
||||
for cid in ids_ordered:
|
||||
await db.execute(
|
||||
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
|
||||
cid,
|
||||
)
|
||||
await execute_modbus_commands(ids_ordered, db)
|
||||
await verify_modbus_commands(ids_ordered, db, site_id)
|
||||
else:
|
||||
logger.critical(
|
||||
"[cmd clock] 3 failed verify attempts (62-64); režim se nemění automaticky"
|
||||
)
|
||||
site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id)
|
||||
await notify_modbus_clock_verify_exhausted(
|
||||
db,
|
||||
site_id,
|
||||
site["code"] if site else str(site_id),
|
||||
ac0,
|
||||
(w62, w63, w64),
|
||||
(a62, a63, a64),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def verify_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
site_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write.
|
||||
Při mismatch řeší retry a po vyčerpání kritických registrů SELF_SUSTAIN.
|
||||
"""
|
||||
from services.notification_service import notify_modbus_mismatch
|
||||
|
||||
inv_cfg = await _load_inverter_config(site_id, db)
|
||||
|
||||
async def _apply_verify_result(
|
||||
cmd: asyncpg.Record,
|
||||
actual_i: int,
|
||||
*,
|
||||
client: Any,
|
||||
unit: int,
|
||||
) -> bool:
|
||||
reg = int(cmd["register"])
|
||||
cmd_id = int(cmd["id"])
|
||||
|
||||
if reg in DEYE_CLOCK_REGS:
|
||||
asset_id = int(cmd["asset_id"])
|
||||
host = str(cmd["device_host"])
|
||||
port_i = int(cmd["device_port"])
|
||||
uid = int(cmd["device_unit_id"])
|
||||
bundle = await _fetch_written_deye_clock_commands(
|
||||
site_id, asset_id, host, port_i, uid, db
|
||||
)
|
||||
if not bundle:
|
||||
bundle = [cmd]
|
||||
try:
|
||||
cvals = await client.read_holding_registers(62, 3, uid)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"verify clock guard read 62-64 failed (reg 0x%04X): %s", reg, e
|
||||
)
|
||||
return False
|
||||
if len(cvals) != 3:
|
||||
logger.error("verify clock guard: expected 3 regs, got %s", len(cvals))
|
||||
return False
|
||||
logger.warning(
|
||||
"Clock register 0x%04X reached strict verify path; using tolerant 62-64 bundle",
|
||||
reg,
|
||||
)
|
||||
return await _verify_deye_clock_written_bundle(
|
||||
site_id,
|
||||
bundle,
|
||||
int(cvals[0]),
|
||||
int(cvals[1]),
|
||||
int(cvals[2]),
|
||||
db,
|
||||
)
|
||||
|
||||
expected_i = int(cmd["value_to_write"])
|
||||
matches = actual_i == expected_i
|
||||
if reg == 178:
|
||||
first_178 = int(actual_i)
|
||||
second_178: int | None = None
|
||||
if not _deye_reg178_verify_match(expected_i, first_178):
|
||||
try:
|
||||
r178 = await client.read_holding_registers(178, 1, unit)
|
||||
if r178 and len(r178) >= 1:
|
||||
second_178 = int(r178[0])
|
||||
except Exception as e:
|
||||
logger.warning("[cmd %s] reg178 double-read failed: %s", cmd_id, e)
|
||||
matches, actual_i = _deye_reg178_verify_with_double_read(
|
||||
expected_i, first_178, second_178
|
||||
)
|
||||
if (
|
||||
matches
|
||||
and second_178 is not None
|
||||
and not _deye_reg178_verify_match(expected_i, first_178)
|
||||
):
|
||||
logger.info(
|
||||
"[cmd %s] reg178 double-read recovered: first=%s second=%s",
|
||||
cmd_id,
|
||||
first_178,
|
||||
second_178,
|
||||
)
|
||||
if not matches and reg in DEYE_TOU_POWER_REGS and inv_cfg is not None:
|
||||
matches = _deye_tou_power_verify_match(expected_i, actual_i, inv_cfg)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET value_verified=$1::int, verified_at=now(),
|
||||
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
|
||||
WHERE id=$3::int
|
||||
""",
|
||||
actual_i,
|
||||
matches,
|
||||
cmd_id,
|
||||
)
|
||||
|
||||
if not matches:
|
||||
logger.error(
|
||||
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
reg,
|
||||
expected_i,
|
||||
actual_i,
|
||||
" (reg178 mask 0x%04X)" % REG178_VERIFY_MASK if reg == 178 else "",
|
||||
)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||
await notify_modbus_mismatch(
|
||||
db,
|
||||
site_id,
|
||||
cmd["asset_code"],
|
||||
reg,
|
||||
cmd["register_name"] or "",
|
||||
expected_i,
|
||||
actual_i,
|
||||
attempts,
|
||||
)
|
||||
|
||||
if attempts < 3:
|
||||
await db.execute(
|
||||
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
|
||||
cmd_id,
|
||||
)
|
||||
await execute_modbus_commands([cmd_id], db)
|
||||
await verify_modbus_commands([cmd_id], db, site_id)
|
||||
else:
|
||||
# SELF_SUSTAIN fallback je Deye politika — mismatch na jiném
|
||||
# zařízení (EV wallbox…) nesmí degradovat režim celé lokality.
|
||||
if (
|
||||
str(cmd["asset_type"]) == "inverter"
|
||||
and deye_reg_triggers_self_sustain_after_verify_exhaust(reg)
|
||||
):
|
||||
logger.critical(
|
||||
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
|
||||
cmd_id,
|
||||
)
|
||||
await _switch_to_self_sustain(
|
||||
site_id,
|
||||
db,
|
||||
reason=(
|
||||
f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} "
|
||||
f"reg 0x{reg:04X}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[cmd %s] 3 failed verify attempts on non-critical reg 0x%04X "
|
||||
"(no mode change): %s",
|
||||
cmd_id,
|
||||
reg,
|
||||
cmd["asset_code"],
|
||||
)
|
||||
return False
|
||||
|
||||
if reg == 178 and actual_i != expected_i:
|
||||
logger.info(
|
||||
"[cmd %s] verified OK (reg178 masked): %s 0x%04X value_to_write=%s actual=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
reg,
|
||||
expected_i,
|
||||
actual_i,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[cmd %s] verified OK: %s 0x%04X=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
reg,
|
||||
actual_i,
|
||||
)
|
||||
return True
|
||||
|
||||
cmds: list[asyncpg.Record] = []
|
||||
for cmd_id in command_ids:
|
||||
cmd = await db.fetchrow(
|
||||
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
if cmd is not None and cmd["status"] == "written":
|
||||
cmds.append(cmd)
|
||||
|
||||
if not cmds:
|
||||
return True
|
||||
|
||||
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
|
||||
for cmd in cmds:
|
||||
by_gw[
|
||||
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
|
||||
].append(cmd)
|
||||
|
||||
all_ok = True
|
||||
for (host, port, unit), group in by_gw.items():
|
||||
client = await get_modbus_client(host, port)
|
||||
clock_cmds = [c for c in group if int(c["register"]) in DEYE_CLOCK_REGS]
|
||||
rest = [c for c in group if int(c["register"]) not in DEYE_CLOCK_REGS]
|
||||
|
||||
if clock_cmds:
|
||||
asset_id = int(clock_cmds[0]["asset_id"])
|
||||
bundle = await _fetch_written_deye_clock_commands(
|
||||
site_id, asset_id, host, port, unit, db
|
||||
)
|
||||
if not bundle:
|
||||
bundle = clock_cmds
|
||||
try:
|
||||
cvals = await client.read_holding_registers(62, 3, unit)
|
||||
except Exception as e:
|
||||
logger.error("verify clock read 62-64 failed: %s", e)
|
||||
all_ok = False
|
||||
else:
|
||||
if len(cvals) != 3:
|
||||
logger.error("verify clock read: expected 3 regs, got %s", len(cvals))
|
||||
all_ok = False
|
||||
else:
|
||||
matched = await _verify_deye_clock_written_bundle(
|
||||
site_id,
|
||||
bundle,
|
||||
int(cvals[0]),
|
||||
int(cvals[1]),
|
||||
int(cvals[2]),
|
||||
db,
|
||||
)
|
||||
if not matched:
|
||||
all_ok = False
|
||||
|
||||
for run in _modbus_command_contiguous_runs(rest):
|
||||
start_reg = int(run[0]["register"])
|
||||
n = len(run)
|
||||
try:
|
||||
values = await client.read_holding_registers(start_reg, n, unit)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"verify batch read 0x%04X count=%s failed: %s", start_reg, n, e
|
||||
)
|
||||
all_ok = False
|
||||
continue
|
||||
if len(values) != n:
|
||||
logger.error(
|
||||
"verify read 0x%04X: expected %s regs, got %s",
|
||||
start_reg,
|
||||
n,
|
||||
len(values),
|
||||
)
|
||||
all_ok = False
|
||||
continue
|
||||
for cmd, actual in zip(run, values):
|
||||
matched = await _apply_verify_result(
|
||||
cmd, int(actual), client=client, unit=unit
|
||||
)
|
||||
if not matched:
|
||||
all_ok = False
|
||||
|
||||
return all_ok
|
||||
367
backend/services/discord_bot.py
Normal file
367
backend/services/discord_bot.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""Discord bot (gateway) — interaktivní EV zprávy se dvěma výběry.
|
||||
|
||||
Architektura: websocket spojení jde Z BACKENDU VEN (žádný veřejný endpoint,
|
||||
EMS zůstává na VPN). Bot reaguje výhradně na whitelisted user ID a jediné,
|
||||
co umí, je patch otevřené EV session + okamžitý replan — žádné režimy,
|
||||
žádné registry. Bez DISCORD_BOT_TOKEN modul tiše spí (fáze A webhook).
|
||||
|
||||
UI: dva persistent Selecty (custom_id template, takže fungují i po
|
||||
restartu backendu — obsluha jde přes on_interaction + regex, ne přes
|
||||
zaregistrovanou View instanci):
|
||||
ev:<site_id>:<charger_code>:dep — „Kdy odjíždíš?"
|
||||
za 2 h | za 4 h | dnes večer 18:00 | zítra ráno 7:00 |
|
||||
zítra poledne 12:00 | pondělí ráno 7:00
|
||||
ev:<site_id>:<charger_code>:tgt — „Kolik potřebuješ?"
|
||||
30 % | 50 % | 70 % | 100 % | Nenabíjet (target = SoC při připojení)
|
||||
|
||||
Každý výběr okamžitě PATCHne session (fn_ev_session_apply_patch) jen v dané
|
||||
dimenzi — druhý rozměr zůstává (default z ems.fn_ev_session_defaults nebo
|
||||
předchozí výběr). Legacy tlačítka h2/h4/morning/full/stop ze starších zpráv
|
||||
zůstávají obsloužená (action_to_patch).
|
||||
|
||||
Postup zřízení bota: docs/discord-ev-interaction.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PRAGUE = ZoneInfo("Europe/Prague")
|
||||
_POOL: asyncpg.Pool | None = None
|
||||
_CLIENT: Any = None # discord.Client za lazy importem
|
||||
|
||||
CUSTOM_ID_RE = re.compile(
|
||||
r"^ev:(?P<site>\d+):(?P<charger>[a-z0-9\-]+)"
|
||||
r":(?P<action>dep|tgt|h2|h4|morning|full|stop)$"
|
||||
)
|
||||
|
||||
#: Výběr 1 — „Kdy odjíždíš?" (value, label); absolutní čas viz
|
||||
#: departure_choice_to_deadline (Europe/Prague).
|
||||
DEP_CHOICES: list[tuple[str, str]] = [
|
||||
("h2", "za 2 h"),
|
||||
("h4", "za 4 h"),
|
||||
("today18", "dnes večer 18:00"),
|
||||
("tomorrow7", "zítra ráno 7:00"),
|
||||
("tomorrow12", "zítra poledne 12:00"),
|
||||
("monday7", "pondělí ráno 7:00"),
|
||||
]
|
||||
|
||||
#: Výběr 2 — „Kolik potřebuješ?" (value, label); "stop" = nenabíjet.
|
||||
TGT_CHOICES: list[tuple[str, str]] = [
|
||||
("30", "30 %"),
|
||||
("50", "50 %"),
|
||||
("70", "70 %"),
|
||||
("100", "100 %"),
|
||||
("stop", "Nenabíjet"),
|
||||
]
|
||||
|
||||
#: Legacy tlačítka (starší zprávy poslané před přechodem na selecty).
|
||||
LEGACY_ACTION_LABELS = {
|
||||
"h2": "za 2 h",
|
||||
"h4": "za 4 h",
|
||||
"morning": "ráno",
|
||||
"full": "do plna",
|
||||
"stop": "nenabíjet",
|
||||
}
|
||||
|
||||
|
||||
def parse_custom_id(cid: str) -> tuple[int, str, str] | None:
|
||||
m = CUSTOM_ID_RE.match(cid or "")
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group("site")), m.group("charger"), m.group("action")
|
||||
|
||||
|
||||
def departure_choice_to_deadline(choice: str, *, now: datetime) -> datetime:
|
||||
"""Absolutní deadline (Europe/Prague) pro volbu z výběru „Kdy odjíždíš?".
|
||||
|
||||
Čisté a testovatelné. Volby s pevným časem znamenají NEJBLIŽŠÍ budoucí
|
||||
výskyt (dnes 18:00 po 18. hodině → zítra 18:00; pondělí 7:00 z pátku je
|
||||
explicitní volba — smí být i za >48 h).
|
||||
"""
|
||||
local = now.astimezone(_PRAGUE)
|
||||
if choice == "h2":
|
||||
return local + timedelta(hours=2)
|
||||
if choice == "h4":
|
||||
return local + timedelta(hours=4)
|
||||
if choice == "today18":
|
||||
candidate = local.replace(hour=18, minute=0, second=0, microsecond=0)
|
||||
if candidate <= local:
|
||||
candidate += timedelta(days=1)
|
||||
return candidate
|
||||
if choice == "tomorrow7":
|
||||
return (local + timedelta(days=1)).replace(
|
||||
hour=7, minute=0, second=0, microsecond=0
|
||||
)
|
||||
if choice == "tomorrow12":
|
||||
return (local + timedelta(days=1)).replace(
|
||||
hour=12, minute=0, second=0, microsecond=0
|
||||
)
|
||||
if choice == "monday7":
|
||||
candidate = local.replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
candidate += timedelta(days=(0 - local.weekday()) % 7) # 0 = pondělí
|
||||
if candidate <= local:
|
||||
candidate += timedelta(days=7)
|
||||
return candidate
|
||||
raise ValueError(f"unknown departure choice {choice}")
|
||||
|
||||
|
||||
def select_to_patch(
|
||||
kind: str,
|
||||
value: str,
|
||||
*,
|
||||
now: datetime,
|
||||
soc_at_connect: float | None,
|
||||
) -> dict:
|
||||
"""Patch pro fn_ev_session_apply_patch z hodnoty selectu (čisté, testovatelné).
|
||||
|
||||
Patchuje VŽDY jen jednu dimenzi — druhá zůstává beze změny
|
||||
(default z fn_ev_session_defaults, případně dřívější výběr).
|
||||
"""
|
||||
if kind == "dep":
|
||||
deadline = departure_choice_to_deadline(value, now=now)
|
||||
return {"target_deadline": deadline.isoformat()}
|
||||
if kind == "tgt":
|
||||
if value == "stop":
|
||||
return {"target_soc_pct": float(soc_at_connect or 0)}
|
||||
return {"target_soc_pct": float(value)}
|
||||
raise ValueError(f"unknown select kind {kind}")
|
||||
|
||||
|
||||
def choice_label(kind: str, value: str) -> str:
|
||||
"""Lidský popisek volby pro potvrzení ve zprávě."""
|
||||
if kind == "dep":
|
||||
return "odjezd " + dict(DEP_CHOICES).get(value, value)
|
||||
if kind == "tgt":
|
||||
if value == "stop":
|
||||
return "nenabíjet"
|
||||
return "cíl " + dict(TGT_CHOICES).get(value, value)
|
||||
return LEGACY_ACTION_LABELS.get(value, value)
|
||||
|
||||
|
||||
def action_to_patch(
|
||||
action: str,
|
||||
*,
|
||||
now: datetime,
|
||||
soc_at_connect: float | None,
|
||||
default_deadline_hour: int | None,
|
||||
) -> dict:
|
||||
"""Patch pro legacy tlačítka h2/h4/morning/full/stop (starší zprávy)."""
|
||||
if action == "h2":
|
||||
return {"target_deadline": (now + timedelta(hours=2)).isoformat()}
|
||||
if action == "h4":
|
||||
return {"target_deadline": (now + timedelta(hours=4)).isoformat()}
|
||||
if action == "morning":
|
||||
hour = int(default_deadline_hour or 7)
|
||||
local = now.astimezone(_PRAGUE)
|
||||
candidate = local.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
if candidate <= local:
|
||||
candidate += timedelta(days=1)
|
||||
return {"target_deadline": candidate.isoformat()}
|
||||
if action == "full":
|
||||
return {
|
||||
"target_soc_pct": 100,
|
||||
"target_deadline": (now + timedelta(hours=1)).isoformat(),
|
||||
}
|
||||
if action == "stop":
|
||||
return {"target_soc_pct": float(soc_at_connect or 0)}
|
||||
raise ValueError(f"unknown action {action}")
|
||||
|
||||
|
||||
def set_pool(pool: asyncpg.Pool) -> None:
|
||||
global _POOL
|
||||
_POOL = pool
|
||||
|
||||
|
||||
def _allowed_user_ids() -> set[int]:
|
||||
raw = (getattr(get_settings(), "discord_allowed_user_ids", "") or "").strip()
|
||||
out: set[int] = set()
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
out.add(int(part))
|
||||
return out
|
||||
|
||||
|
||||
def _build_view(site_id: int, charger_code: str):
|
||||
"""View se dvěma selecty (persistent custom_id, timeout=None)."""
|
||||
import discord
|
||||
|
||||
view = discord.ui.View(timeout=None)
|
||||
view.add_item(
|
||||
discord.ui.Select(
|
||||
custom_id=f"ev:{site_id}:{charger_code}:dep",
|
||||
placeholder="🕑 Kdy odjíždíš?",
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
options=[
|
||||
discord.SelectOption(label=label, value=value)
|
||||
for value, label in DEP_CHOICES
|
||||
],
|
||||
)
|
||||
)
|
||||
view.add_item(
|
||||
discord.ui.Select(
|
||||
custom_id=f"ev:{site_id}:{charger_code}:tgt",
|
||||
placeholder="🔋 Kolik potřebuješ?",
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
options=[
|
||||
discord.SelectOption(label=label, value=value)
|
||||
for value, label in TGT_CHOICES
|
||||
],
|
||||
)
|
||||
)
|
||||
return view
|
||||
|
||||
|
||||
async def post_ev_arrival(
|
||||
site_id: int, charger_code: str, session_id: int, text: str
|
||||
) -> bool:
|
||||
"""Pošle zprávu s výběry přes bota. False = bot neběží/není kanál (fallback webhook)."""
|
||||
if _CLIENT is None or not _CLIENT.is_ready():
|
||||
return False
|
||||
channel_id = int(getattr(get_settings(), "discord_ev_channel_id", 0) or 0)
|
||||
if not channel_id:
|
||||
return False
|
||||
channel = _CLIENT.get_channel(channel_id)
|
||||
if channel is None:
|
||||
return False
|
||||
await channel.send(content=text, view=_build_view(site_id, charger_code))
|
||||
return True
|
||||
|
||||
|
||||
async def _handle_action(
|
||||
interaction: Any,
|
||||
site_id: int,
|
||||
charger_code: str,
|
||||
action: str,
|
||||
value: str | None,
|
||||
) -> None:
|
||||
import json
|
||||
|
||||
from services.control_exporter import export_setpoints
|
||||
from services.ev_notify import build_ev_plan_summary, get_open_session
|
||||
from services.planning_engine import run_rolling_replan
|
||||
|
||||
assert _POOL is not None
|
||||
async with _POOL.acquire() as conn:
|
||||
sess = await get_open_session(site_id, charger_code, conn)
|
||||
if sess is None:
|
||||
await interaction.followup.send(
|
||||
"Session už není otevřená (auto odpojeno?).", ephemeral=True
|
||||
)
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
if action in ("dep", "tgt"):
|
||||
if not value:
|
||||
return
|
||||
patch = select_to_patch(
|
||||
action,
|
||||
value,
|
||||
now=now,
|
||||
soc_at_connect=sess["soc_at_connect_pct"],
|
||||
)
|
||||
label = choice_label(action, value)
|
||||
else: # legacy tlačítka starších zpráv
|
||||
patch = action_to_patch(
|
||||
action,
|
||||
now=now,
|
||||
soc_at_connect=sess["soc_at_connect_pct"],
|
||||
default_deadline_hour=sess["default_deadline_hour"],
|
||||
)
|
||||
label = LEGACY_ACTION_LABELS.get(action, action)
|
||||
await conn.fetchval(
|
||||
"select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
|
||||
site_id,
|
||||
int(sess["session_id"]),
|
||||
json.dumps(patch),
|
||||
)
|
||||
await run_rolling_replan(
|
||||
site_id, conn, triggered_by=f"discord:{action}:{charger_code}"
|
||||
)
|
||||
await export_setpoints(site_id, conn)
|
||||
new_text = await build_ev_plan_summary(site_id, charger_code, conn)
|
||||
|
||||
if new_text:
|
||||
await interaction.message.edit(
|
||||
content=new_text + f"\n_(nastaveno: {label})_",
|
||||
view=_build_view(site_id, charger_code),
|
||||
)
|
||||
await interaction.followup.send(f"Přeplánováno ✓ ({label})", ephemeral=True)
|
||||
|
||||
|
||||
async def run_discord_bot() -> None:
|
||||
"""Lifespan task: připojí gateway a obsluhuje selecty/tlačítka. Bez tokenu hned končí."""
|
||||
token = (getattr(get_settings(), "discord_bot_token", "") or "").strip()
|
||||
if not token:
|
||||
logger.info("Discord bot: token není nastaven — fáze B vypnuta")
|
||||
return
|
||||
import discord
|
||||
|
||||
intents = discord.Intents.default()
|
||||
client = discord.Client(intents=intents)
|
||||
|
||||
@client.event
|
||||
async def on_ready() -> None:
|
||||
logger.info("Discord bot připojen jako %s", client.user)
|
||||
try:
|
||||
channel_id = int(getattr(get_settings(), "discord_ev_channel_id", 0) or 0)
|
||||
ch = client.get_channel(channel_id) if channel_id else None
|
||||
if ch is not None:
|
||||
await ch.send("✅ EMS bot online — notifikace aktivní")
|
||||
except Exception:
|
||||
logger.exception("Discord on_ready ping failed")
|
||||
|
||||
@client.event
|
||||
async def on_interaction(interaction: discord.Interaction) -> None:
|
||||
if interaction.type != discord.InteractionType.component:
|
||||
return
|
||||
data = interaction.data or {}
|
||||
cid = data.get("custom_id", "")
|
||||
parsed = parse_custom_id(str(cid))
|
||||
if parsed is None:
|
||||
return
|
||||
allowed = _allowed_user_ids()
|
||||
if allowed and interaction.user.id not in allowed:
|
||||
await interaction.response.send_message(
|
||||
"Tenhle výběr není pro tebe. 🙂", ephemeral=True
|
||||
)
|
||||
return
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
site_id, charger_code, action = parsed
|
||||
values = data.get("values") or []
|
||||
value = str(values[0]) if values else None
|
||||
try:
|
||||
await _handle_action(interaction, site_id, charger_code, action, value)
|
||||
except Exception:
|
||||
logger.exception("Discord akce selhala (%s, value=%s)", cid, value)
|
||||
try:
|
||||
await interaction.followup.send(
|
||||
"Akce selhala — mrkni do logů.", ephemeral=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
global _CLIENT
|
||||
_CLIENT = client
|
||||
try:
|
||||
await client.start(token)
|
||||
except asyncio.CancelledError:
|
||||
await client.close()
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Discord bot spadl — fáze B mimo provoz (fallback webhook)")
|
||||
finally:
|
||||
_CLIENT = None
|
||||
115
backend/services/ev_notify.py
Normal file
115
backend/services/ev_notify.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Souhrn EV nabíjecího plánu pro notifikace (Discord webhook i bot).
|
||||
|
||||
Sdílené mezi telemetry_collector (zpráva po příjezdu) a discord_bot
|
||||
(přestavba zprávy po akci tlačítkem).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PRAGUE = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
async def get_open_session(
|
||||
site_id: int, charger_code: str, conn: asyncpg.Connection
|
||||
) -> asyncpg.Record | None:
|
||||
return await conn.fetchrow(
|
||||
"""
|
||||
select es.id as session_id, es.soc_at_connect_pct, es.target_soc_pct,
|
||||
es.target_deadline, v.battery_capacity_kwh, v.name as vehicle_name,
|
||||
v.default_deadline_hour
|
||||
from ems.ev_session es
|
||||
join ems.asset_ev_charger c on c.id = es.charger_id
|
||||
left join ems.asset_vehicle v on v.id = es.vehicle_id
|
||||
where es.site_id = $1 and c.code = $2 and es.session_end is null
|
||||
order by es.id desc limit 1
|
||||
""",
|
||||
site_id,
|
||||
charger_code,
|
||||
)
|
||||
|
||||
|
||||
async def build_ev_plan_summary(
|
||||
site_id: int, charger_code: str, conn: asyncpg.Connection
|
||||
) -> str | None:
|
||||
"""Markdown souhrn: stav baterie auta → cíl, deadline, nabíjecí okna z plánu."""
|
||||
row = await get_open_session(site_id, charger_code, conn)
|
||||
if row is None:
|
||||
return None
|
||||
ev_col = "ev1_setpoint_w" if charger_code.endswith("1") else "ev2_setpoint_w"
|
||||
slots = await conn.fetch(
|
||||
f"""
|
||||
select pi.interval_start, pi.{ev_col} as w, pi.effective_buy_price
|
||||
from ems.planning_interval pi
|
||||
join ems.planning_run pr on pr.id = pi.run_id
|
||||
where pr.site_id = $1 and pr.status = 'active'
|
||||
and coalesce(pi.{ev_col}, 0) > 0
|
||||
order by pi.interval_start
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
def _fmt(dt) -> str:
|
||||
return dt.astimezone(_PRAGUE).strftime("%H:%M")
|
||||
|
||||
windows: list[str] = []
|
||||
kwh = 0.0
|
||||
prices: list[float] = []
|
||||
if slots:
|
||||
start = prev = slots[0]["interval_start"]
|
||||
for r in slots:
|
||||
ts = r["interval_start"]
|
||||
if (ts - prev).total_seconds() > 900:
|
||||
windows.append(f"{_fmt(start)}–{_fmt(prev)} (+15m)")
|
||||
start = ts
|
||||
prev = ts
|
||||
kwh += float(r["w"]) * 0.25 / 1000.0
|
||||
prices.append(float(r["effective_buy_price"] or 0))
|
||||
windows.append(f"{_fmt(start)}–{_fmt(prev)} (+15m)")
|
||||
|
||||
soc = row["soc_at_connect_pct"]
|
||||
tgt = row["target_soc_pct"]
|
||||
cap = float(row["battery_capacity_kwh"] or 0)
|
||||
need = max(0.0, (float(tgt or 0) - float(soc or 0)) / 100.0 * cap)
|
||||
lines = [
|
||||
f"🔌 **{row['vehicle_name'] or charger_code} připojeno**",
|
||||
f"Baterie auta: **{soc if soc is not None else '?'} %** → cíl {tgt if tgt is not None else '?'} %"
|
||||
+ (f" (~{need:.0f} kWh)" if need else ""),
|
||||
]
|
||||
dl = row["target_deadline"]
|
||||
if dl is not None:
|
||||
lines.append(f"Deadline: {dl.astimezone(_PRAGUE).strftime('%a %d.%m. %H:%M')}")
|
||||
if windows:
|
||||
avg_p = sum(prices) / max(1, len(prices))
|
||||
lines.append(
|
||||
f"Plán nabíjení: {'; '.join(windows[:4])} — {kwh:.1f} kWh, ø {avg_p:.2f} Kč/kWh"
|
||||
)
|
||||
else:
|
||||
lines.append("Plán nabíjení: zatím žádné sloty (čeká na levné okno / PV)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_ev_arrival(site_id: int, charger_code: str, conn: asyncpg.Connection) -> None:
|
||||
"""Pošle souhrn po příjezdu: přednostně bot s tlačítky, jinak webhook."""
|
||||
from services.notification_service import send_discord
|
||||
|
||||
text = await build_ev_plan_summary(site_id, charger_code, conn)
|
||||
if text is None:
|
||||
return
|
||||
try:
|
||||
from services.discord_bot import post_ev_arrival
|
||||
|
||||
row = await get_open_session(site_id, charger_code, conn)
|
||||
if row is not None and await post_ev_arrival(
|
||||
site_id, charger_code, int(row["session_id"]), text
|
||||
):
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Discord bot post failed — fallback webhook")
|
||||
await send_discord(conn, site_id, text, level="info")
|
||||
112
backend/services/ev_presence_notify.py
Normal file
112
backend/services/ev_presence_notify.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho".
|
||||
|
||||
Tenký orchestrátor: veškerá doménová logika (kdo je doma, odpojený, výhodná cena,
|
||||
SoC pod cílem) i dedup jsou v ems.fn_ev_presence_nudge_due(). Python jen zavolá
|
||||
funkci pro každou aktivní lokalitu a pro každý vrácený (= nově due, ještě
|
||||
neposlaný) řádek pošle jeden Discord nudge.
|
||||
|
||||
Dedup je čistě v DB: funkce zapíše řádek do ems.ev_presence_nudge_sent
|
||||
(on conflict do nothing) a vrátí jen ty, kterým insert skutečně prošel — tedy
|
||||
jeden nudge na "epizodu" auta doma+odpojeno. Opakované 20–30min ticky proto
|
||||
nespamují, dokud se auto nepíchne nebo neodjede (čímž se klíč epizody změní).
|
||||
|
||||
DEFAULT-OFF: funkce nevrátí nic, dokud není na vozidle
|
||||
asset_vehicle.presence_nudge_enabled = true. Job tedy běží inertně.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from services.notification_service import send_discord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fmt_price(value: Any) -> str:
|
||||
try:
|
||||
return f"{float(value):.2f}"
|
||||
except (TypeError, ValueError):
|
||||
return "?"
|
||||
|
||||
|
||||
def _build_message(row: asyncpg.Record) -> str:
|
||||
name = row["vehicle_name"] or "EV"
|
||||
reason = str(row["trigger_reason"] or "")
|
||||
sell = row["effective_sell_price_czk_kwh"]
|
||||
buy = row["effective_buy_price_czk_kwh"]
|
||||
soc = row["battery_level_pct"]
|
||||
tgt = row["target_soc_pct"]
|
||||
|
||||
if reason == "NEG_OR_ZERO_SELL":
|
||||
why = f"výkup je teď {_fmt_price(sell)} Kč/kWh (≤ 0) — přebytek se hodí do auta"
|
||||
else:
|
||||
why = f"nákup je teď levný: {_fmt_price(buy)} Kč/kWh"
|
||||
|
||||
soc_line = ""
|
||||
if soc is not None:
|
||||
soc_line = f"\nBaterie auta: **{_fmt_price(soc)} %**" + (
|
||||
f" (cíl {_fmt_price(tgt)} %)" if tgt is not None else ""
|
||||
)
|
||||
|
||||
return (
|
||||
f"🚗 **{name} je doma a nepíchnuté** — {why}.{soc_line}\n"
|
||||
f"Píchni ho a plán se o zbytek postará (přebytky / levné sloty)."
|
||||
)
|
||||
|
||||
|
||||
async def run_ev_presence_nudge_for_site(
|
||||
site_id: int, conn: asyncpg.Connection
|
||||
) -> int:
|
||||
"""Jedna lokalita: zavolá fn (dedup v DB) a pošle Discord pro každé due vozidlo.
|
||||
|
||||
Vrátí počet odeslaných notifikací.
|
||||
"""
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"select * from ems.fn_ev_presence_nudge_due($1::int)",
|
||||
site_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"ev_presence_nudge: fn_ev_presence_nudge_due failed site=%s", site_id
|
||||
)
|
||||
return 0
|
||||
|
||||
sent = 0
|
||||
for row in rows:
|
||||
try:
|
||||
await send_discord(conn, site_id, _build_message(row), level="info")
|
||||
sent += 1
|
||||
logger.info(
|
||||
"ev_presence_nudge sent site=%s vehicle=%s reason=%s",
|
||||
site_id,
|
||||
row["vehicle_id"],
|
||||
row["trigger_reason"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"ev_presence_nudge: Discord send failed site=%s vehicle=%s",
|
||||
site_id,
|
||||
row["vehicle_id"],
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
async def run_ev_presence_nudge_for_all_active_sites(pool: asyncpg.Pool) -> None:
|
||||
"""Scheduler entrypoint: projde aktivní lokality a pošle proaktivní nudge."""
|
||||
async with pool.acquire() as conn:
|
||||
raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()")
|
||||
sites = raw if isinstance(raw, list) else []
|
||||
for site in sites:
|
||||
if not isinstance(site, dict) or site.get("id") is None:
|
||||
continue
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
await run_ev_presence_nudge_for_site(site_id, conn)
|
||||
except Exception:
|
||||
logger.exception("ev_presence_nudge site=%s failed", site_id)
|
||||
@@ -25,9 +25,27 @@ logger = logging.getLogger(__name__)
|
||||
_flock_warned = False
|
||||
|
||||
|
||||
class GatewayLockTimeout(TimeoutError):
|
||||
"""Brána je držena jiným tahem (telemetrie / druhý proces) déle než timeout."""
|
||||
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
_DEFAULT_LOCK_DIR = _BACKEND_ROOT / ".ems-modbus-locks"
|
||||
|
||||
#: Maximální čekání na exkluzivní zámek brány. Dřív se čekalo blokovaně bez
|
||||
#: limitu — exporter pak mohl na bráně obsazené pollingem mrtvého unit_id
|
||||
#: viset donekonečna (journal řádky trvale 'pending'). Po timeoutu se vyhodí
|
||||
#: GatewayLockTimeout a volající označí příkaz failed ('gateway lock timeout').
|
||||
_FLOCK_TIMEOUT_DEFAULT_S = 20.0
|
||||
_FLOCK_POLL_INTERVAL_S = 0.25
|
||||
|
||||
|
||||
def _flock_timeout_s() -> float:
|
||||
try:
|
||||
return float(os.getenv("EMS_MODBUS_FLOCK_TIMEOUT_S", _FLOCK_TIMEOUT_DEFAULT_S))
|
||||
except ValueError:
|
||||
return _FLOCK_TIMEOUT_DEFAULT_S
|
||||
|
||||
|
||||
def _gateway_lock_path(host: str, port: int) -> Path:
|
||||
# Výchozí = backend/.ems-modbus-locks (v Dockeru /app → mount ./backend), aby flock sdílel
|
||||
@@ -65,14 +83,32 @@ async def _gateway_exclusive(host: str, port: int):
|
||||
path = _gateway_lock_path(host_s, port_i)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(path, "a+b") # noqa: SIM115
|
||||
locked = False
|
||||
try:
|
||||
await asyncio.to_thread(fcntl.flock, f.fileno(), fcntl.LOCK_EX)
|
||||
# Neblokující pokusy s deadline místo flock(LOCK_EX) bez limitu:
|
||||
# blokované čekání v to_thread nejde zrušit a při bráně obsazené
|
||||
# pollingem mrtvého unit_id (32 s z každé minuty) hrozí starvation.
|
||||
timeout_s = _flock_timeout_s()
|
||||
deadline = asyncio.get_running_loop().time() + timeout_s
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
locked = True
|
||||
break
|
||||
except OSError:
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise GatewayLockTimeout(
|
||||
f"gateway lock timeout {host_s}:{port_i} "
|
||||
f"after {timeout_s:.0f}s"
|
||||
) from None
|
||||
await asyncio.sleep(_FLOCK_POLL_INTERVAL_S)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
await asyncio.to_thread(fcntl.flock, f.fileno(), fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
if locked:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
f.close()
|
||||
|
||||
|
||||
@@ -260,12 +296,17 @@ class PersistentModbusClient:
|
||||
return await self._write_registers_locked(address, values, device_id)
|
||||
|
||||
async def force_disconnect(self) -> None:
|
||||
"""Uzavře socket pod lockem (např. před retry po chybě)."""
|
||||
async with _gateway_exclusive(self.host, self.port):
|
||||
async with self._lock:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
"""Uzavře socket pod lockem (např. před retry po chybě).
|
||||
|
||||
Záměrně BEZ _gateway_exclusive: zavření vlastního TCP socketu není
|
||||
transakce na RS485 sběrnici a čekání na zámek brány tady umělo
|
||||
protáhnout / shodit retry cestu exporteru (GatewayLockTimeout
|
||||
uvnitř except větve execute_modbus_commands).
|
||||
"""
|
||||
async with self._lock:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def batch(self, device_id: int = 1) -> AsyncIterator[ModbusBatch]:
|
||||
|
||||
@@ -195,24 +195,41 @@ async def send_discord(
|
||||
"""
|
||||
kind = "daily" if level == "info" else "error"
|
||||
webhook_url = await _get_site_webhook_url(conn, site_id, kind)
|
||||
if not webhook_url:
|
||||
logger.debug("Discord webhook not configured, skipping notification")
|
||||
return False
|
||||
|
||||
emoji = {"info": "ℹ️", "warning": "⚠️", "error": "❌", "critical": "🚨"}.get(level, "ℹ️")
|
||||
content = f"{emoji} **EMS** [{level.upper()}]\n{message}"
|
||||
|
||||
if webhook_url:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(webhook_url, json={"content": content})
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Discord webhook failed: %s — zkouším bot fallback", e)
|
||||
|
||||
# Fallback: bot REST (kanál z DISCORD_EV_CHANNEL_ID) — webhooky per site
|
||||
# nebyly nikdy nastavené, takže bez fallbacku se notifikace tiše zahazovaly.
|
||||
return await _send_via_bot(content)
|
||||
|
||||
|
||||
async def _send_via_bot(content: str) -> bool:
|
||||
s = get_settings()
|
||||
token = (getattr(s, "discord_bot_token", "") or "").strip()
|
||||
channel = (getattr(s, "discord_ev_channel_id", "") or "").strip()
|
||||
if not token or not channel:
|
||||
logger.debug("Discord: žádný webhook ani bot — notifikace zahozena")
|
||||
return False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
webhook_url,
|
||||
json={
|
||||
"content": f"{emoji} **EMS Alert** [{level.upper()}]\n{message}",
|
||||
},
|
||||
r = await client.post(
|
||||
f"https://discord.com/api/v10/channels/{channel}/messages",
|
||||
headers={"Authorization": f"Bot {token}"},
|
||||
json={"content": content[:1900]},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Discord notification failed: %s", e)
|
||||
logger.warning("Discord bot fallback failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
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)."""
|
||||
128
backend/services/planning/constants.py
Normal file
128
backend/services/planning/constants.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# backend/services/planning/constants.py
|
||||
#
|
||||
# EMS plánovač – konstanty (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
# POZOR: ekonomické penalty/váhy jsou kandidáti na přesun do DB ve Fázi 2
|
||||
# (CLAUDE.md pravidlo 16: žádný skrytý faktor v Pythonu).
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# ============================================================
|
||||
# Konstanty
|
||||
# ============================================================
|
||||
|
||||
# Když DB vrátí NULL (skoro žádná OTE data), denní plán použije krátký fallback (soulad s min hodinami ve fn_planning_horizon_end).
|
||||
_DAILY_FALLBACK_HORIZON_HOURS = 1.0
|
||||
# Shadow cena zbytkové energie na konci horizontu: - (avg_buy * FACTOR / 1000) * soc[T-1] (Kč; soc v Wh).
|
||||
INTERVAL_H = 0.25 # 15 minut v hodinách
|
||||
CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A
|
||||
SOLVER_TIME_LIMIT = 10 # sekund
|
||||
# MILP: významný export ge (W) ⇒ koncové soc[t] ≥ podlaha; mimo arbitrážní relax je to arb_base_wh
|
||||
# (rezerva z DB). Při relaxaci spodku před extrémně záporným buy je podlaha soc_panel_min[t]
|
||||
# (planner floor), jinak by šlo jen do zátěže a nešlo by „vypustit do sítě“ před levným nákupem.
|
||||
GE_MIN_EXPORT_W = 1.0
|
||||
# Dvouprůchodové solve: stop když acquisition z pass1 vs pass2 se liší méně než (Kč/kWh).
|
||||
ACQUISITION_TWO_PASS_EPS_KWH = 0.05
|
||||
# Load-first (Deye): PV nejdřív pokryje load+EV+TČ; bc_pv/ge_pv jen z pv_sp (přebytek).
|
||||
LOAD_FIRST_INCENTIVE_CZK_KWH = 0.05
|
||||
# Dokud je kotva pro hluboký dump (první sell < 0 v horizontu, jinak první extrémní buy) dál než
|
||||
# tento počet 15min slotů, držíme plánovací spodek na rezervě (arb_base_wh) místo planner floor —
|
||||
# priorita: beze „ztráty na prodeji“ (sell >= 0) držet buffer, hluboký vývoz až těsně před záporným prodejem.
|
||||
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8
|
||||
# Měkká kotva: chceme být u planner floor už v posledním slotu před prvním sell < 0.
|
||||
# Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila
|
||||
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
|
||||
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
|
||||
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail).
|
||||
PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
|
||||
# Curtailment při sell<0 + allow_charge: nesmí být téměř zdarma oproti nabíjení (BA81).
|
||||
NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0
|
||||
# Odměna v objective za FVE→baterie při sell<0 (doplňuje shortfall; BA81 fixed tarif).
|
||||
NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8
|
||||
# Měkký tlak: v okně sell<0 dobít na soc_max (ne zastavit na ~94 % kvůli curtail).
|
||||
NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35
|
||||
# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek.
|
||||
NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0
|
||||
# Fáze sell<0 (v32): ASAP na prep_soc %, tail rampa na soc_max.
|
||||
NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.85
|
||||
NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH = 60.0
|
||||
# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž).
|
||||
EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12
|
||||
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
|
||||
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
|
||||
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
|
||||
PLANNER_BUILD_TAG = "2026-06-06-home01-strict-late-replan-v5"
|
||||
SOLVER_RELAX_STEPS: tuple[str, ...] = (
|
||||
"strict",
|
||||
"relaxed_expensive_import",
|
||||
"relaxed_neg_buy_charge",
|
||||
"relaxed_neg_prep_hold_only",
|
||||
"relaxed_neg_prep_window",
|
||||
"neg_sell_phases_fallback",
|
||||
"relaxed_pos_sell_ge_block",
|
||||
"relaxed_solver_masks",
|
||||
)
|
||||
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
|
||||
DAWN_LOW_PV_NO_CURTAIL_W = 1500
|
||||
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
|
||||
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
|
||||
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
|
||||
NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0
|
||||
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
|
||||
NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
|
||||
# Kotva: SoC na konci večera 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
|
||||
|
||||
# --- EV anti-fragmentace (Fix B, solver_v2) ---
|
||||
# IEC 61851 min. nabíjecí proud (A) na fázi. 3f wallbox NEumí jet 1f trickle pod
|
||||
# 6 A na všech fázích → fyzikální dolní mez dávky je 6 A × phases × napětí.
|
||||
EV_MIN_CHARGE_CURRENT_A = 6.0
|
||||
# Síťové napětí fáze (V) pro odhad 3f power floor (3f wallbox: 6 A × 3 × 230 ≈ 4140 W).
|
||||
EV_PHASE_VOLTAGE_V = 230.0
|
||||
# Práh, od kolika fází považujeme wallbox za vícefázový (≥ tato hodnota → power floor
|
||||
# z fází; jinak držíme min_power_w z DB). 3 = jen čistě 3f wallbox dostane 3f floor.
|
||||
EV_MULTIPHASE_FLOOR_MIN_PHASES = 3
|
||||
472
backend/services/planning/db_io.py
Normal file
472
backend/services/planning/db_io.py
Normal file
@@ -0,0 +1,472 @@
|
||||
# backend/services/planning/db_io.py
|
||||
#
|
||||
# EMS plánovač – DB vrstva: načtení site contextu a slotů, uložení běhu
|
||||
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
# Jediné SQL: select ems.fn_* (SQL-first pravidlo CLAUDE.md).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
|
||||
from services.planning.constants import (
|
||||
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
|
||||
PLANNER_BUILD_TAG,
|
||||
)
|
||||
from services.planning.types import (
|
||||
PlannerSolverError,
|
||||
PlanningSlot,
|
||||
_parse_json_dt,
|
||||
_slot_float_nullable,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
|
||||
if obj is None or obj == []:
|
||||
return None
|
||||
if isinstance(obj, str):
|
||||
obj = json.loads(obj)
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
# target_deadline SMÍ být None: oportunistická session (auto nad targetem,
|
||||
# nebo bez nastaveného cíle) zůstává v plánu kvůli headroomu i jako známá
|
||||
# zátěž. Tvrdý deadline constraint se aplikuje jen při energy_needed_wh > 0
|
||||
# (a needed > 0 nastane jen s deadlinem). Dřív se taková session zahazovala
|
||||
# (None) a plánovač pak neviděl zátěž auta — bug 2026-06-13.
|
||||
td = _parse_json_dt(obj.get("target_deadline"))
|
||||
return SimpleNamespace(
|
||||
target_deadline=td,
|
||||
energy_needed_wh=float(obj.get("energy_needed_wh") or 0.0),
|
||||
headroom_wh=float(obj.get("headroom_wh") or 0.0),
|
||||
opportunistic_value_czk_kwh=float(obj.get("opportunistic_value_czk_kwh") or 0.0),
|
||||
)
|
||||
|
||||
async def _load_site_context(site_id: int, db):
|
||||
"""
|
||||
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV, režim a TUV statistiky (SQL).
|
||||
"""
|
||||
raw = await db.fetchval(
|
||||
"select ems.fn_planning_site_context($1::int)",
|
||||
site_id,
|
||||
)
|
||||
ctx = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
if ctx.get("error") == "unknown_site":
|
||||
raise RuntimeError(f"Site not found: {site_id}")
|
||||
|
||||
b = ctx["battery"]
|
||||
ec_i = int(b["max_charge_power_w"])
|
||||
ed_i = int(b["max_discharge_power_w"])
|
||||
planner_soc_max = float(b.get("planner_soc_max_wh", b["soc_max_wh"]))
|
||||
floor_pct = b.get("planner_discharge_floor_percent")
|
||||
buy_thr = b.get("planner_extreme_buy_threshold_czk_kwh")
|
||||
relax_prewin = b.get("planner_discharge_relax_prewindow_slots")
|
||||
battery = SimpleNamespace(
|
||||
usable_capacity_wh=float(b["usable_capacity_wh"]),
|
||||
min_soc_wh=float(b["min_soc_wh"]),
|
||||
arb_floor_wh=float(b["arb_floor_wh"]),
|
||||
reserve_soc_wh=float(b["reserve_soc_wh"]),
|
||||
soc_max_wh=planner_soc_max,
|
||||
charge_efficiency=float(b["charge_efficiency"]),
|
||||
discharge_efficiency=float(b["discharge_efficiency"]),
|
||||
degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]),
|
||||
max_charge_power_w=ec_i,
|
||||
max_discharge_power_w=ed_i,
|
||||
charge_slot_buffer=float(b["charge_slot_buffer"])
|
||||
if b.get("charge_slot_buffer") is not None
|
||||
else 0,
|
||||
discharge_slot_buffer=float(b["discharge_slot_buffer"])
|
||||
if b.get("discharge_slot_buffer") is not None
|
||||
else 0,
|
||||
planner_extreme_buy_threshold_czk_kwh=float(buy_thr) if buy_thr is not None else -5.0,
|
||||
planner_discharge_floor_percent=float(floor_pct) if floor_pct is not None else None,
|
||||
planner_discharge_relax_prewindow_slots=int(relax_prewin)
|
||||
if relax_prewin is not None
|
||||
else DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
|
||||
planner_terminal_soc_value_factor=float(b["planner_terminal_soc_value_factor"]),
|
||||
planner_daytime_charge_target_enabled=bool(
|
||||
b.get("planner_daytime_charge_target_enabled", True)
|
||||
),
|
||||
planner_night_baseload_buffer_percent=float(
|
||||
b.get("planner_night_baseload_buffer_percent") or 20.0
|
||||
),
|
||||
planner_daytime_charge_price_quantile=float(
|
||||
b.get("planner_daytime_charge_price_quantile") or 0.70
|
||||
),
|
||||
planner_charge_commitment_penalty_czk_kwh=float(
|
||||
b.get("planner_charge_commitment_penalty_czk_kwh") or 0.20
|
||||
),
|
||||
planner_neg_sell_prep_soc_percent=float(
|
||||
b.get("planner_neg_sell_prep_soc_percent") or 80.0
|
||||
),
|
||||
planner_neg_sell_full_soc_tail_slots=int(
|
||||
b.get("planner_neg_sell_full_soc_tail_slots") or 4
|
||||
),
|
||||
planner_safety_soc_risk_factor=float(
|
||||
b.get("planner_safety_soc_risk_factor") or 0.0
|
||||
),
|
||||
planner_pv_risk_frontload_czk_kwh=float(
|
||||
b.get("planner_pv_risk_frontload_czk_kwh") or 0.0
|
||||
),
|
||||
planner_neg_sell_vent_min_sell_czk_kwh=(
|
||||
float(b["planner_neg_sell_vent_min_sell_czk_kwh"])
|
||||
if b.get("planner_neg_sell_vent_min_sell_czk_kwh") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
hpj = ctx["heat_pump"]
|
||||
heat_pump = SimpleNamespace(
|
||||
rated_heating_power_w=int(hpj["rated_heating_power_w"]),
|
||||
tuv_min_temp_c=float(hpj["tuv_min_temp_c"]),
|
||||
tuv_target_temp_c=float(hpj["tuv_target_temp_c"]),
|
||||
)
|
||||
|
||||
g = ctx["grid"]
|
||||
m = ctx.get("market") or {}
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=int(g["max_import_power_w"]),
|
||||
max_export_power_w=int(g["max_export_power_w"]),
|
||||
block_export_on_negative_sell=bool(g.get("block_export_on_negative_sell") or False),
|
||||
deye_gen_microinverter_cutoff_enabled=bool(g.get("deye_gen_microinverter_cutoff_enabled") or False),
|
||||
purchase_pricing_mode=str(m.get("purchase_pricing_mode") or "spot").strip().lower(),
|
||||
sale_pricing_mode=str(m.get("sale_pricing_mode") or "spot").strip().lower(),
|
||||
)
|
||||
|
||||
vehicles: list[SimpleNamespace] = []
|
||||
for v in ctx.get("vehicles") or []:
|
||||
vehicles.append(
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=int(v["max_charge_power_w"]),
|
||||
min_power_w=int(v.get("min_power_w") or 0),
|
||||
# phases / planner_ev_start_penalty_czk: parametry wallboxu pro
|
||||
# anti-fragmentaci EV v solver_v2 (Fix B). Default phases=3 (typický
|
||||
# 3f wallbox), start penalta 0 = no-op (golden-safe).
|
||||
phases=int(v.get("phases") or 3),
|
||||
planner_ev_start_penalty_czk=float(
|
||||
v.get("planner_ev_start_penalty_czk") or 0.0
|
||||
),
|
||||
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
|
||||
default_target_soc_pct=float(v["default_target_soc_pct"]),
|
||||
)
|
||||
)
|
||||
while len(vehicles) < 2:
|
||||
vehicles.append(
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
min_power_w=0,
|
||||
phases=3,
|
||||
planner_ev_start_penalty_czk=0.0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
)
|
||||
)
|
||||
|
||||
ev_raw = ctx.get("ev_sessions") or []
|
||||
ev_sessions = [
|
||||
_ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None,
|
||||
_ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None,
|
||||
]
|
||||
|
||||
soc_wh = float(ctx["soc_wh"])
|
||||
tuv_temp = float(ctx["tuv_temp"])
|
||||
operating_mode = ctx.get("operating_mode")
|
||||
|
||||
tuv_stats: dict[tuple[int, int], float] = {}
|
||||
for row in ctx.get("tuv_delta_stats") or []:
|
||||
tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"])
|
||||
|
||||
return (
|
||||
battery,
|
||||
heat_pump,
|
||||
grid,
|
||||
vehicles,
|
||||
ev_sessions,
|
||||
soc_wh,
|
||||
tuv_temp,
|
||||
operating_mode,
|
||||
tuv_stats,
|
||||
)
|
||||
|
||||
async def _load_previous_plan_charge_commitment_prev_w(
|
||||
site_id: int,
|
||||
slots: list[PlanningSlot],
|
||||
db,
|
||||
) -> list[Optional[float]]:
|
||||
"""
|
||||
Pro rolling replan: z aktivního plánu načte battery_setpoint_w pro shodné sloty.
|
||||
Kotva měkkého commitmentu jen když předchozí plán chtěl nabíjet z PV přebytku (viz podmínky).
|
||||
"""
|
||||
if not slots:
|
||||
return []
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
select pi.interval_start,
|
||||
pi.battery_setpoint_w,
|
||||
pi.grid_setpoint_w,
|
||||
coalesce(pi.pv_a_forecast_solver_w, 0) as pva,
|
||||
coalesce(pi.pv_b_forecast_solver_w, 0) as pvb,
|
||||
coalesce(pi.load_baseline_w, 0) as lb
|
||||
from ems.planning_interval pi
|
||||
inner join ems.planning_run pr on pr.id = pi.run_id
|
||||
where pr.site_id = $1::int
|
||||
and pr.status = 'active'
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
by_start = {r["interval_start"]: r for r in rows}
|
||||
out: list[Optional[float]] = []
|
||||
for s in slots:
|
||||
r = by_start.get(s.interval_start)
|
||||
if r is None:
|
||||
out.append(None)
|
||||
continue
|
||||
bw = int(r["battery_setpoint_w"] or 0)
|
||||
gw = int(r["grid_setpoint_w"] or 0)
|
||||
pva = int(r["pva"] or 0)
|
||||
pvb = int(r["pvb"] or 0)
|
||||
lb = int(r["lb"] or 0)
|
||||
# Commitment má kotvit jen „nabíjení z PV přebytku“, ne situace kdy plán současně
|
||||
# výrazně exportuje do sítě (typicky charge while exporting). To by stabilizovalo špatný cyklus.
|
||||
if bw > 500 and (pva + pvb) > lb and gw <= 0 and gw >= -500:
|
||||
out.append(float(bw))
|
||||
else:
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
async def _load_slots(
|
||||
site_id: int,
|
||||
from_dt: datetime,
|
||||
to_dt: datetime,
|
||||
db,
|
||||
*,
|
||||
soc_wh: float,
|
||||
) -> list[PlanningSlot]:
|
||||
"""15min sloty z ems.fn_load_planning_slots_full."""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
|
||||
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
|
||||
ev1_connected, ev2_connected, allow_charge, allow_discharge_export,
|
||||
night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh,
|
||||
future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh,
|
||||
is_daytime_pv_surplus_slot,
|
||||
charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at,
|
||||
min_buy_before_cutoff_czk_kwh, pv_charge_wh_ahead, neg_buy_wh_ahead,
|
||||
grid_charge_suppressed_reason,
|
||||
charge_target_wh, pre_window_wh, in_window_wh,
|
||||
charge_slot_wh, charge_cum_wh, charge_layer, charge_slot_reason
|
||||
from ems.fn_load_planning_slots_full(
|
||||
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
from_dt,
|
||||
to_dt,
|
||||
soc_wh,
|
||||
)
|
||||
out: list[PlanningSlot] = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
out.append(
|
||||
PlanningSlot(
|
||||
interval_start=d["interval_start"],
|
||||
buy_price=float(d["buy_price"]),
|
||||
sell_price=float(d["sell_price"]),
|
||||
pv_a_forecast_w=int(d["pv_a_forecast_w"] or 0),
|
||||
pv_b_forecast_w=int(d["pv_b_forecast_w"] or 0),
|
||||
load_baseline_w=int(d["load_baseline_w"] or 0),
|
||||
ev1_connected=bool(d["ev1_connected"]),
|
||||
ev2_connected=bool(d["ev2_connected"]),
|
||||
is_predicted_price=bool(d.get("is_predicted_price")),
|
||||
allow_charge=bool(d.get("allow_charge", True)),
|
||||
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
|
||||
night_baseload_target_wh=_slot_float_nullable(d, "night_baseload_target_wh"),
|
||||
night_baseload_buffer_wh=_slot_float_nullable(d, "night_baseload_buffer_wh"),
|
||||
safety_soc_target_wh=_slot_float_nullable(d, "safety_soc_target_wh"),
|
||||
future_avoided_buy_czk_kwh=_slot_float_nullable(d, "future_avoided_buy_czk_kwh"),
|
||||
future_sell_opportunity_czk_kwh=_slot_float_nullable(
|
||||
d, "future_sell_opportunity_czk_kwh"
|
||||
),
|
||||
is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)),
|
||||
charge_acquisition_buy_czk_kwh=_slot_float_nullable(
|
||||
d, "charge_acquisition_buy_czk_kwh"
|
||||
),
|
||||
charge_acquisition_cutoff_at=d.get("charge_acquisition_cutoff_at"),
|
||||
min_buy_before_cutoff_czk_kwh=_slot_float_nullable(
|
||||
d, "min_buy_before_cutoff_czk_kwh"
|
||||
),
|
||||
pv_charge_wh_ahead=_slot_float_nullable(d, "pv_charge_wh_ahead"),
|
||||
neg_buy_wh_ahead=_slot_float_nullable(d, "neg_buy_wh_ahead"),
|
||||
grid_charge_suppressed_reason=d.get("grid_charge_suppressed_reason"),
|
||||
charge_target_wh=_slot_float_nullable(d, "charge_target_wh"),
|
||||
pre_window_wh=_slot_float_nullable(d, "pre_window_wh"),
|
||||
in_window_wh=_slot_float_nullable(d, "in_window_wh"),
|
||||
charge_slot_wh=_slot_float_nullable(d, "charge_slot_wh"),
|
||||
charge_cum_wh=_slot_float_nullable(d, "charge_cum_wh"),
|
||||
charge_layer=d.get("charge_layer"),
|
||||
charge_slot_reason=d.get("charge_slot_reason"),
|
||||
)
|
||||
)
|
||||
if not out:
|
||||
raise RuntimeError(
|
||||
"No planning slots available – check market prices and horizon settings"
|
||||
)
|
||||
if any(s.is_predicted_price for s in out):
|
||||
logger.warning(
|
||||
"[site=%s] Unexpected predicted-price slots in planning horizon",
|
||||
site_id,
|
||||
)
|
||||
return out
|
||||
|
||||
def _build_slot_inputs(
|
||||
slots_raw_pv: list[PlanningSlot],
|
||||
slots_solver: list[PlanningSlot],
|
||||
) -> list[tuple[int, int, int, int, int]]:
|
||||
"""(load_baseline_w, pv_a_raw, pv_b_raw, pv_a_solver, pv_b_solver) pro každý slot."""
|
||||
if len(slots_raw_pv) != len(slots_solver):
|
||||
raise ValueError("slots_raw_pv and slots_solver length mismatch")
|
||||
out: list[tuple[int, int, int, int, int]] = []
|
||||
for raw, sol in zip(slots_raw_pv, slots_solver):
|
||||
out.append(
|
||||
(
|
||||
int(raw.load_baseline_w),
|
||||
int(raw.pv_a_forecast_w),
|
||||
int(raw.pv_b_forecast_w),
|
||||
int(sol.pv_a_forecast_w),
|
||||
int(sol.pv_b_forecast_w),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
async def _save_planning_run(
|
||||
site_id, results, horizon_from, horizon_to,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_wh, duration_ms, correction, db,
|
||||
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
||||
*,
|
||||
activate_run: bool = True,
|
||||
solver_snapshot: Optional[dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
|
||||
if slot_inputs is not None and len(slot_inputs) != len(results):
|
||||
raise ValueError("slot_inputs and results length mismatch")
|
||||
run_meta: dict[str, Any] = {
|
||||
"run_type": run_type,
|
||||
"triggered_by": triggered_by,
|
||||
"replan_from": replan_from.isoformat() if replan_from else None,
|
||||
"soc_at_replan_wh": soc_wh,
|
||||
"solver_duration_ms": duration_ms,
|
||||
"forecast_correction_factor": correction,
|
||||
}
|
||||
if solver_snapshot is not None:
|
||||
run_meta["solver_params"] = solver_snapshot
|
||||
intervals: list[dict] = []
|
||||
for i, r in enumerate(results):
|
||||
row: dict = {
|
||||
"interval_start": r.interval_start.isoformat()
|
||||
if hasattr(r.interval_start, "isoformat")
|
||||
else r.interval_start,
|
||||
"battery_setpoint_w": r.battery_setpoint_w,
|
||||
"battery_soc_target_pct": r.battery_soc_target,
|
||||
"grid_setpoint_w": r.grid_setpoint_w,
|
||||
"export_limit_w": r.export_limit_w,
|
||||
"export_mode": r.export_mode,
|
||||
"deye_physical_mode": r.deye_physical_mode,
|
||||
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
|
||||
"ev1_setpoint_w": r.ev1_setpoint_w,
|
||||
"ev2_setpoint_w": r.ev2_setpoint_w,
|
||||
"ev1_via_bat_w": r.ev1_via_bat_w,
|
||||
"ev2_via_bat_w": r.ev2_via_bat_w,
|
||||
"heat_pump_enabled": r.heat_pump_enabled,
|
||||
"heat_pump_setpoint_w": r.heat_pump_setpoint_w,
|
||||
"pv_a_curtailed_w": r.pv_a_curtailed_w,
|
||||
"expected_cost_czk": float(r.expected_cost_czk),
|
||||
"cashflow_czk": float(r.cashflow_czk),
|
||||
"battery_arbitrage_czk": float(r.battery_arbitrage_czk),
|
||||
"penalty_czk": float(r.penalty_czk),
|
||||
"green_bonus_czk": float(r.green_bonus_czk),
|
||||
"effective_buy_price": float(r.effective_buy_price),
|
||||
"effective_sell_price": float(r.effective_sell_price),
|
||||
"is_predicted_price": r.is_predicted_price,
|
||||
}
|
||||
if slot_inputs is not None:
|
||||
si = slot_inputs[i]
|
||||
row["load_baseline_w"] = si[0]
|
||||
row["pv_a_forecast_raw_w"] = si[1]
|
||||
row["pv_b_forecast_raw_w"] = si[2]
|
||||
row["pv_a_forecast_solver_w"] = si[3]
|
||||
row["pv_b_forecast_solver_w"] = si[4]
|
||||
intervals.append(row)
|
||||
|
||||
return int(
|
||||
await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_run_commit(
|
||||
$1::int, $2::timestamptz, $3::timestamptz,
|
||||
$4::jsonb, $5::jsonb, $6::boolean
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
horizon_from,
|
||||
horizon_to,
|
||||
json.dumps(run_meta, default=str),
|
||||
json.dumps(intervals, default=str),
|
||||
activate_run,
|
||||
)
|
||||
)
|
||||
|
||||
async def _save_failed_planning_run(
|
||||
site_id: int,
|
||||
horizon_from: datetime,
|
||||
horizon_to: datetime,
|
||||
*,
|
||||
run_type: str,
|
||||
triggered_by: str,
|
||||
replan_from: datetime | None,
|
||||
soc_wh: float,
|
||||
correction: float,
|
||||
db,
|
||||
error: PlannerSolverError,
|
||||
slot_count: int | None = None,
|
||||
) -> int:
|
||||
"""Uloží neúspěšný běh plánovače (status=failed); aktivní plán nemění."""
|
||||
run_meta: dict[str, Any] = {
|
||||
"run_type": run_type,
|
||||
"triggered_by": triggered_by,
|
||||
"replan_from": replan_from.isoformat() if replan_from else None,
|
||||
"soc_at_replan_wh": soc_wh,
|
||||
"solver_duration_ms": 0,
|
||||
"forecast_correction_factor": correction,
|
||||
"error_text": str(error),
|
||||
"solver_params": {
|
||||
"status": "failed",
|
||||
"planner_build_tag": PLANNER_BUILD_TAG,
|
||||
"solver_status": error.solver_status,
|
||||
"relax_chain": error.relax_chain,
|
||||
"slot_count": slot_count,
|
||||
},
|
||||
}
|
||||
run_id = int(
|
||||
await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_run_fail(
|
||||
$1::int, $2::timestamptz, $3::timestamptz, $4::jsonb
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
horizon_from,
|
||||
horizon_to,
|
||||
json.dumps(run_meta, default=str),
|
||||
)
|
||||
)
|
||||
logger.error(
|
||||
"[site=%s] Planning solver failed run_id=%s: %s relax_chain=%s",
|
||||
site_id,
|
||||
run_id,
|
||||
error,
|
||||
error.relax_chain,
|
||||
)
|
||||
return run_id
|
||||
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
623
backend/services/planning/solver_v2.py
Normal file
623
backend/services/planning/solver_v2.py
Normal file
@@ -0,0 +1,623 @@
|
||||
# backend/services/planning/solver_v2.py
|
||||
#
|
||||
# EMS plánovač v2 — ČISTÉ ekonomické jádro (Fáze 3).
|
||||
#
|
||||
# Filozofie: objective = reálné peníze (nákup − prodej + degradace − terminal
|
||||
# hodnota energie). Žádné heuristické penalty z constants.py, žádné pre-solver
|
||||
# fáze/okna/kotvy. Chování (neg-sell příprava, evening export, arbitráž) má
|
||||
# VYPLYNOUT z cen a fyziky, ne z ručně laděných vah.
|
||||
#
|
||||
# Co zůstává (tvrdá pravidla — fyzika, HW, CLAUDE.md):
|
||||
# - bilance sběrnice, SoC dynamika s účinnostmi, výkonové stropy
|
||||
# - curtailment jen pole A (pravidlo 5); GEN cutoff binárka pole B (pravidlo 6)
|
||||
# - block_export_on_negative_sell → ge == 0 při sell < 0 (pravidlo 6, KV1)
|
||||
# - buy < 0 → ge == 0 (žádná pumpa 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)
|
||||
# - noční SoC polštář: plán nesmí kalkulovat s vybitím až na min_soc — chyba
|
||||
# predikce noční spotřeby by znamenala neplánovaný noční nákup. Velikost
|
||||
# z DB (planner_night_baseload_buffer_percent → slot.night_baseload_buffer_wh,
|
||||
# klesá k 0 do rána); porušení je PLACENÉ cenou buy daného slotu (riziko
|
||||
# zpětného nákupu), takže extrémní sell špička ho smí racionálně prodat.
|
||||
# - PV-risk front-load: v okně sell<0 je nabíjení z PV zdarma kdykoliv →
|
||||
# indiference v čase; odložení ale spoléhá na predikci (večerní mrak).
|
||||
# Malá prémie za držení energie dřív (DB planner_pv_risk_frontload_czk_kwh)
|
||||
# vede k "nabít plným výkonem hned, pak řezat A" — emergentně, bez rampy.
|
||||
# - oportunistické EV („měkký cíl"): nad tvrdý target smí auto vzít až
|
||||
# headroom_wh (do 100 %), oceněno opportunistic_value_czk_kwh (= budoucí
|
||||
# ušetřené nabíjení, session override → vozidlo, DB) — kupuje jen velmi
|
||||
# levnou/zápornou energii. Dekompozice Σ(EV energie) == needed − unmet + opp
|
||||
# zároveň stropuje celkovou energii do auta (dřív při buy<0 bez stropu);
|
||||
# opp vrstva NENÍ vázaná deadline (auto bývá doma dál, odjezd řeší rolling
|
||||
# replan); bez session je EV == 0 (stop-session). Deadline suma jde po
|
||||
# slot PŘED deadline (slot začínající v deadline už nepatří „do deadline").
|
||||
# - min. výkon wallboxu (asset_ev_charger.min_power_w, 6 A ≈ 1380 W):
|
||||
# binárka ev_on → setpoint ∈ {0} ∪ [min_power_w, max]; ev_direct ≤ gi + PV
|
||||
# (fyzikální split direct/via_bat). Reporting: kWh přes ev_via_bat plní
|
||||
# battery_arbitrage_czk oportunitní cenou (min sell exportního slotu dne,
|
||||
# jinak terminal value) — slotový buy pro ně neplatí. U TŘÍFÁZOVÉHO wallboxu
|
||||
# (asset_ev_charger.phases ≥ 3) je floor zvednut na 6 A × fáze × 230 V (≈ 4140
|
||||
# W pro 3f) místo 1f ~1380 W → ruší sub-6A 1f trickle drobky (cap = max výkon
|
||||
# vozidla). Fáze/min jdou z DB přes vehicle kontext (R__039).
|
||||
# - anti-fragmentace EV (Fix B): per-slot binárka ev_on (vždy při floor NEBO
|
||||
# start penaltě) + hrana ev_start[t] ≥ ev_on[t] − ev_on[t−1]; objektiv +=
|
||||
# Σ ev_start × asset_ev_charger.planner_ev_start_penalty_czk (Kč). Drobná
|
||||
# penalta (filozofie v2: nejistota/opotřebení = cena, ne tvrdá priorita) →
|
||||
# souvislá dávka místo rozsekání. Default 0 = no-op (golden-safe).
|
||||
# - denní SoC rampa: deficit pod slot.safety_soc_target_wh (R__063: reserve →
|
||||
# reserve+noc, 6–19 h) platí za slot nájem buy×faktor (DB
|
||||
# planner_safety_soc_risk_factor) — ráno se nejdřív dotáhne rezerva
|
||||
# (nenadálý odběr by se kupoval draho), pak se prodává.
|
||||
#
|
||||
# Vědomé odchylky od v1 (změří harness):
|
||||
# - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to
|
||||
# výstupy charge-slot-budget heuristik, ne fyzika)
|
||||
# - EV náklady jen přes bilanci (v1 je účtuje navíc v objective — dvojí započtení)
|
||||
# - import breaker je tvrdý strop (v1 měkký s 10 Kč/kWh)
|
||||
# - nedodaná EV energie má explicitní cenu místo infeasibility
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import pulp
|
||||
|
||||
from services.planning.constants import (
|
||||
EV_MIN_CHARGE_CURRENT_A,
|
||||
EV_MULTIPHASE_FLOOR_MIN_PHASES,
|
||||
EV_PHASE_VOLTAGE_V,
|
||||
INTERVAL_H,
|
||||
SOLVER_TIME_LIMIT,
|
||||
)
|
||||
from services.planning.types import (
|
||||
DispatchResult,
|
||||
PlanningSlot,
|
||||
_prague_calendar_date,
|
||||
_prague_dow_hour,
|
||||
)
|
||||
from services.planning.heuristics import _dispatch_grid_setpoint_w
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
V2_BUILD_TAG = "v2-ev-accounting-2026-06-12"
|
||||
|
||||
# Cena za vypnutí GEN portu (mikroinvertory pole B): reálné riziko/opotřebení
|
||||
# cyklování stykače — drobná, ale nenulová, aby cutoff platil jen při sell < 0.
|
||||
V2_GEN_CUTOFF_CZK_KWH = 2.0
|
||||
# SELF_SUSTAIN: export je nežádoucí, ale tvrdé ge=0 by s neřiditelným polem B
|
||||
# a plnou baterií bylo infeasible — vysoká cena funguje jako ventil.
|
||||
V2_SELF_SUSTAIN_EXPORT_CZK_KWH = 100.0
|
||||
# Cena nedodané EV energie do deadline (Kč/kWh) — místo tvrdé infeasibility.
|
||||
V2_EV_UNMET_CZK_KWH = 50.0
|
||||
# Nepatrný tie-break proti zbytečnému curtailu při cenové indiferenci (Kč/kWh).
|
||||
V2_CURTAIL_TIEBREAK_CZK_KWH = 0.001
|
||||
|
||||
|
||||
def _terminal_value_czk_per_wh(slots: list[PlanningSlot], battery: Any) -> float:
|
||||
"""Shadow cena zbytkové energie: průměrný buy prvních 24 h × DB faktor (pravidlo 16)."""
|
||||
n24 = min(len(slots), int(24 / INTERVAL_H))
|
||||
avg_buy = sum(float(s.buy_price) for s in slots[:n24]) / max(1, n24)
|
||||
factor = float(getattr(battery, "planner_terminal_soc_value_factor", 1.0) or 1.0)
|
||||
return max(0.0, avg_buy) * factor / 1000.0
|
||||
|
||||
|
||||
def _arb_floor_wh(battery: Any) -> float:
|
||||
"""Podlaha SoC pro export z baterie (pravidlo 19): ekonomická rezerva z DB."""
|
||||
floor = getattr(battery, "arb_floor_wh", None)
|
||||
if floor is None:
|
||||
floor = getattr(battery, "reserve_soc_wh", None)
|
||||
return max(float(floor or 0.0), float(battery.min_soc_wh))
|
||||
|
||||
|
||||
def solve_dispatch_v2(
|
||||
slots: list[PlanningSlot],
|
||||
battery: Any,
|
||||
heat_pump: Any,
|
||||
grid: Any,
|
||||
ev_sessions: list,
|
||||
vehicles: list,
|
||||
current_soc_wh: float,
|
||||
current_tuv_temp_c: float,
|
||||
*,
|
||||
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
|
||||
operating_mode: str = "AUTO",
|
||||
planner_version: str | None = None,
|
||||
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
|
||||
"""Čistý ekonomický MILP; rozhraní kompatibilní se solve_dispatch (v1)."""
|
||||
if not slots:
|
||||
raise RuntimeError("solve_dispatch_v2 requires at least one slot")
|
||||
t0 = time.monotonic()
|
||||
T = len(slots)
|
||||
om = (operating_mode or "AUTO").upper()
|
||||
EV = min(len(vehicles), 2)
|
||||
|
||||
max_imp = float(grid.max_import_power_w)
|
||||
max_exp = float(grid.max_export_power_w)
|
||||
max_chg = float(battery.max_charge_power_w)
|
||||
max_dis = float(battery.max_discharge_power_w)
|
||||
eff_c = float(battery.charge_efficiency)
|
||||
eff_d = float(battery.discharge_efficiency)
|
||||
deg = float(battery.degradation_cost_czk_kwh)
|
||||
soc_min = float(battery.min_soc_wh)
|
||||
soc_max = float(battery.soc_max_wh)
|
||||
usable = float(battery.usable_capacity_wh)
|
||||
arb_floor = _arb_floor_wh(battery)
|
||||
terminal = _terminal_value_czk_per_wh(slots, battery)
|
||||
block_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
|
||||
gen_cutoff_avail = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False))
|
||||
soc0 = min(max(float(current_soc_wh), soc_min), soc_max)
|
||||
|
||||
prob = pulp.LpProblem("dispatch_v2", pulp.LpMinimize)
|
||||
|
||||
gi = [pulp.LpVariable(f"gi_{t}", 0, max_imp) for t in range(T)]
|
||||
ge_pv = [pulp.LpVariable(f"gepv_{t}", 0, max_exp) for t in range(T)]
|
||||
ge_bat = [pulp.LpVariable(f"gebat_{t}", 0, max_exp) for t in range(T)]
|
||||
bc_pv = [pulp.LpVariable(f"bcpv_{t}", 0, max_chg) for t in range(T)]
|
||||
bc_gi = [pulp.LpVariable(f"bcgi_{t}", 0, max_chg) for t in range(T)]
|
||||
bd = [pulp.LpVariable(f"bd_{t}", 0, max_dis) for t in range(T)]
|
||||
ca = [pulp.LpVariable(f"ca_{t}", 0, max(0, int(slots[t].pv_a_forecast_w))) for t in range(T)]
|
||||
soc = [pulp.LpVariable(f"soc_{t}", soc_min, soc_max) for t in range(T)]
|
||||
hp = [pulp.LpVariable(f"hp_{t}", 0, float(heat_pump.rated_heating_power_w)) for t in range(T)]
|
||||
y_imp = [pulp.LpVariable(f"yimp_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
z_exp = [pulp.LpVariable(f"zexp_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
z_gen = (
|
||||
[pulp.LpVariable(f"zgen_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
if gen_cutoff_avail
|
||||
else None
|
||||
)
|
||||
ev_direct = [
|
||||
[
|
||||
pulp.LpVariable(f"evd_{e}_{t}", 0, min(float(vehicles[e].max_charge_power_w), max_imp))
|
||||
for t in range(T)
|
||||
]
|
||||
for e in range(EV)
|
||||
]
|
||||
ev_via_bat = [
|
||||
[
|
||||
pulp.LpVariable(f"evb_{e}_{t}", 0, float(vehicles[e].max_charge_power_w))
|
||||
for t in range(T)
|
||||
]
|
||||
for e in range(EV)
|
||||
]
|
||||
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
|
||||
ev_opp: list = [] # (var, value_czk_kwh) — energie nad target (měkký cíl)
|
||||
ev_start_terms: list = [] # (ev_start var, penalta Kč) — anti-fragmentace (Fix B)
|
||||
|
||||
def _ev_min_power_w(e: int) -> float:
|
||||
"""Dolní mez nabíjecí dávky (W): u 3f wallboxu fyzikální 6 A × fáze × napětí
|
||||
(≈ 4140 W) místo 1f ~1380 W → zruší sub-6A 1f trickle. Stropuje se max
|
||||
výkonem vozidla (jinak by připojený slot byl infeasible). Bez spolehlivého
|
||||
počtu fází padá zpět na min_power_w z DB."""
|
||||
veh = vehicles[e]
|
||||
base_min = max(0.0, float(getattr(veh, "min_power_w", 0) or 0))
|
||||
phases = int(getattr(veh, "phases", 0) or 0)
|
||||
ev_max = float(veh.max_charge_power_w)
|
||||
if phases >= EV_MULTIPHASE_FLOOR_MIN_PHASES:
|
||||
floor = EV_MIN_CHARGE_CURRENT_A * phases * EV_PHASE_VOLTAGE_V
|
||||
base_min = max(base_min, floor)
|
||||
# strop max výkonem vozidla — floor nesmí překročit, co auto/wallbox umí
|
||||
if ev_max > 0:
|
||||
base_min = min(base_min, ev_max)
|
||||
return base_min
|
||||
|
||||
def _ev_start_penalty_czk(e: int) -> float:
|
||||
return max(0.0, float(getattr(vehicles[e], "planner_ev_start_penalty_czk", 0.0) or 0.0))
|
||||
|
||||
ev_min_w = [_ev_min_power_w(e) for e in range(EV)]
|
||||
ev_start_pen = [_ev_start_penalty_czk(e) for e in range(EV)]
|
||||
# ev_on[e][t]: zapnutost wallboxu v slotu. Vždy potřeba, pokud platí min-power
|
||||
# floor (gate) NEBO start penalta (anti-fragmentace). ev_start[e][t]: náběžná
|
||||
# hrana ev_on (start nové dávky) — jen když je start penalta > 0 (jinak žádný
|
||||
# extra MILP balast a default 0 = no-op, golden-safe).
|
||||
ev_needs_on = [(ev_min_w[e] > 0.0) or (ev_start_pen[e] > 0.0) for e in range(EV)]
|
||||
ev_on = [
|
||||
[
|
||||
pulp.LpVariable(f"evon_{e}_{t}", cat=pulp.LpBinary)
|
||||
for t in range(T)
|
||||
]
|
||||
if ev_needs_on[e]
|
||||
else None
|
||||
for e in range(EV)
|
||||
]
|
||||
ev_start = [
|
||||
[
|
||||
pulp.LpVariable(f"evstart_{e}_{t}", 0, 1)
|
||||
for t in range(T)
|
||||
]
|
||||
if ev_start_pen[e] > 0.0
|
||||
else None
|
||||
for e in range(EV)
|
||||
]
|
||||
nb_buffer_wh = [max(0.0, float(s.night_baseload_buffer_wh or 0.0)) for s in slots]
|
||||
safety_risk = float(getattr(battery, "planner_safety_soc_risk_factor", 0.0) or 0.0)
|
||||
safety_tgt_wh = [
|
||||
min(soc_max, max(0.0, float(s.safety_soc_target_wh or 0.0)))
|
||||
if safety_risk > 0 else 0.0
|
||||
for s in slots
|
||||
]
|
||||
ds_slack = [
|
||||
pulp.LpVariable(f"dss_{t}", 0, soc_max) if safety_tgt_wh[t] > 0 else None
|
||||
for t in range(T)
|
||||
]
|
||||
nb_slack = [
|
||||
pulp.LpVariable(f"nbs_{t}", 0, nb_buffer_wh[t]) if nb_buffer_wh[t] > 0 else None
|
||||
for t in range(T)
|
||||
]
|
||||
|
||||
def _connected(e: int, t: int) -> bool:
|
||||
return bool(slots[t].ev1_connected if e == 0 else slots[t].ev2_connected)
|
||||
|
||||
for t in range(T):
|
||||
s = slots[t]
|
||||
pv_a = max(0.0, float(s.pv_a_forecast_w))
|
||||
pv_b = max(0.0, float(s.pv_b_forecast_w))
|
||||
pv_a_net = pv_a - ca[t]
|
||||
pv_b_eff = pv_b - (pv_b * z_gen[t] if z_gen is not None else 0.0)
|
||||
|
||||
ev_total_t = pulp.lpSum(
|
||||
ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV)
|
||||
)
|
||||
load_site = float(s.load_baseline_w) + ev_total_t + hp[t]
|
||||
|
||||
# bilance sběrnice (W)
|
||||
prob += (
|
||||
pv_a_net + pv_b_eff + gi[t] + bd[t]
|
||||
== load_site + bc_pv[t] + bc_gi[t] + ge_pv[t] + ge_bat[t]
|
||||
), f"balance_{t}"
|
||||
|
||||
# SoC dynamika (Wh)
|
||||
prev = soc0 if t == 0 else soc[t - 1]
|
||||
prob += (
|
||||
soc[t]
|
||||
== prev
|
||||
+ (bc_pv[t] + bc_gi[t]) * eff_c * INTERVAL_H
|
||||
- bd[t] / eff_d * INTERVAL_H
|
||||
), f"soc_{t}"
|
||||
|
||||
# výkonové stropy
|
||||
prob += bc_pv[t] + bc_gi[t] <= max_chg, f"chg_cap_{t}"
|
||||
prob += ge_pv[t] + ge_bat[t] <= max_exp, f"exp_cap_{t}"
|
||||
|
||||
# PV cesty omezené dostupnou výrobou (load-first vynucuje HW; bilance účtuje energii)
|
||||
prob += bc_pv[t] + ge_pv[t] <= pv_a_net + pv_b_eff, f"pv_src_{t}"
|
||||
# bc_gi jen ze sítě:
|
||||
prob += bc_gi[t] <= gi[t], f"bcgi_src_{t}"
|
||||
# vybíjení kryje dům + EV-via-bat + export z baterie
|
||||
prob += ge_bat[t] + pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t], f"bd_split_{t}"
|
||||
# ev_direct fyzicky jen ze sítě + PV (ne z baterie) — split direct/via_bat
|
||||
# není arbitrární, ekonomiku nemění (bilance platí stejně)
|
||||
prob += (
|
||||
pulp.lpSum(ev_direct[e][t] for e in range(EV)) <= gi[t] + pv_a_net + pv_b_eff
|
||||
), f"evd_src_{t}"
|
||||
|
||||
# zákaz současného importu a exportu
|
||||
prob += gi[t] <= max_imp * y_imp[t], f"imp_excl_{t}"
|
||||
prob += ge_pv[t] + ge_bat[t] <= max_exp * (1 - y_imp[t]), f"exp_excl_{t}"
|
||||
|
||||
# pravidlo 19: export z baterie ⇒ SoC ≥ arb floor
|
||||
prob += ge_bat[t] <= max_exp * z_exp[t], f"zexp_link_{t}"
|
||||
prob += soc[t] >= arb_floor - (soc_max - soc_min) * (1 - z_exp[t]), f"zexp_floor_{t}"
|
||||
|
||||
# noční SoC polštář (viz hlavička): soft floor nad min_soc
|
||||
if nb_slack[t] is not None:
|
||||
prob += soc[t] >= soc_min + nb_buffer_wh[t] - nb_slack[t], f"night_buf_{t}"
|
||||
|
||||
# denní SoC rampa (viz hlavička): soft floor k safety targetu
|
||||
if ds_slack[t] is not None:
|
||||
prob += soc[t] >= safety_tgt_wh[t] - ds_slack[t], f"day_safety_{t}"
|
||||
|
||||
# tvrdá cenová pravidla
|
||||
if float(s.buy_price) < 0.0:
|
||||
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}"
|
||||
if float(s.sell_price) < 0.0 and block_neg_sell:
|
||||
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_sell_block_{t}"
|
||||
|
||||
# EV dostupnost + min. výkon wallboxu (binárka ev_on) + start hrana.
|
||||
# ev_on existuje, když platí min-power floor NEBO start penalta.
|
||||
for e in range(EV):
|
||||
on_t = ev_on[e][t] if ev_on[e] is not None else None
|
||||
if not _connected(e, t):
|
||||
prob += ev_direct[e][t] == 0
|
||||
prob += ev_via_bat[e][t] == 0
|
||||
if on_t is not None:
|
||||
prob += on_t == 0, f"ev_off_{e}_{t}"
|
||||
else:
|
||||
ev_max_w = float(vehicles[e].max_charge_power_w)
|
||||
ev_total = ev_direct[e][t] + ev_via_bat[e][t]
|
||||
if on_t is not None and ev_max_w > 0:
|
||||
# on=1 nutné kdykoli ev_total > 0 (start penalta i floor to potřebují)
|
||||
prob += ev_total <= ev_max_w * on_t, f"ev_max_{e}_{t}"
|
||||
if 0 < ev_min_w[e] <= ev_max_w:
|
||||
prob += ev_total >= ev_min_w[e] * on_t, f"ev_min_{e}_{t}"
|
||||
else:
|
||||
prob += ev_total <= ev_max_w
|
||||
# start = náběžná hrana ev_on (≥ on[t] − on[t−1]); slot 0 startuje vždy,
|
||||
# když je on (žádný předchozí stav v horizontu).
|
||||
if ev_start[e] is not None and on_t is not None:
|
||||
prev_on = ev_on[e][t - 1] if t > 0 else 0
|
||||
prob += ev_start[e][t] >= on_t - prev_on, f"ev_start_{e}_{t}"
|
||||
|
||||
# provozní režimy (tvrdé constraints dle operating-modes.md)
|
||||
if om == "SELF_SUSTAIN":
|
||||
prob += gi[t] <= float(s.load_baseline_w), f"ss_gi_{t}"
|
||||
elif om == "PRESERVE":
|
||||
prob += bc_pv[t] == 0
|
||||
prob += bc_gi[t] == 0
|
||||
prob += bd[t] == 0
|
||||
elif om == "CHARGE_CHEAP":
|
||||
prob += ge_pv[t] + ge_bat[t] == 0
|
||||
prob += bd[t] == 0
|
||||
|
||||
# EV deadline (s placeným slackem místo infeasibility) + měkký cíl.
|
||||
# Bez session není mandát nabíjet: připojené auto bez session (stop-session,
|
||||
# golden fixtures s vynulovanými sessions) nesmí při buy<0 „pumpovat" energii.
|
||||
for e in range(EV):
|
||||
sess = ev_sessions[e] if e < len(ev_sessions) else None
|
||||
if sess is None:
|
||||
for t in range(T):
|
||||
if _connected(e, t):
|
||||
prob += ev_direct[e][t] == 0, f"ev_nosess_d_{e}_{t}"
|
||||
prob += ev_via_bat[e][t] == 0, f"ev_nosess_b_{e}_{t}"
|
||||
continue
|
||||
needed = max(0.0, float(getattr(sess, "energy_needed_wh", 0.0) or 0.0))
|
||||
unmet = pulp.LpVariable(f"ev_unmet_{e}", 0, needed)
|
||||
ev_unmet.append(unmet)
|
||||
if needed > 0:
|
||||
# první slot s interval_start >= deadline už do deadline NEPATŘÍ
|
||||
# (slot [deadline, deadline+15min) dodává energii až po odjezdu)
|
||||
t_dl = next(
|
||||
(t for t in range(T) if slots[t].interval_start >= sess.target_deadline),
|
||||
T,
|
||||
)
|
||||
prob += (
|
||||
pulp.lpSum(
|
||||
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
|
||||
for t in range(t_dl)
|
||||
if _connected(e, t)
|
||||
)
|
||||
+ unmet
|
||||
>= needed
|
||||
), f"ev_deadline_{e}"
|
||||
|
||||
# měkký cíl: dekompozice celkové energie == needed − unmet + opp.
|
||||
# Oportunistická vrstva NENÍ omezená deadline — auto bývá doma dál,
|
||||
# odjezd řeší rolling replan (rozhodnutí 2026-06-12).
|
||||
headroom = max(0.0, float(getattr(sess, "headroom_wh", 0.0) or 0.0))
|
||||
opp_val = float(getattr(sess, "opportunistic_value_czk_kwh", 0.0) or 0.0)
|
||||
opp = pulp.LpVariable(f"ev_opp_{e}", 0, headroom if opp_val > 0 else 0.0)
|
||||
ev_opp.append((opp, opp_val))
|
||||
prob += (
|
||||
pulp.lpSum(
|
||||
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
|
||||
for t in range(T)
|
||||
if _connected(e, t)
|
||||
)
|
||||
== needed - unmet + opp
|
||||
), f"ev_total_{e}"
|
||||
|
||||
# TUV look-ahead (převzato z v1 — komfortní constraint, ne heuristika)
|
||||
rated_hp = float(heat_pump.rated_heating_power_w)
|
||||
if tuv_delta_stats and rated_hp > 0 and getattr(heat_pump, "tuv_min_temp_c", None):
|
||||
tuv_pred = float(current_tuv_temp_c)
|
||||
tgt = float(getattr(heat_pump, "tuv_target_temp_c", 55.0) or 55.0)
|
||||
thr = float(heat_pump.tuv_min_temp_c) + 5.0
|
||||
for t in range(T):
|
||||
dow, hour = _prague_dow_hour(slots[t].interval_start)
|
||||
delta = tuv_delta_stats.get((dow, hour), -0.1)
|
||||
tuv_pred += float(delta) * INTERVAL_H
|
||||
if tuv_pred < thr:
|
||||
prob += (
|
||||
pulp.lpSum(hp[s_] for s_ in range(max(0, t - 8), t + 1))
|
||||
>= rated_hp * 0.5
|
||||
), f"tuv_heat_{t}"
|
||||
tuv_pred = tgt
|
||||
if float(current_tuv_temp_c) < float(heat_pump.tuv_min_temp_c):
|
||||
prob += hp[0] >= rated_hp * 0.8, "tuv_emergency"
|
||||
|
||||
# ---------------- objective: jen reálné peníze ----------------
|
||||
wh = INTERVAL_H / 1000.0 # W → kWh za slot
|
||||
cash = pulp.lpSum(
|
||||
gi[t] * float(slots[t].buy_price) * wh
|
||||
- (ge_pv[t] + ge_bat[t]) * float(slots[t].sell_price) * wh
|
||||
for t in range(T)
|
||||
)
|
||||
degradation = pulp.lpSum(
|
||||
0.5 * (bc_pv[t] + bc_gi[t] + bd[t]) * deg * wh for t in range(T)
|
||||
)
|
||||
extras = pulp.lpSum(ca[t] * V2_CURTAIL_TIEBREAK_CZK_KWH * wh for t in range(T))
|
||||
if z_gen is not None:
|
||||
extras += pulp.lpSum(
|
||||
max(0.0, float(slots[t].pv_b_forecast_w)) * z_gen[t] * V2_GEN_CUTOFF_CZK_KWH * wh
|
||||
for t in range(T)
|
||||
)
|
||||
if om == "SELF_SUSTAIN":
|
||||
extras += pulp.lpSum(
|
||||
(ge_pv[t] + ge_bat[t]) * V2_SELF_SUSTAIN_EXPORT_CZK_KWH * wh for t in range(T)
|
||||
)
|
||||
if ev_unmet:
|
||||
extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in ev_unmet)
|
||||
if ev_opp:
|
||||
extras -= pulp.lpSum(o / 1000.0 * val for o, val in ev_opp if val > 0)
|
||||
# anti-fragmentace EV (Fix B): Σ ev_start × start_penalta (Kč). Default 0 → no-op.
|
||||
ev_start_terms = [
|
||||
ev_start[e][t] * ev_start_pen[e]
|
||||
for e in range(EV)
|
||||
if ev_start[e] is not None and ev_start_pen[e] > 0.0
|
||||
for t in range(T)
|
||||
]
|
||||
if ev_start_terms:
|
||||
extras += pulp.lpSum(ev_start_terms)
|
||||
nb_terms = [
|
||||
nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
|
||||
for t in range(T)
|
||||
if nb_slack[t] is not None
|
||||
]
|
||||
if nb_terms:
|
||||
extras += pulp.lpSum(nb_terms)
|
||||
ds_terms = [
|
||||
ds_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price)) * safety_risk
|
||||
for t in range(T)
|
||||
if ds_slack[t] is not None
|
||||
]
|
||||
if ds_terms:
|
||||
extras += pulp.lpSum(ds_terms)
|
||||
frontload = float(getattr(battery, "planner_pv_risk_frontload_czk_kwh", 0.0) or 0.0)
|
||||
neg_idx = [t for t in range(T) if float(slots[t].sell_price) < 0.0]
|
||||
if frontload > 0 and neg_idx:
|
||||
# odměna za soc[t] v neg slotech = dřívější nabití vyhrává při indiferenci
|
||||
extras -= pulp.lpSum(soc[t] / 1000.0 * frontload for t in neg_idx)
|
||||
|
||||
prob += cash + degradation + extras - terminal * soc[T - 1]
|
||||
|
||||
solver = (
|
||||
pulp.HiGHS_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
|
||||
if pulp.HiGHS_CMD().available()
|
||||
else pulp.PULP_CBC_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
|
||||
)
|
||||
status = prob.solve(solver)
|
||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||
status_str = pulp.LpStatus[status]
|
||||
if status_str != "Optimal":
|
||||
# v2 nemá relax řetězec — model je navržen tak, aby byl feasible
|
||||
# (placené slacky místo tvrdých kotev). Ne-Optimal je skutečná chyba.
|
||||
raise RuntimeError(f"solver_v2: {status_str}")
|
||||
|
||||
# ---------------- DispatchResult assembly (parita s v1) ----------------
|
||||
def _val(var) -> float:
|
||||
v = pulp.value(var)
|
||||
return float(v) if v is not None else 0.0
|
||||
|
||||
# Reporting EV-via-bat: kWh do auta z baterie neplatí slotový buy (jdou
|
||||
# z baterie), ale ušlou příležitost. Aproximace oportunitní ceny: nejnižší
|
||||
# sell slotu, kde plán exportuje, v témže pražském dni; bez exportu ten den
|
||||
# terminal value (Kč/kWh). Plní battery_arbitrage_czk (dřív konstantní 0).
|
||||
day_min_export_sell: dict[Any, float] = {}
|
||||
for t in range(T):
|
||||
if _val(ge_pv[t]) + _val(ge_bat[t]) >= 1.0:
|
||||
d_key = _prague_calendar_date(slots[t])
|
||||
sp = float(slots[t].sell_price)
|
||||
if d_key not in day_min_export_sell or sp < day_min_export_sell[d_key]:
|
||||
day_min_export_sell[d_key] = sp
|
||||
|
||||
results: list[DispatchResult] = []
|
||||
for t in range(T):
|
||||
s = slots[t]
|
||||
via1_w = _val(ev_via_bat[0][t]) if EV > 0 else 0.0
|
||||
via2_w = _val(ev_via_bat[1][t]) if EV > 1 else 0.0
|
||||
via_kwh = (via1_w + via2_w) * wh
|
||||
if via_kwh > 1e-9:
|
||||
opp_price = max(
|
||||
0.0,
|
||||
day_min_export_sell.get(_prague_calendar_date(s), terminal * 1000.0),
|
||||
)
|
||||
arb_czk = via_kwh * opp_price
|
||||
else:
|
||||
arb_czk = 0.0
|
||||
bc_tot = _val(bc_pv[t]) + _val(bc_gi[t])
|
||||
bd_v = _val(bd[t])
|
||||
batt_w = round(bc_tot - bd_v)
|
||||
ge_pv_w = round(_val(ge_pv[t]))
|
||||
ge_bat_w = round(_val(ge_bat[t]))
|
||||
gi_w = _val(gi[t])
|
||||
ge_w = float(ge_pv_w + ge_bat_w)
|
||||
grid_w, export_mode = _dispatch_grid_setpoint_w(
|
||||
gi_w=gi_w,
|
||||
ge_w=ge_w,
|
||||
ge_bat_w=float(ge_bat_w),
|
||||
ge_pv_w=float(ge_pv_w),
|
||||
max_export_power_w=int(max_exp),
|
||||
)
|
||||
if batt_w < 0 and grid_w < 0:
|
||||
deye_mode = "SELL"
|
||||
elif batt_w > 0 and grid_w > 0:
|
||||
deye_mode = "CHARGE"
|
||||
else:
|
||||
deye_mode = "PASSIVE"
|
||||
gen_cut = bool(round(_val(z_gen[t]))) if z_gen is not None else None
|
||||
hp_v = _val(hp[t])
|
||||
hp_on = hp_v > rated_hp * 0.5 if rated_hp > 0 else False
|
||||
cash_t = gi_w * float(s.buy_price) * wh - ge_w * float(s.sell_price) * wh
|
||||
pen_t = 0.0
|
||||
if gen_cut:
|
||||
pen_t += max(0.0, float(s.pv_b_forecast_w)) * V2_GEN_CUTOFF_CZK_KWH * wh
|
||||
results.append(
|
||||
DispatchResult(
|
||||
interval_start=s.interval_start,
|
||||
battery_setpoint_w=batt_w,
|
||||
battery_soc_target=round(_val(soc[t]) / usable * 100.0, 2),
|
||||
grid_setpoint_w=grid_w,
|
||||
export_limit_w=int(max_exp) if grid_w < 0 else 0,
|
||||
export_mode=export_mode,
|
||||
deye_physical_mode=deye_mode,
|
||||
deye_gen_cutoff_enabled=gen_cut,
|
||||
ev1_setpoint_w=(
|
||||
round(_val(ev_direct[0][t]) + _val(ev_via_bat[0][t]))
|
||||
if EV > 0 and s.ev1_connected
|
||||
else None
|
||||
),
|
||||
ev2_setpoint_w=(
|
||||
round(_val(ev_direct[1][t]) + _val(ev_via_bat[1][t]))
|
||||
if EV > 1 and s.ev2_connected
|
||||
else None
|
||||
),
|
||||
ev1_via_bat_w=round(via1_w),
|
||||
ev2_via_bat_w=round(via2_w),
|
||||
heat_pump_enabled=hp_on,
|
||||
heat_pump_setpoint_w=int(rated_hp) if hp_on else 0,
|
||||
pv_a_curtailed_w=round(_val(ca[t])),
|
||||
expected_cost_czk=round(cash_t, 4),
|
||||
effective_buy_price=float(s.buy_price),
|
||||
effective_sell_price=float(s.sell_price),
|
||||
is_predicted_price=bool(s.is_predicted_price),
|
||||
cashflow_czk=round(cash_t, 4),
|
||||
battery_arbitrage_czk=round(arb_czk, 4),
|
||||
penalty_czk=round(pen_t, 4),
|
||||
green_bonus_czk=float(getattr(s, "green_bonus_czk_per_slot", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
snapshot: dict[str, Any] = {
|
||||
"version": planner_version or "v2-clean",
|
||||
"planner_build_tag": V2_BUILD_TAG,
|
||||
"inputs": {
|
||||
"operating_mode": om,
|
||||
"current_soc_wh": soc0,
|
||||
"terminal_czk_per_wh": round(terminal, 8),
|
||||
"arb_floor_wh": arb_floor,
|
||||
"block_export_on_negative_sell": block_neg_sell,
|
||||
"gen_cutoff_available": gen_cutoff_avail,
|
||||
"slot_count": T,
|
||||
"ev_sessions": sum(1 for x in ev_sessions if x is not None),
|
||||
"ev_min_power_w": ev_min_w,
|
||||
"ev_phases": [int(getattr(vehicles[e], "phases", 0) or 0) for e in range(EV)],
|
||||
"ev_start_penalty_czk": ev_start_pen,
|
||||
"masks_ignored": True,
|
||||
"night_buffer_slots": sum(1 for b in nb_buffer_wh if b > 0),
|
||||
"pv_risk_frontload_czk_kwh": frontload if neg_idx else 0.0,
|
||||
"safety_soc_risk_factor": safety_risk,
|
||||
"safety_soc_slots": sum(1 for x in safety_tgt_wh if x > 0),
|
||||
"night_buffer_max_wh": round(max(nb_buffer_wh), 1) if nb_buffer_wh else 0,
|
||||
},
|
||||
"objective_terms": {
|
||||
"cash_czk": round(float(pulp.value(cash)), 3),
|
||||
"degradation_czk": round(float(pulp.value(degradation)), 3),
|
||||
"extras_czk": round(float(pulp.value(extras)), 3) if not isinstance(extras, float) else 0.0,
|
||||
"terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
|
||||
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet],
|
||||
"ev_opp_wh": [round(_val(o), 1) for o, _v in ev_opp],
|
||||
"ev_starts": [
|
||||
int(round(sum(_val(ev_start[e][t]) for t in range(T))))
|
||||
if ev_start[e] is not None
|
||||
else 0
|
||||
for e in range(EV)
|
||||
],
|
||||
},
|
||||
"solver_duration_ms": duration_ms,
|
||||
"solver_status": status_str,
|
||||
}
|
||||
return results, duration_ms, snapshot
|
||||
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
112
backend/services/shelly_client.py
Normal file
112
backend/services/shelly_client.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Shelly Gen2+ RPC klient (HTTP, httpx) — Switch.GetStatus / Switch.Set.
|
||||
|
||||
Záměrně POUZE Gen2 RPC (`/rpc/<Method>?...`). Gen1 REST (`/relay/0?turn=on`)
|
||||
nepodporujeme — všechna nasazovaná relé (Plus/Pro řada) mluví Gen2 a fallback
|
||||
by jen maskoval chybnou konfiguraci. Viz docs/04-modules/pool-shelly.md.
|
||||
|
||||
Žádné retry smyčky: telemetrii volá poll cyklus každých 60 s a další pokus
|
||||
zajistí sám; ovládání jde přes signal_service (vlastní retry + verify).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShellySwitchStatus:
|
||||
"""Stav Switch komponenty ze Switch.GetStatus."""
|
||||
|
||||
output: bool
|
||||
apower_w: float | None
|
||||
aenergy_total_wh: float | None
|
||||
|
||||
|
||||
def shelly_base_url(protocol: str | None, host: str, port: int | None) -> str:
|
||||
"""Base URL Shelly z řádku ems.site_endpoint (protocol/host/port)."""
|
||||
p = (protocol or "http").lower()
|
||||
if p not in ("http", "https"):
|
||||
p = "http"
|
||||
prt = int(port or (443 if p == "https" else 80))
|
||||
return f"{p}://{host}:{prt}"
|
||||
|
||||
|
||||
def parse_switch_status(data: dict[str, Any]) -> ShellySwitchStatus:
|
||||
"""Čistý parser odpovědi Switch.GetStatus (testovatelné bez HTTP).
|
||||
|
||||
Gen2: {"id":0,"output":true,"apower":745.3,"aenergy":{"total":12345.678,...},...}
|
||||
`aenergy.total` je ve Wh; `apower` ve W. Obojí volitelné (ne každý model měří).
|
||||
"""
|
||||
if "output" not in data:
|
||||
raise ValueError("Shelly Switch.GetStatus: missing 'output' (not a Gen2 RPC response?)")
|
||||
output = bool(data["output"])
|
||||
|
||||
apower_w: float | None = None
|
||||
if data.get("apower") is not None:
|
||||
apower_w = float(data["apower"])
|
||||
|
||||
aenergy_total_wh: float | None = None
|
||||
aenergy = data.get("aenergy")
|
||||
if isinstance(aenergy, dict) and aenergy.get("total") is not None:
|
||||
aenergy_total_wh = float(aenergy["total"])
|
||||
|
||||
return ShellySwitchStatus(
|
||||
output=output,
|
||||
apower_w=apower_w,
|
||||
aenergy_total_wh=aenergy_total_wh,
|
||||
)
|
||||
|
||||
|
||||
async def get_switch_status(
|
||||
base_url: str,
|
||||
switch_id: int = 0,
|
||||
*,
|
||||
timeout: float = DEFAULT_TIMEOUT_S,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> ShellySwitchStatus:
|
||||
"""GET {base}/rpc/Switch.GetStatus?id=N → ShellySwitchStatus.
|
||||
|
||||
`client` lze injektovat (testy, sdílený klient); jinak se vytvoří jednorázový.
|
||||
"""
|
||||
url = f"{base_url.rstrip('/')}/rpc/Switch.GetStatus"
|
||||
params = {"id": int(switch_id)}
|
||||
if client is not None:
|
||||
resp = await client.get(url, params=params)
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=timeout) as c:
|
||||
resp = await c.get(url, params=params)
|
||||
resp.raise_for_status()
|
||||
return parse_switch_status(resp.json())
|
||||
|
||||
|
||||
async def set_switch(
|
||||
base_url: str,
|
||||
on: bool,
|
||||
switch_id: int = 0,
|
||||
*,
|
||||
timeout: float = DEFAULT_TIMEOUT_S,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> bool | None:
|
||||
"""GET {base}/rpc/Switch.Set?id=N&on=true|false. Vrátí was_on (předchozí stav), pokud ho Shelly poslalo.
|
||||
|
||||
Pozn.: produkční ovládání bazénu jde přes signal_service (journal + verify);
|
||||
tato funkce je pro ruční zásahy / budoucí přímé použití.
|
||||
"""
|
||||
url = f"{base_url.rstrip('/')}/rpc/Switch.Set"
|
||||
# Gen2 RPC parsuje query parametry jako JSON — bool musí být 'true'/'false'.
|
||||
params = {"id": int(switch_id), "on": "true" if on else "false"}
|
||||
if client is not None:
|
||||
resp = await client.get(url, params=params)
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=timeout) as c:
|
||||
resp = await c.get(url, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
was_on = data.get("was_on") if isinstance(data, dict) else None
|
||||
return bool(was_on) if was_on is not None else None
|
||||
@@ -178,6 +178,10 @@ async def compute_export_ban_active(site_id: int, conn: asyncpg.Connection) -> b
|
||||
if bool(pi.get("is_predicted_price")):
|
||||
return True
|
||||
|
||||
export_mode = str(pi.get("export_mode") or "").upper()
|
||||
if export_mode in ("PV_SURPLUS", "BATTERY_SELL"):
|
||||
return False
|
||||
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
grid_sp = int(pi.get("grid_setpoint_w") or 0)
|
||||
if sell_raw is None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
198
backend/services/tesla_client.py
Normal file
198
backend/services/tesla_client.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Tesla Fleet API – čtení stavu nabití vozidla (SoC) po příjezdu k wallboxu.
|
||||
|
||||
Zásady:
|
||||
- Volat JEN při příjezdu (vehicle_data budí auto → vampire drain); žádný polling.
|
||||
- Refresh token Tesla při každém použití ROTUJE → runtime hodnota žije v DB
|
||||
(ems.tesla_token, fn_tesla_token_get/upsert); env TESLA_REFRESH_TOKEN je jen
|
||||
prvotní seed. Access token cache ~8 h dle expires_in.
|
||||
- Bez credentials (env prázdné) modul tiše nic nedělá — EV plánování běží na
|
||||
defaultech z asset_vehicle.
|
||||
|
||||
Postup zřízení: docs/tesla-fleet-api.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db_json import fetch_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTH_TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token"
|
||||
API_BASE = "https://fleet-api.prd.eu.vn.cloud.tesla.com"
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
#: rezerva před expirací access tokenu
|
||||
ACCESS_EXPIRY_MARGIN_S = 120
|
||||
|
||||
|
||||
MILES_TO_KM = 1.609344
|
||||
|
||||
|
||||
def parse_charge_state(vehicle_data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Parser vehicle_data → battery_level, charge_limit_soc, charging_state, odometer_km.
|
||||
|
||||
POZOR: Tesla API vrací odometer v MÍLÍCH → převod na km.
|
||||
"""
|
||||
resp = vehicle_data.get("response") or {}
|
||||
cs = resp.get("charge_state") or {}
|
||||
vs = resp.get("vehicle_state") or {}
|
||||
level = cs.get("battery_level")
|
||||
if level is None:
|
||||
return None
|
||||
odo_miles = vs.get("odometer")
|
||||
ds = resp.get("drive_state") or {}
|
||||
return {
|
||||
"latitude": ds.get("latitude"),
|
||||
"longitude": ds.get("longitude"),
|
||||
"shift_state": ds.get("shift_state"),
|
||||
"vin": resp.get("vin"),
|
||||
"battery_level": int(level),
|
||||
"charge_limit_soc": int(cs.get("charge_limit_soc") or 0) or None,
|
||||
"charging_state": cs.get("charging_state"),
|
||||
"odometer_km": round(float(odo_miles) * MILES_TO_KM, 1) if odo_miles is not None else None,
|
||||
}
|
||||
|
||||
|
||||
async def _get_access_token(db: asyncpg.Connection) -> Optional[str]:
|
||||
s = get_settings()
|
||||
client_id = (getattr(s, "tesla_client_id", "") or "").strip()
|
||||
if not client_id:
|
||||
return None
|
||||
|
||||
tok = await fetch_json(db, "select ems.fn_tesla_token_get()")
|
||||
if not isinstance(tok, dict):
|
||||
tok = {}
|
||||
refresh = (tok.get("refresh_token") or "").strip()
|
||||
if not refresh:
|
||||
refresh = (getattr(s, "tesla_refresh_token", "") or "").strip()
|
||||
if not refresh:
|
||||
logger.debug("Tesla: žádný refresh token (env ani DB) — přeskočeno")
|
||||
return None
|
||||
|
||||
access = tok.get("access_token")
|
||||
exp_raw = tok.get("access_expires_at")
|
||||
if access and exp_raw:
|
||||
try:
|
||||
exp = datetime.fromisoformat(str(exp_raw))
|
||||
if exp - timedelta(seconds=ACCESS_EXPIRY_MARGIN_S) > datetime.now(timezone.utc):
|
||||
return str(access)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_S) as client:
|
||||
r = await client.post(
|
||||
AUTH_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": client_id,
|
||||
"refresh_token": refresh,
|
||||
},
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
# 400 invalid_grant = token spálený rotací NEBO ~10min výpadek po
|
||||
# revokaci souhlasu (Tesla docs). Neshazovat volajícího tracebackem.
|
||||
body = r.text[:300]
|
||||
logger.error(
|
||||
"Tesla token refresh selhal (HTTP %s): %s — pokud jsi právě "
|
||||
"revokoval souhlas, počkej ~10 min; jinak obnov token dle "
|
||||
"docs/tesla-fleet-api.md (deploy/tesla/reauth.sh)",
|
||||
r.status_code,
|
||||
body,
|
||||
)
|
||||
return None
|
||||
data = r.json()
|
||||
|
||||
new_access = str(data["access_token"])
|
||||
# rotace: Tesla vrací nový refresh token — starý přestává platit, ULOŽIT
|
||||
new_refresh = str(data.get("refresh_token") or refresh)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=int(data.get("expires_in") or 3600))
|
||||
await db.execute(
|
||||
"select ems.fn_tesla_token_upsert($1::text, $2::text, $3::timestamptz)",
|
||||
new_refresh,
|
||||
new_access,
|
||||
expires_at,
|
||||
)
|
||||
return new_access
|
||||
|
||||
|
||||
async def get_charge_state(
|
||||
db: asyncpg.Connection, vin: str | None
|
||||
) -> dict[str, Any] | None:
|
||||
"""SoC vozidla: dle VIN, nebo jediného vozidla na účtu (VIN vrací pro doplnění).
|
||||
|
||||
Vrací parse_charge_state dict, nebo None (bez credentials / vozidlo nenalezeno /
|
||||
offline). Výjimky síťové vrstvy propadají volajícímu (hook je loguje).
|
||||
"""
|
||||
token = await _get_access_token(db)
|
||||
if token is None:
|
||||
return None
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_S, headers=headers) as client:
|
||||
r = await client.get(f"{API_BASE}/api/1/vehicles")
|
||||
r.raise_for_status()
|
||||
vehicles = (r.json().get("response") or [])
|
||||
if not vehicles:
|
||||
logger.warning("Tesla: účet nemá žádná vozidla")
|
||||
return None
|
||||
chosen = None
|
||||
if vin:
|
||||
chosen = next((v for v in vehicles if v.get("vin") == vin), None)
|
||||
if chosen is None:
|
||||
logger.warning("Tesla: VIN %s na účtu nenalezen", vin)
|
||||
return None
|
||||
elif len(vehicles) == 1:
|
||||
chosen = vehicles[0]
|
||||
else:
|
||||
logger.warning(
|
||||
"Tesla: %s vozidel na účtu a VIN v asset_vehicle chybí — doplň VIN",
|
||||
len(vehicles),
|
||||
)
|
||||
return None
|
||||
|
||||
r = await client.get(
|
||||
f"{API_BASE}/api/1/vehicles/{chosen['id']}/vehicle_data",
|
||||
params={"endpoints": "charge_state;vehicle_state;location_data"},
|
||||
)
|
||||
if r.status_code == 408:
|
||||
logger.info("Tesla: vozidlo spí / nedostupné (408) — SoC nedoplněno")
|
||||
return None
|
||||
r.raise_for_status()
|
||||
return parse_charge_state(r.json())
|
||||
|
||||
|
||||
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Vzdálenost dvou GPS bodů v metrech (čisté, testovatelné)."""
|
||||
import math
|
||||
|
||||
r = 6_371_000.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp = math.radians(lat2 - lat1)
|
||||
dl = math.radians(lon2 - lon1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
async def get_vehicle_api_state(db: asyncpg.Connection, vin: str | None) -> str | None:
|
||||
"""Jen state z /vehicles (online/asleep/offline) — NIKDY nebudí auto."""
|
||||
token = await _get_access_token(db)
|
||||
if token is None:
|
||||
return None
|
||||
async with httpx.AsyncClient(
|
||||
timeout=HTTP_TIMEOUT_S, headers={"Authorization": f"Bearer {token}"}
|
||||
) as client:
|
||||
r = await client.get(f"{API_BASE}/api/1/vehicles")
|
||||
r.raise_for_status()
|
||||
vehicles = r.json().get("response") or []
|
||||
if vin:
|
||||
v = next((x for x in vehicles if x.get("vin") == vin), None)
|
||||
else:
|
||||
v = vehicles[0] if len(vehicles) == 1 else None
|
||||
return str(v["state"]) if v else None
|
||||
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
144
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
144
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""PASSIVE + PV_SURPLUS: reg 108 sleduje charge intent (fix 2026-06-16).
|
||||
|
||||
bat_w>0 (plán chce nabíjet z přebytku) → 108=max (baterka nabere co zvládne, zbytek ven);
|
||||
SoC u maxima + přebytek → 108=max (BMS kalibrace na 100 %); jen "prodej PV a drž baterku"
|
||||
daleko od maxima (bat_w<=0) → 108=0. 109=max, 142 zůstává zero-export (1/2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.control.setpoints import deye_battery_charge_discharge_amps
|
||||
|
||||
|
||||
class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
|
||||
def test_pv_surplus_export_zeros_charge_amps(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=-177,
|
||||
grid_w=-2851,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=90,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 90)
|
||||
|
||||
def test_pv_surplus_with_positive_battery_w_charges_at_max(self) -> None:
|
||||
"""Fix 2026-06-16: plán chce nabíjet z přebytku (bat_w>0) → 108=max (ne 0).
|
||||
|
||||
Baterka nabere kolik zvládne, přebytek nad nabíjecí rychlost jde do sítě (BA81).
|
||||
"""
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=5000,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 100)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_pv_surplus_near_full_tops_off_for_calibration(self) -> None:
|
||||
"""SoC u maxima (97 >= 100-3) + přebytek → 108=max i při bat_w<=0 (BMS kalibrace)."""
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=0,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
current_soc_pct=97.0,
|
||||
max_soc_pct=100,
|
||||
)
|
||||
self.assertEqual(ch, 100)
|
||||
|
||||
def test_pv_surplus_sell_hold_far_from_full_zeros_charge(self) -> None:
|
||||
"""Prodej PV a drž baterku daleko od maxima (bat_w<=0, SoC nízko) → 108=0."""
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=0,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
current_soc_pct=60.0,
|
||||
max_soc_pct=100,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_passive_charge_without_export_mode_uses_max_108(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=5000,
|
||||
grid_w=0,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="NONE",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 100)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_legacy_negative_grid_infers_pv_surplus(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=0,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode=None,
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_charge_mode_still_scales_108_from_battery_w(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="CHARGE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=2000,
|
||||
grid_w=3000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
)
|
||||
self.assertLess(ch, 100)
|
||||
self.assertGreater(ch, 0)
|
||||
self.assertEqual(dis, 0)
|
||||
|
||||
def test_sell_skips_charge_amp_write(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="SELL",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=-3000,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=80,
|
||||
)
|
||||
self.assertIsNone(ch)
|
||||
self.assertEqual(dis, 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
153
backend/tests/test_control_export_plan_guard.py
Normal file
153
backend/tests/test_control_export_plan_guard.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Exekuční pojistka exportu podle plánu (Plan 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.control.exporter_monolith import (
|
||||
ControlSetpoints,
|
||||
_apply_export_plan_guard,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.control.models import OperatingModeInfo
|
||||
from services.control.setpoints import _DictRecord
|
||||
|
||||
|
||||
def _auto_mode() -> OperatingModeInfo:
|
||||
return OperatingModeInfo(
|
||||
mode_code="AUTO",
|
||||
battery_mode="AUTO",
|
||||
grid_mode="AUTO",
|
||||
ev_enabled=True,
|
||||
heat_pump_enabled_def=True,
|
||||
loxone_mode_value=0,
|
||||
)
|
||||
|
||||
|
||||
def _sp(**kwargs: object) -> ControlSetpoints:
|
||||
base = dict(
|
||||
battery_w=0,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
deye_physical_mode="SELL",
|
||||
export_mode="BATTERY_SELL",
|
||||
export_ban=False,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return ControlSetpoints(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class ExportPlanGuardTests(unittest.TestCase):
|
||||
def test_neg_sell_forces_passive_no_export(self) -> None:
|
||||
sp = _sp()
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": -8000,
|
||||
"effective_sell_price": -0.5,
|
||||
"export_mode": "NONE",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertEqual(get_deye_mode(out), "PASSIVE")
|
||||
self.assertTrue(out.export_ban)
|
||||
self.assertEqual(out.grid_export_limit, 0)
|
||||
self.assertGreaterEqual(out.grid_setpoint_w, 0)
|
||||
self.assertEqual(out.export_mode, "NONE")
|
||||
self.assertTrue(out.deye_gen_cutoff_enabled)
|
||||
|
||||
def test_export_mode_none_with_non_negative_grid(self) -> None:
|
||||
sp = _sp(grid_setpoint_w=0, battery_w=-5000, export_mode="BATTERY_SELL")
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": 0,
|
||||
"effective_sell_price": 2.5,
|
||||
"export_mode": "NONE",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertEqual(get_deye_mode(out), "PASSIVE")
|
||||
self.assertEqual(out.battery_w, 0)
|
||||
self.assertEqual(out.grid_export_limit, 0)
|
||||
# Kladná vykupní: žádný tvrdý ban — MI (pole B) se NEodstavuje, 145 zůstává 1
|
||||
# (BA81 2026-06-12: cutoff při sell +1.36 zahazoval výrobu mikroinvertorů).
|
||||
self.assertFalse(out.export_ban)
|
||||
self.assertFalse(out.deye_gen_cutoff_enabled)
|
||||
|
||||
def test_export_mode_none_positive_sell_respects_plan_cutoff(self) -> None:
|
||||
# Plán explicitně chce cut-off (z_gen_cutoff) -> guard ho nesmí shodit.
|
||||
sp = _sp(
|
||||
grid_setpoint_w=0,
|
||||
battery_w=2000,
|
||||
export_mode="NONE",
|
||||
deye_physical_mode="PASSIVE",
|
||||
deye_gen_cutoff_enabled=True,
|
||||
)
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": 0,
|
||||
"effective_sell_price": 2.5,
|
||||
"export_mode": "NONE",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertTrue(out.deye_gen_cutoff_enabled)
|
||||
|
||||
def test_profitable_export_unchanged(self) -> None:
|
||||
sp = _sp()
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": -8000,
|
||||
"effective_sell_price": 9.5,
|
||||
"export_mode": "BATTERY_SELL",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertIs(out, sp)
|
||||
self.assertEqual(get_deye_mode(out), "SELL")
|
||||
|
||||
def test_neg_sell_grid_charge_not_blocked(self) -> None:
|
||||
# Záporný sell + IMPORT na nabití baterie (CHARGE / grid>0 & bat>0):
|
||||
# guard NESMÍ překlopit na PASSIVE — jinak Deye nenabije ze sítě
|
||||
# v záporných cenách (bug 2026-06-13).
|
||||
sp = _sp(
|
||||
grid_setpoint_w=17000,
|
||||
battery_w=17000,
|
||||
deye_physical_mode="CHARGE",
|
||||
export_mode="NONE",
|
||||
)
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": 17000,
|
||||
"battery_setpoint_w": 17000,
|
||||
"deye_physical_mode": "CHARGE",
|
||||
"effective_sell_price": -1.2,
|
||||
"export_mode": "NONE",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertIs(out, sp)
|
||||
self.assertEqual(get_deye_mode(out), "CHARGE")
|
||||
|
||||
def test_non_auto_mode_skipped(self) -> None:
|
||||
sp = _sp()
|
||||
pi = _DictRecord({"effective_sell_price": -1.0, "export_mode": "NONE"})
|
||||
mode = OperatingModeInfo(
|
||||
mode_code="SELF_SUSTAIN",
|
||||
battery_mode="PASSIVE",
|
||||
grid_mode="PASSIVE",
|
||||
ev_enabled=False,
|
||||
heat_pump_enabled_def=False,
|
||||
loxone_mode_value=1,
|
||||
)
|
||||
out = _apply_export_plan_guard(1, mode, pi, sp)
|
||||
self.assertIs(out, sp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,6 +11,7 @@ from services.control.exporter_monolith import (
|
||||
compute_pv_a_reg340_max_solar_w,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||
)
|
||||
from services.control.setpoints import plan_skips_deye_reg340_write
|
||||
|
||||
|
||||
def _auto_mode() -> OperatingModeInfo:
|
||||
@@ -51,6 +52,15 @@ class ComputePvAReg340Tests(unittest.TestCase):
|
||||
def test_curtail_floor_zero(self) -> None:
|
||||
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 1000, 5000), 0)
|
||||
|
||||
def test_min_clamp_when_positive(self) -> None:
|
||||
self.assertEqual(
|
||||
compute_pv_a_reg340_max_solar_w(32_000, 5000, 4600, min_w=400),
|
||||
400,
|
||||
)
|
||||
|
||||
def test_min_not_applied_when_curtail_to_zero(self) -> None:
|
||||
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 1000, 5000, min_w=400), 0)
|
||||
|
||||
|
||||
class BuildSetpointsReg340Tests(unittest.TestCase):
|
||||
def test_with_cap_sets_pv_a_allowed(self) -> None:
|
||||
@@ -102,6 +112,91 @@ class BuildSetpointsReg340Tests(unittest.TestCase):
|
||||
assert sp is not None
|
||||
self.assertEqual(sp.pv_a_allowed_w, 0)
|
||||
|
||||
def test_skipped_low_pv_forecast_with_mi_no_curtail(self) -> None:
|
||||
"""BA81 úsvit: slabý forecast, bez curtail — EMS neposílá reg 340."""
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(
|
||||
pv_a_forecast_solver_w=405,
|
||||
pv_b_forecast_solver_w=49,
|
||||
pv_a_curtailed_w=0,
|
||||
grid_setpoint_w=-100,
|
||||
battery_setpoint_w=0,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_limit_w=100,
|
||||
),
|
||||
pv_a_cap_w=32_000,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertIsNone(sp.pv_a_allowed_w)
|
||||
|
||||
def test_skipped_when_no_export_no_charge_no_curtail(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(
|
||||
grid_setpoint_w=0,
|
||||
battery_setpoint_w=0,
|
||||
export_mode="NONE",
|
||||
export_limit_w=0,
|
||||
pv_a_curtailed_w=0,
|
||||
),
|
||||
pv_a_cap_w=10_000,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertIsNone(sp.pv_a_allowed_w)
|
||||
|
||||
def test_writes_reg340_when_curtail_planned(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(
|
||||
grid_setpoint_w=0,
|
||||
battery_setpoint_w=0,
|
||||
export_mode="NONE",
|
||||
pv_a_curtailed_w=3000,
|
||||
),
|
||||
pv_a_cap_w=10_000,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertEqual(sp.pv_a_allowed_w, 5000)
|
||||
|
||||
def test_writes_reg340_when_battery_charging_without_export(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(
|
||||
grid_setpoint_w=0,
|
||||
battery_setpoint_w=5000,
|
||||
export_mode="NONE",
|
||||
pv_a_curtailed_w=0,
|
||||
),
|
||||
pv_a_cap_w=10_000,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertEqual(sp.pv_a_allowed_w, 10_000)
|
||||
|
||||
def test_plan_skips_helper(self) -> None:
|
||||
self.assertTrue(
|
||||
plan_skips_deye_reg340_write(
|
||||
battery_setpoint_w=0,
|
||||
grid_setpoint_w=0,
|
||||
export_mode="NONE",
|
||||
export_limit_w=0,
|
||||
pv_a_curtailed_w=0,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
plan_skips_deye_reg340_write(
|
||||
battery_setpoint_w=0,
|
||||
grid_setpoint_w=-2000,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_limit_w=2000,
|
||||
pv_a_curtailed_w=0,
|
||||
)
|
||||
)
|
||||
|
||||
def test_skipped_when_reg340_control_disabled(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
|
||||
@@ -11,10 +11,15 @@ from services.control.exporter_monolith import (
|
||||
_deye_reg178_verify_with_double_read,
|
||||
_deye_tou_params,
|
||||
_deye_tou_power_verify_match,
|
||||
_deye_zero_export_amps_for_passive,
|
||||
deye_mi_export_cutoff_want_enabled,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.control.models import OperatingModeInfo
|
||||
from services.control.setpoints import (
|
||||
_build_setpoints,
|
||||
_deye_zero_export_amps_for_passive,
|
||||
)
|
||||
|
||||
|
||||
def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterConfig:
|
||||
@@ -110,6 +115,60 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
|
||||
def test_mi_export_cutoff_on_export_ban_without_plan_flag(self) -> None:
|
||||
self.assertTrue(
|
||||
deye_mi_export_cutoff_want_enabled(
|
||||
gen_microinverter_cutoff_enabled=True,
|
||||
deye_gen_cutoff_enabled=False,
|
||||
export_ban=True,
|
||||
deye_mode="PASSIVE",
|
||||
)
|
||||
)
|
||||
|
||||
def test_mi_export_cutoff_off_when_sell_mode(self) -> None:
|
||||
self.assertFalse(
|
||||
deye_mi_export_cutoff_want_enabled(
|
||||
gen_microinverter_cutoff_enabled=True,
|
||||
deye_gen_cutoff_enabled=True,
|
||||
export_ban=True,
|
||||
deye_mode="SELL",
|
||||
)
|
||||
)
|
||||
|
||||
def test_mi_export_cutoff_off_without_feature_flag(self) -> None:
|
||||
self.assertFalse(
|
||||
deye_mi_export_cutoff_want_enabled(
|
||||
gen_microinverter_cutoff_enabled=False,
|
||||
deye_gen_cutoff_enabled=True,
|
||||
export_ban=True,
|
||||
deye_mode="PASSIVE",
|
||||
)
|
||||
)
|
||||
|
||||
def test_build_setpoints_uses_explicit_export_limit(self) -> None:
|
||||
mode = OperatingModeInfo(
|
||||
mode_code="AUTO",
|
||||
battery_mode="AUTO",
|
||||
grid_mode="AUTO",
|
||||
ev_enabled=False,
|
||||
heat_pump_enabled_def=False,
|
||||
loxone_mode_value=1,
|
||||
)
|
||||
pi = {
|
||||
"battery_setpoint_w": 0,
|
||||
"grid_setpoint_w": -3000,
|
||||
"export_limit_w": 13_500,
|
||||
"export_mode": "PV_SURPLUS",
|
||||
"ev1_setpoint_w": 0,
|
||||
"ev2_setpoint_w": 0,
|
||||
"heat_pump_enabled": False,
|
||||
"battery_soc_target_pct": 50,
|
||||
"effective_sell_price": 1.0,
|
||||
}
|
||||
sp = _build_setpoints(mode, pi)
|
||||
self.assertIsNotNone(sp)
|
||||
self.assertEqual(sp.grid_export_limit, 13_500)
|
||||
|
||||
def test_pv_led_export_with_small_battery_is_sell(self) -> None:
|
||||
"""Obě záporné → SELL (bez porovnání |bat| vs |grid|)."""
|
||||
sp = ControlSetpoints(
|
||||
@@ -247,7 +306,7 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
|
||||
def test_zero_export_amps_fve_overflow(self) -> None:
|
||||
c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90)
|
||||
self.assertEqual(c, 0)
|
||||
self.assertEqual(c, 100)
|
||||
self.assertEqual(d, 90)
|
||||
|
||||
def test_zero_export_amps_import_hold_discharge(self) -> None:
|
||||
|
||||
164
backend/tests/test_discord_bot.py
Normal file
164
backend/tests/test_discord_bot.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Discord bot — čisté helpery (custom_id, výběry → patch, deadline z voleb),
|
||||
bez sítě/discord lib."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.discord_bot import (
|
||||
action_to_patch,
|
||||
choice_label,
|
||||
departure_choice_to_deadline,
|
||||
parse_custom_id,
|
||||
select_to_patch,
|
||||
)
|
||||
|
||||
_PRAGUE = ZoneInfo("Europe/Prague")
|
||||
# 2026-06-12 je pátek; 10:00 UTC = 12:00 Europe/Prague (CEST)
|
||||
_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _prague(dt: datetime) -> str:
|
||||
return dt.astimezone(_PRAGUE).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
class ParseCustomIdTests(unittest.TestCase):
|
||||
def test_valid_selects(self) -> None:
|
||||
self.assertEqual(
|
||||
parse_custom_id("ev:2:ev-charger-1:dep"), (2, "ev-charger-1", "dep")
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_custom_id("ev:2:ev-charger-1:tgt"), (2, "ev-charger-1", "tgt")
|
||||
)
|
||||
|
||||
def test_valid_legacy_buttons(self) -> None:
|
||||
self.assertEqual(
|
||||
parse_custom_id("ev:2:ev-charger-1:h2"), (2, "ev-charger-1", "h2")
|
||||
)
|
||||
|
||||
def test_invalid(self) -> None:
|
||||
for bad in ("", "ev:2:x:jump", "foo:1:c:h2", "ev:abc:c:h2", "ev:1:c:dep:x"):
|
||||
self.assertIsNone(parse_custom_id(bad))
|
||||
|
||||
|
||||
class DepartureChoiceTests(unittest.TestCase):
|
||||
"""Absolutní deadline z výběru „Kdy odjíždíš?" (Europe/Prague)."""
|
||||
|
||||
def test_h2(self) -> None:
|
||||
dl = departure_choice_to_deadline("h2", now=_NOW)
|
||||
self.assertEqual(_prague(dl), "2026-06-12 14:00")
|
||||
|
||||
def test_h4(self) -> None:
|
||||
dl = departure_choice_to_deadline("h4", now=_NOW)
|
||||
self.assertEqual(_prague(dl), "2026-06-12 16:00")
|
||||
|
||||
def test_today18_before_18(self) -> None:
|
||||
dl = departure_choice_to_deadline("today18", now=_NOW) # 12:00 Prague
|
||||
self.assertEqual(_prague(dl), "2026-06-12 18:00")
|
||||
|
||||
def test_today18_after_18_rolls_to_next_day(self) -> None:
|
||||
late = datetime(2026, 6, 12, 17, 30, tzinfo=timezone.utc) # 19:30 Prague
|
||||
dl = departure_choice_to_deadline("today18", now=late)
|
||||
self.assertEqual(_prague(dl), "2026-06-13 18:00")
|
||||
|
||||
def test_tomorrow7_crosses_midnight(self) -> None:
|
||||
# 23:30 Prague v pátek → zítra (sobota) 07:00, tj. +7,5 h
|
||||
late = datetime(2026, 6, 12, 21, 30, tzinfo=timezone.utc)
|
||||
dl = departure_choice_to_deadline("tomorrow7", now=late)
|
||||
self.assertEqual(_prague(dl), "2026-06-13 07:00")
|
||||
|
||||
def test_tomorrow12(self) -> None:
|
||||
dl = departure_choice_to_deadline("tomorrow12", now=_NOW)
|
||||
self.assertEqual(_prague(dl), "2026-06-13 12:00")
|
||||
|
||||
def test_monday7_from_friday_allows_over_48h(self) -> None:
|
||||
# explicitní volba smí přes 48h limit fn_ev_session_defaults
|
||||
dl = departure_choice_to_deadline("monday7", now=_NOW) # pátek 12:00
|
||||
self.assertEqual(_prague(dl), "2026-06-15 07:00")
|
||||
self.assertGreater((dl - _NOW).total_seconds(), 48 * 3600)
|
||||
|
||||
def test_monday7_on_monday_before_7_is_today(self) -> None:
|
||||
mon_early = datetime(2026, 6, 15, 3, 0, tzinfo=timezone.utc) # po 05:00
|
||||
dl = departure_choice_to_deadline("monday7", now=mon_early)
|
||||
self.assertEqual(_prague(dl), "2026-06-15 07:00")
|
||||
|
||||
def test_monday7_on_monday_after_7_is_next_week(self) -> None:
|
||||
mon_late = datetime(2026, 6, 15, 8, 0, tzinfo=timezone.utc) # po 10:00
|
||||
dl = departure_choice_to_deadline("monday7", now=mon_late)
|
||||
self.assertEqual(_prague(dl), "2026-06-22 07:00")
|
||||
|
||||
def test_unknown_choice_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
departure_choice_to_deadline("never", now=_NOW)
|
||||
|
||||
|
||||
class SelectPatchTests(unittest.TestCase):
|
||||
"""Mapování výběrů na patch payload pro fn_ev_session_apply_patch."""
|
||||
|
||||
def test_dep_patches_only_deadline(self) -> None:
|
||||
p = select_to_patch("dep", "today18", now=_NOW, soc_at_connect=55.0)
|
||||
self.assertEqual(set(p), {"target_deadline"})
|
||||
self.assertIn("2026-06-12T18:00", p["target_deadline"])
|
||||
self.assertIn("+02:00", p["target_deadline"]) # Europe/Prague (CEST)
|
||||
|
||||
def test_tgt_patches_only_target(self) -> None:
|
||||
for value, expected in (("30", 30.0), ("50", 50.0), ("70", 70.0), ("100", 100.0)):
|
||||
p = select_to_patch("tgt", value, now=_NOW, soc_at_connect=55.0)
|
||||
self.assertEqual(p, {"target_soc_pct": expected})
|
||||
|
||||
def test_tgt_stop_targets_connect_soc(self) -> None:
|
||||
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=42.5)
|
||||
self.assertEqual(p, {"target_soc_pct": 42.5})
|
||||
|
||||
def test_tgt_stop_without_soc(self) -> None:
|
||||
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=None)
|
||||
self.assertEqual(p, {"target_soc_pct": 0.0})
|
||||
|
||||
def test_unknown_kind_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
select_to_patch("foo", "30", now=_NOW, soc_at_connect=None)
|
||||
|
||||
def test_labels(self) -> None:
|
||||
self.assertEqual(choice_label("dep", "monday7"), "odjezd pondělí ráno 7:00")
|
||||
self.assertEqual(choice_label("tgt", "70"), "cíl 70 %")
|
||||
self.assertEqual(choice_label("tgt", "stop"), "nenabíjet")
|
||||
|
||||
|
||||
class LegacyActionPatchTests(unittest.TestCase):
|
||||
"""Legacy tlačítka starších zpráv (h2/h4/morning/full/stop)."""
|
||||
|
||||
def _patch(self, action: str, **kw):
|
||||
return action_to_patch(
|
||||
action,
|
||||
now=_NOW,
|
||||
soc_at_connect=kw.get("soc", 55.0),
|
||||
default_deadline_hour=kw.get("hour", 7),
|
||||
)
|
||||
|
||||
def test_h2_deadline(self) -> None:
|
||||
p = self._patch("h2")
|
||||
self.assertIn("2026-06-12T12:00", p["target_deadline"])
|
||||
|
||||
def test_morning_next_occurrence(self) -> None:
|
||||
p = self._patch("morning", hour=7)
|
||||
# 12:00 Prague > 7:00 → zítra 7:00 Prague
|
||||
self.assertIn("2026-06-13T07:00", p["target_deadline"])
|
||||
|
||||
def test_morning_today_if_before(self) -> None:
|
||||
early = datetime(2026, 6, 12, 2, 0, tzinfo=timezone.utc) # 4:00 Prague
|
||||
p = action_to_patch("morning", now=early, soc_at_connect=50, default_deadline_hour=7)
|
||||
self.assertIn("2026-06-12T07:00", p["target_deadline"])
|
||||
|
||||
def test_full(self) -> None:
|
||||
p = self._patch("full")
|
||||
self.assertEqual(p["target_soc_pct"], 100)
|
||||
|
||||
def test_stop_targets_connect_soc(self) -> None:
|
||||
p = self._patch("stop", soc=42.5)
|
||||
self.assertEqual(p["target_soc_pct"], 42.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
41
backend/tests/test_ev_presence.py
Normal file
41
backend/tests/test_ev_presence.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""EV presence — čisté helpery (haversine, přechody)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.telemetry_collector import ev_presence_transition
|
||||
from services.tesla_client import haversine_m
|
||||
|
||||
|
||||
class HaversineTests(unittest.TestCase):
|
||||
def test_zero_distance(self) -> None:
|
||||
self.assertAlmostEqual(haversine_m(49.2445, 17.4070, 49.2445, 17.4070), 0.0, places=2)
|
||||
|
||||
def test_known_distance(self) -> None:
|
||||
# ~111 km na 1° zeměpisné šířky
|
||||
d = haversine_m(49.0, 17.0, 50.0, 17.0)
|
||||
self.assertAlmostEqual(d, 111_195, delta=300)
|
||||
|
||||
def test_geofence_scale(self) -> None:
|
||||
# ~100 m posun (0.0009° lat)
|
||||
d = haversine_m(49.24457, 17.407054, 49.24547, 17.407054)
|
||||
self.assertTrue(80 < d < 120, d)
|
||||
|
||||
|
||||
class TransitionTests(unittest.TestCase):
|
||||
def test_arrived(self) -> None:
|
||||
self.assertEqual(ev_presence_transition(False, True), "arrived")
|
||||
|
||||
def test_left(self) -> None:
|
||||
self.assertEqual(ev_presence_transition(True, False), "left")
|
||||
|
||||
def test_none_cases(self) -> None:
|
||||
self.assertIsNone(ev_presence_transition(None, True))
|
||||
self.assertIsNone(ev_presence_transition(True, None))
|
||||
self.assertIsNone(ev_presence_transition(True, True))
|
||||
self.assertIsNone(ev_presence_transition(False, False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
66
backend/tests/test_ev_session_parse.py
Normal file
66
backend/tests/test_ev_session_parse.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Parser EV session z fn_planning_site_context (_ev_session_from_json).
|
||||
|
||||
Bug 2026-06-13: session BEZ deadline (auto nad targetem / bez cíle) se v
|
||||
parseru zahazovala (None), takže plánovač neviděl zátěž auta ani oportunismus.
|
||||
Oprava: session bez deadline zůstává objektem s energy_needed_wh=0 a headroom.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from services.planning.db_io import _ev_session_from_json
|
||||
|
||||
|
||||
class EvSessionParseTests(unittest.TestCase):
|
||||
def test_none_and_empty_return_none(self) -> None:
|
||||
self.assertIsNone(_ev_session_from_json(None))
|
||||
self.assertIsNone(_ev_session_from_json([]))
|
||||
self.assertIsNone(_ev_session_from_json(123))
|
||||
|
||||
def test_session_without_deadline_kept_for_opportunism(self) -> None:
|
||||
sess = _ev_session_from_json(
|
||||
{
|
||||
"target_deadline": None,
|
||||
"energy_needed_wh": 0,
|
||||
"headroom_wh": 18000.0,
|
||||
"opportunistic_value_czk_kwh": 1.0,
|
||||
}
|
||||
)
|
||||
self.assertIsNotNone(sess)
|
||||
assert sess is not None
|
||||
self.assertIsNone(sess.target_deadline)
|
||||
self.assertEqual(sess.energy_needed_wh, 0.0)
|
||||
self.assertEqual(sess.headroom_wh, 18000.0)
|
||||
self.assertEqual(sess.opportunistic_value_czk_kwh, 1.0)
|
||||
|
||||
def test_session_with_deadline_and_need(self) -> None:
|
||||
sess = _ev_session_from_json(
|
||||
{
|
||||
"target_deadline": "2026-06-14T05:00:00+00:00",
|
||||
"energy_needed_wh": 12000.0,
|
||||
"headroom_wh": 6000.0,
|
||||
"opportunistic_value_czk_kwh": 1.0,
|
||||
}
|
||||
)
|
||||
assert sess is not None
|
||||
self.assertIsNotNone(sess.target_deadline)
|
||||
self.assertEqual(sess.energy_needed_wh, 12000.0)
|
||||
|
||||
def test_missing_needed_defaults_zero(self) -> None:
|
||||
sess = _ev_session_from_json(
|
||||
{"target_deadline": None, "headroom_wh": 1000.0}
|
||||
)
|
||||
assert sess is not None
|
||||
self.assertEqual(sess.energy_needed_wh, 0.0)
|
||||
self.assertEqual(sess.opportunistic_value_czk_kwh, 0.0)
|
||||
|
||||
def test_json_string_payload(self) -> None:
|
||||
sess = _ev_session_from_json(
|
||||
'{"target_deadline": null, "energy_needed_wh": 0, '
|
||||
'"headroom_wh": 5000, "opportunistic_value_czk_kwh": 1.0}'
|
||||
)
|
||||
assert sess is not None
|
||||
self.assertEqual(sess.headroom_wh, 5000.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
212
backend/tests/test_ev_write_on_change.py
Normal file
212
backend/tests/test_ev_write_on_change.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""TeltoCharge zápis: reg 15 (amps) VŽDY, watchdog 19/20 write-on-change.
|
||||
|
||||
Export tick běží ~8x/hod (control_export :14,:29,:44,:59 + rolling replan
|
||||
*/15 s exportem). **Reg 15 (amps to use) se zapisuje VŽDY** — TeltoCharge ho
|
||||
po výpadku komunikace sám přepíše na failsafe (reg 20) bez journal řádku, a
|
||||
kdyby byl write-on-change, EMS by tichý drift 0 → 8 A nikdy nezahlédlo
|
||||
(verify čte zpět jen `written`). **Reg 19/20 (watchdog config, EEPROM wear)
|
||||
zůstávají write-on-change** proti fn_modbus_device_state_map (nejnovější
|
||||
written/verified řádek per registr): zapíší se jednou po startu / po výpadku;
|
||||
sytí je i FC3 čtení telemetrie (60 s), periodické zápisy netřeba.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import services.control.modbus_journal as journal
|
||||
from services.control.modbus_journal import _drop_registers_matching_last_verified
|
||||
from services.control.models import ControlSetpoints
|
||||
from services.control.outputs import (
|
||||
TELTO_REG_AMPS_TO_USE,
|
||||
TELTO_REG_COMM_TIMEOUT_S,
|
||||
TELTO_REG_FAILSAFE_CURRENT_A,
|
||||
TELTO_WATCHDOG_FAILSAFE_A,
|
||||
TELTO_WATCHDOG_TIMEOUT_S,
|
||||
_split_amps_and_watchdog,
|
||||
_telto_setpoint_registers,
|
||||
write_ev_arrival_hold,
|
||||
write_ev_setpoints,
|
||||
)
|
||||
|
||||
#: Stav zařízení po prvním úspěšném exportu s 0 A (klid, auto nepřipojené).
|
||||
_STEADY_STATE_0A = {
|
||||
TELTO_REG_AMPS_TO_USE: 0,
|
||||
TELTO_REG_COMM_TIMEOUT_S: TELTO_WATCHDOG_TIMEOUT_S,
|
||||
TELTO_REG_FAILSAFE_CURRENT_A: TELTO_WATCHDOG_FAILSAFE_A,
|
||||
}
|
||||
|
||||
|
||||
def _setpoints(ev1_a: int = 0) -> ControlSetpoints:
|
||||
return ControlSetpoints(
|
||||
battery_w=None,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=ev1_a,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
)
|
||||
|
||||
|
||||
class TeltoSetpointRegistersTests(unittest.TestCase):
|
||||
def test_triple_for_zero_amps(self) -> None:
|
||||
regs = _telto_setpoint_registers(0)
|
||||
self.assertEqual(
|
||||
[(r, v) for r, _, v in regs],
|
||||
[(15, 0), (19, 300), (20, 8)],
|
||||
)
|
||||
|
||||
def test_amps_below_six_coerced_to_zero_and_clamped_to_32(self) -> None:
|
||||
self.assertEqual(_telto_setpoint_registers(5)[0][2], 0)
|
||||
self.assertEqual(_telto_setpoint_registers(6)[0][2], 6)
|
||||
self.assertEqual(_telto_setpoint_registers(40)[0][2], 32)
|
||||
|
||||
def test_per_charger_failsafe_and_timeout(self) -> None:
|
||||
regs = _telto_setpoint_registers(0, comm_timeout_s=120, failsafe_a=6)
|
||||
self.assertEqual([(r, v) for r, _, v in regs], [(15, 0), (19, 120), (20, 6)])
|
||||
|
||||
def test_failsafe_clamped_to_0_32(self) -> None:
|
||||
self.assertEqual(_telto_setpoint_registers(0, failsafe_a=99)[2][2], 32)
|
||||
self.assertEqual(_telto_setpoint_registers(0, failsafe_a=-5)[2][2], 0)
|
||||
|
||||
def test_split_separates_amps_from_watchdog(self) -> None:
|
||||
amps, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0))
|
||||
self.assertEqual([r for r, _, _ in amps], [15])
|
||||
self.assertEqual([r for r, _, _ in watchdog], [19, 20])
|
||||
|
||||
|
||||
class DropAgainstDeviceStateTests(unittest.TestCase):
|
||||
def test_watchdog_steady_state_drops_19_20(self) -> None:
|
||||
_, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0))
|
||||
out, skipped = _drop_registers_matching_last_verified(
|
||||
watchdog, _STEADY_STATE_0A
|
||||
)
|
||||
self.assertEqual(out, [])
|
||||
self.assertEqual(skipped, [19, 20])
|
||||
|
||||
def test_empty_state_after_outage_keeps_19_20(self) -> None:
|
||||
_, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0))
|
||||
out, skipped = _drop_registers_matching_last_verified(watchdog, {})
|
||||
self.assertEqual([r for r, _, _ in out], [19, 20])
|
||||
self.assertEqual(skipped, [])
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Jen řádky chargeru; journal funkce se patchují v modbus_journal."""
|
||||
|
||||
def __init__(self, failsafe_a: int = 8, comm_timeout_s: int = 300) -> None:
|
||||
self.row = {
|
||||
"asset_id": 7,
|
||||
"code": "ev-charger-1",
|
||||
"host": "172.16.1.16",
|
||||
"port": 502,
|
||||
"unit_id": 1,
|
||||
"watchdog_failsafe_a": failsafe_a,
|
||||
"watchdog_comm_timeout_s": comm_timeout_s,
|
||||
}
|
||||
|
||||
async def fetch(self, query: str, *args: object) -> list[dict]:
|
||||
return [self.row]
|
||||
|
||||
async def fetchrow(self, query: str, *args: object) -> dict:
|
||||
return self.row
|
||||
|
||||
async def fetchval(self, query: str, *args: object) -> None:
|
||||
raise AssertionError(f"unexpected fetchval: {query}")
|
||||
|
||||
|
||||
class WriteEvSetpointsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def _run(
|
||||
self, device_state: dict[int, int], ev1_a: int, db: _FakeDB | None = None
|
||||
) -> tuple[AsyncMock, AsyncMock]:
|
||||
create = AsyncMock(return_value=[1, 2, 3])
|
||||
execute = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch.object(
|
||||
journal,
|
||||
"_fetch_device_state_registers",
|
||||
AsyncMock(return_value=device_state),
|
||||
),
|
||||
patch.object(journal, "create_modbus_commands", create),
|
||||
patch.object(journal, "execute_modbus_commands", execute),
|
||||
):
|
||||
await write_ev_setpoints(1, _setpoints(ev1_a), db or _FakeDB()) # type: ignore[arg-type]
|
||||
return create, execute
|
||||
|
||||
async def test_steady_state_still_reasserts_reg_15(self) -> None:
|
||||
# Reg 15 se zapisuje VŽDY (re-asert proti tichému failsafe driftu),
|
||||
# i když je device-state mapa shodná. Watchdog 19/20 se přeskočí.
|
||||
create, execute = await self._run(_STEADY_STATE_0A, ev1_a=0)
|
||||
create.assert_awaited_once()
|
||||
registers = create.await_args.args[8]
|
||||
self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)])
|
||||
execute.assert_awaited_once()
|
||||
|
||||
async def test_plan_change_writes_only_amps(self) -> None:
|
||||
create, execute = await self._run(_STEADY_STATE_0A, ev1_a=16)
|
||||
create.assert_awaited_once()
|
||||
registers = create.await_args.args[8]
|
||||
self.assertEqual([(r, v) for r, _, v in registers], [(15, 16)])
|
||||
execute.assert_awaited_once()
|
||||
|
||||
async def test_after_outage_writes_amps_then_watchdog(self) -> None:
|
||||
create, execute = await self._run({}, ev1_a=0)
|
||||
registers = create.await_args.args[8]
|
||||
self.assertEqual([r for r, _, _ in registers], [15, 19, 20])
|
||||
execute.assert_awaited_once()
|
||||
|
||||
async def test_per_charger_failsafe_from_db(self) -> None:
|
||||
# Failsafe 6 A z DB → po výpadku se zapíše reg 20 = 6 (prázdná mapa).
|
||||
create, _ = await self._run(
|
||||
{}, ev1_a=0, db=_FakeDB(failsafe_a=6, comm_timeout_s=120)
|
||||
)
|
||||
registers = create.await_args.args[8]
|
||||
self.assertEqual(
|
||||
[(r, v) for r, _, v in registers], [(15, 0), (19, 120), (20, 6)]
|
||||
)
|
||||
|
||||
|
||||
class WriteEvArrivalHoldTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def _run(
|
||||
self, device_state: dict[int, int]
|
||||
) -> tuple[bool, AsyncMock, AsyncMock]:
|
||||
create = AsyncMock(return_value=[1])
|
||||
execute = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch.object(
|
||||
journal,
|
||||
"_fetch_device_state_registers",
|
||||
AsyncMock(return_value=device_state),
|
||||
),
|
||||
patch.object(journal, "create_modbus_commands", create),
|
||||
patch.object(journal, "execute_modbus_commands", execute),
|
||||
):
|
||||
ok = await write_ev_arrival_hold(1, "ev-charger-1", _FakeDB()) # type: ignore[arg-type]
|
||||
return ok, create, execute
|
||||
|
||||
async def test_hold_always_writes_reg_15_even_if_device_at_zero(self) -> None:
|
||||
# Tvrdé zastavení po píchnutí kabelu — reg 15 = 0 se zapíše VŽDY.
|
||||
ok, create, execute = await self._run(_STEADY_STATE_0A)
|
||||
self.assertTrue(ok)
|
||||
registers = create.await_args.args[8]
|
||||
self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)])
|
||||
execute.assert_awaited_once()
|
||||
|
||||
async def test_hold_writes_amps_and_watchdog_when_device_drifted(self) -> None:
|
||||
ok, create, execute = await self._run(
|
||||
{
|
||||
TELTO_REG_AMPS_TO_USE: 16,
|
||||
TELTO_REG_COMM_TIMEOUT_S: TELTO_WATCHDOG_TIMEOUT_S,
|
||||
TELTO_REG_FAILSAFE_CURRENT_A: TELTO_WATCHDOG_FAILSAFE_A,
|
||||
}
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
registers = create.await_args.args[8]
|
||||
# Reg 15 = 0 zapsán (i když device hlásí 16); 19/20 shodné → skip.
|
||||
self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)])
|
||||
execute.assert_awaited_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
35
backend/tests/test_export_hard_limit.py
Normal file
35
backend/tests/test_export_hard_limit.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""HARD LIMIT exportu (CLAUDE.md §4.19): reg 143 nikdy nad limit ulice.
|
||||
|
||||
Pokuta v řádu desítek tisíc Kč za každou kW překročení rezervovaného
|
||||
exportního výkonu na fakturačním elektroměru. Terminálový limit (reg 143)
|
||||
nesmí přesáhnout max_export_power_w za žádných okolností — žádný
|
||||
feed-forward o měřenou spotřebu mezi střídačem a CT.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.control.setpoints import _deye_reg143_export_w
|
||||
|
||||
|
||||
class ExportHardLimitTests(unittest.TestCase):
|
||||
def test_reg143_never_exceeds_street_limit(self) -> None:
|
||||
street_limit = 13_500
|
||||
self.assertLessEqual(
|
||||
_deye_reg143_export_w(False, street_limit), street_limit
|
||||
)
|
||||
|
||||
def test_no_export_is_zero(self) -> None:
|
||||
self.assertEqual(_deye_reg143_export_w(True, 13_500), 0)
|
||||
|
||||
def test_plan_export_limit_caps_not_raises(self) -> None:
|
||||
# vzor z write_inverter_setpoints: export_lim = min(hw, plan) — plán
|
||||
# smí limit jen SNÍŽIT, nikdy zvýšit
|
||||
hw = _deye_reg143_export_w(False, 13_500)
|
||||
plan_limit = 20_000
|
||||
self.assertLessEqual(min(hw, plan_limit), 13_500)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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()
|
||||
234
backend/tests/test_modbus_execute_failsafe.py
Normal file
234
backend/tests/test_modbus_execute_failsafe.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""execute_modbus_commands: žádná cesta nesmí nechat příkaz 'pending'.
|
||||
|
||||
Regrese na živý incident home-01 (TeltoCharge 172.16.1.16): zápisová trojice
|
||||
(15, 19–20) buď skončila 'failed' s prázdným error_msg (str(TimeoutError())
|
||||
== ''), nebo zůstala trvale 'pending' (export visel bez limitu na flock brány
|
||||
obsazené pollingem mrtvého unit_id; výjimka mimo retry cyklus stav neuložila).
|
||||
|
||||
Testy: (1) error_msg nikdy prázdný; (2) GatewayLockTimeout → failed
|
||||
s 'gateway lock timeout'; (3) CancelledError / chyba DB → safety net označí
|
||||
zbylé příkazy failed a výjimku propaguje; (4) flock s timeoutem v
|
||||
modbus_client; (5) backoff pollingu nedosažitelného wallboxu.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import services.control.modbus_journal as journal
|
||||
import services.modbus_client as mc
|
||||
import services.telemetry_collector as tc
|
||||
from services.control.modbus_journal import (
|
||||
_modbus_error_text,
|
||||
execute_modbus_commands,
|
||||
)
|
||||
from services.modbus_client import GatewayLockTimeout
|
||||
|
||||
|
||||
def _cmd_row(cid: int, reg: int, val: int = 0) -> dict:
|
||||
return {
|
||||
"id": cid,
|
||||
"register": reg,
|
||||
"value_to_write": val,
|
||||
"device_host": "172.16.1.16",
|
||||
"device_port": 502,
|
||||
"device_unit_id": 1,
|
||||
"asset_code": "ev-charger-1",
|
||||
}
|
||||
|
||||
|
||||
class _JournalDB:
|
||||
"""In-memory journal — sleduje status a error_msg per command id."""
|
||||
|
||||
def __init__(self, rows: list[dict], fail_written_update: bool = False) -> None:
|
||||
self.rows = {r["id"]: dict(r) for r in rows}
|
||||
self.status = {r["id"]: "pending" for r in rows}
|
||||
self.error_msg: dict[int, str | None] = {r["id"]: None for r in rows}
|
||||
self.fail_written_update = fail_written_update
|
||||
|
||||
async def fetchrow(self, query: str, cid: int) -> dict | None:
|
||||
return self.rows.get(cid)
|
||||
|
||||
async def execute(self, query: str, *args: object) -> None:
|
||||
if "status='written'" in query:
|
||||
if self.fail_written_update:
|
||||
raise RuntimeError("db connection lost")
|
||||
_val, cid = args
|
||||
self.status[int(cid)] = "written" # type: ignore[arg-type]
|
||||
self.error_msg[int(cid)] = None # type: ignore[arg-type]
|
||||
elif "status='failed'" in query:
|
||||
msg, cid = args
|
||||
self.status[int(cid)] = "failed" # type: ignore[arg-type]
|
||||
self.error_msg[int(cid)] = str(msg) # type: ignore[arg-type]
|
||||
else:
|
||||
raise AssertionError(f"unexpected execute: {query}")
|
||||
|
||||
|
||||
def _fake_client(write_exc: BaseException | None = None) -> AsyncMock:
|
||||
client = AsyncMock()
|
||||
if write_exc is not None:
|
||||
client.write_registers.side_effect = write_exc
|
||||
client.force_disconnect = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
class ErrorTextTests(unittest.TestCase):
|
||||
def test_empty_str_exception_falls_back_to_repr(self) -> None:
|
||||
self.assertEqual(_modbus_error_text(TimeoutError()), "TimeoutError()")
|
||||
|
||||
def test_nonempty_str_kept(self) -> None:
|
||||
self.assertEqual(_modbus_error_text(OSError("boom")), "boom")
|
||||
|
||||
|
||||
class ExecuteFailsafeTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def _run(
|
||||
self,
|
||||
db: _JournalDB,
|
||||
client: AsyncMock,
|
||||
ids: list[int],
|
||||
) -> bool:
|
||||
with (
|
||||
patch.object(journal, "get_modbus_client", AsyncMock(return_value=client)),
|
||||
patch.object(journal.asyncio, "sleep", AsyncMock()),
|
||||
):
|
||||
return await execute_modbus_commands(ids, db) # type: ignore[arg-type]
|
||||
|
||||
async def test_timeout_with_empty_str_marks_failed_with_nonempty_msg(self) -> None:
|
||||
db = _JournalDB([_cmd_row(1, 15), _cmd_row(2, 19), _cmd_row(3, 20)])
|
||||
ok = await self._run(db, _fake_client(TimeoutError()), [1, 2, 3])
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(set(db.status.values()), {"failed"})
|
||||
for msg in db.error_msg.values():
|
||||
self.assertTrue(msg) # nikdy NULL/prázdný
|
||||
|
||||
async def test_gateway_lock_timeout_marks_failed_with_reason(self) -> None:
|
||||
db = _JournalDB([_cmd_row(1, 15)])
|
||||
exc = GatewayLockTimeout("gateway lock timeout 172.16.1.16:502 after 20s")
|
||||
ok = await self._run(db, _fake_client(exc), [1])
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(db.status[1], "failed")
|
||||
self.assertIn("gateway lock timeout", db.error_msg[1] or "")
|
||||
|
||||
async def test_cancelled_error_marks_failed_and_reraises(self) -> None:
|
||||
db = _JournalDB([_cmd_row(1, 15), _cmd_row(2, 19), _cmd_row(3, 20)])
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await self._run(db, _fake_client(asyncio.CancelledError()), [1, 2, 3])
|
||||
self.assertEqual(set(db.status.values()), {"failed"})
|
||||
for msg in db.error_msg.values():
|
||||
self.assertIn("execute aborted", msg or "")
|
||||
|
||||
async def test_db_failure_in_written_update_marks_rest_failed(self) -> None:
|
||||
db = _JournalDB([_cmd_row(1, 15), _cmd_row(2, 19)], fail_written_update=True)
|
||||
with self.assertRaises(RuntimeError):
|
||||
await self._run(db, _fake_client(), [1, 2])
|
||||
self.assertEqual(set(db.status.values()), {"failed"})
|
||||
self.assertIn("db connection lost", db.error_msg[1] or "")
|
||||
|
||||
async def test_force_disconnect_failure_does_not_leave_pending(self) -> None:
|
||||
db = _JournalDB([_cmd_row(1, 15)])
|
||||
client = _fake_client(OSError("write boom"))
|
||||
client.force_disconnect.side_effect = OSError("disconnect boom")
|
||||
ok = await self._run(db, client, [1])
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(db.status[1], "failed")
|
||||
self.assertIn("write boom", db.error_msg[1] or "")
|
||||
|
||||
async def test_success_path_still_written(self) -> None:
|
||||
db = _JournalDB([_cmd_row(1, 15), _cmd_row(2, 19), _cmd_row(3, 20)])
|
||||
ok = await self._run(db, _fake_client(), [1, 2, 3])
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(set(db.status.values()), {"written"})
|
||||
|
||||
|
||||
class GatewayFlockTimeoutTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_lock_timeout_raises_gateway_lock_timeout(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d, patch.dict(
|
||||
os.environ,
|
||||
{"EMS_MODBUS_LOCK_DIR": d, "EMS_MODBUS_FLOCK_TIMEOUT_S": "0.3"},
|
||||
):
|
||||
path = mc._gateway_lock_path("10.99.99.99", 502)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
holder = open(path, "a+b") # noqa: SIM115
|
||||
fcntl.flock(holder.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
with self.assertRaises(GatewayLockTimeout) as ctx:
|
||||
async with mc._gateway_exclusive("10.99.99.99", 502):
|
||||
pass
|
||||
self.assertIn("gateway lock timeout", str(ctx.exception))
|
||||
finally:
|
||||
fcntl.flock(holder.fileno(), fcntl.LOCK_UN)
|
||||
holder.close()
|
||||
|
||||
async def test_lock_acquired_when_free(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d, patch.dict(
|
||||
os.environ, {"EMS_MODBUS_LOCK_DIR": d}
|
||||
):
|
||||
async with mc._gateway_exclusive("10.99.99.98", 502):
|
||||
pass # bez výjimky
|
||||
|
||||
|
||||
class EvPollBackoffTests(unittest.TestCase):
|
||||
KEY = ("172.16.1.16", 502, 2)
|
||||
|
||||
def setUp(self) -> None:
|
||||
tc._EV_POLL_FAIL_STREAK.clear()
|
||||
tc._EV_POLL_NEXT_ATTEMPT.clear()
|
||||
|
||||
def test_below_threshold_never_skips(self) -> None:
|
||||
tc._ev_poll_record_failure(self.KEY, 100.0)
|
||||
tc._ev_poll_record_failure(self.KEY, 160.0)
|
||||
self.assertFalse(tc._ev_poll_should_skip(self.KEY, 220.0))
|
||||
|
||||
def test_skips_after_threshold_until_backoff_elapses(self) -> None:
|
||||
for t in (100.0, 160.0, 220.0):
|
||||
tc._ev_poll_record_failure(self.KEY, t)
|
||||
self.assertTrue(tc._ev_poll_should_skip(self.KEY, 221.0))
|
||||
self.assertTrue(
|
||||
tc._ev_poll_should_skip(self.KEY, 220.0 + tc.EV_POLL_BACKOFF_S - 1)
|
||||
)
|
||||
self.assertFalse(
|
||||
tc._ev_poll_should_skip(self.KEY, 220.0 + tc.EV_POLL_BACKOFF_S + 1)
|
||||
)
|
||||
|
||||
def test_success_resets_streak(self) -> None:
|
||||
for t in (100.0, 160.0, 220.0):
|
||||
tc._ev_poll_record_failure(self.KEY, t)
|
||||
tc._ev_poll_record_success(self.KEY)
|
||||
self.assertFalse(tc._ev_poll_should_skip(self.KEY, 221.0))
|
||||
|
||||
|
||||
class _PollDB:
|
||||
"""Jen řádek chargeru pro poll_ev_chargers (failure path se dál nedotkne DB)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.row = {
|
||||
"id": 7,
|
||||
"code": "ev-charger-2",
|
||||
"host": "172.16.1.16",
|
||||
"port": 502,
|
||||
"unit_id": 2,
|
||||
}
|
||||
|
||||
async def fetch(self, query: str, *args: object) -> list[dict]:
|
||||
return [self.row]
|
||||
|
||||
|
||||
class PollEvChargersBackoffIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_dead_unit_stops_hitting_gateway_after_threshold(self) -> None:
|
||||
tc._EV_POLL_FAIL_STREAK.clear()
|
||||
tc._EV_POLL_NEXT_ATTEMPT.clear()
|
||||
get_client = AsyncMock(side_effect=OSError("unit 2 unreachable"))
|
||||
with patch.object(tc, "get_modbus_client", get_client):
|
||||
for _ in range(tc.EV_POLL_FAIL_THRESHOLD):
|
||||
await tc.poll_ev_chargers(1, _PollDB()) # type: ignore[arg-type]
|
||||
self.assertEqual(get_client.await_count, tc.EV_POLL_FAIL_THRESHOLD)
|
||||
# další tick uvnitř backoff okna už na bránu nesahá
|
||||
await tc.poll_ev_chargers(1, _PollDB()) # type: ignore[arg-type]
|
||||
self.assertEqual(get_client.await_count, tc.EV_POLL_FAIL_THRESHOLD)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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()
|
||||
157
backend/tests/test_shelly_client.py
Normal file
157
backend/tests/test_shelly_client.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Shelly Gen2 RPC klient — parser Switch.GetStatus a stavba RPC volání (mock httpx)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
|
||||
from services.shelly_client import (
|
||||
ShellySwitchStatus,
|
||||
get_switch_status,
|
||||
parse_switch_status,
|
||||
set_switch,
|
||||
shelly_base_url,
|
||||
)
|
||||
|
||||
|
||||
class ParseSwitchStatusTests(unittest.TestCase):
|
||||
def test_full_gen2_payload(self) -> None:
|
||||
st = parse_switch_status(
|
||||
{
|
||||
"id": 0,
|
||||
"source": "HTTP_in",
|
||||
"output": True,
|
||||
"apower": 745.3,
|
||||
"voltage": 231.2,
|
||||
"current": 3.25,
|
||||
"aenergy": {"total": 12345.678, "by_minute": [123, 120, 118]},
|
||||
"temperature": {"tC": 41.2},
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
st,
|
||||
ShellySwitchStatus(output=True, apower_w=745.3, aenergy_total_wh=12345.678),
|
||||
)
|
||||
|
||||
def test_minimal_payload_without_metering(self) -> None:
|
||||
# Levnější relé bez měření: jen output.
|
||||
st = parse_switch_status({"id": 0, "output": False})
|
||||
self.assertFalse(st.output)
|
||||
self.assertIsNone(st.apower_w)
|
||||
self.assertIsNone(st.aenergy_total_wh)
|
||||
|
||||
def test_missing_output_raises(self) -> None:
|
||||
# Gen1 /relay/0 odpověď ('ison') nesmí tiše projít — podporujeme jen Gen2.
|
||||
with self.assertRaises(ValueError):
|
||||
parse_switch_status({"ison": True, "has_timer": False})
|
||||
|
||||
def test_zero_values_kept(self) -> None:
|
||||
st = parse_switch_status(
|
||||
{"id": 0, "output": False, "apower": 0.0, "aenergy": {"total": 0.0}}
|
||||
)
|
||||
self.assertEqual(st.apower_w, 0.0)
|
||||
self.assertEqual(st.aenergy_total_wh, 0.0)
|
||||
|
||||
|
||||
class ShellyBaseUrlTests(unittest.TestCase):
|
||||
def test_defaults(self) -> None:
|
||||
self.assertEqual(shelly_base_url(None, "192.168.1.50", None), "http://192.168.1.50:80")
|
||||
|
||||
def test_https_default_port(self) -> None:
|
||||
self.assertEqual(shelly_base_url("https", "shelly.local", None), "https://shelly.local:443")
|
||||
|
||||
def test_unknown_protocol_falls_back_to_http(self) -> None:
|
||||
self.assertEqual(shelly_base_url("modbus_tcp", "1.2.3.4", 8080), "http://1.2.3.4:8080")
|
||||
|
||||
|
||||
class ShellyRpcTests(unittest.TestCase):
|
||||
"""RPC přes httpx.MockTransport — bez sítě."""
|
||||
|
||||
def _client(self, handler) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
def test_get_switch_status(self) -> None:
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["path"] = request.url.path
|
||||
seen["id"] = request.url.params.get("id")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": 0, "output": True, "apower": 740.0, "aenergy": {"total": 999.5}},
|
||||
)
|
||||
|
||||
async def run() -> ShellySwitchStatus:
|
||||
async with self._client(handler) as client:
|
||||
return await get_switch_status("http://192.168.1.50:80", 0, client=client)
|
||||
|
||||
st = asyncio.run(run())
|
||||
self.assertEqual(seen["path"], "/rpc/Switch.GetStatus")
|
||||
self.assertEqual(seen["id"], "0")
|
||||
self.assertTrue(st.output)
|
||||
self.assertEqual(st.apower_w, 740.0)
|
||||
self.assertEqual(st.aenergy_total_wh, 999.5)
|
||||
|
||||
def test_set_switch_sends_json_bool_and_returns_was_on(self) -> None:
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["path"] = request.url.path
|
||||
seen["id"] = request.url.params.get("id")
|
||||
seen["on"] = request.url.params.get("on")
|
||||
return httpx.Response(200, json={"was_on": False})
|
||||
|
||||
async def run() -> bool | None:
|
||||
async with self._client(handler) as client:
|
||||
return await set_switch("http://192.168.1.50:80/", True, 0, client=client)
|
||||
|
||||
was_on = asyncio.run(run())
|
||||
self.assertEqual(seen["path"], "/rpc/Switch.Set")
|
||||
self.assertEqual(seen["id"], "0")
|
||||
# Gen2 RPC parsuje query jako JSON — bool musí být doslova 'true'/'false'.
|
||||
self.assertEqual(seen["on"], "true")
|
||||
self.assertIs(was_on, False)
|
||||
|
||||
def test_set_switch_off(self) -> None:
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["on"] = request.url.params.get("on")
|
||||
return httpx.Response(200, json={"was_on": True})
|
||||
|
||||
async def run() -> bool | None:
|
||||
async with self._client(handler) as client:
|
||||
return await set_switch("http://10.0.0.7", False, client=client)
|
||||
|
||||
self.assertIs(asyncio.run(run()), True)
|
||||
self.assertEqual(seen["on"], "false")
|
||||
|
||||
def test_http_error_raises(self) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, text="boom")
|
||||
|
||||
async def run() -> None:
|
||||
async with self._client(handler) as client:
|
||||
await get_switch_status("http://10.0.0.7", client=client)
|
||||
|
||||
with self.assertRaises(httpx.HTTPStatusError):
|
||||
asyncio.run(run())
|
||||
|
||||
def test_non_gen2_body_raises_value_error(self) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
# Gen1 odpověď — klient ji odmítne (žádný Gen1 fallback).
|
||||
return httpx.Response(200, content=json.dumps({"ison": True}))
|
||||
|
||||
async def run() -> None:
|
||||
async with self._client(handler) as client:
|
||||
await get_switch_status("http://10.0.0.7", client=client)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
472
backend/tests/test_solver_v2.py
Normal file
472
backend/tests/test_solver_v2.py
Normal file
@@ -0,0 +1,472 @@
|
||||
"""solver_v2 (čisté jádro): tvrdá pravidla, režimy, EV deadline, arbitráž (bez DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.planning.solver_v2 import solve_dispatch_v2
|
||||
from services.planning.types import PlanningSlot
|
||||
|
||||
|
||||
def _slot(
|
||||
base: datetime,
|
||||
i: int,
|
||||
*,
|
||||
buy: float,
|
||||
sell: float,
|
||||
pv_a: int = 0,
|
||||
pv_b: int = 0,
|
||||
load: int = 1000,
|
||||
ev1: bool = False,
|
||||
) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=pv_a,
|
||||
pv_b_forecast_w=pv_b,
|
||||
load_baseline_w=load,
|
||||
ev1_connected=ev1,
|
||||
ev2_connected=False,
|
||||
)
|
||||
|
||||
|
||||
def _battery(uc_wh: float = 20_000.0) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
usable_capacity_wh=uc_wh,
|
||||
min_soc_wh=0.12 * uc_wh,
|
||||
arb_floor_wh=0.20 * uc_wh,
|
||||
reserve_soc_wh=0.20 * uc_wh,
|
||||
soc_max_wh=0.95 * uc_wh,
|
||||
charge_efficiency=0.95,
|
||||
discharge_efficiency=0.95,
|
||||
degradation_cost_czk_kwh=0.5,
|
||||
max_charge_power_w=8000,
|
||||
max_discharge_power_w=8000,
|
||||
planner_terminal_soc_value_factor=0.8,
|
||||
)
|
||||
|
||||
|
||||
def _grid(block_neg: bool = False, gen_cutoff: bool = False) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
max_import_power_w=17_000,
|
||||
max_export_power_w=13_500,
|
||||
block_export_on_negative_sell=block_neg,
|
||||
deye_gen_microinverter_cutoff_enabled=gen_cutoff,
|
||||
)
|
||||
|
||||
|
||||
_HP = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
_VEHICLES = [
|
||||
SimpleNamespace(max_charge_power_w=11_000, battery_capacity_kwh=60.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
_BASE = datetime(2026, 6, 10, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _solve(
|
||||
slots,
|
||||
*,
|
||||
battery=None,
|
||||
grid=None,
|
||||
ev_sessions=(None, None),
|
||||
soc0=None,
|
||||
mode="AUTO",
|
||||
vehicles=None,
|
||||
):
|
||||
bat = battery or _battery()
|
||||
return solve_dispatch_v2(
|
||||
slots,
|
||||
bat,
|
||||
_HP,
|
||||
grid or _grid(),
|
||||
list(ev_sessions),
|
||||
vehicles if vehicles is not None else _VEHICLES,
|
||||
soc0 if soc0 is not None else 0.5 * bat.usable_capacity_wh,
|
||||
50.0,
|
||||
operating_mode=mode,
|
||||
)
|
||||
|
||||
|
||||
class HardRulesTests(unittest.TestCase):
|
||||
def test_negative_buy_blocks_export(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=-2.0, sell=1.5, pv_a=6000, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots)
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.grid_setpoint_w, 0, "buy<0 → žádný export (pumpa)")
|
||||
|
||||
def test_block_export_on_negative_sell(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-0.5, pv_a=8000, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots, grid=_grid(block_neg=True))
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.grid_setpoint_w, 0, "KV1: sell<0 → ge=0")
|
||||
|
||||
def test_negative_sell_prefers_charge_or_curtail_over_paid_export(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-1.0, pv_a=8000, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots)
|
||||
paid_export = sum(-r.grid_setpoint_w for r in results if r.grid_setpoint_w < 0)
|
||||
self.assertEqual(paid_export, 0, "spot: za export při sell<0 se platí → ekonomika ho vyloučí")
|
||||
|
||||
def test_battery_export_requires_arb_floor(self) -> None:
|
||||
bat = _battery()
|
||||
slots = [_slot(_BASE, i, buy=1.0, sell=8.0, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.5 * bat.usable_capacity_wh)
|
||||
for r in results:
|
||||
if r.grid_setpoint_w < 0 and r.battery_setpoint_w < 0:
|
||||
self.assertGreaterEqual(
|
||||
r.battery_soc_target / 100.0 * bat.usable_capacity_wh,
|
||||
bat.arb_floor_wh - 1.0,
|
||||
"export z baterie nesmí podlézt arb floor",
|
||||
)
|
||||
|
||||
def test_curtailment_only_pv_a(self) -> None:
|
||||
# extrémně záporný sell bez block_export: pole B nelze omezit, A ano
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-3.0, pv_a=5000, pv_b=4000, load=300) for i in range(8)]
|
||||
bat = _battery(uc_wh=2000.0) # malá baterie, ať se přebytek nevejde
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.9 * 2000.0)
|
||||
self.assertTrue(any(r.pv_a_curtailed_w > 0 for r in results), "A se curtailuje")
|
||||
for r in results:
|
||||
self.assertLessEqual(r.pv_a_curtailed_w, 5000, "curtail max = výroba A")
|
||||
|
||||
|
||||
class ArbitrageTests(unittest.TestCase):
|
||||
def test_cheap_night_charge_expensive_evening_discharge(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=1.0, sell=0.5, load=1000) for i in range(16)]
|
||||
slots += [_slot(_BASE, 16 + i, buy=8.0, sell=7.0, load=1000) for i in range(16)]
|
||||
results, _, _ = _solve(slots)
|
||||
charged = sum(r.battery_setpoint_w for r in results[:16] if r.battery_setpoint_w > 0)
|
||||
discharged = sum(-r.battery_setpoint_w for r in results[16:] if r.battery_setpoint_w < 0)
|
||||
self.assertGreater(charged, 0, "levná noc → nabíjet")
|
||||
self.assertGreater(discharged, 0, "drahý večer → vybíjet")
|
||||
|
||||
|
||||
class OperatingModeTests(unittest.TestCase):
|
||||
def _slots(self):
|
||||
return [_slot(_BASE, i, buy=1.0, sell=6.0, pv_a=3000, load=1000) for i in range(8)]
|
||||
|
||||
def test_preserve_locks_battery(self) -> None:
|
||||
results, _, _ = _solve(self._slots(), mode="PRESERVE")
|
||||
for r in results:
|
||||
self.assertEqual(r.battery_setpoint_w, 0)
|
||||
|
||||
def test_charge_cheap_no_export_no_discharge(self) -> None:
|
||||
results, _, _ = _solve(self._slots(), mode="CHARGE_CHEAP")
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.grid_setpoint_w, 0)
|
||||
self.assertGreaterEqual(r.battery_setpoint_w, 0)
|
||||
|
||||
def test_self_sustain_import_capped_to_load(self) -> None:
|
||||
results, _, _ = _solve(self._slots(), mode="SELF_SUSTAIN")
|
||||
for r in results:
|
||||
self.assertLessEqual(r.grid_setpoint_w, 1000, "import ≤ baseline load")
|
||||
|
||||
|
||||
class NightReserveTests(unittest.TestCase):
|
||||
def test_night_discharge_respects_buffer(self) -> None:
|
||||
# noc: vysoký sell, žádné PV; buffer 2000 Wh nad min → plán nesmí
|
||||
# kalkulovat s vybitím pod min+buffer (sell < buy ⇒ slack se nevyplatí)
|
||||
bat = _battery()
|
||||
slots = []
|
||||
for i in range(16):
|
||||
s = _slot(_BASE, i, buy=6.0, sell=4.5, load=800)
|
||||
s.night_baseload_buffer_wh = 2000.0
|
||||
slots.append(s)
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.6 * bat.usable_capacity_wh)
|
||||
floor_pct = (bat.min_soc_wh + 2000.0) / bat.usable_capacity_wh * 100.0
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.battery_soc_target, floor_pct - 0.6)
|
||||
|
||||
def test_extreme_sell_spike_may_sell_reserve(self) -> None:
|
||||
# sell výrazně nad buy → racionální polštář prodat (placený slack)
|
||||
bat = _battery()
|
||||
slots = []
|
||||
for i in range(16):
|
||||
s = _slot(_BASE, i, buy=2.0, sell=12.0, load=300)
|
||||
s.night_baseload_buffer_wh = 2000.0
|
||||
slots.append(s)
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.6 * bat.usable_capacity_wh)
|
||||
min_soc_pct = min(r.battery_soc_target for r in results)
|
||||
floor_pct = (bat.min_soc_wh + 2000.0) / bat.usable_capacity_wh * 100.0
|
||||
self.assertLess(min_soc_pct, floor_pct - 1.0, "spike má polštář vyprodat")
|
||||
|
||||
def test_start_below_buffer_is_feasible(self) -> None:
|
||||
bat = _battery()
|
||||
slots = []
|
||||
for i in range(8):
|
||||
s = _slot(_BASE, i, buy=6.0, sell=1.0, load=1500)
|
||||
s.night_baseload_buffer_wh = 3000.0
|
||||
slots.append(s)
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=bat.min_soc_wh + 500.0)
|
||||
self.assertEqual(len(results), 8)
|
||||
|
||||
|
||||
class DaytimeSafetyRampTests(unittest.TestCase):
|
||||
def test_morning_tops_up_reserve_before_selling(self) -> None:
|
||||
# KV1 scénář: ráno baterie u dna, fixní buy 6.35 >> sell 2.5, PV jede;
|
||||
# s rampou (target 30 % usable) musí nejdřív dotáhnout rezervu, ne prodávat
|
||||
bat = _battery()
|
||||
bat.planner_safety_soc_risk_factor = 0.05
|
||||
target_wh = 0.30 * bat.usable_capacity_wh
|
||||
slots = []
|
||||
for i in range(16):
|
||||
s = _slot(_BASE, i, buy=6.35, sell=2.5, pv_a=6000, load=800)
|
||||
s.safety_soc_target_wh = target_wh
|
||||
slots.append(s)
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.11 * bat.usable_capacity_wh)
|
||||
soc_pct = [r.battery_soc_target for r in results]
|
||||
first_reach = next((i for i, v in enumerate(soc_pct) if v >= 29.5), None)
|
||||
self.assertIsNotNone(first_reach, "rampa má dotáhnout na rezervu")
|
||||
exported_before = sum(
|
||||
-r.grid_setpoint_w for r in results[:first_reach] if r.grid_setpoint_w < 0
|
||||
)
|
||||
self.assertLess(
|
||||
exported_before, 500 * max(1, first_reach),
|
||||
"před dosažením rezervy se nemá významně prodávat",
|
||||
)
|
||||
|
||||
def test_sell_spike_beats_ramp(self) -> None:
|
||||
# extrémní sell nad buy → deficit je racionální podstoupit
|
||||
bat = _battery()
|
||||
bat.planner_safety_soc_risk_factor = 0.05
|
||||
slots = []
|
||||
for i in range(16):
|
||||
s = _slot(_BASE, i, buy=2.0, sell=14.0, pv_a=2000, load=300)
|
||||
s.safety_soc_target_wh = 0.5 * bat.usable_capacity_wh
|
||||
slots.append(s)
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.45 * bat.usable_capacity_wh)
|
||||
total_export = sum(-r.grid_setpoint_w for r in results if r.grid_setpoint_w < 0)
|
||||
self.assertGreater(total_export, 5000, "spike má vyprodat i pod target")
|
||||
|
||||
|
||||
class PvRiskFrontloadTests(unittest.TestCase):
|
||||
def test_neg_window_charges_asap(self) -> None:
|
||||
# sell<0 okno, PV >> load, prázdnější baterie: s frontload prémií musí
|
||||
# nabíjení běžet plným tempem od začátku (ne odložené na konec okna)
|
||||
bat = _battery()
|
||||
bat.planner_pv_risk_frontload_czk_kwh = 0.05
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-0.5, pv_a=12000, load=500) for i in range(12)]
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.2 * bat.usable_capacity_wh)
|
||||
# max tempo: 8 kW × 0.25 h × 0.95 eff = 1.9 kWh/slot = 9.5 p.b. na 20 kWh
|
||||
soc_mid = results[3].battery_soc_target
|
||||
self.assertGreaterEqual(
|
||||
soc_mid, 20.0 + 4 * 9.0,
|
||||
"frontload: prvni 4 sloty maji nabijet plnym vykonem",
|
||||
)
|
||||
|
||||
|
||||
class EvOpportunisticTests(unittest.TestCase):
|
||||
def _session(self, needed=4000.0, headroom=20000.0, opp=1.0):
|
||||
return SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=2),
|
||||
energy_needed_wh=needed,
|
||||
headroom_wh=headroom,
|
||||
opportunistic_value_czk_kwh=opp,
|
||||
)
|
||||
|
||||
def test_negative_prices_fill_beyond_target(self) -> None:
|
||||
# buy<0 celé okno → nad target se vyplatí brát (hodnota 1 Kč/kWh + platí ti síť)
|
||||
slots = [_slot(_BASE, i, buy=-1.0, sell=-0.5, ev1=True, load=300) for i in range(16)]
|
||||
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
|
||||
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||
self.assertGreater(delivered, 4000.0 + 2000.0, "měkký cíl má nasávat")
|
||||
self.assertLessEqual(delivered, 4000.0 + 20000.0 + 1.0, "strop headroom")
|
||||
self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0)
|
||||
|
||||
def test_normal_prices_no_opportunistic(self) -> None:
|
||||
# běžné ceny (buy 3) > hodnota 1 Kč/kWh → jen tvrdý cíl
|
||||
slots = [_slot(_BASE, i, buy=3.0, sell=2.0, ev1=True, load=300) for i in range(16)]
|
||||
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
|
||||
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||
self.assertLess(delivered, 4000.0 + 200.0)
|
||||
self.assertLess(snap["objective_terms"]["ev_opp_wh"][0], 100.0)
|
||||
|
||||
def test_cheap_sell_prefers_car_over_grid(self) -> None:
|
||||
# sell 0.3 < opp 1.0, plná domácí baterka, velký PV přebytek
|
||||
# → přebytek do auta, ne za babku do sítě
|
||||
bat = _battery()
|
||||
slots = [_slot(_BASE, i, buy=3.0, sell=0.3, pv_a=9000, load=500, ev1=True) for i in range(16)]
|
||||
results, _, snap = _solve(
|
||||
slots, battery=bat, soc0=bat.soc_max_wh, # baterka plná
|
||||
ev_sessions=(self._session(needed=2000.0, headroom=25000.0), None),
|
||||
)
|
||||
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||
exported = sum(-r.grid_setpoint_w * 0.25 for r in results if r.grid_setpoint_w < 0)
|
||||
self.assertGreater(delivered, 15000.0, "přebytek má téct do auta")
|
||||
self.assertLess(exported, delivered, "prodej za 0.3 nemá vyhrát nad autem")
|
||||
|
||||
def test_total_energy_capped_even_at_negative_buy(self) -> None:
|
||||
# fix latentního bugu: bez headroom (opp=0) nesmí buy<0 pumpovat nad needed
|
||||
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(16)]
|
||||
sess = self._session(needed=3000.0, headroom=0.0, opp=0.0)
|
||||
results, _, _ = _solve(slots, ev_sessions=(sess, None))
|
||||
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||
self.assertLessEqual(delivered, 3000.0 + 1.0)
|
||||
|
||||
|
||||
class EvAccountingTests(unittest.TestCase):
|
||||
"""EV účtování 2026-06-12: deadline boundary, stop-session, fyzikální split,
|
||||
min. výkon wallboxu, opp po deadline, battery_arbitrage_czk reporting."""
|
||||
|
||||
def test_deadline_boundary_slot_excluded(self) -> None:
|
||||
# slot začínající přesně v deadline (slot 4) už do deadline nepatří;
|
||||
# levné sloty 4..7 nesmí krýt tvrdý cíl (dřív off-by-one t_dl+1)
|
||||
slots = [
|
||||
_slot(_BASE, i, buy=5.0 if i < 4 else 0.5, sell=0.2, ev1=True)
|
||||
for i in range(8)
|
||||
]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=1), # = start slotu 4
|
||||
energy_needed_wh=4000.0,
|
||||
headroom_wh=0.0,
|
||||
opportunistic_value_czk_kwh=0.0,
|
||||
)
|
||||
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||||
before = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[:4])
|
||||
after = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:])
|
||||
self.assertGreaterEqual(before, 4000.0 - 1.0, "tvrdý cíl jen sloty PŘED deadline")
|
||||
self.assertLessEqual(after, 1.0, "slot v deadline a dál nekryje tvrdý cíl")
|
||||
self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0])
|
||||
|
||||
def test_stop_session_zero_everywhere(self) -> None:
|
||||
# needed 0 + opp 0 (stop-session) → EV nula i při záporných cenách
|
||||
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=2),
|
||||
energy_needed_wh=0.0,
|
||||
headroom_wh=0.0,
|
||||
opportunistic_value_czk_kwh=0.0,
|
||||
)
|
||||
results, _, _ = _solve(slots, ev_sessions=(session, None))
|
||||
for r in results:
|
||||
self.assertEqual(r.ev1_setpoint_w or 0, 0)
|
||||
|
||||
def test_no_session_zero_even_at_negative_buy(self) -> None:
|
||||
# připojené auto BEZ session nemá mandát nabíjet (golden fixtures)
|
||||
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)]
|
||||
results, _, _ = _solve(slots, ev_sessions=(None, None))
|
||||
for r in results:
|
||||
self.assertEqual(r.ev1_setpoint_w or 0, 0)
|
||||
|
||||
def test_ev_direct_within_grid_plus_pv(self) -> None:
|
||||
# fyzikální split: direct (= setpoint − via_bat) nesmí překročit gi + PV
|
||||
slots = [
|
||||
_slot(_BASE, i, buy=2.0, sell=1.0, pv_a=(3000 if i < 4 else 0), ev1=True)
|
||||
for i in range(12)
|
||||
]
|
||||
bat = _battery()
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=3),
|
||||
energy_needed_wh=10000.0,
|
||||
headroom_wh=0.0,
|
||||
opportunistic_value_czk_kwh=0.0,
|
||||
)
|
||||
results, _, _ = _solve(
|
||||
slots, battery=bat, soc0=0.9 * bat.usable_capacity_wh,
|
||||
ev_sessions=(session, None),
|
||||
)
|
||||
for i, r in enumerate(results):
|
||||
direct = (r.ev1_setpoint_w or 0) - r.ev1_via_bat_w
|
||||
gi_w = max(0, r.grid_setpoint_w)
|
||||
pv_w = slots[i].pv_a_forecast_w + slots[i].pv_b_forecast_w
|
||||
self.assertLessEqual(direct, gi_w + pv_w + 2, f"slot {i}: direct > gi+pv")
|
||||
|
||||
def test_min_power_setpoints_zero_or_above_min(self) -> None:
|
||||
# wallbox min 1380 W (6 A): setpoint ∈ {0} ∪ [1380, max] — žádné 400–900 W
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=11_000, min_power_w=1380,
|
||||
battery_capacity_kwh=60.0, default_target_soc_pct=80.0,
|
||||
),
|
||||
_VEHICLES[1],
|
||||
]
|
||||
# ceny nutí rozprostřít malé množství energie → bez binárky by vyšlo ~86 W/slot
|
||||
slots = [_slot(_BASE, i, buy=2.0 + 0.01 * i, sell=1.0, ev1=True) for i in range(8)]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=2),
|
||||
energy_needed_wh=690.0, # 2 sloty × 1380 W × 0.25 h
|
||||
headroom_wh=0.0,
|
||||
opportunistic_value_czk_kwh=0.0,
|
||||
)
|
||||
results, _, _ = _solve(slots, ev_sessions=(session, None), vehicles=vehicles)
|
||||
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||
self.assertGreaterEqual(delivered, 690.0 - 1.0)
|
||||
for i, r in enumerate(results):
|
||||
sp = r.ev1_setpoint_w or 0
|
||||
self.assertTrue(
|
||||
sp == 0 or sp >= 1379,
|
||||
f"slot {i}: setpoint {sp} W je pod minimem wallboxu",
|
||||
)
|
||||
|
||||
def test_opportunistic_after_deadline_allowed(self) -> None:
|
||||
# ROZHODNUTO 2026-06-12: opp vrstva NENÍ omezená deadline — záporné ceny
|
||||
# po deadline smí téct do auta (odjezd řeší rolling replan)
|
||||
slots = [
|
||||
_slot(_BASE, i, buy=(3.0 if i < 4 else -1.5), sell=(1.0 if i < 4 else -0.5),
|
||||
ev1=True, load=300)
|
||||
for i in range(16)
|
||||
]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=1), # slot 4
|
||||
energy_needed_wh=2000.0,
|
||||
headroom_wh=20000.0,
|
||||
opportunistic_value_czk_kwh=1.0,
|
||||
)
|
||||
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||||
after_deadline = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:])
|
||||
total = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||
self.assertGreater(after_deadline, 0.0, "opp po deadline musí zůstat povolené")
|
||||
self.assertLessEqual(total, 2000.0 + 20000.0 + 1.0, "strop needed + headroom")
|
||||
self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0.0)
|
||||
|
||||
def test_battery_arbitrage_reported_for_via_bat(self) -> None:
|
||||
# EV kryté z baterie (noc, drahý buy, plná baterie) → via_bat > 0 a
|
||||
# battery_arbitrage_czk nese oportunitní cenu (ne konstantní 0)
|
||||
bat = _battery()
|
||||
slots = [_slot(_BASE, i, buy=8.0, sell=1.0, ev1=True, load=300) for i in range(8)]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=2),
|
||||
energy_needed_wh=6000.0,
|
||||
headroom_wh=0.0,
|
||||
opportunistic_value_czk_kwh=0.0,
|
||||
)
|
||||
results, _, _ = _solve(
|
||||
slots, battery=bat, soc0=bat.soc_max_wh, ev_sessions=(session, None)
|
||||
)
|
||||
via = sum(r.ev1_via_bat_w for r in results)
|
||||
self.assertGreater(via, 0, "drahý buy + plná baterie → EV z baterie")
|
||||
arb = sum(r.battery_arbitrage_czk for r in results)
|
||||
self.assertGreater(arb, 0.0, "via_bat sloty musí reportovat oportunitní Kč")
|
||||
for r in results:
|
||||
if r.ev1_via_bat_w == 0:
|
||||
self.assertEqual(r.battery_arbitrage_czk, 0.0)
|
||||
|
||||
|
||||
class EvDeadlineTests(unittest.TestCase):
|
||||
def test_ev_energy_delivered_before_deadline(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=4), # slot 16 → vše do konce
|
||||
energy_needed_wh=8000.0,
|
||||
)
|
||||
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||||
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||
self.assertGreaterEqual(delivered, 8000.0 - 1.0)
|
||||
self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0])
|
||||
# levné sloty (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()
|
||||
263
backend/tests/test_telemetry_idle_skip.py
Normal file
263
backend/tests/test_telemetry_idle_skip.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Idle-skip zápisů telemetrie: _idle_skip + detekce příjezdu/odjezdu EV přes skip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import services.telemetry_collector as tc
|
||||
from services.telemetry_collector import (
|
||||
IDLE_SKIP_MAX_GAP_S,
|
||||
TELTO_REG_BLOCK_COUNT,
|
||||
_idle_skip,
|
||||
_sig_round,
|
||||
)
|
||||
|
||||
|
||||
class IdleSkipTests(unittest.TestCase):
|
||||
KEY = ("telemetry_test", 1)
|
||||
|
||||
def setUp(self) -> None:
|
||||
tc._IDLE_SKIP_STATE.clear()
|
||||
|
||||
def test_first_sample_after_start_is_stored(self) -> None:
|
||||
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
|
||||
|
||||
def test_unchanged_idle_sample_is_skipped(self) -> None:
|
||||
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
|
||||
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1060.0))
|
||||
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1120.0))
|
||||
|
||||
def test_signature_change_is_stored(self) -> None:
|
||||
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
|
||||
self.assertFalse(_idle_skip(self.KEY, ("a", 100), False, 1060.0))
|
||||
|
||||
def test_active_device_is_always_stored(self) -> None:
|
||||
self.assertFalse(_idle_skip(self.KEY, ("a", 0), True, 1000.0))
|
||||
self.assertFalse(_idle_skip(self.KEY, ("a", 0), True, 1060.0))
|
||||
|
||||
def test_heartbeat_after_max_gap(self) -> None:
|
||||
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
|
||||
# přesně na hranici se ještě přeskakuje (> max_gap_s, ne >=)
|
||||
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S))
|
||||
self.assertFalse(
|
||||
_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S + 1.0)
|
||||
)
|
||||
# heartbeat resetuje last_stored_at → další idle vzorek se zase přeskočí
|
||||
self.assertTrue(
|
||||
_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S + 61.0)
|
||||
)
|
||||
|
||||
def test_keys_are_independent(self) -> None:
|
||||
other = ("telemetry_test", 2)
|
||||
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
|
||||
self.assertFalse(_idle_skip(other, ("a", 0), False, 1060.0))
|
||||
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1060.0))
|
||||
|
||||
def test_sig_round(self) -> None:
|
||||
self.assertIsNone(_sig_round(None, 0.2))
|
||||
self.assertEqual(_sig_round(47.31, 0.2), 47.4)
|
||||
self.assertEqual(_sig_round(47.29, 0.2), 47.2)
|
||||
self.assertEqual(_sig_round(-3.1, 0.2), -3.2)
|
||||
|
||||
|
||||
def _frame_regs(status_raw: int, power_w: int = 0) -> list[int]:
|
||||
regs = [0] * TELTO_REG_BLOCK_COUNT
|
||||
regs[0] = 230
|
||||
regs[6] = status_raw # 7 = available, 0 = charging
|
||||
regs[38] = power_w
|
||||
return regs
|
||||
|
||||
|
||||
class _FakeBatch:
|
||||
def __init__(self, regs: list[int]) -> None:
|
||||
self._regs = regs
|
||||
|
||||
async def __aenter__(self) -> "_FakeBatch":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> bool:
|
||||
return False
|
||||
|
||||
async def read_holding_registers(self, start: int, count: int) -> list[int]:
|
||||
return self._regs
|
||||
|
||||
|
||||
class _FakeModbusClient:
|
||||
def __init__(self) -> None:
|
||||
self.regs: list[int] = _frame_regs(7)
|
||||
|
||||
def batch(self, unit_id: int) -> _FakeBatch:
|
||||
return _FakeBatch(self.regs)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Min. asyncpg.Connection náhrada pro poll_ev_chargers."""
|
||||
|
||||
def __init__(self, latest_status: str | None) -> None:
|
||||
self.latest_status = latest_status
|
||||
self.inserts: list[tuple] = []
|
||||
self.transitions: list[tuple[str, str]] = []
|
||||
|
||||
async def fetch(self, query: str, *args: object) -> list[dict]:
|
||||
return [{"id": 7, "code": "ev-charger-1", "host": "h", "port": 502, "unit_id": 1}]
|
||||
|
||||
async def fetchval(self, query: str, *args: object):
|
||||
if "vw_latest_ev_charger" in query:
|
||||
return self.latest_status
|
||||
if "fn_ev_session_transition" in query:
|
||||
self.transitions.append((str(args[2]), str(args[3])))
|
||||
return None
|
||||
raise AssertionError(f"unexpected fetchval: {query}")
|
||||
|
||||
async def execute(self, query: str, *args: object) -> None:
|
||||
assert "fn_telemetry_ev_charger_sample" in query
|
||||
self.inserts.append(args)
|
||||
|
||||
|
||||
class EvArrivalSurvivesIdleSkipTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
tc._IDLE_SKIP_STATE.clear()
|
||||
tc._EV_LAST_STATUS.clear()
|
||||
|
||||
async def _poll(self, db: _FakeDB, client: _FakeModbusClient) -> None:
|
||||
with (
|
||||
patch.object(tc, "get_modbus_client", AsyncMock(return_value=client)),
|
||||
patch.object(tc, "_on_ev_arrival", AsyncMock()) as arrival,
|
||||
patch.object(tc, "_on_ev_departure", AsyncMock()) as departure,
|
||||
):
|
||||
await tc.poll_ev_chargers(1, db) # type: ignore[arg-type]
|
||||
await asyncio.sleep(0) # nechat doběhnout create_task
|
||||
self.arrival_called = arrival.await_count > 0
|
||||
self.departure_called = departure.await_count > 0
|
||||
|
||||
async def test_arrival_detected_after_skipped_idle_samples(self) -> None:
|
||||
db = _FakeDB(latest_status=None)
|
||||
client = _FakeModbusClient()
|
||||
|
||||
# 1. tick po startu: available → uloží se (prázdný stav), žádný příjezd
|
||||
await self._poll(db, client)
|
||||
self.assertEqual(len(db.inserts), 1)
|
||||
self.assertFalse(self.arrival_called)
|
||||
self.assertEqual(db.transitions, [])
|
||||
|
||||
# 2.–3. tick: idle beze změny → řádky se přeskočí
|
||||
await self._poll(db, client)
|
||||
await self._poll(db, client)
|
||||
self.assertEqual(len(db.inserts), 1)
|
||||
|
||||
# 4. tick: EV se připojí (charging) → insert + transition + arrival hook
|
||||
client.regs = _frame_regs(0, power_w=11000)
|
||||
await self._poll(db, client)
|
||||
self.assertEqual(len(db.inserts), 2)
|
||||
self.assertEqual(db.transitions, [("available", "charging")])
|
||||
self.assertTrue(self.arrival_called)
|
||||
|
||||
# 5. tick: nabíjí dál (aktivní) → ukládá se každou minutu, bez transition
|
||||
await self._poll(db, client)
|
||||
self.assertEqual(len(db.inserts), 3)
|
||||
self.assertEqual(len(db.transitions), 1)
|
||||
|
||||
# 6. tick: odpojení → departure
|
||||
client.regs = _frame_regs(7)
|
||||
await self._poll(db, client)
|
||||
self.assertEqual(db.transitions[-1], ("charging", "available"))
|
||||
self.assertTrue(self.departure_called)
|
||||
|
||||
async def test_no_false_arrival_after_restart(self) -> None:
|
||||
# restart procesu: in-memory stav prázdný, DB má poslední řádek 'charging',
|
||||
# nabíječka stále nabíjí → žádný falešný příjezd
|
||||
db = _FakeDB(latest_status="charging")
|
||||
client = _FakeModbusClient()
|
||||
client.regs = _frame_regs(0, power_w=11000)
|
||||
await self._poll(db, client)
|
||||
self.assertFalse(self.arrival_called)
|
||||
self.assertEqual(db.transitions, [])
|
||||
|
||||
async def test_transition_across_restart_detected(self) -> None:
|
||||
# během výpadku backendu EV přijelo: DB 'available', teď 'charging'
|
||||
db = _FakeDB(latest_status="available")
|
||||
client = _FakeModbusClient()
|
||||
client.regs = _frame_regs(0, power_w=11000)
|
||||
await self._poll(db, client)
|
||||
self.assertEqual(db.transitions, [("available", "charging")])
|
||||
self.assertTrue(self.arrival_called)
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
async def execute(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
async def fetchval(self, *args: object, **kwargs: object) -> object:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeAcquireCtx:
|
||||
def __init__(self, conn: _FakeConn) -> None:
|
||||
self._conn = conn
|
||||
|
||||
async def __aenter__(self) -> _FakeConn:
|
||||
return self._conn
|
||||
|
||||
async def __aexit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = _FakeConn()
|
||||
|
||||
def acquire(self) -> _FakeAcquireCtx:
|
||||
return _FakeAcquireCtx(self.conn)
|
||||
|
||||
|
||||
class EvDepartureTriggersReplanTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""Odjezd EV musí okamžitě přeplánovat (ne čekat na */15) — symetrie k příjezdu."""
|
||||
|
||||
async def test_departure_triggers_replan_and_export(self) -> None:
|
||||
import app.db_json as dbj
|
||||
import services.control_exporter as ce
|
||||
import services.planning_engine as pe
|
||||
|
||||
replan = AsyncMock()
|
||||
export = AsyncMock()
|
||||
# OBS část: non-tesla ctx → krátí se před voláním Tesla API.
|
||||
fake_fetch = AsyncMock(return_value={"api_type": "loxone"})
|
||||
with (
|
||||
patch.object(tc, "_BG_POOL", _FakePool()),
|
||||
patch.object(pe, "run_rolling_replan", replan),
|
||||
patch.object(ce, "export_setpoints", export),
|
||||
patch.object(dbj, "fetch_json", fake_fetch),
|
||||
):
|
||||
await tc._on_ev_departure(2, "vt-ev-charger-1")
|
||||
|
||||
replan.assert_awaited_once()
|
||||
_, kwargs = replan.await_args
|
||||
self.assertEqual(kwargs.get("triggered_by"), "ev_departure:vt-ev-charger-1")
|
||||
export.assert_awaited_once()
|
||||
|
||||
async def test_departure_replan_failure_does_not_block_obs(self) -> None:
|
||||
# Replan spadne → OBS část (jiný conn/try) musí proběhnout dál bez výjimky.
|
||||
import app.db_json as dbj
|
||||
import services.control_exporter as ce
|
||||
import services.planning_engine as pe
|
||||
|
||||
replan = AsyncMock(side_effect=RuntimeError("solver down"))
|
||||
export = AsyncMock()
|
||||
fake_fetch = AsyncMock(return_value={"api_type": "loxone"})
|
||||
with (
|
||||
patch.object(tc, "_BG_POOL", _FakePool()),
|
||||
patch.object(pe, "run_rolling_replan", replan),
|
||||
patch.object(ce, "export_setpoints", export),
|
||||
patch.object(dbj, "fetch_json", fake_fetch),
|
||||
):
|
||||
await tc._on_ev_departure(2, "vt-ev-charger-1") # nesmí vyhodit
|
||||
|
||||
replan.assert_awaited_once()
|
||||
export.assert_not_awaited() # export se po pádu replanu nevolá
|
||||
fake_fetch.assert_awaited() # OBS část přesto běžela
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
58
backend/tests/test_teltocharge_parse.py
Normal file
58
backend/tests/test_teltocharge_parse.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Parser rámce TeltoCharge (registry 0–40) a mapování stavů na EV session logiku."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.telemetry_collector import (
|
||||
TELTO_REG_BLOCK_COUNT,
|
||||
TELTO_STATUS_MAP,
|
||||
parse_teltocharge_frame,
|
||||
)
|
||||
|
||||
|
||||
def _frame(**over: int) -> list[int]:
|
||||
regs = [0] * TELTO_REG_BLOCK_COUNT
|
||||
regs[0], regs[1], regs[2] = 230, 231, 229 # napětí
|
||||
regs[3], regs[4], regs[5] = 160, 158, 0 # proud ×10 A (16.0 A max)
|
||||
regs[6] = 7 # A – bez EV
|
||||
regs[38] = 0 # výkon W
|
||||
regs[39] = 0 # session kWh ×100
|
||||
for k, v in over.items():
|
||||
regs[int(k.lstrip("r"))] = v
|
||||
return regs
|
||||
|
||||
|
||||
class TeltoChargeParseTests(unittest.TestCase):
|
||||
def test_charging_frame(self) -> None:
|
||||
f = parse_teltocharge_frame(_frame(r6=0, r38=10870, r39=523))
|
||||
self.assertEqual(f["status"], "charging")
|
||||
self.assertEqual(f["power_w"], 10870)
|
||||
self.assertAlmostEqual(f["session_energy_kwh"], 5.23)
|
||||
self.assertAlmostEqual(f["current_a"], 16.0)
|
||||
|
||||
def test_no_ev_is_available(self) -> None:
|
||||
self.assertEqual(parse_teltocharge_frame(_frame(r6=7))["status"], "available")
|
||||
|
||||
def test_all_connected_states_are_not_available(self) -> None:
|
||||
# detekce příjezdu (fn_ev_session_transition) stojí na ≠ 'available'
|
||||
for raw, mapped in TELTO_STATUS_MAP.items():
|
||||
if raw == 7:
|
||||
continue
|
||||
self.assertNotEqual(mapped, "available", f"EVSE status {raw}")
|
||||
|
||||
def test_unknown_raw_status(self) -> None:
|
||||
self.assertEqual(parse_teltocharge_frame(_frame(r6=42))["status"], "unknown")
|
||||
|
||||
def test_error_bits_passthrough(self) -> None:
|
||||
f = parse_teltocharge_frame(_frame(r6=8, r35=0b10000))
|
||||
self.assertEqual(f["status"], "faulted")
|
||||
self.assertEqual(f["error_bits"], 16)
|
||||
|
||||
def test_short_frame_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
parse_teltocharge_frame([0] * 10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
51
backend/tests/test_tesla_client.py
Normal file
51
backend/tests/test_tesla_client.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Tesla Fleet API – čisté parsery (bez sítě/DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.tesla_client import parse_charge_state
|
||||
|
||||
|
||||
class ParseChargeStateTests(unittest.TestCase):
|
||||
def test_full_response(self) -> None:
|
||||
data = {
|
||||
"response": {
|
||||
"vin": "5YJYGDEE0MF000000",
|
||||
"charge_state": {
|
||||
"battery_level": 47,
|
||||
"charge_limit_soc": 80,
|
||||
"charging_state": "Stopped",
|
||||
},
|
||||
}
|
||||
}
|
||||
out = parse_charge_state(data)
|
||||
self.assertEqual(out["battery_level"], 47)
|
||||
self.assertEqual(out["charge_limit_soc"], 80)
|
||||
self.assertEqual(out["vin"], "5YJYGDEE0MF000000")
|
||||
|
||||
def test_missing_level_returns_none(self) -> None:
|
||||
self.assertIsNone(parse_charge_state({"response": {"charge_state": {}}}))
|
||||
self.assertIsNone(parse_charge_state({}))
|
||||
|
||||
def test_odometer_miles_to_km(self) -> None:
|
||||
data = {
|
||||
"response": {
|
||||
"charge_state": {"battery_level": 60},
|
||||
"vehicle_state": {"odometer": 12345.6}, # míle!
|
||||
}
|
||||
}
|
||||
out = parse_charge_state(data)
|
||||
self.assertAlmostEqual(out["odometer_km"], 19868.3, places=1)
|
||||
|
||||
def test_missing_odometer_is_none(self) -> None:
|
||||
data = {"response": {"charge_state": {"battery_level": 60}}}
|
||||
self.assertIsNone(parse_charge_state(data)["odometer_km"])
|
||||
|
||||
def test_zero_limit_normalized_to_none(self) -> None:
|
||||
data = {"response": {"charge_state": {"battery_level": 10, "charge_limit_soc": 0}}}
|
||||
self.assertIsNone(parse_charge_state(data)["charge_limit_soc"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal file
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Parametry pro denní „safety charge“ (měkké LP penalizace) a kotvu rolling replanu.
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_daytime_charge_target_enabled boolean not null default true;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_night_baseload_buffer_percent numeric not null default 20;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_daytime_charge_price_quantile numeric not null default 0.70;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_charge_commitment_penalty_czk_kwh numeric not null default 0.20;
|
||||
|
||||
comment on column ems.asset_battery.planner_daytime_charge_target_enabled is
|
||||
'Zapíná SQL/LP měkké denní cíle SoC (safety) z fn_load_planning_slots_full; ne tvrdé allow_charge masky.';
|
||||
|
||||
comment on column ems.asset_battery.planner_night_baseload_buffer_percent is
|
||||
'Procentní přirážka k odhadu nočního baseload Wh (20 = +20 % k night_baseload_target_wh).';
|
||||
|
||||
comment on column ems.asset_battery.planner_daytime_charge_price_quantile is
|
||||
'Rezervováno pro budoucí výběr „drahých“ oken z cenové distribuce; v1 se v LP nepoužívá.';
|
||||
|
||||
comment on column ems.asset_battery.planner_charge_commitment_penalty_czk_kwh is
|
||||
'Koeficient měkké penalizace (Kč/kWh krátkého nedodržení) proti předchozímu plánu při rolling replanu.';
|
||||
10
db/migration/V078__planning_interval_export.sql
Normal file
10
db/migration/V078__planning_interval_export.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- export_mode / export_limit_w z LP — potřeba pro control exporter (reg 142/143)
|
||||
|
||||
alter table ems.planning_interval
|
||||
add column if not exists export_mode text,
|
||||
add column if not exists export_limit_w int;
|
||||
|
||||
comment on column ems.planning_interval.export_mode is
|
||||
'Záměr exportu z solveru: NONE / PV_SURPLUS / BATTERY_SELL.';
|
||||
comment on column ems.planning_interval.export_limit_w is
|
||||
'Tvrdý limit exportu do sítě (W) v slotu; 0 = bez exportu.';
|
||||
23
db/migration/V079__pv_delta_profile_cache.sql
Normal file
23
db/migration/V079__pv_delta_profile_cache.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Cache výsledku fn_pv_forecast_delta_profile per site (obnovuje job fn_fill_forecast_accuracy).
|
||||
-- Zrychlení GET /plan/current a plánování (canonical PV forecast).
|
||||
|
||||
alter table ems.site_pv_forecast_calibration
|
||||
add column if not exists delta_profile_cache jsonb null,
|
||||
add column if not exists delta_profile_cached_at timestamptz null;
|
||||
|
||||
comment on column ems.site_pv_forecast_calibration.delta_profile_cache is
|
||||
'Poslední JSON z fn_pv_forecast_delta_profile (120d lookback, now); NULL = ještě nepočítáno.';
|
||||
|
||||
comment on column ems.site_pv_forecast_calibration.delta_profile_cached_at is
|
||||
'Čas posledního refresh cache (fn_refresh_site_pv_delta_profile_cache).';
|
||||
|
||||
create index if not exists idx_planning_run_site_comparison_of
|
||||
on ems.planning_run (
|
||||
site_id,
|
||||
((solver_params->>'comparison_of_run_id')::int),
|
||||
created_at desc
|
||||
)
|
||||
where status = 'comparison';
|
||||
|
||||
comment on index ems.idx_planning_run_site_comparison_of is
|
||||
'Rychlé nalezení comparison runu pro GET /plan/compare (comparison_of_run_id v solver_params).';
|
||||
181
db/migration/V080__seed_site_hulin_bess.sql
Normal file
181
db/migration/V080__seed_site_hulin_bess.sql
Normal file
@@ -0,0 +1,181 @@
|
||||
-- =============================================================
|
||||
-- V080__seed_site_hulin_bess.sql
|
||||
-- Idempotentní seed BESS lokality Hulín, Krátká 780 (bez FVE, nízká vlastní spotřeba).
|
||||
-- Střídač Deye 2×20 kW (AC max 40 kW), baterie 4×32 kWh (128 kWh usable).
|
||||
-- BMS z/do baterie max 2×350 A (~36 kW); jistič import ~63 A (~43 kW); export max 42 kW.
|
||||
-- Viz docs/new-site-setup-template.md (sekce BESS bez FVE).
|
||||
-- =============================================================
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_site_code text := 'hulin-bess';
|
||||
|
||||
-- Modbus host doplnit před zapnutím endpointu (enabled = true).
|
||||
v_host_deye text := '0.0.0.0';
|
||||
v_port_deye int := 502;
|
||||
|
||||
v_site_id int;
|
||||
v_ep_deye int;
|
||||
v_inv_main int;
|
||||
begin
|
||||
insert into ems.site (code, name, timezone, latitude, longitude, active, notes)
|
||||
values (
|
||||
v_site_code,
|
||||
'Hulín, Krátká 780 (BESS)',
|
||||
'Europe/Prague',
|
||||
49.312314,
|
||||
17.474594,
|
||||
true,
|
||||
'Adresa: Krátká 780, 768 24 Hulín. BESS – ukládání energie bez FVE, bez významné vlastní spotřeby. '
|
||||
'Střídač Deye 2×20 kW; baterie 4×32 kWh; BMS max ~36 kW z/do baterie; jistič ~43 kW import, export 42 kW. '
|
||||
'Souřadnice pro případnou budoucí FVE / počasí. Modbus endpoint zatím vypnutý – doplnit IP a enabled.'
|
||||
)
|
||||
on conflict (code) do update set
|
||||
name = excluded.name,
|
||||
timezone = excluded.timezone,
|
||||
latitude = excluded.latitude,
|
||||
longitude = excluded.longitude,
|
||||
active = excluded.active,
|
||||
notes = excluded.notes
|
||||
returning id into v_site_id;
|
||||
|
||||
select se.id into v_ep_deye
|
||||
from ems.site_endpoint se
|
||||
where se.site_id = v_site_id
|
||||
and se.endpoint_type = 'modbus_tcp'
|
||||
and se.notes ilike '%Deye%'
|
||||
order by se.id
|
||||
limit 1;
|
||||
|
||||
if v_ep_deye is null then
|
||||
insert into ems.site_endpoint (
|
||||
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
|
||||
)
|
||||
values (
|
||||
v_site_id, 'modbus_tcp', v_host_deye, v_port_deye, 'modbus_tcp', 1, false,
|
||||
'Deye 2×20 kW – Modbus TCP (Waveshare). Host/IP doplnit před enabled = true.'
|
||||
)
|
||||
returning id into v_ep_deye;
|
||||
end if;
|
||||
|
||||
insert into ems.site_grid_connection (
|
||||
site_id,
|
||||
max_import_power_w,
|
||||
max_export_power_w,
|
||||
no_export,
|
||||
reserved_capacity_w,
|
||||
block_export_on_negative_sell,
|
||||
notes
|
||||
)
|
||||
values (
|
||||
v_site_id,
|
||||
43000,
|
||||
42000,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
'Hlavní jistič ~63 A → import cca 43 kW. Export do DS max 42 kW. '
|
||||
'BESS bez FVE – block_export_on_negative_sell pro zápornou výkupní cenu v LP.'
|
||||
)
|
||||
on conflict (site_id) do update set
|
||||
max_import_power_w = excluded.max_import_power_w,
|
||||
max_export_power_w = excluded.max_export_power_w,
|
||||
no_export = excluded.no_export,
|
||||
reserved_capacity_w = excluded.reserved_capacity_w,
|
||||
block_export_on_negative_sell = excluded.block_export_on_negative_sell,
|
||||
notes = excluded.notes;
|
||||
|
||||
if not exists (
|
||||
select 1 from ems.site_market_config smc
|
||||
where smc.site_id = v_site_id and smc.valid_to is null
|
||||
) then
|
||||
insert into ems.site_market_config (
|
||||
site_id,
|
||||
purchase_pricing_mode, sale_pricing_mode,
|
||||
buy_margin_fixed_czk, buy_margin_percent,
|
||||
sell_margin_fixed_czk, sell_margin_percent,
|
||||
currency, valid_from, valid_to, notes,
|
||||
tariff_id, hdo_code_id, system_services_czk_kwh, ote_fee_czk_kwh
|
||||
)
|
||||
values (
|
||||
v_site_id,
|
||||
'spot', 'spot',
|
||||
0.050, 0,
|
||||
-0.020, 0,
|
||||
'CZK', now(), null,
|
||||
'Výchozí spot nákup/prodej (marže jako home-01). Upřesnit dle smlouvy provozovatele BESS.',
|
||||
null, null, 0, 0
|
||||
);
|
||||
end if;
|
||||
|
||||
insert into ems.site_operating_mode (site_id, mode_code, activated_by, notes)
|
||||
values (
|
||||
v_site_id,
|
||||
'MANUAL',
|
||||
'migration:V080_seed_site_hulin_bess',
|
||||
'Start MANUAL (bez zápisů na Deye). Po ověření Modbus a SoC přepnout na AUTO.'
|
||||
)
|
||||
on conflict (site_id) do nothing;
|
||||
|
||||
select ai.id into v_inv_main
|
||||
from ems.asset_inverter ai
|
||||
where ai.site_id = v_site_id and ai.code = 'deye-main'
|
||||
limit 1;
|
||||
|
||||
if v_inv_main is null then
|
||||
insert into ems.asset_inverter (
|
||||
site_id, code, manufacturer, model, endpoint_id,
|
||||
max_charge_power_w, max_discharge_power_w, max_export_power_w,
|
||||
max_ac_output_w, max_dc_input_w, max_battery_charge_w, max_battery_discharge_w,
|
||||
gen_port_max_power_w,
|
||||
deye_register_max_charge_a, deye_register_max_discharge_a,
|
||||
deye_zero_export_mode,
|
||||
controllable, active, notes
|
||||
)
|
||||
values (
|
||||
v_site_id,
|
||||
'deye-main',
|
||||
'Deye',
|
||||
'2× SUN-20K (40 kW AC)',
|
||||
v_ep_deye,
|
||||
36000, 36000, 42000,
|
||||
40000, 0, 36000, 36000,
|
||||
null,
|
||||
350, 350,
|
||||
2,
|
||||
true, true,
|
||||
'Hybrid 2×20 kW. BMS limit z/do baterie 2×350 A (~36 kW). AC/střídač max 40 kW. '
|
||||
'Reg 108/109 cap 350 A. deye_zero_export_mode=2 (CT na odběrném místě) – ověřit po instalaci.'
|
||||
)
|
||||
returning id into v_inv_main;
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1 from ems.asset_battery ab
|
||||
where ab.site_id = v_site_id and ab.code = 'bat-main'
|
||||
) then
|
||||
insert into ems.asset_battery (
|
||||
site_id, inverter_id, code,
|
||||
usable_capacity_wh, min_soc_percent, reserve_soc_percent, max_soc_percent,
|
||||
charge_efficiency, discharge_efficiency, degradation_cost_czk_kwh,
|
||||
max_charge_c_rate, max_discharge_c_rate, bms_max_charge_w, bms_max_discharge_w,
|
||||
planner_max_soc_percent,
|
||||
charge_slot_buffer, discharge_slot_buffer
|
||||
)
|
||||
values (
|
||||
v_site_id, v_inv_main, 'bat-main',
|
||||
128000,
|
||||
10, 10, 95,
|
||||
0.95, 0.95,
|
||||
0.50,
|
||||
0.5, 0.5,
|
||||
36000, 36000,
|
||||
100,
|
||||
1.3, 1.5
|
||||
);
|
||||
end if;
|
||||
|
||||
-- Žádné asset_pv_array / EV / TČ – čistý BESS arbitrážní uzel.
|
||||
|
||||
end;
|
||||
$$;
|
||||
25
db/migration/V081__planning_interval_economics.sql
Normal file
25
db/migration/V081__planning_interval_economics.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Rozsireni ekonomickeho rozpadu planu (audit transparence: cashflow vs arbitraz vs penalizace vs bonus).
|
||||
-- Drive byl v planning_interval jen expected_cost_czk = gi*buy - ge*sell (bez penalizaci a bez acquisition).
|
||||
|
||||
alter table ems.planning_interval
|
||||
add column if not exists cashflow_czk numeric,
|
||||
add column if not exists battery_arbitrage_czk numeric,
|
||||
add column if not exists penalty_czk numeric,
|
||||
add column if not exists green_bonus_czk numeric;
|
||||
|
||||
comment on column ems.planning_interval.cashflow_czk is
|
||||
'Net penezni tok ze site v slotu: gi*buy_price*h - ge*sell_price*h (Kc). '
|
||||
'Kladne = platba EMS, zaporne = prijem. Shodne s expected_cost_czk (ponechano jako legacy).';
|
||||
|
||||
comment on column ems.planning_interval.battery_arbitrage_czk is
|
||||
'Marze z exportu baterie do site: ge_bat * (sell_price - acquisition_used) * h (Kc). '
|
||||
'Kladne = zisk arbitraze (cena prodeje > vazeny nakup zasoby).';
|
||||
|
||||
comment on column ems.planning_interval.penalty_czk is
|
||||
'Soucet penalizaci v slotu (Kc): shortfall (peak_export, pv_charge, neg_sell_dump) + safety_deficit '
|
||||
'+ curtailment + commitment. Neviditelne v cashflow_czk, ale solver je optimalizuje.';
|
||||
|
||||
comment on column ems.planning_interval.green_bonus_czk is
|
||||
'Planovany zeleny bonus z vyroby poli s active green_bonus_czk_kwh (Kc). '
|
||||
'pv_*_forecast_solver_w * green_bonus_czk_kwh * h, scitano pres vsechna pole se zelenym bonusem '
|
||||
'platnym v slotu (ems.asset_pv_array.green_bonus_*).';
|
||||
33
db/migration/V082__deye_reg340_inverter_limits.sql
Normal file
33
db/migration/V082__deye_reg340_inverter_limits.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Reg 340 (max solar power): strop dle výkonu střídače, ne součtu Wp polí; min dle firmware.
|
||||
alter table ems.asset_inverter
|
||||
add column if not exists deye_reg340_max_solar_w int,
|
||||
add column if not exists deye_reg340_min_solar_w int not null default 0;
|
||||
|
||||
comment on column ems.asset_inverter.deye_reg340_max_solar_w is
|
||||
'Horní strop zápisu Deye reg 340 (max solar power, W). Studené panely mohou překročit součet Wp — použít plný DC limit střídače (např. 32000 home-01, 65000 větší hybridy). NULL = fallback max_dc_input_w, pak součet Wp řiditelných polí.';
|
||||
|
||||
comment on column ems.asset_inverter.deye_reg340_min_solar_w is
|
||||
'Minimální hodnota reg 340 přijatá firmwarem střídače (W). 0 = bez spodního limitu; starší Deye (home-01) často 400.';
|
||||
|
||||
-- home-01: SUN-20K, reg 340 max 32 kW, firmware min 400 W
|
||||
update ems.asset_inverter inv
|
||||
set
|
||||
deye_reg340_max_solar_w = 32000,
|
||||
deye_reg340_min_solar_w = 400
|
||||
from ems.site s
|
||||
where s.id = inv.site_id
|
||||
and s.code = 'home-01'
|
||||
and inv.code = 'deye-main'
|
||||
and inv.controllable = true;
|
||||
|
||||
-- Ostatní řízené Deye hybridy: 65 kW strop, min 0 (novější firmware)
|
||||
update ems.asset_inverter inv
|
||||
set
|
||||
deye_reg340_max_solar_w = coalesce(inv.deye_reg340_max_solar_w, 65000),
|
||||
deye_reg340_min_solar_w = 0
|
||||
from ems.site s
|
||||
where s.id = inv.site_id
|
||||
and s.code <> 'home-01'
|
||||
and inv.code = 'deye-main'
|
||||
and inv.controllable = true
|
||||
and inv.active = true;
|
||||
35
db/migration/V083__planner_neg_sell_phases.sql
Normal file
35
db/migration/V083__planner_neg_sell_phases.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Fázované SoC a curtail v okně sell < 0 (plánovač v32).
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_neg_sell_prep_soc_percent numeric(5, 2) not null default 80;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_neg_sell_full_soc_tail_slots int not null default 4;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_neg_sell_vent_min_sell_czk_kwh numeric;
|
||||
|
||||
comment on column ems.asset_battery.planner_neg_sell_prep_soc_percent is
|
||||
'Cíl SoC (%) v hlavní části denního okna sell<0 (ASAP nabít z FVE). 100 = legacy (tlak na soc_max až na konci). Realizace škrcení A přes plánovaný pv_a_curtailed_w → Deye reg 340.';
|
||||
|
||||
comment on column ems.asset_battery.planner_neg_sell_full_soc_tail_slots is
|
||||
'Počet 15min slotů před koncem denního úseku sell<0 (Europe/Prague), kdy LP rampuje cíl SoC na soc_max. 0 = bez tail fáze (legacy).';
|
||||
|
||||
comment on column ems.asset_battery.planner_neg_sell_vent_min_sell_czk_kwh is
|
||||
'V tail fázi: dobrovolný ventil pole B (ge_pv) jen pokud effective sell >= tato hodnota (Kč/kWh). NULL = vent jen při plné baterii (stávající w_pv_b_vent).';
|
||||
|
||||
update ems.asset_battery ab
|
||||
set
|
||||
planner_neg_sell_prep_soc_percent = 80,
|
||||
planner_neg_sell_full_soc_tail_slots = 4,
|
||||
planner_neg_sell_vent_min_sell_czk_kwh = -1.0
|
||||
from ems.site s
|
||||
where ab.site_id = s.id
|
||||
and s.code = 'home-01';
|
||||
|
||||
update ems.asset_battery ab
|
||||
set planner_neg_sell_prep_soc_percent = 100
|
||||
from ems.site s
|
||||
join ems.site_grid_connection sgc on sgc.site_id = s.id
|
||||
where ab.site_id = s.id
|
||||
and coalesce(sgc.block_export_on_negative_sell, false) = true;
|
||||
14
db/migration/V084__planning_run_failed_status.sql
Normal file
14
db/migration/V084__planning_run_failed_status.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Journal neúspěšných běhů plánovače (Solver: Infeasible po celém retry řetězci).
|
||||
|
||||
alter table ems.planning_run
|
||||
add column if not exists error_text text;
|
||||
|
||||
comment on column ems.planning_run.error_text is
|
||||
'Chybová zpráva u status=failed (typicky Solver: Infeasible); aktivní plán se nemění.';
|
||||
|
||||
comment on column ems.planning_run.status is
|
||||
'Stav plánu: draft, approved, active, superseded, comparison (shadow běh), failed (solver selhal).';
|
||||
|
||||
create index if not exists idx_planning_run_site_failed
|
||||
on ems.planning_run (site_id, created_at desc)
|
||||
where status = 'failed';
|
||||
25
db/migration/V086__vehicle_vin_tesla_token.sql
Normal file
25
db/migration/V086__vehicle_vin_tesla_token.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Tesla Fleet API: VIN na vozidle, aktivace api_type pro Model Y (home-01),
|
||||
-- singleton tabulka tokenů (refresh token Tesla ROTUJE při každém použití —
|
||||
-- nelze ho držet jen v .env, runtime hodnota žije zde; .env je jen seed).
|
||||
|
||||
alter table ems.asset_vehicle
|
||||
add column if not exists vin text;
|
||||
|
||||
comment on column ems.asset_vehicle.vin is
|
||||
'VIN pro párování s vozidlem v API výrobce (Tesla Fleet). NULL = doplní se automaticky při prvním úspěšném čtení (jediné vozidlo na účtu), jinak nutno vyplnit ručně.';
|
||||
|
||||
update ems.asset_vehicle
|
||||
set api_type = 'tesla'
|
||||
where code = 'tesla-my'
|
||||
and site_id = (select id from ems.site where code = 'home-01');
|
||||
|
||||
create table if not exists ems.tesla_token (
|
||||
id int primary key default 1 check (id = 1),
|
||||
refresh_token text not null,
|
||||
access_token text,
|
||||
access_expires_at timestamptz,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table ems.tesla_token is
|
||||
'Singleton: aktuální Tesla Fleet API tokeny. Seed refresh tokenu z env TESLA_REFRESH_TOKEN při prvním použití; rotace ukládá fn_tesla_token_upsert.';
|
||||
107
db/migration/V087__pool_shelly.sql
Normal file
107
db/migration/V087__pool_shelly.sql
Normal file
@@ -0,0 +1,107 @@
|
||||
-- Bazénové čerpadlo přes Shelly relé (Gen2 RPC).
|
||||
-- (a) asset + 1min telemetrie vlastním pollingem (Shelly drží jen okamžitý stav a čítač
|
||||
-- aenergy.total — historii si stavíme sami jako u ostatních zařízení, 60 s),
|
||||
-- (b) ovládání on/off přes existující signal infrastrukturu (signal_def POOL_PUMP_ON,
|
||||
-- route http_rest na Switch.Set — route je per site, seed v docs/04-modules/pool-shelly.md),
|
||||
-- (c) plánovač: odložitelná zátěž s denní povinnou dobou filtrace (follow-up, viz docs).
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Aktivum: bazénové čerpadlo za Shelly relé
|
||||
-- ------------------------------------------------------------
|
||||
create table ems.asset_pool_pump (
|
||||
id serial primary key,
|
||||
site_id int not null references ems.site (id),
|
||||
code text not null,
|
||||
manufacturer text,
|
||||
model text,
|
||||
endpoint_id int references ems.site_endpoint (id),
|
||||
shelly_switch_id int not null default 0,
|
||||
rated_power_w int not null,
|
||||
min_run_min int not null default 15,
|
||||
daily_runtime_min int not null default 240,
|
||||
schedulable boolean not null default true,
|
||||
notes text,
|
||||
constraint uq_asset_pool_pump_site_code unique (site_id, code)
|
||||
);
|
||||
|
||||
comment on table ems.asset_pool_pump is
|
||||
'Bazénové (filtrační) čerpadlo spínané přes Shelly relé (Gen2 RPC, HTTP). Konstantní příkon, odložitelná zátěž s denní povinnou dobou běhu.';
|
||||
|
||||
comment on column ems.asset_pool_pump.site_id is
|
||||
'Vazba na lokalitu.';
|
||||
|
||||
comment on column ems.asset_pool_pump.code is
|
||||
'Kód aktiva, unikátní v rámci lokality. Příklad: pool-pump-01.';
|
||||
|
||||
comment on column ems.asset_pool_pump.endpoint_id is
|
||||
'HTTP endpoint Shelly relé (ems.site_endpoint, endpoint_type http_api nebo shelly_http). Bez endpointu se čerpadlo nepolluje.';
|
||||
|
||||
comment on column ems.asset_pool_pump.shelly_switch_id is
|
||||
'Id Switch komponenty v Shelly Gen2 RPC (Switch.GetStatus?id=N). U 1kanálových relé 0.';
|
||||
|
||||
comment on column ems.asset_pool_pump.rated_power_w is
|
||||
'Jmenovitý příkon čerpadla ve W. Plánovač s ním počítá jako s konstantním výkonem při běhu.';
|
||||
|
||||
comment on column ems.asset_pool_pump.min_run_min is
|
||||
'Minimální nepřerušený běh v minutách (ochrana čerpadla před krátkým cyklováním). Násobky 15min slotů.';
|
||||
|
||||
comment on column ems.asset_pool_pump.daily_runtime_min is
|
||||
'Denní povinná doba filtrace v minutách — AKTUÁLNÍ sezónní hodnota (léto typ. více, zima méně / 0). Mění ji provozovatel ručně podle sezóny; plnohodnotný sezónní profil (tabulka měsíc → minuty) je follow-up, viz docs/04-modules/pool-shelly.md. 0 = filtrace vypnutá (mimo sezónu).';
|
||||
|
||||
comment on column ems.asset_pool_pump.schedulable is
|
||||
'true = plánovač smí rozkládat běh do levných/přebytkových slotů; false = EMS jen měří, nespíná.';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1min telemetrie (TimescaleDB hypertable)
|
||||
-- ------------------------------------------------------------
|
||||
create table ems.telemetry_pool_pump (
|
||||
site_id int not null references ems.site (id),
|
||||
pump_id int not null references ems.asset_pool_pump (id),
|
||||
measured_at timestamptz not null,
|
||||
is_on boolean,
|
||||
power_w int,
|
||||
energy_wh_total bigint,
|
||||
primary key (pump_id, measured_at)
|
||||
);
|
||||
|
||||
comment on table ems.telemetry_pool_pump is
|
||||
'Telemetrie bazénového čerpadla ze Shelly relé (Gen2 Switch.GetStatus), 1min polling. TimescaleDB hypertable. Historie se staví výhradně tady — Shelly ji nedrží.';
|
||||
|
||||
comment on column ems.telemetry_pool_pump.site_id is
|
||||
'Vazba na lokalitu.';
|
||||
|
||||
comment on column ems.telemetry_pool_pump.pump_id is
|
||||
'Vazba na ems.asset_pool_pump.';
|
||||
|
||||
comment on column ems.telemetry_pool_pump.measured_at is
|
||||
'Čas měření (UTC).';
|
||||
|
||||
comment on column ems.telemetry_pool_pump.is_on is
|
||||
'Stav relé (Switch.GetStatus output).';
|
||||
|
||||
comment on column ems.telemetry_pool_pump.power_w is
|
||||
'Okamžitý činný příkon ve W (Switch.GetStatus apower). NULL pokud model neměří výkon.';
|
||||
|
||||
comment on column ems.telemetry_pool_pump.energy_wh_total is
|
||||
'Kumulativní čítač energie ve Wh (Switch.GetStatus aenergy.total). Po výpadku napájení Shelly může čítač začít znovu — energii za interval počítat jako kladnou diferenci.';
|
||||
|
||||
select create_hypertable(
|
||||
'ems.telemetry_pool_pump',
|
||||
'measured_at',
|
||||
chunk_time_interval => interval '1 week',
|
||||
if_not_exists => true
|
||||
);
|
||||
|
||||
create index idx_telemetry_pool_pump_site_time
|
||||
on ems.telemetry_pool_pump (site_id, measured_at desc);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Signál pro ovládání relé (route per site se seeduje provozně, šablona v docs)
|
||||
-- ------------------------------------------------------------
|
||||
insert into ems.signal_def (code, value_type, description)
|
||||
values (
|
||||
'POOL_PUMP_ON',
|
||||
'bool',
|
||||
'Požadovaný stav bazénového čerpadla (Shelly relé). Doručuje signal_service přes signal_route http_rest na Shelly Gen2 Switch.Set, readback verify přes Switch.GetStatus. Hodnotu nastavuje plánovač / operátor (fn_signal_enqueue_bool).'
|
||||
)
|
||||
on conflict (code) do nothing;
|
||||
35
db/migration/V088__seed_pool_home01.sql
Normal file
35
db/migration/V088__seed_pool_home01.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Seed bazénového čerpadla home-01: Shelly Plug S Gen3 na 172.16.1.15 (VPN).
|
||||
-- Telemetry-only start (schedulable=false): nejdřív měříme, ovládání signálem
|
||||
-- POOL_PUMP_ON zapneme po ověření telemetrie. rated_power_w je odhad — skutečný
|
||||
-- příkon ukáže telemetrie (apower), pak upřesnit.
|
||||
|
||||
insert into ems.site_endpoint (
|
||||
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
|
||||
)
|
||||
select s.id, 'shelly_http', '172.16.1.15', 80, 'http', null, true,
|
||||
'Shelly Plug S Gen3 – bazénové čerpadlo (Gen2+ RPC).'
|
||||
from ems.site s
|
||||
where s.code = 'home-01'
|
||||
and not exists (
|
||||
select 1 from ems.site_endpoint se
|
||||
where se.site_id = s.id and se.host = '172.16.1.15'
|
||||
);
|
||||
|
||||
insert into ems.asset_pool_pump (
|
||||
site_id, code, manufacturer, model, endpoint_id, shelly_switch_id,
|
||||
rated_power_w, min_run_min, daily_runtime_min, schedulable, notes
|
||||
)
|
||||
select
|
||||
s.id, 'pool-pump-1', 'Shelly', 'Plug S Gen3', se.id, 0,
|
||||
600, -- odhad; upřesnit dle telemetrie (Shelly apower)
|
||||
15,
|
||||
480, -- 8 h/den (letní filtrace); zimní profil = follow-up
|
||||
false, -- telemetry-only; ovládání po ověření
|
||||
'Bazénové čerpadlo přes Shelly Plug S Gen3. Ovládání: signal POOL_PUMP_ON (fn_signal_enqueue_bool), zatím vypnuto.'
|
||||
from ems.site s
|
||||
join ems.site_endpoint se on se.site_id = s.id and se.host = '172.16.1.15'
|
||||
where s.code = 'home-01'
|
||||
and not exists (
|
||||
select 1 from ems.asset_pool_pump p
|
||||
where p.site_id = s.id and p.code = 'pool-pump-1'
|
||||
);
|
||||
63
db/migration/V089__ev_usage_forecast.sql
Normal file
63
db/migration/V089__ev_usage_forecast.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- EV spotřební forecast: pozorování (odometer+SoC při příjezdu/odjezdu, auto je
|
||||
-- vzhůru — žádné buzení navíc), jízdy, statistiky per den v týdnu. Cíl: target
|
||||
-- SoC a deadline session z reálného týdenního rytmu (pondělí služebka ~150 km
|
||||
-- → skoro plná; konec týdne míň; víkend = levné sloty na přípravu pondělka).
|
||||
-- Zapnutí per vozidlo: target_soc_forecast_enabled (default false = sbírá se,
|
||||
-- session jedou na defaultech).
|
||||
|
||||
alter table ems.asset_vehicle
|
||||
add column if not exists target_soc_forecast_enabled boolean not null default false,
|
||||
add column if not exists min_target_soc_pct numeric(5,2) not null default 30.0;
|
||||
|
||||
comment on column ems.asset_vehicle.target_soc_forecast_enabled is
|
||||
'true = target SoC + deadline session z ev_usage_stats (fn_ev_required_soc); false = default_target_soc_pct/default_deadline_hour. Forecast vyžaduje >= 4 vzorky pro daný den v týdnu, jinak fallback.';
|
||||
comment on column ems.asset_vehicle.min_target_soc_pct is
|
||||
'Komfortní spodní mez forecast targetu (%). Forecast smí jít pod default_target_soc_pct (např. pátek), ne pod tuto mez.';
|
||||
|
||||
create table ems.ev_vehicle_obs (
|
||||
id bigserial primary key,
|
||||
site_id int not null references ems.site (id),
|
||||
vehicle_id int not null references ems.asset_vehicle (id),
|
||||
observed_at timestamptz not null default now(),
|
||||
trigger text not null check (trigger in ('arrival', 'departure', 'manual')),
|
||||
odometer_km numeric(10, 1),
|
||||
soc_pct numeric(5, 2),
|
||||
charging_state text
|
||||
);
|
||||
|
||||
create index idx_ev_vehicle_obs_vehicle_time
|
||||
on ems.ev_vehicle_obs (vehicle_id, observed_at desc);
|
||||
|
||||
comment on table ems.ev_vehicle_obs is
|
||||
'Pozorování vozidla z API výrobce (Tesla Fleet) při příjezdu/odjezdu — auto je v těch okamžicích vzhůru, čtení nebudí. Zdroj pro ev_trip.';
|
||||
|
||||
create table ems.ev_trip (
|
||||
id serial primary key,
|
||||
vehicle_id int not null references ems.asset_vehicle (id),
|
||||
departure_obs_id bigint not null references ems.ev_vehicle_obs (id),
|
||||
arrival_obs_id bigint not null references ems.ev_vehicle_obs (id),
|
||||
departed_at timestamptz not null,
|
||||
arrived_at timestamptz not null,
|
||||
km numeric(8, 1),
|
||||
kwh_est numeric(7, 2),
|
||||
charged_away boolean not null default false,
|
||||
constraint uq_ev_trip_departure unique (departure_obs_id)
|
||||
);
|
||||
|
||||
comment on table ems.ev_trip is
|
||||
'Jízda = pár odjezd→příjezd: km z odometru, kWh z ΔSoC × kapacita. charged_away = SoC po cestě vzrostlo (nabíjení mimo dům) — kWh nevypovídá, vyloučit ze statistik.';
|
||||
|
||||
create table ems.ev_usage_stats (
|
||||
vehicle_id int not null references ems.asset_vehicle (id),
|
||||
day_of_week int not null check (day_of_week between 0 and 6),
|
||||
avg_day_kwh numeric(7, 2),
|
||||
stddev_day_kwh numeric(7, 2),
|
||||
avg_day_km numeric(8, 1),
|
||||
avg_departure_hour numeric(4, 2),
|
||||
sample_count int not null default 0,
|
||||
last_updated timestamptz not null default now(),
|
||||
primary key (vehicle_id, day_of_week)
|
||||
);
|
||||
|
||||
comment on table ems.ev_usage_stats is
|
||||
'Týdenní rytmus vozidla per den v týdnu (0=neděle, Europe/Prague): průměrná denní spotřeba jízdou (kWh), km, typická hodina prvního odjezdu. Plní fn_update_ev_usage_stats (job 00:50). Vstup fn_ev_required_soc / fn_ev_expected_departure.';
|
||||
13
db/migration/V090__pv_risk_frontload.sql
Normal file
13
db/migration/V090__pv_risk_frontload.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- PV-risk front-load: prémie za držení energie DŘÍV uvnitř okna sell<0.
|
||||
-- Solver je k načasování nabíjení v neg okně jinak indiferentní (PV je zdarma
|
||||
-- kdykoliv) — odložené nabití ale spoléhá na predikci (večerní mrak = drahý
|
||||
-- nákup). Malá prémie (Kč/kWh/slot) rozbije indiferenci směrem k "nabít plným
|
||||
-- výkonem hned" (v1 rampa), ale nikdy nepřebije skutečné ceny.
|
||||
-- 0.01 → držení 1 kWh o 6 h dřív = 0.24 Kč; 0 = vypnuto.
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_pv_risk_frontload_czk_kwh numeric(6, 4)
|
||||
not null default 0.01;
|
||||
|
||||
comment on column ems.asset_battery.planner_pv_risk_frontload_czk_kwh is
|
||||
'v2: prémie za držení energie dřív v okně sell<0 (Kč za kWh a 15min slot). Ocenění rizika chyby PV predikce — front-load nabíjení. 0 = vypnuto.';
|
||||
13
db/migration/V091__safety_soc_risk_factor.sql
Normal file
13
db/migration/V091__safety_soc_risk_factor.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Denní SoC bezpečnostní rampa ve v2: deficit pod safety_soc_target_wh
|
||||
-- (R__063: rampa reserve→reserve+noční potřeba, 6–19 h) platí za každý slot
|
||||
-- "nájem" = buy_cena × faktor. Ráno tak baterie nejdřív dotáhne na ~reserve
|
||||
-- (KV1/BA81 30 %) a teprve pak prodává — nenadálý odběr/mrak nekupuje za
|
||||
-- draho ze sítě. Extrémní sell špička smí deficit racionálně podstoupit.
|
||||
-- 0 = vypnuto; default 0.05 (deficit 1 kWh držený 4 h při buy 6 Kč ≈ 4.8 Kč).
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_safety_soc_risk_factor numeric(5, 3)
|
||||
not null default 0.05;
|
||||
|
||||
comment on column ems.asset_battery.planner_safety_soc_risk_factor is
|
||||
'v2: podíl buy ceny účtovaný za KAŽDÝ 15min slot deficitu pod safety_soc_target_wh (denní rampa z R__063). Ocenění rizika nenadálého odběru při slabé predikci. 0 = vypnuto.';
|
||||
55
db/migration/V092__pool_season_temp_loxone.sql
Normal file
55
db/migration/V092__pool_season_temp_loxone.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- Bazén: sezóna, délka filtrace dle teploty vody, čtení čidel z Loxone.
|
||||
--
|
||||
-- Sezóna: přepínač = existující asset_pool_pump.schedulable (true = plánovač
|
||||
-- řídí; konec sezóny -> false: telemetrie běží dál, signály/solver ne).
|
||||
-- Viz docs/04-modules/pool-shelly.md § Sezóna.
|
||||
--
|
||||
-- Teplotní funkce (slaná voda, chlorinátor potřebuje průtok; teplejší voda =
|
||||
-- delší filtrace): runtime_min(t) = clamp(base + per_c × (t − ref), min, max).
|
||||
-- Defaulty pro 30 m³ / 8 m³/h (obrátka 3.75 h): 20 °C → 4.5 h, 26 °C → 7.5 h,
|
||||
-- 28 °C → 8.5 h, strop 10 h. Bez čidla / starého měření → fallback
|
||||
-- daily_runtime_min. Vše per čerpadlo v DB (pravidlo 16).
|
||||
|
||||
create table ems.loxone_sensor (
|
||||
id serial primary key,
|
||||
site_id int not null references ems.site (id),
|
||||
code text not null,
|
||||
loxone_name text not null,
|
||||
unit text,
|
||||
enabled boolean not null default true,
|
||||
notes text,
|
||||
constraint uq_loxone_sensor_site_code unique (site_id, code)
|
||||
);
|
||||
|
||||
comment on table ems.loxone_sensor is
|
||||
'Čidla čtená z Loxone Miniserveru (GET /jdev/sps/io/<loxone_name>/state přes loxone_http endpoint site). Telemetrie 60 s do telemetry_loxone_sensor.';
|
||||
|
||||
create table ems.telemetry_loxone_sensor (
|
||||
sensor_id int not null references ems.loxone_sensor (id),
|
||||
measured_at timestamptz not null,
|
||||
value numeric(10, 2),
|
||||
primary key (sensor_id, measured_at)
|
||||
);
|
||||
|
||||
select create_hypertable(
|
||||
'ems.telemetry_loxone_sensor',
|
||||
'measured_at',
|
||||
chunk_time_interval => interval '1 week',
|
||||
if_not_exists => true
|
||||
);
|
||||
|
||||
comment on table ems.telemetry_loxone_sensor is
|
||||
'1min hodnoty Loxone čidel (teplota bazénu, akumulační nádrže, ...).';
|
||||
|
||||
alter table ems.asset_pool_pump
|
||||
add column if not exists water_temp_sensor_id int references ems.loxone_sensor (id),
|
||||
add column if not exists runtime_ref_temp_c numeric(4, 1) not null default 20.0,
|
||||
add column if not exists runtime_base_min int not null default 270,
|
||||
add column if not exists runtime_min_per_c int not null default 30,
|
||||
add column if not exists runtime_min_min int not null default 180,
|
||||
add column if not exists runtime_max_min int not null default 600;
|
||||
|
||||
comment on column ems.asset_pool_pump.water_temp_sensor_id is
|
||||
'Loxone čidlo teploty vody; NULL = teplotní funkce vypnutá (fallback daily_runtime_min).';
|
||||
comment on column ems.asset_pool_pump.runtime_base_min is
|
||||
'Minuty filtrace/den při runtime_ref_temp_c; nad ní +runtime_min_per_c za °C, clamp [runtime_min_min, runtime_max_min].';
|
||||
10
db/migration/V093__tesla_lfp_capacity.sql
Normal file
10
db/migration/V093__tesla_lfp_capacity.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Tesla Model Y 2025 Standard RWD (LFP): kapacita ~62.5 kWh (v seedu bylo 75 =
|
||||
-- hodnota LR varianty) a default cíl 100 % — LFP chemie pravidelné nabití na
|
||||
-- 100 % vyžaduje (balancování), žádná degradační penalizace jako u NMC.
|
||||
-- Kapacita vstupuje do energy_needed (target − soc) × kWh a do EV usage stats.
|
||||
|
||||
update ems.asset_vehicle
|
||||
set battery_capacity_kwh = 62.5,
|
||||
default_target_soc_pct = 100
|
||||
where code = 'tesla-my'
|
||||
and site_id = (select id from ems.site where code = 'home-01');
|
||||
13
db/migration/V094__ev_opportunistic.sql
Normal file
13
db/migration/V094__ev_opportunistic.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Oportunistické EV nabíjení („měkký cíl"): nad tvrdý target smí auto nasát
|
||||
-- přebytky až do 100 %, oceněné hodnotou BUDOUCÍHO ušetřeného nabíjení
|
||||
-- (~1 Kč/kWh — budoucí nabíjení je stejně v levných slotech). Uplatní se
|
||||
-- hlavně při záporných cenách / plné domácí baterce (lepší než curtail);
|
||||
-- běžné ceny ho nezaplatí. 0 = vypnuto. Víkend: páteční malý tvrdý cíl
|
||||
-- + víkendové negativní ceny → auto se doplní samo, bez speciální logiky.
|
||||
|
||||
alter table ems.asset_vehicle
|
||||
add column if not exists opportunistic_value_czk_kwh numeric(6, 3)
|
||||
not null default 1.0;
|
||||
|
||||
comment on column ems.asset_vehicle.opportunistic_value_czk_kwh is
|
||||
'v2: hodnota kWh nabité NAD target session (do 100 %) = ušetřené budoucí nabíjení. Solver ji zaplatí jen při velmi levné/záporné energii. 0 = vypnuto.';
|
||||
22
db/migration/V095__ev_presence.sql
Normal file
22
db/migration/V095__ev_presence.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Presence vozidla (Tesla location scope): kde auto je, kdy bývá doma.
|
||||
-- Zdroj: levný poll /vehicles (state, NEbudí) + při online location_data.
|
||||
-- Účel: (a) notifikace "auto doma a nepíchlé + svítí přebytek → píchni ho",
|
||||
-- (b) dostupnostní statistika per DOW×hodina pro plánovač (maska ev_connected
|
||||
-- a zreálnění oportunistické hodnoty) — follow-up nad těmito daty.
|
||||
|
||||
create table ems.ev_presence_obs (
|
||||
id bigserial primary key,
|
||||
vehicle_id int not null references ems.asset_vehicle (id),
|
||||
observed_at timestamptz not null default now(),
|
||||
api_state text, -- online / asleep / offline (z /vehicles, bez buzení)
|
||||
at_home boolean, -- null = poloha neznámá (asleep)
|
||||
distance_m int,
|
||||
charging_state text, -- Disconnected / Stopped / Charging…
|
||||
shift_state text
|
||||
);
|
||||
|
||||
create index idx_ev_presence_obs_vehicle_time
|
||||
on ems.ev_presence_obs (vehicle_id, observed_at desc);
|
||||
|
||||
comment on table ems.ev_presence_obs is
|
||||
'Pozorování přítomnosti vozidla (geofence vs GPS site). Poll ~5 min, poloha jen když je auto vzhůru (nebudí). Vstup pro "píchni auto" notifikace a budoucí dostupnostní statistiku.';
|
||||
21
db/migration/V096__heat_pump_mim_b19n.sql
Normal file
21
db/migration/V096__heat_pump_mim_b19n.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Samsung TČ (EHS) přes Modbus interface MIM-B19N(T): skutečný RS485→TCP
|
||||
-- převodník (Waveshare RS485 TO POE ETH (B)) na 172.16.1.17 nahrazuje
|
||||
-- placeholder 192.168.1.103 ze seedu. MIM = Modbus RTU slave, 9600 8E1,
|
||||
-- adresa dle DIP/rotary (zde 1). Registry: docs/04-modules/modbus-registers-mim-b19n.md.
|
||||
|
||||
update ems.site_endpoint e
|
||||
set host = '172.16.1.17',
|
||||
port = 502,
|
||||
notes = 'Waveshare RS485 TO POE ETH (B) pro Samsung EHS přes MIM-B19N(T). Sériová linka 9600 8E1 (parita EVEN!), Modbus TCP server :502, unit_id = adresa MIM dle DIP (1).'
|
||||
where e.id = (
|
||||
select hp.endpoint_id
|
||||
from ems.asset_heat_pump hp
|
||||
join ems.site s on s.id = hp.site_id
|
||||
where s.code = 'home-01'
|
||||
);
|
||||
|
||||
alter table ems.telemetry_heat_pump
|
||||
add column if not exists room_temp_c numeric(5,2);
|
||||
|
||||
comment on column ems.telemetry_heat_pump.room_temp_c is
|
||||
'Prostorová teplota hlášená vnitřní jednotkou (MIM reg base+9, °C×10). Vstup budoucího termálního modelu domu.';
|
||||
27
db/migration/V097__wallbox_rs485_endpoints.sql
Normal file
27
db/migration/V097__wallbox_rs485_endpoints.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Wallboxy TeltoCharge: skutečný RS485→TCP převodník (Waveshare) 172.16.1.16
|
||||
-- nahrazuje placeholdery 192.168.1.101/.102. Sdílená RS485 sběrnice
|
||||
-- (9600 8N1 dle nastavení převodníku 2026-06-12): WB1 = unit 1, WB2 = unit 2,
|
||||
-- výhledově Chint elektroměr = unit 3. Modbus na wallboxech nutno povolit
|
||||
-- v Teltonika aplikaci (adresa + baud shodné s převodníkem).
|
||||
|
||||
update ems.site_endpoint e
|
||||
set host = '172.16.1.16',
|
||||
port = 502,
|
||||
unit_id = 1,
|
||||
notes = 'Waveshare RS485 TO POE ETH (B) 172.16.1.16 — sdílená sběrnice wallboxů (9600 8N1, Modbus TCP↔RTU). TeltoCharge #1 = unit 1.'
|
||||
where e.id = (
|
||||
select ec.endpoint_id from ems.asset_ev_charger ec
|
||||
join ems.site s on s.id = ec.site_id
|
||||
where s.code = 'home-01' and ec.code = 'ev-charger-1'
|
||||
);
|
||||
|
||||
update ems.site_endpoint e
|
||||
set host = '172.16.1.16',
|
||||
port = 502,
|
||||
unit_id = 2,
|
||||
notes = 'Waveshare RS485 TO POE ETH (B) 172.16.1.16 — sdílená sběrnice wallboxů (9600 8N1, Modbus TCP↔RTU). TeltoCharge #2 = unit 2.'
|
||||
where e.id = (
|
||||
select ec.endpoint_id from ems.asset_ev_charger ec
|
||||
join ems.site s on s.id = ec.site_id
|
||||
where s.code = 'home-01' and ec.code = 'ev-charger-2'
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user