Compare commits
171 Commits
b022311dec
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e419f0a5e | ||
|
|
ad4b52c9ce | ||
|
|
b5dbc8cf0a | ||
|
|
60f5f77146 | ||
|
|
d767d0abca | ||
|
|
1d5b97c65f | ||
|
|
f2901ef366 | ||
|
|
02e0134794 | ||
|
|
eb360da910 | ||
|
|
ca6bd4ab2a | ||
|
|
a8b4342099 | ||
|
|
293f32cff1 | ||
|
|
7c2669def6 | ||
|
|
90f79d9abe | ||
|
|
7d9ce5746a | ||
|
|
90a85b2727 | ||
|
|
368291e562 | ||
|
|
ec13c2ad6e | ||
|
|
9a2229641d | ||
|
|
0dc2e1df96 | ||
|
|
cb6afbb3fd | ||
|
|
dcbb5de98c | ||
|
|
d83917da51 | ||
|
|
4ee5cebf2a | ||
|
|
484f1f85fc | ||
|
|
edc8ae9774 | ||
|
|
50ac40868d | ||
|
|
b7903db714 | ||
|
|
3ad5bec76b | ||
|
|
37df01d43c | ||
|
|
3161421d5c | ||
|
|
36cb06b9d0 | ||
|
|
0f7dc6ed94 | ||
|
|
a7879f1141 | ||
|
|
09bca0a903 | ||
|
|
2a963c9793 | ||
|
|
1429d402e5 | ||
|
|
d44a2cbb44 | ||
|
|
96adbff9ea | ||
|
|
63eff96c5f | ||
|
|
0dcf11d471 | ||
|
|
430e081841 | ||
|
|
5d06f49d2b | ||
|
|
111f51c06c | ||
|
|
8950fafba2 | ||
|
|
578cf315e2 | ||
|
|
a03b45d4a9 | ||
|
|
830aa7a4cc | ||
|
|
4f67aad4d8 | ||
|
|
96d0d52b07 | ||
|
|
5208e035a4 | ||
|
|
d3e9caf0fb | ||
|
|
308c24f029 | ||
|
|
b73c3323e1 | ||
|
|
877f5b6180 | ||
|
|
230351b38a | ||
|
|
88df09640c | ||
|
|
a7dff75e58 | ||
|
|
620a557a89 | ||
|
|
ba0b55bf10 | ||
|
|
52e4b68789 | ||
|
|
4e5de5df90 | ||
|
|
8c7072da07 | ||
|
|
19108002ca | ||
|
|
96b16b9ff9 | ||
|
|
398e658d16 | ||
|
|
d1ba864fc0 | ||
|
|
58b0a2f882 | ||
|
|
a53bcd0b81 | ||
|
|
94eb256598 | ||
|
|
b4e5fc5040 | ||
|
|
da79eec077 | ||
|
|
91a9bef3d7 | ||
|
|
8494ea26de | ||
|
|
25c864db61 | ||
|
|
b03f08d3a0 | ||
|
|
18ace46ea9 | ||
|
|
2e27c8c5de | ||
|
|
91af5c76c2 | ||
|
|
c6074e9c74 | ||
|
|
e06f76b9ff | ||
|
|
f1a4dbd7e7 | ||
|
|
37a525cb4f | ||
|
|
b8e47e2623 | ||
|
|
f90004142c | ||
|
|
0a0668000b | ||
|
|
a1270dcda3 | ||
|
|
4beb8cf99f | ||
|
|
161b463367 | ||
|
|
a2a35981a1 | ||
|
|
5fb4c10ff6 | ||
|
|
254508fe1a | ||
|
|
095676e3b1 | ||
|
|
67d34aba41 | ||
|
|
b46da6b2dc | ||
|
|
7036bcfdb8 | ||
|
|
b03855b3d1 | ||
|
|
9ba65ea6bb | ||
|
|
b844a9182f | ||
|
|
8bef1c6da6 | ||
|
|
2d021b15c3 | ||
|
|
9a15a4c618 | ||
|
|
747a5bed08 | ||
|
|
9d31b19ec6 | ||
|
|
c43bd0a6c6 | ||
|
|
a3c4af3573 | ||
|
|
fb0d947af6 | ||
|
|
bd06779fe5 | ||
|
|
ce571a93fa | ||
|
|
7ff2abc7e0 | ||
|
|
61a58a62b1 | ||
|
|
904c318532 | ||
|
|
645f48036d | ||
|
|
0f922c91f5 | ||
|
|
dbc004a949 | ||
|
|
e3e5fc138c | ||
|
|
b44f74b249 | ||
|
|
da52cf168b | ||
|
|
1ec92bdf79 | ||
|
|
a52be1b792 | ||
|
|
8845350c0b | ||
|
|
f157c10480 | ||
|
|
0c4de4e5b9 | ||
|
|
9cf7708909 | ||
|
|
c5525c729f | ||
|
|
f960e08307 | ||
|
|
cb638b9302 | ||
|
|
2ebc48f813 | ||
|
|
7b25640557 | ||
|
|
fff7fdb7c4 | ||
|
|
d9ecc70980 | ||
|
|
7c63fed296 | ||
|
|
e295e55770 | ||
|
|
c9149babd3 | ||
|
|
649c9e9510 | ||
|
|
fc0761fb2a | ||
|
|
66834ddfa6 | ||
|
|
3b4d54dcc7 | ||
|
|
739249a244 | ||
|
|
ba1cdcbee4 | ||
|
|
52bedcf67d | ||
|
|
b78597fdda | ||
|
|
08f1b6741a | ||
|
|
d984716f69 | ||
|
|
eb425a26f2 | ||
|
|
44a06b6288 | ||
|
|
27323fd77a | ||
|
|
49d0aa68a2 | ||
|
|
a17c22d475 | ||
|
|
1426c0e153 | ||
|
|
7490ac3d70 | ||
|
|
d89d8b1e3a | ||
|
|
30f16a14c2 | ||
|
|
851ec2b637 | ||
|
|
64327af8e0 | ||
|
|
a5184ec42f | ||
|
|
ab80d13ecb | ||
|
|
0d2839d6db | ||
|
|
d54579e3b1 | ||
|
|
5b383e9028 | ||
| 459f33d55c | |||
|
|
8a3a49806b | ||
| a3afd392d3 | |||
|
|
b35f292295 | ||
| e44cd013f4 | |||
|
|
6471467bc5 | ||
|
|
ba53fe5bfc | ||
| 87fc9b41cf | |||
|
|
335c413232 | ||
|
|
bcb05d4896 | ||
|
|
405e832f8d |
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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ dist/
|
||||
*.tsbuildinfo
|
||||
frontend/vendor/
|
||||
frontend/scripts/.native-tmp/
|
||||
.claude/settings.local.json
|
||||
|
||||
18
CLAUDE.md
18
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,11 +106,11 @@ 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`** → **108=0**, **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10–100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** reg **108** EMS **nezapisuje** (selling first = **142**), **109**=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
|
||||
|
||||
19. **Baterie – export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 11–12 %, migrace V029 + komentář sloupce).
|
||||
|
||||
@@ -201,7 +203,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,6 +218,11 @@ 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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ class Settings(BaseSettings):
|
||||
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)
|
||||
planning_cheap_price_threshold: float = Field(default=0.85)
|
||||
planning_expensive_price_threshold: float = Field(default=1.15)
|
||||
planning_engine_version: str = Field(default="v1")
|
||||
planning_engine_compare_enabled: bool = Field(default=False)
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -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,53 @@ 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)
|
||||
diff, slot_diffs = _build_plan_diff(active, comparison)
|
||||
return PlanningCompareResponseModel(
|
||||
active=active,
|
||||
comparison=comparison,
|
||||
diff=diff,
|
||||
slot_diffs=slot_diffs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +147,10 @@ async def patch_pv_forecast_calibration(
|
||||
status_code=404,
|
||||
detail="PV forecast calibration row missing; run migration V057",
|
||||
)
|
||||
await conn.execute(
|
||||
"select ems.fn_refresh_site_pv_delta_profile_cache($1::int)",
|
||||
site_id,
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT to_jsonb(c.*) AS j
|
||||
|
||||
@@ -65,7 +65,7 @@ DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)",
|
||||
143: "export_limit_w (max export do sítě)",
|
||||
145: "solar_sell (0=disabled, 1=enabled)",
|
||||
340: "max_solar_power_w (strop DC PV A v W; součet nominal_power_wp řiditelných polí)",
|
||||
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",
|
||||
@@ -93,6 +93,27 @@ def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -157,11 +178,21 @@ def next_slot_hhmm() -> int:
|
||||
return next_hour * 100 + next_min
|
||||
|
||||
|
||||
def compute_pv_a_reg340_max_solar_w(cap_w: int, forecast_w: int, curtail_w: int) -> int:
|
||||
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:
|
||||
return int(cap_w)
|
||||
return max(0, min(int(cap_w), int(forecast_w) - int(curtail_w)))
|
||||
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:
|
||||
|
||||
@@ -31,6 +31,7 @@ from services.control.deye_helpers import (
|
||||
battery_watts_to_amps,
|
||||
compute_pv_a_reg340_max_solar_w,
|
||||
current_slot_hhmm,
|
||||
deye_mi_export_cutoff_want_enabled,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust, # noqa: F401 - re-export
|
||||
next_slot_hhmm,
|
||||
watts_to_amps,
|
||||
@@ -59,6 +60,7 @@ from services.control.repository import (
|
||||
)
|
||||
from services.control.setpoints import (
|
||||
_DictRecord,
|
||||
_apply_export_plan_guard,
|
||||
_apply_price_failsafe_guard,
|
||||
_build_setpoints,
|
||||
_clamp_deye_tou_soc_pct,
|
||||
@@ -69,7 +71,6 @@ from services.control.setpoints import (
|
||||
_deye_tou_min_soc_pct,
|
||||
_deye_tou_params,
|
||||
_deye_tou_reserve_soc_pct,
|
||||
_deye_zero_export_amps_for_passive,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.control.verify import (
|
||||
|
||||
@@ -24,8 +24,8 @@ from services.control.deye_helpers import (
|
||||
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,
|
||||
battery_watts_to_amps,
|
||||
current_slot_hhmm,
|
||||
next_slot_hhmm,
|
||||
)
|
||||
@@ -44,7 +44,7 @@ from services.control.setpoints import (
|
||||
_deye_tou_min_soc_pct,
|
||||
_deye_tou_params,
|
||||
_deye_tou_reserve_soc_pct,
|
||||
_deye_zero_export_amps_for_passive,
|
||||
deye_battery_charge_discharge_amps,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.modbus_client import get_modbus_client
|
||||
@@ -67,7 +67,10 @@ async def write_inverter_setpoints(
|
||||
raw_bat = setpoints_now.battery_w
|
||||
grid_w = int(setpoints_now.grid_setpoint_w or 0)
|
||||
no_export = inv.no_export
|
||||
export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w)
|
||||
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)
|
||||
@@ -78,25 +81,17 @@ async def write_inverter_setpoints(
|
||||
deye_mode = get_deye_mode(setpoints_now)
|
||||
|
||||
bat_w = int(raw_bat) if raw_bat is not None else 0
|
||||
if setpoints_now.lock_battery:
|
||||
charge_a = 0
|
||||
discharge_a = 0
|
||||
elif deye_mode == "CHARGE":
|
||||
charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a)
|
||||
discharge_a = 0
|
||||
elif deye_mode == "SELL":
|
||||
charge_a = 0
|
||||
discharge_a = int(inv.max_discharge_a)
|
||||
elif setpoints_now.self_sustain_local_use:
|
||||
charge_a = int(inv.max_charge_a)
|
||||
discharge_a = int(inv.max_discharge_a)
|
||||
else:
|
||||
charge_a, discharge_a = _deye_zero_export_amps_for_passive(
|
||||
grid_w,
|
||||
bat_w,
|
||||
int(inv.max_charge_a),
|
||||
int(inv.max_discharge_a),
|
||||
)
|
||||
charge_a, discharge_a = deye_battery_charge_discharge_amps(
|
||||
lock_battery=setpoints_now.lock_battery,
|
||||
deye_mode=deye_mode,
|
||||
self_sustain_local_use=setpoints_now.self_sustain_local_use,
|
||||
bat_w=bat_w,
|
||||
grid_w=grid_w,
|
||||
max_charge_a=int(inv.max_charge_a),
|
||||
max_discharge_a=int(inv.max_discharge_a),
|
||||
export_mode=setpoints_now.export_mode,
|
||||
export_ban=bool(setpoints_now.export_ban),
|
||||
)
|
||||
|
||||
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
|
||||
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
|
||||
@@ -104,10 +99,11 @@ async def write_inverter_setpoints(
|
||||
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} discharge_a={discharge_a} | "
|
||||
f"charge_a={charge_a_log} discharge_a={discharge_a} | "
|
||||
f"reg142={selling_mode} reg145={solar_sell} reg143={export_limit}W reg178={reg178_val}"
|
||||
)
|
||||
|
||||
@@ -170,10 +166,13 @@ async def write_inverter_setpoints(
|
||||
"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(
|
||||
[
|
||||
(108, "", charge_a),
|
||||
(109, "", discharge_a),
|
||||
amp_regs
|
||||
+ [
|
||||
(141, "energy_mode (0)", 0),
|
||||
(142, "limit_control", selling_mode),
|
||||
(143, "", export_limit),
|
||||
@@ -196,7 +195,12 @@ async def write_inverter_setpoints(
|
||||
current_178 = int(r178[0])
|
||||
peak_bits = int(reg178_val) & int(REG178_VERIFY_MASK)
|
||||
if inv.deye_gen_microinverter_cutoff_enabled:
|
||||
want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL"
|
||||
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)
|
||||
|
||||
@@ -30,8 +30,10 @@ class InverterConfig:
|
||||
deye_tou_inactive_signature: str | None = None
|
||||
deye_zero_export_mode: int = 1
|
||||
deye_gen_microinverter_cutoff_enabled: bool = False
|
||||
#: Součet nominal_power_wp controllable PV na invertoru; 0 = EMS nezapisuje reg 340.
|
||||
#: 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
|
||||
|
||||
@@ -50,6 +52,8 @@ class ControlSetpoints:
|
||||
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).
|
||||
|
||||
@@ -19,7 +19,11 @@ from services.control.repository import (
|
||||
_fetch_plan_row_for_slot_offset,
|
||||
_load_inverter_config,
|
||||
)
|
||||
from services.control.setpoints import _apply_price_failsafe_guard, _build_setpoints
|
||||
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__)
|
||||
@@ -38,6 +42,7 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
||||
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
|
||||
@@ -49,12 +54,14 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -91,8 +98,10 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> 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(
|
||||
|
||||
@@ -78,6 +78,7 @@ async def _load_inverter_config(
|
||||
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,
|
||||
@@ -182,6 +183,7 @@ async def _load_inverter_config(
|
||||
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
|
||||
),
|
||||
|
||||
@@ -66,11 +66,38 @@ class _DictRecord:
|
||||
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
|
||||
@@ -96,19 +123,29 @@ def _build_setpoints(
|
||||
export_mode = str(export_mode_raw).strip().upper() if export_mode_raw is not None else None
|
||||
if export_mode == "NONE":
|
||||
export_limit = 0
|
||||
elif export_limit <= 0 and grid_sp < 0:
|
||||
export_limit = abs(grid_sp)
|
||||
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá.
|
||||
export_ban = sell_f is not None and float(sell_f) < 0 and grid_sp >= 0
|
||||
gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled")
|
||||
gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False
|
||||
bat_w = int(pi["battery_setpoint_w"] or 0)
|
||||
pv_a_allowed: int | None = None
|
||||
if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0:
|
||||
forecast = int(pi.get("pv_a_forecast_solver_w") or 0)
|
||||
curtail = int(pi.get("pv_a_curtailed_w") or 0)
|
||||
pv_a_allowed = compute_pv_a_reg340_max_solar_w(int(pv_a_cap_w), forecast, curtail)
|
||||
buy_raw = pi.get("effective_buy_price")
|
||||
buy_f: float | None = float(buy_raw) if buy_raw is not None else None
|
||||
pv_b = int(pi.get("pv_b_forecast_solver_w") or 0)
|
||||
# Slabý úsvit: neposílat reg 340 — forecast nepřesný, Deye řídí sám (108/109/142).
|
||||
_low_pv_no_reg340_w = 1500
|
||||
if (
|
||||
forecast < _low_pv_no_reg340_w
|
||||
and curtail <= 0
|
||||
and pv_b > 0
|
||||
):
|
||||
pv_a_allowed = None
|
||||
elif (
|
||||
buy_f is not None
|
||||
and sell_f is not None
|
||||
and float(buy_f) < 0.0
|
||||
@@ -116,8 +153,23 @@ def _build_setpoints(
|
||||
and pv_b > 0
|
||||
):
|
||||
pv_a_allowed = 0
|
||||
elif plan_skips_deye_reg340_write(
|
||||
battery_setpoint_w=bat_w,
|
||||
grid_setpoint_w=grid_sp,
|
||||
export_mode=export_mode,
|
||||
export_limit_w=max(0, export_limit),
|
||||
pv_a_curtailed_w=curtail,
|
||||
):
|
||||
pv_a_allowed = None
|
||||
else:
|
||||
pv_a_allowed = compute_pv_a_reg340_max_solar_w(
|
||||
int(pv_a_cap_w),
|
||||
forecast,
|
||||
curtail,
|
||||
min_w=int(pv_a_reg340_min_w),
|
||||
)
|
||||
return ControlSetpoints(
|
||||
battery_w=int(pi["battery_setpoint_w"] or 0),
|
||||
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),
|
||||
@@ -127,6 +179,7 @@ def _build_setpoints(
|
||||
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,
|
||||
@@ -178,6 +231,71 @@ def _build_setpoints(
|
||||
return None
|
||||
|
||||
|
||||
def _passive_no_export_guard(sp: ControlSetpoints) -> ControlSetpoints:
|
||||
"""PASSIVE, žádný vývoz do sítě; vybíjení baterie do sítě vynulováno (reg 109 přes export_ban)."""
|
||||
bat = int(sp.battery_w or 0)
|
||||
if bat < 0:
|
||||
bat = 0
|
||||
return ControlSetpoints(
|
||||
battery_w=bat,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=sp.ev1_current_a,
|
||||
ev2_current_a=sp.ev2_current_a,
|
||||
heat_pump_enable=sp.heat_pump_enable,
|
||||
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
|
||||
ev1_power_w=sp.ev1_power_w,
|
||||
ev2_power_w=sp.ev2_power_w,
|
||||
target_soc_pct=sp.target_soc_pct,
|
||||
deye_physical_mode="PASSIVE",
|
||||
export_mode="NONE",
|
||||
export_ban=True,
|
||||
deye_gen_cutoff_enabled=True,
|
||||
effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh,
|
||||
pv_a_allowed_w=sp.pv_a_allowed_w,
|
||||
lock_battery=sp.lock_battery,
|
||||
self_sustain_local_use=sp.self_sustain_local_use,
|
||||
)
|
||||
|
||||
|
||||
def _apply_export_plan_guard(
|
||||
site_id: int,
|
||||
mode: OperatingModeInfo,
|
||||
pi: Any | None,
|
||||
sp: ControlSetpoints,
|
||||
) -> ControlSetpoints:
|
||||
"""
|
||||
Exekuční pojistka: plán zakazuje vývoz (záporná vykupní nebo export_mode NONE),
|
||||
ale Deye může zůstat v SELL — vynutit PASSIVE a export_ban před zápisem Modbus.
|
||||
"""
|
||||
if mode.mode_code != "AUTO" or pi is None:
|
||||
return sp
|
||||
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
sell_f: float | None = (
|
||||
float(sell_raw) if sell_raw is not None else sp.effective_sell_price_czk_kwh
|
||||
)
|
||||
export_mode_raw = pi.get("export_mode")
|
||||
export_mode = (
|
||||
str(export_mode_raw).strip().upper()
|
||||
if export_mode_raw is not None
|
||||
else (sp.export_mode or "")
|
||||
)
|
||||
grid_sp = int(pi.get("grid_setpoint_w") or sp.grid_setpoint_w or 0)
|
||||
|
||||
neg_sell = sell_f is not None and float(sell_f) < 0
|
||||
plan_no_export = export_mode == "NONE" and grid_sp >= 0
|
||||
if not neg_sell and not plan_no_export:
|
||||
return sp
|
||||
|
||||
reason = "neg_sell" if neg_sell else "export_mode_none"
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO export plan guard (%s) -> PASSIVE no-export",
|
||||
site_id,
|
||||
reason,
|
||||
)
|
||||
return _passive_no_export_guard(sp)
|
||||
|
||||
|
||||
def _apply_price_failsafe_guard(
|
||||
site_id: int,
|
||||
mode: OperatingModeInfo,
|
||||
@@ -214,6 +332,27 @@ def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> in
|
||||
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))
|
||||
|
||||
@@ -244,15 +383,60 @@ def _deye_zero_export_amps_for_passive(
|
||||
max_discharge_a: int,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
PASSIVE (zero export k CT/zátěži): výchozí plné 108/109.
|
||||
PASSIVE (zero export k CT/zátěži): asymetrie jen tam, kde dává smysl pro import.
|
||||
|
||||
Export v plánu bez vybíjení baterie vypne charge A; import bez nabíjení vypne discharge A.
|
||||
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 0, max_discharge_a
|
||||
if grid_w > 0 and bat_w <= 0:
|
||||
return max_charge_a, 0
|
||||
return max_charge_a, max_discharge_a
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
|
||||
|
||||
def deye_battery_charge_discharge_amps(
|
||||
*,
|
||||
lock_battery: bool,
|
||||
deye_mode: str,
|
||||
self_sustain_local_use: bool,
|
||||
bat_w: int,
|
||||
grid_w: int,
|
||||
max_charge_a: int,
|
||||
max_discharge_a: int,
|
||||
export_mode: str | None = None,
|
||||
export_ban: bool = False,
|
||||
) -> tuple[int | None, int]:
|
||||
"""
|
||||
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
|
||||
|
||||
**PV_SURPLUS** (PASSIVE, export FVE): **108 = 0**, **109 = max** — baterie se přes limit
|
||||
nabíjení neplní, přebytek jde do sítě (142 = zero-export dle instalace, 145 = 1).
|
||||
|
||||
PASSIVE + nabíjení bez exportního záměru (`battery_w > 0`, export_mode NONE): **108 = max**.
|
||||
**CHARGE** ze sítě: 108 z `battery_w`.
|
||||
|
||||
**SELL** (selling first, reg 142 = 0): vrací ``(None, max_discharge)`` — reg **108 se nezapisuje**
|
||||
(export řídí 142/178; nulování 108 a obnova po návratu jsou zbytečné zápisy do paměti).
|
||||
"""
|
||||
if lock_battery:
|
||||
return 0, 0
|
||||
if deye_mode == "CHARGE":
|
||||
return battery_watts_to_amps(bat_w, max_charge_a), 0
|
||||
if deye_mode == "SELL":
|
||||
return None, int(max_discharge_a)
|
||||
if self_sustain_local_use:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
if _is_passive_pv_surplus_export(
|
||||
deye_mode=deye_mode,
|
||||
export_mode=export_mode,
|
||||
export_ban=export_ban,
|
||||
grid_w=grid_w,
|
||||
):
|
||||
return 0, int(max_discharge_a)
|
||||
if bat_w > 0:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
return _deye_zero_export_amps_for_passive(
|
||||
grid_w, bat_w, int(max_charge_a), int(max_discharge_a)
|
||||
)
|
||||
|
||||
|
||||
def get_deye_mode(setpoints: ControlSetpoints) -> str:
|
||||
|
||||
1
backend/services/planning/__init__.py
Normal file
1
backend/services/planning/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""EMS plánovač – moduly (Fáze 1 dekompozice planning_engine.py)."""
|
||||
118
backend/services/planning/constants.py
Normal file
118
backend/services/planning/constants.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# backend/services/planning/constants.py
|
||||
#
|
||||
# EMS plánovač – konstanty (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
# POZOR: ekonomické penalty/váhy jsou kandidáti na přesun do DB ve Fázi 2
|
||||
# (CLAUDE.md pravidlo 16: žádný skrytý faktor v Pythonu).
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# ============================================================
|
||||
# Konstanty
|
||||
# ============================================================
|
||||
|
||||
# Když DB vrátí NULL (skoro žádná OTE data), denní plán použije krátký fallback (soulad s min hodinami ve fn_planning_horizon_end).
|
||||
_DAILY_FALLBACK_HORIZON_HOURS = 1.0
|
||||
# Shadow cena zbytkové energie na konci horizontu: - (avg_buy * FACTOR / 1000) * soc[T-1] (Kč; soc v Wh).
|
||||
INTERVAL_H = 0.25 # 15 minut v hodinách
|
||||
CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A
|
||||
SOLVER_TIME_LIMIT = 10 # sekund
|
||||
# MILP: významný export ge (W) ⇒ koncové soc[t] ≥ podlaha; mimo arbitrážní relax je to arb_base_wh
|
||||
# (rezerva z DB). Při relaxaci spodku před extrémně záporným buy je podlaha soc_panel_min[t]
|
||||
# (planner floor), jinak by šlo jen do zátěže a nešlo by „vypustit do sítě“ před levným nákupem.
|
||||
GE_MIN_EXPORT_W = 1.0
|
||||
# Dvouprůchodové solve: stop když acquisition z pass1 vs pass2 se liší méně než (Kč/kWh).
|
||||
ACQUISITION_TWO_PASS_EPS_KWH = 0.05
|
||||
# Load-first (Deye): PV nejdřív pokryje load+EV+TČ; bc_pv/ge_pv jen z pv_sp (přebytek).
|
||||
LOAD_FIRST_INCENTIVE_CZK_KWH = 0.05
|
||||
# Dokud je kotva pro hluboký dump (první sell < 0 v horizontu, jinak první extrémní buy) dál než
|
||||
# tento počet 15min slotů, držíme plánovací spodek na rezervě (arb_base_wh) místo planner floor —
|
||||
# priorita: beze „ztráty na prodeji“ (sell >= 0) držet buffer, hluboký vývoz až těsně před záporným prodejem.
|
||||
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8
|
||||
# Měkká kotva: chceme být u planner floor už v posledním slotu před prvním sell < 0.
|
||||
# Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila
|
||||
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
|
||||
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
|
||||
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail).
|
||||
PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
|
||||
# Curtailment při sell<0 + allow_charge: nesmí být téměř zdarma oproti nabíjení (BA81).
|
||||
NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0
|
||||
# Odměna v objective za FVE→baterie při sell<0 (doplňuje shortfall; BA81 fixed tarif).
|
||||
NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8
|
||||
# Měkký tlak: v okně sell<0 dobít na soc_max (ne zastavit na ~94 % kvůli curtail).
|
||||
NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35
|
||||
# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek.
|
||||
NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0
|
||||
# Fáze sell<0 (v32): ASAP na prep_soc %, tail rampa na soc_max.
|
||||
NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.85
|
||||
NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH = 60.0
|
||||
# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž).
|
||||
EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12
|
||||
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
|
||||
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
|
||||
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
|
||||
PLANNER_BUILD_TAG = "2026-06-06-home01-strict-late-replan-v5"
|
||||
SOLVER_RELAX_STEPS: tuple[str, ...] = (
|
||||
"strict",
|
||||
"relaxed_expensive_import",
|
||||
"relaxed_neg_buy_charge",
|
||||
"relaxed_neg_prep_hold_only",
|
||||
"relaxed_neg_prep_window",
|
||||
"neg_sell_phases_fallback",
|
||||
"relaxed_pos_sell_ge_block",
|
||||
"relaxed_solver_masks",
|
||||
)
|
||||
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
|
||||
DAWN_LOW_PV_NO_CURTAIL_W = 1500
|
||||
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
|
||||
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
|
||||
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
|
||||
NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0
|
||||
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
|
||||
NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
|
||||
# Kotva: SoC na konci večera D−1 a těsně před 1. sell<0 ráno D ≤ reserve_soc.
|
||||
NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH = 400.0
|
||||
NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 55.0
|
||||
# Terminal SoC shadow price: effective_factor = base × (1 − w_neg); w_neg roste s blízkostí a záporností buy<0.
|
||||
TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS = int(36 / INTERVAL_H)
|
||||
TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK = 1.0
|
||||
TERMINAL_NEG_BUY_MAGNITUDE_FLOOR = 0.25
|
||||
TERMINAL_NEG_BUY_WEIGHT_CAP = 0.95
|
||||
# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl.
|
||||
PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15
|
||||
PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0
|
||||
PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 55.0
|
||||
PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH = 90.0
|
||||
POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30
|
||||
PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25
|
||||
PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
EVENING_PEAK_SELL_EPS_CZK_KWH = 0.05
|
||||
# Rolling replan: držet evening_push_ts při malé změně peak sell / SoC.
|
||||
EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH = 0.5
|
||||
EVENING_PUSH_HYSTERESIS_SOC_PCT = 5.0
|
||||
# Noční výprodej baterie: večer (≥17h) + ráno do východu FVE (0–5h Prague), jedna špička přes půlnoc.
|
||||
NIGHT_EXPORT_EVENING_START_HOUR = 17
|
||||
NIGHT_EXPORT_MORNING_END_HOUR = 5
|
||||
NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W = 500.0
|
||||
# Převáží terminal SoC shadow price při krátkém večerním horizontu (home-01).
|
||||
EVENING_PUSH_Z_EXPORT_BONUS_CZK = 2500.0
|
||||
# buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B).
|
||||
PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0
|
||||
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
|
||||
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
|
||||
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
||||
# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast
|
||||
CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0
|
||||
# Dynamická ekonomická podlaha (MILP w_arb): lookahead FVE energie v dalších slotech
|
||||
ARB_LOOKAHEAD_SLOTS = 32 # 8 h při INTERVAL_H=0.25
|
||||
ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..1)
|
||||
|
||||
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
|
||||
# --- Konstanty původně roztroušené mezi funkcemi planning_engine.py (Fáze 1) ---
|
||||
MORNING_PRENEG_START_HOUR = 5
|
||||
MORNING_PRENEG_END_HOUR = 11
|
||||
450
backend/services/planning/db_io.py
Normal file
450
backend/services/planning/db_io.py
Normal file
@@ -0,0 +1,450 @@
|
||||
# backend/services/planning/db_io.py
|
||||
#
|
||||
# EMS plánovač – DB vrstva: načtení site contextu a slotů, uložení běhu
|
||||
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
# Jediné SQL: select ems.fn_* (SQL-first pravidlo CLAUDE.md).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
|
||||
from services.planning.constants import (
|
||||
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
|
||||
PLANNER_BUILD_TAG,
|
||||
)
|
||||
from services.planning.types import (
|
||||
PlannerSolverError,
|
||||
PlanningSlot,
|
||||
_parse_json_dt,
|
||||
_slot_float_nullable,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
|
||||
if obj is None or obj == []:
|
||||
return None
|
||||
if isinstance(obj, str):
|
||||
obj = json.loads(obj)
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
td = _parse_json_dt(obj.get("target_deadline"))
|
||||
if td is None:
|
||||
return None
|
||||
return SimpleNamespace(
|
||||
target_deadline=td,
|
||||
energy_needed_wh=float(obj["energy_needed_wh"]),
|
||||
)
|
||||
|
||||
async def _load_site_context(site_id: int, db):
|
||||
"""
|
||||
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV, režim a TUV statistiky (SQL).
|
||||
"""
|
||||
raw = await db.fetchval(
|
||||
"select ems.fn_planning_site_context($1::int)",
|
||||
site_id,
|
||||
)
|
||||
ctx = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
if ctx.get("error") == "unknown_site":
|
||||
raise RuntimeError(f"Site not found: {site_id}")
|
||||
|
||||
b = ctx["battery"]
|
||||
ec_i = int(b["max_charge_power_w"])
|
||||
ed_i = int(b["max_discharge_power_w"])
|
||||
planner_soc_max = float(b.get("planner_soc_max_wh", b["soc_max_wh"]))
|
||||
floor_pct = b.get("planner_discharge_floor_percent")
|
||||
buy_thr = b.get("planner_extreme_buy_threshold_czk_kwh")
|
||||
relax_prewin = b.get("planner_discharge_relax_prewindow_slots")
|
||||
battery = SimpleNamespace(
|
||||
usable_capacity_wh=float(b["usable_capacity_wh"]),
|
||||
min_soc_wh=float(b["min_soc_wh"]),
|
||||
arb_floor_wh=float(b["arb_floor_wh"]),
|
||||
reserve_soc_wh=float(b["reserve_soc_wh"]),
|
||||
soc_max_wh=planner_soc_max,
|
||||
charge_efficiency=float(b["charge_efficiency"]),
|
||||
discharge_efficiency=float(b["discharge_efficiency"]),
|
||||
degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]),
|
||||
max_charge_power_w=ec_i,
|
||||
max_discharge_power_w=ed_i,
|
||||
charge_slot_buffer=float(b["charge_slot_buffer"])
|
||||
if b.get("charge_slot_buffer") is not None
|
||||
else 0,
|
||||
discharge_slot_buffer=float(b["discharge_slot_buffer"])
|
||||
if b.get("discharge_slot_buffer") is not None
|
||||
else 0,
|
||||
planner_extreme_buy_threshold_czk_kwh=float(buy_thr) if buy_thr is not None else -5.0,
|
||||
planner_discharge_floor_percent=float(floor_pct) if floor_pct is not None else None,
|
||||
planner_discharge_relax_prewindow_slots=int(relax_prewin)
|
||||
if relax_prewin is not None
|
||||
else DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
|
||||
planner_terminal_soc_value_factor=float(b["planner_terminal_soc_value_factor"]),
|
||||
planner_daytime_charge_target_enabled=bool(
|
||||
b.get("planner_daytime_charge_target_enabled", True)
|
||||
),
|
||||
planner_night_baseload_buffer_percent=float(
|
||||
b.get("planner_night_baseload_buffer_percent") or 20.0
|
||||
),
|
||||
planner_daytime_charge_price_quantile=float(
|
||||
b.get("planner_daytime_charge_price_quantile") or 0.70
|
||||
),
|
||||
planner_charge_commitment_penalty_czk_kwh=float(
|
||||
b.get("planner_charge_commitment_penalty_czk_kwh") or 0.20
|
||||
),
|
||||
planner_neg_sell_prep_soc_percent=float(
|
||||
b.get("planner_neg_sell_prep_soc_percent") or 80.0
|
||||
),
|
||||
planner_neg_sell_full_soc_tail_slots=int(
|
||||
b.get("planner_neg_sell_full_soc_tail_slots") or 4
|
||||
),
|
||||
planner_neg_sell_vent_min_sell_czk_kwh=(
|
||||
float(b["planner_neg_sell_vent_min_sell_czk_kwh"])
|
||||
if b.get("planner_neg_sell_vent_min_sell_czk_kwh") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
hpj = ctx["heat_pump"]
|
||||
heat_pump = SimpleNamespace(
|
||||
rated_heating_power_w=int(hpj["rated_heating_power_w"]),
|
||||
tuv_min_temp_c=float(hpj["tuv_min_temp_c"]),
|
||||
tuv_target_temp_c=float(hpj["tuv_target_temp_c"]),
|
||||
)
|
||||
|
||||
g = ctx["grid"]
|
||||
m = ctx.get("market") or {}
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=int(g["max_import_power_w"]),
|
||||
max_export_power_w=int(g["max_export_power_w"]),
|
||||
block_export_on_negative_sell=bool(g.get("block_export_on_negative_sell") or False),
|
||||
deye_gen_microinverter_cutoff_enabled=bool(g.get("deye_gen_microinverter_cutoff_enabled") or False),
|
||||
purchase_pricing_mode=str(m.get("purchase_pricing_mode") or "spot").strip().lower(),
|
||||
sale_pricing_mode=str(m.get("sale_pricing_mode") or "spot").strip().lower(),
|
||||
)
|
||||
|
||||
vehicles: list[SimpleNamespace] = []
|
||||
for v in ctx.get("vehicles") or []:
|
||||
vehicles.append(
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=int(v["max_charge_power_w"]),
|
||||
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
|
||||
default_target_soc_pct=float(v["default_target_soc_pct"]),
|
||||
)
|
||||
)
|
||||
while len(vehicles) < 2:
|
||||
vehicles.append(
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
)
|
||||
)
|
||||
|
||||
ev_raw = ctx.get("ev_sessions") or []
|
||||
ev_sessions = [
|
||||
_ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None,
|
||||
_ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None,
|
||||
]
|
||||
|
||||
soc_wh = float(ctx["soc_wh"])
|
||||
tuv_temp = float(ctx["tuv_temp"])
|
||||
operating_mode = ctx.get("operating_mode")
|
||||
|
||||
tuv_stats: dict[tuple[int, int], float] = {}
|
||||
for row in ctx.get("tuv_delta_stats") or []:
|
||||
tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"])
|
||||
|
||||
return (
|
||||
battery,
|
||||
heat_pump,
|
||||
grid,
|
||||
vehicles,
|
||||
ev_sessions,
|
||||
soc_wh,
|
||||
tuv_temp,
|
||||
operating_mode,
|
||||
tuv_stats,
|
||||
)
|
||||
|
||||
async def _load_previous_plan_charge_commitment_prev_w(
|
||||
site_id: int,
|
||||
slots: list[PlanningSlot],
|
||||
db,
|
||||
) -> list[Optional[float]]:
|
||||
"""
|
||||
Pro rolling replan: z aktivního plánu načte battery_setpoint_w pro shodné sloty.
|
||||
Kotva měkkého commitmentu jen když předchozí plán chtěl nabíjet z PV přebytku (viz podmínky).
|
||||
"""
|
||||
if not slots:
|
||||
return []
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
select pi.interval_start,
|
||||
pi.battery_setpoint_w,
|
||||
pi.grid_setpoint_w,
|
||||
coalesce(pi.pv_a_forecast_solver_w, 0) as pva,
|
||||
coalesce(pi.pv_b_forecast_solver_w, 0) as pvb,
|
||||
coalesce(pi.load_baseline_w, 0) as lb
|
||||
from ems.planning_interval pi
|
||||
inner join ems.planning_run pr on pr.id = pi.run_id
|
||||
where pr.site_id = $1::int
|
||||
and pr.status = 'active'
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
by_start = {r["interval_start"]: r for r in rows}
|
||||
out: list[Optional[float]] = []
|
||||
for s in slots:
|
||||
r = by_start.get(s.interval_start)
|
||||
if r is None:
|
||||
out.append(None)
|
||||
continue
|
||||
bw = int(r["battery_setpoint_w"] or 0)
|
||||
gw = int(r["grid_setpoint_w"] or 0)
|
||||
pva = int(r["pva"] or 0)
|
||||
pvb = int(r["pvb"] or 0)
|
||||
lb = int(r["lb"] or 0)
|
||||
# Commitment má kotvit jen „nabíjení z PV přebytku“, ne situace kdy plán současně
|
||||
# výrazně exportuje do sítě (typicky charge while exporting). To by stabilizovalo špatný cyklus.
|
||||
if bw > 500 and (pva + pvb) > lb and gw <= 0 and gw >= -500:
|
||||
out.append(float(bw))
|
||||
else:
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
async def _load_slots(
|
||||
site_id: int,
|
||||
from_dt: datetime,
|
||||
to_dt: datetime,
|
||||
db,
|
||||
*,
|
||||
soc_wh: float,
|
||||
) -> list[PlanningSlot]:
|
||||
"""15min sloty z ems.fn_load_planning_slots_full."""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
|
||||
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
|
||||
ev1_connected, ev2_connected, allow_charge, allow_discharge_export,
|
||||
night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh,
|
||||
future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh,
|
||||
is_daytime_pv_surplus_slot,
|
||||
charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at,
|
||||
min_buy_before_cutoff_czk_kwh, pv_charge_wh_ahead, neg_buy_wh_ahead,
|
||||
grid_charge_suppressed_reason,
|
||||
charge_target_wh, pre_window_wh, in_window_wh,
|
||||
charge_slot_wh, charge_cum_wh, charge_layer, charge_slot_reason
|
||||
from ems.fn_load_planning_slots_full(
|
||||
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
from_dt,
|
||||
to_dt,
|
||||
soc_wh,
|
||||
)
|
||||
out: list[PlanningSlot] = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
out.append(
|
||||
PlanningSlot(
|
||||
interval_start=d["interval_start"],
|
||||
buy_price=float(d["buy_price"]),
|
||||
sell_price=float(d["sell_price"]),
|
||||
pv_a_forecast_w=int(d["pv_a_forecast_w"] or 0),
|
||||
pv_b_forecast_w=int(d["pv_b_forecast_w"] or 0),
|
||||
load_baseline_w=int(d["load_baseline_w"] or 0),
|
||||
ev1_connected=bool(d["ev1_connected"]),
|
||||
ev2_connected=bool(d["ev2_connected"]),
|
||||
is_predicted_price=bool(d.get("is_predicted_price")),
|
||||
allow_charge=bool(d.get("allow_charge", True)),
|
||||
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
|
||||
night_baseload_target_wh=_slot_float_nullable(d, "night_baseload_target_wh"),
|
||||
night_baseload_buffer_wh=_slot_float_nullable(d, "night_baseload_buffer_wh"),
|
||||
safety_soc_target_wh=_slot_float_nullable(d, "safety_soc_target_wh"),
|
||||
future_avoided_buy_czk_kwh=_slot_float_nullable(d, "future_avoided_buy_czk_kwh"),
|
||||
future_sell_opportunity_czk_kwh=_slot_float_nullable(
|
||||
d, "future_sell_opportunity_czk_kwh"
|
||||
),
|
||||
is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)),
|
||||
charge_acquisition_buy_czk_kwh=_slot_float_nullable(
|
||||
d, "charge_acquisition_buy_czk_kwh"
|
||||
),
|
||||
charge_acquisition_cutoff_at=d.get("charge_acquisition_cutoff_at"),
|
||||
min_buy_before_cutoff_czk_kwh=_slot_float_nullable(
|
||||
d, "min_buy_before_cutoff_czk_kwh"
|
||||
),
|
||||
pv_charge_wh_ahead=_slot_float_nullable(d, "pv_charge_wh_ahead"),
|
||||
neg_buy_wh_ahead=_slot_float_nullable(d, "neg_buy_wh_ahead"),
|
||||
grid_charge_suppressed_reason=d.get("grid_charge_suppressed_reason"),
|
||||
charge_target_wh=_slot_float_nullable(d, "charge_target_wh"),
|
||||
pre_window_wh=_slot_float_nullable(d, "pre_window_wh"),
|
||||
in_window_wh=_slot_float_nullable(d, "in_window_wh"),
|
||||
charge_slot_wh=_slot_float_nullable(d, "charge_slot_wh"),
|
||||
charge_cum_wh=_slot_float_nullable(d, "charge_cum_wh"),
|
||||
charge_layer=d.get("charge_layer"),
|
||||
charge_slot_reason=d.get("charge_slot_reason"),
|
||||
)
|
||||
)
|
||||
if not out:
|
||||
raise RuntimeError(
|
||||
"No planning slots available – check market prices and horizon settings"
|
||||
)
|
||||
if any(s.is_predicted_price for s in out):
|
||||
logger.warning(
|
||||
"[site=%s] Unexpected predicted-price slots in planning horizon",
|
||||
site_id,
|
||||
)
|
||||
return out
|
||||
|
||||
def _build_slot_inputs(
|
||||
slots_raw_pv: list[PlanningSlot],
|
||||
slots_solver: list[PlanningSlot],
|
||||
) -> list[tuple[int, int, int, int, int]]:
|
||||
"""(load_baseline_w, pv_a_raw, pv_b_raw, pv_a_solver, pv_b_solver) pro každý slot."""
|
||||
if len(slots_raw_pv) != len(slots_solver):
|
||||
raise ValueError("slots_raw_pv and slots_solver length mismatch")
|
||||
out: list[tuple[int, int, int, int, int]] = []
|
||||
for raw, sol in zip(slots_raw_pv, slots_solver):
|
||||
out.append(
|
||||
(
|
||||
int(raw.load_baseline_w),
|
||||
int(raw.pv_a_forecast_w),
|
||||
int(raw.pv_b_forecast_w),
|
||||
int(sol.pv_a_forecast_w),
|
||||
int(sol.pv_b_forecast_w),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
async def _save_planning_run(
|
||||
site_id, results, horizon_from, horizon_to,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_wh, duration_ms, correction, db,
|
||||
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
||||
*,
|
||||
activate_run: bool = True,
|
||||
solver_snapshot: Optional[dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
|
||||
if slot_inputs is not None and len(slot_inputs) != len(results):
|
||||
raise ValueError("slot_inputs and results length mismatch")
|
||||
run_meta: dict[str, Any] = {
|
||||
"run_type": run_type,
|
||||
"triggered_by": triggered_by,
|
||||
"replan_from": replan_from.isoformat() if replan_from else None,
|
||||
"soc_at_replan_wh": soc_wh,
|
||||
"solver_duration_ms": duration_ms,
|
||||
"forecast_correction_factor": correction,
|
||||
}
|
||||
if solver_snapshot is not None:
|
||||
run_meta["solver_params"] = solver_snapshot
|
||||
intervals: list[dict] = []
|
||||
for i, r in enumerate(results):
|
||||
row: dict = {
|
||||
"interval_start": r.interval_start.isoformat()
|
||||
if hasattr(r.interval_start, "isoformat")
|
||||
else r.interval_start,
|
||||
"battery_setpoint_w": r.battery_setpoint_w,
|
||||
"battery_soc_target_pct": r.battery_soc_target,
|
||||
"grid_setpoint_w": r.grid_setpoint_w,
|
||||
"export_limit_w": r.export_limit_w,
|
||||
"export_mode": r.export_mode,
|
||||
"deye_physical_mode": r.deye_physical_mode,
|
||||
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
|
||||
"ev1_setpoint_w": r.ev1_setpoint_w,
|
||||
"ev2_setpoint_w": r.ev2_setpoint_w,
|
||||
"ev1_via_bat_w": r.ev1_via_bat_w,
|
||||
"ev2_via_bat_w": r.ev2_via_bat_w,
|
||||
"heat_pump_enabled": r.heat_pump_enabled,
|
||||
"heat_pump_setpoint_w": r.heat_pump_setpoint_w,
|
||||
"pv_a_curtailed_w": r.pv_a_curtailed_w,
|
||||
"expected_cost_czk": float(r.expected_cost_czk),
|
||||
"cashflow_czk": float(r.cashflow_czk),
|
||||
"battery_arbitrage_czk": float(r.battery_arbitrage_czk),
|
||||
"penalty_czk": float(r.penalty_czk),
|
||||
"green_bonus_czk": float(r.green_bonus_czk),
|
||||
"effective_buy_price": float(r.effective_buy_price),
|
||||
"effective_sell_price": float(r.effective_sell_price),
|
||||
"is_predicted_price": r.is_predicted_price,
|
||||
}
|
||||
if slot_inputs is not None:
|
||||
si = slot_inputs[i]
|
||||
row["load_baseline_w"] = si[0]
|
||||
row["pv_a_forecast_raw_w"] = si[1]
|
||||
row["pv_b_forecast_raw_w"] = si[2]
|
||||
row["pv_a_forecast_solver_w"] = si[3]
|
||||
row["pv_b_forecast_solver_w"] = si[4]
|
||||
intervals.append(row)
|
||||
|
||||
return int(
|
||||
await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_run_commit(
|
||||
$1::int, $2::timestamptz, $3::timestamptz,
|
||||
$4::jsonb, $5::jsonb, $6::boolean
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
horizon_from,
|
||||
horizon_to,
|
||||
json.dumps(run_meta, default=str),
|
||||
json.dumps(intervals, default=str),
|
||||
activate_run,
|
||||
)
|
||||
)
|
||||
|
||||
async def _save_failed_planning_run(
|
||||
site_id: int,
|
||||
horizon_from: datetime,
|
||||
horizon_to: datetime,
|
||||
*,
|
||||
run_type: str,
|
||||
triggered_by: str,
|
||||
replan_from: datetime | None,
|
||||
soc_wh: float,
|
||||
correction: float,
|
||||
db,
|
||||
error: PlannerSolverError,
|
||||
slot_count: int | None = None,
|
||||
) -> int:
|
||||
"""Uloží neúspěšný běh plánovače (status=failed); aktivní plán nemění."""
|
||||
run_meta: dict[str, Any] = {
|
||||
"run_type": run_type,
|
||||
"triggered_by": triggered_by,
|
||||
"replan_from": replan_from.isoformat() if replan_from else None,
|
||||
"soc_at_replan_wh": soc_wh,
|
||||
"solver_duration_ms": 0,
|
||||
"forecast_correction_factor": correction,
|
||||
"error_text": str(error),
|
||||
"solver_params": {
|
||||
"status": "failed",
|
||||
"planner_build_tag": PLANNER_BUILD_TAG,
|
||||
"solver_status": error.solver_status,
|
||||
"relax_chain": error.relax_chain,
|
||||
"slot_count": slot_count,
|
||||
},
|
||||
}
|
||||
run_id = int(
|
||||
await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_run_fail(
|
||||
$1::int, $2::timestamptz, $3::timestamptz, $4::jsonb
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
horizon_from,
|
||||
horizon_to,
|
||||
json.dumps(run_meta, default=str),
|
||||
)
|
||||
)
|
||||
logger.error(
|
||||
"[site=%s] Planning solver failed run_id=%s: %s relax_chain=%s",
|
||||
site_id,
|
||||
run_id,
|
||||
error,
|
||||
error.relax_chain,
|
||||
)
|
||||
return run_id
|
||||
97
backend/services/planning/forecast.py
Normal file
97
backend/services/planning/forecast.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# backend/services/planning/forecast.py
|
||||
#
|
||||
# EMS plánovač – korekce FVE forecastu podle skutečné výroby
|
||||
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from services.planning.constants import (
|
||||
CORRECTION_DECAY_SLOTS,
|
||||
CORRECTION_MAX_CLAMP,
|
||||
CORRECTION_MIN_CLAMP,
|
||||
CORRECTION_WINDOW_H,
|
||||
)
|
||||
from services.planning.types import PlanningSlot, _parse_json_dt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def compute_correction_factor(
|
||||
site_id: int,
|
||||
now: datetime,
|
||||
db,
|
||||
window_h: float = CORRECTION_WINDOW_H,
|
||||
) -> tuple[float, dict]:
|
||||
"""
|
||||
Spočítá korekční faktor FVE forecastu z posledních window_h hodin.
|
||||
|
||||
Vrátí (factor, log_data) kde factor je v rozsahu [CORRECTION_MIN_CLAMP, CORRECTION_MAX_CLAMP].
|
||||
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
|
||||
"""
|
||||
window_start = now - timedelta(hours=window_h)
|
||||
raw = await db.fetchval(
|
||||
"""
|
||||
select ems.fn_pv_forecast_correction_factor(
|
||||
$1::int, $2::timestamptz, $3::timestamptz,
|
||||
$4::numeric, $5::numeric
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
window_start,
|
||||
now,
|
||||
CORRECTION_MIN_CLAMP,
|
||||
CORRECTION_MAX_CLAMP,
|
||||
)
|
||||
j = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
factor = float(j.get("correction_factor", 1.0))
|
||||
# JSON z DB má často ISO řetězce; asyncpg u $2/$3 vyžaduje datetime
|
||||
ws = _parse_json_dt(j.get("window_start")) or window_start
|
||||
we = _parse_json_dt(j.get("window_end")) or now
|
||||
log_data = {
|
||||
"window_start": ws,
|
||||
"window_end": we,
|
||||
"actual_pv_wh": j.get("actual_pv_wh"),
|
||||
"forecast_pv_wh": j.get("forecast_pv_wh"),
|
||||
"correction_factor": factor,
|
||||
"reason": j.get("reason", "ok"),
|
||||
}
|
||||
if j.get("raw_factor") is not None:
|
||||
log_data["raw_factor"] = j["raw_factor"]
|
||||
return factor, log_data
|
||||
|
||||
def apply_forecast_correction(
|
||||
slots: list[PlanningSlot],
|
||||
now: datetime,
|
||||
factor: float,
|
||||
decay_slots: int = CORRECTION_DECAY_SLOTS,
|
||||
) -> list[PlanningSlot]:
|
||||
"""
|
||||
Aplikuje korekční faktor na FVE forecast zbývajících slotů.
|
||||
Korekce se lineárně utlumuje: na 1. slotu plná korekce,
|
||||
na decay_slots-tém slotu žádná korekce.
|
||||
|
||||
Příklad: factor=0.85, slot 0 → pv_a *= 0.85, slot 8 → pv_a *= 0.925, slot 16+ → žádná korekce
|
||||
"""
|
||||
corrected = []
|
||||
for i, slot in enumerate(slots):
|
||||
if factor == 1.0 or i >= decay_slots:
|
||||
corrected.append(slot)
|
||||
continue
|
||||
|
||||
# Lineární útlum: weight klesá od 1.0 (slot 0) do 0.0 (slot decay_slots)
|
||||
weight = 1.0 - (i / decay_slots)
|
||||
effective_factor = 1.0 + (factor - 1.0) * weight
|
||||
|
||||
corrected.append(
|
||||
replace(
|
||||
slot,
|
||||
pv_a_forecast_w=max(0, int(slot.pv_a_forecast_w * effective_factor)),
|
||||
pv_b_forecast_w=max(0, int(slot.pv_b_forecast_w * effective_factor)),
|
||||
)
|
||||
)
|
||||
|
||||
return corrected
|
||||
1981
backend/services/planning/heuristics.py
Normal file
1981
backend/services/planning/heuristics.py
Normal file
File diff suppressed because it is too large
Load Diff
400
backend/services/planning/solver_v2.py
Normal file
400
backend/services/planning/solver_v2.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# backend/services/planning/solver_v2.py
|
||||
#
|
||||
# EMS plánovač v2 — ČISTÉ ekonomické jádro (Fáze 3).
|
||||
#
|
||||
# Filozofie: objective = reálné peníze (nákup − prodej + degradace − terminal
|
||||
# hodnota energie). Žádné heuristické penalty z constants.py, žádné pre-solver
|
||||
# fáze/okna/kotvy. Chování (neg-sell příprava, evening export, arbitráž) má
|
||||
# VYPLYNOUT z cen a fyziky, ne z ručně laděných vah.
|
||||
#
|
||||
# Co zůstává (tvrdá pravidla — fyzika, HW, CLAUDE.md):
|
||||
# - bilance sběrnice, SoC dynamika s účinnostmi, výkonové stropy
|
||||
# - curtailment jen pole A (pravidlo 5); GEN cutoff binárka pole B (pravidlo 6)
|
||||
# - block_export_on_negative_sell → ge == 0 při sell < 0 (pravidlo 6, KV1)
|
||||
# - buy < 0 → ge == 0 (žádná pumpa import−export přes jeden elektroměr; import
|
||||
# je omezen breakerem — pravidlo 7)
|
||||
# - export z BATERIE ⇒ koncové SoC ≥ arb floor (pravidlo 19; PV export floor nevynucuje)
|
||||
# - zákaz současného importu a exportu (binárka)
|
||||
# - load-first Deye: bc_pv + ge_pv jen z PV přebytku nad zátěží
|
||||
# - EV deadline, TUV look-ahead, provozní režimy (legitimní constraints)
|
||||
#
|
||||
# Vědomé odchylky od v1 (změří harness):
|
||||
# - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to
|
||||
# výstupy charge-slot-budget heuristik, ne fyzika)
|
||||
# - EV náklady jen přes bilanci (v1 je účtuje navíc v objective — dvojí započtení)
|
||||
# - import breaker je tvrdý strop (v1 měkký s 10 Kč/kWh)
|
||||
# - nedodaná EV energie má explicitní cenu místo infeasibility
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import pulp
|
||||
|
||||
from services.planning.constants import (
|
||||
INTERVAL_H,
|
||||
SOLVER_TIME_LIMIT,
|
||||
)
|
||||
from services.planning.types import (
|
||||
DispatchResult,
|
||||
PlanningSlot,
|
||||
_prague_dow_hour,
|
||||
)
|
||||
from services.planning.heuristics import _dispatch_grid_setpoint_w
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
V2_BUILD_TAG = "v2-clean-2026-06-11"
|
||||
|
||||
# Cena za vypnutí GEN portu (mikroinvertory pole B): reálné riziko/opotřebení
|
||||
# cyklování stykače — drobná, ale nenulová, aby cutoff platil jen při sell < 0.
|
||||
V2_GEN_CUTOFF_CZK_KWH = 2.0
|
||||
# SELF_SUSTAIN: export je nežádoucí, ale tvrdé ge=0 by s neřiditelným polem B
|
||||
# a plnou baterií bylo infeasible — vysoká cena funguje jako ventil.
|
||||
V2_SELF_SUSTAIN_EXPORT_CZK_KWH = 100.0
|
||||
# Cena nedodané EV energie do deadline (Kč/kWh) — místo tvrdé infeasibility.
|
||||
V2_EV_UNMET_CZK_KWH = 50.0
|
||||
# Nepatrný tie-break proti zbytečnému curtailu při cenové indiferenci (Kč/kWh).
|
||||
V2_CURTAIL_TIEBREAK_CZK_KWH = 0.001
|
||||
|
||||
|
||||
def _terminal_value_czk_per_wh(slots: list[PlanningSlot], battery: Any) -> float:
|
||||
"""Shadow cena zbytkové energie: průměrný buy prvních 24 h × DB faktor (pravidlo 16)."""
|
||||
n24 = min(len(slots), int(24 / INTERVAL_H))
|
||||
avg_buy = sum(float(s.buy_price) for s in slots[:n24]) / max(1, n24)
|
||||
factor = float(getattr(battery, "planner_terminal_soc_value_factor", 1.0) or 1.0)
|
||||
return max(0.0, avg_buy) * factor / 1000.0
|
||||
|
||||
|
||||
def _arb_floor_wh(battery: Any) -> float:
|
||||
"""Podlaha SoC pro export z baterie (pravidlo 19): ekonomická rezerva z DB."""
|
||||
floor = getattr(battery, "arb_floor_wh", None)
|
||||
if floor is None:
|
||||
floor = getattr(battery, "reserve_soc_wh", None)
|
||||
return max(float(floor or 0.0), float(battery.min_soc_wh))
|
||||
|
||||
|
||||
def solve_dispatch_v2(
|
||||
slots: list[PlanningSlot],
|
||||
battery: Any,
|
||||
heat_pump: Any,
|
||||
grid: Any,
|
||||
ev_sessions: list,
|
||||
vehicles: list,
|
||||
current_soc_wh: float,
|
||||
current_tuv_temp_c: float,
|
||||
*,
|
||||
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
|
||||
operating_mode: str = "AUTO",
|
||||
planner_version: str | None = None,
|
||||
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
|
||||
"""Čistý ekonomický MILP; rozhraní kompatibilní se solve_dispatch (v1)."""
|
||||
if not slots:
|
||||
raise RuntimeError("solve_dispatch_v2 requires at least one slot")
|
||||
t0 = time.monotonic()
|
||||
T = len(slots)
|
||||
om = (operating_mode or "AUTO").upper()
|
||||
EV = min(len(vehicles), 2)
|
||||
|
||||
max_imp = float(grid.max_import_power_w)
|
||||
max_exp = float(grid.max_export_power_w)
|
||||
max_chg = float(battery.max_charge_power_w)
|
||||
max_dis = float(battery.max_discharge_power_w)
|
||||
eff_c = float(battery.charge_efficiency)
|
||||
eff_d = float(battery.discharge_efficiency)
|
||||
deg = float(battery.degradation_cost_czk_kwh)
|
||||
soc_min = float(battery.min_soc_wh)
|
||||
soc_max = float(battery.soc_max_wh)
|
||||
usable = float(battery.usable_capacity_wh)
|
||||
arb_floor = _arb_floor_wh(battery)
|
||||
terminal = _terminal_value_czk_per_wh(slots, battery)
|
||||
block_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
|
||||
gen_cutoff_avail = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False))
|
||||
soc0 = min(max(float(current_soc_wh), soc_min), soc_max)
|
||||
|
||||
prob = pulp.LpProblem("dispatch_v2", pulp.LpMinimize)
|
||||
|
||||
gi = [pulp.LpVariable(f"gi_{t}", 0, max_imp) for t in range(T)]
|
||||
ge_pv = [pulp.LpVariable(f"gepv_{t}", 0, max_exp) for t in range(T)]
|
||||
ge_bat = [pulp.LpVariable(f"gebat_{t}", 0, max_exp) for t in range(T)]
|
||||
bc_pv = [pulp.LpVariable(f"bcpv_{t}", 0, max_chg) for t in range(T)]
|
||||
bc_gi = [pulp.LpVariable(f"bcgi_{t}", 0, max_chg) for t in range(T)]
|
||||
bd = [pulp.LpVariable(f"bd_{t}", 0, max_dis) for t in range(T)]
|
||||
ca = [pulp.LpVariable(f"ca_{t}", 0, max(0, int(slots[t].pv_a_forecast_w))) for t in range(T)]
|
||||
soc = [pulp.LpVariable(f"soc_{t}", soc_min, soc_max) for t in range(T)]
|
||||
hp = [pulp.LpVariable(f"hp_{t}", 0, float(heat_pump.rated_heating_power_w)) for t in range(T)]
|
||||
y_imp = [pulp.LpVariable(f"yimp_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
z_exp = [pulp.LpVariable(f"zexp_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
z_gen = (
|
||||
[pulp.LpVariable(f"zgen_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
if gen_cutoff_avail
|
||||
else None
|
||||
)
|
||||
ev_direct = [
|
||||
[
|
||||
pulp.LpVariable(f"evd_{e}_{t}", 0, min(float(vehicles[e].max_charge_power_w), max_imp))
|
||||
for t in range(T)
|
||||
]
|
||||
for e in range(EV)
|
||||
]
|
||||
ev_via_bat = [
|
||||
[
|
||||
pulp.LpVariable(f"evb_{e}_{t}", 0, float(vehicles[e].max_charge_power_w))
|
||||
for t in range(T)
|
||||
]
|
||||
for e in range(EV)
|
||||
]
|
||||
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
|
||||
|
||||
def _connected(e: int, t: int) -> bool:
|
||||
return bool(slots[t].ev1_connected if e == 0 else slots[t].ev2_connected)
|
||||
|
||||
for t in range(T):
|
||||
s = slots[t]
|
||||
pv_a = max(0.0, float(s.pv_a_forecast_w))
|
||||
pv_b = max(0.0, float(s.pv_b_forecast_w))
|
||||
pv_a_net = pv_a - ca[t]
|
||||
pv_b_eff = pv_b - (pv_b * z_gen[t] if z_gen is not None else 0.0)
|
||||
|
||||
ev_total_t = pulp.lpSum(
|
||||
ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV)
|
||||
)
|
||||
load_site = float(s.load_baseline_w) + ev_total_t + hp[t]
|
||||
|
||||
# bilance sběrnice (W)
|
||||
prob += (
|
||||
pv_a_net + pv_b_eff + gi[t] + bd[t]
|
||||
== load_site + bc_pv[t] + bc_gi[t] + ge_pv[t] + ge_bat[t]
|
||||
), f"balance_{t}"
|
||||
|
||||
# SoC dynamika (Wh)
|
||||
prev = soc0 if t == 0 else soc[t - 1]
|
||||
prob += (
|
||||
soc[t]
|
||||
== prev
|
||||
+ (bc_pv[t] + bc_gi[t]) * eff_c * INTERVAL_H
|
||||
- bd[t] / eff_d * INTERVAL_H
|
||||
), f"soc_{t}"
|
||||
|
||||
# výkonové stropy
|
||||
prob += bc_pv[t] + bc_gi[t] <= max_chg, f"chg_cap_{t}"
|
||||
prob += ge_pv[t] + ge_bat[t] <= max_exp, f"exp_cap_{t}"
|
||||
|
||||
# PV cesty omezené dostupnou výrobou (load-first vynucuje HW; bilance účtuje energii)
|
||||
prob += bc_pv[t] + ge_pv[t] <= pv_a_net + pv_b_eff, f"pv_src_{t}"
|
||||
# bc_gi jen ze sítě:
|
||||
prob += bc_gi[t] <= gi[t], f"bcgi_src_{t}"
|
||||
# vybíjení kryje dům + EV-via-bat + export z baterie
|
||||
prob += ge_bat[t] + pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t], f"bd_split_{t}"
|
||||
|
||||
# zákaz současného importu a exportu
|
||||
prob += gi[t] <= max_imp * y_imp[t], f"imp_excl_{t}"
|
||||
prob += ge_pv[t] + ge_bat[t] <= max_exp * (1 - y_imp[t]), f"exp_excl_{t}"
|
||||
|
||||
# pravidlo 19: export z baterie ⇒ SoC ≥ arb floor
|
||||
prob += ge_bat[t] <= max_exp * z_exp[t], f"zexp_link_{t}"
|
||||
prob += soc[t] >= arb_floor - (soc_max - soc_min) * (1 - z_exp[t]), f"zexp_floor_{t}"
|
||||
|
||||
# tvrdá cenová pravidla
|
||||
if float(s.buy_price) < 0.0:
|
||||
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}"
|
||||
if float(s.sell_price) < 0.0 and block_neg_sell:
|
||||
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_sell_block_{t}"
|
||||
|
||||
# EV dostupnost
|
||||
for e in range(EV):
|
||||
if not _connected(e, t):
|
||||
prob += ev_direct[e][t] == 0
|
||||
prob += ev_via_bat[e][t] == 0
|
||||
else:
|
||||
prob += ev_direct[e][t] + ev_via_bat[e][t] <= float(
|
||||
vehicles[e].max_charge_power_w
|
||||
)
|
||||
|
||||
# provozní režimy (tvrdé constraints dle operating-modes.md)
|
||||
if om == "SELF_SUSTAIN":
|
||||
prob += gi[t] <= float(s.load_baseline_w), f"ss_gi_{t}"
|
||||
elif om == "PRESERVE":
|
||||
prob += bc_pv[t] == 0
|
||||
prob += bc_gi[t] == 0
|
||||
prob += bd[t] == 0
|
||||
elif om == "CHARGE_CHEAP":
|
||||
prob += ge_pv[t] + ge_bat[t] == 0
|
||||
prob += bd[t] == 0
|
||||
|
||||
# EV deadline (s placeným slackem místo infeasibility)
|
||||
for e in range(EV):
|
||||
sess = ev_sessions[e] if e < len(ev_sessions) else None
|
||||
if sess is None or not getattr(sess, "energy_needed_wh", 0):
|
||||
continue
|
||||
t_dl = next(
|
||||
(t for t in range(T) if slots[t].interval_start >= sess.target_deadline),
|
||||
T - 1,
|
||||
)
|
||||
unmet = pulp.LpVariable(f"ev_unmet_{e}", 0, float(sess.energy_needed_wh))
|
||||
ev_unmet.append(unmet)
|
||||
prob += (
|
||||
pulp.lpSum(
|
||||
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
|
||||
for t in range(t_dl + 1)
|
||||
if _connected(e, t)
|
||||
)
|
||||
+ unmet
|
||||
>= float(sess.energy_needed_wh)
|
||||
), f"ev_deadline_{e}"
|
||||
|
||||
# TUV look-ahead (převzato z v1 — komfortní constraint, ne heuristika)
|
||||
rated_hp = float(heat_pump.rated_heating_power_w)
|
||||
if tuv_delta_stats and rated_hp > 0 and getattr(heat_pump, "tuv_min_temp_c", None):
|
||||
tuv_pred = float(current_tuv_temp_c)
|
||||
tgt = float(getattr(heat_pump, "tuv_target_temp_c", 55.0) or 55.0)
|
||||
thr = float(heat_pump.tuv_min_temp_c) + 5.0
|
||||
for t in range(T):
|
||||
dow, hour = _prague_dow_hour(slots[t].interval_start)
|
||||
delta = tuv_delta_stats.get((dow, hour), -0.1)
|
||||
tuv_pred += float(delta) * INTERVAL_H
|
||||
if tuv_pred < thr:
|
||||
prob += (
|
||||
pulp.lpSum(hp[s_] for s_ in range(max(0, t - 8), t + 1))
|
||||
>= rated_hp * 0.5
|
||||
), f"tuv_heat_{t}"
|
||||
tuv_pred = tgt
|
||||
if float(current_tuv_temp_c) < float(heat_pump.tuv_min_temp_c):
|
||||
prob += hp[0] >= rated_hp * 0.8, "tuv_emergency"
|
||||
|
||||
# ---------------- objective: jen reálné peníze ----------------
|
||||
wh = INTERVAL_H / 1000.0 # W → kWh za slot
|
||||
cash = pulp.lpSum(
|
||||
gi[t] * float(slots[t].buy_price) * wh
|
||||
- (ge_pv[t] + ge_bat[t]) * float(slots[t].sell_price) * wh
|
||||
for t in range(T)
|
||||
)
|
||||
degradation = pulp.lpSum(
|
||||
0.5 * (bc_pv[t] + bc_gi[t] + bd[t]) * deg * wh for t in range(T)
|
||||
)
|
||||
extras = pulp.lpSum(ca[t] * V2_CURTAIL_TIEBREAK_CZK_KWH * wh for t in range(T))
|
||||
if z_gen is not None:
|
||||
extras += pulp.lpSum(
|
||||
max(0.0, float(slots[t].pv_b_forecast_w)) * z_gen[t] * V2_GEN_CUTOFF_CZK_KWH * wh
|
||||
for t in range(T)
|
||||
)
|
||||
if om == "SELF_SUSTAIN":
|
||||
extras += pulp.lpSum(
|
||||
(ge_pv[t] + ge_bat[t]) * V2_SELF_SUSTAIN_EXPORT_CZK_KWH * wh for t in range(T)
|
||||
)
|
||||
if ev_unmet:
|
||||
extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in ev_unmet)
|
||||
|
||||
prob += cash + degradation + extras - terminal * soc[T - 1]
|
||||
|
||||
solver = (
|
||||
pulp.HiGHS_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
|
||||
if pulp.HiGHS_CMD().available()
|
||||
else pulp.PULP_CBC_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
|
||||
)
|
||||
status = prob.solve(solver)
|
||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||
status_str = pulp.LpStatus[status]
|
||||
if status_str != "Optimal":
|
||||
# v2 nemá relax řetězec — model je navržen tak, aby byl feasible
|
||||
# (placené slacky místo tvrdých kotev). Ne-Optimal je skutečná chyba.
|
||||
raise RuntimeError(f"solver_v2: {status_str}")
|
||||
|
||||
# ---------------- DispatchResult assembly (parita s v1) ----------------
|
||||
def _val(var) -> float:
|
||||
v = pulp.value(var)
|
||||
return float(v) if v is not None else 0.0
|
||||
|
||||
results: list[DispatchResult] = []
|
||||
for t in range(T):
|
||||
s = slots[t]
|
||||
bc_tot = _val(bc_pv[t]) + _val(bc_gi[t])
|
||||
bd_v = _val(bd[t])
|
||||
batt_w = round(bc_tot - bd_v)
|
||||
ge_pv_w = round(_val(ge_pv[t]))
|
||||
ge_bat_w = round(_val(ge_bat[t]))
|
||||
gi_w = _val(gi[t])
|
||||
ge_w = float(ge_pv_w + ge_bat_w)
|
||||
grid_w, export_mode = _dispatch_grid_setpoint_w(
|
||||
gi_w=gi_w,
|
||||
ge_w=ge_w,
|
||||
ge_bat_w=float(ge_bat_w),
|
||||
ge_pv_w=float(ge_pv_w),
|
||||
max_export_power_w=int(max_exp),
|
||||
)
|
||||
if batt_w < 0 and grid_w < 0:
|
||||
deye_mode = "SELL"
|
||||
elif batt_w > 0 and grid_w > 0:
|
||||
deye_mode = "CHARGE"
|
||||
else:
|
||||
deye_mode = "PASSIVE"
|
||||
gen_cut = bool(round(_val(z_gen[t]))) if z_gen is not None else None
|
||||
hp_v = _val(hp[t])
|
||||
hp_on = hp_v > rated_hp * 0.5 if rated_hp > 0 else False
|
||||
cash_t = gi_w * float(s.buy_price) * wh - ge_w * float(s.sell_price) * wh
|
||||
pen_t = 0.0
|
||||
if gen_cut:
|
||||
pen_t += max(0.0, float(s.pv_b_forecast_w)) * V2_GEN_CUTOFF_CZK_KWH * wh
|
||||
results.append(
|
||||
DispatchResult(
|
||||
interval_start=s.interval_start,
|
||||
battery_setpoint_w=batt_w,
|
||||
battery_soc_target=round(_val(soc[t]) / usable * 100.0, 2),
|
||||
grid_setpoint_w=grid_w,
|
||||
export_limit_w=int(max_exp) if grid_w < 0 else 0,
|
||||
export_mode=export_mode,
|
||||
deye_physical_mode=deye_mode,
|
||||
deye_gen_cutoff_enabled=gen_cut,
|
||||
ev1_setpoint_w=(
|
||||
round(_val(ev_direct[0][t]) + _val(ev_via_bat[0][t]))
|
||||
if EV > 0 and s.ev1_connected
|
||||
else None
|
||||
),
|
||||
ev2_setpoint_w=(
|
||||
round(_val(ev_direct[1][t]) + _val(ev_via_bat[1][t]))
|
||||
if EV > 1 and s.ev2_connected
|
||||
else None
|
||||
),
|
||||
ev1_via_bat_w=round(_val(ev_via_bat[0][t])) if EV > 0 else 0,
|
||||
ev2_via_bat_w=round(_val(ev_via_bat[1][t])) if EV > 1 else 0,
|
||||
heat_pump_enabled=hp_on,
|
||||
heat_pump_setpoint_w=int(rated_hp) if hp_on else 0,
|
||||
pv_a_curtailed_w=round(_val(ca[t])),
|
||||
expected_cost_czk=round(cash_t, 4),
|
||||
effective_buy_price=float(s.buy_price),
|
||||
effective_sell_price=float(s.sell_price),
|
||||
is_predicted_price=bool(s.is_predicted_price),
|
||||
cashflow_czk=round(cash_t, 4),
|
||||
battery_arbitrage_czk=0.0,
|
||||
penalty_czk=round(pen_t, 4),
|
||||
green_bonus_czk=float(getattr(s, "green_bonus_czk_per_slot", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
snapshot: dict[str, Any] = {
|
||||
"version": planner_version or "v2-clean",
|
||||
"planner_build_tag": V2_BUILD_TAG,
|
||||
"inputs": {
|
||||
"operating_mode": om,
|
||||
"current_soc_wh": soc0,
|
||||
"terminal_czk_per_wh": round(terminal, 8),
|
||||
"arb_floor_wh": arb_floor,
|
||||
"block_export_on_negative_sell": block_neg_sell,
|
||||
"gen_cutoff_available": gen_cutoff_avail,
|
||||
"slot_count": T,
|
||||
"ev_sessions": sum(1 for x in ev_sessions if x is not None),
|
||||
"masks_ignored": True,
|
||||
},
|
||||
"objective_terms": {
|
||||
"cash_czk": round(float(pulp.value(cash)), 3),
|
||||
"degradation_czk": round(float(pulp.value(degradation)), 3),
|
||||
"extras_czk": round(float(pulp.value(extras)), 3) if not isinstance(extras, float) else 0.0,
|
||||
"terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
|
||||
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet],
|
||||
},
|
||||
"solver_duration_ms": duration_ms,
|
||||
"solver_status": status_str,
|
||||
}
|
||||
return results, duration_ms, snapshot
|
||||
140
backend/services/planning/types.py
Normal file
140
backend/services/planning/types.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# backend/services/planning/types.py
|
||||
#
|
||||
# EMS plánovač – datové typy a čisté časové utility
|
||||
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.planning.constants import _PRAGUE_TZ
|
||||
|
||||
|
||||
class PlannerSolverError(RuntimeError):
|
||||
"""Solver selhal po vyčerpání retry řetězce (typicky Infeasible)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
solver_status: str,
|
||||
*,
|
||||
relax_chain: list[str] | None = None,
|
||||
) -> None:
|
||||
self.solver_status = solver_status
|
||||
self.relax_chain = list(relax_chain or [])
|
||||
super().__init__(f"Solver: {solver_status}")
|
||||
|
||||
def _timestamptz_from_db(val: object) -> Optional[datetime]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
|
||||
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||
|
||||
def _slot_float_nullable(d: dict[str, Any], key: str) -> float | None:
|
||||
v = d.get(key)
|
||||
if v is None:
|
||||
return None
|
||||
return float(v)
|
||||
|
||||
def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
|
||||
"""DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So."""
|
||||
dt = interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
loc = dt.astimezone(_PRAGUE_TZ)
|
||||
return (loc.weekday() + 1) % 7, loc.hour
|
||||
|
||||
@dataclass
|
||||
class PlanningSlot:
|
||||
interval_start: datetime
|
||||
buy_price: float # Kč/kWh
|
||||
sell_price: float # Kč/kWh
|
||||
pv_a_forecast_w: int # W – pole A (řiditelné)
|
||||
pv_b_forecast_w: int # W – pole B (zelený bonus, pevné)
|
||||
load_baseline_w: int # W – predikce bazální spotřeby
|
||||
ev1_connected: bool
|
||||
ev2_connected: bool
|
||||
is_predicted_price: bool = False
|
||||
allow_charge: bool = True
|
||||
allow_discharge_export: bool = True
|
||||
#: Měkké LP vstupy z `ems.fn_load_planning_slots_full` (mimo masky allow_*).
|
||||
night_baseload_target_wh: float | None = None
|
||||
night_baseload_buffer_wh: float | None = None
|
||||
safety_soc_target_wh: float | None = None
|
||||
future_avoided_buy_czk_kwh: float | None = None
|
||||
future_sell_opportunity_czk_kwh: float | None = None
|
||||
is_daytime_pv_surplus_slot: bool = False
|
||||
#: Vážená nákupní / opportunity cena zásoby před prvním exportním oknem (SQL odhad z masek).
|
||||
charge_acquisition_buy_czk_kwh: float | None = None
|
||||
charge_acquisition_cutoff_at: datetime | None = None
|
||||
min_buy_before_cutoff_czk_kwh: float | None = None
|
||||
pv_charge_wh_ahead: float | None = None
|
||||
neg_buy_wh_ahead: float | None = None
|
||||
grid_charge_suppressed_reason: str | None = None
|
||||
charge_target_wh: float | None = None
|
||||
pre_window_wh: float | None = None
|
||||
in_window_wh: float | None = None
|
||||
charge_slot_wh: float | None = None
|
||||
charge_cum_wh: float | None = None
|
||||
charge_layer: str | None = None
|
||||
charge_slot_reason: str | None = None
|
||||
#: Pomocny atribut pro green_bonus v planning_interval (Kc/slot); lite default 0.
|
||||
green_bonus_czk_per_slot: float = 0.0
|
||||
|
||||
SOC_MIN_RELAX_LOOKAHEAD_SLOTS = 144
|
||||
|
||||
@dataclass
|
||||
class DispatchResult:
|
||||
interval_start: datetime
|
||||
battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení
|
||||
battery_soc_target: float # % SoC na konci intervalu
|
||||
grid_setpoint_w: int # kladné = import, záporné = export
|
||||
export_limit_w: int # tvrdý limit exportu do sítě; 0 = bez exportu
|
||||
export_mode: str # NONE / PV_SURPLUS / BATTERY_SELL
|
||||
#: Explicitní fyzický režim Deye pro control exporter (PASSIVE / SELL / CHARGE).
|
||||
#: Cíl: odstranit heuristiky z exporteru a nést záměr přímo v plánu.
|
||||
deye_physical_mode: str
|
||||
#: True = v daném slotu odpojit GEN port (MI export cutoff) přes reg 178 bits0–1 (0-based; v UI často jako "register 179").
|
||||
#: None = lokalita tuto funkci nemá / nepoužívá.
|
||||
deye_gen_cutoff_enabled: bool | None
|
||||
ev1_setpoint_w: Optional[int]
|
||||
ev2_setpoint_w: Optional[int]
|
||||
ev1_via_bat_w: int
|
||||
ev2_via_bat_w: int
|
||||
heat_pump_enabled: bool
|
||||
heat_pump_setpoint_w: int
|
||||
pv_a_curtailed_w: int
|
||||
expected_cost_czk: float
|
||||
effective_buy_price: float
|
||||
effective_sell_price: float
|
||||
is_predicted_price: bool # shodné s PlanningSlot (chybí OTE v efektivní ceně → fn_get_predicted_price)
|
||||
cashflow_czk: float
|
||||
battery_arbitrage_czk: float
|
||||
penalty_czk: float
|
||||
green_bonus_czk: float
|
||||
|
||||
def _prague_calendar_date(slot: PlanningSlot):
|
||||
dt = slot.interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(ZoneInfo("Europe/Prague")).date()
|
||||
|
||||
def _prague_hour(slot: PlanningSlot) -> int:
|
||||
dt = slot.interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(ZoneInfo("Europe/Prague")).hour
|
||||
|
||||
def _parse_json_dt(val: object) -> Optional[datetime]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
|
||||
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||
|
||||
def _current_slot_start(dt: datetime) -> datetime:
|
||||
"""Zaokrouhlí čas dolů na začátek aktuálního 15min slotu."""
|
||||
minute = (dt.minute // 15) * 15
|
||||
return dt.replace(minute=minute, second=0, microsecond=0)
|
||||
File diff suppressed because it is too large
Load Diff
4933
backend/tests/golden/fixtures/BA81_2026-06-09_normal.json
Normal file
4933
backend/tests/golden/fixtures/BA81_2026-06-09_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
5662
backend/tests/golden/fixtures/KV1_2026-06-09_fixed_normal.json
Normal file
5662
backend/tests/golden/fixtures/KV1_2026-06-09_fixed_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
5673
backend/tests/golden/fixtures/home-01_2026-05-25_evening_push.json
Normal file
5673
backend/tests/golden/fixtures/home-01_2026-05-25_evening_push.json
Normal file
File diff suppressed because it is too large
Load Diff
5673
backend/tests/golden/fixtures/home-01_2026-06-07_neg_sell_deep.json
Normal file
5673
backend/tests/golden/fixtures/home-01_2026-06-07_neg_sell_deep.json
Normal file
File diff suppressed because it is too large
Load Diff
5673
backend/tests/golden/fixtures/home-01_2026-06-09_normal.json
Normal file
5673
backend/tests/golden/fixtures/home-01_2026-06-09_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
3181
backend/tests/golden/snapshots/BA81_2026-06-09_normal.json
Normal file
3181
backend/tests/golden/snapshots/BA81_2026-06-09_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
3181
backend/tests/golden/snapshots/KV1_2026-06-09_fixed_normal.json
Normal file
3181
backend/tests/golden/snapshots/KV1_2026-06-09_fixed_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"solver_error": "Infeasible",
|
||||
"relax_chain": [
|
||||
"strict",
|
||||
"relaxed_expensive_import",
|
||||
"relaxed_neg_buy_charge",
|
||||
"relaxed_neg_prep_hold_only",
|
||||
"relaxed_neg_prep_window",
|
||||
"neg_sell_phases_fallback",
|
||||
"relaxed_pos_sell_ge_block",
|
||||
"relaxed_solver_masks"
|
||||
]
|
||||
}
|
||||
3181
backend/tests/golden/snapshots/home-01_2026-05-25_evening_push.json
Normal file
3181
backend/tests/golden/snapshots/home-01_2026-05-25_evening_push.json
Normal file
File diff suppressed because it is too large
Load Diff
3181
backend/tests/golden/snapshots/home-01_2026-06-07_neg_sell_deep.json
Normal file
3181
backend/tests/golden/snapshots/home-01_2026-06-07_neg_sell_deep.json
Normal file
File diff suppressed because it is too large
Load Diff
3181
backend/tests/golden/snapshots/home-01_2026-06-09_normal.json
Normal file
3181
backend/tests/golden/snapshots/home-01_2026-06-09_normal.json
Normal file
File diff suppressed because it is too large
Load Diff
101
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
101
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""PASSIVE + PV_SURPLUS: 108=0 (nepoužívat baterii), 109=max; 142 zůstává zero-export (1/2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.control.setpoints import deye_battery_charge_discharge_amps
|
||||
|
||||
|
||||
class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
|
||||
def test_pv_surplus_export_zeros_charge_amps(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=-177,
|
||||
grid_w=-2851,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=90,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 90)
|
||||
|
||||
def test_pv_surplus_even_if_lp_shows_positive_battery_w(self) -> None:
|
||||
"""Plán může mít kladný battery_w; exportní záměr je PV_SURPLUS → 108=0."""
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=5000,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_passive_charge_without_export_mode_uses_max_108(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=5000,
|
||||
grid_w=0,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="NONE",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 100)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_legacy_negative_grid_infers_pv_surplus(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=0,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode=None,
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_charge_mode_still_scales_108_from_battery_w(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="CHARGE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=2000,
|
||||
grid_w=3000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
)
|
||||
self.assertLess(ch, 100)
|
||||
self.assertGreater(ch, 0)
|
||||
self.assertEqual(dis, 0)
|
||||
|
||||
def test_sell_skips_charge_amp_write(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="SELL",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=-3000,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=80,
|
||||
)
|
||||
self.assertIsNone(ch)
|
||||
self.assertEqual(dis, 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
107
backend/tests/test_control_export_plan_guard.py
Normal file
107
backend/tests/test_control_export_plan_guard.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Exekuční pojistka exportu podle plánu (Plan 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.control.exporter_monolith import (
|
||||
ControlSetpoints,
|
||||
_apply_export_plan_guard,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.control.models import OperatingModeInfo
|
||||
from services.control.setpoints import _DictRecord
|
||||
|
||||
|
||||
def _auto_mode() -> OperatingModeInfo:
|
||||
return OperatingModeInfo(
|
||||
mode_code="AUTO",
|
||||
battery_mode="AUTO",
|
||||
grid_mode="AUTO",
|
||||
ev_enabled=True,
|
||||
heat_pump_enabled_def=True,
|
||||
loxone_mode_value=0,
|
||||
)
|
||||
|
||||
|
||||
def _sp(**kwargs: object) -> ControlSetpoints:
|
||||
base = dict(
|
||||
battery_w=0,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
deye_physical_mode="SELL",
|
||||
export_mode="BATTERY_SELL",
|
||||
export_ban=False,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return ControlSetpoints(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class ExportPlanGuardTests(unittest.TestCase):
|
||||
def test_neg_sell_forces_passive_no_export(self) -> None:
|
||||
sp = _sp()
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": -8000,
|
||||
"effective_sell_price": -0.5,
|
||||
"export_mode": "NONE",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertEqual(get_deye_mode(out), "PASSIVE")
|
||||
self.assertTrue(out.export_ban)
|
||||
self.assertEqual(out.grid_export_limit, 0)
|
||||
self.assertGreaterEqual(out.grid_setpoint_w, 0)
|
||||
self.assertEqual(out.export_mode, "NONE")
|
||||
self.assertTrue(out.deye_gen_cutoff_enabled)
|
||||
|
||||
def test_export_mode_none_with_non_negative_grid(self) -> None:
|
||||
sp = _sp(grid_setpoint_w=0, battery_w=-5000, export_mode="BATTERY_SELL")
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": 0,
|
||||
"effective_sell_price": 2.5,
|
||||
"export_mode": "NONE",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertEqual(get_deye_mode(out), "PASSIVE")
|
||||
self.assertEqual(out.battery_w, 0)
|
||||
self.assertTrue(out.export_ban)
|
||||
|
||||
def test_profitable_export_unchanged(self) -> None:
|
||||
sp = _sp()
|
||||
pi = _DictRecord(
|
||||
{
|
||||
"grid_setpoint_w": -8000,
|
||||
"effective_sell_price": 9.5,
|
||||
"export_mode": "BATTERY_SELL",
|
||||
}
|
||||
)
|
||||
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
||||
self.assertIs(out, sp)
|
||||
self.assertEqual(get_deye_mode(out), "SELL")
|
||||
|
||||
def test_non_auto_mode_skipped(self) -> None:
|
||||
sp = _sp()
|
||||
pi = _DictRecord({"effective_sell_price": -1.0, "export_mode": "NONE"})
|
||||
mode = OperatingModeInfo(
|
||||
mode_code="SELF_SUSTAIN",
|
||||
battery_mode="PASSIVE",
|
||||
grid_mode="PASSIVE",
|
||||
ev_enabled=False,
|
||||
heat_pump_enabled_def=False,
|
||||
loxone_mode_value=1,
|
||||
)
|
||||
out = _apply_export_plan_guard(1, mode, pi, sp)
|
||||
self.assertIs(out, sp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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,12 +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
|
||||
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:
|
||||
@@ -112,6 +115,36 @@ 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",
|
||||
@@ -273,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:
|
||||
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
27
backend/tests/test_planning_economics_columns.py
Normal file
27
backend/tests/test_planning_economics_columns.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""DispatchResult: nove ekonomicke sloupce (cashflow/arbitraz/penalty/bonus)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import fields
|
||||
|
||||
from services.planning_engine import DispatchResult
|
||||
|
||||
|
||||
class DispatchResultEconomicsFieldsTests(unittest.TestCase):
|
||||
def test_has_new_economics_fields(self) -> None:
|
||||
names = {f.name for f in fields(DispatchResult)}
|
||||
for required in (
|
||||
"cashflow_czk",
|
||||
"battery_arbitrage_czk",
|
||||
"penalty_czk",
|
||||
"green_bonus_czk",
|
||||
):
|
||||
self.assertIn(required, names, f"DispatchResult missing field {required}")
|
||||
|
||||
def test_legacy_expected_cost_czk_kept(self) -> None:
|
||||
names = {f.name for f in fields(DispatchResult)}
|
||||
self.assertIn("expected_cost_czk", names)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
286
backend/tests/test_planning_safety_commitment.py
Normal file
286
backend/tests/test_planning_safety_commitment.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Měkké safety SoC a rolling charge commitment v solve_dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.planning_engine import PlanningSlot, solve_dispatch
|
||||
|
||||
|
||||
def _bat(**kwargs: object) -> SimpleNamespace:
|
||||
base = dict(
|
||||
usable_capacity_wh=20_000.0,
|
||||
min_soc_wh=2000.0,
|
||||
arb_floor_wh=4000.0,
|
||||
reserve_soc_wh=4000.0,
|
||||
soc_max_wh=19_000.0,
|
||||
charge_efficiency=0.95,
|
||||
discharge_efficiency=0.95,
|
||||
degradation_cost_czk_kwh=0.1,
|
||||
max_charge_power_w=5000,
|
||||
max_discharge_power_w=5000,
|
||||
planner_terminal_soc_value_factor=0.2,
|
||||
planner_extreme_buy_threshold_czk_kwh=-5.0,
|
||||
planner_discharge_floor_percent=None,
|
||||
planner_discharge_relax_prewindow_slots=8,
|
||||
planner_daytime_charge_target_enabled=True,
|
||||
planner_charge_commitment_penalty_czk_kwh=0.5,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _grid() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
max_import_power_w=11_000,
|
||||
max_export_power_w=11_000,
|
||||
block_export_on_negative_sell=False,
|
||||
deye_gen_microinverter_cutoff_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def _hp() -> SimpleNamespace:
|
||||
return SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
|
||||
|
||||
def _slot(
|
||||
t0: datetime,
|
||||
idx: int,
|
||||
*,
|
||||
buy: float = 3.0,
|
||||
sell: float = 2.5,
|
||||
pv_a: int = 0,
|
||||
load: int = 1500,
|
||||
safety: float | None = None,
|
||||
fut_buy: float | None = None,
|
||||
fut_sell: float | None = None,
|
||||
daytime_pv_surplus: bool = False,
|
||||
) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=t0 + timedelta(minutes=15 * idx),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=pv_a,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=load,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
safety_soc_target_wh=safety,
|
||||
future_avoided_buy_czk_kwh=fut_buy,
|
||||
future_sell_opportunity_czk_kwh=fut_sell,
|
||||
is_daytime_pv_surplus_slot=daytime_pv_surplus,
|
||||
)
|
||||
|
||||
|
||||
class PlanningSafetyCommitmentTests(unittest.TestCase):
|
||||
def test_solver_snapshot_has_version_and_masks(self) -> None:
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
slots = [_slot(t0, i, buy=2.0, sell=2.0, pv_a=6000, load=1500) for i in range(8)]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=5000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(res), 8)
|
||||
self.assertEqual(snap.get("version"), 1)
|
||||
self.assertIn("masks", snap)
|
||||
self.assertEqual(len(snap["masks"]), 8)
|
||||
|
||||
def test_charge_commitment_snapshot_populated(self) -> None:
|
||||
"""Rolling kotva: při předchozím nabíjení z PV se do snapshotu zapíše commitment."""
|
||||
t0 = datetime(2026, 5, 4, 10, 0, tzinfo=timezone.utc)
|
||||
slots = [_slot(t0, i, buy=1.5, sell=1.2, pv_a=8000, load=1000) for i in range(12)]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
prev = [None] * 12
|
||||
prev[0] = 4000.0
|
||||
_res1, _, snap1 = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=4000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
charge_commitment_prev_w=prev,
|
||||
)
|
||||
self.assertTrue(snap1["chosen_slots"]["charge_commitment"])
|
||||
_res2, _, snap2 = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=4000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
charge_commitment_prev_w=None,
|
||||
)
|
||||
self.assertEqual(snap2["chosen_slots"]["charge_commitment"], [])
|
||||
|
||||
def test_export_floor_uses_safety_target_in_non_high_sell_slot(self) -> None:
|
||||
"""Regrese: safety target nemá tlačit jen přes objective, ale chránit export floor."""
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
# Slot 0 není high-sell (future max sell je vyšší), ale safety target je nad arb_base.
|
||||
slots = [
|
||||
_slot(
|
||||
t0,
|
||||
0,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=8000,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0, # high-sell somewhere later, not this slot
|
||||
daytime_pv_surplus=True,
|
||||
),
|
||||
_slot(
|
||||
t0,
|
||||
1,
|
||||
buy=3.0,
|
||||
sell=6.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
_res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=8000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
b0 = snap["soc_bounds"][0]
|
||||
self.assertEqual(b0["export_floor_reason"], "safety_export_floor")
|
||||
self.assertEqual(float(b0["export_soc_floor_wh"]), 12_000.0)
|
||||
self.assertFalse(bool(b0["high_sell_slot"]))
|
||||
|
||||
def test_export_floor_keeps_arb_base_in_high_sell_slot(self) -> None:
|
||||
"""High-sell výjimka: v peak slotu nesmí safety floor blokovat arbitráž."""
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
# Slot 0 je high-sell (sell == future max), safety target je nad arb_base, ale nemá se aplikovat.
|
||||
slots = [
|
||||
_slot(
|
||||
t0,
|
||||
0,
|
||||
buy=3.0,
|
||||
sell=6.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
_slot(
|
||||
t0,
|
||||
1,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
_res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=8000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
b0 = snap["soc_bounds"][0]
|
||||
self.assertTrue(bool(b0["high_sell_slot"]))
|
||||
self.assertEqual(b0["export_floor_reason"], "arb_base")
|
||||
self.assertEqual(float(b0["export_soc_floor_wh"]), 4000.0)
|
||||
|
||||
def test_safety_penalty_only_active_in_daytime_pv_surplus_slots(self) -> None:
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
_slot(
|
||||
t0,
|
||||
0,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=8000,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=True,
|
||||
),
|
||||
_slot(
|
||||
t0,
|
||||
1,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
_res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=8000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
t0o = snap["objective_terms"][0]
|
||||
t1o = snap["objective_terms"][1]
|
||||
self.assertTrue(bool(t0o["safety_penalty_active"]))
|
||||
self.assertGreater(float(t0o["safety_deficit_penalty_czk_per_wh"]), 0.0)
|
||||
self.assertFalse(bool(t1o["safety_penalty_active"]))
|
||||
self.assertEqual(float(t1o["safety_deficit_penalty_czk_per_wh"]), 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
183
backend/tests/test_solver_v2.py
Normal file
183
backend/tests/test_solver_v2.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""solver_v2 (čisté jádro): tvrdá pravidla, režimy, EV deadline, arbitráž (bez DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.planning.solver_v2 import solve_dispatch_v2
|
||||
from services.planning.types import PlanningSlot
|
||||
|
||||
|
||||
def _slot(
|
||||
base: datetime,
|
||||
i: int,
|
||||
*,
|
||||
buy: float,
|
||||
sell: float,
|
||||
pv_a: int = 0,
|
||||
pv_b: int = 0,
|
||||
load: int = 1000,
|
||||
ev1: bool = False,
|
||||
) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=pv_a,
|
||||
pv_b_forecast_w=pv_b,
|
||||
load_baseline_w=load,
|
||||
ev1_connected=ev1,
|
||||
ev2_connected=False,
|
||||
)
|
||||
|
||||
|
||||
def _battery(uc_wh: float = 20_000.0) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
usable_capacity_wh=uc_wh,
|
||||
min_soc_wh=0.12 * uc_wh,
|
||||
arb_floor_wh=0.20 * uc_wh,
|
||||
reserve_soc_wh=0.20 * uc_wh,
|
||||
soc_max_wh=0.95 * uc_wh,
|
||||
charge_efficiency=0.95,
|
||||
discharge_efficiency=0.95,
|
||||
degradation_cost_czk_kwh=0.5,
|
||||
max_charge_power_w=8000,
|
||||
max_discharge_power_w=8000,
|
||||
planner_terminal_soc_value_factor=0.8,
|
||||
)
|
||||
|
||||
|
||||
def _grid(block_neg: bool = False, gen_cutoff: bool = False) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
max_import_power_w=17_000,
|
||||
max_export_power_w=13_500,
|
||||
block_export_on_negative_sell=block_neg,
|
||||
deye_gen_microinverter_cutoff_enabled=gen_cutoff,
|
||||
)
|
||||
|
||||
|
||||
_HP = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
_VEHICLES = [
|
||||
SimpleNamespace(max_charge_power_w=11_000, battery_capacity_kwh=60.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
_BASE = datetime(2026, 6, 10, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _solve(slots, *, battery=None, grid=None, ev_sessions=(None, None), soc0=None, mode="AUTO"):
|
||||
bat = battery or _battery()
|
||||
return solve_dispatch_v2(
|
||||
slots,
|
||||
bat,
|
||||
_HP,
|
||||
grid or _grid(),
|
||||
list(ev_sessions),
|
||||
_VEHICLES,
|
||||
soc0 if soc0 is not None else 0.5 * bat.usable_capacity_wh,
|
||||
50.0,
|
||||
operating_mode=mode,
|
||||
)
|
||||
|
||||
|
||||
class HardRulesTests(unittest.TestCase):
|
||||
def test_negative_buy_blocks_export(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=-2.0, sell=1.5, pv_a=6000, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots)
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.grid_setpoint_w, 0, "buy<0 → žádný export (pumpa)")
|
||||
|
||||
def test_block_export_on_negative_sell(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-0.5, pv_a=8000, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots, grid=_grid(block_neg=True))
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.grid_setpoint_w, 0, "KV1: sell<0 → ge=0")
|
||||
|
||||
def test_negative_sell_prefers_charge_or_curtail_over_paid_export(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-1.0, pv_a=8000, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots)
|
||||
paid_export = sum(-r.grid_setpoint_w for r in results if r.grid_setpoint_w < 0)
|
||||
self.assertEqual(paid_export, 0, "spot: za export při sell<0 se platí → ekonomika ho vyloučí")
|
||||
|
||||
def test_battery_export_requires_arb_floor(self) -> None:
|
||||
bat = _battery()
|
||||
slots = [_slot(_BASE, i, buy=1.0, sell=8.0, load=500) for i in range(8)]
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.5 * bat.usable_capacity_wh)
|
||||
for r in results:
|
||||
if r.grid_setpoint_w < 0 and r.battery_setpoint_w < 0:
|
||||
self.assertGreaterEqual(
|
||||
r.battery_soc_target / 100.0 * bat.usable_capacity_wh,
|
||||
bat.arb_floor_wh - 1.0,
|
||||
"export z baterie nesmí podlézt arb floor",
|
||||
)
|
||||
|
||||
def test_curtailment_only_pv_a(self) -> None:
|
||||
# extrémně záporný sell bez block_export: pole B nelze omezit, A ano
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-3.0, pv_a=5000, pv_b=4000, load=300) for i in range(8)]
|
||||
bat = _battery(uc_wh=2000.0) # malá baterie, ať se přebytek nevejde
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.9 * 2000.0)
|
||||
self.assertTrue(any(r.pv_a_curtailed_w > 0 for r in results), "A se curtailuje")
|
||||
for r in results:
|
||||
self.assertLessEqual(r.pv_a_curtailed_w, 5000, "curtail max = výroba A")
|
||||
|
||||
|
||||
class ArbitrageTests(unittest.TestCase):
|
||||
def test_cheap_night_charge_expensive_evening_discharge(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=1.0, sell=0.5, load=1000) for i in range(16)]
|
||||
slots += [_slot(_BASE, 16 + i, buy=8.0, sell=7.0, load=1000) for i in range(16)]
|
||||
results, _, _ = _solve(slots)
|
||||
charged = sum(r.battery_setpoint_w for r in results[:16] if r.battery_setpoint_w > 0)
|
||||
discharged = sum(-r.battery_setpoint_w for r in results[16:] if r.battery_setpoint_w < 0)
|
||||
self.assertGreater(charged, 0, "levná noc → nabíjet")
|
||||
self.assertGreater(discharged, 0, "drahý večer → vybíjet")
|
||||
|
||||
|
||||
class OperatingModeTests(unittest.TestCase):
|
||||
def _slots(self):
|
||||
return [_slot(_BASE, i, buy=1.0, sell=6.0, pv_a=3000, load=1000) for i in range(8)]
|
||||
|
||||
def test_preserve_locks_battery(self) -> None:
|
||||
results, _, _ = _solve(self._slots(), mode="PRESERVE")
|
||||
for r in results:
|
||||
self.assertEqual(r.battery_setpoint_w, 0)
|
||||
|
||||
def test_charge_cheap_no_export_no_discharge(self) -> None:
|
||||
results, _, _ = _solve(self._slots(), mode="CHARGE_CHEAP")
|
||||
for r in results:
|
||||
self.assertGreaterEqual(r.grid_setpoint_w, 0)
|
||||
self.assertGreaterEqual(r.battery_setpoint_w, 0)
|
||||
|
||||
def test_self_sustain_import_capped_to_load(self) -> None:
|
||||
results, _, _ = _solve(self._slots(), mode="SELF_SUSTAIN")
|
||||
for r in results:
|
||||
self.assertLessEqual(r.grid_setpoint_w, 1000, "import ≤ baseline load")
|
||||
|
||||
|
||||
class EvDeadlineTests(unittest.TestCase):
|
||||
def test_ev_energy_delivered_before_deadline(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(hours=4), # slot 16 → vše do konce
|
||||
energy_needed_wh=8000.0,
|
||||
)
|
||||
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||||
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||
self.assertGreaterEqual(delivered, 8000.0 - 1.0)
|
||||
self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0])
|
||||
# levné sloty (0–7) mají dodat většinu energie
|
||||
cheap = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[:8])
|
||||
self.assertGreater(cheap, 4000.0, "EV nabíjí přednostně v levných slotech")
|
||||
|
||||
def test_ev_unreachable_deadline_uses_paid_slack(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=1.0, ev1=(i == 0)) for i in range(8)]
|
||||
session = SimpleNamespace(
|
||||
target_deadline=_BASE + timedelta(minutes=15),
|
||||
energy_needed_wh=50_000.0, # nesplnitelné za 1 slot
|
||||
)
|
||||
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||||
self.assertGreater(snap["objective_terms"]["ev_unmet_wh"][0], 0.0, "slack místo infeasible")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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';
|
||||
90
db/routines/R__018_fn_pv_delta_profile_cache.sql
Normal file
90
db/routines/R__018_fn_pv_delta_profile_cache.sql
Normal file
@@ -0,0 +1,90 @@
|
||||
-- Cache delta profilu PV (těžká agregace forecast_accuracy) — refresh po fn_fill_forecast_accuracy.
|
||||
-- Prefix R__018: musí běžet před R__022 (volá fn_refresh_site_pv_delta_profile_cache).
|
||||
|
||||
create or replace function ems.fn_refresh_site_pv_delta_profile_cache(p_site_id int)
|
||||
returns void
|
||||
language plpgsql
|
||||
as $fn$
|
||||
declare
|
||||
v_profile jsonb;
|
||||
begin
|
||||
v_profile := ems.fn_pv_forecast_delta_profile(
|
||||
p_site_id,
|
||||
now() - interval '120 days',
|
||||
now()
|
||||
);
|
||||
|
||||
update ems.site_pv_forecast_calibration c
|
||||
set
|
||||
delta_profile_cache = v_profile,
|
||||
delta_profile_cached_at = now(),
|
||||
updated_at = now()
|
||||
where c.site_id = p_site_id;
|
||||
|
||||
if not found then
|
||||
insert into ems.site_pv_forecast_calibration (
|
||||
site_id,
|
||||
delta_learn_min_ts,
|
||||
delta_profile_cache,
|
||||
delta_profile_cached_at
|
||||
)
|
||||
values (
|
||||
p_site_id,
|
||||
timestamptz '2026-04-11T22:00:00Z',
|
||||
v_profile,
|
||||
now()
|
||||
);
|
||||
end if;
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_refresh_site_pv_delta_profile_cache(int) is
|
||||
'Přepočte a uloží delta_profile_cache pro site (volá fn_pv_forecast_delta_profile).';
|
||||
|
||||
create or replace function ems.fn_pv_forecast_delta_profile_cached(
|
||||
p_site_id int,
|
||||
p_data_from timestamptz default (now() - interval '120 days'),
|
||||
p_data_to timestamptz default now(),
|
||||
p_half_life_days numeric default 14,
|
||||
p_threshold_w int default 150,
|
||||
p_top_n_days int default 3,
|
||||
p_non_top_day_factor numeric default 0.02,
|
||||
p_day_weight_gamma numeric default 1.0,
|
||||
p_max_age interval default interval '30 minutes'
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
as $fn$
|
||||
declare
|
||||
v_cached jsonb;
|
||||
v_cached_at timestamptz;
|
||||
begin
|
||||
select c.delta_profile_cache, c.delta_profile_cached_at
|
||||
into v_cached, v_cached_at
|
||||
from ems.site_pv_forecast_calibration c
|
||||
where c.site_id = p_site_id;
|
||||
|
||||
if v_cached is not null
|
||||
and v_cached_at is not null
|
||||
and v_cached_at >= now() - p_max_age
|
||||
and p_data_from >= (now() - interval '120 days')
|
||||
and p_data_to <= now() + interval '5 minutes' then
|
||||
return v_cached;
|
||||
end if;
|
||||
|
||||
return ems.fn_pv_forecast_delta_profile(
|
||||
p_site_id,
|
||||
p_data_from,
|
||||
p_data_to,
|
||||
p_half_life_days,
|
||||
p_threshold_w,
|
||||
p_top_n_days,
|
||||
p_non_top_day_factor,
|
||||
p_day_weight_gamma
|
||||
);
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_pv_forecast_delta_profile_cached is
|
||||
'Delta profil z cache (max 30 min) nebo přepočet; pro canonical PV a /plan/current.';
|
||||
@@ -185,6 +185,9 @@ BEGIN
|
||||
learning_exclude_reason = EXCLUDED.learning_exclude_reason;
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
|
||||
perform ems.fn_refresh_site_pv_delta_profile_cache(p_site_id);
|
||||
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$;
|
||||
@@ -194,6 +197,7 @@ COMMENT ON FUNCTION ems.fn_fill_forecast_accuracy(INT, INT) IS
|
||||
learning_eligible / learning_exclude_reason: před delta_learn_min_ts (kalibrace site) se nepočítá do učení delty;
|
||||
po pv_curtailment_policy_effective_from sloty s curtailment / gen cutoff / cutoff_switch_log (export off) mají NULL actual a jsou vyloučeny z učení;
|
||||
telemetrie: is_export_limited nebo pv_derating_flags <> 0 v okně slotu → stejné vyloučení (telemetry_derating).
|
||||
Po úspěšném INSERT volá fn_refresh_site_pv_delta_profile_cache (V079 cache pro /plan/current).
|
||||
Volat každých 15 minut (spolu s audit_filler) pro inkrementální plnění.
|
||||
p_lookback_hours: kolik hodin zpět zpracovat (default 48h pro catch-up).
|
||||
Pro první backfill: SELECT ems.fn_fill_forecast_accuracy(2, 8760) -- 1 rok';
|
||||
|
||||
@@ -17,6 +17,12 @@ declare
|
||||
v_cap numeric;
|
||||
v_cov numeric;
|
||||
v_scarcity numeric;
|
||||
v_horizon_start timestamptz;
|
||||
v_horizon_end timestamptz;
|
||||
v_chart_end timestamptz;
|
||||
v_fc_from timestamptz;
|
||||
v_fc_to timestamptz;
|
||||
v_fc_slots jsonb;
|
||||
begin
|
||||
select to_jsonb(pr)
|
||||
into v_run
|
||||
@@ -31,6 +37,23 @@ begin
|
||||
end if;
|
||||
|
||||
v_run_id := (v_run->>'id')::int;
|
||||
v_horizon_start := (v_run->>'horizon_start')::timestamptz;
|
||||
v_horizon_end := (v_run->>'horizon_end')::timestamptz;
|
||||
v_chart_end := greatest(v_horizon_end, v_horizon_start + interval '96 hours');
|
||||
|
||||
-- Kanonický PV forecast jen za horizontem uloženého plánu (graf až 96 h).
|
||||
if v_horizon_end < v_chart_end then
|
||||
v_fc_from := v_horizon_end;
|
||||
v_fc_to := v_chart_end;
|
||||
v_fc_slots := ems.fn_forecast_pv_slots_range_canonical_ab(
|
||||
p_site_id,
|
||||
v_fc_from,
|
||||
v_fc_to,
|
||||
now()
|
||||
);
|
||||
else
|
||||
v_fc_slots := '[]'::jsonb;
|
||||
end if;
|
||||
|
||||
select coalesce(sum(ab.usable_capacity_wh), 0)::float
|
||||
into v_batt_wh
|
||||
@@ -39,42 +62,94 @@ begin
|
||||
|
||||
with fc_slot as (
|
||||
select
|
||||
u.interval_start,
|
||||
coalesce(sum(u.power_w), 0)::bigint as pv_forecast_total_w
|
||||
from (
|
||||
select distinct on (fpi.interval_start, fpr.pv_array_id)
|
||||
fpi.interval_start,
|
||||
fpi.power_w
|
||||
from ems.forecast_pv_interval fpi
|
||||
join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
|
||||
join ems.asset_pv_array apa
|
||||
on apa.id = fpr.pv_array_id
|
||||
and apa.site_id = fpr.site_id
|
||||
where fpr.site_id = p_site_id
|
||||
and fpr.status = 'ok'
|
||||
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
|
||||
) u
|
||||
group by u.interval_start
|
||||
c.interval_start,
|
||||
(coalesce(c.pv_a_forecast_canonical_w, 0) + coalesce(c.pv_b_forecast_canonical_w, 0))::bigint as pv_forecast_total_w,
|
||||
coalesce(c.pv_a_forecast_canonical_w, 0)::bigint as pv_a_forecast_solver_w,
|
||||
coalesce(c.pv_b_forecast_canonical_w, 0)::bigint as pv_b_forecast_solver_w
|
||||
from jsonb_to_recordset(v_fc_slots) as c(
|
||||
interval_start timestamptz,
|
||||
pv_a_forecast_canonical_w bigint,
|
||||
pv_b_forecast_canonical_w bigint
|
||||
)
|
||||
),
|
||||
joined as (
|
||||
select
|
||||
to_jsonb(pi.*)
|
||||
|| jsonb_build_object(
|
||||
jsonb_build_object(
|
||||
'interval_start', pi.interval_start,
|
||||
'battery_setpoint_w', pi.battery_setpoint_w,
|
||||
'battery_soc_target_pct', pi.battery_soc_target_pct,
|
||||
'grid_setpoint_w', pi.grid_setpoint_w,
|
||||
'export_limit_w', pi.export_limit_w,
|
||||
'export_mode', pi.export_mode,
|
||||
'deye_physical_mode', pi.deye_physical_mode,
|
||||
'deye_gen_cutoff_enabled', pi.deye_gen_cutoff_enabled,
|
||||
'ev1_setpoint_w', pi.ev1_setpoint_w,
|
||||
'ev2_setpoint_w', pi.ev2_setpoint_w,
|
||||
'heat_pump_enabled', pi.heat_pump_enabled,
|
||||
'pv_a_curtailed_w', pi.pv_a_curtailed_w,
|
||||
'expected_cost_czk', pi.expected_cost_czk,
|
||||
'effective_buy_price', pi.effective_buy_price,
|
||||
'effective_sell_price', pi.effective_sell_price,
|
||||
'is_predicted_price', coalesce(pi.is_predicted_price, false),
|
||||
'pv_power_w', ai.actual_pv_power_w,
|
||||
'pv_forecast_total_w', fs.pv_forecast_total_w
|
||||
'pv_forecast_total_w',
|
||||
coalesce(pi.pv_a_forecast_solver_w, 0)
|
||||
+ coalesce(pi.pv_b_forecast_solver_w, 0),
|
||||
'pv_a_forecast_solver_w', pi.pv_a_forecast_solver_w,
|
||||
'pv_b_forecast_solver_w', pi.pv_b_forecast_solver_w,
|
||||
'load_baseline_w', pi.load_baseline_w
|
||||
) as j,
|
||||
pi.interval_start,
|
||||
pi.expected_cost_czk,
|
||||
pi.pv_a_curtailed_w,
|
||||
pi.battery_setpoint_w,
|
||||
pi.grid_setpoint_w,
|
||||
fs.pv_forecast_total_w
|
||||
(coalesce(pi.pv_a_forecast_solver_w, 0) + coalesce(pi.pv_b_forecast_solver_w, 0))::bigint as pv_forecast_total_w
|
||||
from ems.planning_interval pi
|
||||
left join ems.audit_interval ai
|
||||
on ai.site_id = p_site_id
|
||||
and ai.interval_start = pi.interval_start
|
||||
left join fc_slot fs on fs.interval_start = pi.interval_start
|
||||
where pi.run_id = v_run_id
|
||||
union all
|
||||
select
|
||||
jsonb_build_object(
|
||||
'interval_start', fs.interval_start,
|
||||
'battery_setpoint_w', null,
|
||||
'battery_soc_target_pct', null,
|
||||
'grid_setpoint_w', null,
|
||||
'export_limit_w', null,
|
||||
'export_mode', null,
|
||||
'deye_physical_mode', null,
|
||||
'deye_gen_cutoff_enabled', null,
|
||||
'ev1_setpoint_w', null,
|
||||
'ev2_setpoint_w', null,
|
||||
'heat_pump_enabled', null,
|
||||
'pv_a_curtailed_w', null,
|
||||
'expected_cost_czk', null,
|
||||
'effective_buy_price', null,
|
||||
'effective_sell_price', null,
|
||||
'is_predicted_price', false,
|
||||
'pv_power_w', null,
|
||||
'pv_forecast_total_w', fs.pv_forecast_total_w,
|
||||
'pv_a_forecast_solver_w', fs.pv_a_forecast_solver_w,
|
||||
'pv_b_forecast_solver_w', fs.pv_b_forecast_solver_w,
|
||||
'load_baseline_w', null
|
||||
) as j,
|
||||
fs.interval_start,
|
||||
null::numeric as expected_cost_czk,
|
||||
null::int as pv_a_curtailed_w,
|
||||
null::int as battery_setpoint_w,
|
||||
null::int as grid_setpoint_w,
|
||||
fs.pv_forecast_total_w
|
||||
from fc_slot fs
|
||||
where fs.interval_start >= v_horizon_start
|
||||
and fs.interval_start < v_chart_end
|
||||
and not exists (
|
||||
select 1
|
||||
from ems.planning_interval pi2
|
||||
where pi2.run_id = v_run_id
|
||||
and pi2.interval_start = fs.interval_start
|
||||
)
|
||||
),
|
||||
agg as (
|
||||
select
|
||||
@@ -174,4 +249,4 @@ end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_plan_current_bundle(int) is
|
||||
'Aktivní planning_run + intervaly + souhrn (GET /plan/current).';
|
||||
'Aktivní planning_run + intervaly + souhrn (GET /plan/current). PV za horizont plánu z canonical forecast; delta profil z cache.';
|
||||
|
||||
@@ -24,6 +24,7 @@ DECLARE
|
||||
v_ev JSONB;
|
||||
v_fc JSONB;
|
||||
v_ov JSONB;
|
||||
v_econ JSONB;
|
||||
BEGIN
|
||||
IF p_site_id IS NULL THEN
|
||||
RETURN jsonb_build_object('error', 'site_id_required');
|
||||
@@ -89,6 +90,49 @@ BEGIN
|
||||
AND pi.interval_start < v_win_end
|
||||
) t;
|
||||
|
||||
select jsonb_build_object(
|
||||
'window_start_utc', v_slot,
|
||||
'window_end_utc', v_win_end,
|
||||
'total_import_kwh', coalesce(sum(
|
||||
case when pi.grid_setpoint_w > 0
|
||||
then pi.grid_setpoint_w * 0.25 / 1000.0 else 0 end
|
||||
), 0),
|
||||
'total_export_kwh', coalesce(sum(
|
||||
case when pi.grid_setpoint_w < 0
|
||||
then -pi.grid_setpoint_w * 0.25 / 1000.0 else 0 end
|
||||
), 0),
|
||||
'total_buy_cost_czk', coalesce(sum(
|
||||
case when pi.grid_setpoint_w > 0
|
||||
then pi.grid_setpoint_w * pi.effective_buy_price * 0.25 / 1000.0
|
||||
else 0 end
|
||||
), 0),
|
||||
'total_sell_revenue_czk', coalesce(sum(
|
||||
case when pi.grid_setpoint_w < 0
|
||||
then -pi.grid_setpoint_w * pi.effective_sell_price * 0.25 / 1000.0
|
||||
else 0 end
|
||||
), 0),
|
||||
'total_cashflow_czk', coalesce(sum(pi.cashflow_czk), 0),
|
||||
'total_battery_arbitrage_czk', coalesce(sum(pi.battery_arbitrage_czk), 0),
|
||||
'total_penalty_czk', coalesce(sum(pi.penalty_czk), 0),
|
||||
'total_green_bonus_czk', coalesce(sum(pi.green_bonus_czk), 0),
|
||||
'net_economic_czk',
|
||||
coalesce(-sum(pi.cashflow_czk), 0)
|
||||
+ coalesce(sum(pi.battery_arbitrage_czk), 0)
|
||||
- coalesce(sum(pi.penalty_czk), 0)
|
||||
+ coalesce(sum(pi.green_bonus_czk), 0),
|
||||
'neg_sell_export_slots', count(*) filter (
|
||||
where pi.effective_sell_price < 0 and pi.grid_setpoint_w < -500
|
||||
),
|
||||
'first_grid_charge_slot_utc', min(pi.interval_start) filter (
|
||||
where pi.grid_setpoint_w > 500
|
||||
)
|
||||
)
|
||||
into v_econ
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = v_run.id
|
||||
and pi.interval_start >= v_slot
|
||||
and pi.interval_start < v_win_end;
|
||||
|
||||
SELECT to_jsonb(m.*) || jsonb_build_object('mode_name', d.name)
|
||||
INTO v_mode
|
||||
FROM ems.site_operating_mode m
|
||||
@@ -170,6 +214,7 @@ BEGIN
|
||||
'ev_sessions_open', v_ev,
|
||||
'forecast_correction_log_recent', v_fc,
|
||||
'site_overrides_active_in_window', v_ov,
|
||||
'economics_summary', v_econ,
|
||||
'ai_readme', jsonb_build_object(
|
||||
'purpose',
|
||||
'Data stačí k vysvětlení „proč plán v dalších hodinách vypadá takto“: ceny v řádcích intervalů, vstupy (baseline, PV), výstupy (bat/grid/EV/TČ), režim a síťové limity.',
|
||||
|
||||
@@ -5,7 +5,8 @@ create or replace function ems.fn_planning_run_commit(
|
||||
p_horizon_start timestamptz,
|
||||
p_horizon_end timestamptz,
|
||||
p_run_meta jsonb,
|
||||
p_intervals jsonb
|
||||
p_intervals jsonb,
|
||||
p_activate_run boolean default true
|
||||
)
|
||||
returns int
|
||||
language plpgsql
|
||||
@@ -23,12 +24,13 @@ begin
|
||||
insert into ems.planning_run (
|
||||
site_id, horizon_start, horizon_end, status,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor
|
||||
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor,
|
||||
solver_params
|
||||
) values (
|
||||
p_site_id,
|
||||
p_horizon_start,
|
||||
p_horizon_end,
|
||||
'draft',
|
||||
case when p_activate_run then 'draft' else 'comparison' end,
|
||||
nullif(trim(p_run_meta->>'run_type'), ''),
|
||||
nullif(trim(p_run_meta->>'triggered_by'), ''),
|
||||
case
|
||||
@@ -39,7 +41,12 @@ begin
|
||||
end,
|
||||
(p_run_meta->>'soc_at_replan_wh')::numeric,
|
||||
(p_run_meta->>'solver_duration_ms')::int,
|
||||
(p_run_meta->>'forecast_correction_factor')::numeric
|
||||
(p_run_meta->>'forecast_correction_factor')::numeric,
|
||||
case
|
||||
when p_run_meta ? 'solver_params' and jsonb_typeof(p_run_meta->'solver_params') = 'object'
|
||||
then p_run_meta->'solver_params'
|
||||
else null::jsonb
|
||||
end
|
||||
)
|
||||
returning id into v_run_id;
|
||||
|
||||
@@ -50,6 +57,8 @@ begin
|
||||
run_id, interval_start,
|
||||
battery_setpoint_w, battery_soc_target_pct,
|
||||
grid_setpoint_w,
|
||||
export_mode,
|
||||
export_limit_w,
|
||||
deye_physical_mode,
|
||||
deye_gen_cutoff_enabled,
|
||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||
@@ -59,13 +68,19 @@ begin
|
||||
is_predicted_price,
|
||||
load_baseline_w,
|
||||
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
|
||||
pv_a_forecast_solver_w, pv_b_forecast_solver_w
|
||||
pv_a_forecast_solver_w, pv_b_forecast_solver_w,
|
||||
cashflow_czk,
|
||||
battery_arbitrage_czk,
|
||||
penalty_czk,
|
||||
green_bonus_czk
|
||||
) values (
|
||||
v_run_id,
|
||||
(r.value->>'interval_start')::timestamptz,
|
||||
(r.value->>'battery_setpoint_w')::int,
|
||||
(r.value->>'battery_soc_target_pct')::numeric,
|
||||
(r.value->>'grid_setpoint_w')::int,
|
||||
nullif(trim(r.value->>'export_mode'), ''),
|
||||
nullif(r.value->>'export_limit_w', '')::int,
|
||||
nullif(trim(r.value->>'deye_physical_mode'), ''),
|
||||
(r.value->>'deye_gen_cutoff_enabled')::boolean,
|
||||
nullif(r.value->>'ev1_setpoint_w', '')::int,
|
||||
@@ -83,26 +98,38 @@ begin
|
||||
(r.value->>'pv_a_forecast_raw_w')::int,
|
||||
(r.value->>'pv_b_forecast_raw_w')::int,
|
||||
(r.value->>'pv_a_forecast_solver_w')::int,
|
||||
(r.value->>'pv_b_forecast_solver_w')::int
|
||||
(r.value->>'pv_b_forecast_solver_w')::int,
|
||||
nullif(r.value->>'cashflow_czk', '')::numeric,
|
||||
nullif(r.value->>'battery_arbitrage_czk', '')::numeric,
|
||||
nullif(r.value->>'penalty_czk', '')::numeric,
|
||||
nullif(r.value->>'green_bonus_czk', '')::numeric
|
||||
);
|
||||
else
|
||||
insert into ems.planning_interval (
|
||||
run_id, interval_start,
|
||||
battery_setpoint_w, battery_soc_target_pct,
|
||||
grid_setpoint_w,
|
||||
export_mode,
|
||||
export_limit_w,
|
||||
deye_physical_mode,
|
||||
deye_gen_cutoff_enabled,
|
||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||
heat_pump_enabled, heat_pump_setpoint_w,
|
||||
pv_a_curtailed_w, expected_cost_czk,
|
||||
effective_buy_price, effective_sell_price,
|
||||
is_predicted_price
|
||||
is_predicted_price,
|
||||
cashflow_czk,
|
||||
battery_arbitrage_czk,
|
||||
penalty_czk,
|
||||
green_bonus_czk
|
||||
) values (
|
||||
v_run_id,
|
||||
(r.value->>'interval_start')::timestamptz,
|
||||
(r.value->>'battery_setpoint_w')::int,
|
||||
(r.value->>'battery_soc_target_pct')::numeric,
|
||||
(r.value->>'grid_setpoint_w')::int,
|
||||
nullif(trim(r.value->>'export_mode'), ''),
|
||||
nullif(r.value->>'export_limit_w', '')::int,
|
||||
nullif(trim(r.value->>'deye_physical_mode'), ''),
|
||||
(r.value->>'deye_gen_cutoff_enabled')::boolean,
|
||||
nullif(r.value->>'ev1_setpoint_w', '')::int,
|
||||
@@ -115,20 +142,27 @@ begin
|
||||
(r.value->>'expected_cost_czk')::numeric,
|
||||
(r.value->>'effective_buy_price')::numeric,
|
||||
(r.value->>'effective_sell_price')::numeric,
|
||||
coalesce((r.value->>'is_predicted_price')::boolean, false)
|
||||
coalesce((r.value->>'is_predicted_price')::boolean, false),
|
||||
nullif(r.value->>'cashflow_czk', '')::numeric,
|
||||
nullif(r.value->>'battery_arbitrage_czk', '')::numeric,
|
||||
nullif(r.value->>'penalty_czk', '')::numeric,
|
||||
nullif(r.value->>'green_bonus_czk', '')::numeric
|
||||
);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
update ems.planning_run
|
||||
set status = 'superseded'
|
||||
where site_id = p_site_id
|
||||
where p_activate_run
|
||||
and site_id = p_site_id
|
||||
and status = 'active'
|
||||
and id <> v_run_id;
|
||||
|
||||
update ems.planning_run
|
||||
set status = 'active'
|
||||
where id = v_run_id;
|
||||
if p_activate_run then
|
||||
update ems.planning_run
|
||||
set status = 'active'
|
||||
where id = v_run_id;
|
||||
end if;
|
||||
|
||||
return v_run_id;
|
||||
end;
|
||||
|
||||
@@ -10,6 +10,7 @@ declare
|
||||
v_b jsonb;
|
||||
v_hp jsonb;
|
||||
v_grid jsonb;
|
||||
v_market jsonb;
|
||||
v_veh jsonb;
|
||||
v_ev jsonb;
|
||||
v_soc_pct numeric;
|
||||
@@ -67,7 +68,14 @@ begin
|
||||
)::int,
|
||||
'charge_slot_buffer', ab.charge_slot_buffer,
|
||||
'discharge_slot_buffer', ab.discharge_slot_buffer,
|
||||
'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor
|
||||
'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor,
|
||||
'planner_daytime_charge_target_enabled', coalesce(ab.planner_daytime_charge_target_enabled, true),
|
||||
'planner_night_baseload_buffer_percent', coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric),
|
||||
'planner_daytime_charge_price_quantile', coalesce(ab.planner_daytime_charge_price_quantile, 0.70::numeric),
|
||||
'planner_charge_commitment_penalty_czk_kwh', coalesce(ab.planner_charge_commitment_penalty_czk_kwh, 0.20::numeric),
|
||||
'planner_neg_sell_prep_soc_percent', coalesce(ab.planner_neg_sell_prep_soc_percent, 80::numeric),
|
||||
'planner_neg_sell_full_soc_tail_slots', coalesce(ab.planner_neg_sell_full_soc_tail_slots, 4),
|
||||
'planner_neg_sell_vent_min_sell_czk_kwh', ab.planner_neg_sell_vent_min_sell_czk_kwh
|
||||
)
|
||||
into v_b
|
||||
from ems.asset_battery ab
|
||||
@@ -132,6 +140,25 @@ begin
|
||||
raise exception 'No site_grid_connection for site_id=%', p_site_id;
|
||||
end if;
|
||||
|
||||
select jsonb_build_object(
|
||||
'purchase_pricing_mode', lower(trim(coalesce(smc.purchase_pricing_mode, 'spot'))),
|
||||
'sale_pricing_mode', lower(trim(coalesce(smc.sale_pricing_mode, 'spot')))
|
||||
)
|
||||
into v_market
|
||||
from ems.site_market_config smc
|
||||
where smc.site_id = p_site_id
|
||||
and smc.valid_to is null
|
||||
order by smc.valid_from desc
|
||||
limit 1;
|
||||
|
||||
v_market := coalesce(
|
||||
v_market,
|
||||
jsonb_build_object(
|
||||
'purchase_pricing_mode', 'spot',
|
||||
'sale_pricing_mode', 'spot'
|
||||
)
|
||||
);
|
||||
|
||||
select coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
@@ -259,6 +286,7 @@ begin
|
||||
'battery', v_b,
|
||||
'heat_pump', v_hp,
|
||||
'grid', v_grid,
|
||||
'market', v_market,
|
||||
'vehicles', v_veh,
|
||||
'ev_sessions', v_ev,
|
||||
'soc_wh', v_soc_wh,
|
||||
|
||||
@@ -18,37 +18,34 @@ declare
|
||||
v_factor numeric := 1.0;
|
||||
v_clamped boolean := false;
|
||||
begin
|
||||
select coalesce(sum(ti.pv_power_w) * 0.25 / 1000.0, 0)
|
||||
-- Telemetrie je 1min (avg power). Energie v kWh ≈ sum(W) * (1/60 h) / 1000.
|
||||
select coalesce(sum(ti.pv_power_w) / 60.0 / 1000.0, 0)
|
||||
into v_actual
|
||||
from ems.telemetry_inverter ti
|
||||
where ti.site_id = p_site_id
|
||||
and ti.measured_at >= p_window_start
|
||||
and ti.measured_at < p_window_end;
|
||||
|
||||
with pv_arrays as (
|
||||
select apa.id as pv_array_id
|
||||
from ems.asset_pv_array apa
|
||||
where apa.site_id = p_site_id
|
||||
),
|
||||
latest_run as (
|
||||
select distinct on (fpr.pv_array_id)
|
||||
fpr.pv_array_id,
|
||||
fpr.id as run_id
|
||||
from pv_arrays pa
|
||||
join ems.forecast_pv_run fpr
|
||||
on fpr.pv_array_id = pa.pv_array_id
|
||||
and fpr.site_id = p_site_id
|
||||
where fpr.status = 'ok'
|
||||
and fpr.created_at <= p_window_start
|
||||
order by fpr.pv_array_id, fpr.created_at desc
|
||||
)
|
||||
select coalesce(sum(fpi.power_w) * 0.25 / 1000.0, 0)
|
||||
-- Forecast pro korekční faktor bereme stejně jako pro plánování/UI:
|
||||
-- nejnovější `ok` run per (interval_start, pv_array_id) v daném okně.
|
||||
select coalesce(sum(u.power_w) * 0.25 / 1000.0, 0)
|
||||
into v_forecast
|
||||
from ems.forecast_pv_interval fpi
|
||||
join latest_run lr on lr.run_id = fpi.run_id
|
||||
where fpi.interval_start >= p_window_start
|
||||
and fpi.interval_start < p_window_end
|
||||
and fpi.pv_array_id = lr.pv_array_id;
|
||||
from (
|
||||
select distinct on (fpi.interval_start, fpr.pv_array_id)
|
||||
fpi.power_w
|
||||
from ems.forecast_pv_interval fpi
|
||||
join ems.forecast_pv_run fpr
|
||||
on fpr.id = fpi.run_id
|
||||
and fpr.site_id = p_site_id
|
||||
and fpr.pv_array_id = fpi.pv_array_id
|
||||
and fpr.status = 'ok'
|
||||
where fpi.interval_start >= p_window_start
|
||||
and fpi.interval_start < p_window_end
|
||||
and fpi.pv_array_id in (
|
||||
select apa.id from ems.asset_pv_array apa where apa.site_id = p_site_id
|
||||
)
|
||||
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
|
||||
) u;
|
||||
|
||||
if v_forecast < 0.1 or coalesce(v_actual, 0) < 0.05 then
|
||||
return jsonb_build_object(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,8 @@ as $fn$
|
||||
s.interval_start,
|
||||
ai.actual_grid_power_w,
|
||||
ai.deviation_grid_w,
|
||||
pi.grid_setpoint_w as plan_grid_w
|
||||
pi.grid_setpoint_w as plan_grid_w,
|
||||
pi.effective_sell_price as plan_sell_czk
|
||||
from slots s
|
||||
inner join ems.audit_interval ai
|
||||
on ai.site_id = p_site_id
|
||||
@@ -41,6 +42,12 @@ as $fn$
|
||||
b.deviation_grid_w,
|
||||
case
|
||||
when b.plan_grid_w is null or b.deviation_grid_w is null then null::text
|
||||
when coalesce(
|
||||
b.plan_sell_czk,
|
||||
ems.fn_effective_sell_price(p_site_id, b.interval_start)
|
||||
) < 0
|
||||
and coalesce(b.actual_grid_power_w, 0) < -4000
|
||||
then 'NEG_SELL_EXPORT'
|
||||
when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500
|
||||
then 'GRID_IMPORT_VS_EXPORT_PLAN'
|
||||
when b.plan_grid_w <> 0
|
||||
@@ -60,6 +67,22 @@ as $fn$
|
||||
end as reason_code,
|
||||
case
|
||||
when b.plan_grid_w is null or b.deviation_grid_w is null then null::text
|
||||
when coalesce(
|
||||
b.plan_sell_czk,
|
||||
ems.fn_effective_sell_price(p_site_id, b.interval_start)
|
||||
) < 0
|
||||
and coalesce(b.actual_grid_power_w, 0) < -4000
|
||||
then format(
|
||||
'záporná vykupní %s Kč/kWh, skutečnost síť %s W (vývoz nad práh 4 kW)',
|
||||
round(
|
||||
coalesce(
|
||||
b.plan_sell_czk,
|
||||
ems.fn_effective_sell_price(p_site_id, b.interval_start)
|
||||
)::numeric,
|
||||
4
|
||||
),
|
||||
coalesce(b.actual_grid_power_w, 0)
|
||||
)
|
||||
when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500
|
||||
then format(
|
||||
'plán síť %s W vs skutečnost %s W (plán vývoz, skutečnost silný odběr)',
|
||||
@@ -154,7 +177,7 @@ as $fn$
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_plan_actual_slot_guard_site(int, timestamptz) is
|
||||
'Poslední 2 uzavřené 15min sloty: fatální odchylka síť plán vs. audit → insert plan_fatal_deviation_sent (dedup); vrátí JSON s alerts k odeslání na Discord.';
|
||||
'Poslední 2 uzavřené 15min sloty: fatální odchylka síť plán vs. audit (včetně NEG_SELL_EXPORT při sell<0 a vývozu >4 kW) → insert plan_fatal_deviation_sent (dedup); JSON alerts pro Discord.';
|
||||
|
||||
create or replace function ems.fn_plan_actual_slot_guard_all_active(
|
||||
p_now timestamptz default now()
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
-- Cap pro reg 340 max solar power (W): součet nominal_power_wp řiditelných PV polí na invertoru.
|
||||
-- Cap pro reg 340 max solar power (W): plný výkon střídače, ne jen součet Wp polí A.
|
||||
create or replace function ems.fn_inverter_pv_a_max_w(p_inverter_id int)
|
||||
returns int
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select coalesce(sum(nominal_power_wp), 0)::int
|
||||
from ems.asset_pv_array
|
||||
where inverter_id = p_inverter_id
|
||||
and controllable = true
|
||||
with pv as (
|
||||
select coalesce(sum(nominal_power_wp), 0)::int as wp_sum
|
||||
from ems.asset_pv_array
|
||||
where inverter_id = p_inverter_id
|
||||
and controllable = true
|
||||
)
|
||||
select case
|
||||
when (select wp_sum from pv) <= 0 then 0
|
||||
else coalesce(
|
||||
nullif(ai.deye_reg340_max_solar_w, 0),
|
||||
nullif(ai.max_dc_input_w, 0),
|
||||
(select wp_sum from pv),
|
||||
0
|
||||
)::int
|
||||
end
|
||||
from ems.asset_inverter ai
|
||||
where ai.id = p_inverter_id
|
||||
$$;
|
||||
|
||||
comment on function ems.fn_inverter_pv_a_max_w(int) is
|
||||
'Cap pro reg 340 (max solar power, W) = součet nominal_power_wp řiditelných PV polí na daném invertoru. 0 = EMS reg 340 neaktivní (skip zápisu).';
|
||||
'Cap pro reg 340 (max solar power, W): deye_reg340_max_solar_w, jinak max_dc_input_w, jinak součet Wp řiditelných polí. 0 = bez řiditelného PV A nebo bez capu — EMS reg 340 nezapisuje.';
|
||||
|
||||
76
db/routines/R__087_fn_planning_run_debug.sql
Normal file
76
db/routines/R__087_fn_planning_run_debug.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
-- Kompaktní JSON pro diagnostiku jednoho planning_run (MCP / UI).
|
||||
|
||||
create or replace function ems.fn_planning_run_debug(p_run_id int)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
as $fn$
|
||||
declare
|
||||
r_run ems.planning_run%rowtype;
|
||||
v_intervals jsonb;
|
||||
v_first_charge timestamptz;
|
||||
v_first_bat_export timestamptz;
|
||||
v_top_sell jsonb;
|
||||
begin
|
||||
select * into r_run from ems.planning_run where id = p_run_id;
|
||||
if not found then
|
||||
return null::jsonb;
|
||||
end if;
|
||||
|
||||
select coalesce(jsonb_agg(to_jsonb(pi.*) order by pi.interval_start), '[]'::jsonb)
|
||||
into v_intervals
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = p_run_id;
|
||||
|
||||
select pi.interval_start
|
||||
into v_first_charge
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = p_run_id
|
||||
and coalesce(pi.battery_setpoint_w, 0) > 500
|
||||
order by pi.interval_start
|
||||
limit 1;
|
||||
|
||||
select pi.interval_start
|
||||
into v_first_bat_export
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = p_run_id
|
||||
and coalesce(pi.battery_setpoint_w, 0) < -500
|
||||
and coalesce(pi.grid_setpoint_w, 0) < 0
|
||||
order by pi.interval_start
|
||||
limit 1;
|
||||
|
||||
select coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'interval_start', x.interval_start,
|
||||
'effective_sell_price', x.effective_sell_price
|
||||
)
|
||||
order by x.effective_sell_price desc nulls last
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
into v_top_sell
|
||||
from (
|
||||
select pi.interval_start, pi.effective_sell_price
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = p_run_id
|
||||
order by pi.effective_sell_price desc nulls last
|
||||
limit 3
|
||||
) x;
|
||||
|
||||
return jsonb_build_object(
|
||||
'planning_run', to_jsonb(r_run),
|
||||
'solver_params', r_run.solver_params,
|
||||
'intervals', v_intervals,
|
||||
'summary', jsonb_build_object(
|
||||
'first_charge_slot', to_jsonb(v_first_charge),
|
||||
'first_battery_export_slot', to_jsonb(v_first_bat_export),
|
||||
'top_sell_slots', v_top_sell,
|
||||
'solver_params_version', r_run.solver_params->'version'
|
||||
)
|
||||
);
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_planning_run_debug(int) is
|
||||
'Jeden jsonb: metadata planning_run, solver_params, všechny planning_interval řádky a krátký summary.';
|
||||
230
db/routines/R__088_fn_forecast_pv_slots_range_canonical_ab.sql
Normal file
230
db/routines/R__088_fn_forecast_pv_slots_range_canonical_ab.sql
Normal file
@@ -0,0 +1,230 @@
|
||||
-- ============================================================
|
||||
-- PV forecast sloty (15min) – kanonický vstup pro plánování
|
||||
--
|
||||
-- Kombinuje:
|
||||
-- 1) delta-korekci per-array (fn_pv_forecast_delta_profile)
|
||||
-- 2) rolling multiplikativní faktor vs telemetrie (fn_pv_forecast_correction_factor)
|
||||
-- s lineárním decay do 1.0 v p_decay_slots.
|
||||
--
|
||||
-- Výstup je rozsplitěný na PV-A (controllable=true) a PV-B (controllable=false),
|
||||
-- protože curtailment v LP smí omezovat jen PV-A.
|
||||
-- ============================================================
|
||||
|
||||
create or replace function ems.fn_forecast_pv_slots_range_canonical_ab(
|
||||
p_site_id int,
|
||||
p_from timestamptz,
|
||||
p_to timestamptz,
|
||||
p_now timestamptz default now(),
|
||||
p_delta_data_from timestamptz default (now() - interval '120 days'),
|
||||
p_delta_data_to timestamptz default now(),
|
||||
p_half_life_days numeric default 14,
|
||||
p_threshold_w int default 150,
|
||||
p_factor_window_h numeric default 1,
|
||||
p_factor_min_clamp numeric default 0.5,
|
||||
p_factor_max_clamp numeric default 1.5,
|
||||
p_decay_slots int default 16
|
||||
)
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
set work_mem = '64MB'
|
||||
as $fn$
|
||||
with tz as (
|
||||
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague') as tz_name
|
||||
from ems.site s
|
||||
where s.id = p_site_id
|
||||
),
|
||||
bounds as (
|
||||
select
|
||||
date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') as ts_from,
|
||||
case
|
||||
when p_to <= p_from then date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') + interval '15 minutes'
|
||||
when p_to > p_from + interval '60 days' then date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') + interval '60 days'
|
||||
else date_bin(interval '15 minutes', p_to, timestamptz '1970-01-01T00:00:00Z')
|
||||
end as ts_to,
|
||||
date_bin(interval '15 minutes', p_now, timestamptz '1970-01-01T00:00:00Z') as now_slot
|
||||
),
|
||||
slot_spine as (
|
||||
select gs as interval_start
|
||||
from bounds b,
|
||||
generate_series(
|
||||
b.ts_from,
|
||||
(b.ts_to - interval '15 minutes')::timestamptz,
|
||||
interval '15 minutes'
|
||||
) as gs
|
||||
),
|
||||
slot_tz as (
|
||||
select
|
||||
s.interval_start,
|
||||
(
|
||||
(extract(hour from (s.interval_start at time zone t.tz_name))::int * 60)
|
||||
+ extract(minute from (s.interval_start at time zone t.tz_name))::int
|
||||
) / 15 as slot_of_day
|
||||
from slot_spine s
|
||||
cross join tz t
|
||||
),
|
||||
factor_raw as (
|
||||
select ems.fn_pv_forecast_correction_factor(
|
||||
p_site_id,
|
||||
(p_now - (p_factor_window_h::text || ' hours')::interval)::timestamptz,
|
||||
p_now,
|
||||
p_factor_min_clamp,
|
||||
p_factor_max_clamp
|
||||
) as j
|
||||
),
|
||||
factor as (
|
||||
select
|
||||
coalesce((j->>'correction_factor')::numeric, 1.0::numeric) as rolling_factor
|
||||
from factor_raw
|
||||
),
|
||||
profile as (
|
||||
select ems.fn_pv_forecast_delta_profile_cached(
|
||||
p_site_id,
|
||||
p_delta_data_from,
|
||||
p_delta_data_to,
|
||||
p_half_life_days,
|
||||
p_threshold_w
|
||||
) as j
|
||||
),
|
||||
delta_by_array as (
|
||||
select (kv.key)::int as pv_array_id,
|
||||
(x->>'slot_of_day')::int as slot_of_day,
|
||||
(x->>'delta_w')::int as delta_w
|
||||
from profile p
|
||||
cross join lateral jsonb_each((p.j)->'deltas_by_array') kv(key, value)
|
||||
cross join lateral jsonb_array_elements(kv.value->'deltas') x
|
||||
),
|
||||
deltas_legacy as (
|
||||
select (x->>'slot_of_day')::int as slot_of_day,
|
||||
(x->>'delta_w')::int as delta_w
|
||||
from profile p
|
||||
cross join lateral jsonb_array_elements(p.j->'deltas') x
|
||||
),
|
||||
flags as (
|
||||
select exists (select 1 from delta_by_array) as use_per_array
|
||||
),
|
||||
fc_by_array as (
|
||||
select distinct on (fpi.interval_start, fpr.pv_array_id)
|
||||
fpi.interval_start,
|
||||
fpr.pv_array_id,
|
||||
apa.controllable,
|
||||
fpi.power_w::bigint as power_w
|
||||
from bounds b
|
||||
inner join ems.forecast_pv_interval fpi
|
||||
on fpi.interval_start >= b.ts_from
|
||||
and fpi.interval_start < b.ts_to
|
||||
and fpi.pv_array_id in (
|
||||
select apa0.id from ems.asset_pv_array apa0 where apa0.site_id = p_site_id
|
||||
)
|
||||
inner join ems.forecast_pv_run fpr
|
||||
on fpr.id = fpi.run_id
|
||||
and fpr.site_id = p_site_id
|
||||
and fpr.pv_array_id = fpi.pv_array_id
|
||||
and fpr.status = 'ok'
|
||||
inner join ems.asset_pv_array apa
|
||||
on apa.id = fpr.pv_array_id
|
||||
and apa.site_id = p_site_id
|
||||
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
|
||||
),
|
||||
fc_with_sod as (
|
||||
select
|
||||
fa.interval_start,
|
||||
fa.pv_array_id,
|
||||
fa.controllable,
|
||||
fa.power_w,
|
||||
st.slot_of_day
|
||||
from fc_by_array fa
|
||||
join slot_tz st on st.interval_start = fa.interval_start
|
||||
),
|
||||
fc_delta as (
|
||||
select
|
||||
f.interval_start,
|
||||
f.controllable,
|
||||
sum(f.power_w)::bigint as raw_w,
|
||||
sum(
|
||||
greatest(
|
||||
0::bigint,
|
||||
f.power_w
|
||||
- (
|
||||
case
|
||||
when fl.use_per_array then coalesce(d.delta_w, 0)::bigint
|
||||
else coalesce(dl.delta_w, 0)::bigint
|
||||
end
|
||||
)
|
||||
)
|
||||
)::bigint as delta_w
|
||||
from fc_with_sod f
|
||||
cross join flags fl
|
||||
left join delta_by_array d
|
||||
on fl.use_per_array
|
||||
and d.pv_array_id = f.pv_array_id
|
||||
and d.slot_of_day = f.slot_of_day
|
||||
left join lateral (
|
||||
select dl0.delta_w
|
||||
from deltas_legacy dl0
|
||||
where dl0.slot_of_day = f.slot_of_day
|
||||
limit 1
|
||||
) dl on not fl.use_per_array
|
||||
group by f.interval_start, f.controllable
|
||||
),
|
||||
fc_ab as (
|
||||
select
|
||||
st.interval_start,
|
||||
coalesce(sum(case when fd.controllable then fd.raw_w else 0 end), 0)::bigint as pv_a_forecast_raw_w,
|
||||
coalesce(sum(case when not fd.controllable then fd.raw_w else 0 end), 0)::bigint as pv_b_forecast_raw_w,
|
||||
coalesce(sum(case when fd.controllable then fd.delta_w else 0 end), 0)::bigint as pv_a_forecast_delta_w,
|
||||
coalesce(sum(case when not fd.controllable then fd.delta_w else 0 end), 0)::bigint as pv_b_forecast_delta_w,
|
||||
st.slot_of_day
|
||||
from slot_tz st
|
||||
left join fc_delta fd on fd.interval_start = st.interval_start
|
||||
group by st.interval_start, st.slot_of_day
|
||||
),
|
||||
with_factor as (
|
||||
select
|
||||
ab.interval_start,
|
||||
ab.slot_of_day,
|
||||
ab.pv_a_forecast_raw_w,
|
||||
ab.pv_b_forecast_raw_w,
|
||||
ab.pv_a_forecast_delta_w,
|
||||
ab.pv_b_forecast_delta_w,
|
||||
f.rolling_factor,
|
||||
case
|
||||
when ab.interval_start < b.now_slot then 1.0::numeric
|
||||
when p_decay_slots <= 0 then f.rolling_factor
|
||||
else
|
||||
case
|
||||
when ((extract(epoch from (ab.interval_start - b.now_slot)) / 900)::int) >= p_decay_slots then 1.0::numeric
|
||||
else (1.0::numeric + (f.rolling_factor - 1.0::numeric) * (1.0::numeric - ((extract(epoch from (ab.interval_start - b.now_slot)) / 900)::numeric / p_decay_slots::numeric)))
|
||||
end
|
||||
end as rolling_effective_factor
|
||||
from fc_ab ab
|
||||
cross join factor f
|
||||
cross join bounds b
|
||||
)
|
||||
select coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'interval_start', w.interval_start,
|
||||
'slot_of_day', w.slot_of_day,
|
||||
'pv_a_forecast_raw_w', w.pv_a_forecast_raw_w,
|
||||
'pv_b_forecast_raw_w', w.pv_b_forecast_raw_w,
|
||||
'pv_a_forecast_delta_w', w.pv_a_forecast_delta_w,
|
||||
'pv_b_forecast_delta_w', w.pv_b_forecast_delta_w,
|
||||
'rolling_factor', w.rolling_factor,
|
||||
'rolling_effective_factor', w.rolling_effective_factor,
|
||||
'pv_a_forecast_canonical_w', greatest(0::bigint, round(w.pv_a_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint,
|
||||
'pv_b_forecast_canonical_w', greatest(0::bigint, round(w.pv_b_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint,
|
||||
'pv_forecast_total_canonical_w',
|
||||
greatest(0::bigint, round(w.pv_a_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint
|
||||
+ greatest(0::bigint, round(w.pv_b_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint
|
||||
)
|
||||
order by w.interval_start
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
from with_factor w;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_forecast_pv_slots_range_canonical_ab is
|
||||
'Kanonická PV forecast řada po 15 min pro plánování: delta-korekce per-array (fn_pv_forecast_delta_profile) + rolling multiplikativní faktor (fn_pv_forecast_correction_factor) s decay. Vrací PV-A/PV-B (controllable) i total.';
|
||||
|
||||
83
db/routines/R__089_fn_forecast_pv_slots_range_raw_ab.sql
Normal file
83
db/routines/R__089_fn_forecast_pv_slots_range_raw_ab.sql
Normal file
@@ -0,0 +1,83 @@
|
||||
-- ============================================================
|
||||
-- PV forecast sloty (15min) – RAW (bez korekcí), rozdělené na PV-A/PV-B
|
||||
--
|
||||
-- Nejnovější `ok` forecast_pv_run per (interval_start, pv_array_id).
|
||||
-- Slouží pro audit/debug v planning_interval.*_forecast_raw_w.
|
||||
-- ============================================================
|
||||
|
||||
create or replace function ems.fn_forecast_pv_slots_range_raw_ab(
|
||||
p_site_id int,
|
||||
p_from timestamptz,
|
||||
p_to timestamptz
|
||||
)
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
set work_mem = '64MB'
|
||||
as $fn$
|
||||
with bounds as (
|
||||
select
|
||||
date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') as ts_from,
|
||||
case
|
||||
when p_to <= p_from then date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') + interval '15 minutes'
|
||||
when p_to > p_from + interval '60 days' then date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') + interval '60 days'
|
||||
else date_bin(interval '15 minutes', p_to, timestamptz '1970-01-01T00:00:00Z')
|
||||
end as ts_to
|
||||
),
|
||||
slot_spine as (
|
||||
select gs as interval_start
|
||||
from bounds b,
|
||||
generate_series(
|
||||
b.ts_from,
|
||||
(b.ts_to - interval '15 minutes')::timestamptz,
|
||||
interval '15 minutes'
|
||||
) as gs
|
||||
),
|
||||
fc_by_array as (
|
||||
select distinct on (fpi.interval_start, fpr.pv_array_id)
|
||||
fpi.interval_start,
|
||||
apa.controllable,
|
||||
fpi.power_w::bigint as power_w
|
||||
from bounds b
|
||||
join ems.forecast_pv_interval fpi
|
||||
on fpi.interval_start >= b.ts_from
|
||||
and fpi.interval_start < b.ts_to
|
||||
and fpi.pv_array_id in (
|
||||
select apa0.id from ems.asset_pv_array apa0 where apa0.site_id = p_site_id
|
||||
)
|
||||
join ems.forecast_pv_run fpr
|
||||
on fpr.id = fpi.run_id
|
||||
and fpr.site_id = p_site_id
|
||||
and fpr.pv_array_id = fpi.pv_array_id
|
||||
and fpr.status = 'ok'
|
||||
join ems.asset_pv_array apa
|
||||
on apa.id = fpr.pv_array_id
|
||||
and apa.site_id = p_site_id
|
||||
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
|
||||
),
|
||||
fc_ab as (
|
||||
select
|
||||
s.interval_start,
|
||||
coalesce(sum(case when f.controllable then f.power_w else 0 end), 0)::bigint as pv_a_forecast_raw_w,
|
||||
coalesce(sum(case when not f.controllable then f.power_w else 0 end), 0)::bigint as pv_b_forecast_raw_w
|
||||
from slot_spine s
|
||||
left join fc_by_array f on f.interval_start = s.interval_start
|
||||
group by s.interval_start
|
||||
)
|
||||
select coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'interval_start', r.interval_start,
|
||||
'pv_a_forecast_raw_w', r.pv_a_forecast_raw_w,
|
||||
'pv_b_forecast_raw_w', r.pv_b_forecast_raw_w
|
||||
)
|
||||
order by r.interval_start
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
from fc_ab r;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_forecast_pv_slots_range_raw_ab is
|
||||
'RAW PV forecast po 15 min (bez korekcí), rozdělený na PV-A/PV-B, jako nejnovější ok run per array a slot.';
|
||||
|
||||
60
db/routines/R__090_fn_plan_compare_bundle.sql
Normal file
60
db/routines/R__090_fn_plan_compare_bundle.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Jedno volání DB pro GET /plan/compare (aktivní bundle + comparison run debug).
|
||||
|
||||
create or replace function ems.fn_plan_compare_bundle(p_site_id int)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
as $fn$
|
||||
declare
|
||||
v_active jsonb;
|
||||
v_active_run_id int;
|
||||
v_compare_run_id int;
|
||||
v_comparison jsonb;
|
||||
begin
|
||||
v_active := ems.fn_plan_current_bundle(p_site_id);
|
||||
if v_active ? 'error' then
|
||||
return v_active;
|
||||
end if;
|
||||
|
||||
v_active_run_id := (v_active->'run'->>'id')::int;
|
||||
if v_active_run_id is null then
|
||||
return jsonb_build_object('error', 'no_active_plan');
|
||||
end if;
|
||||
|
||||
select pr.id
|
||||
into v_compare_run_id
|
||||
from ems.planning_run pr
|
||||
where pr.site_id = p_site_id
|
||||
and pr.status = 'comparison'
|
||||
and (pr.solver_params->>'comparison_of_run_id')::int = v_active_run_id
|
||||
order by pr.created_at desc
|
||||
limit 1;
|
||||
|
||||
if v_compare_run_id is null then
|
||||
select pr.id
|
||||
into v_compare_run_id
|
||||
from ems.planning_run pr
|
||||
where pr.site_id = p_site_id
|
||||
and pr.status = 'comparison'
|
||||
order by pr.created_at desc
|
||||
limit 1;
|
||||
end if;
|
||||
|
||||
if v_compare_run_id is null then
|
||||
return jsonb_build_object('error', 'no_comparison_plan');
|
||||
end if;
|
||||
|
||||
v_comparison := ems.fn_planning_run_debug(v_compare_run_id);
|
||||
if v_comparison is null then
|
||||
return jsonb_build_object('error', 'no_comparison_plan');
|
||||
end if;
|
||||
|
||||
return jsonb_build_object(
|
||||
'active', v_active,
|
||||
'comparison', v_comparison
|
||||
);
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_plan_compare_bundle(int) is
|
||||
'Aktivní plán + comparison planning_run (GET /plan/compare).';
|
||||
50
db/routines/R__091_fn_planning_run_fail.sql
Normal file
50
db/routines/R__091_fn_planning_run_fail.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- neúspěšný běh plánovače bez aktivace a bez supersede aktivního plánu
|
||||
|
||||
create or replace function ems.fn_planning_run_fail(
|
||||
p_site_id int,
|
||||
p_horizon_start timestamptz,
|
||||
p_horizon_end timestamptz,
|
||||
p_run_meta jsonb
|
||||
)
|
||||
returns int
|
||||
language plpgsql
|
||||
as $fn$
|
||||
declare
|
||||
v_run_id int;
|
||||
begin
|
||||
insert into ems.planning_run (
|
||||
site_id, horizon_start, horizon_end, status,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor,
|
||||
solver_params, error_text
|
||||
) values (
|
||||
p_site_id,
|
||||
p_horizon_start,
|
||||
p_horizon_end,
|
||||
'failed',
|
||||
nullif(trim(p_run_meta->>'run_type'), ''),
|
||||
nullif(trim(p_run_meta->>'triggered_by'), ''),
|
||||
case
|
||||
when p_run_meta ? 'replan_from' and (p_run_meta->>'replan_from') is not null
|
||||
and (p_run_meta->>'replan_from') <> 'null'
|
||||
then (p_run_meta->>'replan_from')::timestamptz
|
||||
else null::timestamptz
|
||||
end,
|
||||
(p_run_meta->>'soc_at_replan_wh')::numeric,
|
||||
coalesce((p_run_meta->>'solver_duration_ms')::int, 0),
|
||||
coalesce((p_run_meta->>'forecast_correction_factor')::numeric, 1.0),
|
||||
case
|
||||
when p_run_meta ? 'solver_params' and jsonb_typeof(p_run_meta->'solver_params') = 'object'
|
||||
then p_run_meta->'solver_params'
|
||||
else null::jsonb
|
||||
end,
|
||||
nullif(trim(p_run_meta->>'error_text'), '')
|
||||
)
|
||||
returning id into v_run_id;
|
||||
|
||||
return v_run_id;
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_planning_run_fail is
|
||||
'Uloží planning_run se statusem failed; neaktivuje plán a nesupersededuje active.';
|
||||
10
db/routines/R__091_fn_soc_tracking_bundle.sql
Normal file
10
db/routines/R__091_fn_soc_tracking_bundle.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Odstraněno v39: kalibrace discharge_calibration_factor nahrazena opravou SoC bilance (jen bd, ne bd+ge_bat).
|
||||
|
||||
drop function if exists ems.fn_soc_tracking_bundle(
|
||||
int,
|
||||
timestamptz,
|
||||
numeric,
|
||||
numeric,
|
||||
numeric,
|
||||
numeric
|
||||
);
|
||||
@@ -2,90 +2,140 @@
|
||||
-- R__058_vw_latest_telemetry.sql
|
||||
-- EMS Platform – aktuální stav všech zařízení per lokalita
|
||||
-- Repeatable migration
|
||||
--
|
||||
-- Výkon (audit 2026-06-11): původní DISTINCT ON přes celé hypertable
|
||||
-- třídilo ~195k (inverter) / ~277k (EV) řádků při každém čtení
|
||||
-- (fn_site_full_status ~1.7 s). LATERAL limit 1 per zařízení čte jen
|
||||
-- špičku PK indexu ((inverter_id|charger_id, …, measured_at)).
|
||||
-- =============================================================
|
||||
|
||||
-- security_invoker = false: oprávnění na podkladové hypertably nemusí mít ems_anon (PostgREST).
|
||||
CREATE OR REPLACE VIEW ems.vw_latest_inverter
|
||||
WITH (security_invoker = false)
|
||||
AS
|
||||
SELECT DISTINCT ON (t.inverter_id)
|
||||
t.site_id,
|
||||
t.inverter_id,
|
||||
inv.code AS inverter_code,
|
||||
t.measured_at,
|
||||
t.pv_power_w,
|
||||
t.battery_soc_percent,
|
||||
t.battery_power_w,
|
||||
t.grid_power_w,
|
||||
t.load_power_w,
|
||||
t.inverter_temp_c,
|
||||
t.operating_mode,
|
||||
t.fault_code,
|
||||
now() - t.measured_at AS data_age,
|
||||
t.pv1_power_w,
|
||||
t.pv2_power_w,
|
||||
t.gen_port_power_w,
|
||||
t.batt_charge_today_wh,
|
||||
t.batt_discharge_today_wh,
|
||||
t.run_state,
|
||||
t.is_export_limited,
|
||||
t.pv_derating_flags
|
||||
FROM ems.telemetry_inverter t
|
||||
JOIN ems.asset_inverter inv ON inv.id = t.inverter_id
|
||||
ORDER BY t.inverter_id, t.measured_at DESC;
|
||||
create or replace view ems.vw_latest_inverter
|
||||
with (security_invoker = false)
|
||||
as
|
||||
select
|
||||
inv.site_id,
|
||||
inv.id as inverter_id,
|
||||
inv.code as inverter_code,
|
||||
t.measured_at,
|
||||
t.pv_power_w,
|
||||
t.battery_soc_percent,
|
||||
t.battery_power_w,
|
||||
t.grid_power_w,
|
||||
t.load_power_w,
|
||||
t.inverter_temp_c,
|
||||
t.operating_mode,
|
||||
t.fault_code,
|
||||
now() - t.measured_at as data_age,
|
||||
t.pv1_power_w,
|
||||
t.pv2_power_w,
|
||||
t.gen_port_power_w,
|
||||
t.batt_charge_today_wh,
|
||||
t.batt_discharge_today_wh,
|
||||
t.run_state,
|
||||
t.is_export_limited,
|
||||
t.pv_derating_flags
|
||||
from ems.asset_inverter inv
|
||||
left join lateral (
|
||||
select
|
||||
ti.measured_at,
|
||||
ti.pv_power_w,
|
||||
ti.battery_soc_percent,
|
||||
ti.battery_power_w,
|
||||
ti.grid_power_w,
|
||||
ti.load_power_w,
|
||||
ti.inverter_temp_c,
|
||||
ti.operating_mode,
|
||||
ti.fault_code,
|
||||
ti.pv1_power_w,
|
||||
ti.pv2_power_w,
|
||||
ti.gen_port_power_w,
|
||||
ti.batt_charge_today_wh,
|
||||
ti.batt_discharge_today_wh,
|
||||
ti.run_state,
|
||||
ti.is_export_limited,
|
||||
ti.pv_derating_flags
|
||||
from ems.telemetry_inverter ti
|
||||
where ti.inverter_id = inv.id
|
||||
order by ti.measured_at desc
|
||||
limit 1
|
||||
) t on true
|
||||
where t.measured_at is not null;
|
||||
|
||||
COMMENT ON VIEW ems.vw_latest_inverter IS
|
||||
'Nejnovější telemetrická data pro každý střídač. Slouží pro real-time dashboard a health check.';
|
||||
comment on view ems.vw_latest_inverter is
|
||||
'Nejnovější telemetrická data pro každý střídač (LATERAL per-inverter, PK index). Slouží pro real-time dashboard a health check.';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE VIEW ems.vw_latest_ev_charger
|
||||
WITH (security_invoker = false)
|
||||
AS
|
||||
SELECT DISTINCT ON (t.charger_id, t.connector_id)
|
||||
t.site_id,
|
||||
t.charger_id,
|
||||
ch.code AS charger_code,
|
||||
t.connector_id,
|
||||
t.measured_at,
|
||||
t.status,
|
||||
t.power_w,
|
||||
t.energy_kwh,
|
||||
t.current_a,
|
||||
t.session_id,
|
||||
t.error_code,
|
||||
now() - t.measured_at AS data_age
|
||||
FROM ems.telemetry_ev_charger t
|
||||
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
|
||||
ORDER BY t.charger_id, t.connector_id, t.measured_at DESC;
|
||||
create or replace view ems.vw_latest_ev_charger
|
||||
with (security_invoker = false)
|
||||
as
|
||||
select
|
||||
ch.site_id,
|
||||
ch.id as charger_id,
|
||||
ch.code as charger_code,
|
||||
conn.connector_id,
|
||||
t.measured_at,
|
||||
t.status,
|
||||
t.power_w,
|
||||
t.energy_kwh,
|
||||
t.current_a,
|
||||
t.session_id,
|
||||
t.error_code,
|
||||
now() - t.measured_at as data_age
|
||||
from ems.asset_ev_charger ch
|
||||
-- konektory za posledních 30 dní (tabulka konektorů neexistuje; konektor bez
|
||||
-- telemetrie 30 dní je pro „latest“ dashboard mrtvý)
|
||||
left join lateral (
|
||||
select distinct tc.connector_id
|
||||
from ems.telemetry_ev_charger tc
|
||||
where tc.charger_id = ch.id
|
||||
and tc.measured_at >= now() - interval '30 days'
|
||||
) conn on true
|
||||
left join lateral (
|
||||
select
|
||||
te.measured_at,
|
||||
te.status,
|
||||
te.power_w,
|
||||
te.energy_kwh,
|
||||
te.current_a,
|
||||
te.session_id,
|
||||
te.error_code
|
||||
from ems.telemetry_ev_charger te
|
||||
where te.charger_id = ch.id
|
||||
and te.connector_id = conn.connector_id
|
||||
order by te.measured_at desc
|
||||
limit 1
|
||||
) t on true
|
||||
where t.measured_at is not null;
|
||||
|
||||
COMMENT ON VIEW ems.vw_latest_ev_charger IS
|
||||
'Nejnovější telemetrická data pro každý konektor EV nabíječky. Slouží pro dashboard a řízení nabíjení.';
|
||||
comment on view ems.vw_latest_ev_charger is
|
||||
'Nejnovější telemetrická data pro každý konektor EV nabíječky (LATERAL per-konektor, PK index). Slouží pro dashboard a řízení nabíjení.';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE VIEW ems.vw_latest_heat_pump
|
||||
WITH (security_invoker = false)
|
||||
AS
|
||||
SELECT
|
||||
hp.site_id,
|
||||
hp.id AS heat_pump_id,
|
||||
hp.code AS heat_pump_code,
|
||||
t.measured_at,
|
||||
t.outdoor_temp_c,
|
||||
t.tuv_tank_temp_c,
|
||||
t.water_outlet_temp_c,
|
||||
t.power_w,
|
||||
t.operating_mode,
|
||||
t.cop_actual,
|
||||
t.defrost_active,
|
||||
t.alarm_code,
|
||||
-- Odhadovaný COP pro aktuální venkovní teplotu
|
||||
ems.fn_cop_estimate(hp.id, t.outdoor_temp_c) AS cop_estimated,
|
||||
now() - t.measured_at AS data_age
|
||||
FROM ems.asset_heat_pump hp
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
create or replace view ems.vw_latest_heat_pump
|
||||
with (security_invoker = false)
|
||||
as
|
||||
select
|
||||
hp.site_id,
|
||||
hp.id as heat_pump_id,
|
||||
hp.code as heat_pump_code,
|
||||
t.measured_at,
|
||||
t.outdoor_temp_c,
|
||||
t.tuv_tank_temp_c,
|
||||
t.water_outlet_temp_c,
|
||||
t.power_w,
|
||||
t.operating_mode,
|
||||
t.cop_actual,
|
||||
t.defrost_active,
|
||||
t.alarm_code,
|
||||
-- Odhadovaný COP pro aktuální venkovní teplotu
|
||||
ems.fn_cop_estimate(hp.id, t.outdoor_temp_c) as cop_estimated,
|
||||
now() - t.measured_at as data_age
|
||||
from ems.asset_heat_pump hp
|
||||
left join lateral (
|
||||
select
|
||||
thp.measured_at,
|
||||
thp.outdoor_temp_c,
|
||||
thp.tuv_tank_temp_c,
|
||||
@@ -95,12 +145,12 @@ LEFT JOIN LATERAL (
|
||||
thp.cop_actual,
|
||||
thp.defrost_active,
|
||||
thp.alarm_code
|
||||
FROM ems.telemetry_heat_pump thp
|
||||
WHERE thp.heat_pump_id = hp.id
|
||||
ORDER BY thp.measured_at DESC
|
||||
LIMIT 1
|
||||
) t ON true;
|
||||
from ems.telemetry_heat_pump thp
|
||||
where thp.heat_pump_id = hp.id
|
||||
order by thp.measured_at desc
|
||||
limit 1
|
||||
) t on true;
|
||||
|
||||
COMMENT ON VIEW ems.vw_latest_heat_pump IS
|
||||
comment on view ems.vw_latest_heat_pump is
|
||||
'Nejnovější telemetrická data pro každé tepelné čerpadlo včetně odhadovaného COP.
|
||||
Slouží pro real-time dashboard a rozhodovací logiku plánování.';
|
||||
|
||||
@@ -93,6 +93,10 @@ services:
|
||||
OPEN_METEO_API_URL: ${OPEN_METEO_API_URL:-https://api.open-meteo.com/v1/forecast}
|
||||
TELEMETRY_POLL_INTERVAL_SEC: ${TELEMETRY_POLL_INTERVAL_SEC:-60}
|
||||
PLANNING_HP_MAX_COST_CZK_KWH: ${PLANNING_HP_MAX_COST_CZK_KWH:-3.0}
|
||||
# Plánovač v1/v2 (docs/refactor-clean-planner.md): shadow porovnání zapnuto,
|
||||
# aktivní zůstává v1; přepnutí = PLANNING_ENGINE_VERSION=v2 v /opt/ems-deploy/.env.
|
||||
PLANNING_ENGINE_VERSION: ${PLANNING_ENGINE_VERSION:-v1}
|
||||
PLANNING_ENGINE_COMPARE_ENABLED: ${PLANNING_ENGINE_COMPARE_ENABLED:-true}
|
||||
LOXONE_USER: ${LOXONE_USER:-}
|
||||
LOXONE_PASSWORD: ${LOXONE_PASSWORD:-}
|
||||
POSTGREST_JWT_SECRET: ${POSTGREST_JWT_SECRET}
|
||||
|
||||
@@ -127,13 +127,15 @@ CREATE TABLE asset_battery (
|
||||
-- planner_max_soc_percent, planner_discharge_floor_percent,
|
||||
-- planner_extreme_buy_threshold_czk_kwh,
|
||||
-- planner_terminal_soc_value_factor
|
||||
-- V077: planner_daytime_charge_target_enabled, planner_night_baseload_buffer_percent,
|
||||
-- planner_daytime_charge_price_quantile, planner_charge_commitment_penalty_czk_kwh
|
||||
);
|
||||
```
|
||||
|
||||
### `asset_pv_array`
|
||||
Každé FVE pole zvlášť – důležité pro predikci (azimut, sklon). **Zelený bonus** (dotace za vyrobenou elektřinu) se váže na **úroveň pole**, ne na celou lokalitu: které pole má bonus, jaká je sazba Kč/kWh a platnost, určují sloupce `green_bonus_*`. Výpočet příjmu za interval probíhá funkcí `ems.fn_green_bonus_revenue` z výroby v Wh; není součástí efektivní prodejní ceny ze sítě.
|
||||
|
||||
**Deye reg 340 (max solar power, W):** strop pro řiditelné DC pole A na hybridu počítá `ems.fn_inverter_pv_a_max_w(inverter_id)` jako **součet `nominal_power_wp`** řádků s `controllable = true` vázaných na daný invertor. Zápis z EMS je povolen jen na lokalitách se **zeleným bonusem na PV poli** (`ems.fn_site_has_active_green_bonus_pv(site_id)` — aktivní `asset_pv_array.green_bonus_*` v kalendářním dni Europe/Prague); jinak EMS reg 340 nemění (invertor zůstane na poslední hodnotě).
|
||||
**Deye reg 340 (max solar power, W):** strop `ems.fn_inverter_pv_a_max_w(inverter_id)` = `asset_inverter.deye_reg340_max_solar_w` (seed home-01 **32 000**, ostatní Deye **65 000**), fallback `max_dc_input_w`, pak součet Wp řiditelných polí; funkce vrací **0** bez řiditelného PV A. Spodní limit zápisu: `deye_reg340_min_solar_w` (home-01 **400**, jinde **0**). Zápis jen se zeleným bonusem (`fn_site_has_active_green_bonus_pv`).
|
||||
|
||||
```sql
|
||||
CREATE TABLE asset_pv_array (
|
||||
@@ -359,7 +361,7 @@ CREATE TABLE planning_run (
|
||||
horizon_end TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
status TEXT DEFAULT 'draft', -- 'draft', 'approved', 'active', 'superseded'
|
||||
solver_params JSONB,
|
||||
solver_params JSONB, -- po V077: JSON z planning_engine (masks, soc_bounds, objective_terms, …)
|
||||
notes TEXT
|
||||
);
|
||||
```
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
- Odešle potvrzovací setpointy do Loxone přes HTTP (Loxone jako exekutor fallback logiky)
|
||||
- Loguje každý write pro audit
|
||||
|
||||
### `export_mode` / `export_limit_w` (V078+)
|
||||
|
||||
Solver ukládá záměr exportu (`NONE` / `PV_SURPLUS` / `BATTERY_SELL`) a cap `export_limit_w`. U **`PV_SURPLUS`** (přetok FVE, ne prodej z baterie):
|
||||
|
||||
- **reg 142** = `deye_zero_export_mode` z DB (KV1/BA81 typicky **2** — zero export k CT/zátěži; **ne** selling first)
|
||||
- **reg 108 = 0**, **reg 109 = max** — baterie se přes limit nabíjení neplní, přebytek jde do sítě (**145 = 1**)
|
||||
- **reg 143** z `export_limit_w` / site cap
|
||||
|
||||
Implementace: `setpoints._is_passive_pv_surplus_export`, `deye_battery_charge_discharge_amps`. Ověření: log `reg142=2`, `charge_a=0` při `export_mode=PV_SURPLUS`.
|
||||
|
||||
---
|
||||
|
||||
## Architektura řízení
|
||||
@@ -48,6 +58,21 @@ Ověření: logy backendu kolem pokusu **nebo** `select id,status,created_at fro
|
||||
|
||||
---
|
||||
|
||||
## Exekuční pojistky exportu (AUTO)
|
||||
|
||||
Po `_build_setpoints`, před zápisem Modbus (`orchestrator.export_setpoints`):
|
||||
|
||||
| Guard | Podmínka | Efekt |
|
||||
|-------|----------|--------|
|
||||
| **`_apply_export_plan_guard`** | `effective_sell_price < 0` **nebo** (`export_mode = NONE` a `grid_setpoint_w ≥ 0`) | PASSIVE, `export_ban`, `grid_export_limit = 0`, vybíjení baterie do sítě vynulováno (`battery_w = max(0, …)`), `deye_physical_mode = PASSIVE` |
|
||||
| **`_apply_price_failsafe_guard`** | `is_predicted_price = true` | PASSIVE, všechny výkonové setpointy 0, žádný export |
|
||||
|
||||
Implementace: `backend/services/control/setpoints.py`. Ověření: `pytest backend/tests/test_control_export_plan_guard.py`.
|
||||
|
||||
**Poznámka:** PV B (nekontrolovatelné pole) může při záporné vykupní stále fyzicky exportovat — pojistka řídí Deye (baterie + řízené FVE A), ne mikroinvertory na GEN bez cut-off.
|
||||
|
||||
---
|
||||
|
||||
## Logika exportu
|
||||
|
||||
```python
|
||||
@@ -128,14 +153,17 @@ registru **178** (v některých manuálech/UI uváděno jako “register 179”
|
||||
- `deye_gen_cutoff_enabled = true` → reg **178** bits **0–1** = **3** (`11b`, enable = cut-off **ON** / export blokován)
|
||||
- `deye_gen_cutoff_enabled = false` → reg **178** bits **0–1** = **2** (`10b`, disable = cut-off **OFF** / export povolen)
|
||||
|
||||
**Exekuční pravidlo (2026-06-06):** pokud plán zakazuje vývoz (`export_ban`, typicky záporná vykupní + `grid_setpoint_w ≥ 0`), exporter zapne cut-off **i když** solver uložil `deye_gen_cutoff_enabled = false` — v LP může být PV B modelované jen do domu, ale mikroinvertory na GEN portu bez cut-off fyzicky exportují do sítě. Implementace: `deye_mi_export_cutoff_want_enabled()` v `deye_helpers.py`, volá `write_inverter_setpoints` v `inverter.py`; `_passive_no_export_guard` nastaví flag v `ControlSetpoints`.
|
||||
|
||||
Zápisy se ukládají do `ems.modbus_command` a ověřují v `verify_modbus_commands` (porovnává se pouze maska
|
||||
bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg 178).
|
||||
|
||||
### PV A curtailment — zápis reg 340 (max solar power)
|
||||
|
||||
- **Implementace:** `backend/services/control/exporter_monolith.py` — `export_setpoints` načte cap v `_load_inverter_config` (`ems.fn_inverter_pv_a_max_w(ai.id)`), `_build_setpoints` v režimu **AUTO** dopočítá `ControlSetpoints.pv_a_allowed_w`, `write_inverter_setpoints` zařadí **reg 340**, pokud je `fn_site_has_active_green_bonus_pv` aktivní, cap > 0 a `pv_a_allowed_w` je vyplněné.
|
||||
- **Data:** `pv_a_forecast_solver_w` / `pv_a_curtailed_w` z aktivního `planning_interval` (json z `ems.fn_planning_interval_at_offset`); cap = součet `nominal_power_wp` řiditelných polí na invertoru (bez nového sloupce v DB).
|
||||
- **Data:** `pv_a_forecast_solver_w` / `pv_a_curtailed_w` z aktivního `planning_interval`; cap = `fn_inverter_pv_a_max_w` (`deye_reg340_max_solar_w` na `asset_inverter`, home-01 **32 kW**, ostatní **65 kW**); min = `deye_reg340_min_solar_w` (home-01 **400 W**).
|
||||
- **Policy PV A off (jen na site se zeleným bonusem na PV):** pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` a v aktuálním slotu zároveň `effective_buy_price < 0` a `effective_sell_price < 0` a `pv_b_forecast_solver_w > 0` (PV B vyrábí), exporter nastaví `pv_a_allowed_w = 0` (reg 340) i když je forecast PV A nulový — cílem je držet headroom v baterii pro PV B / další záporný nákup.
|
||||
- **Bez zápisu reg 340:** `plan_skips_deye_reg340_write` — žádný export z plánu, `battery_setpoint_w ≤ 0`, `pv_a_curtailed_w = 0` → `pv_a_allowed_w = None` (invertor řídí pole A sám). Ověření: `pytest backend/tests/test_control_exporter_reg340.py`.
|
||||
- **Verify:** reg **340** není kritický → po 3× mismatch verify **bez** přepnutí do SELF_SUSTAIN (stejně jako reg 178); viz [`modbus-command-journal.md`](modbus-command-journal.md).
|
||||
|
||||
#### Ověření po nasazení (smoke)
|
||||
@@ -150,9 +178,9 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
|
||||
|---|---|
|
||||
| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 |
|
||||
| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 |
|
||||
| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** dle `_deye_zero_export_amps_for_passive` (viz `operating-modes.md`) |
|
||||
| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** z `deye_battery_charge_discharge_amps()` v `setpoints.py` (volá `write_inverter_setpoints`) |
|
||||
|
||||
**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **143** je tvrdý limit exportu z lokality/invertoru (ne forecast). Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*).
|
||||
**PASSIVE** (AUTO, ZERO): **`export_mode = PV_SURPLUS`** → **108 = 0**, **109 = max**, **142** = `deye_zero_export_mode` (selling first **jen** u **SELL** z baterie). **`export_mode = NONE`** a `battery_w > 0` (nabíjení z FVE, záporná vykupní) → **108 = max**. Reg. **145**: **0** při `export_ban`, jinak **1**. Reg. **143** = tvrdý cap z plánu/lokality.
|
||||
|
||||
**SELF_SUSTAIN** zůstává **PASSIVE** v `get_deye_mode`; **108/109** jsou vždy **max z DB** (bez variant ZERO). Rozdíl je **`self_sustain_local_use=True`**: **TOU SOC** = **`min_soc_percent`**, `battery_w=None`.
|
||||
|
||||
@@ -160,8 +188,8 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
|
||||
|
||||
| Registr | Charge | PASSIVE (ZERO) | SELL | Self-consumption |
|
||||
|---|---|---|---|---|
|
||||
| **108** (charge A) | škálo dle `battery_w` | max / **0** (FVE přetok) | **0** | dle varianty |
|
||||
| **109** (discharge A) | **0** | max / **0** (import, držet bat.) | **max z DB** | dle varianty |
|
||||
| **108** (charge A) | škálo dle `battery_w` | max / **0** (export bez nabíjení) / **max** při PASSIVE + `battery_w>0` (FVE do baterie až po strop) | **nezapisuje EMS** | dle varianty |
|
||||
| **109** (discharge A) | **0** | max / **0** (import, držet bat.) / **max** při PASSIVE + `battery_w>0` | **max z DB** | dle varianty |
|
||||
| **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` |
|
||||
| **143** (export cap) | max z DB | max z DB | max z DB (tvrdý limit, bez forecast heuristiky) | max z DB |
|
||||
| **145** (solar sell) | 1 / 0 při `export_ban` | 1 / 0 při `export_ban` | 1 | 1 / 0 při `export_ban` |
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
poslední dostupné uložené forecasty; forecast nespouští implicitně před každým
|
||||
plánovacím během.
|
||||
|
||||
## Kanonický forecast pro plánování (single source of truth)
|
||||
|
||||
Pro plánování (solver) a UI tabulky slotů je **kanonický** výkon FVE počítaný v DB funkcí:
|
||||
|
||||
- `ems.fn_forecast_pv_slots_range_canonical_ab(...)`
|
||||
|
||||
Ta kombinuje dvě korekce do jedné řady:
|
||||
|
||||
- **delta-korekci** per `pv_array_id` (z `ems.fn_pv_forecast_delta_profile`)
|
||||
- **rolling multiplikativní faktor** vs telemetrie (z `ems.fn_pv_forecast_correction_factor`) s lineárním **decay** do 1.0
|
||||
|
||||
Výstup je rozdělený na **PV‑A** (`controllable=true`, curtailment v LP) a **PV‑B** (`controllable=false`).
|
||||
|
||||
---
|
||||
|
||||
## FVE pole na první instalaci (home-01)
|
||||
@@ -97,6 +110,7 @@ power_w = (poa_global * area_m2 * 0.20 * shading_factor).clip(
|
||||
- Default je `7` dní.
|
||||
- Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů.
|
||||
- **Kalibrace delty:** `GET /api/v1/sites/{site_id}/forecast/pv-delta-profile?from=…&to=…` vrací JSON z `ems.fn_pv_forecast_delta_profile` (`deltas`, `deltas_by_array`, `delta_learn_min_ts` z `ems.site_pv_forecast_calibration`). Volitelné query parametry: `half_life_days`, `threshold_w`, `top_n_days`, `non_top_day_factor`, `day_weight_gamma` (NULL u numerických přepsání = hodnota z kalibrační tabulky / default funkce).
|
||||
- **Cache delty (V079):** sloupce `delta_profile_cache` / `delta_profile_cached_at` v `site_pv_forecast_calibration`; refresh `ems.fn_refresh_site_pv_delta_profile_cache(site_id)` po `fn_fill_forecast_accuracy` a po PATCH kalibrace; čtení pro plánování/UI přes `fn_pv_forecast_delta_profile_cached` (TTL 30 min, pak fallback na plný přepočet).
|
||||
- **Úprava kalibrace z API:** `PATCH /api/v1/sites/{site_id}/configuration/pv-forecast-calibration` s JSON tělem (částečný update); odpověď je aktuální řádek kalibrace. Souhrn konfigurace v `GET …/configuration` obsahuje klíč `pv_forecast_calibration`.
|
||||
- **Telemetrie pro učení delty:** `telemetry_collector` při Modbus poll čte reg. **145** a **178**; `fn_telemetry_inverter_sample` ukládá `is_export_limited` / `pv_derating_flags` (bity 1 = solar sell off, 2 = GEN/MI cut-off aktivní dle masky `(reg178 & 3) == 3`). `fn_fill_forecast_accuracy` sloty s těmito signály označí `telemetry_derating`.
|
||||
|
||||
|
||||
@@ -127,6 +127,45 @@ Marže se konfigurují v `site_market_config`:
|
||||
|
||||
Denní ekonomika v DB (`ems.fn_economics_daily_for_window`, repeatable `R__068_fn_economics_daily_month.sql`) musí používat stejnou kombinaci jako `fn_effective_buy_price` (komentář ve funkci).
|
||||
|
||||
**Plánování:** efektivní `buy_price` per 15min slot už nese skok **VT→NT** (distribuce v `fn_effective_buy_price`). Maska grid nabíjení v `fn_load_planning_slots_full` navíc vyžaduje `buy ≤ min(buy v příštích 4 slotech) + ε`, aby se neplánoval import v posledním VT slotu před levným NT — viz `docs/04-modules/planning.md`.
|
||||
|
||||
### Screening skript pro dimenzování baterie
|
||||
|
||||
Analytický skript `scripts/analysis/battery_sizing_screen.py` umí pro nákup v režimu spot simulovat screening režimy bez vazby na konkrétní `site_market_config` (kromě presetu home-01):
|
||||
|
||||
- **`--buy-home-01`:** stejná struktura jako `ems.fn_effective_buy_price` pro **home-01** dle živé `site_market_config` (ověř MCP): raw OTE ×**(1+9 %)** / ×**(1−9 %)** při záporné raw, + distribuce **NT 0,2243 / VT 0,74987** Kč/kWh dle HDO **09–10, 12–13, 16–17, 20–21**, + SS **0,192**, OTE **0,001**, DPH **×1,21**; prodej v EMS **`sell_margin_fixed = −0,30`** (ne −0,02 ze seedu).
|
||||
|
||||
Dále obecné režimy:
|
||||
|
||||
- `--buy-spot-add-fixed-kwh X`: základ nákupu = `raw_ote + X`
|
||||
- `--buy-spot-asym-pct P`: základ nákupu = `raw_ote × (1 + P/100)` pro `raw_ote >= 0`, resp. `raw_ote × (1 - P/100)` pro `raw_ote < 0`
|
||||
|
||||
V obou případech skript ke každému importnímu slotu fixně přičte:
|
||||
|
||||
- `--buy-distribution-kwh`
|
||||
- `--buy-other-fees-kwh`
|
||||
|
||||
Volitelně pak na celý součet aplikuje:
|
||||
|
||||
- `--buy-vat-multiplier` (např. `1.21`)
|
||||
|
||||
Tato logika je implementovaná přímo ve `build_buy_prices_96()` v `scripts/analysis/battery_sizing_screen.py`. Účel je screening nové lokality nebo obchodního modelu ještě před seedem do DB; nejde o náhradu `ems.fn_effective_buy_price`.
|
||||
|
||||
Skript navíc v `solve_one_day()` explicitně zakazuje současný import a export do sítě v jednom 15min slotu a zároveň současné nabíjení a vybíjení baterie. Tím se eliminuje artefakt, kdy by při výhodnějším `buy` než `sell` model vytvářel umělý „loop“ bez fyzického významu.
|
||||
|
||||
Pro delší běhy (měsíce / rok) lze runtime řídit přímo z CLI:
|
||||
|
||||
- `--solver-time-limit-sec` = CBC limit na jeden den
|
||||
- `--progress-every-days` = po kolika dnech skript vytiskne průběh (`0` = ticho)
|
||||
|
||||
To je důležité hlavně po zavedení binárních proměnných pro zákaz současného `import+export` a `charge+discharge`, protože roční běhy jsou výrazně pomalejší než původní čisté LP.
|
||||
|
||||
Ověření:
|
||||
|
||||
- spusť skript nad krátkým vzorkem OTE (`--price-csv` nebo `--db`) a zkontroluj vypsané shrnutí režimu nákupu
|
||||
- pro asymetrickou variantu ověř, že záporné ceny používají faktor `1 - P/100`, nikoli `1 + P/100`
|
||||
- pro arbitráž bez FVE použij `--pv-daily-kwh-summer 0 --pv-daily-kwh-winter 0 --load-kw 0`
|
||||
|
||||
**Zelený bonus** není součástí `fn_effective_sell_price` ani view efektivní prodejní ceny – jde o samostatný příjem z výroby, viz níže.
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +60,7 @@ pro **reg 178** (spolu s peak shaving bity 4–5).
|
||||
| Job | Frekvence | Popis |
|
||||
|-----|-----------|--------|
|
||||
| `verify_modbus` | každé **2 min** | Pro každou aktivní site vybere `written` příkazy s `written_at` v posledních **20 min** a zavolá `verify_modbus_commands`. |
|
||||
| `plan_actual_slot_guard` | **:05, :20, :35, :50** (po `audit_filler`) | `ems.fn_plan_actual_slot_guard_all_active` (+ `plan_actual_slot_guard.py` jen Discord): poslední 2 uzavřené 15min sloty — fatální odchylka **plán vs. audit síť** → **Discord** (`critical`), dedup přes `ems.plan_fatal_deviation_sent`. |
|
||||
| `plan_actual_slot_guard` | **:05, :20, :35, :50** (po `audit_filler`) | `ems.fn_plan_actual_slot_guard_all_active` (+ `plan_actual_slot_guard.py` jen Discord): poslední 2 uzavřené 15min sloty — fatální odchylka **plán vs. audit síť** → **Discord** (`critical`), dedup přes `ems.plan_fatal_deviation_sent`. Kódy: `GRID_SIGN_MISMATCH`, `GRID_EXPORT_SPIKE`, **`NEG_SELL_EXPORT`** (`sell < 0` a skutečný vývoz < −4 kW), `GRID_LARGE_DEVIATION`, … Exekuční pojistka proti opakovanému vývozu: [`control.md`](control.md) — `_apply_export_plan_guard`. |
|
||||
|
||||
Plná tabulka jobů je v [`lifespan.py`](../../backend/app/lifespan.py).
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi
|
||||
|
||||
| Reg | Název | Rozsah | Jednotka | Použití v EMS |
|
||||
|-----|-------|--------|----------|---------------|
|
||||
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). Ve **PASSIVE** (AUTO) podle `_deye_zero_export_amps_for_passive`: výchozí **max**, u exportu v plánu bez vybíjení **0**. **CHARGE:** proud z `battery_w` přes `battery_watts_to_amps`. **SELL:** **0**. |
|
||||
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Ve **PASSIVE** (AUTO): výchozí **max**, u importu bez nabíjení **0**; **SELL** max vybíjení; **CHARGE** typicky **0**. |
|
||||
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). **PASSIVE** + plán chce nabíjet (`battery_w>0`): **108 = max** (špička FVE nesmí být omezená průměrem slotu). **PASSIVE** + export bez nabíjení: **0**. **CHARGE:** z `battery_w` přes `battery_watts_to_amps`. **SELL:** EMS **nezapisuje** (selling first = reg **142**; zbytečné nulování/obnova). |
|
||||
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Ve **PASSIVE** (AUTO): výchozí **max**, u importu bez nabíjení **0**; při **PASSIVE + `battery_w>0` + export** zůstává **max** (domácnost z baterie při výpadku PV). **SELL** max vybíjení; **CHARGE** typicky **0**. |
|
||||
| 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě. Firmware automaticky sníží reálný proud tak, aby `load + battery_charge` nepřekročil velikost jističe — proto LP v `planning_engine.py` plánuje `gi[t]` až **do `max_import_power_w + BMS_max_charge`**, aby uměl využít cenově nejlepší 15min okna pro nabíjení na plný BMS strop (viz `planning.md` sekce „Plánovací strop gi[t] vs. fyzický jistič"). Fyzické dodržení jističe drží reg 128 + firmware. |
|
||||
| 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě |
|
||||
| 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) |
|
||||
| 142 | Limit control (System work mode) | 0/1/2 | — | **0** = selling first, **1** = zero export to load, **2** = zero export to CT. Hodnota v non-SELL režimech pochází z `asset_inverter.deye_zero_export_mode` (závisí na instalaci – viz tabulka níže). V režimu SELL vždy **0**. |
|
||||
| 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; při 108 = 0 a 145 = 1 přebytky z řiditelného stringu typicky tečou do sítě (viz pass-through níže). |
|
||||
| 340 | Max solar power | 0 … cap (W) | 1 W | Strop výkonu DC PV řízeného střídačem (pole A). EMS zapisuje jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (zelený bonus na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`. Hodnota z aktivního `planning_interval`: bez curtailmentu = cap; s `pv_a_curtailed_w > 0` = `clamp(0, cap, pv_a_forecast_solver_w − pv_a_curtailed_w)`. **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. |
|
||||
| 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; směr přebytku (baterie vs. síť) řeší energie management měniče a **142**, ne umělé **108 = 0** (viz pass-through níže). |
|
||||
| 340 | Max solar power | min … cap (W) | 1 W | Strop výkonu DC PV řízeného střídačem (pole A). Cap z `fn_inverter_pv_a_max_w` (`deye_reg340_max_solar_w`, typ. **32 000** home-01, **65 000** větší hybridy), ne součet Wp — studené panely mohou překročit nominál. Min z `deye_reg340_min_solar_w` (home-01 **400 W**, jinde **0** dle firmware). EMS zapisuje jen při zeleném bonusu a cap > 0. **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN`. |
|
||||
| 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w`. EMS ji neodvozuje z forecastu ani z `grid_setpoint_w`; pro exportní sloty je to tvrdý site/inverter cap. |
|
||||
| 178 | Control board special 1 | bitmask | — | Bitové pole pro více funkcí. **EMS používá:** (a) bits **4–5** pro peak shaving switch: **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE/CHARGE**. (b) **BA81:** bits **0–1** pro „MI export to Grid cutoff“ (AC coupling / GEN): **2** = disable (cutoff OFF), **3** = enable (cutoff ON). EMS zapisuje jako **read-modify-write** (zachová ostatní bity). V některých manuálech/UI je to označené jako „register 179“ (1-based). |
|
||||
| 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu |
|
||||
@@ -30,8 +30,10 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi
|
||||
### Reg 340 (max solar power)
|
||||
|
||||
- **FC 0x10**, jednotka **W**; firmware omezuje strop výroby z řiditelných stringů (pole A na hybridu).
|
||||
- **Kdy EMS zapisuje:** `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (součet `nominal_power_wp` z `asset_pv_array` kde `controllable = true`). Při součtu **0** nebo bez aktivního zeleného bonusu EMS reg 340 **nezapisuje** (ruční hodnota v invertoru zůstane).
|
||||
- **Kdy EMS zapisuje:** `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (řiditelné pole A + nenulový strop střídače z `deye_reg340_max_solar_w` / `max_dc_input_w`). Bez bonusu nebo cap **0** EMS reg 340 **nezapisuje**.
|
||||
- **Hodnota:** z `ControlSetpoints.pv_a_allowed_w` (AUTO): bez curtailmentu = plný cap; při `pv_a_curtailed_w > 0` viz tabulka výše. Režimy **SELF_SUSTAIN / PRESERVE / CHARGE_CHEAP** mají `pv_a_allowed_w = None` → žádný zápis 340 z EMS v daném ticku.
|
||||
- **Bez zápisu 340 (2026-05):** pokud plán má **bez exportu** (`export_mode = NONE` nebo `grid_setpoint_w ≥ 0` a `export_limit_w = 0`), **bez nabíjení baterie** (`battery_setpoint_w ≤ 0`) a **bez curtailu A** (`pv_a_curtailed_w = 0`), EMS reg 340 **neposílá** — Deye řídí PV A přes **108/109/142** a při **plné baterii** typicky **solar sell off** (hardware). Funkce `plan_skips_deye_reg340_write` v `setpoints.py`. **v51:** navíc při **`pv_a_forecast_solver_w < 1500`** a **`pv_a_curtailed_w = 0`** (úsvit + MI) → **bez reg 340** i při malém exportu. **Plánovač v32:** škrcení A v okně `sell < 0` jde přes `pv_a_curtailed_w` → reg 340; registry 108/109 se kvůli fázím nemění.
|
||||
- **Výjimka:** explicitní curtail v plánu nebo záporné buy+sell s PV B → `pv_a_allowed_w` se dopočítá / vynuluje jako dřív.
|
||||
- **Živé čtení:** `read_deye_registers_live` vrací **`reg340_max_solar_power_w`** (integer) jen pokud je přepínač zapnutý; jinak **`null`** (bez extra FC3 čtení reg 340).
|
||||
|
||||
### Reg 191 (výkon grid peak shaving)
|
||||
@@ -66,7 +68,7 @@ Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivn
|
||||
|
||||
Režim **CHARGE_CHEAP** nastaví oba setpointy na stejný kladný výkon (min. 1 W), aby byl výsledek **CHARGE**.
|
||||
|
||||
**PASSIVE (ZERO):** reg. **108/109** podle `_deye_zero_export_amps_for_passive` — při exportu v plánu bez vybíjení je **108 = 0** (přetok FVE); při importu bez nabíjení je **109 = 0** (držet baterii). Jinak oba max (AUTO). Detail: `operating-modes.md`.
|
||||
**PASSIVE (ZERO):** u slotu **`export_mode = PV_SURPLUS`** exporter nastaví **108 = 0** (nabíjecí proud), **109 = max** — baterie nemá kam brát přebytek FVE, jde do sítě při **145 = 1**; **142** zůstává **`deye_zero_export_mode`** (u CT často **2** = zero export k měření zátěže, ne selling first z baterie). Detail: `operating-modes.md`.
|
||||
|
||||
### BA81: GEN port cut-off (reg 178 bits0–1) z plánu
|
||||
|
||||
@@ -95,9 +97,9 @@ Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_s
|
||||
|---|---|---|---|---|
|
||||
| **Kdy** | `bat_w > 0`, `grid_w > 0` | typicky `grid_w < 0`, `bat_w ≥ 0` | `grid_w < 0`, `bat_w < 0` | import, `bat_w ≤ 0` či mix |
|
||||
| **Deye mode** | CHARGE | PASSIVE | SELL | PASSIVE |
|
||||
| **108** charge A | dle `bat_w` | **0** při exportu bez vybíjení | **0** | max nebo **0** dle varianty |
|
||||
| **108** charge A | dle `bat_w` | **0** při exportu bez vybíjení | **nezapisuje EMS** | max nebo **0** dle varianty |
|
||||
| **109** discharge A | **0** | max | **max** | **0** při importu bez nabíjení, jinak max |
|
||||
| **142** limit control | `deye_zero_export_mode` (1 nebo 2) | `deye_zero_export_mode` (1 nebo 2) | **0** (selling first) | `deye_zero_export_mode` (1 nebo 2) |
|
||||
| **142** limit control | `deye_zero_export_mode` (1 nebo 2) | **`deye_zero_export_mode`** (1/2 = zero export k load/CT; **ne** „blokace do sítě“). Přetok FVE do sítě: **108=0**, **145=1** | **0** (selling first) | `deye_zero_export_mode` (1 nebo 2) |
|
||||
| **145** solar sell | **1** (enabled) | **1** (enabled) | **1** (enabled) | **1** (enabled) |
|
||||
| **178** peak shaving | 48 (PASSIVE) | 48 (PASSIVE) | **32** (SELL) | 48 (PASSIVE) |
|
||||
| **143** export limit | max export W z DB | max export W z DB | max export W z DB | max export W z DB |
|
||||
@@ -108,12 +110,12 @@ Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_s
|
||||
|
||||
**CHARGE:** TOU řádek nese **`max_soc_percent`** z DB (**clamp 10–100**) jako cíl při **grid charge** (spolu s příznakem grid charge v time pointu). **Energy pattern** („load first“ / „battery first“) v UI střídače zůstává v kompetenci instalace — EMS ho přes Modbus nenastavuje.
|
||||
|
||||
**Jak funguje pass-through fyzicky:**
|
||||
**Jak funguje pass-through (logicky):**
|
||||
|
||||
1. Reg 108 = 0 → baterie se fyzicky nemůže nabíjet (Deye ji považuje za „plnou")
|
||||
2. Reg 142 = 1/2 → zero export mode (Deye nebude aktivně prodávat z baterie)
|
||||
3. Reg 145 = 1 → solar sell enabled: protože baterie je „plná" (108 = 0), PV přebytky tečou do sítě
|
||||
4. Reg 109 = max → pokud spotřeba překročí FVE, baterie může vybíjet (ochrana self-consumption)
|
||||
1. **108 / 109** typicky **max** z invertoru — horní limity, ne příkaz „nabíjej / vybíjej“.
|
||||
2. Reg **142** = 1/2 → zero export to load / CT (instalace závislá).
|
||||
3. Reg **145** = 1 → solar sell enabled; přebytek řiditelné FVE po zátěži a limitech směřuje do sítě podle firmware.
|
||||
4. Plán (`battery_w`, `grid_setpoint_w`) a **CHARGE** / **SELL** větev v `deye_battery_charge_discharge_amps` dál určují asymetrie (např. **CHARGE**: 109 = 0).
|
||||
|
||||
### `deye_zero_export_mode` per inverter
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
- **Žádné wattové prahy pro výběr SELL / CHARGE** — mapování z MILP setpointů je čistě ze **znamének** `battery_setpoint_w` a `grid_setpoint_w` (viz `get_deye_mode` v `exporter_monolith.py`).
|
||||
- **Přetok FVE do sítě** se neodvozuje z forecastového capu: plán nese explicitní `export_limit_w` jako tvrdý limit lokality / invertoru, ne jako tipované maximum z předpovědi.
|
||||
- **ZERO (PASSIVE)** = zero export k CT/zátěži (**142** = `deye_zero_export_mode`), s **plnými proudy 108/109** jen ve výchozím stavu; pro přetok FVE do sítě nebo odběr ze sítě bez vybíjení baterie se jeden z proudů **vynuluje** (`_deye_zero_export_amps_for_passive`).
|
||||
- **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); po návratu do ZERO/CHARGE zase **178** = 48.
|
||||
- **ZERO (PASSIVE)** = **142** = `deye_zero_export_mode` (1/2, ne selling first). **PV_SURPLUS:** **108 = 0**, **109 = max** — přebytek FVE do sítě (**145 = 1**), ne do baterie. Jinak **108/109** typicky max; výjimka import bez vybíjení → **109 = 0**.
|
||||
- **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); reg **108** EMS **nemění** (export řídí 142, ne vynucené 0 A). Po návratu do ZERO/CHARGE zase **178** = 48.
|
||||
- Novou logiku vždy ověřit proti **reálnému řádku plánu** (audit / `planning_interval`).
|
||||
|
||||
## Přehled
|
||||
@@ -42,7 +42,7 @@ Značení: `battery_w` = `battery_setpoint_w` (kladné = nabíjení, záporné =
|
||||
| Režim | Podmínka z plánu | 108 / 109 (zkráceně) | 142 | 178 |
|
||||
|--------|------------------|----------------------|-----|-----|
|
||||
| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 | dle plánu nabíjení / 0 vybíjení | větev CHARGE | 48 |
|
||||
| **SELL** | `battery_w` < 0 **a** `grid_setpoint_w` < 0 | 0 nabíjení / max vybíjení | 0 (selling first) | **32** (peak shaving off) |
|
||||
| **SELL** | `battery_w` < 0 **a** `grid_setpoint_w` < 0 | **108 nezapisuje EMS** / max vybíjení (109) | 0 (selling first) | **32** (peak shaving off) |
|
||||
| **PASSIVE (ZERO)** | vše ostatní | viz tabulka ZERO níže | `deye_zero_export_mode` | 48 |
|
||||
|
||||
### ZERO: výchozí a dvě varianty proudu (reg. 108 / 109)
|
||||
@@ -51,8 +51,8 @@ Všechny řádky předpokládají **142** = zero export (ne SELL).
|
||||
|
||||
| Situace | Podmínka (plán) | Reg. 108 (max charge A) | Reg. 109 (max discharge A) |
|
||||
|---------|-----------------|-------------------------|----------------------------|
|
||||
| Výchozí | ostatní případy PASSIVE | max | max |
|
||||
| Přetok FVE do sítě | `grid_setpoint_w` < 0 **a** `battery_w` ≥ 0 | **0** | max |
|
||||
| Přetok FVE do sítě | `export_mode = PV_SURPLUS` (ne SELL) | **0** | max |
|
||||
| Výchozí | ostatní PASSIVE (nabíjení bez exportního záměru) | max | max |
|
||||
| Držet baterii, brát ze sítě | `grid_setpoint_w` > 0 **a** `battery_w` ≤ 0 | max | **0** |
|
||||
|
||||
V obou exportních případech platí, že `export_limit_w` je **tvrdý site/inverter cap**. Nejde o forecastový odhad exportu, takže se může pustit plný přetok v rámci distribučního limitu.
|
||||
@@ -61,7 +61,7 @@ Nabíjení ze sítě s vysokým cílovým SoC v TOU řeší větev **CHARGE** (g
|
||||
|
||||
### ZERO a „zakázaný export FVE do sítě“ (jen řiditelná pole)
|
||||
|
||||
**Reg. 145 (solar sell)** na Deye je přepínač typu **enabled / disabled** pro **přebytek FVE na straně měniče** (počítá se vůči režimu **142** zero export a stavu **108** — viz `modbus-registers.md`, pass-through krok za krokem).
|
||||
**Reg. 145 (solar sell)** na Deye je přepínač typu **enabled / disabled** pro **přebytek FVE na straně měniče** (vůči režimu **142** zero export a interní logice měniče — viz `modbus-registers.md`, pass-through).
|
||||
|
||||
- **Pouze to, co EMS umí přes Deye Modbus ovlivnit** — typicky **FVE pole řízené střídačem** (`asset_pv_array.controllable = true`, u referenční lokality **home-01** např. string za Deye).
|
||||
- **Pole mimo tento kanál** (např. **pv-b** u **home-01**, `controllable = false`, často samostatný ongrid GEN) **reg. 145 neovládá**; jejich výkon jde do plánovače jako `pv_b_forecast_w`, ale curtailment / solar sell logika Deye se jich netýká.
|
||||
@@ -75,7 +75,12 @@ Týká se jen výroby, kterou Deye umí ovlivnit; **pv-b / ongrid GEN** u home-0
|
||||
|
||||
#### PV1/PV2 vs. GEN port (důležité pro BLOCK_EXPORT)
|
||||
|
||||
- **PV1/PV2 (hlavní stringy na DC vstupu Deye)**: výkon je v režimu zero-export **řiditelný** (střídač umí výrobu stáhnout až k nule, pokud není odběr a baterie už nemůže nabíjet). Při BLOCK_EXPORT tedy dává smysl „zakázat export“ přes **reg 145 = 0** – Deye zamezí přetokům z těchto stringů.\n+- **GEN port (AC coupling / mikroinvertory / ongrid GEN)**: výkon **nelze plynule omezovat**. Pole vyrábí „co dá slunce“ a pokud ho **nespotřebuje dům + EV/TČ + baterie**, přebytek fyzicky teče do sítě.\n+ - U instalací typu **BA81** je proto k dispozici jen **tvrdý cut-off** (reg 179 bits0–1).\n+ - U **malé baterie** (např. BA81 ~6 kW max charge a navíc při vysokém SoC ještě méně) může při plném osvitu často nastat přebytek i při BLOCK_EXPORT – a bez cut-off by šel do sítě.\n+ - Naopak při **malém osvitu / velké spotřebě** jsou „každé watty z GEN“ užitečné (jít do domu/baterie) a cut-off by zbytečně zahodil výrobu.\n+
|
||||
- **PV1/PV2 (hlavní stringy na DC vstupu Deye)**: výkon je v režimu zero-export **řiditelný** (střídač umí výrobu stáhnout až k nule, pokud není odběr a baterie už nemůže nabíjet). Při BLOCK_EXPORT tedy dává smysl „zakázat export“ přes **reg 145 = 0** – Deye zamezí přetokům z těchto stringů.
|
||||
- **GEN port (AC coupling / mikroinvertory / ongrid GEN)**: výkon **nelze plynule omezovat**. Pole vyrábí „co dá slunce“ a pokud ho **nespotřebuje dům + EV/TČ + baterie**, přebytek fyzicky teče do sítě.
|
||||
- U instalací typu **BA81** je proto k dispozici jen **tvrdý cut-off** (reg 179 bits0–1).
|
||||
- U **malé baterie** (např. BA81 ~6 kW max charge a navíc při vysokém SoC ještě méně) může při plném osvitu často nastat přebytek i při BLOCK_EXPORT – a bez cut-off by šel do sítě.
|
||||
- Naopak při **malém osvitu / velké spotřebě** jsou „každé watty z GEN“ užitečné (jít do domu/baterie) a cut-off by zbytečně zahodil výrobu.
|
||||
|
||||
Z toho plyne: **cut-off GEN portu je smysluplné řídit podle očekávaného přebytku**, ne jen podle „sell < 0“. Detail návrhu implementace je v `docs/04-modules/planning.md` (sekce o GEN portu a export banu).
|
||||
|
||||
**SELF_SUSTAIN:** `battery_w = None` ⇒ v `get_deye_mode` jako 0 ⇒ **PASSIVE**; v `write_inverter_setpoints` při `self_sustain_local_use=True` → **108 i 109 = max** (bez variant ZERO výše), reg. **142** dle DB, TOU SOC = **`min_soc_percent`**. **PRESERVE:** `lock_battery` → **108 = 0**, **109 = 0**.
|
||||
|
||||
164
docs/04-modules/planning-arbitrage-accounting.md
Normal file
164
docs/04-modules/planning-arbitrage-accounting.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Plánování: arbitráž a účtování energie (mezi sloty vs. v jednom slotu)
|
||||
|
||||
**Účel:** Trvalá poznámka pro implementaci i pro agenty — aby se neopakovala chyba „řešit arbitráž přes `buy` a `sell` ve stejném 15min slotu“ nebo přes **`min(buy)` celého horizontu** jako nákupní cenu uložené energie.
|
||||
|
||||
**Související:** [`planning.md`](planning.md), [`planning_engine.py`](../../backend/services/planning_engine.py) (`solve_dispatch`), [`R__063_fn_load_planning_slots_full.sql`](../../db/routines/R__063_fn_load_planning_slots_full.sql).
|
||||
|
||||
---
|
||||
|
||||
## 1. Co uživatel / provoz očekává (správný model)
|
||||
|
||||
Arbitráž baterie je **časový posun**:
|
||||
|
||||
1. V **levných hodinách** (může jich být **více za sebou**) nabít z site — např. home-01: baterie **64 kWh**, import z site typicky až **17 kW** → za 15 min až ~**4,25 kWh** ze sítě na slot, ale **klidně 8–16 slotů** (2–4 h) dokud cena sedí.
|
||||
2. V **drahých / výkupních hodinách** (jiný čas) stejnou energii prodat do sítě nebo ušetřit drahý import domu.
|
||||
|
||||
Ekonomický přínos je přibližně:
|
||||
|
||||
```text
|
||||
zisk ≈ (efektivní sell ve výprodejním okně)
|
||||
− (efektivní buy v nabíjecím okně)
|
||||
− degradace cyklu / účinnost
|
||||
```
|
||||
|
||||
**Není to** rozhodnutí „v tomto jednom 15min okně koupím za 7 Kč a prodám za 4,6 Kč“ — ve výprodejním slotu se **nekupuje** energie určená k exportu z baterie; ta byla nabitá dříve za jinou cenu.
|
||||
|
||||
---
|
||||
|
||||
## 2. Co dělá dnešní LP (a proč to arbitráž láme)
|
||||
|
||||
### 2.1 Účelová funkce je po slotech
|
||||
|
||||
V `solve_dispatch` je v každém slotu `t` zhruba:
|
||||
|
||||
```text
|
||||
náklad += gi[t] × buy[t]
|
||||
výnos -= ge[t] × sell[t]
|
||||
```
|
||||
|
||||
Energetická bilance je také **per slot** (15 min). Když solver v evening slotu zvýší `ge_bat` (export baterie), bilance často vyžaduje současně `gi` (síť krmí dům) a `bd`/`ge_bat`. Marginalně pak vypadá každá vyvezená kWh jako:
|
||||
|
||||
- „koupeno“ za **`buy[t]`** večer (např. 7 Kč/kWh),
|
||||
- „prodáno“ za **`sell[t]`** večer (např. 4,6 Kč/kWh),
|
||||
|
||||
→ v jednom okně **ztráta**, i když energie v baterii pochází z poledních **0,7 Kč/kWh**.
|
||||
|
||||
**Závěr:** Samotné opravy typu „přidat `ge_bat × (sell − ref_buy)`“ **nestačí**, pokud `ref_buy` je jedna čísla z jednoho slotu — pořád myslíme příliš v rámci jednoho okna. Cíl je **oddělit nákupní okno od výprodejního okna** v ekonomice modelu.
|
||||
|
||||
### 2.2 Guardy `sell < buy` ve stejném slotu
|
||||
|
||||
Tvrdé zákazy typu `ge_pv = 0` když `sell[t] < buy[t]` brání **ztrátovému** exportu FVE v tomže slotu (prodat za 3 Kč při VT nákupu 5 Kč).
|
||||
|
||||
**Výjimka (AUTO, od 2026-05):** pokud je v budoucnu `allow_charge` (levný nákup), solver **povolí** FVE export i při `sell[t] < buy[t]`, ale **jen když** `(sell[t] − min_buy_charge) ≥ (future_sell_opportunity − charge_acquisition) + degrad` — tj. prodat teď a později levně dobít překoná uložení PV na večerní špičku. Při odpoledním sell ~1,4 Kč a večer ~5,5 Kč **export se nevnucuje** (energie do baterie). Implementace: `solve_dispatch()` v `planning_engine.py`.
|
||||
|
||||
Pro **baterii** stejný test v **exportním** slotu **nesmí** být jediná logika arbitráže — večer téměř vždy `sell[t] < buy[t]` (VT/NT vs výkupní marže), přesto má smysl **vybíjet do sítě** energii nabitou v levném okně.
|
||||
|
||||
---
|
||||
|
||||
## 3. Proč `min(buy)` přes celý horizont **není** nákupní cena zásoby
|
||||
|
||||
`min(buy_price)` v horizontu je **jeden** 15min slot (nejlevnější čtvrthodina).
|
||||
|
||||
| home-01 (typicky) | Hodnota |
|
||||
|-------------------|--------|
|
||||
| Kapacita baterie | 64 kWh |
|
||||
| Max import ze site | 17 kW |
|
||||
| Max energie ze sítě / slot (15 min) | 17 kW × 0,25 h ≈ **4,25 kWh** |
|
||||
| Počet slotů na „plné“ grid nabíjení | až **~15** slotů (≈ 64/4,25), tedy **hodiny** |
|
||||
|
||||
**Min buy** tedy popisuje **špičku** v jednom čtvrthodině, ne průměrnou cenu energie, kterou skutečně natankujeme přes **dlouhé nabíjecí okno**.
|
||||
|
||||
Použití `min(buy)` jako „acquisition cost“ pro večerní export:
|
||||
|
||||
- **podhodnotí** skutečný náklad, pokud nabíjíme i v druhém/třetím levném slotu s vyšší cenou;
|
||||
- **neříká nic** o tom, kolik energie v levném pásmu vůbec nabít — to řeší masky `allow_charge` a rozpočet Wh, ne jedna čísla.
|
||||
|
||||
**Kde je `min(buy)` dnes OK:** hrubá **brána** („existuje v horizontu levný nákup?“), výběr slotů pro vrstvu B (`buy ≤ min + ε`), **ne** jako jediná proměnná pro výpočet zisku z baterie.
|
||||
|
||||
---
|
||||
|
||||
## 4. Co používat místo toho (směr návrhu)
|
||||
|
||||
| Pojem | Význam | Poznámka |
|
||||
|--------|--------|----------|
|
||||
| **`buy_charge_window`** | Nákupní cena energie do baterie | Odvozená z **množiny nabíjecích slotů** (`allow_charge` / skutečný `bc`+`gi`), ne z jednoho minima |
|
||||
| **`sell_discharge_window`** | Výkup při vybíjení do sítě | Např. průměr / percentil `sell` v `allow_discharge_export` slotech |
|
||||
| **Spread** | `sell_discharge − buy_charge − degradace` | Rozhoduje, zda má smysl večer `ge_bat` |
|
||||
|
||||
Příklady výpočtu **`buy_charge`** (zvolit jednu politiku v implementaci):
|
||||
|
||||
1. **Průměr přes `allow_charge` sloty** (vážený 0/1 — všechny povolené sloty stejně).
|
||||
2. **Průměr přes N nejlevnějších slotů**, kde N = počet slotů potřebných na dobití:
|
||||
`ceil(energy_to_fill_wh / (max_charge_w × η × 0,25 h))`.
|
||||
3. **Vážený průměr** `sum(buy[t] × charge_wh[t]) / sum(charge_wh[t])` z výsledku LP (až po solve — iterace nebo aproximace před solve z masky).
|
||||
|
||||
Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19:00–22:00 za ~3,5–5,5 Kč je spread řádově **2–4 Kč/kWh** — to LP dnes nevidí, pokud účtuje večerní `buy[t]`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Co **nedělat** v dalších iteracích
|
||||
|
||||
- Navrhovat „opravu arbitráže“ jen jako **`sell[t] − min(buy horizontu)`** v objective — **min buy je jeden slot**, nabíjení je **více hodin**.
|
||||
- Zaměňovat **stejnoslotové** `buy`/`sell` s **mezi-slotovou** arbitráží — uživatel to explicitně považuje za nesmysl.
|
||||
- Očekávat, že zvýšení `allow_discharge_export` samo spustí večerní **SELL**, když objective pořád trestá export při `buy[t] > sell[t]` ve stejném slotu.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementace (LP-first přestavba, 2026-05)
|
||||
|
||||
### Hotovo
|
||||
|
||||
1. **`ems.fn_load_planning_slots_full`** (`R__063`): grid **B** = nejlevnější sloty v AM/PM do Wh rozpočtu; **nevyčerpaný AM rozpočet přejde do PM** (odpolední NT za ~0,5 Kč může nabíjet i po ranním dobití). `grid_target × charge_slot_buffer`, cap slotů též × buffer. **A** = PV jen pokud `sell ≥ future_sell_lookahead − degrad`.
|
||||
2. **`solve_dispatch` (AUTO):** objective `gi×buy − ge_pv×sell − ge_bat×sell + ge_bat×acquisition` (export bat. jen v `allow_discharge_export`). Odstraněn cross-slot guard `ge_pv ≥ surplus` / `bc=0` dle `export_refill_net`.
|
||||
3. **Guard FVE:** `ge_pv=0` při `sell < future_sell_opportunity − degrad` **jen pokud `sell < 0`** (spot) nebo fixní tarif — u **`sell ≥ 0`** spot neblokuje export FVE kvůli budoucímu peak sell (solver export vs. nabíjení; baterii šetří `ge_bat`). Při `sell < 0` home-01: `ge_pv=0` / ventil pole B. Tag `2026-05-28-pv-positive-sell-solver-v29`.
|
||||
4. **`solve_dispatch_two_pass`:** pass 1 → vážený `buy` z `bc`+`gi` v `allow_charge` → pass 2; volá `run_daily_plan` / `run_rolling_replan` v AUTO. Snapshot: `acquisition_pass1_czk_kwh`, `acquisition_pass2_czk_kwh`, `two_pass_enabled`.
|
||||
5. **Regrese:** `Home01RegressionTests` v `backend/tests/test_planning_dispatch_milp.py`; masky v `test_planning_charge_slot_selection.py`.
|
||||
6. **Load-first (Deye, AUTO, tvrdý v34):** `pv_ld` / `pv_sp`, `bc_pv` / `bc_gi`; `bc_pv + ge_pv ≤ pv_sp`; **`gi ≤ bc_gi + max(0, max_load − pv_forecast)`**; při `pv ≥ load + 500 W` **`pv_ld ≥ load`**. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`. Test `LoadFirstDispatchTests`.
|
||||
7. **Self-konzistentní vrstva B (`R__063`, 2026-05):** iterativní filtr v plpgsql — vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 % deficitu SoC` (levnější alternativa dál v horizontu). Failsafe unlock pokud výsledek nepokryje safety target. Důsledek: `acquisition_pass1 ~ acquisition_pass2` v drtivé většině případů. Nové debug sloupce: `min_buy_before_cutoff_czk_kwh`, `pv_charge_wh_ahead`, `neg_buy_wh_ahead`, `grid_charge_suppressed_reason` (`cheaper_pv_ahead` / `cheaper_neg_buy_ahead` / `safety_failsafe_unlock`).
|
||||
8. **Ekonomická transparentnost plánu (`V081`, 2026-05):** `planning_interval` — `cashflow_czk`, `battery_arbitrage_czk`, `penalty_czk`, `green_bonus_czk`; `fn_plan_explain_bundle` → `economics_summary`; post-processing v `solve_dispatch()`.
|
||||
|
||||
### Co dál neřešit ad-hoc
|
||||
|
||||
- Další Python `if sell < buy` guardy — ekonomiku drží LP + acquisition + masky rozpočtu slotů.
|
||||
- Multi-period inventory model (větší projekt) — mimo tuto vlnu.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ověření po změně (home-01)
|
||||
|
||||
```sql
|
||||
-- levné okno: víc allow_charge, rozumný buy_charge (~0.7–1.0)
|
||||
select interval_start at time zone 'Europe/Prague' as t,
|
||||
buy_price, allow_charge
|
||||
from ems.fn_load_planning_slots_full(2, <from>, <to>, <soc_wh>)
|
||||
where buy_price < 1.2
|
||||
order by 1;
|
||||
|
||||
-- večer: BATTERY_SELL, záporný grid_setpoint
|
||||
select interval_start at time zone 'Europe/Prague' as t,
|
||||
effective_buy_price, effective_sell_price,
|
||||
battery_setpoint_w, grid_setpoint_w, export_mode
|
||||
from ems.planning_interval
|
||||
where run_id = <active_run_id>
|
||||
and extract(hour from interval_start at time zone 'Europe/Prague') between 19 and 22;
|
||||
```
|
||||
|
||||
Očekávání: SoC před večerem **70–90 %** po levném pásmu; večer **export do sítě** v špičce sell, ne jen ~2 kW do domu.
|
||||
|
||||
---
|
||||
|
||||
## 6. Plánováno: výběr nabíjecích slotů podle Wh (charge-slot-budget)
|
||||
|
||||
**Stav:** neimplementováno — plná specifikace v [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md).
|
||||
|
||||
Shrnutí vztahu k arbitráži:
|
||||
|
||||
- **`charge_acquisition`** má vycházet z **vybrané fronty** slotů (`allow_charge` / `allow_grid_charge`), ne z jednoho `min(buy)` ani prahu `sell > min + ε`.
|
||||
- **Počet slotů** nabíjení má odpovídat **potřebným Wh** (`soc_max − current`, případně `soc_need[first_neg] − observed` před neg oknem), s `min(pv_surplus, P_max) × 0,25` per slot.
|
||||
- **Export FVE** v drahém slotu je správný **až po** vyčerpání levnější fronty — ne tvrdý `bc_pv = 0` (v58) nezávisle na rozpočtu.
|
||||
|
||||
Tím se sjednotí fixní tarif (řazení `sell ASC`) a spot (řazení `buy ASC` + pre-neg `pre_window_wh`).
|
||||
|
||||
---
|
||||
|
||||
*Poslední aktualizace: 2026-06-01 — přidán odkaz na plánovaný charge-slot-budget; dříve 2026-05-27 self-konzistentní grid maska B (v12).*
|
||||
340
docs/04-modules/planning-charge-slot-budget.md
Normal file
340
docs/04-modules/planning-charge-slot-budget.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Plánování: rozpočet nabíjecích slotů (Wh × ceny × forecast)
|
||||
|
||||
**Stav:** **Branch 3 implementováno** (2026-06-06, tag `2026-06-06-charge-slot-budget-v1`) — fixed tarify BA81/KV1; home-01 pre-neg fronta §6 zatím ne.
|
||||
**Účel:** nahradit tvrdé prahy typu `sell > min_sell + 0,20 → bc_pv = 0` (v58) a binární pre-neg „cushion“ (v33) jednotným **energetickým rozpočtem** ve `fn_load_planning_slots_full`, který pokryje fixní tarify (BA81, KV1), spot (home-01) i zkracující se okna `sell < 0` (zima).
|
||||
|
||||
**Související:**
|
||||
|
||||
| Dokument | Vztah |
|
||||
|----------|--------|
|
||||
| [`planning.md`](planning.md) | LP, masky, současné v58–v62 |
|
||||
| [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md) | proč ne `sell < buy` v jednom slotu |
|
||||
| [`planning-neg-sell-strategy.md`](planning-neg-sell-strategy.md) | rampa, tail, pole B — cílové SoC v okně |
|
||||
| [`R__063_fn_load_planning_slots_full.sql`](../../db/routines/R__063_fn_load_planning_slots_full.sql) | místo implementace masek |
|
||||
| [`planning_engine.py`](../../backend/services/planning_engine.py) | LP jen respektuje masky + objective |
|
||||
|
||||
**Changelog (plánované):** [`docs/planning-changelog.md`](../planning-changelog.md) — sekce *Plánováno: charge-slot-budget*.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problém, který řešíme
|
||||
|
||||
### 1.1 Fixní tarif (BA81, KV1) — slunečný den ~60 % SoC
|
||||
|
||||
**Symptom:** přes den nabíjení ze slunce jen na ~60–66 %, zbytek FVE jde do sítě při výkupu 2–3 Kč/kWh; nabíjení jen v 2–4 slotech u minima výkupu (~1,45 Kč).
|
||||
|
||||
**Příčina (současný kód):**
|
||||
|
||||
- `R__063` už počítá `v_energy_to_fill` a vrstvu A (PV) s kumulací Wh, ale u fixního tarifu řadí PV vrstvu podle **`store_score`** (future sell − sell).
|
||||
- **`planning_engine` v58:** tvrdě `bc_pv = 0` (a často i `bc_gi = 0`) když `sell > min(sell≥0) + 0,20` — LP **nesmí** nabíjet, i když SQL nastaví `allow_charge = true`.
|
||||
|
||||
→ Rozpor mezi maskou a LP; ekonomicky „správný“ export v 10:00 blokuje doplnění baterie, i když večerní arbitráž to nevyžaduje.
|
||||
|
||||
### 1.2 home-01 — velká baterie, kratší / slabší okno `sell < 0`
|
||||
|
||||
**Symptom:** ráno export FVE před `sell < 0` i při výkupu ~2–3 Kč, zatímco v záporném okně by energie mohla být potřeba víc (krátké okno, slabší zimní FVE).
|
||||
|
||||
**Příčina (současný kód):**
|
||||
|
||||
- **v33:** `_pre_neg_pv_export_forecast_cushion_ok` — **binární** rozhodnutí: pokud forecast PV v celém denním okně `sell < 0` pokryje deficit do `soc_target[first_neg]` × 1,15 → **všechny** pre-neg sloty s přebytkem → export (`bc_pv = 0`, push `ge_pv`).
|
||||
- Neptá se: *kolik Wh musím nabít **před** oknem*, když *uvnitř* okna forecast nestačí.
|
||||
- **v44:** na neg den **žádný grid** před 1. `sell < 0` — při nedostatku FVE v okně chybí páka levného NT před oknem.
|
||||
|
||||
### 1.3 Společná chyba modelu
|
||||
|
||||
| Špatně | Správně |
|
||||
|--------|---------|
|
||||
| Prah `sell > min + 0,20` | **Kolik Wh** chybí do cíle a **které sloty** je nejlevněji doplní |
|
||||
| Binární cushion OK / fail | **pre_window_wh** = max(0, deficit − in_window_wh) |
|
||||
| `store_score` u fixního buy | U fixního tarifu řadit **`sell ASC`** (výkup = příležitostní cena uložení) |
|
||||
| LP přepisuje SQL masky (v58) | LP **jen** `bc_*` kde `allow_charge`; export kde maska nepřidělila charge budget |
|
||||
|
||||
---
|
||||
|
||||
## 2. Principy návrhu
|
||||
|
||||
1. **SQL-first:** výběr nabíjecích slotů = **`ems.fn_load_planning_slots_full`** (rozšíření `R__063`), ne nové tvrdé větve v `solve_dispatch` kromě stávajících bezpečnostních guardů (load-first, večerní push, neg fáze).
|
||||
2. **Energetický rozpočet (Wh), ne Kč prah:** ceny řadí **prioritu slotů**; zastavení kumulace je **`cum_wh ≥ target_wh`** (± `charge_slot_buffer`).
|
||||
3. **Forecast v každém slotu:** `pv_surplus_w` = max(0, pv_a + pv_b − load_baseline − …) už ve work tabulce; nabíjecí příspěvek slotu = `min(pv_surplus_w, max_charge_w) × charge_eff × 0,25` (+ volitelně grid `per_slot_charge_wh`).
|
||||
4. **Oddělení nákupního a výprodejního okna** — viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md); `charge_acquisition` z vybraných slotů, ne `min(buy)` celého horizontu.
|
||||
5. **Jeden algoritmus, více režimů:** stejná kostra pro spot i fixed; liší se **řazení** (buy vs sell), **cíl SoC** a **výjimky** (neg den, block_export).
|
||||
|
||||
---
|
||||
|
||||
## 3. Slovník
|
||||
|
||||
| Symbol / pole | Význam |
|
||||
|---------------|--------|
|
||||
| `energy_to_fill_wh` | `soc_max_wh − current_soc_wh` (základní deficit do plné baterie) |
|
||||
| `charge_target_wh` | Cíl pro výběr slotů — může být `< soc_max` (rezerva neg okna, safety, večerní export) |
|
||||
| `in_window_wh` | Odhad energie do baterie **uvnitř** speciálního okna (např. všechny sloty `sell < 0` téhož pražského dne), z forecastu A+B (+ volitelně grid v okně) |
|
||||
| `pre_window_wh` | `max(0, charge_target_wh − in_window_wh × reliability_factor)` — kolik Wh je třeba doplnit **před** oknem |
|
||||
| `slot_charge_wh[t]` | Wh, které lze v `t` reálně natočit (PV surplus cap + výkon baterie) |
|
||||
| `allow_charge` | SQL maska: LP smí `bc_pv` / `bc_gi` |
|
||||
| `allow_grid_charge` | podmnožina: smí `bc_gi` |
|
||||
| `charge_slot_reason` | debug: `grid_layer_b`, `pv_layer_a`, `pre_neg_fill`, `neg_window`, `buy_negative`, … |
|
||||
|
||||
---
|
||||
|
||||
## 4. Jádro algoritmu (společné)
|
||||
|
||||
Pro každý běh plánovače (site, horizont, `current_soc_wh`):
|
||||
|
||||
### Krok 1 — Cíl energie
|
||||
|
||||
```text
|
||||
charge_target_wh := min(
|
||||
soc_max_wh − current_soc_wh,
|
||||
optional_cap_from_neg_strategy, -- viz §6
|
||||
optional_safety_soc_target_wh
|
||||
)
|
||||
```
|
||||
|
||||
- Výchozí: doplnit do **`soc_max_wh`** (s `charge_slot_buffer` z `asset_battery` jako dnes).
|
||||
- U **home-01** s neg dnem: cíl na vstupu do okna = **`soc_need[first_neg_sell]`** z rampy (v35/v36), ne fixních 80 %.
|
||||
- Rezerva pro okno `sell < 0`: stávající logika `v_pv_layer_cap_wh -= neg_window_pv_surplus_wh` v `R__063` zůstane jako **snížení** `charge_target_wh` před oknem, ne jako samostatný binární export.
|
||||
|
||||
### Krok 2 — Dodávka uvnitř speciálního okna (volitelná větev)
|
||||
|
||||
Pro každý pražský den s alespoň jedním `sell < 0`:
|
||||
|
||||
```text
|
||||
in_window_wh := sum over slots t in neg_window(day):
|
||||
min(pv_surplus_w[t], max_charge_w) * charge_eff * 0.25
|
||||
+ (optional) grid_wh if allow_grid in neg window (v45)
|
||||
```
|
||||
|
||||
`reliability_factor` ∈ (0, 1] — např. **0,85** zimní / krátké okno, **1,0** letní dlouhé okno; nebo odvozené z počtu slotů `sell < 0` (≤ 4 sloty → nižší faktor).
|
||||
|
||||
### Krok 3 — Deficit před oknem
|
||||
|
||||
```text
|
||||
pre_window_wh := max(0, charge_target_at_neg_entry − in_window_wh * reliability_factor)
|
||||
```
|
||||
|
||||
Kde `charge_target_at_neg_entry` = SoC potřeba na `first_neg_sell` (z rampy / `soc_need`), minus `current_soc` (pozorované), přepočteno na Wh.
|
||||
|
||||
### Krok 4 — Fronta slotů (řazení + kumulace)
|
||||
|
||||
Kandidáti = sloty s `slot_charge_wh > 0` **nebo** (pro grid vrstvu) sloty bez PV, kde smí síť.
|
||||
|
||||
**Řazení (priorita):**
|
||||
|
||||
| Režim | Primární klíč | Sekundární |
|
||||
|-------|----------------|------------|
|
||||
| Spot (`purchase_pricing_mode ≠ fixed`) | `buy_price ASC` | den plánu, před exportním oknem, `slot_ord` |
|
||||
| Fixní tarif | `sell_price ASC` | stejné geografické priority jako dnes v R__063 |
|
||||
|
||||
**Kumulace:**
|
||||
|
||||
```text
|
||||
cum := 0
|
||||
for slot in candidates ordered:
|
||||
if cum >= budget_wh: break
|
||||
allow_charge[slot] := true
|
||||
if grid_layer: allow_grid_charge[slot] := true
|
||||
cum += slot_charge_wh[slot] -- u grid vrstvy min(cum increment, per_slot_charge_wh)
|
||||
```
|
||||
|
||||
**Budgety (vrstvy, po sobě):**
|
||||
|
||||
1. **Pre-neg / před oknem** — `budget = pre_window_wh` (jen sloty `t < first_neg_sell` téhož dne).
|
||||
2. **Grid B** — `budget = grid_target_wh` (AM/PM 50/50 jako dnes); spot: nejlevnější `buy`; fixed: nejlevnější `sell` u slotů splňujících marži.
|
||||
3. **PV A — zbytek** — `budget = max(0, charge_target_wh − grid_filled_wh − pre_neg_filled_wh)`.
|
||||
|
||||
Po výběru: sloty **mimo** vybranou frontu nemusí mít `allow_charge` — LP pak může exportovat FVE, pokud objective dává smysl (bez v58 zákazu).
|
||||
|
||||
### Krok 5 — Export před oknem (home-01)
|
||||
|
||||
**Nahradit** v33 binární cushion:
|
||||
|
||||
```text
|
||||
export_allowed_pre_neg[t] :=
|
||||
t in pre_neg_calendar_window
|
||||
AND pv_surplus_w[t] > threshold
|
||||
AND NOT allow_charge[t] -- přebytek po naplnění pre_window_wh
|
||||
AND (optional) sell[t] >= sell_export_floor[t] -- ne dump pod ranním pásmem (R__063 morning zone)
|
||||
```
|
||||
|
||||
V Pythonu: `pre_neg_pv_export_ts` = sloty s exportem **jen pokud** nejsou v charge frontě; případně měkká penalizace místo `bc_pv = 0` na celé pásmo.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fixní tarif (BA81, KV1)
|
||||
|
||||
### 5.1 Změny oproti dnešku
|
||||
|
||||
| Dnes (v58–v59) | Plán |
|
||||
|----------------|------|
|
||||
| `bc_pv = 0` if `sell > min + 0,20` | **Zrušit** v `planning_engine.py` |
|
||||
| PV vrstva A: `store_score DESC` | Fixed: **`sell_price ASC`** + kumulace `pv_surplus` Wh |
|
||||
| Grid: min sell sloty (v59) | Zachovat, sladit s jednotnou frontou (grid = vrstva B) |
|
||||
| Večer push: `bc_* = 0` | Zachovat (exportní okno) |
|
||||
|
||||
### 5.2 Ekonomická interpretace
|
||||
|
||||
- **Buy** je konstantní → rozhoduje **výkup v slotu** (opportunity cost uložení do baterie).
|
||||
- Nabíjet v pořadí **nejnižší sell**, dokud `cum < charge_target_wh`.
|
||||
- Export v slotu s vyšším sell je OK **až poté**, co je rozpočet Wh vyčerpán v levnějších slotech (včetně pozdějších levných sellů v pořadí času — viz pořadí: nejdřív globálně nejlevnější sell v rámci dne / AM-PM, viz níže).
|
||||
|
||||
### 5.3 AM/PM a pořadí v čase
|
||||
|
||||
Zachovat stávající **AM/PM rozpočet** `grid_target_wh` (50/50, přeliv AM→PM). Uvnitř segmentu:
|
||||
|
||||
- fixed grid: `sell ASC` + filtr `sell ≤ min(sell≥0) + degrad + ε` (jako v59) **nebo** čistě kumulace Wh bez ε, pokud Wh budget stačí — **rozhodnutí při implementaci:** preferovat **Wh kumulaci**; ε jen jako failsafe proti grid nabíjení za 6 Kč v noci (KV1).
|
||||
|
||||
### 5.4 Večerní špička a profitable export
|
||||
|
||||
Beze změny principu: `allow_discharge_export` + `evening_push_ts`; v push slotech **`allow_charge = false`**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Spot — home-01 a negativní výkup
|
||||
|
||||
### 6.1 Běžný spot (bez neg dne v horizontu)
|
||||
|
||||
Stejná kostra jako §4:
|
||||
|
||||
- Grid vrstva: **`buy ASC`** (stávající spot loop + self-konzistentní filtr `pv_charge_wh_ahead`).
|
||||
- PV vrstva: **`store_score DESC`** nebo hybrid — **po** grid; budget = zbytek `charge_target_wh`.
|
||||
|
||||
### 6.2 Den s `sell < 0` (neg strategie)
|
||||
|
||||
Integrace s [`planning-neg-sell-strategy.md`](planning-neg-sell-strategy.md):
|
||||
|
||||
| Komponenta | Role v charge-slot budget |
|
||||
|------------|---------------------------|
|
||||
| `soc_need[t]` rampa (v35/v36) | `charge_target_at_neg_entry` |
|
||||
| `in_window_wh` z A+B v neg sloty | sníží `pre_window_wh` |
|
||||
| `pre_window_wh > 0` | **nabíjecí fronta před `first_neg_sell`** (PV + případně grid) |
|
||||
| Tail / T / curtail | **beze změny** v LP (fáze neg okna) |
|
||||
| v44 `neg_day_no_grid_before_neg_sell` | **Změkčit:** povolit grid v N nejlevnějších `buy` slotech před oknem, pokud `pre_window_wh > in_window_wh × factor` a `buy < 0` nebo `buy ≤ ref_buy_am + degrad` |
|
||||
|
||||
### 6.3 Nahrazení v33 cushion
|
||||
|
||||
| v33 (dnes) | charge-slot budget |
|
||||
|------------|-------------------|
|
||||
| `cushion_ok` → export vše pre-neg | `pre_window_wh` malé → více `allow_charge` pre-neg |
|
||||
| `cushion_fail` → nabíjet | `pre_window_wh` velké → fronta nabíjení |
|
||||
| `bc_pv = 0` v celém `pre_neg_pv_export_ts` | Jen sloty, kde **není** `allow_charge` a export je ekonomicky výhodný |
|
||||
|
||||
**Zima / krátké okno:** málo slotů `sell < 0` → malé `in_window_wh` → velké `pre_window_wh` → **více nabíjení před oknem**, i při sell 2–3 Kč, pokud jsou to nejlevnější dostupné sloty (ne plošný export).
|
||||
|
||||
### 6.4 Velká baterie (64 kWh)
|
||||
|
||||
- `charge_target_wh` může být **desítky kWh** — fronta musí počítat **skutečné** `slot_charge_wh`, ne jen cap 6 slotů / segment, pokud budget vyžaduje více (rozšířit `grid_charge_cap_*` odvozeně od `ceil(budget / per_slot_charge_wh)` — částečně už je).
|
||||
- `charge_acquisition` = vážený buy ve vybraných `allow_grid_charge` slotech (stávající two-pass v Pythonu).
|
||||
|
||||
---
|
||||
|
||||
## 7. Role LP po změně masek
|
||||
|
||||
` solve_dispatch` **nesmí** přepisovat energetický výběr prahy typu v58.
|
||||
|
||||
| Oblast | LP chování |
|
||||
|--------|------------|
|
||||
| Nabíjení | `bc_pv`, `bc_gi` jen kde `allow_charge` / `allow_grid_charge` |
|
||||
| Export FVE | objective `−ge_pv×sell`; **bez** `bc_pv=0` jen kvůli sell |
|
||||
| Export bat | `allow_discharge_export`, `charge_acquisition`, večerní push |
|
||||
| Guard | `ge_pv=0` if `sell < charge_acquisition − degrad` (spot) — **měkká** hranice hodnoty uložené energie |
|
||||
| Spot grid | v61: `bc_gi=0` if `buy > charge_acquisition + degrad` |
|
||||
| Neg fáze | rampa, tail, T — beze změny |
|
||||
|
||||
---
|
||||
|
||||
## 8. Návrh rozšíření `fn_load_planning_slots_full`
|
||||
|
||||
Nové / rozšířené sloupce ve výstupu (nebo JSON v meta — preferováno sloupce pro debug):
|
||||
|
||||
| Sloupec | Typ | Popis |
|
||||
|---------|-----|--------|
|
||||
| `charge_budget_wh` | numeric | celkový cíl pro tento běh |
|
||||
| `charge_slot_wh` | numeric | odhad Wh v daném slotu |
|
||||
| `charge_cum_wh` | numeric | kumulativa po řazení (audit) |
|
||||
| `charge_layer` | text | `pre_neg` / `grid_am` / `grid_pm` / `pv_a` / … |
|
||||
| `pre_window_wh` | numeric | jen informativní per běh (nebo per den v `solver_params`) |
|
||||
|
||||
V `planning_run.solver_params` (commit meta):
|
||||
|
||||
```json
|
||||
{
|
||||
"charge_slot_budget": {
|
||||
"charge_target_wh": 42000,
|
||||
"pre_window_wh_by_day": {"2026-06-02": 18000},
|
||||
"in_window_wh_by_day": {"2026-06-02": 12000},
|
||||
"reliability_factor": 0.85,
|
||||
"planner_build_tag": "…-charge-slot-budget-v1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Co zrušit / neimplementovat znovu
|
||||
|
||||
| Položka | Akce |
|
||||
|---------|------|
|
||||
| v58 `fixed_high_sell_no_pv_charge` | **odstranit** po nasazení budget |
|
||||
| v58 `FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH` | **odstranit** (konstanta) |
|
||||
| v59 `fixed_grid_charge_unprofitable` část `sell > min + 0,20` | nahradit: grid jen ve vybrané frontě; případně ponechat `sell < buy` guard pro grid |
|
||||
| v60 `sell < buy` → `bc_gi=0` (spot) | **neobnovovat** (záměrně zrušeno v61) |
|
||||
| v33 binární cushion jako hlavní páka | nahradit §4 krok 2–3; cushion ponechat jako **audit** / `solver_params.inputs.pre_neg_cushion_legacy_ok` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Fáze implementace (doporučené pořadí)
|
||||
|
||||
1. **Dokumentace + testy scénářů** (tento soubor, pytest fixtures s umělými sloty).
|
||||
2. **`R__063`:** fixed PV vrstva `sell ASC` + Wh kumulace; sloupce debug; bez změny Pythonu → ověřit, že v58 stále blokuje (regrese).
|
||||
3. **Python:** odstranit v58/v59 `bc_pv`/`bc_gi` fixed větve; spoléhat na masky.
|
||||
4. **`R__063` + Python:** pre-neg `pre_window_wh` pro spot; zúžit `pre_neg_pv_export_ts`.
|
||||
5. **v44 změkčení:** grid před neg jen když `pre_window_wh` > práh.
|
||||
6. **Dokumentace + changelog** tag `charge-slot-budget-v1`; MCP ověření home-01 / KV1 / BA81.
|
||||
|
||||
---
|
||||
|
||||
## 11. Ověření
|
||||
|
||||
### 11.1 Automatické testy (pytest)
|
||||
|
||||
| Scénář | Očekávání |
|
||||
|--------|-----------|
|
||||
| Fixed, slunečný den, nízký ranní SoC | `allow_charge` v mnoha PV slotech; max SoC v plánu → blízko `soc_max` |
|
||||
| Fixed, min sell odpoledne | dřívější sloty s vyšším sell **bez** `allow_charge`, pokud budget vyčerpán v levnějších |
|
||||
| Spot, krátké neg okno (2–4 sloty), slabá FVE | `pre_window_wh > 0`; nabíjení před `first_neg_sell`; **ne** plošný pre-neg export |
|
||||
| Spot, dlouhé neg okno, silná FVE | po naplnění `pre_window_wh` může export pre-neg v drahých sell |
|
||||
| home-01 velká baterie | kumulace ≥ desítky kWh přes více slotů |
|
||||
|
||||
### 11.2 SQL / MCP
|
||||
|
||||
```sql
|
||||
select interval_start, allow_charge, allow_grid_charge,
|
||||
charge_layer, charge_slot_wh, charge_cum_wh,
|
||||
sell_price, pv_surplus_w
|
||||
from ems.fn_load_planning_slots_full(<site_id>, <from>, <to>, <soc_wh>)
|
||||
where allow_charge
|
||||
order by interval_start;
|
||||
```
|
||||
|
||||
Po deployi: aktivní `planning_run.solver_params->'charge_slot_budget'`; u home-01 `pre_neg_pv_export_slots` ⊆ sloty bez `allow_charge`.
|
||||
|
||||
### 11.3 Regrese
|
||||
|
||||
- Večerní push (v57), spot v61, KV1 noc v62 — **nesmí** rozpadnout.
|
||||
- Neg rampa v35/v36 — SoC v okně `sell < 0` stejné cíle, mění se jen **příprava před oknem**.
|
||||
|
||||
---
|
||||
|
||||
## 12. Otevřené body (rozhodnutí před kódem)
|
||||
|
||||
1. **`reliability_factor`:** fixní 0,85 vs funkce počtu neg slotů?
|
||||
2. **Fixed řazení:** globální `sell ASC` přes den vs AM/PM segmenty (doporučení: AM/PM budget + `sell ASC` v segmentu).
|
||||
3. **Večer před neg dnem:** zda `charge_target_wh` zahrnuje večerní výboj D−1 (`neg_evening_before_neg`) — ano, přes `soc_need`, ne duplicitní budget.
|
||||
4. **Multi-day horizont:** každý pražský den vlastní `pre_window_wh` (jako v36 bundle) — **ano**.
|
||||
5. **UI:** badge „charge budget“ ve frontendu — volitelné, až budou sloupce v API.
|
||||
|
||||
---
|
||||
|
||||
## 13. Shrnutí pro produkt
|
||||
|
||||
Jednou větou: **místo prahu „sell nad minimum + 20 haléřů“ spočítáme, kolik Wh chybí do cíle, kolik jich dodá záporné výkupní okno z forecastu, a zbytek nabijeme v nejlevnějších slotech (buy nebo sell podle tarifu) s ohledem na PV přebytek a spotřebu — u home-01 tím nahradíme plošný ranní export před `sell < 0`, u KV1/BA81 doplníme baterii ve slunečný den nad ~60 %.**
|
||||
537
docs/04-modules/planning-neg-sell-strategy.md
Normal file
537
docs/04-modules/planning-neg-sell-strategy.md
Normal file
@@ -0,0 +1,537 @@
|
||||
# Strategie záporného výkupu, FVE A/B, termika a flexibilní zátěže (home-01)
|
||||
|
||||
Navazuje na [`planning.md`](planning.md), [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md), [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md) (plánovaná náhrada pre-neg cushion), [`planning-changelog.md`](../planning-changelog.md), [`heat-pump.md`](heat-pump.md), [`ev-charging.md`](ev-charging.md).
|
||||
|
||||
**Stav:** část je **implementovaná** (v32–v40), část je **návrh** (termika, bazén, spirála; **charge-slot-budget** — viz níže). V textu je označeno `✅ hotovo` vs `📋 návrh`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cíl produktu (home-01)
|
||||
|
||||
| Cíl | Popis |
|
||||
|-----|--------|
|
||||
| Baterie v okně `sell < 0` | Dojet na **100 %** (`soc_max`) do konce denního úseku záporného výkupu (Europe/Prague). |
|
||||
| Pole B (zelený bonus) | Při záporném výkupu smí jít přebytek do sítě (ekonomika bonusu); není curtailable. |
|
||||
| Pole A (Deye, curtailable) | Po dosažení plánované energetické pohody **nechat dostupné** pro dům a chybu forecastu — ne nutně „vždy škrtat v 80 %“. |
|
||||
| Ranní kladný sell | Typicky **export celé FVE** do site — nekrást výkon TČ ani fiktivním nabíjením v plánu. |
|
||||
| Termika | TUV komfort / předehřát / večerní doklep — **uvnitř** vhodných oken, ne v ranním exportním pásmu. |
|
||||
| Flexibilní sink | Bazén (filtrace), později spirála — sežrat **plánovaný přebytek** místo exportu za záporný sell. |
|
||||
| EV | Odpoledne; nabíjení po naplnění energetické rampy / v levných slotech. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Slovník
|
||||
|
||||
| Pojem | Význam |
|
||||
|-------|--------|
|
||||
| **Okno `sell < 0`** | Souvislé 15min sloty téhož **kalendářního dne** (Prague), kde `effective_sell_price < 0`. |
|
||||
| **Tail** | Posledních **N** slotů okna (`planner_neg_sell_full_soc_tail_slots`, default **4** = 1 h). Cíl SoC = **`soc_max` (100 %)**. |
|
||||
| **Prep (v32)** | Všechny `sell < 0` sloty **před** tail. Dnes: plochý cíl **`planner_neg_sell_prep_soc_percent`** (default **80 %**). |
|
||||
| **Bod T** (`t_detach`) 📋 | První slot (od tail zpět), od kdy **forecast pole B** (po loadu, s limitem nabíjení) **sám** dožene zbytek SoC na 100 %. Nahrazuje fixních 80 %. |
|
||||
| **`E_surplus_after_t`** 📋 | Integrál plánovaného přebytku FVE (typ. od **T** do `last_sell<0`), který by jinak šel do sítě / curtail — budget pro TČ předehřát, bazén, spirálu. |
|
||||
| **Pre-neg export (v33)** | Kladné `sell` **před** prvním `sell < 0`: export FVE jen pokud forecast v celém `sell < 0` okně pokryje dobítí na prep cíl (× margin **1,15**). **📋 Plánovaná náhrada:** `pre_window_wh` v [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md) §6. |
|
||||
| **Load-first (v34)** | Dům z `pv_ld`; při dostatečné FVE žádný fiktivní `grid_import = load` v plánu. |
|
||||
| **Rampa B + bod T (v35)** | `soc_need` zpět od tail jen z PV B; **t_detach**; `E_surplus_after_t`; uvolnění A po T (měkké). |
|
||||
| **Reg 340** | Deye *max solar power* ≈ `pv_a_forecast_solver_w − pv_a_curtailed_w`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Časová osa dne (referenční home-01)
|
||||
|
||||
```text
|
||||
Prague
|
||||
|-----|-----|-----|-----|-----|-----|-----|-----|
|
||||
06 08 10 12 14 16 18 20
|
||||
|
||||
[ A: ranní sell ≥ 0 — export FVE (v33) ]
|
||||
[ B: sell < 0 — nabíjení bat, T*, TČ, bazén ]
|
||||
[ C: večerní peak sell — export bat (masky) ]
|
||||
[ D: EV často odpoledne / večer ]
|
||||
```
|
||||
|
||||
### 3.1 Fáze A — před prvním `sell < 0` (ranní export)
|
||||
|
||||
- **Chování plánu (v33):** pokud `_pre_neg_pv_export_forecast_cushion_ok`, sloty v `pre_neg_pv_export_ts` tlačí **export** (`ge_pv`), **`bc_pv = 0`** (FVE ne do baterie).
|
||||
- **Termika (📋):** **neplánovat** komfortní TČ/TUV v těchto slotech — výkon by kolidoval s exportní strategií (FVE má jít do site).
|
||||
- **Deye:** load-first na zařízení — dům si vezme z FVE; plán ale může ukazovat export, ne „import pro load“ (viz v34).
|
||||
|
||||
### 3.2 Fáze B — okno `sell < 0`
|
||||
|
||||
**Energie (v32–v35):**
|
||||
|
||||
| Období v B | Chování (v35) |
|
||||
|------------|----------------|
|
||||
| Začátek okna | Nabít podle **rampy SoC** (`soc_need`) zpět z PV B od tail |
|
||||
| Střed okna | Od **t_detach**: měkké omezení `bc_pv`; hold/curtail při `soc_prev ≥ soc_target[t]` |
|
||||
| Tail (posledních N slotů) | Rampa z `soc_need[tail_start]` → 100 % |
|
||||
|
||||
**Termika (📋):**
|
||||
|
||||
- TČ **primárně zde**, když je dost PV / po bodu **T**, ne v ranní fázi A.
|
||||
- **Předehřát** v **T** jen pokud je **T** dostatečně brzy a `E_surplus_after_t` je velké.
|
||||
- **Večerní doklep** TUV (1–2 h před sprchou) — samostatné pravidlo od `tuv_usage_stats`.
|
||||
|
||||
**Bazén (📋):**
|
||||
|
||||
- Jen **slunečné** hodiny v rámci B (a ideálně **po T**), **X hodin/den** — promíchání prohřáté hladiny.
|
||||
|
||||
### 3.3 Den bez `sell < 0` (📋)
|
||||
|
||||
- Přebytek FVE → prodej za **kladný sell** (ne „výmět“).
|
||||
- **TČ:** topit v slotech, kde **COP × sell** dává smysl oproti prodeji kWh (viz `fn_cop_estimate`, `fn_heat_pump_cost_per_kwh_heat`).
|
||||
- **Spirála:** spíš **nízká priorita** — každá kWh do spirály je kWh, kterou šlo prodat.
|
||||
- **Bazén:** volitelně v nejlepších PV slotech, pokud export není ekonomicky nutný.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementované vrstvy (v32–v35)
|
||||
|
||||
### 4.1 v32 — fázované SoC a curtail A ✅
|
||||
|
||||
**DB:** `ems.asset_battery` — migrace **`V083__planner_neg_sell_phases.sql`**
|
||||
|
||||
| Sloupec | Default | Význam |
|
||||
|---------|---------|--------|
|
||||
| `planner_neg_sell_prep_soc_percent` | 80 | **v32 legacy** — od v35 se v LP neřídí (rampa z B). **100** = vypnutí fází (`_neg_sell_phases_enabled`). |
|
||||
| `planner_neg_sell_full_soc_tail_slots` | 4 | Počet 15min slotů tail před koncem denního `sell < 0`. **0** = bez tail. |
|
||||
| `planner_neg_sell_vent_min_sell_czk_kwh` | −1 (home-01) | V tail: ventil pole B (`ge_pv`) pokud `sell ≥` práh. **NULL** = jen při plné baterii. |
|
||||
|
||||
**Kód:** `backend/services/planning_engine.py`
|
||||
|
||||
- `_neg_sell_day_phases()` — `prep` / `tail` / `none` per slot
|
||||
- `prep_soc_shortfall`, `prep_hold_met_binary`, měkké `prep_hold_curtail` / `prep_hold_bcpv`
|
||||
- Výstup: `planning_interval.pv_a_curtailed_w`, `solver_params.masks[].neg_sell_phase`
|
||||
|
||||
**Omezení v32 (důvod návrhu v35):**
|
||||
|
||||
- **80 %** není odvozené z délky okna ani z forecastu B.
|
||||
- Curtail A je **měkký** (penalizace ~1 Kč/kWh) — LP může v sousedním slotu znovu nabíjet.
|
||||
- Hold: `soc_prev ≥ 80 %` na **začátku** slotu, ne dynamická rampa.
|
||||
|
||||
**Ověření:** `NegSellSocPhaseTests`, MCP `planning_interval` + `solver_params->'masks'`.
|
||||
|
||||
### 4.2 v33 — export FVE před `sell < 0` s forecast pojistkou ✅
|
||||
|
||||
**Kód:** `_pre_neg_pv_export_forecast_cushion_ok`, `_neg_sell_day_pv_usable_wh`, `pre_neg_pv_export_ts`.
|
||||
|
||||
- Export v kladných slotech před prvním `sell < 0` **jen pokud** usable FVE v celém `sell < 0` dni ≥ potřebné Wh na prep (× **1,15**).
|
||||
- Jinak LP raději nabíjí z FVE (déšť / slabý forecast v okně).
|
||||
|
||||
**Ověření:** `PreNegPvExportForecastTests`, `solver_params.inputs.pre_neg_pv_export_forecast_ok`.
|
||||
|
||||
### 4.2b 📋 Plánováno — pre-neg jako energetický rozpočet (charge-slot-budget)
|
||||
|
||||
**Stav:** neimplementováno (specifikace 2026-06).
|
||||
|
||||
**Problém v33 při zimě / krátkém okně `sell < 0`:** binární cushion často **projde** (optimistický forecast v okně × 1,15) → ranní export FVE i při sell ~2–3 Kč, přestože **uvnitř** okna energie nestačí na rampu / 100 % tail — velká baterie (home-01) pak přijde do neg okna podnabitá.
|
||||
|
||||
**Záměr (souhrn):**
|
||||
|
||||
```text
|
||||
charge_target_at_neg := soc_need[first_neg] (rampa v35/v36, observed SoC)
|
||||
in_window_wh := sum forecast PV (A+B) v sell<0 sloty dne × η
|
||||
pre_window_wh := max(0, charge_target_at_neg − in_window_wh × reliability)
|
||||
|
||||
Před first_neg: allow_charge v nejlevnějších slotech (buy ASC) + PV surplus,
|
||||
dokud cum_wh < pre_window_wh
|
||||
Export pre-neg: jen sloty s PV přebytkem, které NEJSOU v charge frontě
|
||||
```
|
||||
|
||||
**Vazby:**
|
||||
|
||||
- Rampa / tail / T / curtail A — **beze změny** v LP.
|
||||
- **v44** (`neg_day_no_grid_before_neg_sell`): plánované **změkčení** — grid před oknem povolen v N nejlevnějších `buy` slotech, pokud `pre_window_wh` výrazně převyšuje `in_window_wh`.
|
||||
- **v36 per-den bundle** zůstává; `pre_window_wh` se počítá **per pražský den**, ne globálně.
|
||||
|
||||
**Detail:** [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md) §4–§6, changelog *Plánováno*.
|
||||
|
||||
### 4.3 v34 — tvrdý load-first ✅
|
||||
|
||||
**Tag:** `2026-05-28-load-first-hard-v34`
|
||||
|
||||
- `gi ≤ bc_gi + max(0, max_load − pv_forecast)` — při vysoké FVE žádný fiktivní import = load.
|
||||
- Při `pv_forecast ≥ max_load + 500 W`: `pv_ld ≥ load`.
|
||||
|
||||
**Ověření:** `LoadFirstDispatchTests::test_neg_sell_prep_no_fictitious_grid_import_for_load`.
|
||||
|
||||
### 4.4 v35 — rampa SoC z PV B, bod T, přebytek ✅
|
||||
|
||||
**Tag:** `2026-05-28-neg-sell-b-ramp-v35` (bod T opraven v **v36** — viz níže).
|
||||
|
||||
**Kód:** `_neg_sell_pv_b_charge_wh`, `_neg_sell_day_phases` (rampa), `_neg_sell_e_surplus_after_t_wh`, `_neg_sell_day_pv_b_usable_wh` (cushion v33).
|
||||
|
||||
- Zpětná projekce `soc_need` jen z PV B; prep `soc_target[t] = soc_need[t]` (ne fixních 80 %).
|
||||
- **t_detach** = první prep slot kde `soc_need[t] ≤ soc_need[tail_start]`; **E_surplus_after_t** od T do konce okna.
|
||||
- Prep hold: `soc_prev ≥ soc_target[t]`; po T: `NEG_SELL_POST_DETACH_BCPV_DISCOURAGE` na `bc_pv`.
|
||||
- `solver_params.inputs`: `neg_sell_b_ramp_v35`, `t_detach_idx`, `e_surplus_after_t_wh`, `neg_sell_day_meta`.
|
||||
|
||||
**Ověření:** `NegSellSocPhaseTests::test_b_ramp_t_detach_and_surplus_meta`, MCP `solver_params`.
|
||||
|
||||
### 4.5 v36 — přípravné okno neg dne ✅
|
||||
|
||||
**Tag:** `2026-05-28-neg-prep-window-v36`
|
||||
|
||||
| Problém v35 | Oprava v36 |
|
||||
|-------------|------------|
|
||||
| **T** hned na 1. `sell<0` → celý den curtail A | `t_detach` až `soc_need[t] ≥ 85 % soc_max` + suffix B ≥ zbytek do 100 % |
|
||||
| Ráno 2. neg dne nabíjí místo exportu | **Pre-neg per den** + cushion **A+B**; `pre_neg_pv_export_slots` pro každý pražský den zvlášť |
|
||||
| Večer nevybije před zítřejším neg | `neg_evening_before_neg_slots` — výboj večer **D−1** |
|
||||
|
||||
**Cílová časová osa (např. 27. 5.):**
|
||||
|
||||
```text
|
||||
07–09:30 sell ≥ 0 → export FVE (pre-neg, cushion OK)
|
||||
09:45+ sell < 0 → nabíjení A+B po rampě
|
||||
~11–13 bod T → uvolnění / curtail A, B do domu nebo export
|
||||
večer 26.5 → vybít bat před neg 27.5 (headroom)
|
||||
```
|
||||
|
||||
**Ověření:** `NegSellPrepWindowV36Tests`, `solver_params.inputs.pre_neg_cushion_by_day`, `neg_evening_before_neg_slots`.
|
||||
|
||||
### 4.6 v40 — pozorované SoC pro neg-prep (Plan 5) ✅
|
||||
|
||||
**Tag:** `2026-05-29-neg-prep-observed-soc-v40`
|
||||
|
||||
| Problém v36 | Oprava v40 |
|
||||
|-------------|------------|
|
||||
| Cushion / večerní výboj z **modelového** SoC (řetězení cílů mezi dny) | **`observed_soc_wh`** z telemetrie; žádné `soc_est := soc_target[first_neg]` |
|
||||
| BMS výš → plán „už mám headroom“ nevidí | Cushion OK pokud `observed_soc ≥ soc_target[first_neg]` |
|
||||
| Večerní výboj pod exportuje | Rozpočet `max(0, observed − reserve − night_baseload_buffer)` → `neg_evening_push_slots` |
|
||||
|
||||
**Kód:** `_pre_neg_pv_export_bundle`, `_neg_evening_discharge_budget_wh`, `_neg_evening_before_neg_push_indices` v `planning_engine.py`.
|
||||
|
||||
**Ověření:** `ObservedSocNegPrepTests`; MCP `solver_params.inputs.observed_soc_wh`, `neg_evening_export_budget_wh`, `neg_evening_push_slots`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Specifikace rampy (v35 — reference)
|
||||
|
||||
### 5.0 Rozhodnutí produktu (home-01, 2026-05)
|
||||
|
||||
| Téma | Rozhodnutí |
|
||||
|------|------------|
|
||||
| Rampa / **T** | Odvozené z PV B; **bez** řízení fixním `planner_neg_sell_prep_soc_percent` v LP pro home-01. |
|
||||
| TČ v pre-neg | **Zákaz** plánovaného topení. |
|
||||
| Bazén | Min. 4 h filtrace/den, dynamicky navýšit; Shelly; přitop ručně / později. |
|
||||
| Spirála | Loxone; v38. |
|
||||
| UI flex | Workshop **před** v37 — viz § 9.1. |
|
||||
|
||||
### 5.1 Kotva vzadu (tail — beze změny konceptu)
|
||||
|
||||
Pro každý pražský den s `sell < 0`:
|
||||
|
||||
```text
|
||||
indices = všechny sloty t kde sell[t] < 0, seřazené
|
||||
last_neg = indices[-1]
|
||||
tail_start = max(indices[0], last_neg - (N - 1)) # N = planner_neg_sell_full_soc_tail_slots
|
||||
```
|
||||
|
||||
Pro `t ≥ tail_start`: cíl `soc_target[t] = soc_max` (případně rampa v tail mezi `soc_detach` a `soc_max` pokud `N > 1`).
|
||||
|
||||
### 5.2 Zpětná projekce pouze z pole B
|
||||
|
||||
Pro odhad **nabití z B** v slotu `t` (zjednodušený model, stejný styl jako `_neg_sell_day_pv_usable_wh`):
|
||||
|
||||
```text
|
||||
pv_surplus_b[t] = max(0, pv_b_forecast[t] - load_baseline[t] - rezerva_EV_HP)
|
||||
charge_b[t] = min(pv_surplus_b[t], max_charge_power_w) × charge_efficiency × 0,25 h
|
||||
```
|
||||
|
||||
Zpět od `tail_start`:
|
||||
|
||||
```text
|
||||
soc_need[last_neg] = soc_max
|
||||
soc_need[t-1] = soc_need[t] - charge_b[t] # clamp ≥ min_soc_wh
|
||||
```
|
||||
|
||||
Výsledkem je **`soc_need[t]`** — požadované SoC na **konci** slotu `t`, kdyby stačilo jen B.
|
||||
|
||||
### 5.3 Bod T (`t_detach`) — v36
|
||||
|
||||
**Definice (implementováno v36):** první prep slot `t`, kde současně:
|
||||
|
||||
```text
|
||||
soc_need[t] ≥ max(0,85 × soc_max, 0,92 × soc_need[tail_start])
|
||||
Σ charge_b[t..konec] ≥ (soc_max − soc_need[t]) × 1,05
|
||||
```
|
||||
|
||||
**Zrušeno (chyba v35):** `soc_need[t] ≤ soc_need[tail_start]` — platilo vždy na začátku okna.
|
||||
|
||||
**Interpretace:**
|
||||
|
||||
| Situace | Význam |
|
||||
|---------|--------|
|
||||
| **T** brzy po začátku `sell < 0` | Dlouhé okno, B stačí → od **T** uvolnit A pro dům / odchylku |
|
||||
| **T** těsně před tail | Krátké okno → A potřebné déle, malý `E_surplus_after_t` |
|
||||
| Aktuální SoC **pod** `soc_need[t]` při replanu | Ještě fáze „honit rampu“ (A+B) |
|
||||
| Rampa z aktuálního SoC **nedosáhne** tail ani optimisticky | Slabý den — 100 % dnes nejspíš nevyjde |
|
||||
|
||||
### 5.4 Plánovaný přebytek `E_surplus_after_t`
|
||||
|
||||
Pro sloty `t ∈ [t_detach, last_neg]`:
|
||||
|
||||
```text
|
||||
E_surplus_after_t = Σ_t max(0,
|
||||
pv_a_forecast[t] + pv_b_forecast[t]
|
||||
- load_baseline[t]
|
||||
- charge_to_battery_cap[t]
|
||||
)
|
||||
```
|
||||
|
||||
× `0,25 h` (případně jen část nad tím, co jde do `soc_need`).
|
||||
|
||||
**Použití:**
|
||||
|
||||
| Spotřebič | Pravidlo |
|
||||
|-----------|----------|
|
||||
| TČ předehřát v **T** | Jen pokud `E_surplus_after_t` > práh a **T** je dostatečně brzy |
|
||||
| Bazén filtrace | Rozpočet hodin ≤ f(`E_surplus_after_t`), slunce) |
|
||||
| Spirála (📋) | Až když TČ + bazén nestačí sežrat přebytek |
|
||||
| Export B | Zbytek (zelený bonus) — lepší než -0,3 Kč/kWh, horší než vlastní spotřeba |
|
||||
|
||||
### 5.5 Chování PV A po T (📋)
|
||||
|
||||
**Ne** „tvrdě urazit A v 80 %“.
|
||||
|
||||
| Režim | LP / plán |
|
||||
|-------|-----------|
|
||||
| `t < t_detach` | Plné nabíjení z A+B směrem k `soc_need[t]` |
|
||||
| `t ≥ t_detach` | **Necpát A do baterie** (`bc_pv` z A minimálně); A dostupné pro `pv_ld` / dům |
|
||||
| Curtail A | Měkké nebo jen při riziku zbytečného exportu A za `sell < 0` |
|
||||
|
||||
**Deye:** reg **340** = forecast A − curtail; při plném plánu bez exportu EMS 340 nemusí zapisovat (`plan_skips_deye_reg340_write`).
|
||||
|
||||
### 5.6 Výstupy do `solver_params` (📋)
|
||||
|
||||
Navrhované klíče v `planning_run.solver_params.inputs`:
|
||||
|
||||
| Klíč | Typ | Popis |
|
||||
|------|-----|--------|
|
||||
| `neg_sell_soc_ramp_wh` | pole | `soc_need[t]` per slot ISO |
|
||||
| `t_detach_idx` | int | index slotu T |
|
||||
| `e_surplus_after_t_wh` | float | integrál přebytku |
|
||||
| `neg_sell_window_slots` | int | délka okna |
|
||||
| `planner_build_tag` | string | např. `2026-05-28-neg-sell-b-ramp-v35` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Termika — TČ, TUV, spirála
|
||||
|
||||
### 6.1 Co je dnes v solveru ✅
|
||||
|
||||
- Proměnná **`hp[t]`** 0…`rated_heating_power_w` v bilanci `load_site_expr`.
|
||||
- **TUV look-ahead:** `tuv_usage_stats`, nouz pod `tuv_min_temp_c`, boost při poklesu pod `min+5 °C`.
|
||||
- **Export TČ:** `heat_pump_enabled` / `heat_pump_setpoint_w` v `planning_interval`; Modbus zápis — viz [`control.md`](control.md) (často TODO).
|
||||
|
||||
**Není v modelu:** spirála, bojler jako samostatná zátěž, teplotní stav zásobníku jako spojitá proměnná v každém slotu (jen zjednodušený `tuv_pred`).
|
||||
|
||||
### 6.2 Pravidla podle typu dne (📋)
|
||||
|
||||
#### Den **se** `sell < 0`
|
||||
|
||||
| Kdy | TČ / TUV |
|
||||
|-----|----------|
|
||||
| Ranní pásma **před** `sell < 0` (pre-neg export) | **Netopit** (kromě nouze pod `tuv_min`) |
|
||||
| Uvnitř `sell < 0`, `t < t_detach` | Minimum; priorita nabíjení bat |
|
||||
| Uvnitř `sell < 0`, `t ≥ t_detach` | Komfort / předehřát dle `E_surplus_after_t` |
|
||||
| Večer (sprcha) | **Doklep** na `tuv_comfort_temp_c` |
|
||||
|
||||
#### Den **bez** `sell < 0`
|
||||
|
||||
- TČ v slotech s **nízkým buy** a dobrým **COP** (poledne), ne v nejlepších **exportních** slotech FVE.
|
||||
- Spirála: nízká priorita — preferovat prodej FVE.
|
||||
|
||||
### 6.3 TČ vs spirála (📋)
|
||||
|
||||
| Kritérium | Preferovat TČ | Preferovat spirálu |
|
||||
|-----------|---------------|-------------------|
|
||||
| Dlouhé `sell < 0`, B pokryje bat | Ano (COP) | Ne |
|
||||
| Krátké okno, hodně FVE „na střeše“ | Částečně | Ano, pokud marginal cost ≈ 0 |
|
||||
| Den bez `sell < 0` | Ano při dobrém COP | Spíš ne |
|
||||
|
||||
Spirála vyžaduje **novou zátěž** v DB + LP (`flex_load_spiral[t]` nebo signál Loxone).
|
||||
|
||||
### 6.4 Parametry termiky (rozhodnutí + otevřeno)
|
||||
|
||||
| Parametr | Stav | Hodnota / poznámka |
|
||||
|----------|------|---------------------|
|
||||
| `hp_no_run_pre_neg_export` | **Rozhodnuto** | `true` — v `pre_neg_pv_export_ts` **netopit** (raději export FVE). |
|
||||
| `tuv_comfort_temp_c` | Otevřeno | Např. 50–52 °C — doplnit do konfigurace site. |
|
||||
| `tuv_preheat_temp_c` | Otevřeno | Např. 55–58 °C — jen v bodu **T**, pokud `E_surplus_after_t` stačí. |
|
||||
| `tuv_evening_topup_hour` | **Rozhodnuto** | **19:00** Europe/Prague — večerní doklep TUV (implementace v36). |
|
||||
| Spirála | **Rozhodnuto** | Ovládání **Loxone**; model v EMS až v38. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Bazén — filtrace a přitop (📋)
|
||||
|
||||
### 7.1 Provozní záměr (rozhodnutí home-01)
|
||||
|
||||
- **Filtrace ~1 kW** — min. **4 h/den**; **více hodin**, pokud `E_surplus_after_t` a přebytek dovolí (marginalní náklad ≈ 0).
|
||||
- **Kdy:** přes den ve **slunečných** slotech (`is_daytime_pv_surplus_slot` nebo obdobné); **dynamicky** dle cen / přebytku, ne pevné okno 09–17.
|
||||
- **Proč ve dni:** cirkulace promíchá prohřátou hladinu.
|
||||
- **Priorita:** po rampě bat / od bodu **T**, před exportem B za `sell < 0`.
|
||||
- **Přitop vody:** **mimo** první verzi plánovače; začátek sezóny **ručně**; automatika později.
|
||||
- **Exekuce:** **Shelly** — ovládání z EMS po implementaci assetu (v37).
|
||||
|
||||
### 7.2 Napojení na `E_surplus_after_t`
|
||||
|
||||
```text
|
||||
pool_hours_max = min(
|
||||
pool_filter_hours_per_day_config,
|
||||
floor(E_surplus_after_t_wh / (1000 W × 0,25 h))
|
||||
)
|
||||
```
|
||||
|
||||
Rozložit do slotů s `sell < 0` ∧ slunce ∧ `t ≥ t_detach`.
|
||||
|
||||
### 7.3 Datový model (📋)
|
||||
|
||||
Zatím **není** v `db/migration`. Návrh:
|
||||
|
||||
- `ems.asset_pool` nebo rozšíření site config JSON
|
||||
- sloupce: `filter_power_w`, `filter_hours_per_day`, `solar_window_start_hour`, `solar_window_end_hour` (Prague)
|
||||
|
||||
### 7.4 LP (📋)
|
||||
|
||||
- `pool_filter[t] ∈ [0, filter_power_w]`
|
||||
- Zapnout jen pokud: `soc[t] ≥ soc_need[t]`, `sell[t] < 0`, slunce, zbývá denní rozpočet hodin
|
||||
- Penalizovat `ge_pv` z B při plné baterii a zapnutém bazénu
|
||||
|
||||
---
|
||||
|
||||
## 8. EV
|
||||
|
||||
- Typicky **odpoledne** — session z telemetrie / `ev_session`.
|
||||
- LP: deadline constraint na `target_soc` k `target_deadline`.
|
||||
- Strategická vazba na v35: **po dosažení rampy** nebo v `allow_charge` + PV bohatých slotech — ne v ranním pre-neg exportu.
|
||||
- Konflikt s večerním exportem bat řeší stávající masky `allow_discharge_export`.
|
||||
|
||||
---
|
||||
|
||||
## 9. UI plánování — význam čísel
|
||||
|
||||
Řádek v detailu slotu (**Planning.tsx**):
|
||||
|
||||
**„Škrcení A / ≈ reg 340“**
|
||||
|
||||
| Zobrazení | DB / výpočet | Význam |
|
||||
|-----------|--------------|--------|
|
||||
| **CURTAIL X W** | `pv_a_curtailed_w` | Kolik W z pole A plán **odebírá** (nechce využít). **0** = žádné škrcení. |
|
||||
| **povoleno Y W** | `pv_a_forecast_solver_w − pv_a_curtailed_w` | Odhad **reg 340** (*max solar power*) pro pole A. |
|
||||
|
||||
Příklad: forecast A = 4 654 W, curtail = 1 117 W → povoleno **3 537 W**.
|
||||
|
||||
**Badge `sell− prep` / `sell− tail`:** z `solver_params.masks[].neg_sell_phase` (v32).
|
||||
|
||||
**Bat. / síť / SoC:** `battery_setpoint_w` / `grid_setpoint_w` / `battery_soc_target_pct` — po v34 u vysoké FVE **grid ≈ 0**, ne fiktivní import = load.
|
||||
|
||||
### 9.1 Vizualizace flexibilních zátěží — probrat před implementací (📋)
|
||||
|
||||
**Stav:** produktové rozhodnutí **není** — **neimplementovat** bazén / rozšířené TČ v UI ani v LP sinku, dokud není schválený návrh. Workshop mezi **v35** a **v37**.
|
||||
|
||||
**Proč:** flexibilní zátěže (TČ, bazén, spirála, EV) sdílí stejnou časovou osu jako energie (**T**, `E_surplus_after_t`, fáze sell<0). Bez přehledného UI bude provoz těžko kontrolovatelný.
|
||||
|
||||
**Návrhy k diskusi** (nic z toho není závazná implementace):
|
||||
|
||||
| Nápad | Co ukázat |
|
||||
|-------|-----------|
|
||||
| **Pásma dne** | V grafu plánu: pre-neg export \| sell<0 prep \| od **T** \| tail \| večerní export bat. |
|
||||
| **Bod T** | Svislá značka + tooltip: `t_detach`, `e_surplus_after_t_wh`, odhad hodin bazénu. |
|
||||
| **Rozpočet bazénu** | „Dnes 2/4 h filtrace naplánováno“ + zbývající Wh přebytku. |
|
||||
| **Slot detail** | Kromě bat/síť/FVE: **TČ** (`heat_pump_setpoint_w`), **EV**, (budoucí) **bazén ON**, badge **flex sink**. |
|
||||
| **Srovnání běhů** | Před/po v35: rampa SoC, méně fiktivního grid importu, curtail A. |
|
||||
| **Živě vs plán** | Volitelně: telemetrie TUV / Shelly pool vs plánovaný stav (až bude data). |
|
||||
|
||||
**Výstup workshopu:** krátký mock / seznam widgetů v `Planning.tsx` + které sloupce ukládat do `planning_interval` / `solver_params`.
|
||||
|
||||
**Otevřené otázky UI:** viz [`docs/06-open-questions.md`](../06-open-questions.md).
|
||||
|
||||
---
|
||||
|
||||
## 10. Priorita flexibilních spotřebičů (📋)
|
||||
|
||||
Při `sell < 0` a plné / dostatečné baterii:
|
||||
|
||||
```text
|
||||
1. Bazální dům (load-first, pv_ld)
|
||||
2. Nouz TUV (tuv_min)
|
||||
3. EV deadline
|
||||
4. TČ komfort / doklep / předehřát (dle fáze)
|
||||
5. Bazén filtrace (slunce, rozpočet hodin)
|
||||
6. Spirála (až bude v EMS)
|
||||
7. Export pole B (zelený bonus)
|
||||
8. Curtail A (poslední ventil)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Roadmap implementace
|
||||
|
||||
| Pořadí | Fáze | Tag / doc | Obsah | Blokátor |
|
||||
|--------|------|-----------|--------|----------|
|
||||
| 1 | **v35** ✅ | `neg-sell-b-ramp-v35` | Rampa `soc_need` z B, **T**, `E_surplus_after_t`, uvolnění A | — |
|
||||
| 2 | **UI workshop** | — | Vizualizace flex. zátěží — § 9.1; schválený návrh widgetů | **Před v37** |
|
||||
| 3 | **v36** | `termika-v36` | Blok TČ pre-neg; TUV v `sell<0` po **T**; večerní doklep **19:00** Prague | v35 |
|
||||
| 4 | **v37** | `pool-v37` | Bazén: Shelly, min 4 h/den, LP sink | UI workshop |
|
||||
| 5 | **v38** | `spiral-v38` | Spirála (Loxone) + volba TČ vs spirála | v37 |
|
||||
|
||||
Každá implementační fáze: migrace (pokud DB), `planning_engine.py`, testy MILP, `planning-changelog.md`, ověření MCP na home-01.
|
||||
|
||||
---
|
||||
|
||||
## 12. Ověření v provozu
|
||||
|
||||
```sql
|
||||
-- aktivní běh
|
||||
select id, solver_params->>'planner_build_tag' as tag,
|
||||
solver_params->'inputs'->>'pre_neg_pv_export_forecast_ok' as pre_neg_ok,
|
||||
solver_params->'inputs'->>'t_detach_idx' as t_detach,
|
||||
solver_params->'inputs'->>'e_surplus_after_t_wh' as e_surplus
|
||||
from ems.planning_run
|
||||
where site_id = (select id from ems.site where code = 'home-01')
|
||||
and status = 'active'
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
|
||||
-- sloty kolem poledne
|
||||
select pi.interval_start at time zone 'Europe/Prague' as prague,
|
||||
pi.battery_soc_target_pct,
|
||||
pi.pv_a_curtailed_w,
|
||||
pi.pv_a_forecast_solver_w,
|
||||
pi.battery_setpoint_w,
|
||||
pi.grid_setpoint_w,
|
||||
pi.effective_sell_price
|
||||
from ems.planning_interval pi
|
||||
join ems.planning_run pr on pr.id = pi.run_id
|
||||
where pr.site_id = (select id from ems.site where code = 'home-01')
|
||||
and pr.status = 'active'
|
||||
and (pi.interval_start at time zone 'Europe/Prague')::date = current_date
|
||||
order by pi.interval_start;
|
||||
```
|
||||
|
||||
```bash
|
||||
# testy
|
||||
cd backend && python3 -m pytest tests/test_planning_dispatch_milp.py -k "NegSell or PreNeg or LoadFirst" -q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Související soubory
|
||||
|
||||
| Oblast | Cesta |
|
||||
|--------|--------|
|
||||
| Solver | `backend/services/planning_engine.py` — `_neg_sell_day_phases`, `_pre_neg_pv_export_*`, `solve_dispatch` |
|
||||
| DB parametry | `db/migration/V083__planner_neg_sell_phases.sql` |
|
||||
| Kontext site | `db/routines/R__039_fn_planning_site_context.sql` |
|
||||
| FE plán | `frontend/src/pages/Planning.tsx` — `pvAAllowedW`, curtail badge |
|
||||
| Deye 340 | `backend/services/control/setpoints.py` — `compute_pv_a_reg340_max_solar_w` |
|
||||
| TUV stats | `ems.tuv_usage_stats`, `fn_update_tuv_usage_stats` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Otevřená rozhodnutí
|
||||
|
||||
Živý seznam: [`docs/06-open-questions.md`](../06-open-questions.md) — sekce **Plánování — neg sell, termika, flexibilní zátěže**.
|
||||
|
||||
Zbývá hlavně: **čas večerního doklepu TUV** (~19h?), **návrh UI flex zátěží** (workshop před v37).
|
||||
@@ -8,14 +8,33 @@
|
||||
|
||||
- **SQL-first:** horizont a sloty z DB funkcí (`fn_planning_horizon_end`, `fn_load_planning_slots_full`, …); viz **`CLAUDE.md`** → sekce *SQL-first a read-model*.
|
||||
- **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** – obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu.
|
||||
- **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
|
||||
- **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Důležité: pokud rolling replan startuje s baterií na 100 %, `allow_charge` se nesmí stát globálně `false` pro celý horizont – jinak solver nemá motivaci baterii před PV špičkou „uvolnit“ (headroom), protože ji pak nesmí z PV znovu nabít. Aktuálně se v tomto případě `allow_charge` ponechá povolené alespoň pro sloty s `pv_surplus_w > 0`.
|
||||
- **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × effective_factor / 1000) × soc[T−1]` (Kč), kde `effective_factor = planner_terminal_soc_value_factor × (1 − terminal_neg_buy_weight)` a základní faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Při **`buy<0`** v horizontu (36 h) roste **`terminal_neg_buy_weight`** s blízkostí a záporností ceny — LP nemá „šetřit“ baterii před levným importem. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
|
||||
- **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]`** — výkon vybíjení na AC sběrnici z energetické bilance `pv + gi + bd = load + bc + ge`. Při exportu z baterie je v `bd` už započten i tok do sítě (`ge_bat` je součást `ge`); **`ge_bat` se v SoC znovu neodečítá** (dříve double-count → plán klesal ~2× rychleji než BMS ve večerním exportu). Tag `2026-05-28-evening-export-soc-balance-v39`.
|
||||
- **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie.
|
||||
- **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP).
|
||||
- **Grid ze sítě (vrstva B, před FVE):** výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). **v43 `evening_arbitrage_unlock`:** grid **11–16h** jen na dnech **bez sell<0**, když večer `buy + degrad < evening_peak_sell`. **v44 `neg_day_no_grid_before_neg_sell`:** na neg den **žádný grid před 1. sell<0**. Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`.
|
||||
- **PV vrstva A:** při `sell ≥ 0` jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak). Při **`sell < 0`** vrstva A **bez** tohoto filtru (nabít z FVE v záporném výkupním okně). Historie: [`docs/planning-changelog.md`](../planning-changelog.md).
|
||||
- **LP (AUTO):** objective explicitně `−ge_pv×sell − ge_bat×sell + ge_bat×acquisition` v exportních slotech; **bez** cross-slot vynucení `ge_pv ≥ surplus`. Guard FVE: `ge_pv=0` jen pokud `sell < charge_acquisition − degrad` (ne `sell < buy` ve slotu). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
|
||||
- **Load-first (Deye, AUTO, tvrdý od v34):** proměnné `pv_ld` (PV → load+EV+TČ), `pv_sp` (přebytek), `bc_pv` / `bc_gi`. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`; `bc_pv + ge_pv ≤ pv_sp`; **`gi ≤ bc_gi + max(0, max_load − pv_forecast)`** (při vysoké FVE žádný fiktivní import = load); při `pv ≥ load + 500 W` **`pv_ld ≥ load`**; mimo `allow_discharge_export`: `bd ≤ load − pv_ld`, `pv_ld ≥ load − bd`. Tag `2026-05-28-load-first-hard-v34`. Test `LoadFirstDispatchTests`.
|
||||
- **Tvrdé výkonové limity site/baterie:** `gi ≤ site_grid_connection.max_import_power_w` (breaker); **`bc_pv + bc_gi ≤ asset_battery.max_charge_power_w`**; **`ge ≤ max_export_power_w`** (proměnná `ge`, platí `ge = ge_pv + ge_bat`); **`bd + ge_bat ≤ asset_battery.max_discharge_power_w`** (vybíjení do domu + export z baterie nesmí současně překročit BMS). Dříve LP dovoloval import+nabíjení a dvojnásobné nabíjení; u prodeje hrozilo současné `bd` a `ge_bat` až 2× max discharge — viz `SitePowerCapTests`.
|
||||
- **Hodnota FVE (PV store value):** tvrdé `ge_pv = 0` jen pokud `sell < future_sell_opportunity − degradation` **a** `sell < 0` (spot), nebo u fixního tarifu dle `fixed_pv_b_export_cap`. Při **`sell ≥ 0` (spot home-01, KV1):** `ge_pv` **neblokuje** pv_store — solver volí export vs. `bc_pv` podle `−ge_pv×sell` a degradace; **baterii** na večerní peak drží `ge_bat` (`evening_early` / push), ne curtail FVE. **v31:** při `sell ≥ 0` + PV přebytek **není** plný `ge_bat` push z `pre_neg_buy_discharge` / ranních shortfallů (export cap pro FVE). **Před prvním `sell < 0`:** `allow_pre_neg_pv_export`. Tag `2026-05-28-morning-pv-export-priority-v31`. Testy `Home01PvStoreValueTests`, `PreNegativeSellExportTests`.
|
||||
- **BA81 úsvit + MI (v51):** `fixed_pv_b_export_cap` (`ge_pv ≤ pv_b`) jen pokud **`pv_a_forecast ≥ 1500 W`** (`DAWN_LOW_PV_NO_CURTAIL_W`); při slabším A + přebytku → `fixed_mi_low_pv_surplus_export` (bez pv_store bloku). Exporter: při `forecast < 1500` a bez curtail A → **bez reg 340** (`setpoints.py`). Tag `2026-05-31-ba81-dawn-no-micro-curtail-v51`. Test `test_ba81_dawn_low_pv_no_full_curtail_for_mi_cap`.
|
||||
- **Fixní tarif — charge-slot budget (v1, 2026-06-06):** **`R__063`** vybírá nabíjecí sloty Wh kumulací; PV vrstva A u fixed = **`sell ASC`**. LP **nezakazuje** `bc_pv`/`bc_gi` prahy v58 (`sell > min+0,20`); respektuje jen `allow_charge` / `allow_grid_charge`. Večerní push: **`sell > buy + spread`** (BA81); KV1 navíc v52 morning-peak pravidlo. Debug: `charge_slot_budget` v `solver_params`, sloupce `charge_layer` / `charge_slot_reason` ve `fn_load_planning_slots_full`. Spec: **[`planning-charge-slot-budget.md`](planning-charge-slot-budget.md)**. Tag **`2026-06-06-charge-slot-budget-v1`**. v59 grid maska (min sell) a večerní push `bc=0` v push slotech zůstávají.
|
||||
- **Drahý nákup → vlastní spotřeba z baterie:** mimo `allow_charge` platí `bd + pv_ld ≥ load_baseline + hp[t]` a `gi ≤ EV + hp[t]` (ne `hp_rated`). **Spot:** drahý slot = `buy > min(buy≥0) + degradace`. **Fixní nákup (DB `purchase_pricing_mode=fixed` nebo heuristika rozptylu buy < 0,25):** navíc `buy > charge_acquisition + degradace`. Na spotu **nesmí** `charge_acquisition` (~0,9 Kč) označit všechny sloty jako drahé → Infeasible (home-01). Při **Infeasible** solver jednou opakuje s `relaxed_expensive_import` (síť smí krmit baseload v drahých slotech; v `solver_params.inputs.relaxed_expensive_import=true`). Testy `AutoPassiveSelfConsumptionTests`, `test_spot_low_acquisition_does_not_mark_all_slots_expensive`, `test_negative_buy_in_horizon_does_not_block_all_grid_import`.
|
||||
- **Záporný výkup (`sell < 0`) bez exportu:** `block_export_on_negative_sell` (KV1) **nebo** `purchase_pricing_mode=fixed` (BA81). **Spot (home-01):** `ge_pv=0` dokud není plná baterie; při plné jen ventil pole B (`ge_pv ≤ pv_b`, `w_pv_b_vent_neg`); výboj baterie při `sell<0` jen **12 slotů** před `buy ≤ planner_extreme_buy_threshold` (default −2), pokud spread do budoucna dává smysl — tag `2026-05-26-neg-sell-bat-dump-extreme-buy-v11`. Večerní discharge maska u spotu: denní peak ≥17:00 (ne `sell > ref_buy` v slotu). **v50:** u **KV1** při `sell≥0` a PV přebytku >500 W i **po** 1. `sell<0` → `ge_pv` (PV_SURPLUS), ne tvrdý `ge_bat` z večerního peak/push.
|
||||
- **Pole B při sell<0 (home-01):** pokud `block_export_on_negative_sell = false`, LP nesmí vynutit `ge_pv = 0` (přebytek neriťitelného PV B). KV1 s `block_export = true` jen curtail A / nabíjení.
|
||||
- **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
|
||||
- Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny.
|
||||
- **LP ekonomické guardy** (`solve_dispatch`, AUTO): `ge_pv=0` pokud `sell < charge_acquisition − degradation` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation` mimo charge masku → `gi` jen na load+EV+TČ. Viz `planning_engine.py` po slot pre-selection.
|
||||
- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) a flag `is_daytime_pv_surplus_slot`.\n+\n+ V solveru (`planning_engine.solve_dispatch()`):\n+ - `safety_soc_target_wh` se používá primárně jako **ochrana exportu z baterie**: v běžných slotech (mimo high‑sell špičky) se při aktivním exportu vynutí `soc[t] ≥ max(arb_base_wh, safety_soc_target_wh)`.\n+ - safety deficit penalizace v objective běží jen v `is_daytime_pv_surplus_slot` (a ne v high‑sell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ Tvrdé `allow_charge` se kvůli tomu nemění.
|
||||
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0` a současně **není výrazný export** (`grid_setpoint_w ≥ −500`). To je záměr: commitment má kotvit „nabíjení z PV přebytku“, ne „charge while exporting“. Měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu je řízená `planner_charge_commitment_penalty_czk_kwh` na `asset_battery`. Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
|
||||
- **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug(<run_id>);`** (`R__087_fn_planning_run_debug.sql`).
|
||||
- **Runtime guard v exportu setpointů (legacy):**
|
||||
- při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by `is_predicted_price` v horizontu nemělo nastat).
|
||||
- **Ekonomika baterie:**
|
||||
- `min_soc_percent` = nejnižší SoC v LP a runtime clamp telemetrie; u **více paralelních stringů** držet **nad** holým BMS minimem (typicky **11–12 %**; migrace **V029** + komentář v DB, u `home-01` cílený UPDATE z 10 %),
|
||||
- `reserve_soc_percent` = ekonomická („arbitrážní“) podlaha – pod ní MILP s `w_arb` omezuje vybíjení podle začátku slotu a FVE lookahead (`arb_floor_series`; typicky 20 %),
|
||||
- **Export ze site:** binárka `z_export[t]` – pokud `grid_export ≥ 1` W, musí být **koncové** `soc[t] ≥ arb_base_wh` (fixní z DB, **ne** dynamicky snížená `arb_floor_series`),
|
||||
- **Export ze site:** binárka `z_export[t]` – pokud `grid_export ≥ 1` W, musí být **koncové** `soc[t] ≥ export_soc_floor_wh`, kde:\n+ - při hluboké relaxaci (`soc_panel_min` pod `min_soc`) je `export_soc_floor_wh = soc_panel_min[t]`,\n+ - jinak je `export_soc_floor_wh = arb_base_wh`, a v běžných slotech se safety targetem navíc `max(arb_base_wh, safety_soc_target_wh)` (mimo high‑sell špičky). `arb_floor_series` se pro `z_export` nepoužívá.
|
||||
- `degradation_cost_czk_kwh` (např. 0.15) / penalizace cyklu v objective symetrická (`0.5*(charge+discharge)`).
|
||||
- **PV-aware nejistota:**
|
||||
- objective používá `pv_scarcity_factor` (0.65..1.0), odvozený z forecastu slunce,
|
||||
@@ -24,7 +43,17 @@
|
||||
- měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše.
|
||||
- **Dynamická ekonomická podlaha (fáze 2):**
|
||||
- `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB – silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění.
|
||||
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` omezuje, ve kterých slotech smí solver vybíjet baterii „nad rámec spotřeby“ pro export do sítě (anti-mikrocyklování). Aktuálně se sloty pro exportní vybíjení vybírají **globálně** podle `sell_price desc` přes celé okno (ne 50/50 AM/PM), aby solver neodkládal vybíjení do levnějších ranních slotů, pokud jsou dražší sloty už večer.
|
||||
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` (`R__063`). Tři vrstvy:
|
||||
1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`. Před prvním `sell < 0` se z rozpočtu **vynechají** sloty, kde **později tentýž den** existuje `sell` vyšší o více než `degradation` (OTE, ne pevné hodiny 00–04).
|
||||
2. **Večerní špičky per den:** `sell ≥ max(sell) − degradation` jen pro hodiny **≥ 17** (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera).
|
||||
3. **Ranní pásmo před prvním `sell < 0`:** hodiny **5–11** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno − degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno.
|
||||
**Planner tag v25:** v24 + **dvoufáze před `buy<0`:** u posledního `sell≥0` cíl `soc≈max` (bez exportu); mezi tím a `buy<0` výboj na `_pre_neg_buy_soc_ceiling_wh`; v okně `buy<0` jen import (`bc_pv=0`), **curtail PV A jen při `buy<0`**; ranní `sell<0` před `buy<0` smí PV→bat. Viz changelog v25.
|
||||
**Planner tag v24:** v23 + **večerní tvrdý push** podle rozpočtu Wh (`discharge_slot_buffer`, SoC nad `min_soc`, `per_slot_discharge`) — bez pevného top-3 / `len≥2`. Viz changelog v24.
|
||||
**Planner tag v26:** v25 + upřesnění večerního exportu — viz sekce **Večerní export z baterie** níže a changelog v26.
|
||||
**Planner tag v23:** v22b + **výboj baterie do sítě** před `buy<0` (`_pre_neg_buy_discharge_indices`, sell≥1 Kč/kWh, push `ge_bat` z DB limitů). Viz changelog v23.
|
||||
V `solve_dispatch` (AUTO): **`charge_slots`** = `allow_charge` z DB + **`buy < 0`** + všechny sloty **`sell < 0`** s PV přebytkem > 500 W (i bez `block_export_on_negative_sell`, BA81). **`pv_charge_shortfall`** / **`NEG_SELL_CURTAIL_PENALTY`** platí v těchto slotech. Při **`sell < 0`** (legacy): safety deficit cílí **`soc_max_wh`**; po posledním **`sell < 0`**: **`post_neg_pv_topup`**. **Planner tag v32:** fázované SoC — viz níže.
|
||||
- **Záporný výkup — strategie home-01 (v32–v40 prep hotovo):** **[`planning-neg-sell-strategy.md`](planning-neg-sell-strategy.md)**. **v35:** rampa B. **v36 prep:** oprava **T**, pre-neg per den (cushion A+B), večer D−1. **v40:** cushion a večerní výboj z **`observed_soc_wh`** (telemetrie), rozpočet `neg_evening_export_budget_wh` (`2026-05-29-neg-prep-observed-soc-v40`). **v36 termika** (TČ/TUV) — otevřeno.
|
||||
- **Před sell<0 — export FVE s forecast pojistkou (v33, dočasné):** `_pre_neg_pv_export_forecast_cushion_ok` — export FVE v kladných slotech před prvním `sell<0` jen pokud součet predikovaného PV přebytku v sell<0 okně (týž pražský den) pokryje dobítí na prep SoC (× **1,15**). Jinak LP raději nabíjí z FVE (riziko deště). Při splněné pojistce: `bc_pv=0` v `pre_neg_pv_export_ts`, shortfall na `ge_pv`, penalizace `bc_pv`. `solver_params.inputs.pre_neg_pv_export_forecast_ok`, `pre_neg_pv_export_slots`. Testy `PreNegPvExportForecastTests`. **Plánovaná náhrada:** `pre_window_wh` + nabíjecí fronta — **[`planning-charge-slot-budget.md`](planning-charge-slot-budget.md)** §6. U **fixního tarifu** s polem B: **`ge_pv ≤ pv_b`** (ne pv_store **`ge_pv = 0`**). Při **`deye_gen_microinverter_cutoff_enabled`**: **`ge == 0` jen** pokud **`block_export_on_negative_sell`** (KV1), ne kvůli samotnému `z_gen_cutoff` (BA81 musí moci exportovat B při plné baterii). Vstupní **`soc_wh`** z telemetrie se před MILP omezí přes **`_planner_soc_for_solver`** (rezerva ~650 Wh pod `soc_max`, jinak Infeasible při 100 % SoC a dlouhém záporném výkupu). **`planner_build_tag`** v `solver_params`. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md).
|
||||
- **Záporná nákupní cena:**
|
||||
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
|
||||
- **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —
|
||||
@@ -33,12 +62,114 @@
|
||||
- **Export bez forecastového capu:** solver ukládá explicitní `planning_interval.export_limit_w` jako tvrdý site/inverter limit a `planning_interval.export_mode` (`NONE` / `PV_SURPLUS` / `BATTERY_SELL`). Exportér z plánu neodvozuje žádný forecastový strop exportu.
|
||||
- **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit.
|
||||
- **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu.
|
||||
- **Kalibrace PV forecastu (delta profil):** tabulka `ems.site_pv_forecast_calibration` drží per `site_id` mimo jiné `delta_learn_min_ts` (dolní mez řádků z `forecast_accuracy` pro učení delty), volitelně `pv_curtailment_policy_effective_from` a přepsání parametrů (`top_n_days`, `half_life_days`, …; **V076** navíc `reference_day_weight_mult` pro „připnuté“ dny níže). **`ems.site_pv_forecast_reference_day`** (**V076**) umožňuje zvýšit váhu konkrétních kalendářních dnů (datum ve `site.timezone` jako u časování slotů) při agregaci δ z `forecast_accuracy` (`fn_pv_forecast_delta_profile`); hromadný zápis **`ems.fn_pv_forecast_sync_reference_days`**, detail **`docs/04-modules/forecast.md`**. `ems.fn_fill_forecast_accuracy` nastavuje `learning_eligible` / `learning_exclude_reason` (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v `ems.cutoff_switch_log` po účinnosti policy se z učení vyřadí; u škrcení zůstává `actual_power_w` NULL). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`). `ems.fn_pv_forecast_delta_profile` vrací `deltas_by_array` i součtové `deltas`; `ems.fn_load_planning_slots_full` aplikuje stejnou **per-pole** korekci jako UI (`fn_forecast_pv_slots_range_corrected`); pokud v JSON profilu chybí `deltas_by_array`, použije se souhrnné `deltas` rozpuštěné podle podílu výkonu pole na slotu (solver má tak stále použitou korekci i bez per-pole JSON).
|
||||
- **Kanonický PV forecast (delta + rolling):** tabulka `ems.site_pv_forecast_calibration` drží per `site_id` mimo jiné `delta_learn_min_ts` (dolní mez řádků z `forecast_accuracy` pro učení delty), volitelně `pv_curtailment_policy_effective_from` a přepsání parametrů (`top_n_days`, `half_life_days`, …; **V076** navíc `reference_day_weight_mult` pro „připnuté“ dny níže). **`ems.site_pv_forecast_reference_day`** (**V076**) umožňuje zvýšit váhu konkrétních kalendářních dnů (datum ve `site.timezone` jako u časování slotů) při agregaci δ z `forecast_accuracy` (`fn_pv_forecast_delta_profile`); hromadný zápis **`ems.fn_pv_forecast_sync_reference_days`**, detail **`docs/04-modules/forecast.md`**. `ems.fn_fill_forecast_accuracy` nastavuje `learning_eligible` / `learning_exclude_reason` (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v `ems.cutoff_switch_log` po účinnosti policy se z učení vyřadí; u škrcení zůstává `actual_power_w` NULL). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`).\n+\n+ **Single source of truth pro solver i UI** je `ems.fn_forecast_pv_slots_range_canonical_ab`, která v jednom místě kombinuje:\n+ - delta profil (aditivní odečet per-array)\n+ - rolling multiplikativní faktor vs telemetrie (`fn_pv_forecast_correction_factor`) s decay.\n+ `ems.fn_load_planning_slots_full` bere PV A/B z této kanonické funkce; UI je čte z `/plan/current` (bundle obsahuje `pv_*_forecast_solver_w` i `pv_forecast_total_w` jako součet).
|
||||
|
||||
Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z `fn_planning_horizon_end`) najednou, čímž přirozeně zvládá:
|
||||
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
|
||||
- kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu
|
||||
|
||||
### Večerní / noční export z baterie (v24–v30) — co plánovač dělá a co ne
|
||||
|
||||
Cíl zůstává **maximální ekonomický užitek v celém horizontu**: prodat (a nabít) v časech, kdy to dává smysl podle cen a kapacity baterie. **v30:** noční okno **přes půlnoc** (17:00 → 0–5:00 Prague), konec při **východu FVE** (`pv_a+pv_b > load + 500 W`); **tvrdý push baterie** jen v tmavých slotech, ne po východu slunce.
|
||||
|
||||
#### Co se řeší jinde (není „večerní v26“)
|
||||
|
||||
| Čas / situace | Kde v kódu / SQL | Příklad |
|
||||
|---------------|------------------|---------|
|
||||
| Ráno **5–11** před prvním `sell < 0` | R__063 ranní pásmo + LP `morning_pre_neg_export_ts` | Export před záporným výkupním oknem, ne „před FVE“ jako takové |
|
||||
| Odpoledne / noc, obecně profitable | `allow_discharge_export` z rozpočtu Wh + LP `peak_export_shortfall` | Kdekoliv v horizontu, pokud marže sedí |
|
||||
| **≥ 17:00** večer + **0–5:00** (v30) | v24 Wh push + v26/v28 + **noční peak přes půlnoc** | OTE špička i kolem půlnoci |
|
||||
| Po východu FVE | konec nočního okna | push / peak jen `pv` pod prahem |
|
||||
|
||||
#### Tři vrstvy nočního chování (v30: 17:00 → půlnoc → do východu FVE)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[LP: globální optimum v horizontu] --> B{slot >= 17h a profitable export?}
|
||||
B -->|sell pod nocnim max - 0.05| C[ge_bat = 0: baterie ne pred spickou]
|
||||
B -->|profitable + peak band noc| D[push: sell desc az do Wh rozpoctu]
|
||||
D --> F[ge_bat >= plny vykon na cap v kazdem push slotu]
|
||||
C --> G[Vysledek: energie zustane na nejdrazsi vecer]
|
||||
F --> G
|
||||
```
|
||||
|
||||
1. **SQL masky (R__063, vrstva 2)** — které večerní sloty *smí* export z baterie vůbec (`allow_discharge_export`): mimo jiné sloty v pásmu „denní večerní max − degrad“ (SQL), plus globální Wh rozpočet (vrstva 1).
|
||||
|
||||
2. **v41 — zákaz večerního vývozu mimo špičku** (`evening_early_export_penalty_ts` → tvrdé `ge_bat[t] = 0`):
|
||||
- v **celém nočním okně** pro **všechny** sloty s `allow_discharge_export` **mimo** `evening_push_ts` (výjimky: pre-neg / neg-evening větve);
|
||||
- **nezakazuje** přebytek FVE do sítě (`ge_pv`).
|
||||
|
||||
3. **v43 / v49 — večerní push + nocí vlastní spotřeba + odpolední arbitráž** (`evening_push_ts`):
|
||||
- push jen **≥17h Prague** + `allow_discharge_export`; **v49:** rozpočet Wh z **aktuální SoC** jen pro **první noční epizodu** v horizontu (dnes večer → ráno), **ne** dělení se zítřejším večerem — zítřek přidá vlastní rolling replan po FVE/neg dni;
|
||||
- mimo push: **`night_self_consume_discourage`** — baterie krmí dům, ne import ~5 Kč/kWh;
|
||||
- **R__063 `evening_arbitrage_unlock`:** grid nabíjení **11–16h** jen na dnech **bez sell<0**, když večerní peak sell > buy + degrad;
|
||||
- **bez predawn push** (02–06h); **`peak_export_shortfall`** v noci vypnutý.
|
||||
|
||||
4. **v44 — neg den: místo pro FVE před sell<0 oknem:**
|
||||
- **`neg_day_no_grid_before_neg_sell`:** na kalendářní den s sell<0 **žádné grid nabíjení před 1. sell<0** (ne 3 Kč ráno místo 0,5 Kč v okně);
|
||||
- **`_neg_sell_pv_forecast_charge_wh`:** zpětná soc_need z **A+B** FVE, ne jen pole B;
|
||||
- LP **`bc_gi=0`** před 1. sell<0 na neg den.
|
||||
|
||||
5. **v45 — neg okno + noc z baterie:**
|
||||
- **`neg_window_grid_charge`:** v sell<0 okně neg dne grid nabíjení i bez `pv_surplus` (07:45+);
|
||||
- **`night_self_consume_discourage`** na **celé** noční okno mimo push;
|
||||
- při `relaxed_neg_prep_hold_only` nebo `relaxed_neg_prep_window` bez prep shortfall penalizace.
|
||||
|
||||
6. **v47 — po večerním pushu noc z baterie:**
|
||||
- večerní push zůstává **sell > acq+spread** (sell<buy je záměr před neg dnem);
|
||||
- **`post_evening_push_night_ts`:** po pushu **bd ≥ load**, ne import ~5 Kč i při relaxed solve.
|
||||
|
||||
7. **v52 — KV1 večerní push (fixed + block_export):**
|
||||
- push profitabilita: **`sell ≥ max(sell 5–11 před 1. sell<0) − degrad`**, ne `sell > fixní buy + spread`;
|
||||
- **`evening_early`** beze změny — export jen v `evening_push_ts` (ne rozprostřeně po celé noci).
|
||||
- Snap: `kv1_evening_push_morning_peak_rule`. Tag **`2026-05-31-kv1-evening-push-morning-peak-v52`**.
|
||||
|
||||
8. **v53 — rolling hysteréze push:** při Infeasible retry se **`evening_push_ts_override` zahodí**; filtr override slotů (export maska, bez defer PV). Snap: `evening_push_override_dropped_on_retry`. Tag **`2026-05-31-evening-push-override-retry-v53`**.
|
||||
|
||||
9. **v54 — relaxed prep + two-pass:** při **`relaxed_neg_prep_window`** i vypočtený **`evening_push_ts = ∅`**; pass2 two-pass **nepoužívá override** a dědí relax vlajky z pass1. Tag **`2026-05-31-evening-push-relaxed-clear-v54`**.
|
||||
|
||||
10. **v55 — jakýkoli relaxed retry:** tvrdý push off už od **`relaxed_expensive_import`**; commitment ignorovat od **`relaxed_neg_buy_charge`**; comparison v2 **non-fatal**. Tag **`2026-05-31-evening-push-any-relaxed-v55`**.
|
||||
|
||||
11. **v56 — ranní tvrdý export:** `morning_pre_neg_export` / pre-neg discharge **jen strict**; pass2 Infeasible → **pass1**. Tag **`2026-05-31-morning-export-relaxed-v56`**.
|
||||
|
||||
12. **v57 / v64 — večerní push po rei / prep relax:** `relaxed_expensive_import` **nesmí** vymazat `evening_push_ts`; tvrdý `ge_bat` push vypnut jen při **`neg_sell_phases_fallback`** (v64: ne při `relaxed_neg_prep_window`). Tag **`2026-06-01-evening-push-keep-on-relaxed-import-v57`**, **`2026-06-06-future-neg-buy-evening-export-v64`**.
|
||||
|
||||
13. **v58 — fixní tarif PV vs. nabíjení (BA81/KV1):** `fixed_horizon_min_sell`; při **`sell > min + 0,20`** + PV → **`bc_pv = 0`**, export FVE; profitable noc **`sell > buy`** mimo `evening_early`. Tag **`2026-06-01-fixed-pv-export-min-sell-charge-v58`**.
|
||||
|
||||
14. **v59 — fixní grid jen u min sell:** `bc_gi = 0` při **`sell < buy`** nebo **`sell > min + 0,20`**; push bez charge; **`R__063`** `sell ASC`. Tag **`2026-06-01-fixed-grid-charge-min-sell-v59`**.
|
||||
|
||||
15. **v61 — spot: grid→bat jen při buy ≤ acq:** `sell < buy` ve slotu **není** kritérium (marže); zákaz nabíjení při **`buy > charge_acquisition + degrad`**. Zrušeno v60. Tag **`2026-06-01-spot-grid-charge-at-acq-buy-v61`**.
|
||||
|
||||
16. **v63 — Infeasible journal + granulární prep relax (Branch 1):**
|
||||
- Retry řetězec: strict → `relaxed_expensive_import` → `relaxed_neg_buy_charge` → **`relaxed_neg_prep_hold_only`** (jen prep hold / prep_soc shortfall) → **`relaxed_neg_prep_window`** (vypne strict pre-neg PV export bundle) → `neg_sell_phases_fallback`.
|
||||
- Snap: `relax_chain`, `relaxed_neg_prep_hold_only`.
|
||||
- Selhání po celém řetězci → `planning_run.status = failed`, sloupec `error_text`, `ems.fn_planning_run_fail` (aktivní plán se nemění).
|
||||
- Diagnostika: `scripts/diagnose_home01_infeasible.py --print-export-sql --run-id <id>`. Tag **`2026-06-06-infeasible-journal-granular-prep-relax-v63`**.
|
||||
|
||||
17. **v64 — future neg-buy večerní export (Branch 2, home-01):**
|
||||
- **`future_neg_buy_discharge`**: před **`buy<0`** dnem s dostatečnou FVE v **`sell<0`** zůstává neg-evening push + kotvy **`reserve_soc`** i při **`relaxed_neg_prep_window`**.
|
||||
- **`pos_sell_pre_neg_buy_ge_exempt_slots`**: večerní peak před **`buy<0`** — výjimka z `ge=0` při ekonomicky výhodném vývozu.
|
||||
- **`terminal_soc_factor_effective`**: v64 binární × **0,1** při **`future_neg_buy_discharge`** (nahrazeno v65). Tag **`2026-06-06-future-neg-buy-evening-export-v64`**.
|
||||
|
||||
18. **v65 — dynamický terminal SoC při future neg buy (Branch 5):**
|
||||
- **`terminal_neg_buy_weight`** (`w_neg`): `effective_factor = planner_terminal_soc_value_factor × (1 − w_neg)`; blížší a zápornější **`buy<0`** v horizontu (36 h) → vyšší `w_neg` (cap 0,95).
|
||||
- Snap: `terminal_neg_buy_weight`, `terminal_soc_factor_effective`. Tag **`2026-06-06-terminal-soc-future-neg-buy-v65`**.
|
||||
|
||||
**Funkce:** … home-01 **v61**; BA81/KV1 fixed **v59** (+ `R__063`).
|
||||
|
||||
### Rozpočet nabíjecích slotů (charge-slot-budget v1, 2026-06-06)
|
||||
|
||||
**Branch 3 (BA81/KV1):** `R__063` vrací `charge_target_wh`, `pre_window_wh`, `in_window_wh` a debug sloupce; fixed PV vrstva **`sell ASC`**. LP bez v58 — jen masky SQL. Večerní push fixed: **`sell > buy + spread`**. Tag **`2026-06-06-charge-slot-budget-v1`**. **Zbývá pro home-01:** pre-neg fronta místo v33 cushion, v44 změkčení — [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md) §6.
|
||||
|
||||
### Arbitráž baterie — účtování mezi sloty (povinné čtení)
|
||||
|
||||
**Detail:** [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
|
||||
|
||||
- **Nesmysl:** řídit arbitráž tak, že v **jednom 15min slotu** porovnáváme `buy[t]` a `sell[t]` jako nákup a prodej **téže** kWh z baterie. Ve výprodejním okně (např. sell 4,6 Kč, buy 7 Kč) je LP marginalně proti exportu, i když energie byla nabitá v poledne za ~0,7 Kč.
|
||||
- **`min(buy)` horizontu není nákupní cena zásoby** — je to **jeden** čtvrthodinový slot; u home-01 lze nabíjet **hodiny** (64 kWh, až 17 kW ze site ≈ 4,25 kWh/slot). Acquisition cost musí vycházet z **nabíjecího okna** (průměr / vážený průměr / N nejlevnějších slotů podle potřebných Wh), ne z jednoho minima.
|
||||
- Dnešní `ref_buy = min(buy)` ve maskách je jen **hrubá brána** pro výběr slotů, ne model zisku z cyklu.
|
||||
- **Arbitráž baterie:** `charge_acquisition_buy_czk_kwh` z `fn_load_planning_slots_full` (vážený grid+FVE před `charge_acquisition_cutoff_at`); LP přičítá `ge_bat × acquisition` v `allow_discharge_export`. Detail: [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
|
||||
|
||||
### Verifikace (DB)
|
||||
|
||||
Pro kontrolu masek nabíjení:
|
||||
@@ -50,7 +181,9 @@ where allow_charge is true
|
||||
order by interval_start;
|
||||
```
|
||||
|
||||
- Pokud `current_soc_wh` odpovídá plné baterii (`soc_max_wh`), měly by být `allow_charge=true` alespoň sloty s PV přebytkem (`pv_surplus_w > 0`).
|
||||
- PV-surplus: `allow_charge=true` pro nejvyšší `store_score`, dokud se nepokryje `grid_target`.
|
||||
- Non-PV: levný `buy`, lookahead 4 sloty, cap 6/segment; OTE před predikovanými.
|
||||
- Pokud `current_soc_wh` odpovídá plné baterii (`soc_max_wh`), jsou povoleny všechny sloty.
|
||||
|
||||
---
|
||||
|
||||
@@ -211,9 +344,11 @@ if ev_session[e].target_deadline and ev_session[e].soc_at_connect_pct is not Non
|
||||
|
||||
### SoC kontinuita
|
||||
```python
|
||||
# battery_discharge = bd (W z baterie na AC sběrnici z bilance pv+gi+bd = load+bc+ge).
|
||||
# ge_bat je součást ge — v SoC znovu neodečítat (v39).
|
||||
soc[t] == soc[t-1]
|
||||
+ battery_charge[t] * charge_efficiency * interval_h
|
||||
- battery_discharge[t] / discharge_efficiency * interval_h
|
||||
+ (bc_pv[t] + bc_gi[t]) * charge_efficiency * interval_h
|
||||
- bd[t] / discharge_efficiency * interval_h
|
||||
|
||||
soc[0] == current_soc_wh # počáteční podmínka z telemetrie
|
||||
```
|
||||
@@ -299,7 +434,8 @@ kde:
|
||||
- (případně) explicitní `no_export` politika, pokud je v kontextu dostupná
|
||||
Mimo tyto případy je `z_gen_cutoff[t]` vynucené na `0`.
|
||||
- Cut-off je v účelové funkci **penalizované** (za „zahozenou“ GEN výrobu), aby se zapínalo jen jako poslední možnost.
|
||||
- Výstup se ukládá do `planning_interval.deye_gen_cutoff_enabled` (nullable) a exporter pak nastaví bity reg 178.
|
||||
- **Tvrdé vynucení `z_gen_cutoff[t]=1`** (tag **`2026-06-06-ba81-gen-cutoff-exec-v1`**) když LP zakazuje vývoz při `sell<0`: fixní tarif (`purchase_fixed_pre`), `block_export_on_negative_sell`, nebo `block_pv_export_neg_sell`; stejně při souběhu `buy<0` a `sell<0`. Bez toho plán ukazoval cut-off OFF, ale MI na GEN portu exportovaly (audit BA81 6. 6. 2026 ~08:00).
|
||||
- Výstup se ukládá do `planning_interval.deye_gen_cutoff_enabled` (nullable) a exporter pak nastaví bity reg 178 (viz [`control.md`](control.md) — cut-off i při `export_ban` bez solver flagu).
|
||||
|
||||
**Scope / bezpečnost:** proměnná i flag existují jen na lokalitách, kde je zapnutý `asset_inverter.deye_gen_microinverter_cutoff_enabled` (tj. kde je GEN port s mikroinvertory reálně zapojen). Jinde se nic neřeší ani nezobrazuje.
|
||||
|
||||
@@ -498,13 +634,18 @@ COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS
|
||||
|
||||
## Tuning pro malé baterie (např. BA81)
|
||||
|
||||
Kromě **`planner_terminal_soc_value_factor`** existují od **V077** měkké mechanismy **denní safety charge** a **rolling charge commitment** (viz výše) — malé instalace nelze spolehlivě stabilizovat jen slepým zvyšováním terminal faktoru na **0.9**.
|
||||
|
||||
### Terminal SoC shadow price (kritický parametr)
|
||||
|
||||
V účelové funkci LP je člen **„terminal SoC shadow price“**: energie ponechaná v baterii na konci horizontu je oceněná jako záporný příspěvek k nákladům (solver má motivaci držet část SoC, pokud se to ekonomicky vyplatí oproti okamžitému vývozu / nákupu).
|
||||
|
||||
**Výpočet (zjednodušeně):**
|
||||
`terminal_soc_kcz_per_wh = (průměr buy v prvních 24 h slotů) × planner_terminal_soc_value_factor / 1000`
|
||||
a v objective se přičítá `- terminal_soc_kcz_per_wh × soc[T−1]` (viz `solve_dispatch` v `backend/services/planning_engine.py`).
|
||||
`effective_factor = planner_terminal_soc_value_factor × (1 − terminal_neg_buy_weight)`
|
||||
`terminal_soc_kcz_per_wh = (průměr buy v prvních 24 h slotů) × effective_factor / 1000`
|
||||
a v objective se přičítá `- terminal_soc_kcz_per_wh × soc[T−1]` (viz `_terminal_neg_buy_weight` a `solve_dispatch` v `backend/services/planning_engine.py`).
|
||||
|
||||
**`terminal_neg_buy_weight`:** pokud v horizontu existuje **`buy<0`**, váha roste s blízkostí prvního záporného slotu (horizont 36 h) a magnitudou ceny (ref 1 Kč/kWh, cap **0,95**). Bez záporného buy zůstává **0** — chování jako čistý DB faktor.
|
||||
|
||||
**Kde se bere faktor (jediný kanonický zdroj):**
|
||||
|
||||
@@ -531,6 +672,8 @@ a nechal si kapacitu na nabití v oknech záporných cen.
|
||||
PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout
|
||||
PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE
|
||||
PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu
|
||||
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
|
||||
```
|
||||
|
||||
> **Zelený bonus:** Sazba a platnost jsou v `ems.asset_pv_array` (`green_bonus_*`). Bonus **není** v objective function LP solveru – jako aditivní konstanta k nákladům by optimalizaci stejně neměnil. Příjem z bonusu se počítá v **`fn_fill_audit_interval`** přes `ems.fn_green_bonus_revenue()` a ukládá se do `audit_interval.green_bonus_czk`; v přehledech (např. `vw_audit_daily`) je samostatná položka příjmů vedle nákladů ze sítě. Viz `docs/04-modules/market-prices.md` → sekce Zelený bonus.
|
||||
@@ -555,3 +698,120 @@ highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD)
|
||||
- [ ] EV rozdělení výkonu mezi 2 nabíječky – zatím řešeno jako agregát
|
||||
- [ ] Curtailment pole A – ověřit Modbus registr pro Output Power Limit na Deye SUN-20K
|
||||
- [ ] Testovat solver na reálných datech – ověřit čas výpočtu pro 36h horizont (144 slotů)
|
||||
|
||||
---
|
||||
|
||||
## Planner v2
|
||||
|
||||
Tahle sekce popisuje návrh druhé verze planneru. Cíl je mít samostatný solver, který bude vycházet ze stejného vstupu a bude zapisovat do stejného `planning_interval`, ale provozní pravidla budou čitelné a striktně dané zadáním.
|
||||
|
||||
### Význam hranic SoC
|
||||
|
||||
- `reserve_soc_percent` = ranní cílová hranice, na kterou se má baterie dobít, pokud to denní forecast a ceny umožňují
|
||||
- `min_soc_percent` = fyzická / TOU podlaha, pod kterou baterie nesmí klesnout
|
||||
- `reserve_soc_percent` je tedy provozní kotva pro den, zatímco `min_soc_percent` je tvrdé minimum
|
||||
- `reserve_soc_percent` není predikce noční spotřeby; jen znamená „než začne export z FVE do sítě, drž baterii aspoň sem“
|
||||
|
||||
### Základní pravidla v2
|
||||
|
||||
#### Ráno
|
||||
|
||||
- pokud denní forecast dává dostatek výroby nebo levných hodin, planner dobije baterii minimálně na `reserve_soc_percent`
|
||||
- tato rezerva slouží jako ochrana proti neplánované spotřebě během dne
|
||||
- `min_soc_percent` se v ranní fázi nepoužívá jako cíl, ale jen jako spodní limit
|
||||
|
||||
#### Záporná nákupní cena
|
||||
|
||||
- při `buy_price < 0` má prioritu nabíjení ze sítě
|
||||
- cílem je uložit levnou energii pro pozdější dražší prodej
|
||||
- to ale neznamená, že se má baterie dobít hned v první záporné hodině; pokud jsou v horizontu ještě zápornější ceny, může být lepší nabíjet později
|
||||
- nabíjení ze sítě je omezené jen fyzickými limity baterie a připojení
|
||||
|
||||
#### Záporná prodejní cena
|
||||
|
||||
- při `sell_price < 0` je export do sítě zakázán
|
||||
- řiditelná FVE A se může škrtit
|
||||
- neřiditelná FVE B se neškrtí, pouze se povinně zohlední v bilanci
|
||||
- baterie se nejdřív nabíjí z přebytku FVE, potom se využije flexibilní spotřeba
|
||||
- pokud je potřeba uvolnit místo pro pozdější extrémně záporné ceny, může planner baterii předem záměrně mírně vybít až na bezpečnou ekonomickou podlahu
|
||||
|
||||
#### Nezáporná prodejní cena
|
||||
|
||||
- věta „prodám vše“ v tomto návrhu neznamená povinné okamžité vybití baterie
|
||||
- znamená pouze to, že pokud je baterie už plná z levných nebo záporných hodin, přebytek FVE A jde do sítě
|
||||
- pokud ještě dává větší smysl uložit energii pro pozdější dražší prodej, má přednost uložení do baterie
|
||||
- dynamické zátěže jako TUV a wallbox zůstávají plně součástí bilance; jejich spotřeba může být využita jako další „úložiště“ levné energie
|
||||
|
||||
#### Prodej z baterie
|
||||
|
||||
- při cenové špičce má baterie prodávat do sítě
|
||||
- v2 má využít baterii jako arbitrážní zásobník mezi levnými a drahými okny
|
||||
- vybíjení nesmí klesnout pod `min_soc_percent`
|
||||
|
||||
#### PV A a PV B
|
||||
|
||||
- PV A je řiditelná a může být curtailovaná
|
||||
- PV B je neřiditelná a nikdy se neplánuje jako curtailovaná výroba
|
||||
- PV B je vždy pevný vstup do bilance
|
||||
|
||||
#### BA81 / GEN cutoff
|
||||
|
||||
- v lokalitě BA81 může být zapnutý `deye_gen_microinverter_cutoff_enabled`
|
||||
- pokud by při záporné prodejní ceně nebo no-export politice vznikal nežádoucí export z GEN portu, planner v2 musí umět aktivovat cutoff mikroinvertoru
|
||||
- cutoff má být součást rozhodnutí planneru, ne dodatečná heuristika v exporteru
|
||||
|
||||
### Co má být v plánu zapsané
|
||||
|
||||
Planner v2 má do `planning_interval` zapisovat stejné základní položky jako dosavadní verze:
|
||||
|
||||
- `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`
|
||||
- `effective_buy_price`
|
||||
- `effective_sell_price`
|
||||
|
||||
### Implementační oddělení od v1
|
||||
|
||||
- v1 zůstává beze změny
|
||||
- v2 bude samostatný modul planneru
|
||||
- přepnutí mezi v1 a v2 bude na úrovni orchestrace nebo konfigurace lokality
|
||||
- exportér i control pipeline mají dál číst standardní výstup z `planning_interval`
|
||||
- pokud je zapnuté `PLANNING_ENGINE_COMPARE_ENABLED`, backend spočítá obě verze nad stejným vstupem, aktivní verzi zapíše do plánu a druhou uloží i jako samostatný read-only `planning_run` se stavem `comparison`
|
||||
- compare čtení jde přes `GET /api/v1/sites/{site_id}/plan/compare` → jedno volání `ems.fn_plan_compare_bundle` (aktivní plán + `fn_planning_run_debug` comparison runu)
|
||||
- **Výkon `/plan/current` a `/plan/compare` (V079+):** read-model `ems.fn_plan_current_bundle` dříve při každém HTTP requestu přepočítával `fn_pv_forecast_delta_profile` nad celou historií `forecast_accuracy` (~stovky tisíc řádků na site) a kanonický PV forecast na 96 h. Od **V079** se delta profil cacheuje v `site_pv_forecast_calibration.delta_profile_cache` (refresh po `fn_fill_forecast_accuracy` a po `PATCH …/pv-forecast-calibration` přes `fn_refresh_site_pv_delta_profile_cache`; čtení přes `fn_pv_forecast_delta_profile_cached`, TTL 30 min). Kanonický PV pro graf se počítá jen za horizontem uloženého plánu (`horizon_end` → `horizon_start + 96 h`), ne pro sloty už v `planning_interval`. Ověření: `curl -w '%{time_total}\n' http://…/plan/current` před/po migraci; první request po deployi může být pomalý dokud cache nezaplní job (15 min) nebo ručně `select ems.fn_refresh_site_pv_delta_profile_cache(<site_id>);`
|
||||
- FE stránka `frontend/src/pages/Planning.tsx` ukazuje souhrn aktivní verze, compare verze, slotové rozdíly a compare křivku baterie v grafu. Od 2026-05 navíc: **acquisition** a počty masek z `planning_run.solver_params` (blok „Solver — masky a arbitráž“), sloupce **Export** (`export_mode`) a **Masky** (⚡ `allow_charge` / ↓ `allow_discharge_export`), pásy v grafu (zelená/oranžová okna), detail slotu po kliknutí na řádek. Dashboard `StatePanel` v tooltipu Deye uvádí `export_mode` z plánu.
|
||||
- fyzicky se na střídač aplikuje jen aktivní plán; compare běh slouží jen pro audit a vizualizaci
|
||||
|
||||
### Shrnutí v jedné větě
|
||||
|
||||
Planner v2 má dělat přesně toto:
|
||||
|
||||
- ráno držet baterii na `reserve_soc_percent`
|
||||
- při záporných nákupních cenách nabíjet ze sítě
|
||||
- při záporných prodejních cenách zakázat export
|
||||
- při cenových špičkách prodávat z baterie
|
||||
- PV A škrtit jen když je to nutné
|
||||
- PV B nikdy neškrtit
|
||||
- BA81 řešit přes GEN cutoff
|
||||
|
||||
---
|
||||
|
||||
## Verze enginu: v1 (heuristický) vs v2 (čisté jádro) — od 2026-06-11
|
||||
|
||||
Plánovač má dvě implementace, přepínané env proměnnými (`backend/app/config.py`):
|
||||
|
||||
| Env | Default | Význam |
|
||||
|-----|---------|--------|
|
||||
| `PLANNING_ENGINE_VERSION` | `v1` | Aktivní engine pro daily i rolling plán |
|
||||
| `PLANNING_ENGINE_COMPARE_ENABLED` | `false` | Shadow režim: druhá verze se počítá paralelně, diff se ukládá do `planning_run.solver_params.comparison` (status `comparison`) |
|
||||
|
||||
- **v1** = `solve_dispatch_two_pass` (heuristické fáze/okna/kotvy + penalty; popsáno výše v tomto dokumentu).
|
||||
- **v2** = `services/planning/solver_v2.py`: objective = jen reálné peníze (cash + degradace − terminal SoC value z `asset_battery.planner_terminal_soc_value_factor`); tvrdá pravidla (CLAUDE.md 5/6/7/19), EV deadline (placený slack), TUV look-ahead, provozní režimy. SQL masky `allow_charge`/`allow_discharge_export` **ignoruje**.
|
||||
- Router: `_solve_dispatch_for_version` v `planning_engine.py`; chyby v2 jdou do standardní failure pipeline (`fn_planning_run_fail`).
|
||||
- Regresní brána a měření: `scripts/harness/README.md` (golden replay, economics report, penalty audit, `solver_v2_eval.py`); plán refaktoru: `docs/refactor-clean-planner.md`.
|
||||
|
||||
305
docs/04-modules/provozni-rezimy-checklist.md
Normal file
305
docs/04-modules/provozni-rezimy-checklist.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Provozní režimy EMS - praktický přehled
|
||||
|
||||
Tenhle dokument je zkrácený provozní cheat sheet. Cíl je jednoduchý:
|
||||
|
||||
- rychle poznat, co EMS v daném slotu dělá
|
||||
- umět to porovnat s `planning_interval`
|
||||
- ověřit to na live registrech Deye a ve FE
|
||||
|
||||
## 1. Co je zdroj pravdy
|
||||
|
||||
- EMS provozní režim lokality: `ems.site_operating_mode.mode_code`
|
||||
- aktivní plán: `ems.planning_interval`
|
||||
- fyzická konfigurace invertoru: `asset_inverter` + `site_grid_connection`
|
||||
- live stav Deye: registry 108, 109, 141, 142, 143, 145, 178, 340
|
||||
|
||||
Když něco nesedí, porovnávej vždy v tomto pořadí:
|
||||
|
||||
1. `planning_interval`
|
||||
2. `control/registers` na FE
|
||||
3. `modbus_command` journal
|
||||
|
||||
## 2. EMS režimy lokality
|
||||
|
||||
### AUTO
|
||||
|
||||
Normální provoz. EMS bere sloty z `planning_interval` a podle nich řídí Deye, EV, TČ a signály.
|
||||
|
||||
V AUTO se pak mohou objevit sloty s různým exportním záměrem:
|
||||
|
||||
- `PV_SURPLUS`
|
||||
- `BATTERY_SELL`
|
||||
- `NONE`
|
||||
|
||||
### SELF_SUSTAIN
|
||||
|
||||
Bezpečný provoz bez obchodní logiky.
|
||||
|
||||
- Deye fyzicky běží v PASSIVE
|
||||
- baterie se nechává pro vlastní spotřebu
|
||||
- export je jen nouzový ventil, pokud je potřeba kvůli feasibility
|
||||
|
||||
### CHARGE_CHEAP
|
||||
|
||||
- nabíjení ze sítě
|
||||
- export se nepoužívá
|
||||
- fyzicky CHARGE
|
||||
|
||||
### PRESERVE
|
||||
|
||||
- baterie je uzamčená
|
||||
- žádné nabíjení ani vybíjení
|
||||
- fyzicky PASSIVE
|
||||
|
||||
### MANUAL
|
||||
|
||||
- EMS setpointy nezapisuje
|
||||
- vše je ruční řízení
|
||||
|
||||
## 3. Tvoje 5 provozních archetypů
|
||||
|
||||
### 1. Standardní režim s přetokem
|
||||
|
||||
Co tím myslíme:
|
||||
|
||||
- baterie se normálně nabíjí i vybíjí podle plánu
|
||||
- přetok do sítě je povolený
|
||||
- exportní limit je jen tvrdý site / inverter cap
|
||||
- když je baterie plná, přebytek FVE jde do sítě
|
||||
|
||||
Jak to je v implementaci:
|
||||
|
||||
- `export_mode = PV_SURPLUS`
|
||||
- `export_limit_w = hard cap`
|
||||
- `solar_sell = 1`
|
||||
- `deye_physical_mode = PASSIVE`
|
||||
- v PASSIVE se pro exportní slot (bez plánovaného nabíjení z baterie) používají typicky **`108` i `109` na max** z invertoru; přebytek do sítě řeší **142/145** a firmware, ne umělé **108 = 0** (to dřív matlo měnič jako „baterie plná“)
|
||||
|
||||
Poznámka:
|
||||
|
||||
- exportní limit se už netipuje z forecastu
|
||||
- neomezuješ tedy výkon do sítě podle předpovědi, jen podle hard capu
|
||||
|
||||
### 2. Standardní režim s vypnutým přetokem
|
||||
|
||||
Co tím myslíme:
|
||||
|
||||
- `solar_sell = false`
|
||||
- přebytek FVE se nesmí posílat do sítě
|
||||
- jakmile je baterie plná, FVE se utlumí
|
||||
|
||||
Jak to je v implementaci:
|
||||
|
||||
- tohle není samostatný fyzický Deye režim
|
||||
- většinou jde o kombinaci:
|
||||
- `reg 143 = 0` nebo site `no_export`
|
||||
- případně `export_ban = true` a `reg 145 = 0`
|
||||
- fyzicky to pořád bývá PASSIVE
|
||||
|
||||
Poznámka:
|
||||
|
||||
- tohle je důležité ověřovat na `reg 143` a `reg 145`, ne jen na `grid_setpoint_w`
|
||||
|
||||
### 3. Prodej přebytku do sítě bez nabíjení baterie
|
||||
|
||||
Co tím myslíme:
|
||||
|
||||
- baterie není cílem
|
||||
- nechci ji nabíjet
|
||||
- chci prodávat celou výrobu do sítě
|
||||
|
||||
Jak to je v implementaci:
|
||||
|
||||
- `export_mode = PV_SURPLUS`
|
||||
- `solar_sell = 1`
|
||||
- `export_limit_w = hard cap`
|
||||
|
||||
Poznámka k implementaci:
|
||||
|
||||
- tohle je v kódu garantované až ve chvíli, kdy planner dá `battery_setpoint_w = 0`
|
||||
- pokud je `battery_setpoint_w > 0`, tak současná implementace už dovoluje i nabíjení baterie, i když exportní záměr zůstává `PV_SURPLUS`
|
||||
- jinými slovy: čisté „prodávám výrobu, ale baterii nechci nabíjet“ ještě není samostatný fyzický Deye režim, je to kombinace plánovacího setpointu a exportního záměru
|
||||
|
||||
Použití:
|
||||
|
||||
- vhodné, když je výkupní cena vysoká
|
||||
- baterii chceš šetřit na jiný slot
|
||||
|
||||
### 4. Šetření baterie
|
||||
|
||||
Co tím myslíme:
|
||||
|
||||
- když je kupní cena nízká
|
||||
- nechci brát energii z baterie
|
||||
- raději budu kupovat ze sítě
|
||||
|
||||
Jak to je v implementaci:
|
||||
|
||||
- `battery discharge A = 0`
|
||||
- fyzicky PASSIVE
|
||||
- baterie se nevybíjí, ale podle slotu se může pořád nabíjet nebo držet
|
||||
|
||||
Poznámka:
|
||||
|
||||
- tohle je jiné než SELL
|
||||
- tady jen chráníš baterii, neprodáváš ji
|
||||
|
||||
### 5. Aktivní prodej do sítě z baterie
|
||||
|
||||
Co tím myslíme:
|
||||
|
||||
- `selling first`
|
||||
- baterie prodává do sítě plným výkonem, co dovolí střídač / baterie / síť
|
||||
|
||||
Jak to je v implementaci:
|
||||
|
||||
- `export_mode = BATTERY_SELL`
|
||||
- `deye_physical_mode = SELL`
|
||||
- `reg 142 = 0`
|
||||
- `reg 178 = 32`
|
||||
- `reg 109` na max; **`reg 108` EMS ve SELL nemění** (selling first = **142**; u **PASSIVE** + přetoku FVE se **108** zapisuje **0**)
|
||||
|
||||
## 4. Další režimy, které v praxi existují
|
||||
|
||||
### CHARGE_CHEAP
|
||||
|
||||
- nabíjení ze sítě
|
||||
- fyzicky CHARGE
|
||||
|
||||
### SELF_SUSTAIN
|
||||
|
||||
- vlastní spotřeba
|
||||
- fyzicky PASSIVE
|
||||
- export jen jako nouzový ventil
|
||||
|
||||
### PRESERVE
|
||||
|
||||
- baterie uzamčená
|
||||
- žádné řízení baterie
|
||||
|
||||
### MANUAL
|
||||
|
||||
- EMS nezasahuje
|
||||
|
||||
## 5. Registry, které má smysl kontrolovat
|
||||
|
||||
### 108
|
||||
|
||||
Max charge current.
|
||||
|
||||
### 109
|
||||
|
||||
Max discharge current.
|
||||
|
||||
### 142
|
||||
|
||||
Limit control:
|
||||
|
||||
- `0` = selling first
|
||||
- `1` = zero export to load
|
||||
- `2` = zero export to CT
|
||||
|
||||
### 143
|
||||
|
||||
Export cap.
|
||||
|
||||
- tvrdý site / inverter limit
|
||||
- neforecastuje se
|
||||
|
||||
### 145
|
||||
|
||||
Solar sell:
|
||||
|
||||
- `1` = povoleno
|
||||
- `0` = zakázáno
|
||||
|
||||
### 178
|
||||
|
||||
Bitové pole:
|
||||
|
||||
- bits 4-5 = peak shaving switch
|
||||
- bits 0-1 = GEN export cut-off u BA81
|
||||
|
||||
### 340
|
||||
|
||||
Max solar power pro řízenou FVE A.
|
||||
|
||||
- není to exportní cap
|
||||
- je to strop výroby pole A
|
||||
|
||||
## 6. Co kontrolovat na FE
|
||||
|
||||
### Planning page
|
||||
|
||||
Zkontroluj:
|
||||
|
||||
- `deye_physical_mode`
|
||||
- `grid_setpoint_w`
|
||||
- `export_limit_w`
|
||||
- `export_mode`
|
||||
- `deye_gen_cutoff_enabled`
|
||||
- `effective_buy_price`
|
||||
- `effective_sell_price`
|
||||
|
||||
### Control panel
|
||||
|
||||
Na živém panelu porovnej:
|
||||
|
||||
- reg 142
|
||||
- reg 143
|
||||
- reg 145
|
||||
- reg 178
|
||||
|
||||
Reg 143 musí být vidět jako hard cap.
|
||||
|
||||
## 7. Rychlá kontrola nesrovnalostí
|
||||
|
||||
1. Najdi slot v `Planning`
|
||||
2. Podívej se na:
|
||||
- `battery_setpoint_w`
|
||||
- `grid_setpoint_w`
|
||||
- `export_limit_w`
|
||||
- `export_mode`
|
||||
- `deye_physical_mode`
|
||||
3. Otevři `ControlPanel`
|
||||
4. Porovnej live registry:
|
||||
- 142
|
||||
- 143
|
||||
- 145
|
||||
- 178
|
||||
5. Podívej se do `modbus_command`
|
||||
|
||||
## 8. Co je v implementaci důležité vědět
|
||||
|
||||
Tady jsou dva praktické detaily:
|
||||
|
||||
- `export_limit_w` se bere jako hard cap z lokality / invertoru
|
||||
- export se už netipuje z forecastu
|
||||
|
||||
To znamená:
|
||||
|
||||
- při `PV_SURPLUS` se má pustit maximum, které dovoluje distribuce a HW
|
||||
- při `BATTERY_SELL` se použije SELL a prodej z baterie
|
||||
- při běžném importu / šetření baterie se exportní logika nemá „uhádnout“ z ceny nebo forecastu
|
||||
|
||||
## 9. Kde hledat v kódu
|
||||
|
||||
- plánování: `backend/services/planning_engine.py`
|
||||
- mapování plánu na setpointy: `backend/services/control/setpoints.py`
|
||||
- zápis Deye: `backend/services/control/inverter.py`
|
||||
- live registry: `backend/app/routers/sites.py`
|
||||
- FE plánování: `frontend/src/pages/Planning.tsx`
|
||||
- FE live registry: `frontend/src/components/ControlPanel.tsx`
|
||||
|
||||
## 10. Planner v2
|
||||
|
||||
Pro přesné zadání nové verze planneru se řiď sekcí **Planner v2** v [`docs/04-modules/planning.md`](/home/dusan.vojacek@triglav.local/Documents/AI-projekty/ems-cursor/docs/04-modules/planning.md).
|
||||
|
||||
Krátké shrnutí:
|
||||
|
||||
- `reserve_soc_percent` = ranní cílová rezerva
|
||||
- `min_soc_percent` = tvrdá TOU / fyzická podlaha
|
||||
- PV A je řiditelná, PV B je neřiditelná
|
||||
- při záporné prodejní ceně se zakazuje export
|
||||
- v BA81 se cutoff mikroinvertoru řeší přímo v planneru
|
||||
- pokud je zapnuté `PLANNING_ENGINE_COMPARE_ENABLED`, backend spočítá i druhou verzi nad stejným vstupem, uloží ji jako read-only `planning_run` se stavem `comparison`, napáruje ji na aktivní run přes `comparison_of_run_id` a FE ji ukáže v `/plan/compare`; fyzicky se aplikuje jen aktivní plán
|
||||
@@ -22,6 +22,19 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec
|
||||
|
||||
---
|
||||
|
||||
## Brzké vylepšení (plánování / arbitráž)
|
||||
|
||||
| Popis | Kde | Kdo |
|
||||
|-------|-----|-----|
|
||||
| ~~**`charge_acquisition` po solve (two-pass):**~~ hotovo — `solve_dispatch_two_pass` v `planning_engine.py` (AUTO daily/rolling). | `planning_engine.py`, [`planning-arbitrage-accounting.md`](04-modules/planning-arbitrage-accounting.md) §6 | — |
|
||||
| ~~**Grid maska B (nejlevnější sloty):**~~ hotovo — `buy ASC` v AM/PM do Wh rozpočtu; cap z `ceil(budget/per_slot_wh)`. | `R__063` | — |
|
||||
| **Self-konzistentní filtr B + acquisition bez `buy<0`:** iterativní filtr v `R__063` (v12); vážená acquisition pro filtr i `charge_acquisition_buy_czk_kwh` jen z `allow_grid_charge` s `buy>=0` (záporný OTE buy zůstává `allow_charge`, ale neřítí exportní marži). Two-pass `_recompute_charge_acquisition_from_results` také přeskočí `buy<0`. Ověřit po deploy: `two_pass_converged=true` na home-01. | `R__063`, `planning_engine.py` | programátor |
|
||||
| **Strategie buy<0 (home-01):** v20 revert v19 hard constraintů; další krok = SQL `R__063` + ověření MCP před Python LP. | `R__063`, `planning_engine.py` v20 | programátor |
|
||||
| **KV1 replan timeout (~120 s):** ruční/rolling replan občas spadne na timeout; 5. pokus prošel. Profilovat `fn_load_planning_slots_full` (iterativní filtr) + MILP délku horizontu; případně zkrátit horizont pro test nebo zvýšit limit API. | backend replan endpoint, APScheduler | programátor |
|
||||
| **home-01 export při `sell<0` (26 slotů):** záměrně **ne** `block_export_on_negative_sell` (neriditelné PV B + zelený bonus). Plán stále může dávat `PV_SURPLUS` ~6–7 kW od ~10:30 když je SoC ~97 %+ — jiná osa než noční grid 4,8 Kč. Review ventilu `w_pv_b_vent_neg` / nabíjení před exportem, ne stejný fix jako KV1. | `planning_engine.py`, `planning-arbitrage-accounting.md` | programátor |
|
||||
|
||||
---
|
||||
|
||||
## Budoucí vylepšení (PV kalibrace)
|
||||
|
||||
| Popis | Kde | Kdo |
|
||||
|
||||
@@ -18,6 +18,40 @@ Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout p
|
||||
|
||||
## Důležité (neblokují, ale řeší se brzy)
|
||||
|
||||
### Plánování — neg sell, termika, flexibilní zátěže
|
||||
|
||||
Kompletní návrh: [`docs/04-modules/planning-neg-sell-strategy.md`](04-modules/planning-neg-sell-strategy.md).
|
||||
|
||||
#### Rozhodnuto (home-01, 2026-05)
|
||||
|
||||
| Téma | Rozhodnutí |
|
||||
|------|------------|
|
||||
| **v35 — bod T, rampa SoC** | `soc_detach` a rampa **jen odvozené** z forecastu PV B zpět od tail (100 %). Fixní **80 %** v LP pro home-01 **zrušit** (sloupce V083 mohou zůstat pro legacy/KV1, ale solver home-01 je neřídí). |
|
||||
| **TČ před `sell < 0`** | V ranních slotech **pre-neg export** (v33) **netopit** — energii raději **prodat** do site. |
|
||||
| **Spirála** | Ovládání přes **Loxone** (signál / virtuální vstup). Samostatný model v EMS až ve fázi v38. |
|
||||
| **Bazén — filtrace** | Min. **4 h/den**, za dne **více**, pokud je přebytek (`E_surplus_after_t`) a „nic to nestojí“. Rozložení **dynamicky** dle cen / přebytku / slunce, ne pevné 09–17. |
|
||||
| **Bazén — přitop** | **Mimo** automatiku plánovače na začátku; sezónní nahřátí **ručně**. Automatický přitop až později, pokud vůbec. |
|
||||
| **Bazén — exekuce** | **Shelly** (zapínání filtrace) — napojit až po v37 (asset + LP), ovládání z EMS. |
|
||||
|
||||
#### Otevřeno před implementací
|
||||
|
||||
- [x] **TUV — večerní doklep** — **19:00** Europe/Prague (rozhodnuto 2026-05); implementace v **v36**; doplnit `tuv_comfort_temp_c` / `tuv_preheat_temp_c` do konfigurace site.
|
||||
- [ ] **Vizualizace flexibilních zátěží v UI** — **probrat a navrhnout před v37+** (neimplementovat bazén/TČ sink do FE naslepo). Viz [`planning-neg-sell-strategy.md` § 9.1](04-modules/planning-neg-sell-strategy.md). Návrhy k diskusi: pásma dne (pre-neg / sell<0 / bod **T**), rozpočet hodin bazénu vs. `E_surplus_after_t`, slotový rozpad `hp` / EV / (budoucí pool), srovnání běhů plánu.
|
||||
- [x] **v35 implementace** — rampa B, **t_detach**, `E_surplus_after_t` (`2026-05-28-neg-sell-b-ramp-v35`).
|
||||
- [x] **v36 prep okno** — oprava **T**, pre-neg **per den** (cushion A+B), večerní výboj před neg dnem; kotva **reserve_soc** večer D−1 (`2026-05-28-neg-prep-window-v36d`, slack max 400 Wh — v36b měl neomezený slack → ~50 % SoC).
|
||||
- [ ] **v36 termika** — blok TČ v pre-neg exportu, TUV po **T**, doklep **19:00** (zatím jen plán).
|
||||
|
||||
#### Roadmap (pořadí)
|
||||
|
||||
1. ~~**v35**~~ — hotovo
|
||||
2. ~~**v36 prep okno**~~ — hotovo (T, pre-neg per den, večer D−1)
|
||||
3. **Workshop UI** — flexibilní zátěže (viz výše)
|
||||
4. **v36 termika** — TČ / TUV v `sell < 0`
|
||||
4. **v37** — bazén (Shelly + LP), až po UI dohodě
|
||||
5. **v38** — spirála (Loxone)
|
||||
|
||||
- [x] **Arbitráž baterie — 1. vlna (před solve):** `charge_acquisition_buy_czk_kwh` + cutoff před 1. `allow_discharge_export`; LP `+ge_bat×acquisition` v exportních slotech. Zbývá iterace po solve a více charge slotů — [`planning-arbitrage-accounting.md`](04-modules/planning-arbitrage-accounting.md) §6, [`docs/05-todo.md`](05-todo.md).
|
||||
|
||||
- [ ] **Dvě úrovně min SoC v DB** – Dnes jedno `min_soc_percent` (provozní podlaha pro LP i TOU PASSIVE). Budoucí oddělení „tvrdé BMS minimum“ vs „plánovací minimum“ by vyžadovalo nový sloupec nebo politiku per site.
|
||||
|
||||
- [ ] **TUV výkon** – Je TUV výkon měřitelný zvlášť nebo jen ON/OFF? Pokud jen ON/OFF, použijeme `asset_heat_pump.rated_heating_power_w` jako aproximaci.
|
||||
|
||||
@@ -61,6 +61,11 @@ limit 10;
|
||||
select ems.fn_plan_explain_bundle(2, 6);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Diagnostika posledního běhu plánovače (run_id z planning_run)
|
||||
select ems.fn_planning_run_debug(8107);
|
||||
```
|
||||
|
||||
Měnící funkce (**`ems.fn_delete_forecast_pv_prague_calendar_day`**, **`ems.fn_rebuild_consumption_baseline_stats`**, …) MCP přes **`query` neprovede**, pokud má server jen read-only práva na DB — použij psql aplikačním účtem.
|
||||
|
||||
---
|
||||
@@ -69,6 +74,9 @@ Měnící funkce (**`ems.fn_delete_forecast_pv_prague_calendar_day`**, **`ems.fn
|
||||
|
||||
- Stručná návěstí také v **[`../CLAUDE.md`](../CLAUDE.md)** (sekce MCP + tabulka „Kde hledat co“).
|
||||
- Trvalé pravidlo pro agenta: **[`../.cursor/rules/mcp-postgres-ems.mdc`](../.cursor/rules/mcp-postgres-ems.mdc)** (`alwaysApply: true`).
|
||||
- **Agent skills (Cursor):**
|
||||
- Vysvětlení plánu (sloty, proč nabíjí/exportuje): **[`.cursor/skills/ems-plan-explain/SKILL.md`](../.cursor/skills/ems-plan-explain/SKILL.md)** — `fn_plan_explain_bundle`.
|
||||
- **Triáž bugů plánovače** (422 Infeasible, degradovaný relaxed solve, večerní export, BA81/KV1): **[`.cursor/skills/ems-planner-bug-triage/SKILL.md`](../.cursor/skills/ems-planner-bug-triage/SKILL.md)** — MCP dotazy na `solver_params.inputs`, klasifikace A–E, fix větve; SQL šablony v [`reference.md`](../.cursor/skills/ems-planner-bug-triage/reference.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
33
docs/audits/frontend-performance-2026-06-11.md
Normal file
33
docs/audits/frontend-performance-2026-06-11.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Audit výkonu frontendu (2026-06-11)
|
||||
|
||||
Měřeno na živé DB (site_id=2) + statická analýza kódu a bundle. Plný kontext: agent audit.
|
||||
|
||||
## TOP problémy podle dopadu
|
||||
|
||||
| # | Problém | Měření | Kde | Fix |
|
||||
|---|---------|--------|-----|-----|
|
||||
| 1 | **fn_plan_current_bundle 3 824 ms** | přímé měření DB | `/sites/{id}/plan/current`, `useDashboardData.ts:205`, poll 30 s | SQL optimalizace fn (viz samostatná analýza), SWR pattern na FE |
|
||||
| 2 | **fn_site_full_status 1 719 ms** | přímé měření DB | `useFullStatus.ts:21`, poll 60 s | SQL optimalizace, poll 120 s |
|
||||
| 3 | Promise.all čeká na nejpomalejší (3.8 s) | logika | `useDashboardData.ts:174-406` | 2 vlny: kritická (status ~100 ms) → extended (plan/telemetrie) |
|
||||
| 4 | vw_telemetry_15m_7d limit 1000 (~450 KB), graf zobrazí 384 | logika | `useDashboardData.ts:212-216` | dynamický limit ~420 |
|
||||
| 5 | Planning.tsx tabulka 400+ řádků × 16 sloupců bez virtualizace | ~6400 DOM nodes | `Planning.tsx:1618-1846` | react-window / tanstack-virtual |
|
||||
| 6 | Recharts `Cell` mapování 384× v render | logika | `Planning.tsx:1557-1564` | custom shape / barva v datech |
|
||||
| 7 | Duplicitní výpočty slotFveDisplayW | CPU | `Planning.tsx:122-232` | fveW do PlanTableRow |
|
||||
| 8 | Bundle 1.2 MB bez chunking, eager routes | dist měření | `vite.config.ts`, `main.tsx` | manualChunks (recharts/nivo/react), lazy routes |
|
||||
| 9 | Agresivní polling 30 s/5 s | 120 req/h | `useDashboardData.ts:28-29` | 60 s / 15 s + backoff |
|
||||
| 10 | getMySites → context → data waterfall | 1× při startu | `SiteSelectionContext.tsx` | fallback UI |
|
||||
|
||||
## Souhrn initial load
|
||||
~4 300 ms server time (dominuje fn_plan_current_bundle), ~1 185 KB payload, +1.2 MB bundle (cold).
|
||||
|
||||
## Priority
|
||||
1. **Backend SQL**: fn_plan_current_bundle + fn_site_full_status (největší dopad, řeší se samostatně).
|
||||
2. **FE quick wins**: polling 60/15 s, telemetry limit 420, lazy routes + manualChunks.
|
||||
3. **FE větší**: 2-vlnové načítání, virtualizace Planning tabulky, memoizace.
|
||||
|
||||
## Stav implementace (2026-06-11)
|
||||
|
||||
- ✅ Quick wins (polling 60/15/120 s, payload okna grafu, manualChunks + lazy routes, 2 vlny načítání) — merge `60f5f77`, build ověřen.
|
||||
- ✅ `vw_latest_inverter` / `vw_latest_ev_charger` → LATERAL (508→56 ms, 460→75 ms živě) — commit `1d5b97c`, projeví se deployem.
|
||||
- ⬜ `fn_plan_current_bundle` (90 % času ve `fn_forecast_pv_slots_range_canonical_ab`) — vyžaduje hlubší zásah.
|
||||
- ⬜ Virtualizace Planning tabulky, Recharts Cell mapování.
|
||||
29
docs/audits/frontend-responsive-2026-06-11.md
Normal file
29
docs/audits/frontend-responsive-2026-06-11.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Audit responsivity frontendu (2026-06-11)
|
||||
|
||||
Hlášené problémy: grafy na mobilu špatně zobrazené; tooltip při dotyku koliduje s detailní tabulkou.
|
||||
|
||||
## Inventura problémů
|
||||
|
||||
| Problém | Kde | Fix |
|
||||
|---------|-----|-----|
|
||||
| Pevné výšky grafů (260/380/280/100 px) na všech zařízeních | `EnergyChart.tsx:329`, `EconomicsChart.tsx:110`, `PriceChart.tsx:84`, `SocTuvChart.tsx:238` | responsive výšky (140/200/260 dle breakpointu), aspect-ratio |
|
||||
| **Tooltip × StatePanel kolize** (Chart.js tooltip absolutně pozicovaný, na touch zůstává) | `Dashboard.tsx:323-354` | touch-aware tooltip: na touch tap-to-pin do vyhrazeného panelu NAD grafem, ne overlay; ESC/tap-out zavření |
|
||||
| **Planning detail řádku koliduje při scrollu** | `Planning.tsx:1016-1170` (PlanSlotDetail) | kontejner `position:relative`, detail jako řádek tabulky (ne absolutní), na mobilu modal/bottom-sheet |
|
||||
| StatePanel grid `[52px_1fr]` na <380 px | `StatePanel.tsx:446` | label nad track na mobilu (`md:grid-cols-[52px_1fr]`) |
|
||||
| Metric karty breakpointy (úzké na tabletu) | `Dashboard.tsx:193`, `Economics.tsx:250`, `EnergyFlows.tsx:199` | doplnit `md:` stupeň |
|
||||
| ControlPanel tabulka maxHeight 400px | `ControlPanel.tsx:181-227` | responsive výška |
|
||||
| Touch targets < 44 px, drobné fonty 10-11 px | globálně | CSS min-height 44px na interactive, media font scaling |
|
||||
| tailwind.config bez custom breakpointů/výšek | `tailwind.config.ts` | chart-sm/md/lg výšky |
|
||||
| viewport bez `viewport-fit=cover` | `index.html` | doplnit |
|
||||
|
||||
## Doporučené pořadí
|
||||
1. **Kritické**: tailwind config + responsive výšky grafů, StatePanel mobile, viewport, Planning detail position.
|
||||
2. **Vysoké**: touch-aware tooltip (tap-to-pin) pro Chart.js i Recharts.
|
||||
3. **Střední**: grid breakpointy všude, ControlPanel, font scaling, touch targets.
|
||||
|
||||
Odhad: ~220–250 řádků změn napříč ~13 soubory.
|
||||
|
||||
## Stav implementace (2026-06-11)
|
||||
|
||||
- ✅ Kritické + vysoké: responsive výšky grafů (tailwind chart-*), StatePanel mobile, PlanSlotDetail sticky řádek, tap-to-pin tooltip (Chart.js panel / Recharts trigger click, hook `useIsCoarsePointer`), viewport-fit, touch targets, grid breakpointy — merge `60f5f77` + fix `b5dbc8c`, build ověřen.
|
||||
- ⬜ Otestovat na reálném mobilu (tap-to-pin chování, scroll Planning tabulky).
|
||||
@@ -74,6 +74,10 @@ Pro **`site.active = true`** scheduler zpracovává mimo jiné: telemetrii, denn
|
||||
- Nová data pro novou lokalitu: **nový Flyway soubor** `Vxxx__seed_site_<kód>.sql` (neupravovat už aplikované `V00x__*.sql`).
|
||||
- Repeatable SQL (`db/routines`, `db/views`) se nemění kvůli jedné nové site, pokud nepotřebuješ obecnou úpravu.
|
||||
|
||||
### BESS bez FVE (příklad v repu)
|
||||
|
||||
Lokalita **`hulin-bess`** ([`db/migration/V080__seed_site_hulin_bess.sql`](../db/migration/V080__seed_site_hulin_bess.sql)): jen `site`, grid, market, `deye-main`, `bat-main`; **bez** `asset_pv_array`, EV, TČ. `site_grid_connection.block_export_on_negative_sell = true`. Plánovač a forecast PV fungují s nulovou FVE; baseline bez `consumption_baseline_stats` používá default **500 W** ve `fn_load_planning_slots_full` (po telemetrii přepočítat `fn_update_baseline_stats` / `fn_rebuild_consumption_baseline_stats`).
|
||||
|
||||
---
|
||||
|
||||
## 8. SQL šablona (kopie do verzované Flyway migrace)
|
||||
|
||||
1178
docs/planning-changelog.md
Normal file
1178
docs/planning-changelog.md
Normal file
File diff suppressed because it is too large
Load Diff
62
docs/refactor-clean-planner.md
Normal file
62
docs/refactor-clean-planner.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Refaktor „Čistý plánovač“ — plán a stav
|
||||
|
||||
Cíl: odstranit příčinu neekonomického provozu — heuristickou vrstvu okolo MILP
|
||||
solveru (pre-solver fáze/okna/kotvy + ~26 ručně laděných penalt v objective),
|
||||
která se vzájemně pere a převažuje reálné peníze. Strategie: **ne big-bang
|
||||
přepis projektu** (predikce, Modbus, telemetrie, audit, DB jsou odladěné),
|
||||
ale řízená náhrada jádra plánovače za regresním harnessem.
|
||||
|
||||
## Diagnóza (měřeno 2026-06-11)
|
||||
|
||||
- `planning_engine.py` (před refaktorem 6 345 ř., 112 funkcí): ~35 % ekonomické
|
||||
logiky v heuristikách PŘED solverem, ~60 % jako měkké penalty v objective
|
||||
s ~20 konstantami natvrdo. Solver = „vykonavatel heuristického plánu“.
|
||||
- Na neg-sell dni Σ penalt 2 119 Kč při cashflow −163 Kč (13×).
|
||||
- GAP actual vs perfect-hindsight oracle, home-01 29 dní: **2 185 Kč ≈ 27 %**
|
||||
(stabilní dny 1–5 %, volatilní/neg-sell 50–160 %).
|
||||
- Den 2026-05-01 (buy −13,26): v1 Infeasible po všech 8 relax krocích.
|
||||
- Penalty audit: **16/26 penalt mrtvých** na 6 reprezentativních fixtures.
|
||||
|
||||
## Fáze a stav
|
||||
|
||||
| Fáze | Obsah | Stav |
|
||||
|------|-------|------|
|
||||
| 0 | Ekonomický harness: golden replay gate, fixtures z reálné DB, economics report (actual vs oracle), penalty audit | ✅ hotovo |
|
||||
| 1 | Dekompozice `planning_engine.py` → `services/planning/` (constants/types/forecast/db_io/heuristics), fasáda, identita chování | ✅ hotovo |
|
||||
| 2 | Penalty audit, stale testy → xfail, rozšíření fixtures (extrémní dny) | ✅ hotovo |
|
||||
| 3 | `solver_v2` (čisté jádro) + router verzí + shadow porovnání | ✅ hotovo (kód); **čeká na shadow data z produkce** |
|
||||
| 4 | Slupka: FE výkon + responsivita | ✅ první vlna (viz `docs/audits/`) |
|
||||
|
||||
## Jak se pracuje (závazná pravidla)
|
||||
|
||||
1. **Golden gate** (`backend/tests/test_golden_replay.py`) musí projít po každé
|
||||
změně plánovače. Snapshoty se regenerují (`GOLDEN_UPDATE=1`) jen při vědomé
|
||||
změně chování, s odůvodněním v commitu a s nezhoršeným GAPem
|
||||
(`scripts/harness/economics_report.py`).
|
||||
2. Ekonomické parametry patří do DB (CLAUDE.md pravidlo 16), ne do Pythonu.
|
||||
3. v2 nikdy neměnit bez `solver_v2_eval.py` (v2 vs v1 na fixtures).
|
||||
|
||||
## Nasazení v2 (návod)
|
||||
|
||||
1. **Shadow**: do prod env `PLANNING_ENGINE_COMPARE_ENABLED=true` → v1 řídí,
|
||||
v2 se počítá paralelně, diff v `planning_run.solver_params.comparison`.
|
||||
2. Po ~týdnu vyhodnotit: `select solver_params->'comparison' from ems.planning_run …`
|
||||
+ `economics_report.py` (trend GAPu).
|
||||
3. **Přepnutí**: `PLANNING_ENGINE_VERSION=v2`; golden snapshoty vědomě
|
||||
zregenerovat; heuristics.py + mrtvé penalty postupně mazat.
|
||||
|
||||
## Klíčové výsledky v2 (fixtures, SoC-fér)
|
||||
|
||||
v2 lepší na všech 5 řešitelných fixtures, **+231,5 Kč ≈ +22 %**; den
|
||||
2026-05-01 v1=INFEASIBLE → v2 řeší (−674,5 Kč). Detail:
|
||||
`scripts/harness/solver_v2_eval.py`, changelog 2026-06-11.
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- v2 výkon na extrémních dnech (10 s time limit) — omezit binárky
|
||||
`y_imp`/`z_exp` jen na sloty, kde dávají smysl.
|
||||
- `fn_plan_current_bundle` 3,8 s (90 % v `fn_forecast_pv_slots_range_canonical_ab`)
|
||||
— viz `docs/audits/frontend-performance-2026-06-11.md`.
|
||||
- Virtualizace Planning tabulky; Recharts Cell mapování.
|
||||
- Po přepnutí na v2: smazat mrtvé heuristiky/penalty, přepsat 4 xfail testy
|
||||
na ekonomické asserty.
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="cs" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>EMS Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { Toaster } from 'sonner'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { NavLink, Outlet, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { SiteSelectionProvider, useSiteSelection } from './context/SiteSelectionContext'
|
||||
import { useWsLogErrorCount } from './hooks/useWsLogErrorCount'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import Economics from './pages/Economics'
|
||||
import EnergyFlows from './pages/EnergyFlows'
|
||||
import ForecastVsActual from './pages/ForecastVsActual'
|
||||
import { Logs } from './pages/Logs'
|
||||
import Planning from './pages/Planning'
|
||||
import SiteConfiguration from './pages/SiteConfiguration'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
// Lazy route komponenty — initial bundle nese jen layout; stránky se dotahují per route.
|
||||
const Dashboard = lazy(() =>
|
||||
import('./pages/Dashboard').then((m) => ({ default: m.Dashboard })),
|
||||
)
|
||||
const Economics = lazy(() => import('./pages/Economics'))
|
||||
const EnergyFlows = lazy(() => import('./pages/EnergyFlows'))
|
||||
const ForecastVsActual = lazy(() => import('./pages/ForecastVsActual'))
|
||||
const Logs = lazy(() => import('./pages/Logs').then((m) => ({ default: m.Logs })))
|
||||
const Planning = lazy(() => import('./pages/Planning'))
|
||||
const SiteConfiguration = lazy(() => import('./pages/SiteConfiguration'))
|
||||
const Settings = lazy(() => import('./pages/Settings').then((m) => ({ default: m.Settings })))
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center" role="status" aria-label="Načítání stránky">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-700 border-t-slate-300" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SiteCombo() {
|
||||
const { sites, selectedSiteId, setSelectedSiteId, ready, error } = useSiteSelection()
|
||||
@@ -102,7 +115,9 @@ function AppLayout() {
|
||||
<SiteCombo />
|
||||
</div>
|
||||
</nav>
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<Toaster richColors position="top-right" theme="dark" />
|
||||
</div>
|
||||
)
|
||||
@@ -121,7 +136,14 @@ export default function App() {
|
||||
<Route path="site-config" element={<SiteConfiguration />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="logs" element={<Logs />} />
|
||||
<Route
|
||||
path="logs"
|
||||
element={
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Logs />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</SiteSelectionProvider>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
SitePvForecastCalibrationRow,
|
||||
} from '../types/siteConfiguration'
|
||||
import type { Notification } from '../types/dashboard'
|
||||
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
|
||||
import type { CurrentPlanResponse, PlanningCompareResponse, RunPlanResponse } from '../types/plan'
|
||||
|
||||
const client: AxiosInstance = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
@@ -124,6 +124,13 @@ export async function getCurrentPlan(siteId: number): Promise<CurrentPlanRespons
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getPlanCompare(siteId: number): Promise<PlanningCompareResponse> {
|
||||
const { data } = await client.get<PlanningCompareResponse>(`/sites/${siteId}/plan/compare`, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** Řada FVE předpovědi (součet polí) po 15 min — doplnění grafu za horizont uloženého plánu. */
|
||||
export type ForecastPvSlotRow = {
|
||||
interval_start: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user