Compare commits
125 Commits
eb8dd0368f
...
docs-sync-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02f0ab66e4 | ||
|
|
3595b24f3b | ||
|
|
5ca5eab1d8 | ||
|
|
343f2f9847 | ||
|
|
b20cb6e0f9 | ||
|
|
fffe6c7185 | ||
|
|
ed88ef8910 | ||
|
|
91ee8a6adf | ||
|
|
bf3b10ca50 | ||
|
|
e54eb1dfd9 | ||
|
|
1e0300dd7e | ||
|
|
e686bc1d2c | ||
|
|
6743224cc5 | ||
|
|
03ebc6246d | ||
|
|
efc6e54f0e | ||
|
|
6074535d96 | ||
|
|
2eeab58c8e | ||
|
|
93193fd5dc | ||
|
|
f3a7b0c64f | ||
|
|
b66b0109b9 | ||
|
|
dede8d604d | ||
|
|
2c884e2135 | ||
|
|
342483b885 | ||
|
|
9aceb628aa | ||
|
|
89fb4f1924 | ||
|
|
5593397fd3 | ||
|
|
9d37efb991 | ||
|
|
afee62ba4e | ||
|
|
e35110cb87 | ||
|
|
542cd9a73c | ||
|
|
8114ec5e63 | ||
|
|
c52946a4ce | ||
|
|
69c979b967 | ||
|
|
30585c9779 | ||
|
|
e96bb75b87 | ||
|
|
5b94f8baec | ||
|
|
e4d4fee24d | ||
|
|
16fc6a065e | ||
|
|
cc674900cc | ||
|
|
8960576ee8 | ||
|
|
50a0ca95f4 | ||
|
|
1d04790f28 | ||
|
|
5f96a4cf01 | ||
|
|
4875c31338 | ||
|
|
bf7373fbfe | ||
|
|
3940f6d45c | ||
|
|
a943829c40 | ||
|
|
40b2ff2ff9 | ||
|
|
c6ca68b263 | ||
|
|
0edf9226cb | ||
|
|
b1e124416d | ||
|
|
1735f77863 | ||
|
|
5d7d7e2823 | ||
|
|
f6e239aa8d | ||
|
|
c928e2234d | ||
|
|
1dfab8c7a1 | ||
|
|
568b584748 | ||
|
|
3cd8e44d37 | ||
|
|
5a66cfa63f | ||
|
|
bc0966e4c4 | ||
|
|
638c5444be | ||
|
|
09f1d2de68 | ||
|
|
bd7d6a1b99 | ||
|
|
faf948d75b | ||
|
|
e085068069 | ||
|
|
9ca4b4c577 | ||
|
|
ffe80679cc | ||
|
|
6cf14ed25b | ||
|
|
a07f5d57cb | ||
|
|
b8515f30df | ||
|
|
d8dbb284fd | ||
|
|
43b594c8d5 | ||
|
|
6447666cee | ||
|
|
7f3b0957cc | ||
|
|
e3776226a4 | ||
|
|
d8221e3169 | ||
|
|
ee4355f17f | ||
|
|
70d306961a | ||
|
|
ea2e33972c | ||
|
|
6dc14764d0 | ||
|
|
301f20612f | ||
|
|
f48a7aad61 | ||
|
|
e33207f3fa | ||
|
|
014c6f193b | ||
|
|
ccb2a41e22 | ||
|
|
22bca9cd9e | ||
|
|
0c93f493a4 | ||
|
|
93f883f5e0 | ||
|
|
a02e11ee13 | ||
|
|
f8e1eed127 | ||
|
|
efc2cbfded | ||
|
|
5c868083af | ||
|
|
b4c58156f0 | ||
|
|
dc0e37e580 | ||
|
|
0814b1d8e8 | ||
|
|
ee27f4e3fd | ||
|
|
906eeb1609 | ||
|
|
477e94f321 | ||
|
|
d3fd8b139a | ||
|
|
d5dcf33e13 | ||
|
|
a1aa6acf61 | ||
|
|
fd06811753 | ||
|
|
3b33594354 | ||
|
|
3da738e7e9 | ||
|
|
f0dfcefd54 | ||
|
|
5919b6caf3 | ||
|
|
9ff7c96c22 | ||
|
|
0e5227eb5b | ||
|
|
3c9916f2c0 | ||
|
|
d7e6226962 | ||
|
|
f7d3162eb7 | ||
|
|
3066a82265 | ||
|
|
0ba72c7704 | ||
|
|
71d8405cee | ||
|
|
015c81a8cb | ||
|
|
4e81a36371 | ||
|
|
b50041cfc7 | ||
|
|
44ab3783ce | ||
|
|
a65d134682 | ||
|
|
74ffa5c3e7 | ||
|
|
f714cab0ab | ||
|
|
64221f701a | ||
|
|
806274cf59 | ||
|
|
25090a9d95 | ||
|
|
b8b3de2b70 |
15
.cursor/rules/documentation-update-discipline.mdc
Normal file
15
.cursor/rules/documentation-update-discipline.mdc
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
description: When changing implementation, update relevant docs
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Documentation update discipline
|
||||
|
||||
- When you make an **implementation change** (Python/SQL/frontend), you must also update the **relevant documentation**
|
||||
in `docs/` (and/or `CLAUDE.md` if it’s normative guidance) in the same change set.
|
||||
- The docs update must cover:
|
||||
- what behavior changed (externally visible / operational impact),
|
||||
- where it is implemented (file/function names),
|
||||
- how to verify it (DB table/view, API endpoint, or operational check).
|
||||
|
||||
If there is no existing relevant document, add a short section to the closest module doc under `docs/04-modules/`.
|
||||
13
.cursor/rules/mcp-postgres-ems.mdc
Normal file
13
.cursor/rules/mcp-postgres-ems.mdc
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: MCP PostgreSQL EMS — když uživatel napíše „použij MCP“ nebo chce živá data z DB
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# MCP → EMS Postgres (read-only)
|
||||
|
||||
- **Server ID** pro volání MCP nástroje: **`user-postgres-ems`** (v Cursor UI může být zobrazen jako **postgres-ems** — to je stejný server).
|
||||
- **Nástroj:** **`query`**. **Argument:** `{"sql": "<SELECT …>"}` — pouze read-only.
|
||||
- Při žádosti o živá data / „použij MCP“: **nejprve zavolej `query`**. Neargumentuj, že připojení „nejde“ nebo že MCP „neexistuje“, dokud volání reálně neskončí chybou.
|
||||
- Po chybě: uveď text chyby a praktické kroky (VPN, MCP zapnutý v Cursoru, dostupnost DB z prostředí kde MCP běží).
|
||||
- Detailní postup, příklady SQL a bezpečnost: **`docs/07-mcp-postgres-ems.md`**.
|
||||
33
.cursor/rules/plan-explain-bundle.mdc
Normal file
33
.cursor/rules/plan-explain-bundle.mdc
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: Jak z DB vytáhnout snapshot plánu (vysvětlení „proč je plán takový“) bez zbytečných tokenů
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Vysvětlení plánu z databáze (tokenová efektivita)
|
||||
|
||||
Když uživatel ptá na **důvod tvaru plánu** (např. nejbližších **6 hodin**, nabíjení/vybíjení, export, EV, TČ, ceny), **nejprve** si stáhni jeden balík z DB — **nevymýšlej dotazy znovu od nuly**.
|
||||
|
||||
## 1) Primární zdroj (doporučeno)
|
||||
|
||||
```sql
|
||||
SELECT ems.fn_plan_explain_bundle(<site_id>, 6);
|
||||
```
|
||||
|
||||
- Druhý argument = počet hodin od **začátku aktuálního 15min slotu** (UTC, stejně jako `planning_engine._current_slot_start`).
|
||||
- Vrací **jeden JSONB**: aktivní `planning_run`, `planning_interval` jen v okně, `site_operating_mode`, `asset_battery`, `site_grid_connection`, `asset_heat_pump`, otevřené `ev_session`, poslední řádky `forecast_correction_log`, překrývající se `site_override`, metadata + krátký `ai_readme` s odkazy na kód.
|
||||
|
||||
Pokud `error = no_active_plan`, v odpovědi uveď že aktivní plán v DB není (404 i u API `/plan/current`).
|
||||
|
||||
## 2) Co z JSONu číst při odpovědi
|
||||
|
||||
- **Proč baterie / síť**: `intervals_next_window` → `battery_setpoint_w`, `grid_setpoint_w`, `effective_buy_price` / `effective_sell_price`, `is_predicted_price`, vstupy `load_baseline_w`, `pv_*_forecast_*_w`, výstup `pv_a_curtailed_w`.
|
||||
- **Provozní rámec**: `operating_mode.mode_code` (AUTO vs CHARGE_CHEAP vs …) — LP constraints v `solve_dispatch()`.
|
||||
- **Limity**: `site_grid_connection`, `asset_battery` (`min_soc_percent`, `reserve_soc_percent`, `usable_capacity_wh`, degradace).
|
||||
- **EV deadline**: `ev_sessions_open` + sloupce `target_deadline` / `target_soc_pct` v kontextu intervalů (`ev1_setpoint_w`, `ev2_setpoint_w`).
|
||||
- **Rolling vs daily**: `active_planning_run.run_type`, `triggered_by`, `forecast_correction_factor`, `replan_from`, `soc_at_replan_wh`.
|
||||
- **Horizont a ceny**: produkční LP používá dynamický OTE horizont (`fn_planning_horizon_end`); u intervalu je `hours_from_plan_horizon_start` jen orientační. Váhy 0–36h / 36–72h / 72–96h jsou **historické** (viz `ai_readme` v JSONu a `docs/04-modules/planning-extended-horizon.md`).
|
||||
|
||||
## 3) Volitelně (UI stejné jako dashboard)
|
||||
|
||||
REST `GET /api/v1/sites/{site_id}/plan/current` vrací širší horizont než 6 h; pro **vysvětlování** preferuj `fn_plan_explain_bundle`, aby výstup byl úzký a jednorázový.
|
||||
14
.cursor/rules/postgres-sql-drop-comment.mdc
Normal file
14
.cursor/rules/postgres-sql-drop-comment.mdc
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Postgres DROP/COMMENT ON FUNCTION bez seznamu argumentů (jedna funkce na jméno)
|
||||
globs: db/**/*.sql
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Postgres: `DROP FUNCTION` a `COMMENT ON FUNCTION` bez parametrů
|
||||
|
||||
- U **`DROP FUNCTION`** (včetně schématu, např. `ems.fn_pv_forecast_delta_profile`) **nemusíme** uvádět signaturu argumentů, pokud platí předpoklad: **v DB existuje jen jedna funkce tohoto plného jména** (žádný jiný overload se stejným jménem).
|
||||
- Stejně u **`COMMENT ON FUNCTION`** používej **`COMMENT ON FUNCTION ems.nazev_funkce IS '...'`** bez seznamu typů argumentů — za stejného předpokladu jedné funkce na jméno.
|
||||
|
||||
**Chyba při migraci je v pořádku:** pokud v DB existují **dvě (nebo víc) funkcí stejného jména** (overloady), `DROP FUNCTION` / `COMMENT ON FUNCTION` **bez** seznamu typů může Postgres **zamítnout jako nejednoznačné** — to je žádoucí: hned se detekuje **nechtěný stav**, který se má opravit **odstraněním jedné z funkcí** (nebo přejmenováním), ne obcházením přes dlouhou signaturu v migraci.
|
||||
|
||||
**Když overload záměrně chceme:** jednoznačná jména nebo v daném skriptu dočasně uvést signaturu — v tomto projektu je default „jedna funkce na jméno“.
|
||||
26
.cursor/rules/timescale-continuous-aggregate.mdc
Normal file
26
.cursor/rules/timescale-continuous-aggregate.mdc
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: TimescaleDB continuous aggregates – komentáře a Flyway (EMS)
|
||||
globs: db/**/*.sql
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Timescale continuous aggregate v EMS
|
||||
|
||||
## Komentáře u CA (kritické)
|
||||
|
||||
Continuous aggregate vytvořený jako `CREATE MATERIALIZED VIEW … WITH (timescaledb.continuous)` **není** v systémovém katalogu PostgreSQL evidovaný jako běžný **materialized view**.
|
||||
|
||||
- **Nepoužívat** `COMMENT ON MATERIALIZED VIEW ems.<název_ca> …` → chyba SQL state **42809** („is not a materialized view“).
|
||||
- **Použít** `COMMENT ON VIEW ems.<název_ca> …` — stejný vzor jako u `telemetry_inverter_hourly` v migraci **V011**.
|
||||
|
||||
Samotné **wrapper view** nad CA (např. `vw_telemetry_15m_7d` v repeatable `R__071_vw_telemetry_15m_7d.sql`) komentovat standardně `COMMENT ON VIEW`.
|
||||
|
||||
## Struktura repa
|
||||
|
||||
- **Definice CA + `add_continuous_aggregate_policy`**: verzovaná migrace `db/migration/V0xx__*.sql` (po aplikaci na DB neměnit — nová V migrace).
|
||||
- **Definice čtecího view nad CA**: raději **repeatable** `db/views/R__NNN_vw_*.sql` (číselný prefix kvůli pořadí Flyway), aby šla měnit jedna aktuální verze bez nové V migrace.
|
||||
- **PostgREST**: `GRANT SELECT` na view v `db/views/R__072_z_postgrest_ems_anon_grants.sql`, ne na samotný CA.
|
||||
|
||||
## Odkaz v dokumentaci
|
||||
|
||||
Detailněji: `docs/04-modules/telemetry.md` (sekce o continuous aggregates a dashboardu).
|
||||
93
.cursor/skills/ems-plan-explain/SKILL.md
Normal file
93
.cursor/skills/ems-plan-explain/SKILL.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: ems-plan-explain
|
||||
description: >-
|
||||
Explains EMS dispatch plans from live Postgres (MCP): why battery/grid/PV/curtailment
|
||||
for a site and time window. If the user does not explicitly name a site (id, code, or
|
||||
unambiguous name), query ems.site (with active plan hint), show a numbered list, and
|
||||
ask which site to use — do not run plan analysis for multiple sites in one turn. Use when the
|
||||
user asks why the plan looks a certain way, planning_interval rows, negative prices,
|
||||
export zero, rolling replan, or says „vysvětli plán“, „proč nabíjí“, „proč škrtí FVE“.
|
||||
---
|
||||
|
||||
# EMS — vysvětlení plánu (živá DB + kontext kódu)
|
||||
|
||||
## Kdy skill použít
|
||||
|
||||
- Otázky typu **proč** plán dělá X (nabíjení, export, curtailment, režim, ceny).
|
||||
- Uživatel zmíní **kód lokality** (`BA81`, …) nebo „aktuální plán“.
|
||||
- Porovnání **model vs realita** (záporná cena, nulový export, pole A/B).
|
||||
|
||||
## Tvrdá pravidla
|
||||
|
||||
1. **Nejdřív data z DB přes MCP** (`user-postgres-ems`, nástroj `query`, pouze `SELECT`). Nevysvětlovat konkrétní sloty „z hlavy“ bez dotazu.
|
||||
2. Pokud MCP selže: uvést **přesnou chybu** a praktické kroky (VPN, MCP zapnutý, dostupnost DB).
|
||||
3. **`site_id` jen po explicitní volbě uživatele** (kód, id, potvrzení jedné řádky), nebo když uživatel **lokalitu v dotazu sám pojmenoval**. Neuvedená lokalita → **nejprve jen dotaz na výběr** (viz Krok 1); **zakázáno** analyzovat plán pro více `site` v jedné odpovědi „preventivně“.
|
||||
4. V odpovědi rozlišit: **co říká plán v DB** vs **co předpokládá LP model** vs **co omeží hardware** (např. taper nabíjení u vysokého SoC dnes v LP **není**).
|
||||
|
||||
## Postup (zkopíruj checklist)
|
||||
|
||||
```
|
||||
- [ ] Zjistit site_id: uživatel ji v dotazu pojmenoval? → případně MCP lookup. Jinak MCP seznam + **zeptat se** (viz Krok 1); až po odpovědi → jedna `site_id`
|
||||
- [ ] MCP: fn_plan_explain_bundle(site_id, hours) — default hours=6
|
||||
- [ ] Z JSONu: operating_mode, grid limity, battery limity, intervals_next_window
|
||||
- [ ] Potřebuji konkrétní čas? → doplnit SELECT na planning_interval (viz reference.md)
|
||||
- [ ] Vysvětlit bilanci slotu + relevantní LP pravidla (solve_dispatch)
|
||||
```
|
||||
|
||||
### Krok 1 — `site_id`
|
||||
|
||||
**Co znamená „lokalita explicitně zmíněná“:** v textu uživatele je **číselné `site_id`**, **kód lokality** (`BA81`, `home-01`, …), nebo **jednoznačný** název/fragment, ze kterého MCP vrátí **právě jednu** řádku `ems.site`.
|
||||
|
||||
- Pokud uživatel dal **`site_id` jako číslo**: ověřit MCP, že řádek v `ems.site` existuje → použít.
|
||||
- Pokud dal **kód nebo část názvu** (`BA81`, …): MCP `select id, code, name from ems.site where code ilike … or name ilike …`.
|
||||
- **0 řádků** → nabídnout seznam z [reference.md §0](reference.md) (všechny lokality) + **zeptat se**, kterou myslí.
|
||||
- **1 řádek** → použít jeho `id`.
|
||||
- **Více řádků** → číslovaný výpis + **zeptat se** na jednu (můžeš hintnout *kdo má aktivní plán*, ale **nepouštěj** analýzu dřív než výběr).
|
||||
- Pokud **lokalita vůbec zmíněná není** („vysvětli plán“, „proč nabíjí“ bez kódu apod.):
|
||||
1. MCP: SQL z **reference.md §0** (seřazený seznam `site` + `active_planning_run_id`).
|
||||
2. V odpovědi uvést **číslovaný seznam** `id | code | name | má aktivní plán?`.
|
||||
3. **Výslovně se zeptat uživatele**, kterou lokalitu myslí (číslo z výpisu, `code`, nebo `id`).
|
||||
4. **`fn_plan_explain_bundle` ani rozšířený SELECT na `planning_interval` pro tuto otázku nespouštěj**, dokud uživatel **nevybere jednu** lokalitu (kód / číslo řádku / id / jednoznačné „tu s BA81“). **Nepředvybírej** „beru první řádek“ ani nespouštěj paralelně bundle pro všechny `site_id` — je to zbytečná zátěž a matoucí výstup.
|
||||
5. Je v DB **jen jeden** záznam `ems.site`: stejně **nejdřív** napiš *která* lokalita to je a **zeptej se** na krátké potvrzení (např. *„Mám plán vysvětlit pro **CODE**?“* / stačí „ano“) — **bez** `fn_plan_explain_bundle` před odpovědí. Výjimku tvoří jen situace, kdy uživatel **v téže zprávě** současně explicitně odkáže na tuto jedinou lokalitu (pak není „neuvedená“).
|
||||
|
||||
### Krok 2 — balík pro vysvětlení
|
||||
|
||||
```sql
|
||||
select ems.fn_plan_explain_bundle(<site_id>, <hours>);
|
||||
```
|
||||
|
||||
- **`<hours>`**: default **6**. Jiná hodnota jen když uživatel explicitně chce delší/kratší okno.
|
||||
- Výstup je **jeden JSONB** (`bundle`): viz `.cursor/rules/plan-explain-bundle.mdc` — které klíče číst.
|
||||
|
||||
### Krok 3 — interpretace (struktura odpovědi)
|
||||
|
||||
Krátce a v pořadí:
|
||||
|
||||
1. **Kontext**: `operating_mode.mode_code`, `active_planning_run` (`run_type`, `triggered_by`, `soc_at_replan_wh`, `forecast_correction_factor`).
|
||||
2. **Slot(y)**: z `intervals_next_window` nebo z dodatečného SQL — pro každý relevantní interval:
|
||||
- **Výkon**: `battery_setpoint_w` (+ nabíjení / − vybíjení), `grid_setpoint_w` (+ import / − export), `load_baseline_w`.
|
||||
- **FVE**: `pv_a_forecast_solver_w`, `pv_b_forecast_solver_w`, `pv_a_curtailed_w` (useknuté W na **pole A**).
|
||||
- **Ceny**: `effective_buy_price`, `effective_sell_price`, `is_predicted_price`.
|
||||
- **Exekuce Deye** (pokud je ve sloupcích): `deye_physical_mode`, `deye_gen_cutoff_enabled`.
|
||||
3. **Proč** (odkaz na logiku, ne dlouhá citace):
|
||||
- Záporná **prodejní** cena → export do sítě v LP **neekonomický** / u části instalací **tvrdě 0**; přebytek → nabíjení / curtailment **A** / GEN cutoff (viz `solve_dispatch` v `backend/services/planning_engine.py`).
|
||||
- **Pole B** je v modelu **nekontrolovatelné** — nelze ho `pv_a_curtailed` omezit.
|
||||
- **Zelený bonus** není v účelové funkci LP; počítá se v auditu (`fn_green_bonus_revenue`) — viz `docs/04-modules/planning.md`.
|
||||
4. **Mezery modelu** (upozornit jednou větu, když je to relevantní):
|
||||
- LP používá horní strop **`max_charge_power_w`** bez závislosti na SoC → u vysokého SoC může reálný proud být nižší než plán.
|
||||
|
||||
### Kdy se zeptat uživatele
|
||||
|
||||
- **Lokalita neuvedená nebo nejednoznačná** — vždy **nejdřív** výběr / potvrzení (viz Krok 1); **nikdy** hned neanalyzovat všechny lokality najednou.
|
||||
- **Čas bez časové zóny** („v 11:15“) — potvrdit **Europe/Prague** nebo explicitní offset.
|
||||
- **Širší horizont** než pár hodin — domluvit `hours` nebo přesné `from`/`to` UTC pro doplnkový SELECT.
|
||||
|
||||
## Další SQL a šablony
|
||||
|
||||
→ [reference.md](reference.md)
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Hromadná analýza** (`fn_plan_explain_bundle`, `planning_interval` pro více `site_id`) jen proto, že uživatel **neřekl kterou** lokalitu — vždy se **nejprve** zeptat.
|
||||
- Nevyhledávat plán přes desítky ad-hoc dotazů, když stačí **`fn_plan_explain_bundle`** a případně jeden doplnkový `SELECT` na časové okno.
|
||||
- Nezaměňovat **`pv_a_curtailed_w`** (plán) s tím, **co je vždy zapsané na Modbus** — exekuce curtailmentu na Deye může být instalacně závislá; při pochybnostech říct „ověřit v `docs/05-todo.md` / modbus docs“.
|
||||
104
.cursor/skills/ems-plan-explain/reference.md
Normal file
104
.cursor/skills/ems-plan-explain/reference.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# EMS plan explain — reference SQL (MCP)
|
||||
|
||||
Všechno jen **read-only** `SELECT`. Server MCP: **`user-postgres-ems`**, nástroj **`query`**, argument `{"sql": "…"}`.
|
||||
|
||||
## 0) Lokalita neuvedená v dotazu — seznam pro výběr
|
||||
|
||||
Spusť jeden dotaz; výsledek **vyrenderuj uživateli jako číslovaný seznam** (`id`, `code`, `name`, příznaky).
|
||||
|
||||
```sql
|
||||
select s.id,
|
||||
s.code,
|
||||
s.name,
|
||||
coalesce(s.active, true) as site_active,
|
||||
pr.id as active_planning_run_id,
|
||||
pr.created_at as active_plan_created_at
|
||||
from ems.site s
|
||||
left join lateral (
|
||||
select id, site_id, created_at
|
||||
from ems.planning_run
|
||||
where site_id = s.id
|
||||
and status = 'active'
|
||||
order by created_at desc
|
||||
limit 1
|
||||
) pr on true
|
||||
order by (pr.id is not null) desc,
|
||||
coalesce(s.active, true) desc,
|
||||
s.id;
|
||||
```
|
||||
|
||||
**Po seznamu vždy zeptej se uživatele** na jednu lokalitu (číslo řádku, `code`, nebo `id`). **Nespouštěj** `fn_plan_explain_bundle` pro více lokalit najednou ani „tiše“ pro první řádek — viz skill `ems-plan-explain` Krok 1. Volitelně můžeš v jedné větě upozornit, kdo má `active_planning_run_id`, ale **výběr nech na uživateli** (u jediného záznamu v tabulce stačí krátké potvrzení typu „ano“).
|
||||
|
||||
Až uživatel lokalitu vybere nebo potvrdí, pokračuj `fn_plan_explain_bundle(s.id, hours)`.
|
||||
|
||||
## 1) `site_id` z kódu lokality
|
||||
|
||||
Nahraď literál v uvozovkách (příklad `BA81`):
|
||||
|
||||
```sql
|
||||
select id, code, name, timezone
|
||||
from ems.site
|
||||
where code ilike 'BA81'
|
||||
or name ilike '%BA81%';
|
||||
```
|
||||
|
||||
Pokud více řádků → **zeptat se uživatele**, kterou lokalitu myslí.
|
||||
|
||||
## 2) Primární balík (doporučeno pro vysvětlení)
|
||||
|
||||
Druhý argument = **počet hodin** od začátku aktuálního 15min slotu (UTC), stejně jako plánovač.
|
||||
|
||||
```sql
|
||||
select ems.fn_plan_explain_bundle(3, 6) as bundle;
|
||||
```
|
||||
|
||||
Typicky druhý argument **6**. Větší okno jen když uživatel chce delší výhled (více tokenů).
|
||||
|
||||
## 3) Konkrétní sloty v čase (Europe/Prague)
|
||||
|
||||
Intervaly v DB jsou **`timestamptz` (UTC)**. Pro „zítra 11:15“ převeď na UTC v dotazu nebo použij okno:
|
||||
|
||||
```sql
|
||||
select pi.interval_start,
|
||||
pi.battery_setpoint_w,
|
||||
pi.grid_setpoint_w,
|
||||
pi.load_baseline_w,
|
||||
pi.pv_a_forecast_solver_w,
|
||||
pi.pv_b_forecast_solver_w,
|
||||
pi.pv_a_curtailed_w,
|
||||
pi.effective_buy_price,
|
||||
pi.effective_sell_price,
|
||||
pi.deye_physical_mode,
|
||||
pi.deye_gen_cutoff_enabled
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = (
|
||||
select id from ems.planning_run
|
||||
where site_id = 3 and status = 'active'
|
||||
order by created_at desc
|
||||
limit 1
|
||||
)
|
||||
and pi.interval_start >= '2026-04-27T08:00:00+00:00'
|
||||
and pi.interval_start < '2026-04-27T14:00:00+00:00'
|
||||
order by pi.interval_start;
|
||||
```
|
||||
|
||||
Hodnoty `site_id` a časové meziráky nahraď podle kontextu.
|
||||
|
||||
## 4) Žádný aktivní plán
|
||||
|
||||
Když `fn_plan_explain_bundle` vrátí chybu / `no_active_plan`, ověř:
|
||||
|
||||
```sql
|
||||
select id, status, run_type, created_at, horizon_start, horizon_end
|
||||
from ems.planning_run
|
||||
where site_id = 3
|
||||
order by created_at desc
|
||||
limit 5;
|
||||
```
|
||||
|
||||
## 5) Dokumentace v repu
|
||||
|
||||
- `docs/07-mcp-postgres-ems.md` — MCP bezpečnost a příklady
|
||||
- `.cursor/rules/plan-explain-bundle.mdc` — co číst z JSONu
|
||||
- `backend/services/planning_engine.py` — `solve_dispatch` (omezení `sell < 0`, `buy < 0`, curtailment)
|
||||
- `docs/04-modules/planning.md` — bilance, účelovka, edge cases
|
||||
@@ -16,10 +16,11 @@
|
||||
# ---- PostgreSQL ----
|
||||
DB_USER=ems_user
|
||||
DB_PASSWORD=change_me_strong_password
|
||||
|
||||
# Limit současných připojení k DB (deploy/docker-compose + kořenové docker-compose). Výchozí v compose je 300.
|
||||
# POSTGRES_MAX_CONNECTIONS=300
|
||||
# ---- PostgREST ----
|
||||
POSTGREST_JWT_SECRET=change_me_jwt_secret_min_32_chars
|
||||
# PostgREST anonymní role (viz db/migration/V009__postgrest_roles.sql + R__z_postgrest_ems_anon_grants.sql).
|
||||
# PostgREST anonymní role (viz db/migration/V009__postgrest_roles.sql + R__072_z_postgrest_ems_anon_grants.sql).
|
||||
POSTGREST_ANON_ROLE=ems_anon
|
||||
|
||||
# ---- OTE CZ import ----
|
||||
@@ -41,7 +42,7 @@ DISCORD_WEBHOOK_URL= # Discord webhook URL pro alerty, prázdné = vypnuto
|
||||
TELEMETRY_POLL_INTERVAL_SEC=60
|
||||
|
||||
# ---- Plánování ----
|
||||
PLANNING_HORIZON_HOURS=36
|
||||
# Délka horizontu (strop OTE + min délka pro rolling): ems.fn_planning_horizon_end v DB, ne env.
|
||||
PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ
|
||||
PLANNING_CHEAP_PRICE_THRESHOLD=0.85
|
||||
PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15
|
||||
|
||||
@@ -1,22 +1,73 @@
|
||||
# Deploy na single server: deploy.sh volá hostovský Docker přes /var/run/docker.sock (bez DinD).
|
||||
# CI: immutability + Flyway validate (JDBC na staging / sdílenou DB). Deploy na main až po úspěchu.
|
||||
# Job bez container: — hostovský docker + git (stejně jako deploy).
|
||||
# Gitea secrets: EMS_CI_FLYWAY_URL (jdbc:postgresql://…/ems). Volitelně EMS_CI_FLYWAY_USER, EMS_CI_FLYWAY_PASSWORD.
|
||||
# Runner: container.valid_volumes pro /var/run/docker.sock (viz docs/deployment-self-hosted.md).
|
||||
#
|
||||
# Job běží v kontejneru — /opt/ems-deploy a sock musí být přimountované (viz container.volumes).
|
||||
# V /opt/gitea-stack/runner/config.yaml nastav container.valid_volumes na stejné cesty.
|
||||
# Sladit `runs-on` s labely registrace runneru (výchozí: self-hosted).
|
||||
#
|
||||
# Spuštění: push na větev main (včetně merge PR do main — merge v Gitea/Git je stále push na main).
|
||||
# Nepřidávat paralelně pull_request:closed — při merge by běžel deploy dvakrát (push + PR).
|
||||
# Spuštění deploye: push na main. Nepřidávat paralelně pull_request:closed — při merge by běžel deploy dvakrát.
|
||||
|
||||
name: deploy
|
||||
name: CI and deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- feature/**
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
migration-check:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
su="${{ github.server_url }}"
|
||||
case "$su" in
|
||||
https://*) clone_url="https://oauth2:${TOKEN}@${su#https://}" ;;
|
||||
http://*) clone_url="http://oauth2:${TOKEN}@${su#http://}" ;;
|
||||
*) echo "unknown github.server_url: $su"; exit 1 ;;
|
||||
esac
|
||||
clone_url="${clone_url}/${{ github.repository }}.git"
|
||||
git init
|
||||
git remote add origin "$clone_url"
|
||||
git fetch --depth=64 origin "${{ github.sha }}"
|
||||
git checkout -qf FETCH_HEAD
|
||||
git remote set-branches origin 'main' || true
|
||||
git fetch --depth=64 origin main:refs/remotes/origin/main || true
|
||||
|
||||
- name: Repo layout
|
||||
run: |
|
||||
test -f docker-compose.yml
|
||||
test -f deploy/docker-compose.yml
|
||||
test -x deploy/deploy.sh
|
||||
test -x scripts/ci_check_migration_immutability.sh
|
||||
test -x scripts/ci_flyway_validate_remote.sh
|
||||
|
||||
- name: Migration immutability (vs PR base or main)
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
BASE='origin/main'
|
||||
if [ -n "${PR_BASE_SHA:-}" ]; then
|
||||
BASE="$PR_BASE_SHA"
|
||||
git fetch --no-tags --depth=256 origin "$BASE" || true
|
||||
fi
|
||||
./scripts/ci_check_migration_immutability.sh "$BASE"
|
||||
|
||||
- name: Flyway validate (remote DB)
|
||||
env:
|
||||
EMS_CI_FLYWAY_URL: ${{ secrets.EMS_CI_FLYWAY_URL }}
|
||||
EMS_CI_FLYWAY_USER: ${{ secrets.EMS_CI_FLYWAY_USER }}
|
||||
EMS_CI_FLYWAY_PASSWORD: ${{ secrets.EMS_CI_FLYWAY_PASSWORD }}
|
||||
run: ./scripts/ci_flyway_validate_remote.sh
|
||||
|
||||
deploy:
|
||||
needs: migration-check
|
||||
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Show execution context
|
||||
@@ -27,8 +78,7 @@ jobs:
|
||||
ls -ld /opt/ems-deploy
|
||||
|
||||
- name: Run deploy script
|
||||
run: |
|
||||
bash /opt/ems-deploy/deploy.sh
|
||||
run: bash /opt/ems-deploy/deploy.sh
|
||||
|
||||
# Alternativa: runner v Dockeru bez přístupu k hostu — odkomentovat a upravit SERVER + secrets.
|
||||
# deploy-ssh:
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- feature/**
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
smoke-test:
|
||||
# Stejný label jako deploy.yml — výchozí act_runner má typicky jen `self-hosted`.
|
||||
runs-on: self-hosted
|
||||
# Výchozí job image často nemá Node → `actions/checkout@v4` padá na „Cannot find: node“.
|
||||
# alpine/git je malý a stačí na shallow clone přes token (Gitea = GitHub-kompatibilní kontext).
|
||||
container:
|
||||
image: alpine/git:latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
su="${{ github.server_url }}"
|
||||
case "$su" in
|
||||
https://*) clone_url="https://oauth2:${TOKEN}@${su#https://}" ;;
|
||||
http://*) clone_url="http://oauth2:${TOKEN}@${su#http://}" ;;
|
||||
*) echo "unknown github.server_url: $su"; exit 1 ;;
|
||||
esac
|
||||
clone_url="${clone_url}/${{ github.repository }}.git"
|
||||
git init
|
||||
git remote add origin "$clone_url"
|
||||
git fetch --depth=1 origin "${{ github.sha }}"
|
||||
git checkout -qf FETCH_HEAD
|
||||
|
||||
- name: Repo layout
|
||||
run: |
|
||||
test -f docker-compose.yml
|
||||
test -f deploy/docker-compose.yml
|
||||
test -x deploy/deploy.sh
|
||||
|
||||
- name: Runner info
|
||||
run: |
|
||||
uname -a
|
||||
pwd
|
||||
ls -la
|
||||
15
.idea/data_source_mapping.xml
generated
15
.idea/data_source_mapping.xml
generated
@@ -1,8 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourcePerFileMappings">
|
||||
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/debug-forecast.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/naplneni-base-line-ba81.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/porovnani-view-status.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/migration/V009__postgrest_roles.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/views/R__z_postgrest_ems_anon_grants.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/migration/V065__forecast_pv_interval_interval_start_index.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/migration/V066__latest_telemetry_distinct_on_indexes.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/migration/V070__forecast_accuracy_delta_profile_index.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/migration/V071__forecast_pv_interval_pv_array_interval.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/routines/R__023_fn_forecast_pv_split.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/routines/R__066_fn_site_notifications_context.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/routines/R__068_fn_economics_daily_month.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/routines/R__078_fn_pv_forecast_delta_profile.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/views/R__058_vw_latest_telemetry.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/views/R__072_z_postgrest_ems_anon_grants.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/scripts/analysis/ote_arbitrage_proxy.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/sqldialects.xml
generated
2
.idea/sqldialects.xml
generated
@@ -2,7 +2,5 @@
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/porovnani-view-status.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/db/migration/V009__postgrest_roles.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/db/views/R__z_postgrest_ems_anon_grants.sql" dialect="PostgreSQL" />
|
||||
</component>
|
||||
</project>
|
||||
68
CLAUDE.md
68
CLAUDE.md
@@ -21,6 +21,17 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
| Pole / zařízení | Modbus TCP (`pymodbus`), HTTP (Loxone, případně API vozidel) |
|
||||
| Solver | PuLP + HiGHS (`HiGHS_CMD`) |
|
||||
| Runtime | Docker Compose |
|
||||
| **Živá DB přes MCP (Cursor)** | Server ID **`user-postgres-ems`**, nástroj **`query`**, `{ "sql": "…" }` — viz **`docs/07-mcp-postgres-ems.md`** a pravidlo **`.cursor/rules/mcp-postgres-ems.mdc`** |
|
||||
|
||||
---
|
||||
|
||||
## 2b. MCP — živá EMS databáze (read-only)
|
||||
|
||||
Když uživatel napíše **„použij MCP“** nebo potřebuje **aktuální řádky z Postgresu** (plán, telemetrie, journal):
|
||||
|
||||
1. Zavolej MCP nástroj **`query`** na serveru **`user-postgres-ems`** s argumentem `{"sql": "<SELECT …>"}`.
|
||||
2. **Neodmlouvej** bez pokusu (typ „nepřipojím se“, „MCP neexistuje“). Po chybě popiš **skutečnou** chybu a co zkontrolovat.
|
||||
3. Kanonický popis, příklady a bezpečnost: **`docs/07-mcp-postgres-ems.md`**.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,6 +44,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
| `docs/04-modules/` | Modulové specifikace (ceny, forecast, spotřeba, TČ, telemetrie, řízení, plánování, režimy, EV) |
|
||||
| `docs/loxone-integration.md` | Loxone watchdog, heartbeat, role exekutora |
|
||||
| `docs/06-open-questions.md` | Nedokončené rozhodnutí – doplňovat místo hádání |
|
||||
| `docs/07-mcp-postgres-ems.md` | MCP read-only SQL na EMS DB (server `user-postgres-ems`, nástroj `query`) |
|
||||
| `db/migration/` | Flyway versioned migrace `V00x__*.sql` (schéma, seed, alter) |
|
||||
| `db/routines/` | Repeatable SQL: funkce `ems.fn_*` |
|
||||
| `db/views/` | Repeatable SQL: view `ems.vw_*` |
|
||||
@@ -52,7 +64,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
|
||||
5. **FVE pole B (`controllable = false`, typicky ongrid GEN) – žádný curtailment.** Curtailment jen pole A (Deye). Solver smí omezovat jen `pv_a`; pole B může mít zelený bonus na `asset_pv_array` (`green_bonus_*`), audit `pv_b_production_wh` / `green_bonus_czk`.
|
||||
|
||||
6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint).
|
||||
6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint kde zapnuté): buď **`deye_gen_microinverter_cutoff_enabled`** na `deye-main`, nebo **`ems.site_grid_connection.block_export_on_negative_sell`** (default false). **home-01** kvůli neriťitelnému PV B často **bez** druhého přepínače — přebytek pole B nesmí dělat PL infeasible; **KV1** (bez pole B / fixní nákup) migrace **V074** nastavuje `block_export_on_negative_sell = true`.
|
||||
|
||||
7. **Záporná nákupní cena → omezit import** na realistický horní strop (viz `solve_dispatch` v `planning_engine.py` – nesmí „nekonečný“ import).
|
||||
|
||||
@@ -64,26 +76,39 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
|
||||
11. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` – držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`.
|
||||
|
||||
### SQL-first a read-model (Python jen tenká orchestrace)
|
||||
|
||||
Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a stabilní čtecí rozhraní patří do **PostgreSQL** (`ems.fn_*`, případně **`ems.vw_*`**). Python (FastAPI, joby) volá DB; neskladá vlastní dotazy nad schématem mimo výjimky níže.
|
||||
|
||||
**Formát SQL v repu (`db/migration`, `db/routines`, `db/views`):** odsazení **2 mezery** na úroveň vnoření; **rezervovaná klíčová slova PostgreSQL vždy malými písmeny** (`create table`, `select`, `where`, `references`, …). Identifikátory (`ems.*`, sloupce) **`snake_case`**; typy v deklaracích též malými (`int`, `text`, `timestamptz`, `jsonb`). Nový / upravený SQL v tomto stylu — nesmí se objevovat verzované migrace psané „ALL CAPS keywords“.
|
||||
|
||||
- **Preferuj:** novou nebo rozšířenou **`ems.fn_*(…)`** s jasnými parametry; potřebuješ často stejné sloupce z více tabulek → **`ems.vw_*`** (view zapouzdřuje joiny a strukturu DB; z Pythonu je `SELECT … FROM ems.vw_*` v pořádku).
|
||||
- **Nechtěné:** skládání dotazů v Pythonu (**vlastní JOIN / WITH / poddotazy** nad `ems.*` tabulkami). Místo toho funkce nebo view v `db/routines/` / `db/views/` + jedno volání z aplikace.
|
||||
- **Jediné SQL v `backend/services/*.py` a `backend/app/routers/*.py`:** `SELECT 1` / `EXISTS`; **`select ems.fn_*(…)`**; **`SELECT … FROM ems.vw_*`** (read přes view); žádné jiné ad-hoc **`SELECT`/`INSERT`/`UPDATE`**. IO (Modbus, HTTP); **PuLP**; orchestrace scheduleru.
|
||||
- **Health a Loxone po změně režimu:** `fn_health_summary`, `fn_health_detailed_db`, `fn_vw_site_directory_active`, `fn_site_economics_yesterday_notification`, `fn_site_mode_loxone_bundle` v repeatable `db/routines/R__073_fn_health_site_jobs_mode_bundle.sql`; FastAPI je v [`app/main.py`](backend/app/main.py) + joby v [`app/lifespan.py`](backend/app/lifespan.py).
|
||||
|
||||
### Provozní režimy (operating_mode)
|
||||
|
||||
- Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**.
|
||||
- Režim se načítá v `planning_engine._load_site_context()`; **dodatečné LP constraints** podle režimu jsou v **`solve_dispatch()`** (žádný export / limit importu / zákaz nabíjení nebo vybíjení baterie podle módu).
|
||||
- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `control_exporter.get_deye_mode` a zapisují v `write_inverter_setpoints`.
|
||||
- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `exporter_monolith.get_deye_mode` a zapisují v `write_inverter_setpoints`.
|
||||
- **`lock_battery=True`** u `ControlSetpoints` (PRESERVE): registry **108/109 = 0** – Deye baterii nepoužívá. Výjimka oproti obecnému pravidlu max A ve PASSIVE/SELL.
|
||||
|
||||
12. **`forecast_pv_run` a `forecast_pv_interval` se NESMÍ mazat** – historické běhy zůstávají v DB pro tracking přesnosti (`forecast_accuracy`, `fn_fill_forecast_accuracy`).
|
||||
|
||||
13. **Endpoint `GET …/forecast/pv`** vrací `DISTINCT ON (interval_start, pv_array_id)` seřazené podle nejnovějšího `forecast_pv_run.created_at`, aby UI nemělo duplikáty slotů; plná historie běhů zůstává v tabulkách.
|
||||
|
||||
13a. **PV delta kalibrace:** `GET …/forecast/pv-delta-profile` vrací JSON z `fn_pv_forecast_delta_profile`; `GET …/configuration` obsahuje `pv_forecast_calibration` z `ems.site_pv_forecast_calibration`; `PATCH …/configuration/pv-forecast-calibration` mění cutoff / policy / přepsání parametrů delty. **Referenční dny** špičkové produkce zpětně: tabulka **`ems.site_pv_forecast_reference_day`** (V076) + volitelně sloupec **`reference_day_weight_mult`** v kalibraci — v `fn_pv_forecast_delta_profile` zvednou váhu řádků `forecast_accuracy` těchto kalendářních dní (datum ve `site.timezone` jako u slotů); doplňovat lze **`ems.fn_pv_forecast_sync_reference_days`**. Provozní mazání uložené predikce za den (hranice **Europe/Prague**, ne TZ site): **`ems.fn_delete_forecast_pv_prague_calendar_day`**. Telemetrie `telemetry_inverter.is_export_limited` / `pv_derating_flags` (V058) řídí vyloučení slotu z učení v `fn_fill_forecast_accuracy` (`telemetry_derating`); `telemetry_collector` je plní čtením Deye reg **145** a **179** při poll střídače.
|
||||
|
||||
14. **Příchod a odjezd EV** detekuje `telemetry_collector` z telemetrie nabíječky: přechod `available` → `preparing` / `charging` (resp. jakýkoli stav ≠ `available`) znamená příjezd; přechod na `available` uzavře `ev_session`. Tabulka `ev_arrival_stats` se při příjezdu doplňuje přes `fn_update_ev_arrival_stats` a **nemá se mazat** (dlouhodobá historická statistika).
|
||||
|
||||
15. **Bazální spotřeba** = `load_power_w` minus řízené zátěže (součet EV z `telemetry_ev_charger`, TČ z `telemetry_heat_pump`). Tabulka `consumption_baseline_stats` se plní denně (APScheduler 00:30) přes `fn_update_baseline_stats`. **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`.
|
||||
15. **Bazální spotřeba** = `load_power_w` minus řízené zátěže (součet EV z `telemetry_ev_charger`, TČ z `telemetry_heat_pump`). Tabulka `consumption_baseline_stats` se plní denně (APScheduler 00:30) přes `fn_update_baseline_stats`; **bez EMA „ocasu“** přepočítáš smaž+hromadný update přes **`ems.fn_rebuild_consumption_baseline_stats(site_id, lookback)`** (`site_id NULL` → všechny lokality). **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`.
|
||||
|
||||
16. **Rozšířený horizont plánování (96h):** denní plán pokrývá **96h** od začátku aktuálního 15min slotu. Sloty v prvních **36h** používají přesné efektivní ceny z `vw_site_effective_price` (OTE). Sloty **36–96h** doplňuje **predikovaná cena** z `market_price_stats` přes `fn_get_predicted_price` (prodejní strana hrubý faktor 0,85 vs. nákupní predikce). V účelové funkci LP se uplatní **váhy nejistoty** podle vzdálenosti od začátku okna: **1,0** (0–36h), **0,7** (36–72h), **0,4** (72–96h). Statistiky cen plní `fn_update_market_price_stats` (job 14:45), TUV delta `fn_update_tuv_usage_stats` (job 00:45). Detail: `docs/04-modules/planning-extended-horizon.md`.
|
||||
16. **Dynamický horizont plánování (jen OTE):** konec okna z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (`min` posledního OTE konce a `start + 36 h`, NULL pokud je známé OTE kratší než **1 h** od startu – rolling se přeskočí; denní plán při NULL použije **1 h** fallback v Pythonu). Strop a práh měnit v SQL (defaultní argumenty funkce / repeatable migrace), ne přes env. Solver používá **výhradně** sloty s efektivní cenou z `vw_site_effective_price` (žádná predikce v LP). Účelová funkce má **terminal SoC shadow price**: `−(průměr buy v prvních 24 h slotů × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč; SoC v Wh), kde **`planner_terminal_soc_value_factor`** je **`ems.asset_battery.planner_terminal_soc_value_factor`** načtené přes **`ems.fn_planning_site_context`** (žádný skrytý faktor v Pythonu). `market_price_stats` / `fn_get_predicted_price` zůstávají pro statistiky a budoucí rozšíření; detail historie: `docs/04-modules/planning-extended-horizon.md`.
|
||||
|
||||
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`.
|
||||
|
||||
18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **3–6** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **3–6** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 62–64:** před zařazením do fronty **čtení** 62–64; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního syncu; `deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` po **úspěšném zápisu** 62–64 a znovu po **úspěšné toleranční verifikaci**; při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verify **vždy** čte 62–64 najednou — **reg 64 nesmí** do striktní větve; toleranční odchylka až **120 s**; po 3 neúspěších u hodin **bez** SELF_SUSTAIN (jen Discord). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`.
|
||||
18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **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`**.
|
||||
|
||||
19. **Baterie – export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 11–12 %, migrace V029 + komentář sloupce).
|
||||
|
||||
@@ -96,7 +121,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
| `site` | Lokalita (časová zóna, GPS, aktivita). |
|
||||
| `site_endpoint` | Endpointy: Modbus, Loxone HTTP, atd. |
|
||||
| `site_market_config` | Marže, režimy cenění; časová platnost (zelený bonus není zde – viz `asset_pv_array`). |
|
||||
| `site_grid_connection` | Limity import/export, no_export, rezervovaný výkon. |
|
||||
| `site_grid_connection` | Limity import/export, **block_export_on_negative_sell** (LP při záporném sell), no_export, rezervovaný výkon. |
|
||||
| `site_override` | Manuální přepisy nad plánem (JSON + platnost). |
|
||||
| `site_operating_mode` | Aktuální provozní režim na site (1 řádek/site). |
|
||||
| `site_operating_mode_log` | Historie přepnutí režimů. |
|
||||
@@ -109,12 +134,13 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
| `asset_heat_pump` | TČ (výkon, COP ref, limity běhu, TUV parametry). |
|
||||
| `asset_vehicle` | Vozidlo (kapacita, max AC výkon, default target SoC/deadline). |
|
||||
| `market_interval_price` | Raw spot OTE (15min), bez marží. |
|
||||
| `telemetry_inverter` | 1min telemetrie střídače (Timescale). |
|
||||
| `telemetry_inverter` | 1min telemetrie střídače (Timescale); volitelně `is_export_limited`, `pv_derating_flags` pro vyloučení slotu z učení delty. |
|
||||
| `telemetry_ev_charger` | 1min telemetrie nabíječky (Timescale). |
|
||||
| `telemetry_heat_pump` | 1min telemetrie TČ (Timescale). |
|
||||
| `forecast_pv_run` | Metadata běhu predikce FVE. |
|
||||
| `forecast_pv_interval` | Predikovaný výkon FVE po 15min (Timescale). |
|
||||
| `forecast_accuracy` | Řádky přesnosti predikce vs telemetrie po 15min (per run); doplňuje `fn_fill_forecast_accuracy`. |
|
||||
| `site_pv_forecast_calibration` | Per site: cutoff učení delty, policy škrcení, přepsání parametrů `fn_pv_forecast_delta_profile`. |
|
||||
| `forecast_weather_interval` | Počasí 15min pro site (Timescale). |
|
||||
| `forecast_correction_log` | Log korekcí forecastu vs skutečnost při rolling replanu. |
|
||||
| `planning_run` | Jeden běh plánovače (daily/rolling/manual, stav, parametry solveru). |
|
||||
@@ -127,9 +153,13 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
||||
| `ev_session` | Nabíjecí session na WB (deadline, energie, náklady). |
|
||||
| `ev_arrival_stats` | Agregované počty příjezdů EV podle dne v týdnu a hodiny (Europe/Prague); plní se z detekce příjezdu v telemetrii. |
|
||||
| `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). |
|
||||
| `signal_def` | Katalog odchozích signálů (kód, typ hodnoty); seed `EXPORT_BAN_ACTIVE`. |
|
||||
| `signal_route` | Mapování signál → cíl (`loxone_vi`, `http_rest`) per site + `endpoint_id` + volitelný `route_config_json` / `verify_config_json`. |
|
||||
| `signal_outbound_journal` | Journal HTTP odeslání signálů (`queued` → `sent` → `verified` / retry / `abandoned`). |
|
||||
| `signal_state` | Poslední požadovaná / odeslaná / ověřená hodnota na cíli (idempotence). |
|
||||
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
||||
|
||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`.
|
||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`.
|
||||
|
||||
---
|
||||
|
||||
@@ -142,10 +172,11 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
||||
| `telemetry_collector` | každých **60 s** | Smyčka polling Modbus (Deye, EV×2, TČ) – viz `docs/04-modules/telemetry.md` |
|
||||
| `price_importer` (scheduler) | **13:30 / 14:00 / 00:05** | Jeden globální zápis do `market_interval_price` za tick (ne cyklus per site); po importu obnova predikce záporných cen pro každou aktivní site. Viz `docs/04-modules/market-prices.md` |
|
||||
| `forecast_service` | **14:30** + **06:00** denně | `docs/04-modules/forecast.md` |
|
||||
| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` (horizont **96 h**, váhy slotů 1,0 / 0,7 / 0,4) |
|
||||
| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` + `ems.fn_planning_horizon_end` (dynamický OTE horizont, terminal SoC) |
|
||||
| `run_rolling_replan` | **každých 15 min** (`*/15`) | `planning_engine.py` – přepočet od aktuálního slotu |
|
||||
| `control_exporter` | **každých 15 min** (slot boundary) | `docs/04-modules/control.md` |
|
||||
| `verify_modbus` | **každé 2 min** | Ověření `modbus_command` ve stavu `written` (posledních 10 min); viz `docs/04-modules/modbus-command-journal.md` |
|
||||
| `signal_outbound_send` / `signal_outbound_verify` | **každých 15 s** | `services/signal_service.py` — odeslání fronty `signal_outbound_journal` a readback verify (Loxone / HTTP REST). |
|
||||
| `audit_filler` / `fn_fill_audit_interval` | **každých 15 min** | `docs/02-architecture.md`, DB `fn_fill_audit_interval` |
|
||||
| `forecast_accuracy` / `fn_fill_forecast_accuracy` | **každých 15 min** (min. 2,17,32,47) | Po audit filleru; doplní actual z telemetrie do `forecast_accuracy` |
|
||||
| `fn_update_baseline_stats` | **00:30** denně | Aktualizace `consumption_baseline_stats` z telemetrie (30d lookback) |
|
||||
@@ -160,34 +191,37 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
||||
|-------|-----|
|
||||
| Pochopit systém end-to-end | `docs/01-overview.md`, `docs/02-architecture.md` |
|
||||
| Tabulky, vazby, jednotky | `docs/03-data-model.md` |
|
||||
| OTE ceny, marže, efektivní cena | `docs/04-modules/market-prices.md`, `db/views/R__vw_site_effective_price.sql`, `backend/services/price_importer.py` |
|
||||
| OTE ceny, marže, efektivní cena | `docs/04-modules/market-prices.md`, `db/views/R__061_vw_site_effective_price.sql`, `backend/services/price_importer.py` |
|
||||
| Multi-site UI (combobox), seznam aktivních lokalit | `GET /api/v1/me/sites` v `backend/app/main.py`, `frontend/src/context/SiteSelectionContext.tsx`, `useSiteStatus` (filtr `vw_site_status`) |
|
||||
| FVE forecast, počasí | `docs/04-modules/forecast.md` |
|
||||
| Bazální spotřeba | `docs/04-modules/consumption.md` |
|
||||
| TČ, COP, TUV | `docs/04-modules/heat-pump.md`, `db/routines/R__fn_cop_estimate.sql` |
|
||||
| TČ, COP, TUV | `docs/04-modules/heat-pump.md`, `db/routines/R__005_fn_cop_estimate.sql` |
|
||||
| Modbus, telemetrie, agregace | `docs/04-modules/telemetry.md` |
|
||||
| Dashboard přehled – 15min grafy slotů, SoC vs. živá telemetrie | `docs/04-modules/telemetry.md` (CA `telemetry_inverter_15m`, view `vw_telemetry_15m_7d`), `frontend/src/hooks/useDashboardData.ts`, `frontend/src/components/charts/SocTuvChart.tsx` |
|
||||
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` |
|
||||
| Deye registry (FC 0x10, 108/109/141/142/178/143) | `docs/04-modules/modbus-registers.md` |
|
||||
| Deye registry (FC 0x10, 108/109/141/142/178/143/145/340) | `docs/04-modules/modbus-registers.md` |
|
||||
| Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` |
|
||||
| LP solver, rolling replan, korekce FVE, horizont 96h | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
|
||||
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `R__fn_set_mode.sql` |
|
||||
| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
|
||||
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` |
|
||||
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
|
||||
| Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` |
|
||||
| Rolling plán, forecast log | `db/migration/V007__rolling_replanning.sql` |
|
||||
| Audit 15min | `db/routines/R__fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` |
|
||||
| Audit 15min | `db/routines/R__019_fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` |
|
||||
| Nové sloupce / tabulky | nový `db/migration/V00x__*.sql` + případně `db/routines` / `db/views` |
|
||||
| JSONB read-model (`fn_*`, `fetch_json`) | `docs/02-architecture.md` sekce Read-model JSONB, `app/db_json.py` |
|
||||
| Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` |
|
||||
| Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` |
|
||||
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |
|
||||
| **MCP read-only SQL na EMS DB** | Cursor MCP server **`postgres-ems`**, nástroj **`query`**. |
|
||||
| **MCP read-only SQL na EMS DB** | **`docs/07-mcp-postgres-ems.md`** — server ID **`user-postgres-ems`**, nástroj **`query`**, `{"sql":"…"}`. Pravidlo **`.cursor/rules/mcp-postgres-ems.mdc`**. |
|
||||
|
||||
---
|
||||
|
||||
## Konvence (krátce)
|
||||
|
||||
- Python: `snake_case`, type hints, Pydantic pro API modely.
|
||||
- SQL: `snake_case`, explicitní FK; Flyway pořadí `V###__` / repeatable `R__`.
|
||||
- SQL: viz také odstavec **Formát SQL** u sekce SQL-first výše — **2 mezery** odsazení, **klíčová slova malými písmeny**, `snake_case` identifikátory, explicitní FK; Flyway pořadí `V###__` / repeatable `R__NNN_*.sql` (třímístný prefix = pořadí závislostí mezi fn/vw).
|
||||
- Timescale **continuous aggregate** (CA): komentář k objektu CA je **`COMMENT ON VIEW`**, ne `COMMENT ON MATERIALIZED VIEW` (PG hlásí 42809). Viz `.cursor/rules/timescale-continuous-aggregate.mdc`.
|
||||
- Výkon **W**, energie **Wh**, ceny **Kč/kWh**; čas v DB **`TIMESTAMPTZ` (UTC)**.
|
||||
- NIKDY neupravuj existující V__ migrační soubory po jejich aplikaci na DB.
|
||||
- Pokud je potřeba opravit chybu ve verzované migraci, vytvoř novou V{N+1} migraci.
|
||||
- Deploy: `flyway validate` před `migrate` ([`deploy/deploy.sh`](deploy/deploy.sh)). Lokálně `./scripts/flyway_validate_local.sh`; CI viz [`docs/deployment-self-hosted.md`](docs/deployment-self-hosted.md) a `scripts/ci_check_migration_immutability.sh`.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""asyncpg Record → JSON-serializovatelný dict."""
|
||||
"""asyncpg Record → JSON-serializovatelný dict + helper pro jsonb z fn_*."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
@@ -33,3 +34,17 @@ def record_to_dict(r: asyncpg.Record) -> dict[str, Any]:
|
||||
else:
|
||||
out[k] = str(v)
|
||||
return out
|
||||
|
||||
|
||||
async def fetch_json(conn: asyncpg.Connection, query: str, *args: Any) -> Any:
|
||||
"""fetchval pro dotazy vracející jsonb (např. select ems.fn_*(...))."""
|
||||
v = await conn.fetchval(query, *args)
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (dict, list)):
|
||||
return v
|
||||
if isinstance(v, (bytes, memoryview)):
|
||||
return json.loads(bytes(v).decode("utf-8"))
|
||||
if isinstance(v, str):
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
543
backend/app/lifespan.py
Normal file
543
backend/app/lifespan.py
Normal file
@@ -0,0 +1,543 @@
|
||||
"""FastAPI lifespan: DB pool, APScheduler joby, telemetrie."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from fastapi import FastAPI
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import set_pg_pool
|
||||
from app.refresh_negative_prices import refresh_negative_price_predictions
|
||||
from app.ws_log_handler import WSLogHandler
|
||||
from services.audit_filler import fill_audit_for_completed_intervals
|
||||
from services.plan_actual_slot_guard import run_plan_actual_slot_guard_for_all_active_sites
|
||||
from services.control_exporter import export_setpoints, verify_modbus_commands
|
||||
from services.forecast_service import fetch_pv_forecast
|
||||
from services.heartbeat_service import send_heartbeat
|
||||
from services.notification_service import notify_operating_mode_changed
|
||||
from services.price_importer import import_ote_prices, ote_prague_day_slots_look_complete
|
||||
from services.telemetry_collector import run_telemetry_loop_wrapper
|
||||
from services.signal_service import (
|
||||
run_signal_outbound_send_for_active_sites,
|
||||
run_signal_outbound_verify_for_active_sites,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = AsyncIOScheduler(timezone=ZoneInfo("Europe/Prague"))
|
||||
|
||||
|
||||
def _dsn() -> str:
|
||||
host = os.getenv("DB_HOST", "localhost")
|
||||
port = os.getenv("DB_PORT", "5432")
|
||||
name = os.getenv("DB_NAME", "ems")
|
||||
user = os.getenv("DB_USER", "ems_user")
|
||||
password = os.getenv("DB_PASSWORD", "")
|
||||
return f"postgresql://{user}:{password}@{host}:{port}/{name}"
|
||||
|
||||
|
||||
async def _active_site_rows(conn: asyncpg.Connection) -> list[dict[str, Any]]:
|
||||
raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()")
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [x for x in raw if isinstance(x, dict)]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
pg_pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5)
|
||||
set_pg_pool(pg_pool)
|
||||
app.state.pg_pool = pg_pool
|
||||
|
||||
# Fail fast if Flyway routines are missing (otherwise heartbeat silently goes stale in FE).
|
||||
async with pg_pool.acquire() as conn:
|
||||
fn_ok = await conn.fetchval(
|
||||
"""
|
||||
select exists(
|
||||
select 1
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'ems'
|
||||
and p.proname = 'fn_update_heartbeat'
|
||||
)
|
||||
"""
|
||||
)
|
||||
if not fn_ok:
|
||||
raise RuntimeError("Missing DB routine: ems.fn_update_heartbeat")
|
||||
|
||||
app.state.ws_log_handler = WSLogHandler()
|
||||
app.state.ws_log_handler.setLevel(logging.INFO)
|
||||
logging.getLogger().addHandler(app.state.ws_log_handler)
|
||||
|
||||
from services.planning_engine import run_daily_plan, run_rolling_replan
|
||||
|
||||
async def scheduled_heartbeat() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
try:
|
||||
await send_heartbeat(int(site["id"]), conn)
|
||||
except Exception:
|
||||
logger.exception("scheduled_heartbeat site=%s failed", site["id"])
|
||||
|
||||
async def scheduled_audit_filler() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
try:
|
||||
await fill_audit_for_completed_intervals(int(site["id"]), conn)
|
||||
except Exception:
|
||||
logger.exception("scheduled_audit_filler site=%s failed", site["id"])
|
||||
|
||||
async def scheduled_plan_actual_slot_guard() -> None:
|
||||
"""Po audit filleru: fatální odchylka plán vs. skutečnost (síť) → Discord (dedup v DB)."""
|
||||
try:
|
||||
await run_plan_actual_slot_guard_for_all_active_sites(app.state.pg_pool)
|
||||
except Exception:
|
||||
logger.exception("scheduled_plan_actual_slot_guard failed")
|
||||
|
||||
async def scheduled_forecast_accuracy() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
try:
|
||||
n = await conn.fetchval(
|
||||
"SELECT ems.fn_fill_forecast_accuracy($1, 48)",
|
||||
site["id"],
|
||||
)
|
||||
if n:
|
||||
logger.info(
|
||||
"forecast_accuracy filled %s slots for site %s",
|
||||
n,
|
||||
site["id"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"scheduled_forecast_accuracy site=%s failed", site["id"]
|
||||
)
|
||||
|
||||
async def scheduled_expire_modes() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
try:
|
||||
rows = await conn.fetch("SELECT * FROM ems.fn_expire_modes()")
|
||||
for r in rows:
|
||||
await notify_operating_mode_changed(
|
||||
conn,
|
||||
int(r["site_id"]) if r.get("site_id") is not None else None,
|
||||
str(r["site_code"]),
|
||||
str(r["old_mode"]),
|
||||
str(r["new_mode"]),
|
||||
"system:expiry",
|
||||
"Automatické vypršení dočasného režimu",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("scheduled_expire_modes failed")
|
||||
|
||||
async def scheduled_control_export() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
try:
|
||||
await export_setpoints(int(site["id"]), conn)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"scheduled_control_export site=%s: %s", site["id"], e
|
||||
)
|
||||
|
||||
async def scheduled_signal_outbound_send() -> None:
|
||||
try:
|
||||
await run_signal_outbound_send_for_active_sites(app.state.pg_pool)
|
||||
except Exception:
|
||||
logger.exception("scheduled_signal_outbound_send failed")
|
||||
|
||||
async def scheduled_signal_outbound_verify() -> None:
|
||||
try:
|
||||
await run_signal_outbound_verify_for_active_sites(app.state.pg_pool)
|
||||
except Exception:
|
||||
logger.exception("scheduled_signal_outbound_verify failed")
|
||||
|
||||
async def scheduled_verify_modbus() -> None:
|
||||
"""
|
||||
Ověří příkazy ve stavu written z posledních 20 minut.
|
||||
Běží každé 2 minuty, nezávisle na control_exporter (delší okno kvůli zpoždění jobu).
|
||||
"""
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
id_json = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_modbus_written_command_ids($1::int, interval '20 minutes')",
|
||||
site_id,
|
||||
)
|
||||
if not isinstance(id_json, list):
|
||||
id_json = []
|
||||
ids = [int(x) for x in id_json]
|
||||
if ids:
|
||||
await verify_modbus_commands(ids, conn, site_id)
|
||||
except Exception:
|
||||
logger.exception("scheduled_verify_modbus site=%s failed", site_id)
|
||||
|
||||
async def scheduled_daily_plan() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
await run_daily_plan(site_id, conn)
|
||||
await export_setpoints(site_id, conn)
|
||||
except Exception:
|
||||
logger.exception("scheduled_daily_plan site=%s failed", site_id)
|
||||
|
||||
async def scheduled_rolling_replan() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
await run_rolling_replan(site_id, conn)
|
||||
await export_setpoints(site_id, conn)
|
||||
except Exception:
|
||||
logger.exception("scheduled_rolling_replan site=%s failed", site_id)
|
||||
|
||||
async def scheduled_baseline_update() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
try:
|
||||
n = await conn.fetchval(
|
||||
"SELECT ems.fn_update_baseline_stats($1, 30)",
|
||||
site["id"],
|
||||
)
|
||||
logger.info(
|
||||
"baseline_stats updated %s rows for site %s",
|
||||
n,
|
||||
site["id"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"scheduled_baseline_update site=%s failed", site["id"]
|
||||
)
|
||||
|
||||
async def scheduled_market_price_stats() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
try:
|
||||
n = await conn.fetchval(
|
||||
"SELECT ems.fn_update_market_price_stats($1, 90)",
|
||||
site["id"],
|
||||
)
|
||||
logger.info(
|
||||
"market_price_stats updated %s rows site=%s",
|
||||
n,
|
||||
site["id"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"scheduled_market_price_stats site=%s failed", site["id"]
|
||||
)
|
||||
|
||||
async def scheduled_tuv_usage_stats() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
try:
|
||||
n = await conn.fetchval(
|
||||
"SELECT ems.fn_update_tuv_usage_stats($1, 30)",
|
||||
site["id"],
|
||||
)
|
||||
logger.info(
|
||||
"tuv_usage_stats updated %s rows site=%s",
|
||||
n,
|
||||
site["id"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"scheduled_tuv_usage_stats site=%s failed", site["id"]
|
||||
)
|
||||
|
||||
async def scheduled_forecast_refresh() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
|
||||
if intervals >= 0:
|
||||
logger.info(
|
||||
"scheduled_forecast_refresh site=%s intervals=%s arrays=%s",
|
||||
site_id,
|
||||
intervals,
|
||||
pv_arrays,
|
||||
)
|
||||
await refresh_negative_price_predictions(conn, site_id)
|
||||
else:
|
||||
logger.warning(
|
||||
"scheduled_forecast_refresh site=%s failed",
|
||||
site_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("scheduled_forecast_refresh site=%s failed", site_id)
|
||||
|
||||
async def _count_ote_slots_for_day(
|
||||
conn: asyncpg.Connection, target_day: date
|
||||
) -> int:
|
||||
return int(
|
||||
await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*)::int
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source = 'OTE_CZ'
|
||||
AND interval_start::date = $1::date
|
||||
""",
|
||||
target_day,
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
async def _refresh_negative_price_predictions_all_active(
|
||||
conn: asyncpg.Connection,
|
||||
) -> None:
|
||||
for site in await _active_site_rows(conn):
|
||||
await refresh_negative_price_predictions(conn, int(site["id"]))
|
||||
|
||||
async def _scheduled_ote_import_global(conn: asyncpg.Connection) -> None:
|
||||
"""Jeden OTE fetch na chybějící den; market_interval_price je globální pro všechny site."""
|
||||
prague_tz = ZoneInfo("Europe/Prague")
|
||||
now_loc = datetime.now(prague_tz)
|
||||
today = now_loc.date()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
any_import_ok = False
|
||||
|
||||
for day in (today, tomorrow):
|
||||
slots = await _count_ote_slots_for_day(conn, day)
|
||||
if ote_prague_day_slots_look_complete(slots):
|
||||
continue
|
||||
n, imported_day, _, err = await import_ote_prices(
|
||||
conn, site_id=None, target_date=day
|
||||
)
|
||||
if n < 0:
|
||||
logger.warning(
|
||||
"scheduled_ote_import_global day=%s failed (%s)",
|
||||
day.isoformat(),
|
||||
err,
|
||||
)
|
||||
continue
|
||||
logger.info(
|
||||
"scheduled_ote_import_global day=%s imported=%s slots",
|
||||
imported_day,
|
||||
n,
|
||||
)
|
||||
any_import_ok = True
|
||||
|
||||
if any_import_ok:
|
||||
await _refresh_negative_price_predictions_all_active(conn)
|
||||
|
||||
async def scheduled_ote_import() -> None:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
try:
|
||||
await _scheduled_ote_import_global(conn)
|
||||
except Exception:
|
||||
logger.exception("scheduled_ote_import_global failed")
|
||||
|
||||
scheduler.add_job(scheduled_heartbeat, "interval", seconds=60, id="heartbeat")
|
||||
scheduler.add_job(
|
||||
scheduled_audit_filler,
|
||||
"cron",
|
||||
minute="1,16,31,46",
|
||||
second=0,
|
||||
id="audit_filler",
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_plan_actual_slot_guard,
|
||||
"cron",
|
||||
minute="5,20,35,50",
|
||||
second=0,
|
||||
id="plan_actual_slot_guard",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_forecast_accuracy,
|
||||
"cron",
|
||||
minute="2,17,32,47",
|
||||
id="forecast_accuracy",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(scheduled_expire_modes, "interval", minutes=1, id="expire_modes")
|
||||
scheduler.add_job(
|
||||
scheduled_control_export,
|
||||
"cron",
|
||||
minute="14,29,44,59",
|
||||
second=0,
|
||||
id="control_export",
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_verify_modbus,
|
||||
"interval",
|
||||
minutes=2,
|
||||
id="verify_modbus",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_signal_outbound_send,
|
||||
"interval",
|
||||
seconds=15,
|
||||
id="signal_outbound_send",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_signal_outbound_verify,
|
||||
"interval",
|
||||
seconds=15,
|
||||
id="signal_outbound_verify",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan")
|
||||
scheduler.add_job(
|
||||
scheduled_rolling_replan,
|
||||
"cron",
|
||||
minute="*/15",
|
||||
id="rolling_replan",
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_baseline_update,
|
||||
"cron",
|
||||
hour=0,
|
||||
minute=30,
|
||||
id="baseline_update",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_market_price_stats,
|
||||
"cron",
|
||||
hour=14,
|
||||
minute=45,
|
||||
id="market_price_stats",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_tuv_usage_stats,
|
||||
"cron",
|
||||
hour=0,
|
||||
minute=45,
|
||||
id="tuv_usage_stats",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_ote_import,
|
||||
"cron",
|
||||
hour=13,
|
||||
minute=25,
|
||||
id="ote_import_preopen",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_ote_import,
|
||||
"cron",
|
||||
hour="13,14",
|
||||
minute=12,
|
||||
id="ote_import_retry_early",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_ote_import,
|
||||
"cron",
|
||||
hour="13,14",
|
||||
minute=45,
|
||||
id="ote_import_retry_late",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_ote_import,
|
||||
"cron",
|
||||
hour=14,
|
||||
minute=0,
|
||||
id="ote_import_main",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_ote_import,
|
||||
"cron",
|
||||
hour=0,
|
||||
minute=5,
|
||||
id="ote_import_backfill",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_forecast_refresh,
|
||||
"cron",
|
||||
hour="*/2",
|
||||
minute=5,
|
||||
id="forecast_refresh_2h",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
async def scheduled_daily_economics_notification() -> None:
|
||||
from services.notification_service import notify_daily_economics
|
||||
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
for site in await _active_site_rows(conn):
|
||||
site_id = int(site["id"])
|
||||
site_code = str(site["code"])
|
||||
try:
|
||||
row = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_economics_yesterday_notification($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if row is None or not isinstance(row, dict) or not row:
|
||||
continue
|
||||
yesterday = (
|
||||
datetime.now(ZoneInfo("Europe/Prague")) - timedelta(days=1)
|
||||
).strftime("%Y-%m-%d")
|
||||
await notify_daily_economics(
|
||||
conn,
|
||||
site_id,
|
||||
site_code=site_code,
|
||||
day=yesterday,
|
||||
import_kwh=float(row.get("import_kwh") or 0),
|
||||
import_cost=float(row.get("import_cost_czk") or 0),
|
||||
export_kwh=float(row.get("export_kwh") or 0),
|
||||
export_revenue=float(row.get("export_revenue_czk") or 0),
|
||||
green_bonus=float(row.get("green_bonus_czk") or 0),
|
||||
total_balance=float(row.get("total_balance_czk") or 0),
|
||||
planned_balance=float(row["planned_balance_czk"])
|
||||
if row.get("planned_balance_czk") is not None
|
||||
else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"scheduled_daily_economics_notification site=%s failed",
|
||||
site_id,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
scheduled_daily_economics_notification,
|
||||
"cron",
|
||||
hour=7,
|
||||
minute=0,
|
||||
id="daily_economics_notification",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
|
||||
app.state.telemetry_task = telemetry_task
|
||||
|
||||
yield
|
||||
|
||||
ws_h = getattr(app.state, "ws_log_handler", None)
|
||||
if ws_h is not None:
|
||||
logging.getLogger().removeHandler(ws_h)
|
||||
app.state.ws_log_handler = None
|
||||
|
||||
telemetry_task.cancel()
|
||||
try:
|
||||
await telemetry_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
scheduler.shutdown(wait=False)
|
||||
set_pg_pool(None)
|
||||
app.state.pg_pool = None
|
||||
await pg_pool.close()
|
||||
1219
backend/app/main.py
1219
backend/app/main.py
File diff suppressed because it is too large
Load Diff
22
backend/app/refresh_negative_prices.py
Normal file
22
backend/app/refresh_negative_prices.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Sdílený hook po importu cen / forecastu – obnova cache predikce záporných cen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def refresh_negative_price_predictions(conn: asyncpg.Connection, site_id: int) -> None:
|
||||
try:
|
||||
await conn.fetch(
|
||||
"SELECT * FROM ems.fn_predict_negative_price_windows($1, 7)", site_id
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"fn_predict_negative_price_windows failed for site %s",
|
||||
site_id,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Any
|
||||
@@ -10,6 +11,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(
|
||||
@@ -27,11 +29,13 @@ class DailyEconomics(BaseModel):
|
||||
export_kwh: float
|
||||
pv_kwh: float
|
||||
load_kwh: float
|
||||
self_consumption_kwh: float
|
||||
pv_self_consumption_kwh: float
|
||||
ev_kwh: float
|
||||
hp_kwh: float
|
||||
import_cost_czk: float
|
||||
export_revenue_czk: float
|
||||
grid_import_cashflow_czk: float
|
||||
grid_export_revenue_czk: float
|
||||
net_cost_czk: float
|
||||
green_bonus_czk: float
|
||||
total_balance_czk: float
|
||||
@@ -50,6 +54,8 @@ class IntervalEconomics(BaseModel):
|
||||
import_kwh: float
|
||||
export_kwh: float
|
||||
dynamic_cost_czk: float | None
|
||||
grid_import_cashflow_czk: float | None
|
||||
grid_export_revenue_czk: float | None
|
||||
stored_cost_czk: float | None
|
||||
green_bonus_czk: float | None
|
||||
planned_cost_czk: float | None
|
||||
@@ -68,7 +74,12 @@ class IntervalEconomics(BaseModel):
|
||||
class ChartDayPoint(BaseModel):
|
||||
day: date
|
||||
daily_balance_czk: float
|
||||
daily_grid_balance_czk: float
|
||||
daily_green_bonus_czk: float
|
||||
daily_import_cost_czk: float
|
||||
daily_export_revenue_czk: float
|
||||
cumulative_balance_czk: float
|
||||
cumulative_grid_balance_czk: float
|
||||
|
||||
|
||||
class LockResponse(BaseModel):
|
||||
@@ -82,6 +93,12 @@ def _num(val: Any) -> float:
|
||||
return float(val)
|
||||
|
||||
|
||||
def _opt(val: Any) -> float | None:
|
||||
if val is None:
|
||||
return None
|
||||
return float(val)
|
||||
|
||||
|
||||
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
||||
ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
@@ -90,19 +107,14 @@ async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
|
||||
async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool:
|
||||
return bool(
|
||||
await conn.fetchval(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ems.asset_pv_array
|
||||
WHERE site_id = $1
|
||||
AND green_bonus_czk_kwh IS NOT NULL
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
)
|
||||
def _parse_day(val: Any) -> date:
|
||||
if isinstance(val, datetime):
|
||||
return val.date()
|
||||
if isinstance(val, date):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return date.fromisoformat(val[:10])
|
||||
raise ValueError(val)
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyEconomicsResponse)
|
||||
@@ -127,84 +139,47 @@ async def get_economics_daily(
|
||||
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
has_bonus = await _has_green_bonus(conn, site_id)
|
||||
|
||||
dyn_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
ORDER BY day_local
|
||||
""",
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_economics_daily_month($1::int, $2::date, $3::date)",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
|
||||
lock_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM ems.audit_day_lock
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
""",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
locks = {r["day_local"]: r for r in lock_rows}
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
days_in: list[Any] = list(raw.get("days") or [])
|
||||
days: list[DailyEconomics] = []
|
||||
for r in dyn_rows:
|
||||
d = r["day_local"]
|
||||
lock = locks.get(d)
|
||||
if lock:
|
||||
days.append(
|
||||
DailyEconomics(
|
||||
day=d,
|
||||
interval_count=r["interval_count"],
|
||||
import_kwh=_num(r["import_kwh"]),
|
||||
export_kwh=_num(r["export_kwh"]),
|
||||
pv_kwh=_num(r["pv_kwh"]),
|
||||
load_kwh=_num(r["load_kwh"]),
|
||||
self_consumption_kwh=_num(r["self_consumption_kwh"]),
|
||||
ev_kwh=_num(r["ev_kwh"]),
|
||||
hp_kwh=_num(r["hp_kwh"]),
|
||||
import_cost_czk=_num(lock["import_cost_czk"]),
|
||||
export_revenue_czk=_num(lock["export_revenue_czk"]),
|
||||
net_cost_czk=_num(lock["net_cost_czk"]),
|
||||
green_bonus_czk=_num(lock["green_bonus_czk"]),
|
||||
total_balance_czk=_num(lock["total_balance_czk"]),
|
||||
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
|
||||
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
|
||||
is_locked=True,
|
||||
)
|
||||
for d in days_in:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
days.append(
|
||||
DailyEconomics(
|
||||
day=_parse_day(d.get("day")),
|
||||
interval_count=int(d.get("interval_count") or 0),
|
||||
import_kwh=_num(d.get("import_kwh")),
|
||||
export_kwh=_num(d.get("export_kwh")),
|
||||
pv_kwh=_num(d.get("pv_kwh")),
|
||||
load_kwh=_num(d.get("load_kwh")),
|
||||
pv_self_consumption_kwh=_num(d.get("pv_self_consumption_kwh")),
|
||||
ev_kwh=_num(d.get("ev_kwh")),
|
||||
hp_kwh=_num(d.get("hp_kwh")),
|
||||
import_cost_czk=_num(d.get("import_cost_czk")),
|
||||
export_revenue_czk=_num(d.get("export_revenue_czk")),
|
||||
grid_import_cashflow_czk=_num(d.get("grid_import_cashflow_czk")),
|
||||
grid_export_revenue_czk=_num(d.get("grid_export_revenue_czk")),
|
||||
net_cost_czk=_num(d.get("net_cost_czk")),
|
||||
green_bonus_czk=_num(d.get("green_bonus_czk")),
|
||||
total_balance_czk=_num(d.get("total_balance_czk")),
|
||||
planned_balance_czk=_opt(d.get("planned_balance_czk")),
|
||||
deviation_cost_czk=_opt(d.get("deviation_cost_czk")),
|
||||
is_locked=bool(d.get("is_locked")),
|
||||
)
|
||||
else:
|
||||
days.append(
|
||||
DailyEconomics(
|
||||
day=d,
|
||||
interval_count=r["interval_count"],
|
||||
import_kwh=_num(r["import_kwh"]),
|
||||
export_kwh=_num(r["export_kwh"]),
|
||||
pv_kwh=_num(r["pv_kwh"]),
|
||||
load_kwh=_num(r["load_kwh"]),
|
||||
self_consumption_kwh=_num(r["self_consumption_kwh"]),
|
||||
ev_kwh=_num(r["ev_kwh"]),
|
||||
hp_kwh=_num(r["hp_kwh"]),
|
||||
import_cost_czk=_num(r["import_cost_czk"]),
|
||||
export_revenue_czk=_num(r["export_revenue_czk"]),
|
||||
net_cost_czk=_num(r["net_cost_czk"]),
|
||||
green_bonus_czk=_num(r["green_bonus_czk"]),
|
||||
total_balance_czk=_num(r["total_balance_czk"]),
|
||||
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
|
||||
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
|
||||
is_locked=False,
|
||||
)
|
||||
)
|
||||
|
||||
return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus)
|
||||
)
|
||||
return DailyEconomicsResponse(
|
||||
days=days,
|
||||
has_green_bonus=bool(raw.get("has_green_bonus")),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics])
|
||||
@@ -232,20 +207,22 @@ async def get_economics_intervals(
|
||||
interval_start=r["interval_start"].isoformat(),
|
||||
import_kwh=_num(r["import_kwh"]),
|
||||
export_kwh=_num(r["export_kwh"]),
|
||||
dynamic_cost_czk=float(r["dynamic_cost_czk"]) if r["dynamic_cost_czk"] is not None else None,
|
||||
stored_cost_czk=float(r["stored_cost_czk"]) if r["stored_cost_czk"] is not None else None,
|
||||
green_bonus_czk=float(r["green_bonus_czk"]) if r["green_bonus_czk"] is not None else None,
|
||||
planned_cost_czk=float(r["planned_cost_czk"]) if r["planned_cost_czk"] is not None else None,
|
||||
dynamic_cost_czk=_opt(r["dynamic_cost_czk"]),
|
||||
grid_import_cashflow_czk=_opt(r["grid_import_cashflow_czk"]),
|
||||
grid_export_revenue_czk=_opt(r["grid_export_revenue_czk"]),
|
||||
stored_cost_czk=_opt(r["stored_cost_czk"]),
|
||||
green_bonus_czk=_opt(r["green_bonus_czk"]),
|
||||
planned_cost_czk=_opt(r["planned_cost_czk"]),
|
||||
planned_grid_w=int(r["planned_grid_w"]) if r["planned_grid_w"] is not None else None,
|
||||
actual_grid_power_w=int(r["actual_grid_power_w"]) if r["actual_grid_power_w"] is not None else None,
|
||||
effective_buy_price=float(r["effective_buy_price_czk_kwh"]) if r["effective_buy_price_czk_kwh"] is not None else None,
|
||||
effective_sell_price=float(r["effective_sell_price_czk_kwh"]) if r["effective_sell_price_czk_kwh"] is not None else None,
|
||||
planned_buy_price=float(r["planned_buy_price"]) if r["planned_buy_price"] is not None else None,
|
||||
planned_sell_price=float(r["planned_sell_price"]) if r["planned_sell_price"] is not None else None,
|
||||
effective_buy_price=_opt(r["effective_buy_price_czk_kwh"]),
|
||||
effective_sell_price=_opt(r["effective_sell_price_czk_kwh"]),
|
||||
planned_buy_price=_opt(r["planned_buy_price"]),
|
||||
planned_sell_price=_opt(r["planned_sell_price"]),
|
||||
actual_pv_power_w=int(r["actual_pv_power_w"]) if r["actual_pv_power_w"] is not None else None,
|
||||
actual_load_power_w=int(r["actual_load_power_w"]) if r["actual_load_power_w"] is not None else None,
|
||||
actual_battery_power_w=int(r["actual_battery_power_w"]) if r["actual_battery_power_w"] is not None else None,
|
||||
actual_battery_soc_pct=float(r["actual_battery_soc_pct"]) if r["actual_battery_soc_pct"] is not None else None,
|
||||
actual_battery_soc_pct=_opt(r["actual_battery_soc_pct"]),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
@@ -259,44 +236,18 @@ async def lock_day(
|
||||
) -> LockResponse:
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT import_cost_czk, export_revenue_czk, net_cost_czk,
|
||||
green_bonus_czk, total_balance_czk
|
||||
FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1 AND day_local = $2
|
||||
""",
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_economics_lock_day($1::int, $2::date)",
|
||||
site_id,
|
||||
day,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No economics data for {day.isoformat()}",
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.audit_day_lock
|
||||
(site_id, day_local, import_cost_czk, export_revenue_czk,
|
||||
net_cost_czk, green_bonus_czk, total_balance_czk)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (site_id, day_local) DO UPDATE SET
|
||||
import_cost_czk = EXCLUDED.import_cost_czk,
|
||||
export_revenue_czk = EXCLUDED.export_revenue_czk,
|
||||
net_cost_czk = EXCLUDED.net_cost_czk,
|
||||
green_bonus_czk = EXCLUDED.green_bonus_czk,
|
||||
total_balance_czk = EXCLUDED.total_balance_czk,
|
||||
locked_at = now()
|
||||
""",
|
||||
site_id,
|
||||
day,
|
||||
row["import_cost_czk"],
|
||||
row["export_revenue_czk"],
|
||||
row["net_cost_czk"],
|
||||
row["green_bonus_czk"],
|
||||
row["total_balance_czk"],
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
if raw.get("locked") is not True:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No economics data for {day.isoformat()}",
|
||||
)
|
||||
|
||||
return LockResponse(locked=True, day=day)
|
||||
@@ -310,8 +261,9 @@ async def unlock_day(
|
||||
) -> LockResponse:
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM ems.audit_day_lock WHERE site_id = $1 AND day_local = $2",
|
||||
await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_economics_unlock_day($1::int, $2::date)",
|
||||
site_id,
|
||||
day,
|
||||
)
|
||||
@@ -340,47 +292,29 @@ async def get_monthly_chart(
|
||||
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT day_local, total_balance_czk
|
||||
FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
ORDER BY day_local
|
||||
""",
|
||||
arr = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_economics_monthly_chart($1::int, $2::date, $3::date)",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
|
||||
lock_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT day_local, total_balance_czk
|
||||
FROM ems.audit_day_lock
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
""",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
locks = {r["day_local"]: _num(r["total_balance_czk"]) for r in lock_rows}
|
||||
|
||||
if not isinstance(arr, list):
|
||||
arr = json.loads(arr) if isinstance(arr, str) else []
|
||||
points: list[ChartDayPoint] = []
|
||||
cumulative = 0.0
|
||||
for r in rows:
|
||||
d = r["day_local"]
|
||||
balance = locks.get(d, _num(r["total_balance_czk"]))
|
||||
cumulative += balance
|
||||
for r in arr:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
points.append(
|
||||
ChartDayPoint(
|
||||
day=d,
|
||||
daily_balance_czk=round(balance, 2),
|
||||
cumulative_balance_czk=round(cumulative, 2),
|
||||
day=_parse_day(r.get("day")),
|
||||
daily_balance_czk=float(r.get("daily_balance_czk") or 0),
|
||||
daily_grid_balance_czk=float(r.get("daily_grid_balance_czk") or 0),
|
||||
daily_green_bonus_czk=float(r.get("daily_green_bonus_czk") or 0),
|
||||
daily_import_cost_czk=float(r.get("daily_import_cost_czk") or 0),
|
||||
daily_export_revenue_czk=float(r.get("daily_export_revenue_czk") or 0),
|
||||
cumulative_balance_czk=float(r.get("cumulative_balance_czk") or 0),
|
||||
cumulative_grid_balance_czk=float(r.get("cumulative_grid_balance_czk") or 0),
|
||||
)
|
||||
)
|
||||
|
||||
return points
|
||||
|
||||
192
backend/app/routers/energy_flows.py
Normal file
192
backend/app/routers/energy_flows.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""REST API – analýza energetických toků (modelované toky z audit_interval)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Annotated, Any
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/sites/{site_id}/energy-flows",
|
||||
tags=["energy-flows"],
|
||||
)
|
||||
|
||||
|
||||
class DailyEnergyFlows(BaseModel):
|
||||
day: date
|
||||
interval_count: int
|
||||
pv_production_kwh: float
|
||||
grid_import_kwh: float
|
||||
grid_export_kwh: float
|
||||
batt_charge_kwh: float
|
||||
batt_discharge_kwh: float
|
||||
load_kwh: float
|
||||
pv_to_load_kwh: float
|
||||
pv_to_batt_kwh: float
|
||||
pv_to_grid_kwh: float
|
||||
batt_to_load_kwh: float
|
||||
batt_to_grid_kwh: float
|
||||
grid_to_load_kwh: float
|
||||
grid_to_batt_kwh: float
|
||||
grid_import_cashflow_czk: float
|
||||
grid_export_revenue_czk: float
|
||||
grid_to_load_cost_czk: float
|
||||
grid_to_batt_cost_czk: float
|
||||
|
||||
|
||||
class DailyEnergyFlowsResponse(BaseModel):
|
||||
days: list[DailyEnergyFlows]
|
||||
|
||||
|
||||
class IntervalEnergyFlows(BaseModel):
|
||||
interval_start: str
|
||||
pv_production_kwh: float | None
|
||||
grid_import_kwh: float | None
|
||||
grid_export_kwh: float | None
|
||||
batt_charge_kwh: float | None
|
||||
batt_discharge_kwh: float | None
|
||||
load_kwh: float | None
|
||||
pv_to_load_kwh: float | None
|
||||
pv_to_batt_kwh: float | None
|
||||
pv_to_grid_kwh: float | None
|
||||
batt_to_load_kwh: float | None
|
||||
batt_to_grid_kwh: float | None
|
||||
grid_to_load_kwh: float | None
|
||||
grid_to_batt_kwh: float | None
|
||||
|
||||
|
||||
def _num(val: Any) -> float:
|
||||
if val is None:
|
||||
return 0.0
|
||||
return float(val)
|
||||
|
||||
|
||||
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
||||
ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
|
||||
def _parse_day(val: Any) -> date:
|
||||
from datetime import datetime as _dt
|
||||
|
||||
if isinstance(val, _dt):
|
||||
return val.date()
|
||||
if isinstance(val, date):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return date.fromisoformat(val[:10])
|
||||
raise ValueError(val)
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyEnergyFlowsResponse)
|
||||
async def get_energy_flows_daily(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
month: str = Query(
|
||||
...,
|
||||
description="YYYY-MM",
|
||||
pattern=r"^\d{4}-\d{2}$",
|
||||
),
|
||||
) -> DailyEnergyFlowsResponse:
|
||||
try:
|
||||
year, mon = month.split("-")
|
||||
month_start = date(int(year), int(mon), 1)
|
||||
if int(mon) == 12:
|
||||
month_end = date(int(year) + 1, 1, 1)
|
||||
else:
|
||||
month_end = date(int(year), int(mon) + 1, 1)
|
||||
except (ValueError, IndexError):
|
||||
raise HTTPException(status_code=400, detail="Invalid month, expected YYYY-MM")
|
||||
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_energy_flows_daily_month($1::int, $2::date, $3::date)",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
rows = raw.get("days") or []
|
||||
days: list[DailyEnergyFlows] = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
days.append(
|
||||
DailyEnergyFlows(
|
||||
day=_parse_day(r.get("day")),
|
||||
interval_count=int(r.get("interval_count") or 0),
|
||||
pv_production_kwh=_num(r.get("pv_production_kwh")),
|
||||
grid_import_kwh=_num(r.get("grid_import_kwh")),
|
||||
grid_export_kwh=_num(r.get("grid_export_kwh")),
|
||||
batt_charge_kwh=_num(r.get("batt_charge_kwh")),
|
||||
batt_discharge_kwh=_num(r.get("batt_discharge_kwh")),
|
||||
load_kwh=_num(r.get("load_kwh")),
|
||||
pv_to_load_kwh=_num(r.get("pv_to_load_kwh")),
|
||||
pv_to_batt_kwh=_num(r.get("pv_to_batt_kwh")),
|
||||
pv_to_grid_kwh=_num(r.get("pv_to_grid_kwh")),
|
||||
batt_to_load_kwh=_num(r.get("batt_to_load_kwh")),
|
||||
batt_to_grid_kwh=_num(r.get("batt_to_grid_kwh")),
|
||||
grid_to_load_kwh=_num(r.get("grid_to_load_kwh")),
|
||||
grid_to_batt_kwh=_num(r.get("grid_to_batt_kwh")),
|
||||
grid_import_cashflow_czk=_num(r.get("grid_import_cashflow_czk")),
|
||||
grid_export_revenue_czk=_num(r.get("grid_export_revenue_czk")),
|
||||
grid_to_load_cost_czk=_num(r.get("grid_to_load_cost_czk")),
|
||||
grid_to_batt_cost_czk=_num(r.get("grid_to_batt_cost_czk")),
|
||||
)
|
||||
)
|
||||
return DailyEnergyFlowsResponse(days=days)
|
||||
|
||||
|
||||
@router.get("/daily/{day}/intervals", response_model=list[IntervalEnergyFlows])
|
||||
async def get_energy_flows_intervals(
|
||||
site_id: int,
|
||||
day: date,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> list[IntervalEnergyFlows]:
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
rows = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_energy_flows_intervals_day($1::int, $2::date)",
|
||||
site_id,
|
||||
day,
|
||||
)
|
||||
if not isinstance(rows, list):
|
||||
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||
out: list[IntervalEnergyFlows] = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
ist = r.get("interval_start")
|
||||
out.append(
|
||||
IntervalEnergyFlows(
|
||||
interval_start=ist if isinstance(ist, str) else str(ist),
|
||||
pv_production_kwh=r.get("pv_production_kwh"),
|
||||
grid_import_kwh=r.get("grid_import_kwh"),
|
||||
grid_export_kwh=r.get("grid_export_kwh"),
|
||||
batt_charge_kwh=r.get("batt_charge_kwh"),
|
||||
batt_discharge_kwh=r.get("batt_discharge_kwh"),
|
||||
load_kwh=r.get("load_kwh"),
|
||||
pv_to_load_kwh=r.get("pv_to_load_kwh"),
|
||||
pv_to_batt_kwh=r.get("pv_to_batt_kwh"),
|
||||
pv_to_grid_kwh=r.get("pv_to_grid_kwh"),
|
||||
batt_to_load_kwh=r.get("batt_to_load_kwh"),
|
||||
batt_to_grid_kwh=r.get("batt_to_grid_kwh"),
|
||||
grid_to_load_kwh=r.get("grid_to_load_kwh"),
|
||||
grid_to_batt_kwh=r.get("grid_to_batt_kwh"),
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
@@ -9,7 +10,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}/ev", tags=["ev"])
|
||||
@@ -38,30 +39,19 @@ async def get_active_ev_sessions(
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> list[dict[str, Any]]:
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT es.id, es.charger_id, es.vehicle_id,
|
||||
es.session_start, es.energy_delivered_wh,
|
||||
es.target_soc_pct, es.target_deadline,
|
||||
av.make, av.model, av.battery_capacity_kwh,
|
||||
av.default_target_soc_pct, av.default_deadline_hour,
|
||||
ac.code AS charger_code,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT_WS(' ', ac.manufacturer, ac.model)), ''),
|
||||
ac.code
|
||||
) AS charger_name
|
||||
FROM ems.ev_session es
|
||||
LEFT JOIN ems.asset_vehicle av ON av.id = es.vehicle_id
|
||||
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
|
||||
WHERE es.site_id = $1 AND es.session_end IS NULL
|
||||
ORDER BY es.session_start DESC
|
||||
""",
|
||||
rows = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ev_sessions_active($1::int)",
|
||||
site_id,
|
||||
)
|
||||
return [record_to_dict(r) for r in rows]
|
||||
if not isinstance(rows, list):
|
||||
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
@router.patch("/sessions/{session_id}", response_model=EvSessionPatchResponse)
|
||||
@@ -72,25 +62,25 @@ async def patch_ev_session(
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> EvSessionPatchResponse:
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE ems.ev_session
|
||||
SET target_soc_pct = $1, target_deadline = $2
|
||||
WHERE id = $3 AND site_id = $4
|
||||
RETURNING id
|
||||
""",
|
||||
body.target_soc_pct,
|
||||
body.target_deadline,
|
||||
session_id,
|
||||
patch = body.model_dump(exclude_unset=True)
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
|
||||
site_id,
|
||||
session_id,
|
||||
json.dumps(patch),
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return EvSessionPatchResponse(success=True, session_id=int(row["id"]))
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
if not raw.get("success"):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return EvSessionPatchResponse(success=True, session_id=int(raw["session_id"]))
|
||||
|
||||
|
||||
class ArrivalHourItem(BaseModel):
|
||||
@@ -114,65 +104,48 @@ async def get_ev_arrival_prediction(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> EvArrivalPredictionResponse:
|
||||
"""Top hodiny příjezdu z ems.fn_ev_expected_arrival; při <5 session celkem insufficient_data."""
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
n_sessions = int(
|
||||
await conn.fetchval(
|
||||
"SELECT COUNT(*)::int FROM ems.ev_session WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
or 0
|
||||
)
|
||||
insufficient = n_sessions < 5
|
||||
|
||||
tomorrow = await conn.fetchval(
|
||||
"""
|
||||
SELECT (
|
||||
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
|
||||
NULLIF(TRIM(timezone), ''),
|
||||
'Europe/Prague'
|
||||
)
|
||||
)::date + 1
|
||||
FROM ems.site
|
||||
WHERE id = $1
|
||||
""",
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ev_arrival_prediction_bundle($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if tomorrow is None:
|
||||
raise HTTPException(status_code=500, detail="Site date resolution failed")
|
||||
tomorrow_d: date = tomorrow
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
if raw.get("error") == "site_not_found":
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
chargers_rows = await conn.fetch(
|
||||
"SELECT id, code FROM ems.asset_ev_charger WHERE site_id = $1 ORDER BY id",
|
||||
site_id,
|
||||
)
|
||||
|
||||
chargers: dict[str, ChargerTomorrowArrival] = {}
|
||||
for ch in chargers_rows:
|
||||
code = str(ch["code"])
|
||||
preds = await conn.fetch(
|
||||
"SELECT * FROM ems.fn_ev_expected_arrival($1, $2, $3::date)",
|
||||
site_id,
|
||||
ch["id"],
|
||||
tomorrow_d,
|
||||
)
|
||||
chargers[code] = ChargerTomorrowArrival(
|
||||
tomorrow=[
|
||||
ArrivalHourItem(
|
||||
hour=int(r["expected_hour"]),
|
||||
confidence_pct=int(r["confidence_pct"]),
|
||||
samples=int(r["sample_count"]),
|
||||
chargers: dict[str, ChargerTomorrowArrival] = {}
|
||||
ch_raw = raw.get("chargers") or {}
|
||||
if isinstance(ch_raw, dict):
|
||||
for code, v in ch_raw.items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
tlist = v.get("tomorrow") or []
|
||||
items: list[ArrivalHourItem] = []
|
||||
if isinstance(tlist, list):
|
||||
for it in tlist:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
items.append(
|
||||
ArrivalHourItem(
|
||||
hour=int(it.get("hour") or 0),
|
||||
confidence_pct=int(it.get("confidence_pct") or 0),
|
||||
samples=int(it.get("samples") or 0),
|
||||
)
|
||||
)
|
||||
for r in preds
|
||||
]
|
||||
)
|
||||
chargers[str(code)] = ChargerTomorrowArrival(tomorrow=items)
|
||||
|
||||
td = raw.get("tomorrow_date")
|
||||
if isinstance(td, date):
|
||||
td_s = td.isoformat()
|
||||
elif isinstance(td, datetime):
|
||||
td_s = td.date().isoformat()
|
||||
else:
|
||||
td_s = str(td or "")
|
||||
|
||||
return EvArrivalPredictionResponse(
|
||||
insufficient_data=insufficient,
|
||||
tomorrow_date=tomorrow_d.isoformat(),
|
||||
insufficient_data=bool(raw.get("insufficient_data")),
|
||||
tomorrow_date=td_s,
|
||||
chargers=chargers,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -10,7 +11,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
from app.notifications_logic import (
|
||||
EvSessionRow,
|
||||
@@ -47,6 +48,16 @@ def _iso_utc(dt: datetime | None) -> str | None:
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_ts(val: Any) -> datetime | None:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
return None
|
||||
|
||||
|
||||
def _age_seconds(at: datetime | None) -> int | None:
|
||||
if at is None:
|
||||
return None
|
||||
@@ -81,174 +92,105 @@ async def get_site_status_full(
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> dict[str, Any]:
|
||||
async with pool.acquire() as conn:
|
||||
site = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, code, name, timezone
|
||||
FROM ems.site
|
||||
WHERE id = $1
|
||||
""",
|
||||
bundle = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_full_status($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if site is None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if not isinstance(bundle, dict):
|
||||
bundle = json.loads(bundle)
|
||||
if bundle.get("error") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
tz = site["timezone"] or "Europe/Prague"
|
||||
site = bundle.get("site") or {}
|
||||
mode_row = bundle.get("operating_mode") or {}
|
||||
hb_row = bundle.get("heartbeat") or {}
|
||||
inv_row = bundle.get("inverter_latest")
|
||||
if not isinstance(inv_row, dict):
|
||||
inv_row = None
|
||||
ev_rows = bundle.get("ev_chargers") or []
|
||||
if not isinstance(ev_rows, list):
|
||||
ev_rows = []
|
||||
hp_row = bundle.get("heat_pump_latest")
|
||||
if not isinstance(hp_row, dict):
|
||||
hp_row = None
|
||||
reserve_row = bundle.get("battery_limits") or {}
|
||||
run_row = bundle.get("active_plan")
|
||||
if not isinstance(run_row, dict):
|
||||
run_row = None
|
||||
intervals: list[dict[str, Any]] = []
|
||||
raw_iv = bundle.get("planning_intervals") or []
|
||||
if isinstance(raw_iv, list):
|
||||
intervals = [x for x in raw_iv if isinstance(x, dict)]
|
||||
|
||||
mode_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT m.mode_code, d.name AS mode_name, m.activated_at, m.activated_by
|
||||
FROM ems.site_operating_mode m
|
||||
JOIN ems.operating_mode_def d ON d.code = m.mode_code
|
||||
WHERE m.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
hb_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT last_seen, status
|
||||
FROM ems.site_heartbeat
|
||||
WHERE site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
inv_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT pv_power_w, battery_soc_percent, grid_power_w, measured_at
|
||||
FROM ems.vw_latest_inverter
|
||||
WHERE site_id = $1
|
||||
ORDER BY measured_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
ev_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (charger_id)
|
||||
charger_code AS code,
|
||||
status,
|
||||
power_w,
|
||||
measured_at
|
||||
FROM ems.vw_latest_ev_charger
|
||||
WHERE site_id = $1
|
||||
ORDER BY charger_id, measured_at DESC NULLS LAST
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
hp_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT power_w, tuv_tank_temp_c, measured_at
|
||||
FROM ems.vw_latest_heat_pump
|
||||
WHERE site_id = $1
|
||||
ORDER BY measured_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
reserve_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT MIN(reserve_soc_percent)::float AS reserve_soc,
|
||||
MIN(min_soc_percent)::float AS min_soc
|
||||
FROM ems.asset_battery
|
||||
WHERE site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
run_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, created_at
|
||||
FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
intervals: list[dict[str, Any]] = []
|
||||
if run_row:
|
||||
int_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT interval_start, battery_setpoint_w,
|
||||
load_baseline_w,
|
||||
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
|
||||
pv_a_forecast_solver_w, pv_b_forecast_solver_w
|
||||
FROM ems.planning_interval
|
||||
WHERE run_id = $1
|
||||
ORDER BY interval_start
|
||||
""",
|
||||
run_row["id"],
|
||||
)
|
||||
intervals = [record_to_dict(r) for r in int_rows]
|
||||
|
||||
tomorrow_slots = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*)::int
|
||||
FROM ems.vw_site_effective_price v
|
||||
WHERE v.site_id = $1
|
||||
AND (v.interval_start AT TIME ZONE $2)::date =
|
||||
((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date
|
||||
""",
|
||||
site_id,
|
||||
tz,
|
||||
)
|
||||
tomorrow_slots = int(tomorrow_slots or 0)
|
||||
tomorrow_slots = int(bundle.get("tomorrow_price_slot_count") or 0)
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
hb_last = hb_row["last_seen"] if hb_row else None
|
||||
hb_last = _parse_ts(hb_row.get("last_seen") if hb_row else None)
|
||||
hb_age = _age_seconds(hb_last)
|
||||
inv_measured = inv_row["measured_at"] if inv_row else None
|
||||
inv_measured = _parse_ts(inv_row.get("measured_at") if inv_row else None)
|
||||
inv_age = _age_seconds(inv_measured)
|
||||
|
||||
next_start, next_bat = _next_plan_interval(intervals, now_utc)
|
||||
|
||||
ev_list: list[dict[str, Any]] = []
|
||||
for r in ev_rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
ev_list.append(
|
||||
{
|
||||
"code": r["code"],
|
||||
"status": r["status"],
|
||||
"power_w": int(r["power_w"]) if r["power_w"] is not None else None,
|
||||
"code": r.get("code"),
|
||||
"status": r.get("status"),
|
||||
"power_w": int(r["power_w"]) if r.get("power_w") is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
telemetry: dict[str, Any] = {
|
||||
"inverter": {
|
||||
"pv_power_w": int(inv_row["pv_power_w"]) if inv_row and inv_row["pv_power_w"] is not None else None,
|
||||
"battery_soc_pct": float(inv_row["battery_soc_percent"])
|
||||
if inv_row and inv_row["battery_soc_percent"] is not None
|
||||
"pv_power_w": int(inv_row["pv_power_w"])
|
||||
if inv_row and inv_row.get("pv_power_w") is not None
|
||||
else None,
|
||||
"battery_soc_pct": float(inv_row["battery_soc_percent"])
|
||||
if inv_row and inv_row.get("battery_soc_percent") is not None
|
||||
else None,
|
||||
"grid_power_w": int(inv_row["grid_power_w"])
|
||||
if inv_row and inv_row.get("grid_power_w") is not None
|
||||
else None,
|
||||
"grid_power_w": int(inv_row["grid_power_w"]) if inv_row and inv_row["grid_power_w"] is not None else None,
|
||||
"measured_at": _iso_utc(inv_measured),
|
||||
"age_seconds": inv_age,
|
||||
},
|
||||
"ev_chargers": ev_list,
|
||||
"heat_pump": {
|
||||
"power_w": int(hp_row["power_w"]) if hp_row and hp_row["power_w"] is not None else None,
|
||||
"power_w": int(hp_row["power_w"]) if hp_row and hp_row.get("power_w") is not None else None,
|
||||
"tank_temp_c": float(hp_row["tuv_tank_temp_c"])
|
||||
if hp_row and hp_row["tuv_tank_temp_c"] is not None
|
||||
if hp_row and hp_row.get("tuv_tank_temp_c") is not None
|
||||
else None,
|
||||
"measured_at": _iso_utc(hp_row["measured_at"]) if hp_row else None,
|
||||
"measured_at": _iso_utc(hp_row.get("measured_at")) if hp_row else None,
|
||||
},
|
||||
}
|
||||
|
||||
has_plan = run_row is not None
|
||||
planning = {
|
||||
"has_active_plan": has_plan,
|
||||
"plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None,
|
||||
"plan_created_at": _iso_utc(run_row.get("created_at")) if run_row else None,
|
||||
"next_interval_start": next_start,
|
||||
"next_battery_setpoint_w": next_bat,
|
||||
}
|
||||
|
||||
mode_code = (mode_row["mode_code"] if mode_row else None) or ""
|
||||
reserve_soc = float(reserve_row["reserve_soc"]) if reserve_row and reserve_row["reserve_soc"] is not None else None
|
||||
min_soc = float(reserve_row["min_soc"]) if reserve_row and reserve_row["min_soc"] is not None else None
|
||||
soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None
|
||||
mode_code = (mode_row.get("mode_code") if mode_row else None) or ""
|
||||
reserve_soc = (
|
||||
float(reserve_row["reserve_soc"])
|
||||
if reserve_row and reserve_row.get("reserve_soc") is not None
|
||||
else None
|
||||
)
|
||||
min_soc = (
|
||||
float(reserve_row["min_soc"]) if reserve_row and reserve_row.get("min_soc") is not None else None
|
||||
)
|
||||
soc = (
|
||||
float(inv_row["battery_soc_percent"])
|
||||
if inv_row and inv_row.get("battery_soc_percent") is not None
|
||||
else None
|
||||
)
|
||||
|
||||
alerts: list[dict[str, str]] = []
|
||||
|
||||
@@ -281,17 +223,17 @@ async def get_site_status_full(
|
||||
alerts.sort(key=lambda a: (0 if a["level"] == "error" else 1, a["message"]))
|
||||
|
||||
return {
|
||||
"site": {"id": site["id"], "code": site["code"], "name": site["name"]},
|
||||
"site": {"id": site.get("id"), "code": site.get("code"), "name": site.get("name")},
|
||||
"operating_mode": {
|
||||
"mode_code": mode_row["mode_code"] if mode_row else None,
|
||||
"mode_name": mode_row["mode_name"] if mode_row else None,
|
||||
"activated_at": _iso_utc(mode_row["activated_at"]) if mode_row else None,
|
||||
"activated_by": mode_row["activated_by"] if mode_row else None,
|
||||
"mode_code": mode_row.get("mode_code") if mode_row else None,
|
||||
"mode_name": mode_row.get("mode_name") if mode_row else None,
|
||||
"activated_at": _iso_utc(mode_row.get("activated_at")) if mode_row else None,
|
||||
"activated_by": mode_row.get("activated_by") if mode_row else None,
|
||||
},
|
||||
"heartbeat": {
|
||||
"last_seen": _iso_utc(hb_last),
|
||||
"age_seconds": hb_age,
|
||||
"status": hb_row["status"] if hb_row else None,
|
||||
"status": hb_row.get("status") if hb_row else None,
|
||||
},
|
||||
"telemetry": telemetry,
|
||||
"planning": planning,
|
||||
@@ -395,156 +337,39 @@ async def get_site_notifications(
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> SiteNotificationsResponse:
|
||||
async with pool.acquire() as conn:
|
||||
site = await conn.fetchrow(
|
||||
"SELECT id, timezone FROM ems.site WHERE id = $1",
|
||||
ctx = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_notifications_context($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if site is None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
tz = site["timezone"] or "Europe/Prague"
|
||||
if not isinstance(ctx, dict):
|
||||
ctx = json.loads(ctx)
|
||||
if ctx.get("error") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
mode_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT m.mode_code
|
||||
FROM ems.site_operating_mode m
|
||||
WHERE m.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
run_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
reserve_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT MIN(reserve_soc_percent)::float AS reserve_soc,
|
||||
MIN(min_soc_percent)::float AS min_soc
|
||||
FROM ems.asset_battery
|
||||
WHERE site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
inv_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT battery_soc_percent, measured_at
|
||||
FROM ems.vw_latest_inverter
|
||||
WHERE site_id = $1
|
||||
ORDER BY measured_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
hb_row = await conn.fetchrow(
|
||||
"SELECT last_seen FROM ems.site_heartbeat WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
tomorrow_slots = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*)::int
|
||||
FROM ems.vw_site_effective_price v
|
||||
WHERE v.site_id = $1
|
||||
AND (v.interval_start AT TIME ZONE $2)::date =
|
||||
((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date
|
||||
""",
|
||||
site_id,
|
||||
tz,
|
||||
)
|
||||
has_plan = bool(ctx.get("has_plan"))
|
||||
mode_code = (ctx.get("mode_code") or "") or ""
|
||||
reserve_soc = _float_or_none(ctx.get("reserve_soc"))
|
||||
min_soc = _float_or_none(ctx.get("min_soc"))
|
||||
soc = _float_or_none(ctx.get("soc_pct"))
|
||||
inv_age = _age_seconds(_parse_ts(ctx.get("inv_measured_at")))
|
||||
hb_age = _age_seconds(_parse_ts(ctx.get("hb_last_seen")))
|
||||
tomorrow_slots = int(ctx.get("tomorrow_slots") or 0)
|
||||
|
||||
price_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT interval_start,
|
||||
effective_buy_price_czk_kwh,
|
||||
effective_sell_price_czk_kwh
|
||||
FROM ems.vw_site_effective_price
|
||||
WHERE site_id = $1
|
||||
AND interval_start >= now()
|
||||
AND interval_start < now() + INTERVAL '48 hours'
|
||||
ORDER BY interval_start
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
price_rows = ctx.get("price_slots") or []
|
||||
if not isinstance(price_rows, list):
|
||||
price_rows = []
|
||||
|
||||
avg_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT AVG(effective_buy_price_czk_kwh)::float AS avg_buy
|
||||
FROM ems.vw_site_effective_price
|
||||
WHERE site_id = $1
|
||||
AND interval_start::date IN (CURRENT_DATE, CURRENT_DATE + INTERVAL '1 day')
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
avg_buy = _float_or_none(ctx.get("avg_buy"))
|
||||
usable_wh = _float_or_none(ctx.get("usable_wh"))
|
||||
|
||||
bat_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float AS usable_wh
|
||||
FROM ems.asset_battery ab
|
||||
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id
|
||||
WHERE ai.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
ev_rows = ctx.get("ev_sessions") or []
|
||||
if not isinstance(ev_rows, list):
|
||||
ev_rows = []
|
||||
|
||||
ev_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (es.id)
|
||||
es.id,
|
||||
es.charger_id,
|
||||
es.energy_delivered_wh,
|
||||
es.target_soc_pct,
|
||||
es.session_start,
|
||||
es.soc_at_connect_pct,
|
||||
COALESCE(av_id.battery_capacity_kwh, av_def.battery_capacity_kwh) AS battery_capacity_kwh,
|
||||
COALESCE(av_id.make, av_def.make) AS make,
|
||||
COALESCE(av_id.model, av_def.model) AS model,
|
||||
COALESCE(av_id.default_target_soc_pct, av_def.default_target_soc_pct) AS default_target_soc_pct,
|
||||
ac.code AS charger_code
|
||||
FROM ems.ev_session es
|
||||
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
|
||||
LEFT JOIN ems.asset_vehicle av_id ON av_id.id = es.vehicle_id
|
||||
LEFT JOIN ems.asset_vehicle av_def
|
||||
ON av_def.default_charger_id = ac.id AND es.vehicle_id IS NULL
|
||||
WHERE es.site_id = $1 AND es.session_end IS NULL
|
||||
ORDER BY es.id, av_def.id NULLS LAST
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
neg_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT predicted_date, window_start_hour, window_end_hour, probability_pct
|
||||
FROM ems.predicted_negative_price_window
|
||||
WHERE site_id = $1
|
||||
AND predicted_date BETWEEN CURRENT_DATE AND CURRENT_DATE + 2
|
||||
AND probability_pct >= 50
|
||||
ORDER BY predicted_date, window_start_hour
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
has_plan = run_row is not None
|
||||
mode_code = (mode_row["mode_code"] if mode_row else None) or ""
|
||||
reserve_soc = (
|
||||
float(reserve_row["reserve_soc"])
|
||||
if reserve_row and reserve_row["reserve_soc"] is not None
|
||||
else None
|
||||
)
|
||||
min_soc = (
|
||||
float(reserve_row["min_soc"])
|
||||
if reserve_row and reserve_row["min_soc"] is not None
|
||||
else None
|
||||
)
|
||||
soc = (
|
||||
float(inv_row["battery_soc_percent"])
|
||||
if inv_row and inv_row["battery_soc_percent"] is not None
|
||||
else None
|
||||
)
|
||||
inv_age = _age_seconds(inv_row["measured_at"] if inv_row else None)
|
||||
hb_age = _age_seconds(hb_row["last_seen"] if hb_row else None)
|
||||
neg_rows = ctx.get("neg_windows") or []
|
||||
if not isinstance(neg_rows, list):
|
||||
neg_rows = []
|
||||
|
||||
infra = _infrastructure_notification_items(
|
||||
has_plan=has_plan,
|
||||
@@ -559,11 +384,15 @@ async def get_site_notifications(
|
||||
|
||||
prices: list[PriceSlot] = []
|
||||
for r in price_rows:
|
||||
buy = _float_or_none(r["effective_buy_price_czk_kwh"])
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
buy = _float_or_none(r.get("effective_buy_price_czk_kwh"))
|
||||
if buy is None:
|
||||
continue
|
||||
sell_v = _float_or_none(r["effective_sell_price_czk_kwh"])
|
||||
istart = r["interval_start"]
|
||||
sell_v = _float_or_none(r.get("effective_sell_price_czk_kwh"))
|
||||
istart = r.get("interval_start")
|
||||
if isinstance(istart, str):
|
||||
istart = datetime.fromisoformat(istart.replace("Z", "+00:00"))
|
||||
prices.append(
|
||||
PriceSlot(
|
||||
interval_start=istart,
|
||||
@@ -572,43 +401,50 @@ async def get_site_notifications(
|
||||
)
|
||||
)
|
||||
|
||||
avg_buy = _float_or_none(avg_row["avg_buy"]) if avg_row else None
|
||||
usable_wh = _float_or_none(bat_row["usable_wh"]) if bat_row else None
|
||||
battery_kwh = (usable_wh / 1000.0) if usable_wh is not None else None
|
||||
|
||||
ev_sessions: list[EvSessionRow] = []
|
||||
for er in ev_rows:
|
||||
if not isinstance(er, dict):
|
||||
continue
|
||||
ss = er.get("session_start")
|
||||
if isinstance(ss, str):
|
||||
ss = datetime.fromisoformat(ss.replace("Z", "+00:00"))
|
||||
ev_sessions.append(
|
||||
EvSessionRow(
|
||||
id=int(er["id"]),
|
||||
charger_id=int(er["charger_id"]),
|
||||
energy_delivered_wh=float(er["energy_delivered_wh"] or 0),
|
||||
target_soc_pct=_float_or_none(er["target_soc_pct"]),
|
||||
session_start=er["session_start"],
|
||||
battery_capacity_kwh=_float_or_none(er["battery_capacity_kwh"]),
|
||||
make=er["make"],
|
||||
model=er["model"],
|
||||
default_target_soc_pct=_float_or_none(er["default_target_soc_pct"]),
|
||||
charger_code=str(er["charger_code"] or ""),
|
||||
soc_at_connect_pct=_float_or_none(er["soc_at_connect_pct"]),
|
||||
energy_delivered_wh=float(er.get("energy_delivered_wh") or 0),
|
||||
target_soc_pct=_float_or_none(er.get("target_soc_pct")),
|
||||
session_start=ss,
|
||||
battery_capacity_kwh=_float_or_none(er.get("battery_capacity_kwh")),
|
||||
make=er.get("make"),
|
||||
model=er.get("model"),
|
||||
default_target_soc_pct=_float_or_none(er.get("default_target_soc_pct")),
|
||||
charger_code=str(er.get("charger_code") or ""),
|
||||
soc_at_connect_pct=_float_or_none(er.get("soc_at_connect_pct")),
|
||||
)
|
||||
)
|
||||
|
||||
neg_windows: list[NegWindowRow] = []
|
||||
for nr in neg_rows:
|
||||
dr = nr["predicted_date"]
|
||||
if not isinstance(nr, dict):
|
||||
continue
|
||||
dr = nr.get("predicted_date")
|
||||
if isinstance(dr, datetime):
|
||||
d_conv = dr.date()
|
||||
elif isinstance(dr, date):
|
||||
d_conv = dr
|
||||
elif isinstance(dr, str):
|
||||
d_conv = date.fromisoformat(dr[:10])
|
||||
else:
|
||||
d_conv = date.today()
|
||||
neg_windows.append(
|
||||
NegWindowRow(
|
||||
predicted_date=d_conv,
|
||||
window_start_hour=int(nr["window_start_hour"]),
|
||||
window_end_hour=int(nr["window_end_hour"]),
|
||||
probability_pct=int(nr["probability_pct"]),
|
||||
window_start_hour=int(nr.get("window_start_hour") or 0),
|
||||
window_end_hour=int(nr.get("window_end_hour") or 0),
|
||||
probability_pct=int(nr.get("probability_pct") or 0),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
33
backend/app/routers/me.py
Normal file
33
backend/app/routers/me.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""REST API – /me (fáze bez auth)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(prefix="/api/v1/me", tags=["me"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sites",
|
||||
summary="Lokality přihlášeného uživatele (fáze bez auth)",
|
||||
description="Aktuálně vrací všechny aktivní lokality z vw_site_directory; po zavedení autentizace se odfiltruje podle oprávnění.",
|
||||
)
|
||||
async def list_my_sites(
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> list[dict[str, Any]]:
|
||||
async with db.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at
|
||||
FROM ems.vw_site_directory
|
||||
WHERE active = true
|
||||
ORDER BY code
|
||||
"""
|
||||
)
|
||||
return [record_to_dict(r) for r in rows]
|
||||
@@ -1,5 +1,6 @@
|
||||
"""REST API – aktivní plán a ruční přepočet."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
@@ -8,7 +9,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.db_json import record_to_dict
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
from services.control_exporter import export_setpoints
|
||||
from services.planning_engine import run_plan_api
|
||||
@@ -46,126 +47,36 @@ class CurrentPlanResponseModel(BaseModel):
|
||||
summary: dict[str, Any]
|
||||
|
||||
|
||||
def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
total_cost = 0.0
|
||||
total_curtailed_kwh = 0.0
|
||||
charge_slots = 0
|
||||
discharge_slots = 0
|
||||
export_slots = 0
|
||||
for row in intervals:
|
||||
ec = row.get("expected_cost_czk")
|
||||
if ec is not None:
|
||||
total_cost += float(ec)
|
||||
c = row.get("pv_a_curtailed_w") or 0
|
||||
total_curtailed_kwh += int(c) * 0.25 / 1000.0
|
||||
b = row.get("battery_setpoint_w")
|
||||
if b is not None:
|
||||
if int(b) > 0:
|
||||
charge_slots += 1
|
||||
elif int(b) < 0:
|
||||
discharge_slots += 1
|
||||
g = row.get("grid_setpoint_w")
|
||||
if g is not None and int(g) < 0:
|
||||
export_slots += 1
|
||||
return {
|
||||
"total_expected_cost_czk": round(total_cost, 4),
|
||||
"total_pv_curtailed_kwh": round(total_curtailed_kwh, 6),
|
||||
"charge_slots": charge_slots,
|
||||
"discharge_slots": discharge_slots,
|
||||
"export_slots": export_slots,
|
||||
}
|
||||
|
||||
|
||||
def _pv_scarcity_factor_from_intervals(
|
||||
intervals: list[dict[str, Any]], battery_usable_wh: float | None
|
||||
) -> float:
|
||||
"""Stejná logika jako v solveru: 0.65..1.0 podle očekávané FVE energie na ~24h."""
|
||||
if not intervals:
|
||||
return 1.0
|
||||
batt_kwh = max(1.0, float(battery_usable_wh or 0.0) / 1000.0)
|
||||
horizon_slots = min(len(intervals), int(24 / 0.25))
|
||||
pv_kwh = 0.0
|
||||
for row in intervals[:horizon_slots]:
|
||||
pv = row.get("pv_forecast_total_w")
|
||||
if pv is not None:
|
||||
pv_kwh += max(0.0, float(pv)) * 0.25 / 1000.0
|
||||
coverage = pv_kwh / batt_kwh
|
||||
coverage_clamped = max(0.0, min(1.0, coverage))
|
||||
return round(0.65 + 0.35 * coverage_clamped, 4)
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentPlanResponseModel)
|
||||
async def get_current_plan(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> CurrentPlanResponseModel:
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
run_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT pr.*
|
||||
FROM ems.planning_run pr
|
||||
WHERE pr.site_id = $1 AND pr.status = 'active'
|
||||
ORDER BY pr.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
bundle = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_plan_current_bundle($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if not run_row:
|
||||
raise HTTPException(status_code=404, detail="No active plan")
|
||||
if not isinstance(bundle, dict):
|
||||
bundle = json.loads(bundle)
|
||||
if bundle.get("error") == "no_active_plan":
|
||||
raise HTTPException(status_code=404, detail="No active plan")
|
||||
|
||||
run_id = run_row["id"]
|
||||
int_rows = await conn.fetch(
|
||||
"""
|
||||
WITH latest_fc AS (
|
||||
SELECT id
|
||||
FROM ems.forecast_pv_run
|
||||
WHERE site_id = $2 AND status = 'ok'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
fc_slot AS (
|
||||
SELECT fpi.interval_start, COALESCE(SUM(fpi.power_w), 0)::BIGINT AS pv_forecast_total_w
|
||||
FROM ems.forecast_pv_interval fpi
|
||||
WHERE fpi.run_id = (SELECT id FROM latest_fc)
|
||||
GROUP BY fpi.interval_start
|
||||
)
|
||||
SELECT
|
||||
pi.*,
|
||||
ai.actual_pv_power_w AS pv_power_w,
|
||||
fs.pv_forecast_total_w AS pv_forecast_total_w
|
||||
FROM ems.planning_interval pi
|
||||
LEFT JOIN ems.audit_interval ai
|
||||
ON ai.site_id = $2 AND ai.interval_start = pi.interval_start
|
||||
LEFT JOIN fc_slot fs ON fs.interval_start = pi.interval_start
|
||||
WHERE pi.run_id = $1
|
||||
ORDER BY pi.interval_start
|
||||
""",
|
||||
run_id,
|
||||
site_id,
|
||||
)
|
||||
battery_usable_wh = await conn.fetchval(
|
||||
"""
|
||||
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float
|
||||
FROM ems.asset_battery ab
|
||||
WHERE ab.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
intervals_raw = [record_to_dict(r) for r in int_rows]
|
||||
summary = _build_summary(intervals_raw)
|
||||
summary["pv_scarcity_factor"] = _pv_scarcity_factor_from_intervals(
|
||||
intervals_raw, float(battery_usable_wh or 0.0)
|
||||
)
|
||||
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw]
|
||||
intervals_raw = bundle.get("intervals") or []
|
||||
if not isinstance(intervals_raw, list):
|
||||
intervals_raw = []
|
||||
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
|
||||
return CurrentPlanResponseModel(
|
||||
run=record_to_dict(run_row),
|
||||
run=bundle.get("run") or {},
|
||||
intervals=intervals,
|
||||
summary=summary,
|
||||
summary=bundle.get("summary") or {},
|
||||
)
|
||||
|
||||
|
||||
@@ -176,18 +87,14 @@ async def post_run_plan(
|
||||
plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
|
||||
) -> RunPlanResponse:
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
days_with_prices = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT interval_start::date)::int AS days_with_prices
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
|
||||
AND interval_start >= now()
|
||||
AND interval_start < now() + INTERVAL '48 hours'
|
||||
"""
|
||||
"select ems.fn_planning_future_price_days()",
|
||||
)
|
||||
if (days_with_prices or 0) < 1:
|
||||
raise HTTPException(
|
||||
@@ -199,14 +106,10 @@ async def post_run_plan(
|
||||
run_id, solver_duration_ms = await run_plan_api(
|
||||
site_id, plan_type, conn, triggered_by="api"
|
||||
)
|
||||
# Nový active run aplikuj hned; nečekej na periodický control_export job.
|
||||
await export_setpoints(site_id, conn)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT horizon_start, horizon_end
|
||||
FROM ems.planning_run
|
||||
WHERE id = $1
|
||||
""",
|
||||
row = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_planning_run_horizon($1::int)",
|
||||
run_id,
|
||||
)
|
||||
except HTTPException:
|
||||
@@ -219,7 +122,7 @@ async def post_run_plan(
|
||||
logger.error("Plan run failed: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
|
||||
if row is None:
|
||||
if not isinstance(row, dict) or row.get("horizon_start") is None:
|
||||
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
|
||||
|
||||
return RunPlanResponse(
|
||||
|
||||
205
backend/app/routers/site_configuration.py
Normal file
205
backend/app/routers/site_configuration.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""GET /sites/{site_id}/configuration – read-only souhrn konfigurace lokality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
|
||||
|
||||
|
||||
class PvForecastCalibrationPatch(BaseModel):
|
||||
"""Částečná úprava `ems.site_pv_forecast_calibration`. Vynechané klíče = beze změny."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
delta_learn_min_ts: datetime | None = None
|
||||
pv_curtailment_policy_effective_from: datetime | None = None
|
||||
top_n_days: int | None = Field(default=None, ge=0, le=31)
|
||||
non_top_day_factor: float | None = Field(default=None, ge=0, le=1)
|
||||
day_weight_gamma: float | None = Field(default=None, ge=0.25, le=8)
|
||||
half_life_days: float | None = Field(default=None, ge=1, le=90)
|
||||
threshold_w: int | None = Field(default=None, ge=0, le=10_000)
|
||||
|
||||
|
||||
class InverterModbusCurrentCapsBody(BaseModel):
|
||||
"""Tvrdý strop proudu pro zápis Deye reg 108/109 (A); NULL ve JSONu = smaž strop v DB."""
|
||||
|
||||
deye_register_max_charge_a: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=640,
|
||||
description="None při vynechání klíče = nezměnit; explicitní null = smazat strop",
|
||||
)
|
||||
deye_register_max_discharge_a: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=640,
|
||||
description="Jako u nabíjení",
|
||||
)
|
||||
|
||||
|
||||
def _iso_utc_from_cfg(val: Any) -> str | None:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
return val
|
||||
if isinstance(val, datetime):
|
||||
dt = val
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
return str(val)
|
||||
|
||||
|
||||
@router.get("/configuration")
|
||||
async def get_site_configuration(
|
||||
site_id: int,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> dict[str, Any]:
|
||||
async with pool.acquire() as conn:
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_configuration($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if raw is None:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
op = raw.get("operational")
|
||||
if isinstance(op, dict):
|
||||
op = dict(op)
|
||||
op["heartbeat_last_seen"] = _iso_utc_from_cfg(op.get("heartbeat_last_seen"))
|
||||
op["active_plan_created_at"] = _iso_utc_from_cfg(op.get("active_plan_created_at"))
|
||||
raw["operational"] = op
|
||||
lat = raw.get("site", {}).get("latitude") if isinstance(raw.get("site"), dict) else None
|
||||
lon = raw.get("site", {}).get("longitude") if isinstance(raw.get("site"), dict) else None
|
||||
if isinstance(raw.get("site"), dict):
|
||||
site = dict(raw["site"])
|
||||
site["latitude"] = float(lat) if lat is not None else None
|
||||
site["longitude"] = float(lon) if lon is not None else None
|
||||
raw["site"] = site
|
||||
return raw
|
||||
|
||||
|
||||
@router.patch("/configuration/pv-forecast-calibration")
|
||||
async def patch_pv_forecast_calibration(
|
||||
site_id: int,
|
||||
body: PvForecastCalibrationPatch,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> dict[str, Any]:
|
||||
"""Aktualizace kalibrace PV delty (`ems.site_pv_forecast_calibration`)."""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
if updates.get("delta_learn_min_ts") is None and "delta_learn_min_ts" in updates:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="delta_learn_min_ts cannot be null (column is NOT NULL)",
|
||||
)
|
||||
|
||||
allowed = {
|
||||
"delta_learn_min_ts",
|
||||
"pv_curtailment_policy_effective_from",
|
||||
"top_n_days",
|
||||
"non_top_day_factor",
|
||||
"day_weight_gamma",
|
||||
"half_life_days",
|
||||
"threshold_w",
|
||||
}
|
||||
bad = set(updates) - allowed
|
||||
if bad:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown fields: {sorted(bad)}")
|
||||
|
||||
cols = list(updates.keys())
|
||||
set_parts: list[str] = []
|
||||
args: list[Any] = [site_id]
|
||||
for i, col in enumerate(cols, start=2):
|
||||
set_parts.append(f"{col} = ${i}")
|
||||
args.append(updates[col])
|
||||
set_sql = ", ".join(set_parts) + ", updated_at = now()"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
n = await conn.execute(
|
||||
f"""
|
||||
UPDATE ems.site_pv_forecast_calibration
|
||||
SET {set_sql}
|
||||
WHERE site_id = $1
|
||||
""",
|
||||
*args,
|
||||
)
|
||||
if n == "UPDATE 0":
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="PV forecast calibration row missing; run migration V057",
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT to_jsonb(c.*) AS j
|
||||
FROM ems.site_pv_forecast_calibration c
|
||||
WHERE c.site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
raw = row["j"] if row else {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
return raw
|
||||
|
||||
|
||||
@router.patch("/inverters/{inverter_id}/modbus-current-caps")
|
||||
async def patch_inverter_modbus_current_caps(
|
||||
site_id: int,
|
||||
inverter_id: int,
|
||||
body: InverterModbusCurrentCapsBody,
|
||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Nastavení `deye_register_max_charge_a` / `deye_register_max_discharge_a` na `ems.asset_inverter`.
|
||||
"""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Send at least one of: deye_register_max_charge_a, deye_register_max_discharge_a",
|
||||
)
|
||||
patch: dict[str, Any] = {}
|
||||
if "deye_register_max_charge_a" in updates:
|
||||
patch["deye_register_max_charge_a"] = updates["deye_register_max_charge_a"]
|
||||
if "deye_register_max_discharge_a" in updates:
|
||||
patch["deye_register_max_discharge_a"] = updates["deye_register_max_discharge_a"]
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_inverter_modbus_caps_patch($1::int, $2::int, $3::jsonb)",
|
||||
site_id,
|
||||
inverter_id,
|
||||
json.dumps(patch),
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
raw = json.loads(raw)
|
||||
if not raw.get("ok"):
|
||||
if raw.get("error") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="Inverter not found for this site")
|
||||
raise HTTPException(status_code=400, detail=raw.get("error", "patch_failed"))
|
||||
return {
|
||||
"inverter_id": int(raw["inverter_id"]),
|
||||
"code": raw["code"],
|
||||
"deye_register_max_charge_a": raw.get("deye_register_max_charge_a"),
|
||||
"deye_register_max_discharge_a": raw.get("deye_register_max_discharge_a"),
|
||||
}
|
||||
811
backend/app/routers/sites.py
Normal file
811
backend/app/routers/sites.py
Normal file
@@ -0,0 +1,811 @@
|
||||
"""REST API – lokality: ceny OTE, forecast, Modbus journal/verify."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db_json import fetch_json, record_to_dict
|
||||
from app.deps import get_pg_pool
|
||||
from app.refresh_negative_prices import refresh_negative_price_predictions
|
||||
from services.control_exporter import read_deye_registers_live, verify_modbus_commands
|
||||
from services.forecast_service import fetch_pv_forecast
|
||||
from services.price_importer import import_ote_prices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/sites", tags=["sites"])
|
||||
|
||||
|
||||
def _parse_ymd(s: str) -> date:
|
||||
try:
|
||||
return date.fromisoformat(s)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid date, expected YYYY-MM-DD"
|
||||
) from None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_sites(
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> list[dict[str, Any]]:
|
||||
async with db.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
select id, code, name, timezone, latitude, longitude, active, notes, created_at
|
||||
from ems.vw_site_directory
|
||||
order by id
|
||||
"""
|
||||
)
|
||||
return [record_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{site_id}/prices")
|
||||
async def get_site_prices(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
date_str: str | None = Query(
|
||||
None, alias="date", description="YYYY-MM-DD, default today"
|
||||
),
|
||||
) -> list[dict[str, Any]]:
|
||||
if date_str is None:
|
||||
date_str = date.today().isoformat()
|
||||
d = _parse_ymd(date_str)
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
rows = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_effective_prices_day_prague($1::int, $2::date)",
|
||||
site_id,
|
||||
d,
|
||||
)
|
||||
if not isinstance(rows, list):
|
||||
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
@router.get("/{site_id}/prices/slots")
|
||||
async def get_site_prices_slots_range(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
from_ts: datetime = Query(
|
||||
...,
|
||||
alias="from",
|
||||
description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min",
|
||||
),
|
||||
to_ts: datetime = Query(
|
||||
...,
|
||||
alias="to",
|
||||
description="Konec polouzavřeného intervalu (max. 14 dní za from)",
|
||||
),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
if to_ts <= from_ts:
|
||||
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||
if to_ts - from_ts > timedelta(days=14):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Span between 'from' and 'to' must be at most 14 days",
|
||||
)
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_site_effective_prices_slots_range($1::int, $2::timestamptz, $3::timestamptz)",
|
||||
site_id,
|
||||
from_ts,
|
||||
to_ts,
|
||||
)
|
||||
rows = raw if isinstance(raw, list) else []
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
return {"slots": [r for r in rows if isinstance(r, dict)]}
|
||||
|
||||
|
||||
class PricesImportResponse(BaseModel):
|
||||
slots_imported: int
|
||||
date: str
|
||||
first_price_czk_kwh: float
|
||||
|
||||
|
||||
class PricesLatestResponse(BaseModel):
|
||||
latest_date: str
|
||||
slots: int
|
||||
min_price: float
|
||||
max_price: float
|
||||
avg_price: float
|
||||
|
||||
|
||||
class ForecastRunResponse(BaseModel):
|
||||
intervals_saved: int
|
||||
pv_arrays: int
|
||||
|
||||
|
||||
class ModbusCommandVerifyItem(BaseModel):
|
||||
id: int
|
||||
asset_code: str
|
||||
register_name: str | None
|
||||
value_to_write: int
|
||||
value_verified: int | None
|
||||
status: str
|
||||
|
||||
|
||||
class ModbusVerifyResponse(BaseModel):
|
||||
checked: int
|
||||
verified: int
|
||||
mismatch: int
|
||||
commands: list[ModbusCommandVerifyItem]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{site_id}/prices/import",
|
||||
response_model=PricesImportResponse,
|
||||
summary="Import OTE cen (globální)",
|
||||
description=(
|
||||
"Zapíše do sdílené tabulky ems.market_interval_price (jedna sada dat pro všechny lokality). "
|
||||
"site_id v cestě slouží ke kontrole existence lokality (kompatibilita s UI); po importu se "
|
||||
"obnoví predikce záporných cen pro všechny aktivní lokality."
|
||||
),
|
||||
)
|
||||
async def post_import_site_prices(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
date_str: str | None = Query(
|
||||
None,
|
||||
alias="date",
|
||||
description="YYYY-MM-DD; výchozí = zítřek/dnes dle logiky OTE (Europe/Prague)",
|
||||
),
|
||||
) -> PricesImportResponse:
|
||||
target: date | None = _parse_ymd(date_str) if date_str is not None else None
|
||||
import_error: str | None = None
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
n, day, first_price, import_error = await import_ote_prices(
|
||||
conn, site_id=None, target_date=target
|
||||
)
|
||||
if n >= 0:
|
||||
sites_raw = await fetch_json(
|
||||
conn, "select ems.fn_vw_site_directory_active()"
|
||||
)
|
||||
sites_list = sites_raw if isinstance(sites_raw, list) else []
|
||||
for site in sites_list:
|
||||
if isinstance(site, dict):
|
||||
await refresh_negative_price_predictions(conn, int(site["id"]))
|
||||
if n < 0:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"OTE import selhal ({import_error or 'unknown'})",
|
||||
)
|
||||
return PricesImportResponse(
|
||||
slots_imported=n,
|
||||
date=day,
|
||||
first_price_czk_kwh=first_price,
|
||||
)
|
||||
|
||||
|
||||
class NegPricePredictionItem(BaseModel):
|
||||
predicted_date: str
|
||||
window_start_hour: int
|
||||
window_end_hour: int
|
||||
probability_pct: float
|
||||
expected_min_price: float | None
|
||||
reason: str
|
||||
|
||||
|
||||
class NegativePredictionsResponse(BaseModel):
|
||||
predictions: list[NegPricePredictionItem]
|
||||
insufficient_history: bool
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{site_id}/prices/negative-predictions",
|
||||
response_model=NegativePredictionsResponse,
|
||||
)
|
||||
async def get_site_negative_price_predictions(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> NegativePredictionsResponse:
|
||||
"""Cache predikce záporných cen (per site) + informace, zda je dost historie OTE."""
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
bundle = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_negative_price_predictions($1::int)",
|
||||
site_id,
|
||||
)
|
||||
if not isinstance(bundle, dict):
|
||||
bundle = json.loads(bundle)
|
||||
rows = bundle.get("predictions") or []
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
predictions: list[NegPricePredictionItem] = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
em = r.get("expected_min_price")
|
||||
pd = r.get("predicted_date")
|
||||
predictions.append(
|
||||
NegPricePredictionItem(
|
||||
predicted_date=pd.isoformat()
|
||||
if hasattr(pd, "isoformat")
|
||||
else str(pd),
|
||||
window_start_hour=int(r.get("window_start_hour") or 0),
|
||||
window_end_hour=int(r.get("window_end_hour") or 0),
|
||||
probability_pct=float(r.get("probability_pct") or 0),
|
||||
expected_min_price=float(em) if em is not None else None,
|
||||
reason=str(r.get("reason") or ""),
|
||||
)
|
||||
)
|
||||
return NegativePredictionsResponse(
|
||||
predictions=predictions,
|
||||
insufficient_history=bool(bundle.get("insufficient_history")),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{site_id}/prices/latest", response_model=PricesLatestResponse)
|
||||
async def get_site_prices_latest(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> PricesLatestResponse:
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
row = await fetch_json(conn, "select ems.fn_latest_ote_day_stats()")
|
||||
if not isinstance(row, dict):
|
||||
row = json.loads(row)
|
||||
day = row.get("latest_date")
|
||||
if day is None:
|
||||
raise HTTPException(status_code=404, detail="Žádná tržní data v databázi")
|
||||
latest_date = day.isoformat() if hasattr(day, "isoformat") else str(day)[:10]
|
||||
return PricesLatestResponse(
|
||||
latest_date=latest_date,
|
||||
slots=int(row.get("slots") or 0),
|
||||
min_price=float(row.get("min_price") or 0.0),
|
||||
max_price=float(row.get("max_price") or 0.0),
|
||||
avg_price=float(row.get("avg_price") or 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{site_id}/control/verify", response_model=ModbusVerifyResponse)
|
||||
async def get_verify_modbus_commands(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
minutes: int = Query(10, ge=1, le=1440, description="Jak daleko zpět hledat written příkazy"),
|
||||
) -> ModbusVerifyResponse:
|
||||
"""
|
||||
Ruční ověření Modbus zápisů (written) z posledních N minut.
|
||||
Vhodné hned po manuálním exportu setpointů.
|
||||
"""
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
lookback = timedelta(minutes=minutes)
|
||||
id_json = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_modbus_written_command_ids($1::int, $2::interval)",
|
||||
site_id,
|
||||
lookback,
|
||||
)
|
||||
if not isinstance(id_json, list):
|
||||
id_json = json.loads(id_json) if isinstance(id_json, str) else []
|
||||
ids = [int(x) for x in id_json]
|
||||
checked = len(ids)
|
||||
if ids:
|
||||
await verify_modbus_commands(ids, conn, site_id)
|
||||
|
||||
detail_json = (
|
||||
await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_modbus_commands_by_ids($1::int[])",
|
||||
ids,
|
||||
)
|
||||
if ids
|
||||
else []
|
||||
)
|
||||
if ids and not isinstance(detail_json, list):
|
||||
detail_json = json.loads(detail_json) if isinstance(detail_json, str) else []
|
||||
detail_rows = detail_json if ids else []
|
||||
|
||||
commands = [
|
||||
ModbusCommandVerifyItem(
|
||||
id=int(r["id"]),
|
||||
asset_code=str(r.get("asset_code") or ""),
|
||||
register_name=r.get("register_name"),
|
||||
value_to_write=int(r["value_to_write"]),
|
||||
value_verified=int(r["value_verified"])
|
||||
if r.get("value_verified") is not None
|
||||
else None,
|
||||
status=str(r.get("status") or ""),
|
||||
)
|
||||
for r in detail_rows
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
verified = sum(1 for c in commands if c.status == "verified")
|
||||
mismatch = sum(1 for c in commands if c.status == "mismatch")
|
||||
return ModbusVerifyResponse(
|
||||
checked=checked,
|
||||
verified=verified,
|
||||
mismatch=mismatch,
|
||||
commands=commands,
|
||||
)
|
||||
|
||||
|
||||
class DeyeRegistersLiveResponse(BaseModel):
|
||||
reg108_charge_a: int
|
||||
reg109_discharge_a: int
|
||||
reg141_energy_mode: int
|
||||
reg142_limit_control: int
|
||||
reg143_export_limit_w: int
|
||||
reg178_peak_shaving_switch: int
|
||||
reg178_control_board_special_1: int
|
||||
reg178_mi_export_cutoff_bits: int
|
||||
reg178_mi_export_cutoff_is_on: bool
|
||||
reg191_peak_shaving_w: int
|
||||
read_at: str
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{site_id}/control/registers",
|
||||
response_model=DeyeRegistersLiveResponse,
|
||||
)
|
||||
async def get_control_registers_live(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> DeyeRegistersLiveResponse:
|
||||
"""Živé hodnoty registrů Deye 108/109/141/142/143/178/191 přes sdílený Modbus klient."""
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
try:
|
||||
payload = await read_deye_registers_live(site_id, conn)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No controllable Modbus inverter for this site",
|
||||
) from None
|
||||
except Exception as e:
|
||||
logger.warning("get_control_registers_live site=%s: %s", site_id, e)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Modbus read failed: {e}",
|
||||
) from e
|
||||
return DeyeRegistersLiveResponse(**payload)
|
||||
|
||||
|
||||
class ModbusJournalCommandRow(BaseModel):
|
||||
id: int
|
||||
register: int
|
||||
register_name: str | None
|
||||
value_to_write: int
|
||||
value_written: int | None
|
||||
value_verified: int | None
|
||||
status: str
|
||||
attempt_count: int
|
||||
created_at: str
|
||||
|
||||
|
||||
class ModbusJournalListResponse(BaseModel):
|
||||
commands: list[ModbusJournalCommandRow]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{site_id}/control/journal",
|
||||
response_model=ModbusJournalListResponse,
|
||||
)
|
||||
async def get_control_command_journal(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
) -> ModbusJournalListResponse:
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
rows = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_modbus_journal_list($1::int, $2::int)",
|
||||
site_id,
|
||||
limit,
|
||||
)
|
||||
if not isinstance(rows, list):
|
||||
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||
cmds: list[ModbusJournalCommandRow] = []
|
||||
for r in rows:
|
||||
d = r if isinstance(r, dict) else {}
|
||||
ca = d["created_at"]
|
||||
cmds.append(
|
||||
ModbusJournalCommandRow(
|
||||
id=int(d["id"]),
|
||||
register=int(d["register"]),
|
||||
register_name=d.get("register_name"),
|
||||
value_to_write=int(d["value_to_write"]),
|
||||
value_written=int(d["value_written"])
|
||||
if d.get("value_written") is not None
|
||||
else None,
|
||||
value_verified=int(d["value_verified"])
|
||||
if d.get("value_verified") is not None
|
||||
else None,
|
||||
status=str(d["status"]),
|
||||
attempt_count=int(d["attempt_count"]),
|
||||
created_at=ca if isinstance(ca, str) else str(ca),
|
||||
)
|
||||
)
|
||||
return ModbusJournalListResponse(commands=cmds)
|
||||
|
||||
|
||||
@router.post("/{site_id}/forecast/run", response_model=ForecastRunResponse)
|
||||
async def post_run_site_forecast(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> ForecastRunResponse:
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
try:
|
||||
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
|
||||
except Exception as e:
|
||||
logger.error("Forecast failed: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
if intervals >= 0:
|
||||
await refresh_negative_price_predictions(conn, site_id)
|
||||
if intervals < 0:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Forecast se nepodařilo stáhnout nebo zpracovat",
|
||||
)
|
||||
return ForecastRunResponse(intervals_saved=intervals, pv_arrays=pv_arrays)
|
||||
|
||||
|
||||
@router.get("/{site_id}/forecast/pv")
|
||||
async def get_site_forecast_pv(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
date_str: str | None = Query(
|
||||
None, alias="date", description="YYYY-MM-DD, default tomorrow"
|
||||
),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
if date_str is None:
|
||||
date_str = (date.today() + timedelta(days=1)).isoformat()
|
||||
d = _parse_ymd(date_str)
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
split = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_forecast_pv_split($1::int, $2::date)",
|
||||
site_id,
|
||||
d,
|
||||
)
|
||||
if not isinstance(split, dict):
|
||||
split = json.loads(split) if isinstance(split, str) else {}
|
||||
pv_a = split.get("pv_a") or []
|
||||
pv_b = split.get("pv_b") or []
|
||||
if not isinstance(pv_a, list):
|
||||
pv_a = []
|
||||
if not isinstance(pv_b, list):
|
||||
pv_b = []
|
||||
return {"pv_a": pv_a, "pv_b": pv_b}
|
||||
|
||||
|
||||
@router.get("/{site_id}/forecast/pv-slots")
|
||||
async def get_site_forecast_pv_slots_range(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
from_ts: datetime = Query(
|
||||
...,
|
||||
alias="from",
|
||||
description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min",
|
||||
),
|
||||
to_ts: datetime = Query(
|
||||
...,
|
||||
alias="to",
|
||||
description="Konec polouzavřeného intervalu (max. 60 dní za from)",
|
||||
),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
if to_ts <= from_ts:
|
||||
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||
if to_ts - from_ts > timedelta(days=60):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Span between 'from' and 'to' must be at most 60 days",
|
||||
)
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_forecast_pv_slots_range($1::int, $2::timestamptz, $3::timestamptz)",
|
||||
site_id,
|
||||
from_ts,
|
||||
to_ts,
|
||||
)
|
||||
slots = raw if isinstance(raw, list) else []
|
||||
if not isinstance(slots, list):
|
||||
slots = []
|
||||
return {"slots": slots}
|
||||
|
||||
|
||||
@router.get("/{site_id}/forecast/pv-slots-corrected")
|
||||
async def get_site_forecast_pv_slots_range_corrected(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
from_ts: datetime = Query(
|
||||
...,
|
||||
alias="from",
|
||||
description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min",
|
||||
),
|
||||
to_ts: datetime = Query(
|
||||
...,
|
||||
alias="to",
|
||||
description="Konec polouzavřeného intervalu (max. 60 dní za from)",
|
||||
),
|
||||
delta_from_ts: datetime | None = Query(
|
||||
None,
|
||||
alias="delta_from",
|
||||
description="Začátek okna historie pro výpočet delta profilu (default: now-60d)",
|
||||
),
|
||||
delta_to_ts: datetime | None = Query(
|
||||
None,
|
||||
alias="delta_to",
|
||||
description="Konec okna historie pro výpočet delta profilu (default: now)",
|
||||
),
|
||||
half_life_days: float = Query(
|
||||
14,
|
||||
ge=1,
|
||||
le=90,
|
||||
description="Half-life vážení (dny) pro delta profil",
|
||||
),
|
||||
threshold_w: int = Query(
|
||||
150,
|
||||
ge=0,
|
||||
le=10_000,
|
||||
description="Ignorovat sloty s nízkou výrobou (W) při odhadu profilu",
|
||||
),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
if to_ts <= from_ts:
|
||||
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||
if to_ts - from_ts > timedelta(days=60):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Span between 'from' and 'to' must be at most 60 days",
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
delta_to = delta_to_ts or now
|
||||
delta_from = delta_from_ts or (delta_to - timedelta(days=60))
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"""
|
||||
select ems.fn_forecast_pv_slots_range_corrected(
|
||||
$1::int,
|
||||
$2::timestamptz,
|
||||
$3::timestamptz,
|
||||
$4::timestamptz,
|
||||
$5::timestamptz,
|
||||
$6::numeric,
|
||||
$7::int
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
from_ts,
|
||||
to_ts,
|
||||
delta_from,
|
||||
delta_to,
|
||||
half_life_days,
|
||||
threshold_w,
|
||||
)
|
||||
slots = raw if isinstance(raw, list) else []
|
||||
if not isinstance(slots, list):
|
||||
slots = []
|
||||
return {"slots": [s for s in slots if isinstance(s, dict)]}
|
||||
|
||||
|
||||
@router.get("/{site_id}/forecast/pv-delta-profile")
|
||||
async def get_site_forecast_pv_delta_profile(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
from_ts: datetime = Query(
|
||||
...,
|
||||
alias="from",
|
||||
description="Začátek okna historie pro výpočet delty [from, to)",
|
||||
),
|
||||
to_ts: datetime = Query(
|
||||
...,
|
||||
alias="to",
|
||||
description="Konec okna (max. 120 dní za from; typicky now)",
|
||||
),
|
||||
half_life_days: float = Query(
|
||||
14,
|
||||
ge=1,
|
||||
le=90,
|
||||
description="Half-life vážení (dny) pro delta profil",
|
||||
),
|
||||
threshold_w: int = Query(
|
||||
150,
|
||||
ge=0,
|
||||
le=10_000,
|
||||
description="Ignorovat sloty s nízkou výrobou (W) při odhadu profilu",
|
||||
),
|
||||
top_n_days: int | None = Query(
|
||||
None,
|
||||
ge=0,
|
||||
le=31,
|
||||
description="Top N kalendářních dní podle day_score (NULL = z kalibrace / výchozí funkce)",
|
||||
),
|
||||
non_top_day_factor: float | None = Query(
|
||||
None,
|
||||
ge=0,
|
||||
le=1,
|
||||
description="Ztlumení vah mimo top N (NULL = z kalibrace / default)",
|
||||
),
|
||||
day_weight_gamma: float | None = Query(
|
||||
None,
|
||||
ge=0.25,
|
||||
le=8,
|
||||
description="Exponent na day_weight (NULL = z kalibrace / default)",
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""JSON z `ems.fn_pv_forecast_delta_profile` (`deltas`, `deltas_by_array`, cutoff z DB)."""
|
||||
if to_ts <= from_ts:
|
||||
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||
if to_ts - from_ts > timedelta(days=120):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Span between 'from' and 'to' must be at most 120 days",
|
||||
)
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"""
|
||||
select ems.fn_pv_forecast_delta_profile(
|
||||
$1::int,
|
||||
$2::timestamptz,
|
||||
$3::timestamptz,
|
||||
$4::numeric,
|
||||
$5::int,
|
||||
$6::int,
|
||||
$7::numeric,
|
||||
$8::numeric
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
from_ts,
|
||||
to_ts,
|
||||
half_life_days,
|
||||
threshold_w,
|
||||
top_n_days,
|
||||
non_top_day_factor,
|
||||
day_weight_gamma,
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return raw
|
||||
|
||||
|
||||
@router.get("/{site_id}/timeseries/telemetry-15m")
|
||||
async def get_site_telemetry_15m_range(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
from_ts: datetime = Query(..., alias="from", description="Začátek okna [from, to)"),
|
||||
to_ts: datetime = Query(..., alias="to", description="Konec okna [from, to)"),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
if to_ts <= from_ts:
|
||||
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||
if to_ts - from_ts > timedelta(days=60):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Span between 'from' and 'to' must be at most 60 days",
|
||||
)
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
select
|
||||
slot_start,
|
||||
site_id,
|
||||
avg_pv_w,
|
||||
avg_load_w,
|
||||
avg_grid_w,
|
||||
avg_battery_w,
|
||||
last_soc_pct,
|
||||
sample_count
|
||||
from ems.telemetry_inverter_15m
|
||||
where site_id = $1
|
||||
and slot_start >= $2::timestamptz
|
||||
and slot_start < $3::timestamptz
|
||||
order by slot_start asc
|
||||
""",
|
||||
site_id,
|
||||
from_ts,
|
||||
to_ts,
|
||||
)
|
||||
return {"slots": [record_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/{site_id}/forecast/load-baseline-slots")
|
||||
async def get_site_load_baseline_slots_range(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
from_ts: datetime = Query(..., alias="from", description="Začátek okna [from, to)"),
|
||||
to_ts: datetime = Query(..., alias="to", description="Konec okna [from, to)"),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
if to_ts <= from_ts:
|
||||
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||
if to_ts - from_ts > timedelta(days=60):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Span between 'from' and 'to' must be at most 60 days",
|
||||
)
|
||||
async with db.acquire() as conn:
|
||||
site_ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not site_ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
select interval_start, forecast_w, confidence_w
|
||||
from ems.fn_get_baseline_forecast($1::int, $2::timestamptz, $3::timestamptz)
|
||||
""",
|
||||
site_id,
|
||||
from_ts,
|
||||
to_ts,
|
||||
)
|
||||
return {"slots": [record_to_dict(r) for r in rows]}
|
||||
221
backend/scripts/backfill_ote_prices.py
Normal file
221
backend/scripts/backfill_ote_prices.py
Normal file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Doplnění ems.market_interval_price z veřejného OTE JSON endpointu (stejný jako price_importer).
|
||||
|
||||
Produkce (Docker – závislosti v image backendu), z adresáře kde leží docker-compose.yml:
|
||||
|
||||
cd /opt/ems-deploy
|
||||
docker compose exec -T backend python3 scripts/backfill_ote_prices.py --dry-run
|
||||
|
||||
Nebo z kořene stacku: bash app/deploy/run_backfill_ote_prices.sh --dry-run
|
||||
|
||||
Lokálně (venv s backend/requirements.txt):
|
||||
|
||||
cd /path/to/ems-cursor
|
||||
PYTHONPATH=backend python3 backend/scripts/backfill_ote_prices.py --dry-run
|
||||
|
||||
Volby:
|
||||
--days 730 posledních N kalendářních dní (Europe/Prague), výchozí 730 ≈ 2 roky
|
||||
--from-date / --to-date pevný rozsah YYYY-MM-DD (má přednost před --days u konce rozsahu)
|
||||
--force stáhnout znovu i dny s plným počtem slotů OTE (92/96/100)
|
||||
--dry-run jen vypsat chybějící dny, bez HTTP
|
||||
--delay SEC pauza mezi dny (výchozí 0.35)
|
||||
--refresh-predictions po skončení zavolat fn_predict_negative_price_windows pro aktivní site
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
os.chdir(_BACKEND_ROOT)
|
||||
|
||||
try:
|
||||
import asyncpg
|
||||
except ModuleNotFoundError as e:
|
||||
print(
|
||||
"Chybí modul 'asyncpg' (závislost backendu).\n"
|
||||
"\n"
|
||||
"Na serveru s Docker stackem EMS spusťte skript uvnitř kontejneru backendu, např.:\n"
|
||||
" cd /opt/ems-deploy\n"
|
||||
" docker compose exec -T backend python3 scripts/backfill_ote_prices.py --dry-run\n"
|
||||
"\n"
|
||||
"Lokálně nainstalujte závislosti: pip install -r backend/requirements.txt\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1) from e
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
from services.price_importer import ( # noqa: E402
|
||||
OTE_FULL_DAY_SLOT_COUNTS,
|
||||
backfill_ote_prices,
|
||||
count_ote_slots_prague_day,
|
||||
ote_prague_day_slots_look_complete,
|
||||
)
|
||||
|
||||
PRAGUE = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
def _parse_ymd(s: str) -> date:
|
||||
y, m, d = (int(p) for p in s.split("-", 2))
|
||||
return date(y, m, d)
|
||||
|
||||
|
||||
async def _dry_run_missing(
|
||||
conn: asyncpg.Connection,
|
||||
start: date,
|
||||
end: date,
|
||||
today_prague: date,
|
||||
) -> list[date]:
|
||||
out: list[date] = []
|
||||
d = start
|
||||
while d <= end:
|
||||
if d > today_prague:
|
||||
break
|
||||
n = await count_ote_slots_prague_day(conn, d)
|
||||
if not ote_prague_day_slots_look_complete(n):
|
||||
out.append(d)
|
||||
d += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
async def _refresh_predictions_all(conn: asyncpg.Connection) -> None:
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for row in sites:
|
||||
sid = int(row["id"])
|
||||
try:
|
||||
await conn.fetch("SELECT * FROM ems.fn_predict_negative_price_windows($1, 7)", sid)
|
||||
logging.info("Predikce záporných cen obnovena pro site_id=%s", sid)
|
||||
except Exception:
|
||||
logging.exception("fn_predict_negative_price_windows selhalo pro site_id=%s", sid)
|
||||
|
||||
|
||||
async def main_async(args: argparse.Namespace) -> int:
|
||||
settings = get_settings()
|
||||
pool = await asyncpg.create_pool(
|
||||
host=settings.db_host,
|
||||
port=settings.db_port,
|
||||
user=settings.db_user,
|
||||
password=settings.db_password,
|
||||
database=settings.db_name,
|
||||
min_size=1,
|
||||
max_size=3,
|
||||
)
|
||||
try:
|
||||
today_prague = datetime.now(PRAGUE).date()
|
||||
if args.to_date:
|
||||
end = _parse_ymd(args.to_date)
|
||||
else:
|
||||
end = today_prague
|
||||
if args.from_date:
|
||||
start = _parse_ymd(args.from_date)
|
||||
else:
|
||||
start = end - timedelta(days=max(0, int(args.days) - 1))
|
||||
|
||||
if start > end:
|
||||
logging.error("--from-date je po --to-date")
|
||||
return 2
|
||||
|
||||
logging.info(
|
||||
"Rozsah backfillu: %s … %s (kurz EUR/CZK z .env = %s)",
|
||||
start.isoformat(),
|
||||
end.isoformat(),
|
||||
settings.eur_czk_rate,
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
if args.dry_run:
|
||||
missing = await _dry_run_missing(conn, start, end, today_prague)
|
||||
logging.info(
|
||||
"Dry-run: %s chybějících nebo neúplných dní (plný den = jedna z %s)",
|
||||
len(missing),
|
||||
sorted(OTE_FULL_DAY_SLOT_COUNTS),
|
||||
)
|
||||
for md in missing[:50]:
|
||||
n = await count_ote_slots_prague_day(conn, md)
|
||||
logging.info(" %s (%s slotů)", md.isoformat(), n)
|
||||
if len(missing) > 50:
|
||||
logging.info(" … a dalších %s dní", len(missing) - 50)
|
||||
return 0
|
||||
|
||||
stats = await backfill_ote_prices(
|
||||
conn,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
only_missing=not args.force,
|
||||
pause_between_days_s=float(args.delay),
|
||||
)
|
||||
logging.info(
|
||||
"Hotovo: zkontrolováno %s dní, importováno %s, přeskočeno (kompletní) %s, "
|
||||
"přeskočeno (budoucnost) %s, selhalo %s",
|
||||
stats.days_checked,
|
||||
stats.days_imported,
|
||||
stats.days_skipped_complete,
|
||||
stats.days_skipped_future,
|
||||
stats.days_failed,
|
||||
)
|
||||
for day_str, err in stats.failures[:20]:
|
||||
logging.warning(" %s: %s", day_str, err)
|
||||
if len(stats.failures) > 20:
|
||||
logging.warning(" … dalších %s chyb v seznamu", len(stats.failures) - 20)
|
||||
|
||||
if args.refresh_predictions and stats.days_imported > 0:
|
||||
await _refresh_predictions_all(conn)
|
||||
|
||||
return 1 if stats.days_failed else 0
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(levelname)s %(message)s",
|
||||
)
|
||||
parser = argparse.ArgumentParser(description="Backfill OTE cen do ems.market_interval_price")
|
||||
parser.add_argument(
|
||||
"--days",
|
||||
type=int,
|
||||
default=730,
|
||||
help="Počet dní zpět od --to-date (výchozí 730)",
|
||||
)
|
||||
parser.add_argument("--from-date", type=str, default=None, help="YYYY-MM-DD začátek rozsahu")
|
||||
parser.add_argument(
|
||||
"--to-date",
|
||||
type=str,
|
||||
default=None,
|
||||
help="YYYY-MM-DD konec rozsahu (výchozí dnes Europe/Prague)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Stáhnout znovu i dny s plným počtem slotů OTE (92/96/100)",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Jen vypsat chybějící dny")
|
||||
parser.add_argument(
|
||||
"--delay",
|
||||
type=float,
|
||||
default=0.35,
|
||||
help="Sekundy pauzy mezi dny (výchozí 0.35)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refresh-predictions",
|
||||
action="store_true",
|
||||
help="Po importu obnovit fn_predict_negative_price_windows pro aktivní lokality",
|
||||
)
|
||||
ns = parser.parse_args()
|
||||
raise SystemExit(asyncio.run(main_async(ns)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,51 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
|
||||
"""
|
||||
Naplní audit_interval pro všechny dokončené 15min intervaly
|
||||
za posledních 6 hodin které ještě nemají záznam.
|
||||
Volá PostgreSQL funkci ems.fn_fill_audit_interval().
|
||||
Naplní audit_interval pro dokončené 15min sloty přes ems.fn_fill_audit_for_site_window.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
last_complete = now.replace(
|
||||
minute=(now.minute // 15) * 15, second=0, microsecond=0
|
||||
)
|
||||
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT gs.slot
|
||||
FROM generate_series(
|
||||
$1::timestamptz - interval '6 hours',
|
||||
$1::timestamptz - interval '15 minutes',
|
||||
interval '15 minutes'
|
||||
) AS gs(slot)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ems.audit_interval ai
|
||||
WHERE ai.site_id = $2 AND ai.interval_start = gs.slot
|
||||
)
|
||||
""",
|
||||
last_complete,
|
||||
n = await db.fetchval(
|
||||
"select ems.fn_fill_audit_for_site_window($1::int, 6)",
|
||||
site_id,
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
slot = row["slot"]
|
||||
await db.execute(
|
||||
"SELECT ems.fn_fill_audit_interval($1, $2)",
|
||||
site_id,
|
||||
slot,
|
||||
)
|
||||
await db.execute(
|
||||
"SELECT ems.fn_fill_baseline_load_forecast_accuracy($1, $2)",
|
||||
site_id,
|
||||
slot,
|
||||
)
|
||||
|
||||
if rows:
|
||||
logger.info("[site=%s] Filled %s missing audit intervals", site_id, len(rows))
|
||||
if n:
|
||||
logger.info("[site=%s] Filled %s missing audit intervals", site_id, int(n))
|
||||
|
||||
3
backend/services/control/__init__.py
Normal file
3
backend/services/control/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Deye / Modbus control export (monolith v exporter_monolith.py – postupný split)."""
|
||||
|
||||
from .exporter_monolith import * # noqa: F401,F403
|
||||
2119
backend/services/control/exporter_monolith.py
Normal file
2119
backend/services/control/exporter_monolith.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _db_azimuth_to_pvlib(surface_azimuth_db_deg: float) -> float:
|
||||
"""DB: 0=jih, 90=západ, -90=východ → pvlib (N=0, E=90, S=180, W=270)."""
|
||||
return float((surface_azimuth_db_deg + 180) % 360)
|
||||
"""
|
||||
EMS DB používá standardní azimut (kompasové stupně):
|
||||
N=0, E=90, S=180, W=270 (stejně jako pvlib).
|
||||
"""
|
||||
return float(surface_azimuth_db_deg % 360)
|
||||
|
||||
|
||||
async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
|
||||
|
||||
@@ -61,7 +61,7 @@ async def send_heartbeat(
|
||||
|
||||
status = "ok" if (not endpoint or loxone_ok) else "degraded"
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_heartbeat($1, $2, $3)",
|
||||
"select ems.fn_update_heartbeat($1, $2, $3)",
|
||||
site_id,
|
||||
status,
|
||||
EMS_BACKEND_VERSION,
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
@@ -12,6 +14,42 @@ from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WEBHOOK_CACHE: dict[tuple[int, str], str] = {}
|
||||
_OTE_IMPORT_ALERT_CACHE: dict[tuple[str, str], float] = {}
|
||||
_OTE_IMPORT_OK_CACHE: dict[str, float] = {}
|
||||
|
||||
|
||||
async def _get_site_webhook_url(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
kind: str,
|
||||
) -> str:
|
||||
"""
|
||||
kind: 'daily' | 'error'
|
||||
Fallback: settings.discord_webhook_url
|
||||
"""
|
||||
settings = get_settings()
|
||||
if site_id is None:
|
||||
return settings.discord_webhook_url
|
||||
cache_key = (int(site_id), str(kind))
|
||||
cached = _WEBHOOK_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if conn is None:
|
||||
return settings.discord_webhook_url
|
||||
col = "discord_webhook_daily_url" if kind == "daily" else "discord_webhook_error_url"
|
||||
try:
|
||||
url = await conn.fetchval(
|
||||
f"select {col} from ems.site where id = $1::int",
|
||||
int(site_id),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to load site webhook url site_id=%s kind=%s", site_id, kind)
|
||||
url = None
|
||||
final = str(url or settings.discord_webhook_url or "")
|
||||
_WEBHOOK_CACHE[cache_key] = final
|
||||
return final
|
||||
|
||||
|
||||
def _discord_level_for_mode_change(activated_by: str) -> str:
|
||||
if activated_by == "system:mismatch":
|
||||
@@ -22,6 +60,8 @@ def _discord_level_for_mode_change(activated_by: str) -> str:
|
||||
|
||||
|
||||
async def notify_operating_mode_changed(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
previous_mode: str,
|
||||
new_mode: str,
|
||||
@@ -37,7 +77,33 @@ async def notify_operating_mode_changed(
|
||||
f"**{previous_mode}** → **{new_mode}**\n"
|
||||
f"Aktivoval: `{activated_by}`{note_line}"
|
||||
)
|
||||
await send_discord(msg, level=lvl)
|
||||
await send_discord(conn, site_id, msg, level=lvl)
|
||||
|
||||
|
||||
async def _auto_rolling_replan_after_self_sustain_exit(site_id: int) -> None:
|
||||
"""Po návratu z SELF_SUSTAIN do AUTO přepočítat rolling plán (nové DB spojení)."""
|
||||
try:
|
||||
from app.deps import get_pg_pool
|
||||
from services.planning_engine import run_plan_api
|
||||
|
||||
pool = await get_pg_pool()
|
||||
except Exception as e:
|
||||
logger.warning("Auto replan after SELF_SUSTAIN→AUTO: pool unavailable: %s", e)
|
||||
return
|
||||
try:
|
||||
async with pool.acquire() as replan_conn:
|
||||
await run_plan_api(
|
||||
site_id,
|
||||
"rolling",
|
||||
replan_conn,
|
||||
triggered_by="mode:self_sustain_exit",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Auto rolling replan after SELF_SUSTAIN→AUTO failed: %s",
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def run_fn_set_mode_with_discord(
|
||||
@@ -51,32 +117,29 @@ async def run_fn_set_mode_with_discord(
|
||||
notify_level: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Zavolá ems.fn_set_mode. Při skutečné změně režimu pošle Discord (pokud je webhook).
|
||||
Zavolá ems.fn_set_mode_with_context. Při skutečné změně režimu pošle Discord (pokud je webhook).
|
||||
Vrátí aktuální mode_code z DB po volání.
|
||||
"""
|
||||
prev = await conn.fetchval(
|
||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
|
||||
raw = await conn.fetchval(
|
||||
"""
|
||||
select ems.fn_set_mode_with_context($1::int, $2::text, $3::text, $4::timestamptz, $5::text)
|
||||
""",
|
||||
site_id,
|
||||
mode_code,
|
||||
activated_by,
|
||||
valid_until,
|
||||
notes,
|
||||
)
|
||||
new = await conn.fetchval(
|
||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
ctx = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
prev = ctx.get("previous_mode")
|
||||
new = ctx.get("new_mode")
|
||||
if new is None:
|
||||
new = mode_code
|
||||
site_code = ctx.get("site_code")
|
||||
if prev is not None and prev != new:
|
||||
site_code = await conn.fetchval(
|
||||
"SELECT code FROM ems.site WHERE id = $1", site_id
|
||||
)
|
||||
await notify_operating_mode_changed(
|
||||
conn,
|
||||
site_id,
|
||||
site_code or str(site_id),
|
||||
str(prev),
|
||||
str(new),
|
||||
@@ -84,17 +147,54 @@ async def run_fn_set_mode_with_discord(
|
||||
notes,
|
||||
level=notify_level,
|
||||
)
|
||||
prev_u = str(prev).upper()
|
||||
new_u = str(new).upper()
|
||||
if prev_u == "SELF_SUSTAIN" and new_u == "AUTO":
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(
|
||||
_auto_rolling_replan_after_self_sustain_exit(site_id)
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.debug("No event loop; skip auto rolling replan")
|
||||
return str(new)
|
||||
|
||||
|
||||
async def send_discord(message: str, level: str = "info") -> bool:
|
||||
async def notify_plan_vs_actual_fatal(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
slot_label: str,
|
||||
interval_start_utc: datetime,
|
||||
plan_grid_w: int,
|
||||
actual_grid_w: int,
|
||||
deviation_grid_w: int,
|
||||
reason_code: str,
|
||||
detail: str,
|
||||
) -> None:
|
||||
"""Discord po fatální odchylce plán vs. audit (síť) pro uzavřený 15min slot."""
|
||||
utc_label = interval_start_utc.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
msg = (
|
||||
f"**Fatální odchylka plán vs. realita (síť)** – `{site_code}`\n"
|
||||
f"Slot: **{slot_label}** (`{utc_label}`)\n"
|
||||
f"**{reason_code}**: {detail}\n"
|
||||
f"Plán grid: **{plan_grid_w}** W | Skutečnost: **{actual_grid_w}** W | Δ (act−plan): **{deviation_grid_w}** W"
|
||||
)
|
||||
await send_discord(conn, site_id, msg, level="critical")
|
||||
|
||||
|
||||
async def send_discord(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
message: str,
|
||||
level: str = "info",
|
||||
) -> bool:
|
||||
"""
|
||||
Pošle notifikaci na Discord webhook.
|
||||
level: 'info', 'warning', 'error', 'critical'
|
||||
Vrátí True při úspěchu.
|
||||
"""
|
||||
settings = get_settings()
|
||||
webhook_url = settings.discord_webhook_url
|
||||
kind = "daily" if level == "info" else "error"
|
||||
webhook_url = await _get_site_webhook_url(conn, site_id, kind)
|
||||
if not webhook_url:
|
||||
logger.debug("Discord webhook not configured, skipping notification")
|
||||
return False
|
||||
@@ -116,7 +216,108 @@ async def send_discord(message: str, level: str = "info") -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _should_send_ote_alert(date_str: str, signature: str, *, cooldown_s: float) -> bool:
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
key = (str(date_str), str(signature))
|
||||
last = _OTE_IMPORT_ALERT_CACHE.get(key)
|
||||
if last is not None and (now - last) < cooldown_s:
|
||||
return False
|
||||
_OTE_IMPORT_ALERT_CACHE[key] = now
|
||||
return True
|
||||
|
||||
|
||||
async def notify_ote_import_format_changed(
|
||||
conn: asyncpg.Connection | None,
|
||||
*,
|
||||
report_date: str,
|
||||
error_detail: str,
|
||||
url: str,
|
||||
) -> None:
|
||||
"""
|
||||
Discord alert pro situaci, kdy OTE změnilo formát chart-data a import selže na parseru v DB.
|
||||
|
||||
Dedup: stejný report_date + stejná chyba se pošle max 1× za cooldown.
|
||||
"""
|
||||
signature = (error_detail or "").strip().splitlines()[0][:160]
|
||||
if not _should_send_ote_alert(report_date, signature, cooldown_s=6 * 3600):
|
||||
return
|
||||
|
||||
detail = (error_detail or "").strip()
|
||||
if len(detail) > 1600:
|
||||
detail = detail[:1600] + "…"
|
||||
msg = (
|
||||
f"**OTE import selhal – pravděpodobná změna formátu dat**\n"
|
||||
f"Report date: `{report_date}`\n"
|
||||
f"URL: `{url}`\n"
|
||||
f"Chyba: {detail}\n"
|
||||
f"Doporučení: zkontrolovat `ems.fn_ote_parse_15m_price_json` (tooltipy / struktura payloadu) "
|
||||
f"a upravit parser."
|
||||
)
|
||||
await send_discord(conn, site_id=None, message=msg, level="critical")
|
||||
|
||||
|
||||
def _should_send_ote_ok(report_date: str, *, cooldown_s: float) -> bool:
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
key = str(report_date)
|
||||
last = _OTE_IMPORT_OK_CACHE.get(key)
|
||||
if last is not None and (now - last) < cooldown_s:
|
||||
return False
|
||||
_OTE_IMPORT_OK_CACHE[key] = now
|
||||
return True
|
||||
|
||||
|
||||
async def notify_ote_import_ok_brief(
|
||||
conn: asyncpg.Connection | None,
|
||||
*,
|
||||
report_date: str,
|
||||
brief: dict,
|
||||
url: str,
|
||||
) -> None:
|
||||
"""
|
||||
Info notifikace po úspěšném importu kompletního dne OTE (stručná analýza "co čekat zítra").
|
||||
Dedup: 1× za cooldown na report_date.
|
||||
"""
|
||||
if not _should_send_ote_ok(report_date, cooldown_s=20 * 3600):
|
||||
return
|
||||
|
||||
def _f(x, default: float = 0.0) -> float:
|
||||
try:
|
||||
if x is None:
|
||||
return default
|
||||
return float(x)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
min_p = _f(brief.get("min_price"))
|
||||
max_p = _f(brief.get("max_price"))
|
||||
|
||||
raw_signals = brief.get("signals") or []
|
||||
signals: list[str] = []
|
||||
if isinstance(raw_signals, list):
|
||||
for s in raw_signals[:6]:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
title = str(s.get("title") or s.get("code") or "").strip()
|
||||
detail = str(s.get("detail") or "").strip()
|
||||
if title and detail:
|
||||
signals.append(f"{title} ({detail})")
|
||||
elif title:
|
||||
signals.append(title)
|
||||
if not signals:
|
||||
signals.append("běžný den (bez extrémů)")
|
||||
|
||||
msg = (
|
||||
f"OTE ceny staženy – `{report_date}`\n"
|
||||
f"URL: `{url}`\n"
|
||||
f"Min: **{min_p:.3f}** | Max: **{max_p:.3f}** Kč/kWh\n"
|
||||
f"Signály: " + "; ".join(f"**{s}**" for s in signals)
|
||||
)
|
||||
await send_discord(conn, site_id=None, message=msg, level="info")
|
||||
|
||||
|
||||
async def notify_modbus_mismatch(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
asset_code: str,
|
||||
register: int,
|
||||
register_name: str,
|
||||
@@ -130,18 +331,25 @@ async def notify_modbus_mismatch(
|
||||
f"Zapsáno: `{value_written}` | Přečteno: `{value_verified}`\n"
|
||||
f"Pokus č. {attempt}"
|
||||
)
|
||||
await send_discord(msg, level="error")
|
||||
await send_discord(conn, site_id, msg, level="error")
|
||||
|
||||
|
||||
async def notify_self_sustain_activated(site_code: str, reason: str) -> None:
|
||||
async def notify_self_sustain_activated(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
msg = (
|
||||
f"Přepnutí na **SELF_SUSTAIN** – lokalita `{site_code}`\n"
|
||||
f"Důvod: {reason}"
|
||||
)
|
||||
await send_discord(msg, level="critical")
|
||||
await send_discord(conn, site_id, msg, level="critical")
|
||||
|
||||
|
||||
async def notify_modbus_clock_verify_exhausted(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
asset_code: str,
|
||||
written: tuple[int, int, int],
|
||||
@@ -153,10 +361,12 @@ async def notify_modbus_clock_verify_exhausted(
|
||||
f"Zapsáno: `{written}` | Přečteno: `{actual}`\n"
|
||||
f"Doporučení: zkontrolovat firmware/RS485; režim EMS se nemění automaticky."
|
||||
)
|
||||
await send_discord(msg, level="critical")
|
||||
await send_discord(conn, site_id, msg, level="critical")
|
||||
|
||||
|
||||
async def notify_daily_economics(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
day: str,
|
||||
import_kwh: float,
|
||||
@@ -183,4 +393,4 @@ async def notify_daily_economics(
|
||||
f" Plán předpokládal: {planned_balance:+.2f} Kč "
|
||||
f"(odchylka {dev_sign}{dev:.2f} Kč)"
|
||||
)
|
||||
await send_discord("\n".join(lines), level="info")
|
||||
await send_discord(conn, site_id, "\n".join(lines), level="info")
|
||||
|
||||
119
backend/services/plan_actual_slot_guard.py
Normal file
119
backend/services/plan_actual_slot_guard.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Kontrola plán vs. skutečnost po uzavření 15min slotu.
|
||||
|
||||
Pravidla a dedup INSERT drží ems.fn_plan_actual_slot_guard_site / fn_plan_actual_slot_guard_all_active
|
||||
(repeatable R__076). Python jen zavolá funkci a pošle Discord podle vrácených alertů.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from services.notification_service import notify_plan_vs_actual_fatal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PRAGUE = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
def _interval_start_utc(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
if isinstance(value, str):
|
||||
s = value.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(s)
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
raise TypeError(f"expected datetime or str for interval_start, got {type(value)!r}")
|
||||
|
||||
|
||||
def _slot_label_prague(interval_start: datetime) -> str:
|
||||
loc = interval_start.astimezone(_PRAGUE)
|
||||
return loc.strftime("%Y-%m-%d %H:%M") + " Europe/Prague"
|
||||
|
||||
|
||||
async def _dispatch_site_result(site_payload: dict[str, Any]) -> None:
|
||||
if site_payload.get("error") == "unknown_site":
|
||||
logger.warning("plan_actual_slot_guard: unknown site_id=%s", site_payload.get("site_id"))
|
||||
return
|
||||
site_code = str(site_payload.get("site_code") or site_payload.get("site_id") or "")
|
||||
site_id = int(site_payload.get("site_id") or 0) or None
|
||||
alerts = site_payload.get("alerts")
|
||||
if not isinstance(alerts, list):
|
||||
return
|
||||
for alert in alerts:
|
||||
if not isinstance(alert, dict):
|
||||
continue
|
||||
if not alert.get("notify"):
|
||||
continue
|
||||
interval_start = _interval_start_utc(alert["interval_start"])
|
||||
reason_code = str(alert.get("reason_code") or "")
|
||||
detail = str(alert.get("detail") or "")
|
||||
plan_grid_w = int(alert.get("plan_grid_w") or 0)
|
||||
actual_grid_w = int(alert.get("actual_grid_w") or 0)
|
||||
deviation_grid_w = int(alert.get("deviation_grid_w") or 0)
|
||||
slot_label = _slot_label_prague(interval_start)
|
||||
await notify_plan_vs_actual_fatal(
|
||||
None,
|
||||
site_id,
|
||||
site_code=site_code,
|
||||
slot_label=slot_label,
|
||||
interval_start_utc=interval_start,
|
||||
plan_grid_w=plan_grid_w,
|
||||
actual_grid_w=actual_grid_w,
|
||||
deviation_grid_w=deviation_grid_w,
|
||||
reason_code=reason_code,
|
||||
detail=detail,
|
||||
)
|
||||
logger.warning(
|
||||
"[site=%s] plan_actual fatal %s slot=%s: %s",
|
||||
site_payload.get("site_id"),
|
||||
reason_code,
|
||||
interval_start.isoformat(),
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
async def run_plan_actual_slot_guard_for_all_active_sites(
|
||||
pool: asyncpg.Pool,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""Scheduler: jeden dotaz přes aktivní lokality (SQL dedup + klasifikace)."""
|
||||
async with pool.acquire() as conn:
|
||||
try:
|
||||
if now is not None:
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"SELECT ems.fn_plan_actual_slot_guard_all_active($1::timestamptz)",
|
||||
now,
|
||||
)
|
||||
else:
|
||||
raw = await fetch_json(conn, "SELECT ems.fn_plan_actual_slot_guard_all_active()")
|
||||
except Exception:
|
||||
logger.exception("plan_actual_slot_guard fn_plan_actual_slot_guard_all_active failed")
|
||||
return
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, list):
|
||||
logger.warning("plan_actual_slot_guard: unexpected payload type %s", type(raw))
|
||||
return
|
||||
for site_payload in raw:
|
||||
if not isinstance(site_payload, dict):
|
||||
continue
|
||||
try:
|
||||
await _dispatch_site_result(site_payload)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"plan_actual_slot_guard site=%s failed",
|
||||
site_payload.get("site_id"),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,32 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db_json import fetch_json
|
||||
from services.notification_service import (
|
||||
notify_ote_import_format_changed,
|
||||
notify_ote_import_ok_brief,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Běžný kalendářní den na DAM = 96 čtvrthodin; 92 při přechodu na letní čas, 100 na zimní.
|
||||
OTE_TYPICAL_SLOTS = 96
|
||||
OTE_FULL_DAY_SLOT_COUNTS: frozenset[int] = frozenset({92, 96, 100})
|
||||
# Zpětná kompatibilita ve starších importech
|
||||
OTE_EXPECTED_SLOTS = OTE_TYPICAL_SLOTS
|
||||
|
||||
|
||||
def ote_prague_day_slots_look_complete(slot_count: int) -> bool:
|
||||
"""True, pokud počet řádků odpovídá celému obchodnímu dni OTE (včetně DST)."""
|
||||
return slot_count in OTE_FULL_DAY_SLOT_COUNTS
|
||||
|
||||
OTE_URL = (
|
||||
"https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/"
|
||||
"@@chart-data?report_date={date}&time_resolution=PT15M"
|
||||
@@ -93,6 +110,155 @@ async def _fetch_ote_json(date_str: str) -> tuple[dict | None, str | None]:
|
||||
OTE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
async def _apply_ote_json_to_db(conn, payload: dict) -> int:
|
||||
"""Zapíše JSON z OTE přes ems.fn_ote_import_from_json; vrátí ROW_COUNT z funkce."""
|
||||
settings = get_settings()
|
||||
eur_czk = float(settings.eur_czk_rate)
|
||||
n = await conn.fetchval(
|
||||
"SELECT ems.fn_ote_import_from_json($1::jsonb, $2)",
|
||||
json.dumps(payload),
|
||||
eur_czk,
|
||||
)
|
||||
return int(n)
|
||||
|
||||
|
||||
async def count_ote_slots_prague_day(conn, target_day: date) -> int:
|
||||
"""Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100)."""
|
||||
stats = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||
target_day,
|
||||
)
|
||||
if not isinstance(stats, dict):
|
||||
stats = json.loads(stats)
|
||||
return int(stats.get("count") or 0)
|
||||
|
||||
|
||||
async def import_ote_prices_for_day(
|
||||
conn,
|
||||
target_day: date,
|
||||
) -> tuple[int, str, float, str | None]:
|
||||
"""
|
||||
Stáhne OTE pro jeden konkrétní report_date a uloží přes fn_ote_import_from_json.
|
||||
Stejný význam návratové hodnoty jako import_ote_prices().
|
||||
"""
|
||||
day_str = target_day.isoformat()
|
||||
payload, fetch_error = await _fetch_ote_json(day_str)
|
||||
if payload is None:
|
||||
return -1, day_str, 0.0, fetch_error or "fetch_failed"
|
||||
try:
|
||||
n = await _apply_ote_json_to_db(conn, payload)
|
||||
stats_after = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||
target_day,
|
||||
)
|
||||
if not isinstance(stats_after, dict):
|
||||
stats_after = json.loads(stats_after)
|
||||
first_price = stats_after.get("first_price")
|
||||
n_imported = int(stats_after.get("count") or 0)
|
||||
is_complete = bool(stats_after.get("is_complete"))
|
||||
if not ote_prague_day_slots_look_complete(n_imported):
|
||||
logger.warning(
|
||||
"OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)",
|
||||
n_imported,
|
||||
day_str,
|
||||
sorted(OTE_FULL_DAY_SLOT_COUNTS),
|
||||
)
|
||||
if is_complete:
|
||||
brief = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ote_day_signals_prague($1::date, $2::int)",
|
||||
target_day,
|
||||
14,
|
||||
)
|
||||
if not isinstance(brief, dict):
|
||||
brief = json.loads(brief)
|
||||
await notify_ote_import_ok_brief(
|
||||
conn,
|
||||
report_date=day_str,
|
||||
brief=brief if isinstance(brief, dict) else {},
|
||||
url=OTE_URL.format(date=day_str),
|
||||
)
|
||||
logger.info(
|
||||
"OTE import OK: %s slotů (upsert) pro %s, první cena %.4f Kč/kWh",
|
||||
n,
|
||||
day_str,
|
||||
float(first_price or 0),
|
||||
)
|
||||
return n, day_str, float(first_price or 0.0), None
|
||||
except Exception as e:
|
||||
detail = str(e).strip() or e.__class__.__name__
|
||||
logger.error("OTE import DB error pro %s: %s", day_str, detail, exc_info=True)
|
||||
if (
|
||||
"OTE price dataLine not found" in detail
|
||||
or "OTE price series:" in detail
|
||||
or "cannot parse date from graph.title" in detail
|
||||
):
|
||||
await notify_ote_import_format_changed(
|
||||
conn,
|
||||
report_date=day_str,
|
||||
error_detail=detail,
|
||||
url=OTE_URL.format(date=day_str),
|
||||
)
|
||||
short = detail[:200] if len(detail) > 200 else detail
|
||||
return -1, day_str, 0.0, f"db_import:{e.__class__.__name__}: {short}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OteBackfillStats:
|
||||
start_date: date
|
||||
end_date: date
|
||||
days_checked: int = 0
|
||||
days_imported: int = 0
|
||||
days_skipped_complete: int = 0
|
||||
days_skipped_future: int = 0
|
||||
days_failed: int = 0
|
||||
failures: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
async def backfill_ote_prices(
|
||||
conn,
|
||||
*,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
only_missing: bool = True,
|
||||
pause_between_days_s: float = 0.35,
|
||||
max_failures_logged: int = 80,
|
||||
) -> OteBackfillStats:
|
||||
"""
|
||||
Projde rozsah [start_date, end_date] (kalendář Prague) a doplní chybějící dny z OTE.
|
||||
|
||||
only_missing: přeskočí dny, kde už je „plný“ počet slotů (92/96/100 dle OTE).
|
||||
pause_between_days_s: krátká pauza mezi HTTP požadavky (ohleduplnost k OTE).
|
||||
"""
|
||||
stats = OteBackfillStats(start_date=start_date, end_date=end_date)
|
||||
today_prague = datetime.now(OTE_TZ).date()
|
||||
d = start_date
|
||||
while d <= end_date:
|
||||
stats.days_checked += 1
|
||||
if d > today_prague:
|
||||
stats.days_skipped_future += 1
|
||||
d += timedelta(days=1)
|
||||
continue
|
||||
slots = await count_ote_slots_prague_day(conn, d)
|
||||
if only_missing and ote_prague_day_slots_look_complete(slots):
|
||||
stats.days_skipped_complete += 1
|
||||
d += timedelta(days=1)
|
||||
continue
|
||||
n, day_str, _, err = await import_ote_prices_for_day(conn, d)
|
||||
if n < 0:
|
||||
stats.days_failed += 1
|
||||
if len(stats.failures) < max_failures_logged:
|
||||
stats.failures.append((day_str, err or "unknown"))
|
||||
else:
|
||||
stats.days_imported += 1
|
||||
if pause_between_days_s > 0:
|
||||
await asyncio.sleep(pause_between_days_s)
|
||||
d += timedelta(days=1)
|
||||
return stats
|
||||
|
||||
|
||||
async def import_ote_prices(
|
||||
db,
|
||||
site_id: int | None = None,
|
||||
@@ -105,11 +271,9 @@ async def import_ote_prices(
|
||||
Returns: (počet_slotů, datum_str, první_cena_kč_kwh, error_code)
|
||||
(-1, datum_str, 0.0, error_code) při chybě
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
if site_id is not None:
|
||||
row = await db.fetchrow(
|
||||
"SELECT timezone FROM ems.site WHERE id = $1", site_id
|
||||
"select timezone from ems.vw_site_directory where id = $1", site_id
|
||||
)
|
||||
if row is None:
|
||||
logger.error("OTE import: site id=%s nenalezen", site_id)
|
||||
@@ -149,35 +313,19 @@ async def import_ote_prices(
|
||||
|
||||
date_str = target_day.isoformat()
|
||||
|
||||
# Vše ostatní řeší PostgreSQL funkce
|
||||
eur_czk = float(settings.eur_czk_rate)
|
||||
try:
|
||||
n = await db.fetchval(
|
||||
"SELECT ems.fn_ote_import_from_json($1::jsonb, $2)",
|
||||
json.dumps(payload),
|
||||
eur_czk,
|
||||
)
|
||||
first_price = await db.fetchval(
|
||||
"""
|
||||
SELECT buy_raw_price_czk_kwh
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source = 'OTE_CZ'
|
||||
AND interval_start::date = $1::date
|
||||
ORDER BY interval_start
|
||||
LIMIT 1
|
||||
""",
|
||||
n = await _apply_ote_json_to_db(db, payload)
|
||||
stats_after = await fetch_json(
|
||||
db,
|
||||
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||
target_day,
|
||||
)
|
||||
n_imported = await db.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*)::int
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source = 'OTE_CZ'
|
||||
AND interval_start::date = $1::date
|
||||
""",
|
||||
target_day,
|
||||
)
|
||||
incomplete = (n_imported or 0) < 96
|
||||
if not isinstance(stats_after, dict):
|
||||
stats_after = json.loads(stats_after)
|
||||
first_price = stats_after.get("first_price")
|
||||
n_imported = int(stats_after.get("count") or 0)
|
||||
is_complete = bool(stats_after.get("is_complete"))
|
||||
incomplete = not ote_prague_day_slots_look_complete(n_imported or 0)
|
||||
if incomplete:
|
||||
now_p = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
tomorrow_p = (now_p + timedelta(days=1)).date()
|
||||
@@ -186,14 +334,47 @@ async def import_ote_prices(
|
||||
target_day == tomorrow_p
|
||||
and (now_p.hour, now_p.minute) < (14, 30)
|
||||
):
|
||||
logger.warning("OTE: jen %s/96 slotů pro %s", n_imported, date_str)
|
||||
logger.warning(
|
||||
"OTE: %s slotů pro %s (plný den = jedna z %s)",
|
||||
n_imported,
|
||||
date_str,
|
||||
sorted(OTE_FULL_DAY_SLOT_COUNTS),
|
||||
)
|
||||
if is_complete:
|
||||
brief = await fetch_json(
|
||||
db,
|
||||
"select ems.fn_ote_day_signals_prague($1::date, $2::int)",
|
||||
target_day,
|
||||
14,
|
||||
)
|
||||
if not isinstance(brief, dict):
|
||||
brief = json.loads(brief)
|
||||
await notify_ote_import_ok_brief(
|
||||
db,
|
||||
report_date=date_str,
|
||||
brief=brief if isinstance(brief, dict) else {},
|
||||
url=OTE_URL.format(date=date_str),
|
||||
)
|
||||
logger.info(
|
||||
"OTE import OK: %s slotů pro %s, první cena %.4f Kč/kWh",
|
||||
n, date_str, float(first_price or 0),
|
||||
n,
|
||||
date_str,
|
||||
float(first_price or 0),
|
||||
)
|
||||
return int(n), date_str, float(first_price or 0.0), None
|
||||
except Exception as e:
|
||||
detail = str(e).strip() or e.__class__.__name__
|
||||
logger.error("OTE import DB error: %s", detail, exc_info=True)
|
||||
if (
|
||||
"OTE price dataLine not found" in detail
|
||||
or "OTE price series:" in detail
|
||||
or "cannot parse date from graph.title" in detail
|
||||
):
|
||||
await notify_ote_import_format_changed(
|
||||
db,
|
||||
report_date=date_str,
|
||||
error_detail=detail,
|
||||
url=OTE_URL.format(date=date_str),
|
||||
)
|
||||
short = detail[:200] if len(detail) > 200 else detail
|
||||
return -1, date_str, 0.0, f"db_import:{e.__class__.__name__}: {short}"
|
||||
|
||||
714
backend/services/signal_service.py
Normal file
714
backend/services/signal_service.py
Normal file
@@ -0,0 +1,714 @@
|
||||
"""
|
||||
Odchozí signály EMS → Loxone / HTTP (journal, retry, readback verify).
|
||||
|
||||
Kritické řízení výkonu (Deye, EV, TČ) zůstává v Modbus exporteru a modbus_command.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SIGNAL_EXPORT_BAN_ACTIVE = "EXPORT_BAN_ACTIVE"
|
||||
|
||||
# Po úspěšném verify neposílat stejnou hodnotu znovu po tuto dobu (idempotence).
|
||||
_IDEMPOTENCE_TTL = timedelta(minutes=10)
|
||||
# Max pokusů před abandoned (odeslání + verify dohromady řídí attempt_count).
|
||||
_MAX_ATTEMPTS = 12
|
||||
_VERIFY_AFTER_SEND = timedelta(seconds=1)
|
||||
|
||||
|
||||
def _loxone_auth() -> tuple[str, str] | None:
|
||||
settings = get_settings()
|
||||
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
|
||||
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
|
||||
return (user, password) if user else None
|
||||
|
||||
|
||||
def _endpoint_base_url(proto: str | None, host: str, port: int | None) -> str:
|
||||
p = (proto or "http").lower()
|
||||
if p not in ("http", "https"):
|
||||
p = "http"
|
||||
prt = int(port or (443 if p == "https" else 80))
|
||||
return f"{p}://{host}:{prt}"
|
||||
|
||||
|
||||
def _bool_to_text(v: bool, transform_json: dict[str, Any] | None) -> str:
|
||||
if transform_json and "map_bool" in transform_json:
|
||||
m = transform_json["map_bool"]
|
||||
if isinstance(m, dict):
|
||||
return str(m.get("true" if v else "false", "1" if v else "0"))
|
||||
return "1" if v else "0"
|
||||
|
||||
|
||||
def _parse_loxone_io_value(body: str) -> float | None:
|
||||
"""Z odpovědi Loxone /dev/sps/io/… vytáhni číselnou hodnotu."""
|
||||
if not body:
|
||||
return None
|
||||
s = body.strip()
|
||||
# často XML nebo prostý text s číslem
|
||||
nums = re.findall(r"-?\d+(?:\.\d+)?", s)
|
||||
if not nums:
|
||||
return None
|
||||
try:
|
||||
return float(nums[-1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _http_rest_write_url(
|
||||
base: str, route_config_json: dict[str, Any] | None, value_text: str
|
||||
) -> tuple[str, str]:
|
||||
"""Vrátí (method, url) pro http_rest zápis."""
|
||||
cfg = route_config_json or {}
|
||||
method = str(cfg.get("method", "GET")).upper()
|
||||
path = str(cfg.get("path_template", ""))
|
||||
path = path.replace("{value}", value_text).replace("{v}", value_text)
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return method, f"{base.rstrip('/')}{path}"
|
||||
|
||||
|
||||
def _http_rest_verify_url(base: str, verify_cfg: dict[str, Any] | None) -> str | None:
|
||||
if not verify_cfg:
|
||||
return None
|
||||
path = str(verify_cfg.get("read_path", ""))
|
||||
if not path:
|
||||
return None
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return f"{base.rstrip('/')}{path}"
|
||||
|
||||
|
||||
def _read_json_path(data: Any, path: str | None) -> Any:
|
||||
if path is None or path == "" or path == "$":
|
||||
return data
|
||||
if path.startswith("$."):
|
||||
path = path[2:]
|
||||
cur: Any = data
|
||||
for part in path.split("."):
|
||||
if not part:
|
||||
continue
|
||||
if isinstance(cur, dict) and part in cur:
|
||||
cur = cur[part]
|
||||
else:
|
||||
return None
|
||||
return cur
|
||||
|
||||
|
||||
async def compute_export_ban_active(site_id: int, conn: asyncpg.Connection) -> bool:
|
||||
"""
|
||||
Kanonický význam EXPORT_BAN_ACTIVE (LED varianta B).
|
||||
|
||||
True pokud EMS uplatňuje zákaz exportu: no_export, block_export override,
|
||||
režimy bez exportu (SELF_SUSTAIN, CHARGE_CHEAP, PRESERVE), nebo AUTO se záporným
|
||||
výkupem při grid_setpoint_w >= 0 (soulad s _build_setpoints / export_ban), včetně
|
||||
price failsafe (predikovaná cena → pasivní ochrana).
|
||||
"""
|
||||
mode_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT som.mode_code
|
||||
FROM ems.site_operating_mode som
|
||||
WHERE som.site_id = $1::int
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if mode_row is None:
|
||||
return False
|
||||
mode_code = str(mode_row["mode_code"] or "").upper()
|
||||
|
||||
if mode_code == "MANUAL":
|
||||
return False
|
||||
|
||||
if mode_code in ("SELF_SUSTAIN", "CHARGE_CHEAP", "PRESERVE"):
|
||||
return True
|
||||
|
||||
no_export = await conn.fetchval(
|
||||
"""
|
||||
SELECT COALESCE(sgc.no_export, false)
|
||||
FROM ems.site_grid_connection sgc
|
||||
WHERE sgc.site_id = $1::int
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if bool(no_export):
|
||||
return True
|
||||
|
||||
ov = await conn.fetchval(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM ems.site_override o
|
||||
WHERE o.site_id = $1::int
|
||||
AND o.override_type = 'block_export'
|
||||
AND o.valid_from <= now()
|
||||
AND (o.valid_to IS NULL OR o.valid_to > now())
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if ov is not None:
|
||||
return True
|
||||
|
||||
if mode_code != "AUTO":
|
||||
return False
|
||||
|
||||
raw = await conn.fetchval(
|
||||
"""
|
||||
SELECT ems.fn_planning_interval_at_offset($1::int, 0)
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if raw is None:
|
||||
return False
|
||||
pi = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
if not pi:
|
||||
return False
|
||||
|
||||
if bool(pi.get("is_predicted_price")):
|
||||
return True
|
||||
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
grid_sp = int(pi.get("grid_setpoint_w") or 0)
|
||||
if sell_raw is None:
|
||||
return False
|
||||
try:
|
||||
sell_f = float(sell_raw)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return sell_f < 0 and grid_sp >= 0
|
||||
|
||||
|
||||
async def _should_skip_enqueue(
|
||||
conn: asyncpg.Connection,
|
||||
site_id: int,
|
||||
signal_code: str,
|
||||
destination_type: str,
|
||||
destination_key: str,
|
||||
desired_text: str,
|
||||
) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT last_sent_value_text, last_verified_value_text, last_verified_at
|
||||
FROM ems.signal_state
|
||||
WHERE site_id = $1
|
||||
AND signal_code = $2
|
||||
AND destination_type = $3
|
||||
AND destination_key = $4
|
||||
""",
|
||||
site_id,
|
||||
signal_code,
|
||||
destination_type,
|
||||
destination_key,
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
if row["last_sent_value_text"] != desired_text:
|
||||
return False
|
||||
if row["last_verified_value_text"] != desired_text:
|
||||
return False
|
||||
lv = row["last_verified_at"]
|
||||
if lv is None:
|
||||
return False
|
||||
if lv.tzinfo is None:
|
||||
lv = lv.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) - lv < _IDEMPOTENCE_TTL
|
||||
|
||||
|
||||
async def enqueue_site_signals(site_id: int, conn: asyncpg.Connection) -> None:
|
||||
"""Zařadí odchozí řádky pro všechny aktivní routy daného site (po výpočtu signálů)."""
|
||||
export_ban = await compute_export_ban_active(site_id, conn)
|
||||
desired = {SIGNAL_EXPORT_BAN_ACTIVE: export_ban}
|
||||
|
||||
routes = await conn.fetch(
|
||||
"""
|
||||
SELECT r.id, r.site_id, r.destination_type, r.endpoint_id, r.signal_code,
|
||||
r.destination_key, r.transform_json, r.verify_readback, r.verify_config_json,
|
||||
r.route_config_json, r.enabled
|
||||
FROM ems.signal_route r
|
||||
WHERE r.site_id = $1::int AND r.enabled = true
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
for r in routes:
|
||||
sig = str(r["signal_code"])
|
||||
if sig not in desired:
|
||||
continue
|
||||
dest_type = str(r["destination_type"])
|
||||
dest_key = str(r["destination_key"])
|
||||
tf = r["transform_json"]
|
||||
tfd = tf if isinstance(tf, dict) else (json.loads(tf) if tf else None)
|
||||
val_bool = bool(desired[sig])
|
||||
value_text = _bool_to_text(val_bool, tfd)
|
||||
|
||||
if await _should_skip_enqueue(
|
||||
conn, site_id, sig, dest_type, dest_key, value_text
|
||||
):
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.signal_state (
|
||||
site_id, signal_code, destination_type, destination_key,
|
||||
last_desired_value_text, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (site_id, signal_code, destination_type, destination_key)
|
||||
DO UPDATE SET
|
||||
last_desired_value_text = EXCLUDED.last_desired_value_text,
|
||||
updated_at = now()
|
||||
""",
|
||||
site_id,
|
||||
sig,
|
||||
dest_type,
|
||||
dest_key,
|
||||
value_text,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.signal_outbound_journal (
|
||||
route_id, site_id, signal_code, value_text, value_num, status,
|
||||
attempt_count, next_attempt_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'queued', 0, now())
|
||||
""",
|
||||
int(r["id"]),
|
||||
site_id,
|
||||
sig,
|
||||
value_text,
|
||||
1.0 if val_bool else 0.0,
|
||||
)
|
||||
|
||||
|
||||
async def process_signal_outbound_send(
|
||||
conn: asyncpg.Connection, *, limit: int = 30
|
||||
) -> int:
|
||||
"""Odešle až `limit` řádků ve stavu queued. Vrátí počet zpracovaných."""
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT j.id, j.route_id, j.site_id, j.signal_code, j.value_text, j.attempt_count
|
||||
FROM ems.signal_outbound_journal j
|
||||
WHERE j.status = 'queued'
|
||||
AND j.next_attempt_at <= now()
|
||||
ORDER BY j.id
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
limit,
|
||||
)
|
||||
n = 0
|
||||
for j in rows:
|
||||
jid = int(j["id"])
|
||||
route = await conn.fetchrow(
|
||||
"""
|
||||
SELECT r.*, e.host, e.port, e.protocol, e.endpoint_type
|
||||
FROM ems.signal_route r
|
||||
JOIN ems.site_endpoint e ON e.id = r.endpoint_id
|
||||
WHERE r.id = $1::int AND r.enabled = true
|
||||
""",
|
||||
int(j["route_id"]),
|
||||
)
|
||||
if route is None:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = 'abandoned', last_error = 'route missing or disabled'
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
|
||||
dest_type = str(route["destination_type"])
|
||||
base = _endpoint_base_url(
|
||||
route.get("protocol"), str(route["host"]), route.get("port")
|
||||
)
|
||||
auth = _loxone_auth()
|
||||
url: str
|
||||
method = "GET"
|
||||
cfg = route["route_config_json"]
|
||||
rcfg = cfg if isinstance(cfg, dict) else (json.loads(cfg) if cfg else None)
|
||||
|
||||
try:
|
||||
if dest_type == "loxone_vi":
|
||||
io_name = str(route["destination_key"])
|
||||
val = str(j["value_text"])
|
||||
url = f"{base}/dev/sps/io/{io_name}/{val}"
|
||||
elif dest_type == "http_rest":
|
||||
method, url = _http_rest_write_url(base, rcfg, str(j["value_text"]))
|
||||
else:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = 'abandoned',
|
||||
last_error = $2,
|
||||
attempt_count = attempt_count + 1
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
f"unknown destination_type: {dest_type}",
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
ac = int(j["attempt_count"]) + 1
|
||||
delay = min(300, 2 ** min(ac, 8))
|
||||
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
last_error = $3::text,
|
||||
attempt_count = $4::int,
|
||||
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
st,
|
||||
str(e)[:500],
|
||||
ac,
|
||||
delay,
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
|
||||
t0 = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
if method == "GET":
|
||||
resp = await client.get(url, auth=auth)
|
||||
elif method == "POST":
|
||||
body = None
|
||||
if rcfg and isinstance(rcfg.get("json_body"), dict):
|
||||
body = json.dumps(rcfg["json_body"])
|
||||
resp = await client.post(
|
||||
url,
|
||||
auth=auth,
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"} if body else None,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported HTTP method {method}")
|
||||
resp.raise_for_status()
|
||||
body_txt = (resp.text or "")[:2000]
|
||||
except Exception as e:
|
||||
ac = int(j["attempt_count"]) + 1
|
||||
delay = min(300, 2 ** min(ac, 8))
|
||||
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
attempt_count = $3::int,
|
||||
last_error = $4::text,
|
||||
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END,
|
||||
http_method = $6::text,
|
||||
request_url = $7::text
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
st,
|
||||
ac,
|
||||
str(e)[:500],
|
||||
delay,
|
||||
method,
|
||||
url,
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
|
||||
dt_ms = int(
|
||||
(datetime.now(timezone.utc) - t0).total_seconds() * 1000
|
||||
)
|
||||
vr = bool(route["verify_readback"])
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
http_method = $3::text,
|
||||
request_url = $4::text,
|
||||
http_status = $5::int,
|
||||
latency_ms = $6::int,
|
||||
response_body_trunc = $7::text,
|
||||
sent_at = now(),
|
||||
last_error = NULL,
|
||||
verified_at = CASE WHEN $2::text = 'verified' THEN now() ELSE NULL END
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
"verified" if not vr else "sent",
|
||||
method,
|
||||
url,
|
||||
200,
|
||||
dt_ms,
|
||||
(body_txt or "")[:500],
|
||||
)
|
||||
if not vr:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.signal_state (
|
||||
site_id, signal_code, destination_type, destination_key,
|
||||
last_sent_value_text, last_verified_value_text, last_sent_at, last_verified_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $5, now(), now(), now())
|
||||
ON CONFLICT (site_id, signal_code, destination_type, destination_key)
|
||||
DO UPDATE SET
|
||||
last_sent_value_text = EXCLUDED.last_sent_value_text,
|
||||
last_verified_value_text = EXCLUDED.last_verified_value_text,
|
||||
last_sent_at = now(),
|
||||
last_verified_at = now(),
|
||||
updated_at = now()
|
||||
""",
|
||||
int(j["site_id"]),
|
||||
str(j["signal_code"]),
|
||||
dest_type,
|
||||
str(route["destination_key"]),
|
||||
str(j["value_text"]),
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
async def process_signal_outbound_verify(
|
||||
conn: asyncpg.Connection, *, limit: int = 30
|
||||
) -> int:
|
||||
"""Ověří řádky ve stavu sent (readback). Vrátí počet zpracovaných."""
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT j.id, j.route_id, j.site_id, j.signal_code, j.value_text
|
||||
FROM ems.signal_outbound_journal j
|
||||
WHERE j.status = 'sent'
|
||||
AND j.verified_at IS NULL
|
||||
AND j.sent_at IS NOT NULL
|
||||
AND j.sent_at <= now() - $1::interval
|
||||
ORDER BY j.id
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
_VERIFY_AFTER_SEND,
|
||||
limit,
|
||||
)
|
||||
n = 0
|
||||
for j in rows:
|
||||
jid = int(j["id"])
|
||||
route = await conn.fetchrow(
|
||||
"""
|
||||
SELECT r.*, e.host, e.port, e.protocol
|
||||
FROM ems.signal_route r
|
||||
JOIN ems.site_endpoint e ON e.id = r.endpoint_id
|
||||
WHERE r.id = $1::int AND r.enabled = true
|
||||
""",
|
||||
int(j["route_id"]),
|
||||
)
|
||||
if route is None:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = 'abandoned', last_error = 'route missing', verified_at = now()
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
|
||||
dest_type = str(route["destination_type"])
|
||||
base = _endpoint_base_url(
|
||||
route.get("protocol"), str(route["host"]), route.get("port")
|
||||
)
|
||||
auth = _loxone_auth()
|
||||
vcfg_raw = route["verify_config_json"]
|
||||
vcfg = (
|
||||
vcfg_raw
|
||||
if isinstance(vcfg_raw, dict)
|
||||
else (json.loads(vcfg_raw) if vcfg_raw else {})
|
||||
)
|
||||
|
||||
read_url: str | None = None
|
||||
expected = str(j["value_text"])
|
||||
try:
|
||||
if dest_type == "loxone_vi":
|
||||
io_read = vcfg.get("loxone_io_name") if vcfg else None
|
||||
if not io_read:
|
||||
io_read = str(route["destination_key"]) + "_FB"
|
||||
read_url = f"{base}/dev/sps/io/{io_read}"
|
||||
elif dest_type == "http_rest":
|
||||
read_url = _http_rest_verify_url(base, vcfg)
|
||||
else:
|
||||
read_url = None
|
||||
|
||||
if not read_url:
|
||||
raise ValueError("verify_config missing read URL")
|
||||
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
rresp = await client.get(read_url, auth=auth)
|
||||
rresp.raise_for_status()
|
||||
body = rresp.text or ""
|
||||
|
||||
ok = False
|
||||
read_val: str | None = None
|
||||
if dest_type == "loxone_vi":
|
||||
fv = _parse_loxone_io_value(body)
|
||||
if fv is not None:
|
||||
read_val = str(int(round(fv)))
|
||||
try:
|
||||
ev = float(expected)
|
||||
except ValueError:
|
||||
ev = None
|
||||
if ev is not None and abs(fv - ev) < 0.51:
|
||||
ok = True
|
||||
elif dest_type == "http_rest":
|
||||
ct = (rresp.headers.get("content-type") or "").lower()
|
||||
if "json" in ct:
|
||||
data = rresp.json()
|
||||
jpath = vcfg.get("json_path") or vcfg.get("json_key")
|
||||
if isinstance(jpath, str) and jpath:
|
||||
got = _read_json_path(data, jpath)
|
||||
else:
|
||||
got = data
|
||||
if isinstance(got, bool):
|
||||
read_val = "1" if got else "0"
|
||||
elif isinstance(got, (int, float)):
|
||||
read_val = "1" if float(got) >= 0.5 else "0"
|
||||
elif got is not None:
|
||||
read_val = str(got).strip().lower()
|
||||
else:
|
||||
read_val = None
|
||||
exp_l = expected.strip().lower()
|
||||
if read_val is not None:
|
||||
if read_val in ("true", "on", "1"):
|
||||
read_norm = "1"
|
||||
elif read_val in ("false", "off", "0"):
|
||||
read_norm = "0"
|
||||
else:
|
||||
read_norm = read_val
|
||||
exp_norm = (
|
||||
"1"
|
||||
if exp_l in ("1", "true", "on")
|
||||
else "0"
|
||||
if exp_l in ("0", "false", "off")
|
||||
else expected
|
||||
)
|
||||
ok = read_norm == exp_norm
|
||||
else:
|
||||
fv = _parse_loxone_io_value(body)
|
||||
if fv is not None:
|
||||
read_val = str(int(round(fv)))
|
||||
try:
|
||||
ev = float(expected)
|
||||
except ValueError:
|
||||
ev = None
|
||||
ok = ev is not None and abs(fv - ev) < 0.51
|
||||
|
||||
if ok:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = 'verified', verified_at = now(), last_error = NULL
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.signal_state (
|
||||
site_id, signal_code, destination_type, destination_key,
|
||||
last_sent_value_text, last_verified_value_text, last_sent_at, last_verified_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $5,
|
||||
(SELECT sent_at FROM ems.signal_outbound_journal WHERE id = $6::bigint),
|
||||
now(), now())
|
||||
ON CONFLICT (site_id, signal_code, destination_type, destination_key)
|
||||
DO UPDATE SET
|
||||
last_sent_value_text = EXCLUDED.last_sent_value_text,
|
||||
last_verified_value_text = EXCLUDED.last_verified_value_text,
|
||||
last_sent_at = EXCLUDED.last_sent_at,
|
||||
last_verified_at = now(),
|
||||
updated_at = now()
|
||||
""",
|
||||
int(j["site_id"]),
|
||||
str(j["signal_code"]),
|
||||
dest_type,
|
||||
str(route["destination_key"]),
|
||||
str(j["value_text"]),
|
||||
jid,
|
||||
)
|
||||
else:
|
||||
ac_row = await conn.fetchrow(
|
||||
"SELECT attempt_count FROM ems.signal_outbound_journal WHERE id = $1",
|
||||
jid,
|
||||
)
|
||||
ac = int(ac_row["attempt_count"] or 0) + 1
|
||||
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
|
||||
delay = min(300, 2 ** min(ac, 8))
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
attempt_count = $3::int,
|
||||
last_error = $4::text,
|
||||
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END,
|
||||
sent_at = CASE WHEN $2::text = 'queued' THEN NULL ELSE sent_at END,
|
||||
verified_at = CASE WHEN $2::text != 'queued' THEN now() ELSE NULL END
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
st,
|
||||
ac,
|
||||
f"verify mismatch read={read_val!r} expected={expected!r}"[:500],
|
||||
delay,
|
||||
)
|
||||
except Exception as e:
|
||||
ac_row = await conn.fetchrow(
|
||||
"SELECT attempt_count FROM ems.signal_outbound_journal WHERE id = $1",
|
||||
jid,
|
||||
)
|
||||
ac = int(ac_row["attempt_count"] or 0) + 1
|
||||
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
|
||||
delay = min(300, 2 ** min(ac, 8))
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
attempt_count = $3::int,
|
||||
last_error = $4::text,
|
||||
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END,
|
||||
sent_at = CASE WHEN $2::text = 'queued' THEN NULL ELSE sent_at END
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
st,
|
||||
ac,
|
||||
str(e)[:500],
|
||||
delay,
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
async def run_signal_outbound_send_for_active_sites(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
try:
|
||||
await process_signal_outbound_send(conn, limit=80)
|
||||
except Exception:
|
||||
logger.exception("signal_outbound_send failed")
|
||||
|
||||
|
||||
async def run_signal_outbound_verify_for_active_sites(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
try:
|
||||
await process_signal_outbound_verify(conn, limit=80)
|
||||
except Exception:
|
||||
logger.exception("signal_outbound_verify failed")
|
||||
@@ -22,8 +22,17 @@ DEYE_REG_BATTERY_POWER_FLOW = 590
|
||||
DEYE_REG_GRID_TOTAL_POWER = 625
|
||||
DEYE_REG_GEN_PORT_POWER = 667
|
||||
DEYE_REG_LOAD_TOTAL_POWER = 653
|
||||
DEYE_REG_GRID_IMPORT_TOTAL_LO = 522
|
||||
DEYE_REG_GRID_IMPORT_TOTAL_HI = 523
|
||||
DEYE_REG_GRID_EXPORT_TOTAL_LO = 524
|
||||
DEYE_REG_GRID_EXPORT_TOTAL_HI = 525
|
||||
DEYE_REG_PV1_POWER = 672
|
||||
DEYE_REG_PV2_POWER = 673
|
||||
# Solar sell (0 = přebytek řiditelné FVE nesmí do sítě) a GEN/MI cut-off (reg178 bits0–1 == 3 → cut-off ON).
|
||||
# Pozn.: v některých manuálech/UI se uvádí "register 179" (1-based), ale Modbus adresa je 178 (0-based).
|
||||
# Viz modbus-registers.md.
|
||||
DEYE_REG_SOLAR_SELL = 145
|
||||
DEYE_REG_CONTROL_BOARD_SPECIAL1 = 178
|
||||
|
||||
|
||||
def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
|
||||
@@ -34,16 +43,24 @@ def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
|
||||
return max(0, int(pv1_w)) + max(0, int(pv2_w)) + max(0, int(gen_port_w))
|
||||
|
||||
|
||||
def _export_limit_flags_from_deye_regs(reg145: int | None, reg179: int | None) -> tuple[bool | None, int | None]:
|
||||
"""Odvoď is_export_limited / pv_derating_flags z přečtených holding registrů (NULL = neznámé)."""
|
||||
if reg145 is None and reg179 is None:
|
||||
return None, None
|
||||
flags = 0
|
||||
if reg145 is not None and int(reg145) == 0:
|
||||
flags |= 1
|
||||
if reg179 is not None and (int(reg179) & 3) == 3:
|
||||
flags |= 2
|
||||
return (flags != 0), flags
|
||||
|
||||
|
||||
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ai.id, ai.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_inverter ai
|
||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||
WHERE ai.site_id = $1
|
||||
AND ai.active = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
select inverter_id as id, code, host, port, unit_id
|
||||
from ems.vw_asset_inverter_modbus_poll
|
||||
where site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
@@ -63,34 +80,24 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
|
||||
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
|
||||
grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
|
||||
load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER)
|
||||
load_power = await mb.read_register_signed(DEYE_REG_LOAD_TOTAL_POWER)
|
||||
pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER)
|
||||
pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER)
|
||||
gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER)
|
||||
grid_energy_regs = await mb.read_holding_registers(
|
||||
DEYE_REG_GRID_IMPORT_TOTAL_LO, 4
|
||||
)
|
||||
reg145 = await mb.read_register(DEYE_REG_SOLAR_SELL)
|
||||
reg179 = await mb.read_register(DEYE_REG_CONTROL_BOARD_SPECIAL1)
|
||||
pv_power_w = aggregate_pv_production_w(pv1_power, pv2_power, gen_port_power)
|
||||
grid_import_total_wh = (grid_energy_regs[1] << 16 | grid_energy_regs[0]) * 100
|
||||
grid_export_total_wh = (grid_energy_regs[3] << 16 | grid_energy_regs[2]) * 100
|
||||
is_export_limited, pv_derating_flags = _export_limit_flags_from_deye_regs(reg145, reg179)
|
||||
|
||||
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_inverter (
|
||||
site_id, inverter_id, measured_at,
|
||||
pv_power_w, pv1_power_w, pv2_power_w, gen_port_power_w,
|
||||
battery_soc_percent, battery_power_w,
|
||||
batt_charge_today_wh, batt_discharge_today_wh,
|
||||
grid_power_w, load_power_w,
|
||||
run_state
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
$4, $5, $6, $7,
|
||||
$8, $9,
|
||||
$10, $11,
|
||||
$12, $13,
|
||||
$14
|
||||
)
|
||||
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
||||
""",
|
||||
"select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int, $17::boolean, $18::int)",
|
||||
site_id,
|
||||
inv_id,
|
||||
measured_at,
|
||||
@@ -104,7 +111,11 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
batt_discharge_today,
|
||||
grid_power,
|
||||
load_power,
|
||||
grid_import_total_wh,
|
||||
grid_export_total_wh,
|
||||
run_state,
|
||||
is_export_limited,
|
||||
pv_derating_flags,
|
||||
)
|
||||
inv_temp: float | None = None
|
||||
await manager.broadcast_telemetry(
|
||||
@@ -119,6 +130,8 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
"load_power_w": load_power,
|
||||
"gen_port_power_w": gen_port_power,
|
||||
"inverter_temp_c": inv_temp,
|
||||
"is_export_limited": is_export_limited,
|
||||
"pv_derating_flags": pv_derating_flags,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -128,12 +141,9 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ec.id, ec.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_ev_charger ec
|
||||
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||
WHERE ec.site_id = $1
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
select charger_id as id, code, host, port, unit_id
|
||||
from ems.vw_asset_ev_charger_modbus_poll
|
||||
where site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
@@ -143,117 +153,52 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||
code = row["code"]
|
||||
charger_id = row["id"]
|
||||
logger.info("TODO: EV charger Modbus registry pending | %s", code)
|
||||
# Placeholder až do mapování Modbus: status zůstává available (bez falešných přechodů).
|
||||
current_status = "available"
|
||||
|
||||
previous_status = await db.fetchval(
|
||||
"""
|
||||
SELECT status
|
||||
FROM ems.telemetry_ev_charger
|
||||
WHERE charger_id = $1 AND connector_id = $2
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
select status
|
||||
from ems.telemetry_ev_charger
|
||||
where charger_id = $1 and connector_id = $2
|
||||
order by measured_at desc
|
||||
limit 1
|
||||
""",
|
||||
charger_id,
|
||||
connector_id,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_ev_charger (
|
||||
site_id, charger_id, measured_at, connector_id,
|
||||
status, power_w, energy_kwh
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 0, 0)
|
||||
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
|
||||
""",
|
||||
"select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8)",
|
||||
site_id,
|
||||
charger_id,
|
||||
measured_at,
|
||||
connector_id,
|
||||
current_status,
|
||||
0,
|
||||
0.0,
|
||||
)
|
||||
|
||||
if previous_status is not None:
|
||||
await db.fetchval(
|
||||
"select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
|
||||
site_id,
|
||||
charger_id,
|
||||
str(previous_status),
|
||||
current_status,
|
||||
measured_at,
|
||||
)
|
||||
if previous_status == "available" and current_status != "available":
|
||||
vehicle_id = await db.fetchval(
|
||||
"""
|
||||
SELECT av.id
|
||||
FROM ems.asset_vehicle av
|
||||
WHERE av.site_id = $1
|
||||
AND av.default_charger_id = $2
|
||||
AND av.active = true
|
||||
ORDER BY av.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
charger_id,
|
||||
)
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_ev_arrival_stats($1, $2, $3, $4)",
|
||||
site_id,
|
||||
charger_id,
|
||||
vehicle_id,
|
||||
measured_at,
|
||||
)
|
||||
logger.info("EV arrival detected on charger %s", code)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.ev_session (
|
||||
site_id, charger_id, vehicle_id, session_start,
|
||||
target_soc_pct, target_deadline
|
||||
)
|
||||
SELECT
|
||||
ac.site_id,
|
||||
ac.id,
|
||||
av.id,
|
||||
now(),
|
||||
av.default_target_soc_pct,
|
||||
CASE
|
||||
WHEN av.default_deadline_hour IS NOT NULL THEN
|
||||
(
|
||||
(timezone('Europe/Prague', now()))::date + interval '1 day'
|
||||
+ make_interval(hours => av.default_deadline_hour)
|
||||
)::timestamp AT TIME ZONE 'Europe/Prague'
|
||||
END
|
||||
FROM ems.asset_ev_charger ac
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT v.id, v.default_target_soc_pct, v.default_deadline_hour
|
||||
FROM ems.asset_vehicle v
|
||||
WHERE v.default_charger_id = ac.id
|
||||
AND v.site_id = ac.site_id
|
||||
AND v.active = true
|
||||
ORDER BY v.id
|
||||
LIMIT 1
|
||||
) av ON true
|
||||
WHERE ac.id = $1 AND ac.site_id = $2
|
||||
ON CONFLICT (charger_id) WHERE session_end IS NULL DO NOTHING
|
||||
""",
|
||||
charger_id,
|
||||
site_id,
|
||||
)
|
||||
|
||||
if previous_status != "available" and current_status == "available":
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.ev_session
|
||||
SET session_end = now()
|
||||
WHERE charger_id = $1 AND session_end IS NULL
|
||||
""",
|
||||
charger_id,
|
||||
)
|
||||
elif previous_status != "available" and current_status == "available":
|
||||
logger.info("EV departure detected on charger %s", code)
|
||||
|
||||
|
||||
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT hp.id, hp.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_heat_pump hp
|
||||
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||||
WHERE hp.site_id = $1
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
select heat_pump_id as id, code, host, port, unit_id
|
||||
from ems.vw_asset_heat_pump_modbus_poll
|
||||
where site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
@@ -262,18 +207,15 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||
code = row["code"]
|
||||
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_heat_pump (
|
||||
site_id, heat_pump_id, measured_at,
|
||||
power_w, outdoor_temp_c, water_outlet_temp_c, tuv_tank_temp_c,
|
||||
operating_mode
|
||||
)
|
||||
VALUES ($1, $2, $3, 0, 10.0, 45.0, 55.0, 'standby')
|
||||
ON CONFLICT (heat_pump_id, measured_at) DO NOTHING
|
||||
""",
|
||||
"select ems.fn_telemetry_heat_pump_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8, $7::float8, $8::text)",
|
||||
site_id,
|
||||
row["id"],
|
||||
measured_at,
|
||||
0,
|
||||
10.0,
|
||||
45.0,
|
||||
55.0,
|
||||
"standby",
|
||||
)
|
||||
|
||||
|
||||
@@ -284,7 +226,9 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch(
|
||||
"select id from ems.vw_site_directory where active = true"
|
||||
)
|
||||
for site in sites:
|
||||
sid = site["id"]
|
||||
try:
|
||||
|
||||
118
backend/tests/test_control_exporter_reg340.py
Normal file
118
backend/tests/test_control_exporter_reg340.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Deye reg 340 (max solar power) z plánu a capu z DB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.control.exporter_monolith import (
|
||||
OperatingModeInfo,
|
||||
_DictRecord,
|
||||
_build_setpoints,
|
||||
compute_pv_a_reg340_max_solar_w,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||
)
|
||||
|
||||
|
||||
def _auto_mode() -> OperatingModeInfo:
|
||||
return OperatingModeInfo(
|
||||
mode_code="AUTO",
|
||||
battery_mode="auto",
|
||||
grid_mode="auto",
|
||||
ev_enabled=True,
|
||||
heat_pump_enabled_def=True,
|
||||
loxone_mode_value=0,
|
||||
)
|
||||
|
||||
|
||||
def _pi_base(**kwargs: object) -> _DictRecord:
|
||||
d: dict[str, object] = {
|
||||
"grid_setpoint_w": 0,
|
||||
"battery_setpoint_w": 0,
|
||||
"battery_soc_target_pct": None,
|
||||
"heat_pump_enabled": False,
|
||||
"effective_sell_price": 1.0,
|
||||
"pv_a_forecast_solver_w": 8000,
|
||||
"pv_a_curtailed_w": 0,
|
||||
}
|
||||
d.update(kwargs)
|
||||
return _DictRecord(d)
|
||||
|
||||
|
||||
class ComputePvAReg340Tests(unittest.TestCase):
|
||||
def test_full_cap_when_no_curtail(self) -> None:
|
||||
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 8000, 0), 10_000)
|
||||
|
||||
def test_curtailed_value(self) -> None:
|
||||
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 8000, 2000), 6000)
|
||||
|
||||
def test_clamped_to_cap_when_forecast_high(self) -> None:
|
||||
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 12_000, 0), 10_000)
|
||||
|
||||
def test_curtail_floor_zero(self) -> None:
|
||||
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 1000, 5000), 0)
|
||||
|
||||
|
||||
class BuildSetpointsReg340Tests(unittest.TestCase):
|
||||
def test_with_cap_sets_pv_a_allowed(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(pv_a_forecast_solver_w=8000, pv_a_curtailed_w=2000),
|
||||
pv_a_cap_w=10_000,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertEqual(sp.pv_a_allowed_w, 6000)
|
||||
|
||||
def test_skipped_when_cap_zero(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(),
|
||||
pv_a_cap_w=0,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertIsNone(sp.pv_a_allowed_w)
|
||||
|
||||
def test_self_sustain_no_pv_a_allowed(self) -> None:
|
||||
mode = OperatingModeInfo(
|
||||
mode_code="SELF_SUSTAIN",
|
||||
battery_mode="x",
|
||||
grid_mode="x",
|
||||
ev_enabled=False,
|
||||
heat_pump_enabled_def=False,
|
||||
loxone_mode_value=0,
|
||||
)
|
||||
sp = _build_setpoints(mode, None, pv_a_cap_w=10_000)
|
||||
assert sp is not None
|
||||
self.assertIsNone(sp.pv_a_allowed_w)
|
||||
|
||||
def test_neg_buy_and_sell_with_pv_b_forces_pv_a_off(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(
|
||||
effective_buy_price=-3.0,
|
||||
effective_sell_price=-2.0,
|
||||
pv_b_forecast_solver_w=5000,
|
||||
pv_a_forecast_solver_w=0,
|
||||
pv_a_curtailed_w=0,
|
||||
),
|
||||
pv_a_cap_w=3333,
|
||||
reg340_pv_a_control_enabled=True,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertEqual(sp.pv_a_allowed_w, 0)
|
||||
|
||||
def test_skipped_when_reg340_control_disabled(self) -> None:
|
||||
sp = _build_setpoints(
|
||||
_auto_mode(),
|
||||
_pi_base(pv_a_forecast_solver_w=8000, pv_a_curtailed_w=2000),
|
||||
pv_a_cap_w=10_000,
|
||||
reg340_pv_a_control_enabled=False,
|
||||
)
|
||||
assert sp is not None
|
||||
self.assertIsNone(sp.pv_a_allowed_w)
|
||||
|
||||
|
||||
class Reg340VerifyPolicyTests(unittest.TestCase):
|
||||
def test_reg340_not_critical_for_self_sustain(self) -> None:
|
||||
self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(340))
|
||||
@@ -3,11 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from services.control_exporter import (
|
||||
from services.control.exporter_monolith import (
|
||||
ControlSetpoints,
|
||||
InverterConfig,
|
||||
_deye_reg178_verify_with_double_read,
|
||||
_deye_tou_params,
|
||||
_deye_tou_power_verify_match,
|
||||
_deye_zero_export_amps_for_passive,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||
get_deye_mode,
|
||||
)
|
||||
|
||||
@@ -33,15 +38,39 @@ def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterC
|
||||
)
|
||||
|
||||
|
||||
def _inv_350a() -> InverterConfig:
|
||||
"""350 A × 51.2 V = 17920 W — typický firmware clamp pro TOU power."""
|
||||
return replace(_inv(), max_charge_a=350, max_discharge_a=350)
|
||||
|
||||
|
||||
class ModbusVerifyPolicyTests(unittest.TestCase):
|
||||
def test_tou_power_accepts_firmware_max_w_clamp(self) -> None:
|
||||
inv = _inv_350a()
|
||||
self.assertTrue(_deye_tou_power_verify_match(7752, 17920, inv))
|
||||
self.assertTrue(_deye_tou_power_verify_match(16728, 17920, inv))
|
||||
|
||||
def test_reg178_double_read_recovers_from_glitch(self) -> None:
|
||||
ok, v = _deye_reg178_verify_with_double_read(48, 12014, 48)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(v, 48)
|
||||
|
||||
def test_reg178_not_critical_for_self_sustain(self) -> None:
|
||||
self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(178))
|
||||
|
||||
def test_reg108_critical_for_self_sustain(self) -> None:
|
||||
self.assertTrue(deye_reg_triggers_self_sustain_after_verify_exhaust(108))
|
||||
|
||||
|
||||
class DeyeTouParamsTests(unittest.TestCase):
|
||||
def test_sell_uses_reserve_soc(self) -> None:
|
||||
"""SELL: záporný grid_setpoint_w i battery_w → selling first; TOU SOC = reserve."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=5000,
|
||||
battery_w=-8000,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-500,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
@@ -51,6 +80,66 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 20)
|
||||
|
||||
def test_explicit_deye_physical_mode_from_plan_overrides_detection(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-8000,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
deye_physical_mode="PASSIVE",
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
|
||||
def test_export_ban_does_not_change_deye_mode(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
export_ban=True,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
|
||||
def test_pv_led_export_with_small_battery_is_sell(self) -> None:
|
||||
"""Obě záporné → SELL (bez porovnání |bat| vs |grid|)."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-733,
|
||||
grid_export_limit=1294,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-1294,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "SELL")
|
||||
|
||||
def test_large_export_small_battery_is_sell(self) -> None:
|
||||
"""I když |bat| < |grid| — stále SELL při obou záporných setpointech."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-1500,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "SELL")
|
||||
|
||||
def test_passive_uses_min_soc(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=0,
|
||||
@@ -62,12 +151,51 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
effective_sell_price_czk_kwh=None,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 12)
|
||||
|
||||
def test_passive_negative_sell_tou_stays_min_soc(self) -> None:
|
||||
"""PASSIVE: záporná vykupní nenastavuje TOU na 100 — zůstává min_soc (145/export_ban řeší síť)."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-400,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=14,
|
||||
effective_sell_price_czk_kwh=-0.25,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 12)
|
||||
|
||||
def test_passive_planned_pv_charge_tou_stays_min_soc(self) -> None:
|
||||
"""PASSIVE s kladným battery_w bez grid importu: CHARGE to není — TOU je stále min_soc."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=800,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=60,
|
||||
effective_sell_price_czk_kwh=1.0,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 12)
|
||||
|
||||
def test_charge_unchanged_grid_charge(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=5000,
|
||||
@@ -85,6 +213,74 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
self.assertTrue(g)
|
||||
self.assertEqual(s, 95)
|
||||
|
||||
def test_charge_target_soc_respects_max_soc_100(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=5000,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=5000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=80,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "CHARGE")
|
||||
inv = replace(_inv(), max_soc_percent=100)
|
||||
_p, s, g = _deye_tou_params(sp, inv)
|
||||
self.assertTrue(g)
|
||||
self.assertEqual(s, 100)
|
||||
|
||||
def test_charge_any_positive_pair_without_w_threshold(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=50,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=80,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "CHARGE")
|
||||
|
||||
def test_zero_export_amps_fve_overflow(self) -> None:
|
||||
c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90)
|
||||
self.assertEqual(c, 0)
|
||||
self.assertEqual(d, 90)
|
||||
|
||||
def test_zero_export_amps_import_hold_discharge(self) -> None:
|
||||
c, d = _deye_zero_export_amps_for_passive(500, 0, 100, 90)
|
||||
self.assertEqual(c, 100)
|
||||
self.assertEqual(d, 0)
|
||||
|
||||
def test_zero_export_amps_full_when_discharge_with_export(self) -> None:
|
||||
"""Export + plánované vybíjení → plné proudy (SELL řeší režim 142 zvlášť)."""
|
||||
c, d = _deye_zero_export_amps_for_passive(-2000, -500, 100, 90)
|
||||
self.assertEqual(c, 100)
|
||||
self.assertEqual(d, 90)
|
||||
|
||||
def test_self_sustain_tou_stays_min_soc_even_if_sell_negative(self) -> None:
|
||||
"""SELF_SUSTAIN: nízké TOU (min_soc), ne 100 % z negativní vykupní — LP se nepoužívá."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=None,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
effective_sell_price_czk_kwh=-0.48,
|
||||
self_sustain_local_use=True,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 12)
|
||||
|
||||
def test_lock_battery_uses_min_soc(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=0,
|
||||
|
||||
28
backend/tests/test_db_json_fetch_json.py
Normal file
28
backend/tests/test_db_json_fetch_json.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Smoke: fetch_json toleruje dict z asyncpg (bez reálné DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.db_json import fetch_json
|
||||
|
||||
|
||||
def test_fetch_json_returns_dict() -> None:
|
||||
async def _run() -> None:
|
||||
conn = AsyncMock()
|
||||
conn.fetchval = AsyncMock(return_value={"a": 1})
|
||||
out = await fetch_json(conn, "select ems.fn_x()", 1)
|
||||
assert out == {"a": 1}
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_fetch_json_parses_str() -> None:
|
||||
async def _run() -> None:
|
||||
conn = AsyncMock()
|
||||
conn.fetchval = AsyncMock(return_value='{"b": 2}')
|
||||
out = await fetch_json(conn, "select 1")
|
||||
assert out == {"b": 2}
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.control_exporter import (
|
||||
from services.control.exporter_monolith import (
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC,
|
||||
|
||||
24
backend/tests/test_drop_registers_matching_last_verified.py
Normal file
24
backend/tests/test_drop_registers_matching_last_verified.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from services.control.exporter_monolith import (
|
||||
REG178_PASSIVE,
|
||||
_drop_registers_matching_last_verified,
|
||||
)
|
||||
|
||||
|
||||
def test_drop_registers_skips_reg178_when_mask_matches():
|
||||
# last_verified contains extra bits; reg178 is a bit field and exporter uses RMW.
|
||||
# We want to skip if the relevant bits match (bits4–5 and, if present, bits0–1).
|
||||
last_verified = {178: 12030} # real-world example from home-01 (bits4-5 still == 0b11)
|
||||
expected_rmw = (int(last_verified[178]) & ~0x0030) | int(REG178_PASSIVE)
|
||||
registers = [(178, "control_board_special_1", int(expected_rmw))]
|
||||
out, skipped = _drop_registers_matching_last_verified(registers, last_verified)
|
||||
assert out == []
|
||||
assert skipped == [178]
|
||||
|
||||
|
||||
def test_drop_registers_keeps_reg178_when_mask_differs():
|
||||
registers = [(178, "grid_peak_shaving_switch", REG178_PASSIVE)]
|
||||
last_verified = {178: 32} # SELL mask 0b10
|
||||
out, skipped = _drop_registers_matching_last_verified(registers, last_verified)
|
||||
assert out == registers
|
||||
assert skipped == []
|
||||
|
||||
53
backend/tests/test_full_status_heartbeat_parsing.py
Normal file
53
backend/tests/test_full_status_heartbeat_parsing.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
class _FakeAcquire:
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
async def __aenter__(self):
|
||||
return self._conn
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _FakePool:
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
def acquire(self):
|
||||
return _FakeAcquire(self._conn)
|
||||
|
||||
|
||||
def test_status_full_parses_heartbeat_and_inverter_timestamps(monkeypatch):
|
||||
# Regression: /status/full used to pass string timestamps into _age_seconds()
|
||||
# which expects datetime and accesses .tzinfo.
|
||||
from app.routers import full_status
|
||||
|
||||
async def _fake_fetch_json(conn, sql, *args):
|
||||
assert "fn_site_full_status" in sql
|
||||
return {
|
||||
"site": {"code": "X"},
|
||||
"operating_mode": {"mode_code": "AUTO"},
|
||||
"heartbeat": {"last_seen": "2026-04-20T08:56:36.186Z"},
|
||||
"inverter_latest": {"measured_at": "2026-04-20T08:56:31.165Z"},
|
||||
"ev_chargers": [],
|
||||
"heat_pump_latest": None,
|
||||
"battery_limits": {},
|
||||
"active_plan": None,
|
||||
"planning_intervals": [],
|
||||
"tomorrow_price_slot_count": 96,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(full_status, "fetch_json", _fake_fetch_json)
|
||||
|
||||
out = asyncio.run(
|
||||
full_status.get_site_status_full(site_id=2, pool=_FakePool(conn=object()))
|
||||
)
|
||||
assert isinstance(out, dict)
|
||||
assert out["heartbeat"]["last_seen"] is not None
|
||||
assert out["heartbeat"]["age_seconds"] is not None
|
||||
assert out["telemetry"]["inverter"]["measured_at"] is not None
|
||||
assert out["telemetry"]["inverter"]["age_seconds"] is not None
|
||||
|
||||
194
backend/tests/test_planning_charge_slot_selection.py
Normal file
194
backend/tests/test_planning_charge_slot_selection.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Pre-selection nabíjecích slotů (anti-micro-cycling) – referenční Python.
|
||||
|
||||
Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu
|
||||
pro rychlé unit testy bez PostgreSQL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.planning_engine import INTERVAL_H, PlanningSlot
|
||||
|
||||
|
||||
def _select_charge_slots(
|
||||
slots: list[PlanningSlot],
|
||||
battery: SimpleNamespace,
|
||||
current_soc_wh: float,
|
||||
) -> set[int]:
|
||||
"""Kopie logiky z ems.fn_load_planning_slots_full (charge mask)."""
|
||||
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
|
||||
if charge_buf <= 0:
|
||||
return set(range(len(slots)))
|
||||
|
||||
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
|
||||
if energy_to_fill <= 0:
|
||||
return set()
|
||||
|
||||
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
|
||||
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
|
||||
per_slot_full_wh = max_p_w * eta * INTERVAL_H
|
||||
|
||||
selected: set[int] = set()
|
||||
for t, s in enumerate(slots):
|
||||
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
|
||||
if pv_surplus_w > 0:
|
||||
selected.add(t)
|
||||
|
||||
grid_target_wh = energy_to_fill * charge_buf
|
||||
if grid_target_wh <= 0 or per_slot_full_wh <= 0:
|
||||
return selected
|
||||
|
||||
grid_candidates = [
|
||||
(t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected
|
||||
]
|
||||
grid_candidates.sort(key=lambda x: x[1])
|
||||
|
||||
cumulative = 0.0
|
||||
for t, _price in grid_candidates:
|
||||
if cumulative >= grid_target_wh:
|
||||
break
|
||||
selected.add(t)
|
||||
cumulative += per_slot_full_wh
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
def _slot(*, buy: float, sell: float = 1.0, pv: int = 0, load: int = 2_000) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=datetime(2026, 4, 19, 12, 0, tzinfo=timezone.utc),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=pv,
|
||||
load_baseline_w=load,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
)
|
||||
|
||||
|
||||
def _battery(
|
||||
*,
|
||||
charge_buf: float = 1.3,
|
||||
uc_wh: float = 64_000.0,
|
||||
soc_max_pct: float = 95.0,
|
||||
max_charge_w: float = 18_000.0,
|
||||
charge_eff: float = 0.95,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
usable_capacity_wh=uc_wh,
|
||||
soc_max_wh=soc_max_pct / 100.0 * uc_wh,
|
||||
max_charge_power_w=max_charge_w,
|
||||
charge_efficiency=charge_eff,
|
||||
charge_slot_buffer=charge_buf,
|
||||
)
|
||||
|
||||
|
||||
class SelectChargeSlotsTests(unittest.TestCase):
|
||||
def test_buffer_zero_returns_all_slots(self) -> None:
|
||||
slots = [_slot(buy=3.0) for _ in range(4)]
|
||||
battery = _battery(charge_buf=0.0)
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
||||
self.assertEqual(out, set(range(4)))
|
||||
|
||||
def test_pv_surplus_slot_always_selected_regardless_of_buy_price(self) -> None:
|
||||
"""Slot s PV-surplus má být in, i když má nejvyšší buy_price."""
|
||||
slots = [
|
||||
_slot(buy=0.5, pv=0, load=2_000), # bez PV, levný grid
|
||||
_slot(buy=9.9, pv=8_000, load=2_000), # velký PV-surplus, drahý grid
|
||||
]
|
||||
battery = _battery()
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
||||
self.assertIn(1, out)
|
||||
|
||||
def test_grid_filler_prefers_lowest_buy_price(self) -> None:
|
||||
"""Mezi neprvními sloty se vybere ten s nejnižší buy_price."""
|
||||
slots = [
|
||||
_slot(buy=3.0, pv=0, load=2_000, sell=0.1),
|
||||
_slot(buy=0.4, pv=0, load=2_000, sell=0.3),
|
||||
_slot(buy=1.2, pv=0, load=2_000, sell=0.2),
|
||||
]
|
||||
battery = _battery(
|
||||
charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
|
||||
)
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
||||
self.assertIn(1, out)
|
||||
|
||||
def test_does_not_exclude_slot_just_because_pv_below_load(self) -> None:
|
||||
"""Regrese: dřívější logika vyřazovala sloty bez PV-surplus úplně."""
|
||||
slots = [
|
||||
_slot(buy=0.4, pv=3_320, load=3_747),
|
||||
_slot(buy=0.42, pv=2_116, load=3_747),
|
||||
_slot(buy=0.44, pv=1_649, load=3_747),
|
||||
_slot(buy=0.47, pv=1_276, load=3_747),
|
||||
_slot(buy=1.13, pv=1_286, load=523),
|
||||
_slot(buy=1.60, pv=1_020, load=523),
|
||||
]
|
||||
battery = _battery()
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh)
|
||||
for idx in (0, 1, 2, 3):
|
||||
self.assertIn(
|
||||
idx,
|
||||
out,
|
||||
msg=(
|
||||
f"Slot {idx} (levný grid nákup ~0.4 Kč) musí být povolen pro "
|
||||
"nabíjení i bez PV-surplus, jinak optimizer skončí s dražším "
|
||||
"nákupem v pozdějších slotech (nelogická ekonomika)."
|
||||
),
|
||||
)
|
||||
|
||||
def test_long_horizon_pv_surplus_does_not_exhaust_grid_budget(self) -> None:
|
||||
"""Regrese: v 96h horizontu nesmí PV-surplus sloty „vyžrat“ grid rozpočet.
|
||||
|
||||
V dřívější verzi se kumulativní PV budget odečítal od `charge_buf × headroom`,
|
||||
takže v dlouhém horizontu s mnoha PV sloty zbylo 0 na grid filler a levné
|
||||
grid sloty se nepovolily. Tento test simuluje realistický 96h profil.
|
||||
"""
|
||||
# 40 levných grid-only slotů (simulace noční / „inter-peak“ hodiny).
|
||||
cheap_grid = [_slot(buy=0.4 + 0.01 * i, pv=0, load=2_000) for i in range(40)]
|
||||
# 100 PV-surplus slotů s velkou výrobou (přes den, přes víc dní).
|
||||
pv_days = [_slot(buy=1.5, pv=10_000, load=2_000) for _ in range(100)]
|
||||
slots = cheap_grid + pv_days
|
||||
battery = _battery(
|
||||
charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
|
||||
)
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh)
|
||||
grid_selected = sum(1 for i in range(len(cheap_grid)) if i in out)
|
||||
self.assertGreaterEqual(
|
||||
grid_selected,
|
||||
5,
|
||||
msg=(
|
||||
"V dlouhém horizontu s mnoha PV-surplus sloty musí zůstat dostatek "
|
||||
"grid slotů povolených pro nabíjení z levného importu."
|
||||
),
|
||||
)
|
||||
|
||||
def test_energy_budget_is_charge_buf_times_headroom(self) -> None:
|
||||
"""Součet uvolněné energie se pohybuje okolo charge_buf × (soc_max − current_soc)."""
|
||||
slots = [_slot(buy=float(i + 1), pv=0, load=2_000) for i in range(40)]
|
||||
battery = _battery(
|
||||
charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
|
||||
)
|
||||
current_soc_wh = 0.2 * battery.usable_capacity_wh
|
||||
target = battery.charge_slot_buffer * (battery.soc_max_wh - current_soc_wh)
|
||||
per_slot_wh = (
|
||||
battery.max_charge_power_w * battery.charge_efficiency * INTERVAL_H
|
||||
)
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=current_soc_wh)
|
||||
slots_picked = len(out)
|
||||
self.assertLessEqual((slots_picked - 1) * per_slot_wh, target)
|
||||
self.assertGreaterEqual(slots_picked * per_slot_wh, target)
|
||||
|
||||
def test_returns_empty_when_battery_is_full(self) -> None:
|
||||
slots = [_slot(buy=0.1) for _ in range(3)]
|
||||
battery = _battery()
|
||||
out = _select_charge_slots(
|
||||
slots, battery, current_soc_wh=battery.soc_max_wh + 1.0
|
||||
)
|
||||
self.assertEqual(out, set())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,12 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.planning_engine import (
|
||||
PlanningSlot,
|
||||
_dynamic_arb_floor_wh_series,
|
||||
_prewindow_deferral_slots,
|
||||
_slots_until_buy_le_threshold,
|
||||
_slots_until_sell_lt,
|
||||
_soc_panel_min_wh_series,
|
||||
solve_dispatch,
|
||||
)
|
||||
|
||||
@@ -40,6 +44,7 @@ def _battery(
|
||||
min_pct: float = 10.0,
|
||||
arb_pct: float = 20.0,
|
||||
max_pct: float = 95.0,
|
||||
terminal_soc_value_factor: float = 0.9,
|
||||
) -> SimpleNamespace:
|
||||
uc = uc_wh
|
||||
min_wh = min_pct / 100.0 * uc
|
||||
@@ -55,9 +60,114 @@ def _battery(
|
||||
degradation_cost_czk_kwh=0.15,
|
||||
max_charge_power_w=10_000,
|
||||
max_discharge_power_w=10_000,
|
||||
planner_terminal_soc_value_factor=terminal_soc_value_factor,
|
||||
)
|
||||
|
||||
|
||||
class SlotsUntilSellNegativeTests(unittest.TestCase):
|
||||
def test_slots_until_first_negative_sell(self) -> None:
|
||||
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
|
||||
slots: list[PlanningSlot] = []
|
||||
for i in range(10):
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=1.0,
|
||||
sell_price=2.0 if i < 4 else -0.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
)
|
||||
)
|
||||
dist = _slots_until_sell_lt(slots, 0.0)
|
||||
self.assertEqual(dist[0], 4)
|
||||
self.assertEqual(dist[3], 1)
|
||||
self.assertEqual(dist[4], 0)
|
||||
|
||||
def test_prewindow_deferral_prefers_sell_anchor(self) -> None:
|
||||
"""Když existuje záporný prodej, kotva je vzdálenost k němu, ne k extrémnímu buy."""
|
||||
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
|
||||
slots: list[PlanningSlot] = []
|
||||
for i in range(8):
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=-50.0,
|
||||
sell_price=1.0 if i < 2 else -0.1,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
)
|
||||
)
|
||||
adv = _prewindow_deferral_slots(slots, -2.0)
|
||||
self.assertEqual(adv[0], 2)
|
||||
|
||||
def test_prewindow_deferral_falls_back_to_buy_when_no_negative_sell(self) -> None:
|
||||
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
|
||||
slots: list[PlanningSlot] = []
|
||||
for i in range(10):
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=3.0 if i < 7 else -10.0,
|
||||
sell_price=2.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
)
|
||||
)
|
||||
adv = _prewindow_deferral_slots(slots, -2.0)
|
||||
self.assertEqual(adv[0], 7)
|
||||
|
||||
|
||||
class SlotsUntilBuyExtremeTests(unittest.TestCase):
|
||||
def test_slots_until_first_extreme(self) -> None:
|
||||
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
|
||||
slots: list[PlanningSlot] = []
|
||||
for i in range(10):
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=1.0,
|
||||
sell_price=1.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
)
|
||||
)
|
||||
slots[-1] = PlanningSlot(
|
||||
interval_start=slots[-1].interval_start,
|
||||
buy_price=-10.0,
|
||||
sell_price=0.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
)
|
||||
dist = _slots_until_buy_le_threshold(slots, -2.0)
|
||||
self.assertEqual(dist[0], 9)
|
||||
self.assertEqual(dist[8], 1)
|
||||
self.assertEqual(dist[9], 0)
|
||||
|
||||
def test_prewindow_clamps_relaxed_floor_until_close(self) -> None:
|
||||
sm = [5000.0] * 10
|
||||
dist = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] # obecná kotva (sell nebo buy)
|
||||
panel = _soc_panel_min_wh_series(sm, dist, 10_000.0, 20_000.0, 2)
|
||||
self.assertEqual(panel[0], 20_000.0)
|
||||
self.assertEqual(panel[6], 20_000.0)
|
||||
self.assertEqual(panel[7], 5000.0)
|
||||
self.assertEqual(panel[9], 5000.0)
|
||||
|
||||
|
||||
class DynamicArbFloorTests(unittest.TestCase):
|
||||
def test_more_pv_ahead_lowers_floor(self) -> None:
|
||||
"""Čím víc FVE ve lookahead, tím nižší ekonomická podlaha v prvním slotu."""
|
||||
@@ -95,6 +205,55 @@ def replace_slot(
|
||||
|
||||
|
||||
class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None:
|
||||
"""
|
||||
Když:
|
||||
- aktuální slot má sell < 0 (export je náklad),
|
||||
- v horizontu existuje budoucí buy < 0,
|
||||
- a zároveň existuje PV B (necurtailable) někde v horizontu,
|
||||
solver preferuje curtail PV A (ca) místo placeného exportu ge.
|
||||
"""
|
||||
slots = [
|
||||
_slot(load=0, buy=3.0, sell=-0.1, pv_a=5000, pv_b=0),
|
||||
_slot(load=0, buy=-10.0, sell=1.0, pv_a=0, pv_b=5000),
|
||||
]
|
||||
battery = _battery(uc_wh=50_000.0)
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.50 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 2)
|
||||
# Slot 0: PV A se má raději uříznout než vyvážet za zápornou cenu.
|
||||
self.assertEqual(int(results[0].pv_a_curtailed_w), 5000)
|
||||
self.assertGreaterEqual(int(results[0].grid_setpoint_w), 0)
|
||||
|
||||
def test_two_tier_soc_solves_optimal(self) -> None:
|
||||
slots = [_slot()]
|
||||
battery = _battery()
|
||||
@@ -128,7 +287,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
price_failsafe_active=False,
|
||||
)
|
||||
self.assertGreaterEqual(ms, 0)
|
||||
self.assertEqual(len(results), 1)
|
||||
@@ -169,7 +327,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
price_failsafe_active=False,
|
||||
)
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
@@ -206,12 +363,11 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
price_failsafe_active=False,
|
||||
)
|
||||
self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
|
||||
|
||||
def test_export_implies_end_soc_at_least_reserve(self) -> None:
|
||||
"""Při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB)."""
|
||||
"""Bez arbitrážní relaxace: při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB)."""
|
||||
slots = [
|
||||
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
|
||||
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
|
||||
@@ -247,7 +403,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
price_failsafe_active=False,
|
||||
)
|
||||
reserve_pct = 20.0
|
||||
for r in results:
|
||||
@@ -258,6 +413,555 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
msg="export slot must end at or above reserve SoC",
|
||||
)
|
||||
|
||||
def test_export_before_extreme_negative_buy_can_end_below_reserve(self) -> None:
|
||||
"""
|
||||
Při relaxovaném soc_min (záporný buy v lookahead) smí významný export skončit u planner floor,
|
||||
ne u provozní rezervy — jinak nejde ráno vypustit do sítě a nachystat kapacitu před levným nákupem.
|
||||
"""
|
||||
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
|
||||
s0 = PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=2.5,
|
||||
sell_price=2.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=400,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
s1 = PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=-12.0,
|
||||
sell_price=-0.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=400,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
slots = [s0, s1]
|
||||
battery = _battery(uc_wh=10_000.0, min_pct=10.0, arb_pct=20.0)
|
||||
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
|
||||
battery.planner_discharge_floor_percent = 5.0
|
||||
battery.max_charge_power_w = 50_000
|
||||
battery.max_discharge_power_w = 50_000
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.88 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 2)
|
||||
if results[0].grid_setpoint_w < 0:
|
||||
self.assertLess(
|
||||
results[0].battery_soc_target,
|
||||
19.0,
|
||||
msg="with relaxed soc_min, first-slot export should be able to finish below reserve %",
|
||||
)
|
||||
|
||||
def test_negative_sell_forbids_battery_export_arbitrage(self) -> None:
|
||||
"""
|
||||
Pokud sell < 0, solver nesmí vybíjet baterii do sítě pro arbitráž (dump musí proběhnout předtím).
|
||||
V okně sell<0 smí export vzniknout jen z přebytku FVE; zde ale FVE=0, takže očekáváme grid_setpoint>=0.
|
||||
"""
|
||||
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
|
||||
s0 = PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=2.0,
|
||||
sell_price=2.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
s1 = PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=2.0,
|
||||
sell_price=-0.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
s2 = PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=30),
|
||||
buy_price=-15.0,
|
||||
sell_price=-1.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
slots = [s0, s1, s2]
|
||||
battery = _battery(uc_wh=10_000.0, min_pct=10.0, arb_pct=20.0)
|
||||
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
|
||||
battery.planner_discharge_floor_percent = 5.0
|
||||
battery.max_charge_power_w = 50_000
|
||||
battery.max_discharge_power_w = 50_000
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
soc0 = 0.9 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 3)
|
||||
# V sell<0 slotu bez FVE a bez zátěže nesmí být export (to by muselo být z baterie).
|
||||
self.assertGreaterEqual(results[1].grid_setpoint_w, 0)
|
||||
# A zároveň nesmí být baterie ve výboji (dump musí proběhnout předtím).
|
||||
self.assertGreaterEqual(results[1].battery_setpoint_w, 0)
|
||||
|
||||
def test_anchor_hits_floor_before_first_negative_sell(self) -> None:
|
||||
"""
|
||||
Pokud se v horizontu objeví první sell<0 a současně existuje planner floor (relaxace),
|
||||
solver má skončit už v předchozím slotu u planner floor (cca 5 %), ne na ~15 %.
|
||||
"""
|
||||
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
|
||||
# Slot 0-1: sell >= 0; slot 2: první sell < 0; slot 3: extrémně záporný buy (motivace k bufferu).
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=3.0,
|
||||
sell_price=1.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=3.0,
|
||||
sell_price=0.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=30),
|
||||
buy_price=3.0,
|
||||
sell_price=-0.2,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=45),
|
||||
buy_price=-20.0,
|
||||
sell_price=-1.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
]
|
||||
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
|
||||
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
|
||||
battery.planner_discharge_floor_percent = 5.0
|
||||
battery.max_charge_power_w = 50_000
|
||||
battery.max_discharge_power_w = 50_000
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
soc0 = 0.9 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
# Slot index 1 je poslední před prvním sell<0 (index 2).
|
||||
self.assertLessEqual(
|
||||
results[1].battery_soc_target,
|
||||
6.0,
|
||||
msg="anchor should drive SoC close to planner floor before first negative sell",
|
||||
)
|
||||
|
||||
def test_anchor_uses_planner_floor_even_without_extreme_buy(self) -> None:
|
||||
"""
|
||||
Regrese: pokud v horizontu není buy <= threshold (soc_min_series by se nerelaxovala),
|
||||
kotva před sell<0 má stejně mířit na planner floor (5 %), ne na base min SoC.
|
||||
"""
|
||||
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=3.0,
|
||||
sell_price=1.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=3.0,
|
||||
sell_price=0.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=30),
|
||||
buy_price=3.0,
|
||||
sell_price=-0.2,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
]
|
||||
battery = _battery(uc_wh=20_000.0, min_pct=12.0, arb_pct=20.0)
|
||||
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
|
||||
battery.planner_discharge_floor_percent = 5.0
|
||||
battery.max_charge_power_w = 50_000
|
||||
battery.max_discharge_power_w = 50_000
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
soc0 = 0.9 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
# Slot index 1 je poslední před prvním sell<0 (index 2).
|
||||
self.assertLessEqual(results[1].battery_soc_target, 6.0)
|
||||
|
||||
def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None:
|
||||
"""
|
||||
Soft cap: solver může nominálně překročit breaker, ale jen pokud se to vyplatí.
|
||||
Při běžné (nezáporné) nákupní ceně by měl držet import <= breaker.
|
||||
"""
|
||||
slots = [_slot(load=3700, buy=0.4, sell=-0.3, pv_a=0, pv_b=1500)]
|
||||
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
|
||||
battery.max_charge_power_w = 18_000
|
||||
battery.max_discharge_power_w = 18_000
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.55 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertLessEqual(
|
||||
results[0].grid_setpoint_w,
|
||||
grid.max_import_power_w,
|
||||
msg="soft cap: for normal buy price, planned grid import should not exceed breaker",
|
||||
)
|
||||
|
||||
def test_grid_import_soft_cap_allows_overdraw_when_extremely_negative(self) -> None:
|
||||
"""
|
||||
Regrese: při extrémně záporné nákupní ceně může solver překročit breaker (za cenu penalizace),
|
||||
aby stihl krátké okno nabíjení. Překročení nesmí být 'zadarmo' (kontrolujeme alespoň, že existuje).
|
||||
"""
|
||||
# Dvouslotový scénář: v 1. slotu extrémně záporná cena, ve 2. slotu drahá.
|
||||
# Terminal SoC kotva pak nepenalizuje držení energie (průměrná buy je ~0) a solver má motivaci
|
||||
# v 1. slotu nabít na max, i kdyby to znamenalo malé překročení breakeru.
|
||||
s0 = _slot(load=0, buy=-20.0, sell=-0.3, pv_a=0, pv_b=0)
|
||||
s1 = replace_slot(s0, load=0)
|
||||
s1 = PlanningSlot(
|
||||
interval_start=s0.interval_start + timedelta(minutes=15),
|
||||
buy_price=20.0,
|
||||
sell_price=-0.3,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
)
|
||||
slots = [s0, s1]
|
||||
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
|
||||
battery.max_charge_power_w = 18_000
|
||||
battery.max_discharge_power_w = 18_000
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
soc0 = 0.55 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertGreater(
|
||||
results[0].grid_setpoint_w,
|
||||
grid.max_import_power_w,
|
||||
msg="with very negative buy price, solver may choose to exceed breaker (soft cap)",
|
||||
)
|
||||
|
||||
def test_block_export_on_negative_sell_no_grid_export_pv_surplus(self) -> None:
|
||||
"""site_grid_connection.block_export_on_negative_sell → ge=0 při sell<0."""
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc),
|
||||
buy_price=5.25,
|
||||
sell_price=-0.5,
|
||||
pv_a_forecast_w=7000,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=False,
|
||||
)
|
||||
]
|
||||
battery = _battery(uc_wh=20_000.0, arb_pct=15.0, max_pct=95.0)
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=17_000,
|
||||
max_export_power_w=8000,
|
||||
block_export_on_negative_sell=True,
|
||||
)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.34 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertGreaterEqual(results[0].grid_setpoint_w, 0, "no grid export")
|
||||
self.assertGreater(results[0].battery_setpoint_w, 0, "surplus PV should charge")
|
||||
|
||||
|
||||
class TerminalSocShadowTests(unittest.TestCase):
|
||||
"""Terminal SoC shadow price v objective drží konec horizontu nad holým minimem."""
|
||||
|
||||
def test_terminal_soc_shadow_price_prevents_drain(self) -> None:
|
||||
base = datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc)
|
||||
slots = []
|
||||
for i in range(3):
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=2.0,
|
||||
sell_price=0.6,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=600,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
)
|
||||
)
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=45),
|
||||
buy_price=2.0,
|
||||
sell_price=14.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=600,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
)
|
||||
)
|
||||
battery = _battery(uc_wh=12_000.0, min_pct=12.0, arb_pct=20.0)
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.5 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 4)
|
||||
# Bez shadow price by solver mohl končit u min SoC; kotva drží znatelnou rezervu.
|
||||
self.assertGreaterEqual(
|
||||
results[-1].battery_soc_target,
|
||||
15.0,
|
||||
msg="terminal SoC shadow price should keep end-of-horizon SoC above bare minimum",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
28
backend/tests/test_telemetry_export_limit_flags.py
Normal file
28
backend/tests/test_telemetry_export_limit_flags.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Logika is_export_limited / pv_derating_flags z Deye reg 145 a 179."""
|
||||
|
||||
from services.telemetry_collector import _export_limit_flags_from_deye_regs
|
||||
|
||||
|
||||
def test_both_none_unknown() -> None:
|
||||
lim, flags = _export_limit_flags_from_deye_regs(None, None)
|
||||
assert lim is None and flags is None
|
||||
|
||||
|
||||
def test_solar_sell_disabled() -> None:
|
||||
lim, flags = _export_limit_flags_from_deye_regs(0, None)
|
||||
assert lim is True and flags == 1
|
||||
|
||||
|
||||
def test_solar_sell_enabled_only() -> None:
|
||||
lim, flags = _export_limit_flags_from_deye_regs(1, None)
|
||||
assert lim is False and flags == 0
|
||||
|
||||
|
||||
def test_gen_mi_cutoff_bits() -> None:
|
||||
lim, flags = _export_limit_flags_from_deye_regs(None, 3)
|
||||
assert lim is True and flags == 2
|
||||
|
||||
|
||||
def test_combined_flags() -> None:
|
||||
lim, flags = _export_limit_flags_from_deye_regs(0, 3)
|
||||
assert lim is True and flags == 3
|
||||
@@ -27,7 +27,8 @@ SELECT add_continuous_aggregate_policy(
|
||||
schedule_interval => INTERVAL '15 minutes'
|
||||
);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW ems.telemetry_inverter_15m IS
|
||||
-- Timescale CA není v katalogu „materialized view“ – stejně jako V011 u telemetry_inverter_hourly.
|
||||
COMMENT ON VIEW ems.telemetry_inverter_15m IS
|
||||
'Čtvrthodinové agregáty telemetrie střídače. TimescaleDB continuous aggregate.
|
||||
Refresh každých 15 minut. Dashboard přehled (sloty 15 min).
|
||||
View vw_telemetry_15m_7d je v repeatable R__vw_telemetry_15m_7d.sql.';
|
||||
|
||||
38
db/migration/V040__energy_wh_columns.sql
Normal file
38
db/migration/V040__energy_wh_columns.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- =============================================================
|
||||
-- V040 – Energy Wh columns
|
||||
-- Přidává kumulativní čítače grid energie do telemetrie
|
||||
-- a per-slot Wh sloupce do audit_interval pro přesné
|
||||
-- import/export měření (Deye reg 522-525 + per-minute fallback).
|
||||
-- =============================================================
|
||||
|
||||
-- 1. telemetry_inverter: kumulativní Deye lifetime čítače
|
||||
ALTER TABLE ems.telemetry_inverter
|
||||
ADD COLUMN IF NOT EXISTS grid_import_total_wh BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS grid_export_total_wh BIGINT;
|
||||
|
||||
COMMENT ON COLUMN ems.telemetry_inverter.grid_import_total_wh IS
|
||||
'Kumulativní import ze sítě (Wh) z Deye reg 522+523 (32-bit × 0.1 kWh). Lifetime čítač, monotónně rostoucí.';
|
||||
COMMENT ON COLUMN ems.telemetry_inverter.grid_export_total_wh IS
|
||||
'Kumulativní export do sítě (Wh) z Deye reg 524+525 (32-bit × 0.1 kWh). Lifetime čítač, monotónně rostoucí.';
|
||||
|
||||
-- 2. audit_interval: 6 základních energetických veličin (Wh za 15min slot)
|
||||
ALTER TABLE ems.audit_interval
|
||||
ADD COLUMN IF NOT EXISTS actual_grid_import_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS actual_grid_export_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS actual_batt_charge_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS actual_batt_discharge_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS actual_pv_production_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS actual_load_consumption_wh NUMERIC(10,1);
|
||||
|
||||
COMMENT ON COLUMN ems.audit_interval.actual_grid_import_wh IS
|
||||
'Import ze sítě za 15min slot (Wh). Primárně z delta Deye total counterů (reg 522+523), fallback per-minutový split z grid_power_w.';
|
||||
COMMENT ON COLUMN ems.audit_interval.actual_grid_export_wh IS
|
||||
'Export do sítě za 15min slot (Wh). Primárně z delta Deye total counterů (reg 524+525), fallback per-minutový split z grid_power_w.';
|
||||
COMMENT ON COLUMN ems.audit_interval.actual_batt_charge_wh IS
|
||||
'Nabití baterie za 15min slot (Wh). Per-minutový split z battery_power_w (záporné = nabíjení).';
|
||||
COMMENT ON COLUMN ems.audit_interval.actual_batt_discharge_wh IS
|
||||
'Vybití baterie za 15min slot (Wh). Per-minutový split z battery_power_w (kladné = vybíjení).';
|
||||
COMMENT ON COLUMN ems.audit_interval.actual_pv_production_wh IS
|
||||
'FVE výroba za 15min slot (Wh). SUM(pv_power_w) / 60 z minutových vzorků.';
|
||||
COMMENT ON COLUMN ems.audit_interval.actual_load_consumption_wh IS
|
||||
'Celková spotřeba za 15min slot (Wh). SUM(load_power_w) / 60 z minutových vzorků.';
|
||||
13
db/migration/V041__audit_day_lock_grid_direction.sql
Normal file
13
db/migration/V041__audit_day_lock_grid_direction.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- =============================================================
|
||||
-- V041 – audit_day_lock: směrové cashflow sloupce
|
||||
-- Snapshot pro zamknuté dny rozšířen o cashflow podle směru energie.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE ems.audit_day_lock
|
||||
ADD COLUMN IF NOT EXISTS grid_import_cashflow_czk NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS grid_export_revenue_czk NUMERIC(12,2);
|
||||
|
||||
COMMENT ON COLUMN ems.audit_day_lock.grid_import_cashflow_czk IS
|
||||
'Snapshot: celková cena za import ze sítě v Kč (může být záporná při záporné spotové ceně).';
|
||||
COMMENT ON COLUMN ems.audit_day_lock.grid_export_revenue_czk IS
|
||||
'Snapshot: celkový příjem z exportu do sítě v Kč.';
|
||||
28
db/migration/V042__energy_flow_columns.sql
Normal file
28
db/migration/V042__energy_flow_columns.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- =============================================================
|
||||
-- V042 – Energy flow decomposition (7 directional flows per 15min)
|
||||
-- Plní se v ems.fn_fill_audit_interval (prioritní alokace per minuta).
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE ems.audit_interval
|
||||
ADD COLUMN IF NOT EXISTS flow_pv_to_load_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS flow_pv_to_batt_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS flow_pv_to_grid_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS flow_batt_to_load_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS flow_batt_to_grid_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS flow_grid_to_load_wh NUMERIC(10,1),
|
||||
ADD COLUMN IF NOT EXISTS flow_grid_to_batt_wh NUMERIC(10,1);
|
||||
|
||||
COMMENT ON COLUMN ems.audit_interval.flow_pv_to_load_wh IS
|
||||
'Modelovaný tok FVE → spotřeba (Wh/slot). Per-minutová prioritní alokace: PV nejdřív load.';
|
||||
COMMENT ON COLUMN ems.audit_interval.flow_pv_to_batt_wh IS
|
||||
'Modelovaný tok FVE → nabíjení baterie (Wh/slot).';
|
||||
COMMENT ON COLUMN ems.audit_interval.flow_pv_to_grid_wh IS
|
||||
'Modelovaný tok FVE → export do sítě (Wh/slot).';
|
||||
COMMENT ON COLUMN ems.audit_interval.flow_batt_to_load_wh IS
|
||||
'Modelovaný tok vybití baterie → spotřeba (Wh/slot).';
|
||||
COMMENT ON COLUMN ems.audit_interval.flow_batt_to_grid_wh IS
|
||||
'Modelovaný tok vybití baterie → export (Wh/slot).';
|
||||
COMMENT ON COLUMN ems.audit_interval.flow_grid_to_load_wh IS
|
||||
'Modelovaný tok import ze sítě → spotřeba (Wh/slot).';
|
||||
COMMENT ON COLUMN ems.audit_interval.flow_grid_to_batt_wh IS
|
||||
'Modelovaný tok import ze sítě → nabíjení baterie (Wh/slot).';
|
||||
388
db/migration/V043__site_25a_fixed_buy_seed.sql
Normal file
388
db/migration/V043__site_25a_fixed_buy_seed.sql
Normal file
@@ -0,0 +1,388 @@
|
||||
-- =============================================================
|
||||
-- V043__site_25a_fixed_buy_seed.sql
|
||||
-- Sloupce pro fixní nákupní energii (NT + příplatek VT) a seed lokality site-25a.
|
||||
--
|
||||
-- Jedna verzovaná migrace: čtyři FVE pole (různá orientace), žádný mezikrok pv-a/pv-b.
|
||||
--
|
||||
-- Obnova / přepnutí checksum na DB, kde už běžela starší varianta V043 nebo V044:
|
||||
-- DELETE FROM flyway_schema_history WHERE version IN ('043', '044');
|
||||
-- Potom: flyway migrate
|
||||
-- (Sloupce buy_fixed_* zůstanou díky ADD COLUMN IF NOT EXISTS; DO blok smaže legacy pv-a/pv-b
|
||||
-- a doplní pv-str-*/pv-mi-* pokud chybí.)
|
||||
-- =============================================================
|
||||
|
||||
-- Fixní složka nákupu bez DPH (k distribuci / poplatkům / marži / DPH dle fn_effective_buy_price)
|
||||
ALTER TABLE ems.site_market_config
|
||||
ADD COLUMN IF NOT EXISTS buy_fixed_energy_nt_czk_kwh NUMERIC(10,6),
|
||||
ADD COLUMN IF NOT EXISTS buy_fixed_vt_surcharge_czk_kwh NUMERIC(10,6) NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN ems.site_market_config.buy_fixed_energy_nt_czk_kwh IS
|
||||
'Při purchase_pricing_mode = fixed: základní nákupní cena energie Kč/kWh bez DPH v NT hodinách. VT = tato hodnota + buy_fixed_vt_surcharge_czk_kwh podle HDO oken.';
|
||||
|
||||
COMMENT ON COLUMN ems.site_market_config.buy_fixed_vt_surcharge_czk_kwh IS
|
||||
'Při purchase_pricing_mode = fixed: příplatek Kč/kWh bez DPH k NT ceně ve VT oknech dle hdo_code_id.';
|
||||
|
||||
-- =============================================================
|
||||
-- Seed lokality (idempotentní DO blok)
|
||||
-- Viz docs/new-site-setup-template.md – ev-charger-1 pro planner/telemetrii.
|
||||
-- FVE: čtyři záznamy asset_pv_array (forecast service běží per pole; planner sčítá controllable / !controllable).
|
||||
-- =============================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_site_code TEXT := 'BA81';
|
||||
|
||||
v_host_modbus TEXT := '109.164.83.155';
|
||||
v_port_modbus INT := 502;
|
||||
v_host_loxone TEXT := '109.164.83.155';
|
||||
v_port_loxone INT := 8080;
|
||||
|
||||
v_site_id INT;
|
||||
v_ep_deye INT;
|
||||
v_ep_ev INT;
|
||||
v_ep_loxone INT;
|
||||
v_inv_main INT;
|
||||
v_inv_gen INT;
|
||||
v_hdo_id INT;
|
||||
v_ch_id INT;
|
||||
BEGIN
|
||||
SELECT hc.id INTO v_hdo_id
|
||||
FROM ems.hdo_code hc
|
||||
WHERE hc.distributor = 'EGD' AND hc.code = 'custom_fve_home01'
|
||||
ORDER BY hc.valid_from DESC NULLS LAST
|
||||
LIMIT 1;
|
||||
|
||||
INSERT INTO ems.site (code, name, timezone, latitude, longitude, active, notes)
|
||||
VALUES (
|
||||
v_site_code,
|
||||
'Lokalita 25A / 17 kW příkon',
|
||||
'Europe/Prague',
|
||||
49.24368977130069,
|
||||
17.425553019721196,
|
||||
true,
|
||||
'Připojení 3×25 A → import max 17 kW, export max 16 kW. '
|
||||
'Při omezení exportu do DS nastavit v Deye SmartLoad: „MI export to Grid cutoff“ = enable; '
|
||||
'po uvolnění exportu znovu disable. Veřejná IP tunelovaná z EMS serveru.'
|
||||
)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
timezone = EXCLUDED.timezone,
|
||||
latitude = EXCLUDED.latitude,
|
||||
longitude = EXCLUDED.longitude,
|
||||
active = EXCLUDED.active,
|
||||
notes = EXCLUDED.notes
|
||||
RETURNING id INTO v_site_id;
|
||||
|
||||
SELECT se.id INTO v_ep_deye
|
||||
FROM ems.site_endpoint se
|
||||
WHERE se.site_id = v_site_id
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
AND se.notes ILIKE '%Deye%'
|
||||
ORDER BY se.id
|
||||
LIMIT 1;
|
||||
|
||||
IF v_ep_deye IS NULL THEN
|
||||
INSERT INTO ems.site_endpoint (
|
||||
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, 'modbus_tcp', v_host_modbus, v_port_modbus, 'modbus_tcp', 1, true,
|
||||
'Deye 12kW LV – Modbus TCP (Waveshare).'
|
||||
)
|
||||
RETURNING id INTO v_ep_deye;
|
||||
END IF;
|
||||
|
||||
SELECT se.id INTO v_ep_ev
|
||||
FROM ems.site_endpoint se
|
||||
WHERE se.site_id = v_site_id
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
AND se.notes ILIKE '%Teltonika%'
|
||||
ORDER BY se.id
|
||||
LIMIT 1;
|
||||
|
||||
IF v_ep_ev IS NULL THEN
|
||||
INSERT INTO ems.site_endpoint (
|
||||
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, 'modbus_tcp', v_host_modbus, v_port_modbus, 'modbus_tcp', 2, true,
|
||||
'Teltonika TeltoCharge 22kW – stejná IP jako Deye, unit_id 2 (upřesni dle zapojení).'
|
||||
)
|
||||
RETURNING id INTO v_ep_ev;
|
||||
END IF;
|
||||
|
||||
SELECT se.id INTO v_ep_loxone
|
||||
FROM ems.site_endpoint se
|
||||
WHERE se.site_id = v_site_id
|
||||
AND se.endpoint_type = 'loxone_http'
|
||||
ORDER BY se.id
|
||||
LIMIT 1;
|
||||
|
||||
IF v_ep_loxone IS NULL THEN
|
||||
INSERT INTO ems.site_endpoint (
|
||||
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, 'loxone_http', v_host_loxone, v_port_loxone, 'http', NULL, true,
|
||||
'Loxone Miniserver (HTTP Virtual Inputs).'
|
||||
)
|
||||
RETURNING id INTO v_ep_loxone;
|
||||
END IF;
|
||||
|
||||
INSERT INTO ems.site_grid_connection (
|
||||
site_id, max_import_power_w, max_export_power_w, no_export, reserved_capacity_w, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, 17000, 16000, false, 0,
|
||||
'Max 25 A přívod → cca 17 kW import; přetok / export povolen 16 kW.'
|
||||
)
|
||||
ON CONFLICT (site_id) DO UPDATE SET
|
||||
max_import_power_w = EXCLUDED.max_import_power_w,
|
||||
max_export_power_w = EXCLUDED.max_export_power_w,
|
||||
no_export = EXCLUDED.no_export,
|
||||
reserved_capacity_w = EXCLUDED.reserved_capacity_w,
|
||||
notes = EXCLUDED.notes;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.site_market_config smc
|
||||
WHERE smc.site_id = v_site_id AND smc.valid_to IS NULL
|
||||
) THEN
|
||||
INSERT INTO ems.site_market_config (
|
||||
site_id,
|
||||
purchase_pricing_mode, sale_pricing_mode,
|
||||
buy_margin_fixed_czk, buy_margin_percent,
|
||||
sell_margin_fixed_czk, sell_margin_percent,
|
||||
currency, valid_from, valid_to, notes,
|
||||
tariff_id, hdo_code_id, system_services_czk_kwh, ote_fee_czk_kwh,
|
||||
buy_fixed_energy_nt_czk_kwh, buy_fixed_vt_surcharge_czk_kwh
|
||||
)
|
||||
VALUES (
|
||||
v_site_id,
|
||||
'fixed', 'spot',
|
||||
0, 0,
|
||||
-0.020, 0,
|
||||
'CZK', now(), NULL,
|
||||
'Nákup fixní 3,67 Kč/kWh bez DPH (NT) + 0,52 Kč/kWh bez DPH ve VT (okna dle HDO jako home-01). '
|
||||
'Prodej na spotu jako home-01. Distribuce v efektivní ceně 0 (tariff_id NULL) – energie jen fix + DPH dle vat_rate výchozí.',
|
||||
NULL,
|
||||
v_hdo_id,
|
||||
0,
|
||||
0,
|
||||
3.67,
|
||||
0.52
|
||||
);
|
||||
END IF;
|
||||
|
||||
INSERT INTO ems.site_operating_mode (site_id, mode_code, activated_by, notes)
|
||||
VALUES (
|
||||
v_site_id,
|
||||
'MANUAL',
|
||||
'migration:V043_site_25a',
|
||||
'Start MANUAL; po ověření přepnout na AUTO.'
|
||||
)
|
||||
ON CONFLICT (site_id) DO NOTHING;
|
||||
|
||||
SELECT ai.id INTO v_inv_main
|
||||
FROM ems.asset_inverter ai
|
||||
WHERE ai.site_id = v_site_id AND ai.code = 'deye-main'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_inv_main IS NULL THEN
|
||||
INSERT INTO ems.asset_inverter (
|
||||
site_id, code, manufacturer, model, endpoint_id,
|
||||
max_charge_power_w, max_discharge_power_w, max_export_power_w,
|
||||
max_ac_output_w, max_dc_input_w, max_battery_charge_w, max_battery_discharge_w,
|
||||
gen_port_max_power_w,
|
||||
controllable, active, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id,
|
||||
'deye-main',
|
||||
'Deye',
|
||||
NULL,
|
||||
v_ep_deye,
|
||||
6250, 6250, 12000,
|
||||
12000, 24000, 6250, 6250,
|
||||
5000,
|
||||
true, true,
|
||||
'12kW LV hybrid. Baterie limit 0,5C ≈ 6,25 kW (280 A teoreticky vyšší – plánování dle 6,25 kW). '
|
||||
'GEN port max ~5 kW součet MI.'
|
||||
)
|
||||
RETURNING id INTO v_inv_main;
|
||||
END IF;
|
||||
|
||||
SELECT ai.id INTO v_inv_gen
|
||||
FROM ems.asset_inverter ai
|
||||
WHERE ai.site_id = v_site_id AND ai.code = 'ongrid-gen'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_inv_gen IS NULL THEN
|
||||
INSERT INTO ems.asset_inverter (
|
||||
site_id, code, manufacturer, model, endpoint_id,
|
||||
max_export_power_w, controllable, active, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id,
|
||||
'ongrid-gen',
|
||||
NULL, NULL, NULL,
|
||||
5000, false, true,
|
||||
'Mikroinvertory na GEN portu (2 skupiny panelů), EMS necurtailuje.'
|
||||
)
|
||||
RETURNING id INTO v_inv_gen;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.asset_battery ab
|
||||
WHERE ab.site_id = v_site_id AND ab.code = 'bat-main'
|
||||
) THEN
|
||||
INSERT INTO ems.asset_battery (
|
||||
site_id, inverter_id, code,
|
||||
usable_capacity_wh, min_soc_percent, reserve_soc_percent, max_soc_percent,
|
||||
charge_efficiency, discharge_efficiency, degradation_cost_czk_kwh,
|
||||
max_charge_c_rate, max_discharge_c_rate, bms_max_charge_w, bms_max_discharge_w
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, v_inv_main, 'bat-main',
|
||||
12500,
|
||||
10, 15, 95,
|
||||
0.95, 0.95,
|
||||
0.50,
|
||||
0.5, 0.5,
|
||||
6250, 6250
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Odstranění starého agregovaného seedu (pv-a / pv-b), pokud na DB zůstal z dřívější verze.
|
||||
DELETE FROM ems.forecast_accuracy fa
|
||||
WHERE fa.pv_array_id IN (
|
||||
SELECT id FROM ems.asset_pv_array
|
||||
WHERE site_id = v_site_id AND code IN ('pv-a', 'pv-b')
|
||||
);
|
||||
|
||||
DELETE FROM ems.forecast_pv_interval fpi
|
||||
USING ems.asset_pv_array apa
|
||||
WHERE apa.site_id = v_site_id
|
||||
AND apa.code IN ('pv-a', 'pv-b')
|
||||
AND fpi.pv_array_id = apa.id;
|
||||
|
||||
DELETE FROM ems.forecast_pv_run fpr
|
||||
WHERE fpr.site_id = v_site_id
|
||||
AND fpr.pv_array_id IN (
|
||||
SELECT id FROM ems.asset_pv_array
|
||||
WHERE site_id = v_site_id AND code IN ('pv-a', 'pv-b')
|
||||
);
|
||||
|
||||
DELETE FROM ems.asset_pv_array
|
||||
WHERE site_id = v_site_id AND code IN ('pv-a', 'pv-b');
|
||||
|
||||
-- String 1: 12×620 Wp @110° / 45° (Deye, řiditelné)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.asset_pv_array ap
|
||||
WHERE ap.site_id = v_site_id AND ap.code = 'pv-str-1'
|
||||
) THEN
|
||||
INSERT INTO ems.asset_pv_array (
|
||||
site_id, inverter_id, code, name,
|
||||
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
|
||||
controllable, telemetry_source, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, v_inv_main, 'pv-str-1', 'String 1 – 12×620 Wp',
|
||||
7440, 110, 45, 12, 1.0, true, 'pv_strings',
|
||||
'Hlavní telemetrie stringů Deye (pv1+pv2); druhý string má telemetry_source NULL.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- String 2: 8×620 Wp @200° / 10° (Deye, řiditelné)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.asset_pv_array ap
|
||||
WHERE ap.site_id = v_site_id AND ap.code = 'pv-str-2'
|
||||
) THEN
|
||||
INSERT INTO ems.asset_pv_array (
|
||||
site_id, inverter_id, code, name,
|
||||
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
|
||||
controllable, telemetry_source, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, v_inv_main, 'pv-str-2', 'String 2 – 8×620 Wp',
|
||||
4960, 200, 10, 8, 1.0, true, NULL,
|
||||
'Vlastní predikce orientace; telemetrie sdílená se stringem 1.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- MI 5×620 Wp @200° / 45° (GEN, neriditelné)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.asset_pv_array ap
|
||||
WHERE ap.site_id = v_site_id AND ap.code = 'pv-mi-1'
|
||||
) THEN
|
||||
INSERT INTO ems.asset_pv_array (
|
||||
site_id, inverter_id, code, name,
|
||||
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
|
||||
controllable, telemetry_source, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, v_inv_gen, 'pv-mi-1', 'Mikroinvertory 5×620 Wp',
|
||||
3100, 200, 45, 5, 1.0, false, 'gen_port',
|
||||
'Souhrnná telemetrie GEN portu; druhá MI skupina má telemetry NULL.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- MI 3×620 Wp @110° / 10° (GEN, neriditelné)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.asset_pv_array ap
|
||||
WHERE ap.site_id = v_site_id AND ap.code = 'pv-mi-2'
|
||||
) THEN
|
||||
INSERT INTO ems.asset_pv_array (
|
||||
site_id, inverter_id, code, name,
|
||||
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
|
||||
controllable, telemetry_source, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, v_inv_gen, 'pv-mi-2', 'Mikroinvertory 3×620 Wp',
|
||||
1860, 110, 10, 3, 1.0, false, NULL,
|
||||
'Predikce samostatně; gen_port u pv-mi-1.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.asset_ev_charger c
|
||||
WHERE c.site_id = v_site_id AND c.code = 'ev-charger-1'
|
||||
) THEN
|
||||
INSERT INTO ems.asset_ev_charger (
|
||||
site_id, code, manufacturer, model, endpoint_id,
|
||||
max_power_w, min_power_w, phases, connector_count, schedulable, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, 'ev-charger-1', 'Teltonika', 'TeltoCharge 22kW',
|
||||
v_ep_ev,
|
||||
22000, 1380, 3, 1, true,
|
||||
'Jedna nabíječka; kód ev-charger-1 kvůli planneru / telemetrii.'
|
||||
)
|
||||
RETURNING id INTO v_ch_id;
|
||||
ELSE
|
||||
SELECT id INTO v_ch_id FROM ems.asset_ev_charger
|
||||
WHERE site_id = v_site_id AND code = 'ev-charger-1'
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
INSERT INTO ems.asset_vehicle (
|
||||
site_id, code, name, make, model,
|
||||
battery_capacity_kwh, max_charge_power_w, default_charger_id, api_type,
|
||||
default_target_soc_pct, default_deadline_hour, active
|
||||
)
|
||||
VALUES (
|
||||
v_site_id,
|
||||
'ev-default',
|
||||
'EV (výchozí)',
|
||||
NULL, NULL,
|
||||
60.0,
|
||||
11000,
|
||||
v_ch_id,
|
||||
'none',
|
||||
80,
|
||||
7,
|
||||
true
|
||||
)
|
||||
ON CONFLICT (site_id, code) DO NOTHING;
|
||||
|
||||
END;
|
||||
$$;
|
||||
9
db/migration/V044__deye_register_max_current_a.sql
Normal file
9
db/migration/V044__deye_register_max_current_a.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Volitelný tvrdý strop proudu pro Modbus reg 108/109 (Deye může firmwarem oříznout pod W-odvozeným max, např. 351→350 A).
|
||||
ALTER TABLE ems.asset_inverter
|
||||
ADD COLUMN IF NOT EXISTS deye_register_max_charge_a INT NULL,
|
||||
ADD COLUMN IF NOT EXISTS deye_register_max_discharge_a INT NULL;
|
||||
|
||||
COMMENT ON COLUMN ems.asset_inverter.deye_register_max_charge_a IS
|
||||
'Optional cap for holding reg 108 (A); NULL = use only LEAST(W)/51.2 derived max.';
|
||||
COMMENT ON COLUMN ems.asset_inverter.deye_register_max_discharge_a IS
|
||||
'Optional cap for holding reg 109 (A); NULL = use only derived max.';
|
||||
201
db/migration/V045__seed_site_kv1.sql
Normal file
201
db/migration/V045__seed_site_kv1.sql
Normal file
@@ -0,0 +1,201 @@
|
||||
-- =============================================================
|
||||
-- V045__seed_site_kv1.sql
|
||||
-- Idempotentní seed lokality KV1 (viz docs/new-site-setup-template.md).
|
||||
-- 25 A přívod → import max 17 kW; přetok / export max 8 kW.
|
||||
-- Nákup fixní 5,25 Kč/kWh bez DPH (jednotná sazba – na místě není NT tarif; HDO NULL).
|
||||
-- Prodej na spotu jako home-01 (marže sell -0,02 Kč/kWh).
|
||||
-- Deye 12 kW LV, baterie 12,5 kWh, 0,5C; Waveshare 172.16.2.10. Bez Loxone.
|
||||
-- Start: MANUAL (EMS nezapisuje setpointy); fyzicky Deye PASSIVE dle poznámky.
|
||||
-- =============================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_site_code TEXT := 'KV1';
|
||||
|
||||
v_host_deye TEXT := '172.16.2.10';
|
||||
v_port_deye INT := 502;
|
||||
|
||||
v_site_id INT;
|
||||
v_ep_deye INT;
|
||||
v_inv_main INT;
|
||||
BEGIN
|
||||
INSERT INTO ems.site (code, name, timezone, latitude, longitude, active, notes)
|
||||
VALUES (
|
||||
v_site_code,
|
||||
'KV1',
|
||||
'Europe/Prague',
|
||||
49.23988687187006,
|
||||
17.47170575741328,
|
||||
true,
|
||||
'Připojení max 25 A → import cca 17 kW; povolený přetok / export 8 kW. '
|
||||
'Waveshare RS485→TCP ' || v_host_deye || '. Loxone na instalaci není. '
|
||||
'Provozní start: EMS režim MANUAL (bez zápisů); střídač nechat v PASSIVE do ověření.'
|
||||
)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
timezone = EXCLUDED.timezone,
|
||||
latitude = EXCLUDED.latitude,
|
||||
longitude = EXCLUDED.longitude,
|
||||
active = EXCLUDED.active,
|
||||
notes = EXCLUDED.notes
|
||||
RETURNING id INTO v_site_id;
|
||||
|
||||
SELECT se.id INTO v_ep_deye
|
||||
FROM ems.site_endpoint se
|
||||
WHERE se.site_id = v_site_id
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
AND se.notes ILIKE '%Deye%'
|
||||
ORDER BY se.id
|
||||
LIMIT 1;
|
||||
|
||||
IF v_ep_deye IS NULL THEN
|
||||
INSERT INTO ems.site_endpoint (
|
||||
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, 'modbus_tcp', v_host_deye, v_port_deye, 'modbus_tcp', 1, true,
|
||||
'Deye 12kW LV – Modbus TCP (Waveshare).'
|
||||
)
|
||||
RETURNING id INTO v_ep_deye;
|
||||
END IF;
|
||||
|
||||
INSERT INTO ems.site_grid_connection (
|
||||
site_id, max_import_power_w, max_export_power_w, no_export, reserved_capacity_w, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, 17000, 8000, false, 0,
|
||||
'Max 25 A přívod → cca 17 kW import; přetok do sítě max 8 kW.'
|
||||
)
|
||||
ON CONFLICT (site_id) DO UPDATE SET
|
||||
max_import_power_w = EXCLUDED.max_import_power_w,
|
||||
max_export_power_w = EXCLUDED.max_export_power_w,
|
||||
no_export = EXCLUDED.no_export,
|
||||
reserved_capacity_w = EXCLUDED.reserved_capacity_w,
|
||||
notes = EXCLUDED.notes;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.site_market_config smc
|
||||
WHERE smc.site_id = v_site_id AND smc.valid_to IS NULL
|
||||
) THEN
|
||||
INSERT INTO ems.site_market_config (
|
||||
site_id,
|
||||
purchase_pricing_mode, sale_pricing_mode,
|
||||
buy_margin_fixed_czk, buy_margin_percent,
|
||||
sell_margin_fixed_czk, sell_margin_percent,
|
||||
currency, valid_from, valid_to, notes,
|
||||
tariff_id, hdo_code_id, system_services_czk_kwh, ote_fee_czk_kwh,
|
||||
buy_fixed_energy_nt_czk_kwh, buy_fixed_vt_surcharge_czk_kwh
|
||||
)
|
||||
VALUES (
|
||||
v_site_id,
|
||||
'fixed', 'spot',
|
||||
0, 0,
|
||||
-0.020, 0,
|
||||
'CZK', now(), NULL,
|
||||
'Nákup fixní 5,25 Kč/kWh bez DPH (jednotná sazba; NT tarif na místě není – bez HDO okna). '
|
||||
'Prodej na spotu jako home-01 (sell_margin_fixed -0,02 Kč/kWh). '
|
||||
'Distribuce v efektivní ceně 0 (tariff_id NULL).',
|
||||
NULL,
|
||||
NULL,
|
||||
0,
|
||||
0,
|
||||
5.25,
|
||||
0
|
||||
);
|
||||
END IF;
|
||||
|
||||
INSERT INTO ems.site_operating_mode (site_id, mode_code, activated_by, notes)
|
||||
VALUES (
|
||||
v_site_id,
|
||||
'MANUAL',
|
||||
'migration:V045_seed_site_kv1',
|
||||
'Start MANUAL; střídač PASSIVE. Po ověření přepnout na AUTO a Deye dle plánu.'
|
||||
)
|
||||
ON CONFLICT (site_id) DO NOTHING;
|
||||
|
||||
SELECT ai.id INTO v_inv_main
|
||||
FROM ems.asset_inverter ai
|
||||
WHERE ai.site_id = v_site_id AND ai.code = 'deye-main'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_inv_main IS NULL THEN
|
||||
INSERT INTO ems.asset_inverter (
|
||||
site_id, code, manufacturer, model, endpoint_id,
|
||||
max_charge_power_w, max_discharge_power_w, max_export_power_w,
|
||||
max_ac_output_w, max_dc_input_w, max_battery_charge_w, max_battery_discharge_w,
|
||||
gen_port_max_power_w,
|
||||
controllable, active, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id,
|
||||
'deye-main',
|
||||
'Deye',
|
||||
NULL,
|
||||
v_ep_deye,
|
||||
6250, 6250, 8000,
|
||||
12000, 15000, 6250, 6250,
|
||||
NULL,
|
||||
true, true,
|
||||
'12kW LV hybrid. BMS max proud z/do baterie 280 A; plánování dle 0,5C ≈ 6,25 kW. '
|
||||
'Export do DS max 8 kW dle site_grid_connection.'
|
||||
)
|
||||
RETURNING id INTO v_inv_main;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.asset_battery ab
|
||||
WHERE ab.site_id = v_site_id AND ab.code = 'bat-main'
|
||||
) THEN
|
||||
INSERT INTO ems.asset_battery (
|
||||
site_id, inverter_id, code,
|
||||
usable_capacity_wh, min_soc_percent, reserve_soc_percent, max_soc_percent,
|
||||
charge_efficiency, discharge_efficiency, degradation_cost_czk_kwh,
|
||||
max_charge_c_rate, max_discharge_c_rate, bms_max_charge_w, bms_max_discharge_w
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, v_inv_main, 'bat-main',
|
||||
12500,
|
||||
10, 15, 95,
|
||||
0.95, 0.95,
|
||||
0.50,
|
||||
0.5, 0.5,
|
||||
6250, 6250
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- String 1: 9×460 Wp, sklon 50°, azimut 150° (řiditelné)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.asset_pv_array ap
|
||||
WHERE ap.site_id = v_site_id AND ap.code = 'pv-str-1'
|
||||
) THEN
|
||||
INSERT INTO ems.asset_pv_array (
|
||||
site_id, inverter_id, code, name,
|
||||
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
|
||||
controllable, telemetry_source, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, v_inv_main, 'pv-str-1', 'String 1 – 9×460 Wp',
|
||||
4140, 150, 50, 9, 1.0, true, 'pv_strings',
|
||||
'Hlavní telemetrie stringů Deye; druhý string má telemetry_source NULL.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- String 2: 7×620 Wp, sklon 50°, azimut 241° (řiditelné)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ems.asset_pv_array ap
|
||||
WHERE ap.site_id = v_site_id AND ap.code = 'pv-str-2'
|
||||
) THEN
|
||||
INSERT INTO ems.asset_pv_array (
|
||||
site_id, inverter_id, code, name,
|
||||
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
|
||||
controllable, telemetry_source, notes
|
||||
)
|
||||
VALUES (
|
||||
v_site_id, v_inv_main, 'pv-str-2', 'String 2 – 7×620 Wp',
|
||||
4340, 241, 50, 7, 1.0, true, NULL,
|
||||
'Vlastní predikce orientace; telemetrie sdílená se stringem 1.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
END;
|
||||
$$;
|
||||
40
db/migration/V046__battery_slot_selection_and_registers.sql
Normal file
40
db/migration/V046__battery_slot_selection_and_registers.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- V046: Battery slot selection buffers + Deye zero-export mode + solar sell register
|
||||
--
|
||||
-- Solver: slot pre-selection eliminates battery micro-cycling.
|
||||
-- Registers: reg 142 (zero export mode) per-inverter, reg 145 (solar sell) newly managed.
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Slot selection buffers on asset_battery
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE ems.asset_battery
|
||||
ADD COLUMN IF NOT EXISTS charge_slot_buffer NUMERIC(3,1) DEFAULT 1.3,
|
||||
ADD COLUMN IF NOT EXISTS discharge_slot_buffer NUMERIC(3,1) DEFAULT 1.5;
|
||||
|
||||
COMMENT ON COLUMN ems.asset_battery.charge_slot_buffer IS
|
||||
'Buffer multiplier for charge slot count over minimum to fill battery (1.0 = exact, 1.3 = 30 % extra). NULL = no slot selection.';
|
||||
COMMENT ON COLUMN ems.asset_battery.discharge_slot_buffer IS
|
||||
'Buffer multiplier for discharge-export slot count over minimum to empty battery (1.0 = exact, 1.5 = 50 % extra). NULL = no slot selection.';
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Deye zero-export mode on asset_inverter
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE ems.asset_inverter
|
||||
ADD COLUMN IF NOT EXISTS deye_zero_export_mode SMALLINT DEFAULT 1;
|
||||
|
||||
COMMENT ON COLUMN ems.asset_inverter.deye_zero_export_mode IS
|
||||
'Deye reg 142 value for non-SELL modes: 1 = zero export to load (no CT), 2 = zero export to CT. Depends on physical installation.';
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Per-site seed values
|
||||
-- ============================================================
|
||||
|
||||
-- BA81 (site_id=3, inverter_id=5): CT installed, bump degradation cost
|
||||
UPDATE ems.asset_inverter SET deye_zero_export_mode = 2 WHERE id = 5;
|
||||
UPDATE ems.asset_battery SET degradation_cost_czk_kwh = 1.00 WHERE site_id = 3;
|
||||
|
||||
-- KV1 (site_id=4, inverter_id=7): CT installed
|
||||
UPDATE ems.asset_inverter SET deye_zero_export_mode = 2 WHERE id = 7;
|
||||
|
||||
-- home-01 (site_id=2, inverter_id=3): no CT — default 1 is correct
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Dříve upravené COMMENT v rámci V044; po pravidle Flyway jen nová migrace (checksum V044 nesmí měnit).
|
||||
COMMENT ON COLUMN ems.asset_inverter.deye_register_max_charge_a IS
|
||||
'Optional A for reg 108; EMS uses COALESCE(this, FLOOR(LEAST(W)/51.2)) in _load_inverter_config.';
|
||||
COMMENT ON COLUMN ems.asset_inverter.deye_register_max_discharge_a IS
|
||||
'Optional A for reg 109; EMS uses COALESCE(this, FLOOR(LEAST(W)/51.2)) in _load_inverter_config.';
|
||||
11
db/migration/V049__planning_config.sql
Normal file
11
db/migration/V049__planning_config.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- volitelné plánovací konstanty per site (horizont, decay, …) – čte fn_planning_site_context
|
||||
|
||||
create table if not exists ems.planning_config (
|
||||
site_id int not null references ems.site (id) on delete cascade,
|
||||
config jsonb not null default '{}'::jsonb,
|
||||
updated_at timestamptz not null default now(),
|
||||
primary key (site_id)
|
||||
);
|
||||
|
||||
comment on table ems.planning_config is
|
||||
'JSON konfigurace pro budoucí přesun konstant z planning_engine.py (slot weights, correction decay, …).';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Po přejmenování repeatable skriptů na R__040_vw_* / R__041_fn_* (pořadí závislostí
|
||||
-- při řazení dle description) odstraníme záznamy pro staré názvy souborů, jinak
|
||||
-- Flyway validate hlásí chybějící migrační skript.
|
||||
|
||||
DELETE FROM ems.flyway_schema_history
|
||||
WHERE type = 'SQL'
|
||||
AND version IS NULL
|
||||
AND (
|
||||
script IN (
|
||||
'R__vw_modbus_last_verified.sql',
|
||||
'R__fn_modbus_last_verified_map.sql'
|
||||
)
|
||||
OR script LIKE '%/R__vw_modbus_last_verified.sql'
|
||||
OR script LIKE '%/R__fn_modbus_last_verified_map.sql'
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Po přejmenování všech repeatable na R__NNN_* (globální pořadí dle závislostí fn/vw)
|
||||
-- odstraníme záznamy repeatable z flyway historie. Při dalším migrate se znovu aplikují
|
||||
-- všechny R__ skripty (CREATE OR REPLACE / GRANT je idempotentní).
|
||||
|
||||
DELETE FROM ems.flyway_schema_history
|
||||
WHERE type = 'SQL'
|
||||
AND version IS NULL;
|
||||
18
db/migration/V052__plan_fatal_deviation_sent.sql
Normal file
18
db/migration/V052__plan_fatal_deviation_sent.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Jednorázové potvrzení odeslání fatálního Discord alertu plán vs. skutečnost (deduplikace po slotu).
|
||||
|
||||
create table ems.plan_fatal_deviation_sent (
|
||||
site_id int not null references ems.site (id),
|
||||
interval_start timestamptz not null,
|
||||
reason_code text not null,
|
||||
sent_at timestamptz not null default now(),
|
||||
primary key (site_id, interval_start)
|
||||
);
|
||||
|
||||
create index idx_plan_fatal_deviation_sent_sent_at
|
||||
on ems.plan_fatal_deviation_sent (sent_at desc);
|
||||
|
||||
comment on table ems.plan_fatal_deviation_sent is
|
||||
'Backend job po uzavření 15min slotu: při fatální odchylce grid plán vs. audit jednou pošle Discord a zapíše řádek (PK site_id + interval_start).';
|
||||
|
||||
comment on column ems.plan_fatal_deviation_sent.reason_code is
|
||||
'Kód z ems.fn_plan_actual_slot_guard_site (např. GRID_SIGN_MISMATCH, GRID_EXPORT_SPIKE).';
|
||||
10
db/migration/V053__planning_interval_deye_physical_mode.sql
Normal file
10
db/migration/V053__planning_interval_deye_physical_mode.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Explicitní fyzický režim Deye přímo v plánu (Variant A):
|
||||
-- PASSIVE / SELL / CHARGE. Exporter pak nemusí heuristicky mapovat z wattů.
|
||||
|
||||
ALTER TABLE ems.planning_interval
|
||||
ADD COLUMN IF NOT EXISTS deye_physical_mode TEXT;
|
||||
|
||||
COMMENT ON COLUMN ems.planning_interval.deye_physical_mode IS
|
||||
'Explicitní fyzický režim Deye pro tento slot (PASSIVE / SELL / CHARGE).
|
||||
Zdroj: planning_engine.solve_dispatch() (záměr slotu), použití: control exporter (get_deye_mode).';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Feature flag: řízení microinverter export cutoff přes Deye Modbus (GEN / AC coupling).
|
||||
-- Použito pro instalace typu BA81, kde při BLOCK_EXPORT (sell_price < 0) musíme odpojit / zakázat export z MI na GEN portu.
|
||||
|
||||
alter table ems.asset_inverter
|
||||
add column if not exists deye_gen_microinverter_cutoff_enabled boolean not null default false;
|
||||
|
||||
comment on column ems.asset_inverter.deye_gen_microinverter_cutoff_enabled is
|
||||
'Pokud true, EMS při BLOCK_EXPORT přepíná Deye reg 179 (Control board special 1) bits0–1 pro MI export cutoff na GEN portu.';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- BA81: při BLOCK_EXPORT (sell_price < 0) je potřeba aktivovat „MI export to Grid cutoff“.
|
||||
-- EMS to řeší přes Deye reg 179 bits 0–1 (masked RMW) pouze když je tento feature flag zapnutý.
|
||||
|
||||
update ems.asset_inverter ai
|
||||
set deye_gen_microinverter_cutoff_enabled = true
|
||||
from ems.site s
|
||||
where s.id = ai.site_id
|
||||
and s.code = 'BA81'
|
||||
and ai.code = 'deye-main';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Explicitní flag pro řízení odpojení GEN portu (mikroinvertory / AC coupling) v daném slotu.
|
||||
-- Použito hlavně u BA81: při záporné výkupní ceně a očekávaném přebytku nechceme exportovat, takže solver může zvolit cut-off.
|
||||
|
||||
alter table ems.planning_interval
|
||||
add column if not exists deye_gen_cutoff_enabled boolean;
|
||||
|
||||
comment on column ems.planning_interval.deye_gen_cutoff_enabled is
|
||||
'True = v daném slotu odpojit GEN port (MI export cutoff) přes Deye reg 179 bits0–1.
|
||||
NULL = lokalita / instalace GEN cut-off nepoužívá nebo flag není relevantní.';
|
||||
|
||||
41
db/migration/V057__site_pv_forecast_calibration.sql
Normal file
41
db/migration/V057__site_pv_forecast_calibration.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Kalibrace PV forecastu per site (cutoff učení, škrcení policy, volitelné přepsání parametrů delty).
|
||||
-- forecast_accuracy: flagy pro učení (vyloučení škrcených slotů apod.).
|
||||
|
||||
CREATE TABLE ems.site_pv_forecast_calibration (
|
||||
site_id int NOT NULL PRIMARY KEY REFERENCES ems.site (id) ON DELETE CASCADE,
|
||||
-- Od tohoto okamžiku (UTC) brát řádky do učení delty / vážených statistik (>=).
|
||||
delta_learn_min_ts timestamptz NOT NULL,
|
||||
-- Od kdy platí agresivní export/škrcení policy (NULL = neaplikovat časový filtr u heuristiky škrcení).
|
||||
pv_curtailment_policy_effective_from timestamptz NULL,
|
||||
top_n_days int NULL,
|
||||
non_top_day_factor numeric NULL,
|
||||
day_weight_gamma numeric NULL,
|
||||
half_life_days numeric NULL,
|
||||
threshold_w int NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ems.site_pv_forecast_calibration IS
|
||||
'Per-site kalibrace PV delta profilu a pravidla učení. NULL v numerických sloupích = použít default z ems.fn_pv_forecast_delta_profile.';
|
||||
|
||||
COMMENT ON COLUMN ems.site_pv_forecast_calibration.delta_learn_min_ts IS
|
||||
'Dolní mez interval_start pro učení delty z forecast_accuracy (UTC).';
|
||||
|
||||
COMMENT ON COLUMN ems.site_pv_forecast_calibration.pv_curtailment_policy_effective_from IS
|
||||
'Od tohoto času bereme heuristiku škrcení (planning_interval): sloty po tomto datu s curtailment/cut-off se mohou vyloučit z učení.';
|
||||
|
||||
ALTER TABLE ems.forecast_accuracy
|
||||
ADD COLUMN IF NOT EXISTS learning_eligible boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS learning_exclude_reason text NULL;
|
||||
|
||||
COMMENT ON COLUMN ems.forecast_accuracy.learning_eligible IS
|
||||
'false = řádek se nepoužívá pro učení delty (škrcení, před cutoffem, …); actual_power_w může být NULL pro audit.';
|
||||
|
||||
COMMENT ON COLUMN ems.forecast_accuracy.learning_exclude_reason IS
|
||||
'Důvod vyloučení z učení, např. curtailment_or_gen_cutoff, before_delta_learn_min.';
|
||||
|
||||
-- Seed: všechny existující lokality — stejný cutoff jako dosud v R__078 (začátek 2026-04-12 Europe/Prague).
|
||||
INSERT INTO ems.site_pv_forecast_calibration (site_id, delta_learn_min_ts, top_n_days)
|
||||
SELECT s.id, timestamptz '2026-04-11T22:00:00Z', 3
|
||||
FROM ems.site s
|
||||
ON CONFLICT (site_id) DO NOTHING;
|
||||
12
db/migration/V058__telemetry_inverter_derating_flags.sql
Normal file
12
db/migration/V058__telemetry_inverter_derating_flags.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Volitelné flagy pro vyloučení „škrcených“ slotů z učení PV delty (fáze 2 plánu kalibrace).
|
||||
-- Plní collector podle režimu / registrů (145/179 apod.); dokud NULL, R__022 je ignoruje.
|
||||
|
||||
ALTER TABLE ems.telemetry_inverter
|
||||
ADD COLUMN IF NOT EXISTS is_export_limited boolean NULL,
|
||||
ADD COLUMN IF NOT EXISTS pv_derating_flags int NULL;
|
||||
|
||||
COMMENT ON COLUMN ems.telemetry_inverter.is_export_limited IS
|
||||
'TRUE = interval indikuje omezení exportu / odpojení GEN (např. cut-off mikroinvertorů); fn_fill_forecast_accuracy může vyloučit slot z učení.';
|
||||
|
||||
COMMENT ON COLUMN ems.telemetry_inverter.pv_derating_flags IS
|
||||
'Bitová maska nebo enum z režimu střídače (derating); <> 0 může vést k vyloučení slotu z učení delty.';
|
||||
20
db/migration/V059__planner_soc_extremes.sql
Normal file
20
db/migration/V059__planner_soc_extremes.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Plánovač: vyšší strop SoC než provozní max, relaxované dno při extrémně záporném buy, práh z OTE horizontu.
|
||||
|
||||
ALTER TABLE ems.asset_battery
|
||||
ADD COLUMN IF NOT EXISTS planner_max_soc_percent NUMERIC(5, 2),
|
||||
ADD COLUMN IF NOT EXISTS planner_discharge_floor_percent NUMERIC(5, 2),
|
||||
ADD COLUMN IF NOT EXISTS planner_extreme_buy_threshold_czk_kwh NUMERIC(10, 4) DEFAULT -5.0;
|
||||
|
||||
COMMENT ON COLUMN ems.asset_battery.planner_max_soc_percent IS
|
||||
'Horní mez SoC (%) pro LP; NULL = použij max_soc_percent. Typicky 100 pro plné využití kapacity při silně záporném nákupu.';
|
||||
|
||||
COMMENT ON COLUMN ems.asset_battery.planner_discharge_floor_percent IS
|
||||
'Dolní mez SoC (%) pro LP při aktivaci extrémně záporného nákupu v lookahead; NULL = použij min_soc_percent.';
|
||||
|
||||
COMMENT ON COLUMN ems.asset_battery.planner_extreme_buy_threshold_czk_kwh IS
|
||||
'Prah effective buy (Kč/kWh): pokud min buy v lookahead <= prah, LP smí snížit SoC k planner_discharge_floor_percent.';
|
||||
|
||||
-- home-01: plánovat až na 100 % (provozní max_soc může zůstat 95 %)
|
||||
UPDATE ems.asset_battery
|
||||
SET planner_max_soc_percent = 100
|
||||
WHERE site_id = 2 AND planner_max_soc_percent IS NULL;
|
||||
12
db/migration/V060__planner_discharge_relax_prewindow.sql
Normal file
12
db/migration/V060__planner_discharge_relax_prewindow.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Plánovač: zpoždění hluboké relaxace SoC až do okna před prvním extrémně záporným nákupem (15min sloty).
|
||||
|
||||
ALTER TABLE ems.asset_battery
|
||||
ADD COLUMN IF NOT EXISTS planner_discharge_relax_prewindow_slots integer;
|
||||
|
||||
COMMENT ON COLUMN ems.asset_battery.planner_discharge_relax_prewindow_slots IS
|
||||
'Počet 15min slotů před prvním effective_sell < 0 (nebo před extrémním buy, pokud sell nikde není záporný); '
|
||||
'viz také V061. NULL = 8.';
|
||||
|
||||
UPDATE ems.asset_battery
|
||||
SET planner_discharge_relax_prewindow_slots = 8
|
||||
WHERE planner_discharge_relax_prewindow_slots IS NULL;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Upřesnění významu: prewindow je vůči prvnímu zápornému prodeji (sell), ne k extrémnímu nákupu.
|
||||
|
||||
COMMENT ON COLUMN ems.asset_battery.planner_discharge_relax_prewindow_slots IS
|
||||
'Počet 15min slotů před prvním effective_sell < 0 v horizontu, od kdy platí hluboký planner floor; '
|
||||
'dříve drží LP spodek na rezervě (arb). Pokud v horizontu není záporný prodej, použije se vzdálenost '
|
||||
'k prvnímu buy <= planner_extreme_buy_threshold. NULL = 8.';
|
||||
9
db/migration/V062__planner_terminal_soc_value_factor.sql
Normal file
9
db/migration/V062__planner_terminal_soc_value_factor.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_terminal_soc_value_factor numeric not null default 0.9;
|
||||
|
||||
comment on column ems.asset_battery.planner_terminal_soc_value_factor is
|
||||
'Váha terminal SoC shadow price v LP solveru.
|
||||
0 = solver nemá motivaci držet energii v baterii na konci horizontu (agresivnější arbitráž / vybití).
|
||||
1 = odpovídá ~průměrné nákupní ceně (konzervativní držení energie).
|
||||
Používá se v backend/services/planning_engine.py (terminal_soc_kcz_per_wh).';
|
||||
|
||||
10
db/migration/V063__site_discord_webhooks.sql
Normal file
10
db/migration/V063__site_discord_webhooks.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
alter table ems.site
|
||||
add column if not exists discord_webhook_daily_url text,
|
||||
add column if not exists discord_webhook_error_url text;
|
||||
|
||||
comment on column ems.site.discord_webhook_daily_url is
|
||||
'Discord webhook pro běžné denní zprávy (např. ranní ekonomický report). Per-site konfigurace.';
|
||||
|
||||
comment on column ems.site.discord_webhook_error_url is
|
||||
'Discord webhook pro error/critical alerty (mismatch, fatal plan vs actual, clock verify exhausted, apod.). Per-site konfigurace.';
|
||||
|
||||
122
db/migration/V064__signal_outbound.sql
Normal file
122
db/migration/V064__signal_outbound.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
-- Signály EMS → externí cíle (Loxone VI, HTTP REST), journal + idempotence + verify readback.
|
||||
-- Kritické řízení výkonu (Deye, EV, TČ) zůstává v modbus_command / exporteru.
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Definice signálů (globální katalog kódů)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE ems.signal_def (
|
||||
code TEXT PRIMARY KEY,
|
||||
value_type TEXT NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ems.signal_def IS
|
||||
'Katalog signálů EMS (logické výstupy). Hodnotu pro route počítá backend dle doménové logiky.';
|
||||
|
||||
COMMENT ON COLUMN ems.signal_def.code IS
|
||||
'Unikátní kód signálu, např. EXPORT_BAN_ACTIVE.';
|
||||
|
||||
COMMENT ON COLUMN ems.signal_def.value_type IS
|
||||
'bool | int | float | string — očekávaný typ hodnoty po transformaci na cíl.';
|
||||
|
||||
INSERT INTO ems.signal_def (code, value_type, description)
|
||||
VALUES (
|
||||
'EXPORT_BAN_ACTIVE',
|
||||
'bool',
|
||||
'Pravda pokud EMS aktuálně uplatňuje zákaz exportu do sítě (LED varianta B): override block_export, no_export, režimy bez exportu, AUTO se záporným výkupem při ne-negativním grid setpointu.'
|
||||
)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Směrování signál → cíl (per site)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE ems.signal_route (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site_id INT NOT NULL REFERENCES ems.site (id),
|
||||
destination_type TEXT NOT NULL,
|
||||
endpoint_id INT NOT NULL REFERENCES ems.site_endpoint (id),
|
||||
signal_code TEXT NOT NULL REFERENCES ems.signal_def (code),
|
||||
destination_key TEXT NOT NULL,
|
||||
route_config_json JSONB,
|
||||
transform_json JSONB,
|
||||
verify_readback BOOLEAN NOT NULL DEFAULT true,
|
||||
verify_config_json JSONB,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_signal_route_unique UNIQUE (site_id, destination_type, signal_code, destination_key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_signal_route_site_enabled
|
||||
ON ems.signal_route (site_id, enabled)
|
||||
WHERE enabled = true;
|
||||
|
||||
COMMENT ON TABLE ems.signal_route IS
|
||||
'Mapování signálu na cíl (Loxone Virtual Input, HTTP REST atd.). endpoint_id ukazuje na ems.site_endpoint (loxone_http, budoucí shelly_http, …).';
|
||||
|
||||
COMMENT ON COLUMN ems.signal_route.destination_type IS
|
||||
'loxone_vi = GET /dev/sps/io/{destination_key}/{value}; http_rest = šablona v route_config_json.';
|
||||
|
||||
COMMENT ON COLUMN ems.signal_route.destination_key IS
|
||||
'U Loxone název Virtual Inputu. U HTTP REST stabilní klíč pro log (např. relay0).';
|
||||
|
||||
COMMENT ON COLUMN ems.signal_route.route_config_json IS
|
||||
'Volitelná konfigurace pro http_rest (path_template, method, …). U loxone_vi typicky NULL.';
|
||||
|
||||
COMMENT ON COLUMN ems.signal_route.verify_config_json IS
|
||||
'Readback: u Loxone např. {"loxone_io_name":"EMS_ExportBan_Active_FB"} pro GET /dev/sps/io/{name}. U HTTP JSON path atd.';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Odchozí journal
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE ems.signal_outbound_journal (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
route_id INT NOT NULL REFERENCES ems.signal_route (id),
|
||||
site_id INT NOT NULL REFERENCES ems.site (id),
|
||||
signal_code TEXT NOT NULL,
|
||||
value_text TEXT NOT NULL,
|
||||
value_num NUMERIC,
|
||||
status TEXT NOT NULL,
|
||||
attempt_count INT NOT NULL DEFAULT 0,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_error TEXT,
|
||||
http_method TEXT,
|
||||
request_url TEXT,
|
||||
http_status INT,
|
||||
latency_ms INT,
|
||||
response_body_trunc TEXT,
|
||||
sent_at TIMESTAMPTZ,
|
||||
verified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT chk_signal_outbound_status CHECK (
|
||||
status IN ('queued', 'sent', 'verified', 'failed', 'abandoned')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_signal_outbound_worker
|
||||
ON ems.signal_outbound_journal (status, next_attempt_at);
|
||||
|
||||
CREATE INDEX idx_signal_outbound_site_debug
|
||||
ON ems.signal_outbound_journal (site_id, signal_code, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE ems.signal_outbound_journal IS
|
||||
'Journal odchozích signálů (HTTP). Worker odesílá queued, po úspěchu sent, po readback verified nebo failed s retry.';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Poslední známý stav (idempotence)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE ems.signal_state (
|
||||
site_id INT NOT NULL REFERENCES ems.site (id),
|
||||
signal_code TEXT NOT NULL,
|
||||
destination_type TEXT NOT NULL,
|
||||
destination_key TEXT NOT NULL,
|
||||
last_desired_value_text TEXT,
|
||||
last_sent_value_text TEXT,
|
||||
last_verified_value_text TEXT,
|
||||
last_sent_at TIMESTAMPTZ,
|
||||
last_verified_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (site_id, signal_code, destination_type, destination_key)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ems.signal_state IS
|
||||
'Poslední požadovaná / odeslaná / ověřená hodnota signálu per cíl — idempotence a diagnostika verify.';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- ============================================================
|
||||
-- Forecast PV: urychlení denních/range dotazů podle interval_start
|
||||
-- (fn_forecast_pv_split, pv-slots* funkce)
|
||||
-- ============================================================
|
||||
|
||||
create index if not exists idx_forecast_pv_interval_start_run
|
||||
on ems.forecast_pv_interval (interval_start, run_id);
|
||||
|
||||
18
db/migration/V066__latest_telemetry_distinct_on_indexes.sql
Normal file
18
db/migration/V066__latest_telemetry_distinct_on_indexes.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- =============================================================
|
||||
-- V066__latest_telemetry_distinct_on_indexes.sql
|
||||
-- Zrychlení view ems.vw_latest_* (PostgREST dashboard endpoints).
|
||||
--
|
||||
-- View používají DISTINCT ON (...) s ORDER BY ... measured_at desc.
|
||||
-- Bez odpovídajících indexů může plán spadnout na scan+sort nad
|
||||
-- velkými Timescale hypertabulkami (sekundy latency).
|
||||
-- =============================================================
|
||||
|
||||
create index if not exists idx_telemetry_inverter_site_inverter_time_desc
|
||||
on ems.telemetry_inverter (site_id, inverter_id, measured_at desc);
|
||||
|
||||
create index if not exists idx_telemetry_ev_site_charger_connector_time_desc
|
||||
on ems.telemetry_ev_charger (site_id, charger_id, connector_id, measured_at desc);
|
||||
|
||||
create index if not exists idx_telemetry_hp_site_heat_pump_time_desc
|
||||
on ems.telemetry_heat_pump (site_id, heat_pump_id, measured_at desc);
|
||||
|
||||
8
db/migration/V067__asset_heat_pump_site_index.sql
Normal file
8
db/migration/V067__asset_heat_pump_site_index.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- =============================================================
|
||||
-- V067__asset_heat_pump_site_index.sql
|
||||
-- Zrychlení filtrování asset_heat_pump podle site_id (PostgREST).
|
||||
-- =============================================================
|
||||
|
||||
create index if not exists idx_asset_heat_pump_site
|
||||
on ems.asset_heat_pump (site_id);
|
||||
|
||||
8
db/migration/V068__site_market_config_validity_index.sql
Normal file
8
db/migration/V068__site_market_config_validity_index.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- ============================================================
|
||||
-- Site market config: urychlení lookupu platné konfigurace
|
||||
-- (vw_site_effective_price, fn_effective_*_price)
|
||||
-- ============================================================
|
||||
|
||||
create index if not exists idx_site_market_config_site_valid_from
|
||||
on ems.site_market_config (site_id, valid_from desc);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- planner_terminal_soc_value_factor: LP terminal SoC shadow price (planning_engine).
|
||||
-- V062 přidal sloupec NOT NULL default 0.9; tato migrace je idempotentní upevnění pro starší / ručně upravené DB.
|
||||
|
||||
update ems.asset_battery
|
||||
set planner_terminal_soc_value_factor = 0.9
|
||||
where planner_terminal_soc_value_factor is null;
|
||||
|
||||
alter table ems.asset_battery
|
||||
alter column planner_terminal_soc_value_factor set default 0.9;
|
||||
|
||||
alter table ems.asset_battery
|
||||
alter column planner_terminal_soc_value_factor set not null;
|
||||
16
db/migration/V070__forecast_accuracy_delta_profile_index.sql
Normal file
16
db/migration/V070__forecast_accuracy_delta_profile_index.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Zrychlení fn_pv_forecast_delta_profile (volá ho pv-slots-corrected): range scan site + interval_start
|
||||
-- s podmínkami učení bez sekvenčního full scanu větší historie.
|
||||
|
||||
create index if not exists idx_forecast_accuracy_site_interval_delta_profile
|
||||
on ems.forecast_accuracy (
|
||||
site_id,
|
||||
interval_start desc,
|
||||
pv_array_id,
|
||||
forecast_created_at desc
|
||||
)
|
||||
where actual_power_w is not null
|
||||
and coalesce(learning_eligible, true) = true
|
||||
and forecast_created_at <= interval_start;
|
||||
|
||||
comment on index ems.idx_forecast_accuracy_site_interval_delta_profile is
|
||||
'Partial index pro výběr posledního forecast runu na slot (DISTINCT ON interval_start, pv_array_id) v delta profilu.';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Plán „nejnovější run na slot“ často sahá po forecast_pv_interval přes (run_id, interval).
|
||||
-- Druhý pořádek (pole → čas) pomáhá alternativním plánům při filtru pv_array_id + časové okno.
|
||||
|
||||
create index if not exists idx_forecast_pv_interval_pv_array_interval_start
|
||||
on ems.forecast_pv_interval (pv_array_id, interval_start desc);
|
||||
|
||||
comment on index ems.idx_forecast_pv_interval_pv_array_interval_start is
|
||||
'Podpora dotazů s filtrem na pv_array_id a rozsah interval_start (pv-slots, DISTINCT ON).';
|
||||
52
db/migration/V072__pv_array_telemetry_group_and_sources.sql
Normal file
52
db/migration/V072__pv_array_telemetry_group_and_sources.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- =============================================================
|
||||
-- V072 – asset_pv_array.telemetry_group + rozšíření telemetry_source
|
||||
--
|
||||
-- Cíl:
|
||||
-- - umožnit mapování PV pole → měřicí kanál (pv1/pv2/pv_strings/pv_total/gen_port),
|
||||
-- - umožnit sdílené měření pro více polí (telemetry_group) a následnou alokaci (v routines).
|
||||
-- =============================================================
|
||||
|
||||
alter table ems.asset_pv_array
|
||||
add column if not exists telemetry_group text;
|
||||
|
||||
comment on column ems.asset_pv_array.telemetry_source is
|
||||
'Který sloupec v telemetry_inverter odpovídá tomuto poli.
|
||||
gen_port = gen_port_power_w (AC-coupled pole na GEN portu),
|
||||
pv1 = pv1_power_w (DC string 1 / MPPT1),
|
||||
pv2 = pv2_power_w (DC string 2 / MPPT2),
|
||||
pv_strings = pv1_power_w + pv2_power_w (souhrn DC stringů, pokud nejde rozlišit),
|
||||
pv_total = pv_power_w (souhrnné PV, pokud nejde rozlišit).
|
||||
NULL = pole nemá přímou telemetrii (fallback na forecast).';
|
||||
|
||||
comment on column ems.asset_pv_array.telemetry_group is
|
||||
'Volitelná skupina pro sdílené měření: pokud více pv_array sdílí jeden telemetrický kanál (např. GEN port rozdělený do více orientací),
|
||||
pak mají shodné (site_id, telemetry_source, telemetry_group) a routines alokují actual proporčně podle forecastu.';
|
||||
|
||||
-- --- Seed / upgrade stávajících referenčních lokalit ---
|
||||
|
||||
-- home-01: dvě GEN pole sdílí jeden GEN port → stejné telemetry_group
|
||||
update ems.asset_pv_array
|
||||
set telemetry_source = 'gen_port',
|
||||
telemetry_group = 'gen_port_1'
|
||||
where site_id = (select id from ems.site where code = 'home-01')
|
||||
and code in ('pv-b', 'pv-b-flat');
|
||||
|
||||
-- BA81: stringy mapujeme na PV1/PV2, mikroinvertory sdílí GEN port (alokace podle forecastu).
|
||||
update ems.asset_pv_array
|
||||
set telemetry_source = 'pv1',
|
||||
telemetry_group = null
|
||||
where site_id = (select id from ems.site where code = 'BA81')
|
||||
and code = 'pv-str-1';
|
||||
|
||||
update ems.asset_pv_array
|
||||
set telemetry_source = 'pv2',
|
||||
telemetry_group = null
|
||||
where site_id = (select id from ems.site where code = 'BA81')
|
||||
and code = 'pv-str-2';
|
||||
|
||||
update ems.asset_pv_array
|
||||
set telemetry_source = 'gen_port',
|
||||
telemetry_group = 'gen_port_1'
|
||||
where site_id = (select id from ems.site where code = 'BA81')
|
||||
and code in ('pv-mi-1', 'pv-mi-2');
|
||||
|
||||
56
db/migration/V073__pv_telemetry_source_def_fk.sql
Normal file
56
db/migration/V073__pv_telemetry_source_def_fk.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- =============================================================
|
||||
-- V073 – číselník PV telemetrie + FK na asset_pv_array.telemetry_source
|
||||
--
|
||||
-- Cíl: referenční integrita pro telemetry_source (povolené kódy),
|
||||
-- aby se zabránilo překlepům a nekonzistentním datům.
|
||||
-- =============================================================
|
||||
|
||||
create table if not exists ems.pv_telemetry_source_def (
|
||||
code text primary key,
|
||||
description text not null,
|
||||
telemetry_inverter_expr text null,
|
||||
active boolean not null default true
|
||||
);
|
||||
|
||||
comment on table ems.pv_telemetry_source_def is
|
||||
'Číselník zdrojů PV telemetrie (kanálů) pro asset_pv_array.telemetry_source.';
|
||||
|
||||
comment on column ems.pv_telemetry_source_def.code is
|
||||
'Stabilní kód zdroje telemetrie (FK z asset_pv_array.telemetry_source).';
|
||||
|
||||
comment on column ems.pv_telemetry_source_def.telemetry_inverter_expr is
|
||||
'Volitelně: lidsky čitelný výraz, jak se kanál počítá z telemetry_inverter (informativní; runtime logika je v routines).';
|
||||
|
||||
insert into ems.pv_telemetry_source_def (code, description, telemetry_inverter_expr) values
|
||||
('gen_port', 'AC-coupled výroba na GEN portu (souhrn).', 'gen_port_power_w'),
|
||||
('pv1', 'DC string/MPPT 1 (samostatně).', 'pv1_power_w'),
|
||||
('pv2', 'DC string/MPPT 2 (samostatně).', 'pv2_power_w'),
|
||||
('pv_strings', 'Součet DC stringů (pv1+pv2).', 'pv1_power_w + pv2_power_w'),
|
||||
('pv_total', 'Souhrnná PV výroba (pokud nelze rozlišit).','pv_power_w')
|
||||
on conflict (code) do update
|
||||
set description = excluded.description,
|
||||
telemetry_inverter_expr = excluded.telemetry_inverter_expr,
|
||||
active = true;
|
||||
|
||||
-- FK (idempotentně): NULL povolen (pole bez přímé telemetrie / fallback na forecast).
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_constraint c
|
||||
join pg_class t on t.oid = c.conrelid
|
||||
join pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = 'ems'
|
||||
and t.relname = 'asset_pv_array'
|
||||
and c.conname = 'asset_pv_array_telemetry_source_fk'
|
||||
) then
|
||||
alter table ems.asset_pv_array
|
||||
add constraint asset_pv_array_telemetry_source_fk
|
||||
foreign key (telemetry_source)
|
||||
references ems.pv_telemetry_source_def(code)
|
||||
on update cascade
|
||||
on delete restrict;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
13
db/migration/V074__site_grid_block_export_negative_sell.sql
Normal file
13
db/migration/V074__site_grid_block_export_negative_sell.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Tvrdý zákaz grid exportu při záporné efektivní prodejní ceně v LP (odděleně od GEN cut-off přepínače na invertoru).
|
||||
|
||||
alter table ems.site_grid_connection
|
||||
add column if not exists block_export_on_negative_sell boolean not null default false;
|
||||
|
||||
comment on column ems.site_grid_connection.block_export_on_negative_sell is
|
||||
'LP (solve_dispatch): při effective sell < 0 vynutit ge[t]=0. Nezávislé na deye_gen_microinverter_cutoff_enabled. Zapínat jen u lokalit bez nutnosti vést přebytek neriťitelného PV pole B do sítě (jinak hrozí infeasible); př. KV1 vs home-01.';
|
||||
|
||||
update ems.site_grid_connection sgc
|
||||
set block_export_on_negative_sell = true
|
||||
from ems.site s
|
||||
where sgc.site_id = s.id
|
||||
and s.code = 'KV1';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- buy_margin_percent: spot režim používá asymetrický faktor (R__011 fn_effective_buy_price).
|
||||
comment on column ems.site_market_config.buy_margin_percent is
|
||||
'Procentní nákupní marže za režimu spot: při kladné buy_raw složka OTE ×(1+p/100); při záporné ×(1−p/100); buy_margin_fixed_czk se jen přičte. Za režimu FIXED stále fix + (uzavřená energická složka × p/100).';
|
||||
21
db/migration/V076__pv_forecast_reference_day.sql
Normal file
21
db/migration/V076__pv_forecast_reference_day.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Kalendářní dny lokality označené jako referenční pro učení delty PV forecastu (dobrá obloha).
|
||||
|
||||
create table ems.site_pv_forecast_reference_day (
|
||||
site_id int not null references ems.site (id) on delete cascade,
|
||||
day_local date not null,
|
||||
notes text null,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (site_id, day_local)
|
||||
);
|
||||
|
||||
comment on table ems.site_pv_forecast_reference_day is
|
||||
'Dny v kalendáři lokality podle jejího site.timezone (typicky datum ve zdi Europe/Prague), kterým se v ems.fn_pv_forecast_delta_profile zvýší váha řádků forecast_accuracy při počítání delta profilu.';
|
||||
|
||||
comment on column ems.site_pv_forecast_reference_day.day_local is
|
||||
'Kalendářní datum v časové zóně lokality; porovnává se na (interval_start AT TIME ZONE site.timezone)::date ze slotů.';
|
||||
|
||||
alter table ems.site_pv_forecast_calibration
|
||||
add column if not exists reference_day_weight_mult numeric null;
|
||||
|
||||
comment on column ems.site_pv_forecast_calibration.reference_day_weight_mult is
|
||||
'Násobitel váhy učícího vzorku pro všechny sloty jejichž den spadá do site_pv_forecast_reference_day; NULL použije default v fn_pv_forecast_delta_profile (aktuálně 3).';
|
||||
24
db/routines/R__002_fn_modbus_last_verified_map.sql
Normal file
24
db/routines/R__002_fn_modbus_last_verified_map.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- map register -> value_verified z modbus_command (poslední verified řádek per register)
|
||||
|
||||
create or replace function ems.fn_modbus_last_verified_map(
|
||||
p_site_id int,
|
||||
p_asset_id int
|
||||
)
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
select coalesce(
|
||||
jsonb_object_agg(register::text, to_jsonb(value_verified)),
|
||||
'{}'::jsonb
|
||||
)
|
||||
from (
|
||||
select
|
||||
v.register,
|
||||
v.value_verified
|
||||
from ems.vw_modbus_last_verified v
|
||||
where v.site_id = p_site_id
|
||||
and v.asset_type = 'inverter'
|
||||
and v.asset_id = p_asset_id
|
||||
) t;
|
||||
$fn$;
|
||||
@@ -78,7 +78,8 @@ $$;
|
||||
COMMENT ON FUNCTION ems.fn_update_baseline_stats(INT, INT) IS
|
||||
'Aktualizuje průměry bazální spotřeby z telemetrie posledních N dní.
|
||||
Používá exponenciální klouzavý průměr (EMA 70/30) pro postupné zpřesňování.
|
||||
Volat denně po půlnoci. Pro první naplnění: fn_update_baseline_stats(2, 90).';
|
||||
Volat denně po půlnoci. Pro první naplnění: fn_update_baseline_stats(2, 90).
|
||||
Pro úplný reset bucketů bez „ocasu“ EMA smaž řádky a znovu volej, nebo ems.fn_rebuild_consumption_baseline_stats.';
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION ems.fn_get_baseline_forecast(
|
||||
@@ -101,8 +102,11 @@ AS $$
|
||||
cbs.avg_power_w + 0.5 * COALESCE(cbs.stddev_power_w, 100),
|
||||
550
|
||||
)::INT AS confidence_w
|
||||
FROM generate_series(p_from, p_to - INTERVAL '15 minutes',
|
||||
INTERVAL '15 minutes') AS gs(slot)
|
||||
FROM generate_series(
|
||||
date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z'),
|
||||
date_bin(interval '15 minutes', p_to, timestamptz '1970-01-01T00:00:00Z') - interval '15 minutes',
|
||||
interval '15 minutes'
|
||||
) AS gs(slot)
|
||||
LEFT JOIN ems.consumption_baseline_stats cbs
|
||||
ON cbs.site_id = p_site_id
|
||||
AND cbs.day_of_week = EXTRACT(DOW FROM gs.slot AT TIME ZONE 'Europe/Prague')::INT
|
||||
51
db/routines/R__004_fn_battery_cycle_audit.sql
Normal file
51
db/routines/R__004_fn_battery_cycle_audit.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- audit „ekvivalent plných cyklů“ z 1min telemetrie battery_power_w (bez LP constraintu)
|
||||
|
||||
create or replace function ems.fn_battery_cycle_audit(
|
||||
p_site_id int,
|
||||
p_from timestamptz,
|
||||
p_to timestamptz
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
as $fn$
|
||||
declare
|
||||
v_usable numeric;
|
||||
v_throughput_wh numeric;
|
||||
v_full_cycles numeric;
|
||||
begin
|
||||
select coalesce(sum(ab.usable_capacity_wh), 0)::numeric
|
||||
into v_usable
|
||||
from ems.asset_battery ab
|
||||
where ab.site_id = p_site_id;
|
||||
|
||||
if v_usable is null or v_usable <= 0 then
|
||||
return jsonb_build_object('error', 'no_battery', 'full_cycles', 0);
|
||||
end if;
|
||||
|
||||
select coalesce(
|
||||
sum(abs(ti.battery_power_w::numeric) / 60.0),
|
||||
0
|
||||
)
|
||||
into v_throughput_wh
|
||||
from ems.telemetry_inverter ti
|
||||
where ti.site_id = p_site_id
|
||||
and ti.measured_at >= p_from
|
||||
and ti.measured_at < p_to
|
||||
and ti.battery_power_w is not null;
|
||||
|
||||
v_full_cycles := case
|
||||
when v_usable * 2 > 0 then v_throughput_wh / (v_usable * 2)
|
||||
else 0
|
||||
end;
|
||||
|
||||
return jsonb_build_object(
|
||||
'full_cycles', round(v_full_cycles::numeric, 4),
|
||||
'throughput_wh', round(v_throughput_wh, 2),
|
||||
'throughput_vs_usable_ratio', round((v_throughput_wh / nullif(v_usable, 0))::numeric, 4),
|
||||
'usable_capacity_wh', v_usable,
|
||||
'window_start', p_from,
|
||||
'window_end', p_to
|
||||
);
|
||||
end;
|
||||
$fn$;
|
||||
@@ -1,5 +1,5 @@
|
||||
-- =============================================================
|
||||
-- R__fn_cop_estimate.sql
|
||||
-- R__005_fn_cop_estimate.sql
|
||||
-- EMS Platform – odhad COP tepelného čerpadla dle venkovní teploty
|
||||
-- Repeatable migration
|
||||
-- =============================================================
|
||||
16
db/routines/R__006_fn_deye_clock_drift_sec.sql
Normal file
16
db/routines/R__006_fn_deye_clock_drift_sec.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
create or replace function ems.fn_deye_clock_drift_sec(
|
||||
p_device_ts timestamptz,
|
||||
p_reference_ts timestamptz
|
||||
)
|
||||
returns int
|
||||
language sql
|
||||
immutable
|
||||
as $fn$
|
||||
select case
|
||||
when p_device_ts is null or p_reference_ts is null then null::int
|
||||
else abs(extract(epoch from (p_device_ts - p_reference_ts)))::int
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_deye_clock_drift_sec(timestamptz, timestamptz) is
|
||||
'Absolutní odchylka hodin Deye vs referenční UTC (sekundy).';
|
||||
17
db/routines/R__007_fn_deye_pack_system_time.sql
Normal file
17
db/routines/R__007_fn_deye_pack_system_time.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- pack reg 62–64 (Europe/Prague wall time, seconds = 0) stejně jako _deye_system_time_register_rows
|
||||
|
||||
create or replace function ems.fn_deye_pack_system_time(p_ts timestamptz)
|
||||
returns int[]
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
with loc as (
|
||||
select (p_ts at time zone 'Europe/Prague') as t
|
||||
)
|
||||
select array[
|
||||
((extract(year from t)::int - 2000) << 8) | extract(month from t)::int,
|
||||
(extract(day from t)::int << 8) | extract(hour from t)::int,
|
||||
(extract(minute from t)::int << 8) | 0
|
||||
]
|
||||
from loc;
|
||||
$fn$;
|
||||
27
db/routines/R__008_fn_deye_time_point_regs.sql
Normal file
27
db/routines/R__008_fn_deye_time_point_regs.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- pole registrů pro jeden TOU time point (čistá logika čísel; zápis řeší control exporter)
|
||||
|
||||
create or replace function ems.fn_deye_time_point_regs(
|
||||
p_slot_index int,
|
||||
p_hhmm int,
|
||||
p_power_w int,
|
||||
p_soc_pct int,
|
||||
p_grid_charge_bit int
|
||||
)
|
||||
returns int[]
|
||||
language sql
|
||||
immutable
|
||||
as $fn$
|
||||
select array[
|
||||
148 + p_slot_index * 6,
|
||||
p_hhmm,
|
||||
154 + p_slot_index * 6,
|
||||
p_power_w,
|
||||
166 + p_slot_index * 6,
|
||||
p_soc_pct,
|
||||
172 + p_slot_index * 6,
|
||||
p_grid_charge_bit
|
||||
];
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_deye_time_point_regs(int, int, int, int, int) is
|
||||
'Adresy a hodnoty pro jeden Deye TOU blok (reg páry 148/154/166/172 + offset slotu).';
|
||||
21
db/routines/R__009_fn_deye_tou_inactive_signature.sql
Normal file
21
db/routines/R__009_fn_deye_tou_inactive_signature.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
create or replace function ems.fn_deye_tou_inactive_signature(
|
||||
p_hhmm_inactive int,
|
||||
p_min_soc_pct numeric,
|
||||
p_reserve_soc_pct numeric,
|
||||
p_tp_discharge_w int
|
||||
)
|
||||
returns text
|
||||
language sql
|
||||
immutable
|
||||
as $fn$
|
||||
select concat_ws(
|
||||
'|',
|
||||
p_hhmm_inactive::text,
|
||||
round(p_min_soc_pct, 2)::text,
|
||||
round(p_reserve_soc_pct, 2)::text,
|
||||
p_tp_discharge_w::text
|
||||
);
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_deye_tou_inactive_signature(int, numeric, numeric, int) is
|
||||
'Podpis neaktivních TOU slotů (shoda s asset_inverter.deye_tou_inactive_signature).';
|
||||
15
db/routines/R__010_fn_economics_unlock_day.sql
Normal file
15
db/routines/R__010_fn_economics_unlock_day.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
create or replace function ems.fn_economics_unlock_day(p_site_id int, p_day date)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
as $fn$
|
||||
begin
|
||||
delete from ems.audit_day_lock
|
||||
where site_id = p_site_id
|
||||
and day_local = p_day;
|
||||
|
||||
return jsonb_build_object('locked', false, 'day', p_day);
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_economics_unlock_day(int, date) is
|
||||
'Odebere zámek dne ekonomiky (DELETE lock).';
|
||||
@@ -1,8 +1,76 @@
|
||||
-- =============================================================
|
||||
-- R__fn_effective_price.sql
|
||||
-- R__011_fn_effective_price.sql
|
||||
-- EMS Platform – funkce pro výpočet efektivní ceny per site
|
||||
-- Repeatable migration – nasazuje se při každé změně
|
||||
-- =============================================================
|
||||
-- Pomocné (audit/ekonomika): sjednocení import/export Wh — musí běžet před R__012/R__056.
|
||||
create or replace function ems.fn_audit_grid_import_wh_for_economics(
|
||||
p_import_wh numeric,
|
||||
p_export_wh numeric,
|
||||
p_grid_power_w int
|
||||
)
|
||||
returns numeric
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select case
|
||||
when coalesce(p_import_wh, 0) > 0 and coalesce(p_export_wh, 0) > 0 then
|
||||
coalesce(
|
||||
p_import_wh,
|
||||
greatest(coalesce(p_grid_power_w, 0), 0)::numeric / 4
|
||||
)
|
||||
when coalesce(p_export_wh, 0) = 0 then
|
||||
greatest(
|
||||
coalesce(
|
||||
p_import_wh,
|
||||
greatest(coalesce(p_grid_power_w, 0), 0)::numeric / 4
|
||||
),
|
||||
greatest(coalesce(p_grid_power_w, 0), 0)::numeric / 4
|
||||
)
|
||||
else
|
||||
coalesce(
|
||||
p_import_wh,
|
||||
greatest(coalesce(p_grid_power_w, 0), 0)::numeric / 4
|
||||
)
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function ems.fn_audit_grid_export_wh_for_economics(
|
||||
p_import_wh numeric,
|
||||
p_export_wh numeric,
|
||||
p_grid_power_w int
|
||||
)
|
||||
returns numeric
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select case
|
||||
when coalesce(p_import_wh, 0) > 0 and coalesce(p_export_wh, 0) > 0 then
|
||||
coalesce(
|
||||
p_export_wh,
|
||||
abs(least(coalesce(p_grid_power_w, 0), 0))::numeric / 4
|
||||
)
|
||||
when coalesce(p_import_wh, 0) = 0 then
|
||||
greatest(
|
||||
coalesce(
|
||||
p_export_wh,
|
||||
abs(least(coalesce(p_grid_power_w, 0), 0))::numeric / 4
|
||||
),
|
||||
abs(least(coalesce(p_grid_power_w, 0), 0))::numeric / 4
|
||||
)
|
||||
else
|
||||
coalesce(
|
||||
p_export_wh,
|
||||
abs(least(coalesce(p_grid_power_w, 0), 0))::numeric / 4
|
||||
)
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function ems.fn_audit_grid_import_wh_for_economics(numeric, numeric, int) is
|
||||
'Import Wh pro audit/ekonomiku: u čistého importu max(uložený čítač, max(0,P_grid)×¼ h).';
|
||||
|
||||
comment on function ems.fn_audit_grid_export_wh_for_economics(numeric, numeric, int) is
|
||||
'Export Wh pro audit/ekonomiku: u čistého exportu max(uložený čítač, |min(0,P_grid)|×¼ h).';
|
||||
|
||||
CREATE OR REPLACE FUNCTION ems.fn_effective_buy_price(
|
||||
p_site_id INT,
|
||||
@@ -14,6 +82,7 @@ STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_spot_price NUMERIC;
|
||||
v_energy_czk NUMERIC;
|
||||
v_dist_rate NUMERIC;
|
||||
v_system_services NUMERIC;
|
||||
v_ote_fee NUMERIC;
|
||||
@@ -27,8 +96,14 @@ DECLARE
|
||||
v_hdo_code_id INT;
|
||||
v_tariff_id INT;
|
||||
v_rate_type TEXT;
|
||||
v_purchase_mode TEXT;
|
||||
v_fixed_nt NUMERIC;
|
||||
v_fixed_vt_sur NUMERIC;
|
||||
BEGIN
|
||||
SELECT
|
||||
smc.purchase_pricing_mode,
|
||||
smc.buy_fixed_energy_nt_czk_kwh,
|
||||
smc.buy_fixed_vt_surcharge_czk_kwh,
|
||||
smc.buy_margin_fixed_czk,
|
||||
smc.buy_margin_percent,
|
||||
smc.system_services_czk_kwh,
|
||||
@@ -37,6 +112,9 @@ BEGIN
|
||||
smc.tariff_id,
|
||||
dt.vat_rate
|
||||
INTO
|
||||
v_purchase_mode,
|
||||
v_fixed_nt,
|
||||
v_fixed_vt_sur,
|
||||
v_buy_margin_fixed,
|
||||
v_buy_margin_pct,
|
||||
v_system_services,
|
||||
@@ -62,10 +140,6 @@ BEGIN
|
||||
AND interval_start = p_interval_start
|
||||
LIMIT 1;
|
||||
|
||||
IF v_spot_price IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
v_local_time := (p_interval_start AT TIME ZONE 'Europe/Prague')::TIME;
|
||||
v_dow := EXTRACT(DOW FROM p_interval_start AT TIME ZONE 'Europe/Prague');
|
||||
-- 0=neděle, 6=sobota
|
||||
@@ -106,11 +180,30 @@ BEGIN
|
||||
v_ote_fee := COALESCE(v_ote_fee, 0);
|
||||
v_buy_margin_fixed := COALESCE(v_buy_margin_fixed, 0);
|
||||
v_buy_margin_pct := COALESCE(v_buy_margin_pct, 0);
|
||||
v_buy_margin := v_buy_margin_fixed + (v_spot_price * v_buy_margin_pct / 100.0);
|
||||
v_vat_rate := COALESCE(v_vat_rate, 0.21);
|
||||
v_fixed_vt_sur := COALESCE(v_fixed_vt_sur, 0);
|
||||
|
||||
IF upper(trim(COALESCE(v_purchase_mode, ''))) = 'FIXED'
|
||||
AND v_fixed_nt IS NOT NULL THEN
|
||||
v_energy_czk := v_fixed_nt
|
||||
+ CASE WHEN v_is_vt THEN v_fixed_vt_sur ELSE 0 END;
|
||||
v_buy_margin := v_buy_margin_fixed + (v_energy_czk * v_buy_margin_pct / 100.0);
|
||||
ELSIF v_spot_price IS NULL THEN
|
||||
RETURN NULL;
|
||||
ELSE
|
||||
-- Spot: asymetrický faktor na raw OTE (stejné p jako u kladného ×(1+p/100)):
|
||||
-- kladná raw → ×(1+p/100), záporná raw → ×(1−p/100); fixní marže jen přičíst.
|
||||
v_energy_czk := CASE
|
||||
WHEN v_spot_price >= 0 THEN
|
||||
v_spot_price * (1 + v_buy_margin_pct / 100.0)
|
||||
ELSE
|
||||
v_spot_price * (1 - v_buy_margin_pct / 100.0)
|
||||
END;
|
||||
v_buy_margin := v_buy_margin_fixed;
|
||||
END IF;
|
||||
|
||||
RETURN ROUND(
|
||||
(v_spot_price + v_dist_rate + v_system_services + v_ote_fee + v_buy_margin)
|
||||
(v_energy_czk + v_dist_rate + v_system_services + v_ote_fee + v_buy_margin)
|
||||
* (1 + v_vat_rate),
|
||||
6
|
||||
);
|
||||
@@ -119,8 +212,9 @@ $$;
|
||||
|
||||
COMMENT ON FUNCTION ems.fn_effective_buy_price(INT, TIMESTAMPTZ) IS
|
||||
'Efektivní nákupní cena elektřiny Kč/kWh včetně DPH.
|
||||
Složky: spot OTE + distribuce NT/VT (dle HDO) + systémové služby + OTE poplatek + marže (fix + % ze spotu).
|
||||
DPH aplikováno na celou částku. Distribuce závisí na HDO kódu site.';
|
||||
Režim spot: složka OTE buy_raw jako kladná → ×(1+buy_margin_percent/100), záporná → ×(1−buy_margin_percent/100); + buy_margin_fixed_czk + distribuce NT/VT (HDO) + systémové služby + OTE poplatek; pak DPH na celek.
|
||||
Režim fixed: energie = buy_fixed_energy_nt_czk_kwh (+ příplatek VT dle HDO), marže = fix + procento z této uzavřené energické složky (symetricky jako dříve); + příplatky a DPH stejně.
|
||||
DPH aplikováno na celou částku.';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
102
db/routines/R__012_fn_energy_flows_intervals_day.sql
Normal file
102
db/routines/R__012_fn_energy_flows_intervals_day.sql
Normal file
@@ -0,0 +1,102 @@
|
||||
create or replace function ems.fn_energy_flows_intervals_day(p_site_id int, p_day date)
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
select coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'interval_start', ai.interval_start,
|
||||
'pv_production_kwh',
|
||||
case
|
||||
when ai.actual_pv_production_wh is null then null
|
||||
else round(ai.actual_pv_production_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'grid_import_kwh',
|
||||
case
|
||||
when ai.actual_grid_import_wh is null
|
||||
and ai.actual_grid_export_wh is null
|
||||
and ai.actual_grid_power_w is null
|
||||
then null
|
||||
else round(
|
||||
ems.fn_audit_grid_import_wh_for_economics(
|
||||
ai.actual_grid_import_wh, ai.actual_grid_export_wh, ai.actual_grid_power_w
|
||||
)::numeric / 1000,
|
||||
4
|
||||
)
|
||||
end,
|
||||
'grid_export_kwh',
|
||||
case
|
||||
when ai.actual_grid_import_wh is null
|
||||
and ai.actual_grid_export_wh is null
|
||||
and ai.actual_grid_power_w is null
|
||||
then null
|
||||
else round(
|
||||
ems.fn_audit_grid_export_wh_for_economics(
|
||||
ai.actual_grid_import_wh, ai.actual_grid_export_wh, ai.actual_grid_power_w
|
||||
)::numeric / 1000,
|
||||
4
|
||||
)
|
||||
end,
|
||||
'batt_charge_kwh',
|
||||
case
|
||||
when ai.actual_batt_charge_wh is null then null
|
||||
else round(ai.actual_batt_charge_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'batt_discharge_kwh',
|
||||
case
|
||||
when ai.actual_batt_discharge_wh is null then null
|
||||
else round(ai.actual_batt_discharge_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'load_kwh',
|
||||
case
|
||||
when ai.actual_load_consumption_wh is null then null
|
||||
else round(ai.actual_load_consumption_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'pv_to_load_kwh',
|
||||
case
|
||||
when ai.flow_pv_to_load_wh is null then null
|
||||
else round(ai.flow_pv_to_load_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'pv_to_batt_kwh',
|
||||
case
|
||||
when ai.flow_pv_to_batt_wh is null then null
|
||||
else round(ai.flow_pv_to_batt_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'pv_to_grid_kwh',
|
||||
case
|
||||
when ai.flow_pv_to_grid_wh is null then null
|
||||
else round(ai.flow_pv_to_grid_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'batt_to_load_kwh',
|
||||
case
|
||||
when ai.flow_batt_to_load_wh is null then null
|
||||
else round(ai.flow_batt_to_load_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'batt_to_grid_kwh',
|
||||
case
|
||||
when ai.flow_batt_to_grid_wh is null then null
|
||||
else round(ai.flow_batt_to_grid_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'grid_to_load_kwh',
|
||||
case
|
||||
when ai.flow_grid_to_load_wh is null then null
|
||||
else round(ai.flow_grid_to_load_wh::numeric / 1000, 4)
|
||||
end,
|
||||
'grid_to_batt_kwh',
|
||||
case
|
||||
when ai.flow_grid_to_batt_wh is null then null
|
||||
else round(ai.flow_grid_to_batt_wh::numeric / 1000, 4)
|
||||
end
|
||||
)
|
||||
order by ai.interval_start
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
from ems.audit_interval ai
|
||||
where ai.site_id = p_site_id
|
||||
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date = p_day;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_energy_flows_intervals_day(int, date) is
|
||||
'15min energy flows pro jeden kalendářní den (Prague) jako JSON pole.';
|
||||
70
db/routines/R__014_fn_ev_arrival_prediction_bundle.sql
Normal file
70
db/routines/R__014_fn_ev_arrival_prediction_bundle.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
create or replace function ems.fn_ev_arrival_prediction_bundle(p_site_id int)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
as $fn$
|
||||
declare
|
||||
v_tz text;
|
||||
v_tomorrow date;
|
||||
v_n_sessions int;
|
||||
v_insufficient boolean;
|
||||
v_chargers jsonb := '{}'::jsonb;
|
||||
r record;
|
||||
v_rows jsonb;
|
||||
begin
|
||||
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague')
|
||||
into v_tz
|
||||
from ems.site s
|
||||
where s.id = p_site_id;
|
||||
|
||||
if not found then
|
||||
return jsonb_build_object('error', 'site_not_found');
|
||||
end if;
|
||||
|
||||
v_tomorrow := (
|
||||
(current_timestamp at time zone v_tz)::date + 1
|
||||
);
|
||||
|
||||
select count(*)::int
|
||||
into v_n_sessions
|
||||
from ems.ev_session
|
||||
where site_id = p_site_id;
|
||||
|
||||
v_insufficient := coalesce(v_n_sessions, 0) < 5;
|
||||
|
||||
for r in
|
||||
select id, code
|
||||
from ems.asset_ev_charger
|
||||
where site_id = p_site_id
|
||||
order by id
|
||||
loop
|
||||
select coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'hour', x.expected_hour,
|
||||
'confidence_pct', x.confidence_pct,
|
||||
'samples', x.sample_count
|
||||
)
|
||||
order by x.expected_hour
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
into v_rows
|
||||
from ems.fn_ev_expected_arrival(p_site_id, r.id, v_tomorrow) x;
|
||||
|
||||
v_chargers := v_chargers || jsonb_build_object(
|
||||
r.code::text,
|
||||
jsonb_build_object('tomorrow', coalesce(v_rows, '[]'::jsonb))
|
||||
);
|
||||
end loop;
|
||||
|
||||
return jsonb_build_object(
|
||||
'insufficient_data', v_insufficient,
|
||||
'tomorrow_date', v_tomorrow,
|
||||
'chargers', v_chargers
|
||||
);
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_ev_arrival_prediction_bundle(int) is
|
||||
'Predikce příjezdů pro všechny nabíječky (nahrazuje N+1 volání fn_ev_expected_arrival).';
|
||||
49
db/routines/R__015_fn_ev_session_patch.sql
Normal file
49
db/routines/R__015_fn_ev_session_patch.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
create or replace function ems.fn_ev_session_apply_patch(
|
||||
p_site_id int,
|
||||
p_session_id int,
|
||||
p_patch jsonb
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
as $fn$
|
||||
declare
|
||||
v_id int;
|
||||
begin
|
||||
if not (p_patch ? 'target_soc_pct') and not (p_patch ? 'target_deadline') then
|
||||
return jsonb_build_object('success', false, 'error', 'no_fields');
|
||||
end if;
|
||||
|
||||
update ems.ev_session es
|
||||
set
|
||||
target_soc_pct = case
|
||||
when p_patch ? 'target_soc_pct' then
|
||||
case
|
||||
when p_patch->'target_soc_pct' is null
|
||||
or jsonb_typeof(p_patch->'target_soc_pct') = 'null' then null
|
||||
else (p_patch->>'target_soc_pct')::double precision
|
||||
end
|
||||
else es.target_soc_pct
|
||||
end,
|
||||
target_deadline = case
|
||||
when p_patch ? 'target_deadline' then
|
||||
case
|
||||
when p_patch->'target_deadline' is null
|
||||
or jsonb_typeof(p_patch->'target_deadline') = 'null' then null
|
||||
else (p_patch->>'target_deadline')::timestamptz
|
||||
end
|
||||
else es.target_deadline
|
||||
end
|
||||
where es.id = p_session_id
|
||||
and es.site_id = p_site_id
|
||||
returning es.id into v_id;
|
||||
|
||||
if v_id is null then
|
||||
return jsonb_build_object('success', false, 'session_id', null);
|
||||
end if;
|
||||
|
||||
return jsonb_build_object('success', true, 'session_id', v_id);
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_ev_session_apply_patch(int, int, jsonb) is
|
||||
'PATCH EV session – jen klíče přítomné v JSON (ISO string pro deadline).';
|
||||
87
db/routines/R__016_fn_ev_session_transition.sql
Normal file
87
db/routines/R__016_fn_ev_session_transition.sql
Normal file
@@ -0,0 +1,87 @@
|
||||
create or replace function ems.fn_ev_session_transition(
|
||||
p_site_id int,
|
||||
p_charger_id int,
|
||||
p_prev_status text,
|
||||
p_new_status text,
|
||||
p_measured_at timestamptz
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
as $fn$
|
||||
declare
|
||||
v_vehicle_id int;
|
||||
begin
|
||||
if p_prev_status is not distinct from p_new_status then
|
||||
return jsonb_build_object('action', 'none');
|
||||
end if;
|
||||
|
||||
if p_prev_status = 'available' and p_new_status is distinct from 'available' then
|
||||
select av.id
|
||||
into v_vehicle_id
|
||||
from ems.asset_vehicle av
|
||||
where av.site_id = p_site_id
|
||||
and av.default_charger_id = p_charger_id
|
||||
and av.active = true
|
||||
order by av.id
|
||||
limit 1;
|
||||
|
||||
perform ems.fn_update_ev_arrival_stats(
|
||||
p_site_id,
|
||||
p_charger_id,
|
||||
v_vehicle_id,
|
||||
p_measured_at
|
||||
);
|
||||
|
||||
insert into ems.ev_session (
|
||||
site_id,
|
||||
charger_id,
|
||||
vehicle_id,
|
||||
session_start,
|
||||
target_soc_pct,
|
||||
target_deadline
|
||||
)
|
||||
select
|
||||
ac.site_id,
|
||||
ac.id,
|
||||
av.id,
|
||||
now(),
|
||||
av.default_target_soc_pct,
|
||||
case
|
||||
when av.default_deadline_hour is not null then
|
||||
(
|
||||
(timezone('Europe/Prague', now()))::date + interval '1 day'
|
||||
+ make_interval(hours => av.default_deadline_hour)
|
||||
)::timestamp at time zone 'Europe/Prague'
|
||||
end
|
||||
from ems.asset_ev_charger ac
|
||||
left join lateral (
|
||||
select v.id, v.default_target_soc_pct, v.default_deadline_hour
|
||||
from ems.asset_vehicle v
|
||||
where v.default_charger_id = ac.id
|
||||
and v.site_id = ac.site_id
|
||||
and v.active = true
|
||||
order by v.id
|
||||
limit 1
|
||||
) av on true
|
||||
where ac.id = p_charger_id
|
||||
and ac.site_id = p_site_id
|
||||
on conflict (charger_id) where session_end is null do nothing;
|
||||
|
||||
return jsonb_build_object('action', 'arrival');
|
||||
end if;
|
||||
|
||||
if p_prev_status is distinct from 'available' and p_new_status = 'available' then
|
||||
update ems.ev_session es
|
||||
set session_end = now()
|
||||
where es.charger_id = p_charger_id
|
||||
and es.session_end is null;
|
||||
|
||||
return jsonb_build_object('action', 'departure');
|
||||
end if;
|
||||
|
||||
return jsonb_build_object('action', 'none');
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_ev_session_transition(int, int, text, text, timestamptz) is
|
||||
'Detekce příjezdu/odjezdu EV po změně statusu nabíječky (telemetry_collector).';
|
||||
49
db/routines/R__017_fn_ev_sessions_active.sql
Normal file
49
db/routines/R__017_fn_ev_sessions_active.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
create or replace function ems.fn_ev_sessions_active(p_site_id int)
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
select coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', es.id,
|
||||
'charger_id', es.charger_id,
|
||||
'vehicle_id', es.vehicle_id,
|
||||
'session_start', es.session_start,
|
||||
'energy_delivered_wh', es.energy_delivered_wh,
|
||||
'target_soc_pct', es.target_soc_pct,
|
||||
'target_deadline', es.target_deadline,
|
||||
'make', av.make,
|
||||
'model', av.model,
|
||||
'battery_capacity_kwh', av.battery_capacity_kwh,
|
||||
'default_target_soc_pct', av.default_target_soc_pct,
|
||||
'default_deadline_hour', av.default_deadline_hour,
|
||||
'charger_code', ac.code,
|
||||
'charger_name',
|
||||
coalesce(
|
||||
nullif(
|
||||
trim(
|
||||
concat_ws(
|
||||
' ',
|
||||
nullif(trim(ac.manufacturer), ''),
|
||||
nullif(trim(ac.model), '')
|
||||
)
|
||||
),
|
||||
''
|
||||
),
|
||||
ac.code
|
||||
)
|
||||
)
|
||||
order by es.session_start desc
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
from ems.ev_session es
|
||||
left join ems.asset_vehicle av on av.id = es.vehicle_id
|
||||
join ems.asset_ev_charger ac on ac.id = es.charger_id
|
||||
where es.site_id = p_site_id
|
||||
and es.session_end is null;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_ev_sessions_active(int) is
|
||||
'Aktivní EV session pro site (GET /ev/sessions/active).';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user