Compare commits
143 Commits
eb8dd0368f
...
refactor-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3a49806b | ||
|
|
b35f292295 | ||
|
|
6471467bc5 | ||
|
|
ba53fe5bfc | ||
|
|
335c413232 | ||
|
|
bcb05d4896 | ||
|
|
405e832f8d | ||
|
|
e8eb867a2a | ||
|
|
349a15e96a | ||
|
|
6129677756 | ||
|
|
6cacf523a2 | ||
|
|
44cd7f986a | ||
|
|
53288d130a | ||
|
|
abe4255f88 | ||
|
|
55ccf06627 | ||
|
|
0ca1bed0fd | ||
|
|
6d6341cde8 | ||
| e2f77eda14 | |||
|
|
02f0ab66e4 | ||
|
|
3595b24f3b | ||
|
|
5ca5eab1d8 | ||
|
|
343f2f9847 | ||
|
|
b20cb6e0f9 | ||
|
|
fffe6c7185 | ||
|
|
ed88ef8910 | ||
|
|
91ee8a6adf | ||
|
|
bf3b10ca50 | ||
|
|
e54eb1dfd9 | ||
|
|
1e0300dd7e | ||
|
|
e686bc1d2c | ||
|
|
6743224cc5 | ||
|
|
03ebc6246d | ||
|
|
efc6e54f0e | ||
|
|
6074535d96 | ||
|
|
2eeab58c8e | ||
|
|
93193fd5dc | ||
|
|
f3a7b0c64f | ||
|
|
b66b0109b9 | ||
|
|
dede8d604d | ||
|
|
2c884e2135 | ||
|
|
342483b885 | ||
|
|
9aceb628aa | ||
|
|
89fb4f1924 | ||
|
|
5593397fd3 | ||
|
|
9d37efb991 | ||
|
|
afee62ba4e | ||
|
|
e35110cb87 | ||
|
|
542cd9a73c | ||
|
|
8114ec5e63 | ||
|
|
c52946a4ce | ||
|
|
69c979b967 | ||
|
|
30585c9779 | ||
|
|
e96bb75b87 | ||
|
|
5b94f8baec | ||
|
|
e4d4fee24d | ||
|
|
16fc6a065e | ||
|
|
cc674900cc | ||
|
|
8960576ee8 | ||
|
|
50a0ca95f4 | ||
|
|
1d04790f28 | ||
|
|
5f96a4cf01 | ||
|
|
4875c31338 | ||
|
|
bf7373fbfe | ||
|
|
3940f6d45c | ||
|
|
a943829c40 | ||
|
|
40b2ff2ff9 | ||
|
|
c6ca68b263 | ||
|
|
0edf9226cb | ||
|
|
b1e124416d | ||
|
|
1735f77863 | ||
|
|
5d7d7e2823 | ||
|
|
f6e239aa8d | ||
|
|
c928e2234d | ||
|
|
1dfab8c7a1 | ||
|
|
568b584748 | ||
|
|
3cd8e44d37 | ||
|
|
5a66cfa63f | ||
|
|
bc0966e4c4 | ||
|
|
638c5444be | ||
|
|
09f1d2de68 | ||
|
|
bd7d6a1b99 | ||
|
|
faf948d75b | ||
|
|
e085068069 | ||
|
|
9ca4b4c577 | ||
|
|
ffe80679cc | ||
|
|
6cf14ed25b | ||
|
|
a07f5d57cb | ||
|
|
b8515f30df | ||
|
|
d8dbb284fd | ||
|
|
43b594c8d5 | ||
|
|
6447666cee | ||
|
|
7f3b0957cc | ||
|
|
e3776226a4 | ||
|
|
d8221e3169 | ||
|
|
ee4355f17f | ||
|
|
70d306961a | ||
|
|
ea2e33972c | ||
|
|
6dc14764d0 | ||
|
|
301f20612f | ||
|
|
f48a7aad61 | ||
|
|
e33207f3fa | ||
|
|
014c6f193b | ||
|
|
ccb2a41e22 | ||
|
|
22bca9cd9e | ||
|
|
0c93f493a4 | ||
|
|
93f883f5e0 | ||
|
|
a02e11ee13 | ||
|
|
f8e1eed127 | ||
|
|
efc2cbfded | ||
|
|
5c868083af | ||
|
|
b4c58156f0 | ||
|
|
dc0e37e580 | ||
|
|
0814b1d8e8 | ||
|
|
ee27f4e3fd | ||
|
|
906eeb1609 | ||
|
|
477e94f321 | ||
|
|
d3fd8b139a | ||
|
|
d5dcf33e13 | ||
|
|
a1aa6acf61 | ||
|
|
fd06811753 | ||
|
|
3b33594354 | ||
|
|
3da738e7e9 | ||
|
|
f0dfcefd54 | ||
|
|
5919b6caf3 | ||
|
|
9ff7c96c22 | ||
|
|
0e5227eb5b | ||
|
|
3c9916f2c0 | ||
|
|
d7e6226962 | ||
|
|
f7d3162eb7 | ||
|
|
3066a82265 | ||
|
|
0ba72c7704 | ||
|
|
71d8405cee | ||
|
|
015c81a8cb | ||
|
|
4e81a36371 | ||
|
|
b50041cfc7 | ||
|
|
44ab3783ce | ||
|
|
a65d134682 | ||
|
|
74ffa5c3e7 | ||
|
|
f714cab0ab | ||
|
|
64221f701a | ||
|
|
806274cf59 | ||
|
|
25090a9d95 | ||
|
|
b8b3de2b70 |
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal file
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
name: planner-battery-tuning
|
||||
overview: Opravíme nesoulad mezi plánem a zápisem do Deye při nabíjení z FVE přebytku, doplníme SQL-first vstupy pro denní safety charge, aplikujeme je v LP jako soft penalty a uložíme debug snapshot každého běhu planneru.
|
||||
todos:
|
||||
- id: fix-deye-passive-charge
|
||||
content: Opravit Deye PASSIVE překlad tak, aby plánované nabíjení z FVE přebytku nezapsalo reg108=0.
|
||||
status: completed
|
||||
- id: add-planner-debug-snapshot
|
||||
content: Ukládat ke každému planning_run kompaktní debug JSON do solver_params se sekcemi inputs, masks, soc_bounds, objective_terms a chosen_slots.
|
||||
status: pending
|
||||
- id: prevent-charge-deferral
|
||||
content: Doplnit near-term commitment / soft target před drahým sell oknem, aby rolling replan neodkládal nabíjení bez ekonomické náhrady.
|
||||
status: pending
|
||||
- id: add-daytime-safety-charge
|
||||
content: Spočítat safety-charge vstupy v SQL, předat je do LP a aplikovat jako měkkou penalizaci deficitu proti noční energii.
|
||||
status: pending
|
||||
- id: add-regression-test
|
||||
content: Přidat regresní testy pro PV surplus charge + současný net export a pro neodkládání nabíjení při receding horizon.
|
||||
status: completed
|
||||
- id: tune-small-site-terminal-soc
|
||||
content: Po debug ověření upravit parametry BA81/KV1 cíleně; nezačínat slepým přepsáním `planner_terminal_soc_value_factor` na 0.9.
|
||||
status: cancelled
|
||||
- id: update-docs
|
||||
content: Aktualizovat dokumentaci control/planning a ověřovací MCP dotazy.
|
||||
status: completed
|
||||
- id: verify
|
||||
content: Spustit testy/validaci a sepsat očekávané MCP ověření po deployi.
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Stabilizace plánovače baterie
|
||||
|
||||
## Cíl
|
||||
Opravit tři související problémy:
|
||||
|
||||
- Plán někdy chce nabíjet baterii z PV přebytku, ale Deye dostane `reg108 = 0`, takže fyzicky nenabíjí.
|
||||
- Rolling replan umí posouvat plánované nabíjení dál a dál, až levné PV okno uteče.
|
||||
- Malé baterie BA81/KV1 potřebují robustní denní nabití pro noc, ale zároveň nesmí ztratit schopnost ekonomicky cyklovat a prodávat v opravdu drahých sell oknech.
|
||||
|
||||
## Datové zjištění
|
||||
- `BA81` = site `3`, `KV1` = site `4`, `home-01` = site `2`.
|
||||
- KV1 run `8101` pro slot 17:15 plánoval `battery_setpoint_w = 4737` W, `grid_setpoint_w = -13` W, `deye_physical_mode = PASSIVE`; `modbus_command` následně zapsal a ověřil Deye `register = 108`, `value_to_write = 0`. To je konkrétní bug v control exportu.
|
||||
- BA81 historie rolling runů ukazuje posun prvního charge slotu s časem. To je částečně normální receding-horizon efekt, ale nesmí prodat levný PV přebytek, který je potřeba pro pozdější sell peak nebo noční baseload.
|
||||
- `planner_terminal_soc_value_factor` není jediné řešení. BA81/KV1 mají `0.2`, home-01 má `0.9`; nezvyšovat BA81/KV1 plošně na `0.9`, protože to může vrátit starou neochotu malé baterie cyklovat.
|
||||
|
||||
## Architektonické rozhodnutí
|
||||
- SQL-first zůstává: výpočet vstupů pro planner patří do SQL funkcí / view.
|
||||
- Safety charge nesmí být hard `allow_charge` maska. SQL má spočítat vstupní hodnoty, LP je použije jako soft penalty v objective.
|
||||
- Debug snapshot ukládat do existujícího `ems.planning_run.solver_params`. Samostatnou tabulku nezavádět v první iteraci.
|
||||
- Hodnota energie v baterii není jedna konstanta: `battery_value = max(future_avoided_buy, future_sell_opportunity) - degradation`, plus samostatný měkký noční buffer.
|
||||
|
||||
## Implementace
|
||||
|
||||
### 1. Oprava Deye exportéru
|
||||
Soubory:
|
||||
- [`backend/services/control/inverter.py`](backend/services/control/inverter.py)
|
||||
- [`backend/services/control/setpoints.py`](backend/services/control/setpoints.py)
|
||||
|
||||
Požadované chování:
|
||||
- Pokud `ControlSetpoints.battery_w > 0`, Deye musí dostat nenulový nabíjecí proud podle `battery_w`, i když `grid_setpoint_w < 0`.
|
||||
- V tomto scénáři zůstává `deye_physical_mode = PASSIVE`, pokud plán explicitně neurčí `CHARGE`. Nejde o grid-charge režim; jde o nabíjení z PV přebytku a současný export zbytku.
|
||||
- `discharge_a` v tomto scénáři nastavit na `0` nebo jinak omezit tak, aby Deye současně nevybíjel baterii.
|
||||
- Existující SELL a PRESERVE chování neměnit.
|
||||
|
||||
Konkrétní místo:
|
||||
- V `write_inverter_setpoints()` je problém v PASSIVE větvi, která přes `_deye_zero_export_amps_for_passive()` vrací `charge_a = 0`, když `grid_w < 0` a `bat_w >= 0`.
|
||||
- Přidej před tuto větev explicitní případ `bat_w > 0`: `charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a)`, `discharge_a = 0`.
|
||||
|
||||
### 2. SQL vstupy pro daytime safety charge
|
||||
Soubory:
|
||||
- [`db/routines/R__063_fn_load_planning_slots_full.sql`](db/routines/R__063_fn_load_planning_slots_full.sql)
|
||||
- případně nová repeatable funkce v [`db/routines`](db/routines)
|
||||
|
||||
Neimplementovat jako hard masku. Nezakazovat / nepovolovat sloty natvrdo jen kvůli safety charge.
|
||||
|
||||
Doplnit SQL výstupy, které Python LP použije:
|
||||
- `night_baseload_target_wh`: kolik Wh je potřeba od večera do dalšího ranního PV okna.
|
||||
- `night_baseload_buffer_wh`: bezpečnostní přirážka, např. procento z cíle.
|
||||
- `safety_soc_target_wh`: doporučený SoC cíl pro slot.
|
||||
- `future_avoided_buy_czk_kwh`: odhad ceny, kterou baterie ušetří, pokud energii necháme pro vlastní spotřebu.
|
||||
- `future_sell_opportunity_czk_kwh`: nejlepší relevantní budoucí sell příležitost v horizontu.
|
||||
- `is_daytime_pv_surplus_slot`: pomocný boolean pro debug a vážení cíle.
|
||||
|
||||
Preferovaný způsob:
|
||||
- Rozšířit `ems.fn_load_planning_slots_full(...)`, protože už je hlavní zdroj slotových vstupů pro `_load_slots()`.
|
||||
- Pokud by rozšíření funkce bylo příliš velké, vytvořit samostatnou `ems.fn_planning_safety_charge_inputs(site_id, from, to, current_soc_wh)` a joinovat podle `interval_start` v SQL/Pythonu.
|
||||
|
||||
Výpočet nočního okna:
|
||||
- Praktická první verze: noc = od lokálního západu / večerního konce PV surplus do dalšího rána, zjednodušeně `20:00-06:00 Europe/Prague`.
|
||||
- Přesnější verze později: od posledního dnešního slotu s významným PV forecastem do prvního zítřejšího slotu s významným PV forecastem.
|
||||
- Pro první implementaci stačí konzervativní a čitelná definice, hlavně ji uložit do debug snapshotu.
|
||||
|
||||
### 3. Rozšíření Python datových tříd a načítání slotů
|
||||
Soubor:
|
||||
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
|
||||
|
||||
Upravit `PlanningSlot`:
|
||||
- Přidat volitelná pole pro SQL safety vstupy:
|
||||
- `safety_soc_target_wh: float | None`
|
||||
- `night_baseload_target_wh: float | None`
|
||||
- `night_baseload_buffer_wh: float | None`
|
||||
- `future_avoided_buy_czk_kwh: float | None`
|
||||
- `future_sell_opportunity_czk_kwh: float | None`
|
||||
- `is_daytime_pv_surplus_slot: bool = False`
|
||||
|
||||
Upravit `_load_slots()`:
|
||||
- Načíst nové sloupce ze SQL.
|
||||
- Pokud SQL sloupce dočasně nejsou k dispozici, použít bezpečný fallback `None` / `False`, aby testy starších DB funkcí nespadly.
|
||||
- Nepočítat noční baseload ad-hoc v Pythonu, pokud už SQL funkce hodnotu vrací.
|
||||
|
||||
### 4. LP objective: soft safety target
|
||||
Soubor:
|
||||
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
|
||||
|
||||
Přidat do `solve_dispatch()`:
|
||||
- Pro každý slot `t` s `safety_soc_target_wh is not None` vytvořit spojitou proměnnou `safety_deficit_wh[t] >= 0`.
|
||||
- Přidat omezení:
|
||||
- `safety_deficit_wh[t] >= safety_soc_target_wh[t] - soc[t]`
|
||||
- Přidat do objective penalizaci:
|
||||
- `safety_deficit_wh[t] * safety_penalty_czk_per_wh[t]`
|
||||
|
||||
Výpočet penalty:
|
||||
- `battery_value_czk_kwh = max(future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh) - degradation_cost_effective`
|
||||
- `safety_penalty_czk_per_wh = max(0, battery_value_czk_kwh) / 1000`
|
||||
- Přidat rozumný clamp, aby penalty nebyla extrémní kvůli vadné ceně.
|
||||
|
||||
Chování:
|
||||
- Pokud je vysoký sell peak ekonomicky lepší než držet energii pro noc, LP smí target porušit a prodat.
|
||||
- Pokud je budoucí nákup drahý, typicky KV1, deficit bude drahý a LP bude energii spíš držet pro vlastní spotřebu.
|
||||
- Toto není hard constraint.
|
||||
|
||||
### 5. Near-term commitment proti deferralu
|
||||
Soubory:
|
||||
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
|
||||
- DB čtení z `ems.planning_run` / `ems.planning_interval` přes SQL funkci nebo jednoduchý read model
|
||||
|
||||
Cíl:
|
||||
- Rolling replan nesmí bez náhrady odsunout nejbližší plánované nabíjení z PV přebytku, pokud předchozí aktivní plán pro stejný nebo nejbližší slot chtěl nabíjet.
|
||||
|
||||
První jednoduchá implementace:
|
||||
- Při rolling replanu načíst předchozí aktivní plán pro stejné `site_id`.
|
||||
- Najít nejbližší 1-2 sloty od `replan_from`, kde předchozí plán měl:
|
||||
- `battery_setpoint_w > 500`
|
||||
- `pv_a_forecast_solver_w + pv_b_forecast_solver_w > load_baseline_w`
|
||||
- ideálně `grid_setpoint_w <= 0`
|
||||
- V novém LP pro odpovídající slot přidat soft proměnnou `charge_commitment_shortfall_w[t] >= previous_battery_charge_w - bc[t]`.
|
||||
- Penalizace má být malá, ale nenulová: má zabránit bezdůvodnému odsunu, ne přebít skutečně lepší ekonomiku.
|
||||
- Uložit do debug snapshotu, kdy commitment vznikl a kolik stál.
|
||||
|
||||
Neimplementovat jako hard constraint.
|
||||
|
||||
### 6. Debug snapshot do solver_params
|
||||
Soubory:
|
||||
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
|
||||
- [`db/routines/R__037_fn_planning_run_commit.sql`](db/routines/R__037_fn_planning_run_commit.sql)
|
||||
|
||||
Upravit `_save_planning_run()`:
|
||||
- Rozšířit `run_meta` o `solver_params`.
|
||||
- `solver_params` bude JSON serializovatelný dict.
|
||||
|
||||
Upravit `ems.fn_planning_run_commit(...)`:
|
||||
- Při insertu do `ems.planning_run` uložit `solver_params = p_run_meta->'solver_params'`.
|
||||
|
||||
Minimální struktura JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"inputs": {
|
||||
"current_soc_wh": 0,
|
||||
"operating_mode": "AUTO",
|
||||
"battery": {
|
||||
"usable_capacity_wh": 0,
|
||||
"min_soc_wh": 0,
|
||||
"reserve_soc_wh": 0,
|
||||
"degradation_cost_czk_kwh": 0,
|
||||
"planner_terminal_soc_value_factor": 0.2
|
||||
}
|
||||
},
|
||||
"masks": [
|
||||
{
|
||||
"slot": "2026-05-04T15:45:00+00:00",
|
||||
"allow_charge": true,
|
||||
"allow_discharge_export": false
|
||||
}
|
||||
],
|
||||
"soc_bounds": [
|
||||
{
|
||||
"slot": "2026-05-04T15:45:00+00:00",
|
||||
"soc_min_wh": 0,
|
||||
"arb_floor_wh": 0,
|
||||
"soc_panel_min_wh": 0,
|
||||
"safety_soc_target_wh": 0
|
||||
}
|
||||
],
|
||||
"objective_terms": [
|
||||
{
|
||||
"slot": "2026-05-04T15:45:00+00:00",
|
||||
"buy_price": 0,
|
||||
"sell_price": 0,
|
||||
"future_avoided_buy_czk_kwh": 0,
|
||||
"future_sell_opportunity_czk_kwh": 0,
|
||||
"battery_value_czk_kwh": 0,
|
||||
"safety_deficit_penalty_czk_per_wh": 0,
|
||||
"commitment_penalty_czk_per_w": 0
|
||||
}
|
||||
],
|
||||
"chosen_slots": {
|
||||
"charge_commitment": [],
|
||||
"high_sell_windows": [],
|
||||
"night_window": {
|
||||
"start": "2026-05-04T18:00:00+00:00",
|
||||
"end": "2026-05-05T04:00:00+00:00",
|
||||
"target_wh": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Debug read model
|
||||
Soubor:
|
||||
- nová repeatable funkce v [`db/routines`](db/routines), např. `R__086_fn_planning_run_debug.sql`
|
||||
|
||||
Vytvořit `ems.fn_planning_run_debug(p_run_id int)`:
|
||||
- Vrátí jeden `jsonb`.
|
||||
- Obsahuje:
|
||||
- metadata z `planning_run`,
|
||||
- `solver_params`,
|
||||
- intervaly z `planning_interval` pro daný run,
|
||||
- krátký souhrn: první charge slot, první battery export slot, nejdražší sell sloty, největší safety deficit.
|
||||
|
||||
Použití přes MCP:
|
||||
|
||||
```sql
|
||||
select ems.fn_planning_run_debug(8107);
|
||||
```
|
||||
|
||||
### 8. Parametry
|
||||
Nepřepisovat plošně BA81/KV1 na `planner_terminal_soc_value_factor = 0.9`.
|
||||
|
||||
Nové parametry preferovaně v `ems.asset_battery` přes novou migraci:
|
||||
- `planner_daytime_charge_target_enabled boolean default true`
|
||||
- `planner_night_baseload_buffer_percent numeric default 20`
|
||||
- `planner_daytime_charge_price_quantile numeric default 0.70`
|
||||
- `planner_charge_commitment_penalty_czk_kwh numeric default 0.20`
|
||||
|
||||
Pokud je rozsah příliš velký, první iterace může mít konzervativní konstanty v Pythonu, ale plánovaná cílová podoba je DB parametrizace.
|
||||
|
||||
### 9. Testy
|
||||
Najít existující testovací styl v repu a přidat testy co nejblíže dotčeným modulům.
|
||||
|
||||
Povinné scénáře:
|
||||
- Control exporter: `battery_w > 0`, `grid_setpoint_w < 0`, `deye_physical_mode = PASSIVE` vede na `reg108 > 0`, `reg109 = 0`.
|
||||
- Control exporter: SELL režim se nezmění.
|
||||
- Planner safety: malá baterie, PV surplus přes den, noční baseload, pozdější drahý sell slot. LP má nabíjet v rozumně levném PV slotu a neodsunout charge donekonečna.
|
||||
- Planner economics: pokud `sell_now` převyšuje budoucí avoided buy plus degradaci, LP smí porušit safety target a prodat.
|
||||
- Planner economics KV1-like: pokud budoucí buy je drahý a sell není dost vysoký, LP má držet energii pro vlastní spotřebu.
|
||||
|
||||
### 10. Dokumentace
|
||||
Aktualizovat:
|
||||
- [`docs/04-modules/control.md`](docs/04-modules/control.md)
|
||||
- [`docs/04-modules/planning.md`](docs/04-modules/planning.md)
|
||||
|
||||
Dokumentace musí popsat:
|
||||
- rozdíl mezi plánem, Deye fyzickým režimem a registry `108/109`,
|
||||
- PV-surplus charging při současném exportu,
|
||||
- `solver_params` debug snapshot a `fn_planning_run_debug`,
|
||||
- rozdíl mezi hard maskami (`allow_charge`, `allow_discharge_export`) a soft LP penalizacemi,
|
||||
- že `planner_terminal_soc_value_factor` není jediný mechanismus ochrany malé baterie.
|
||||
|
||||
## Ověření
|
||||
- Spustit backend testy pro control a planner.
|
||||
- Spustit Flyway validate lokálně.
|
||||
- Přes MCP ověřit po nasazení:
|
||||
- pro BA81/KV1 sloty s `battery_setpoint_w > 0` a `grid_setpoint_w < 0` má následný `modbus_command.register = 108` hodnotu > 0,
|
||||
- `planning_run.solver_params` není `NULL` a obsahuje `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`,
|
||||
- `select ems.fn_planning_run_debug(<run_id>)` vrací vysvětlitelný JSON,
|
||||
- rolling replan neodkládá nabíjení z levného PV přebytku bez viditelného ekonomického důvodu v debug snapshotu.
|
||||
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,9 +78,8 @@ jobs:
|
||||
ls -ld /opt/ems-deploy
|
||||
|
||||
- name: Run deploy script
|
||||
run: |
|
||||
bash /opt/ems-deploy/deploy.sh
|
||||
|
||||
run: bash /opt/ems-deploy/deploy.sh
|
||||
|
||||
# Alternativa: runner v Dockeru bez přístupu k hostu — odkomentovat a upravit SERVER + secrets.
|
||||
# deploy-ssh:
|
||||
# runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- feature/**
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
smoke-test:
|
||||
# Stejný label jako deploy.yml — výchozí act_runner má typicky jen `self-hosted`.
|
||||
runs-on: self-hosted
|
||||
# Výchozí job image často nemá Node → `actions/checkout@v4` padá na „Cannot find: node“.
|
||||
# alpine/git je malý a stačí na shallow clone přes token (Gitea = GitHub-kompatibilní kontext).
|
||||
container:
|
||||
image: alpine/git:latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
su="${{ github.server_url }}"
|
||||
case "$su" in
|
||||
https://*) clone_url="https://oauth2:${TOKEN}@${su#https://}" ;;
|
||||
http://*) clone_url="http://oauth2:${TOKEN}@${su#http://}" ;;
|
||||
*) echo "unknown github.server_url: $su"; exit 1 ;;
|
||||
esac
|
||||
clone_url="${clone_url}/${{ github.repository }}.git"
|
||||
git init
|
||||
git remote add origin "$clone_url"
|
||||
git fetch --depth=1 origin "${{ github.sha }}"
|
||||
git checkout -qf FETCH_HEAD
|
||||
|
||||
- name: Repo layout
|
||||
run: |
|
||||
test -f docker-compose.yml
|
||||
test -f deploy/docker-compose.yml
|
||||
test -x deploy/deploy.sh
|
||||
|
||||
- name: Runner info
|
||||
run: |
|
||||
uname -a
|
||||
pwd
|
||||
ls -la
|
||||
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_battery_charge_discharge_amps` a `_deye_zero_export_amps_for_passive` (jen asymetrie **import bez vybíjení** → **109 = 0**; export **108** nenuluje); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 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 modules."""
|
||||
|
||||
from .exporter_monolith import * # noqa: F401,F403
|
||||
233
backend/services/control/deye_helpers.py
Normal file
233
backend/services/control/deye_helpers.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Čisté Deye konstanty a helpery pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.control.models import InverterConfig
|
||||
|
||||
PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
# Hodiny Deye 62-64: po zápisu sekundy na zařízení dál běží, verify musí být toleranční.
|
||||
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120
|
||||
# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund.
|
||||
DEYE_CLOCK_DRIFT_OK_SEC = 60
|
||||
# A zároveň neuplynul tento interval od posledního syncu / potvrzení driftu.
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24
|
||||
|
||||
# Deye LV baterie: převod výkon -> proud pro registry 108/109.
|
||||
BATT_VOLTAGE_V = 51.2
|
||||
|
||||
# Reg 178 - bitové pole: bity 4-5 (peak shaving switch) a bity 0-1 (MI export cutoff).
|
||||
REG178_SELL = 0b00100000
|
||||
REG178_PASSIVE = 0b00110000
|
||||
REG178_VERIFY_MASK = 0x0030
|
||||
REG178_MI_EXPORT_MASK = 0x0003
|
||||
REG178_MI_EXPORT_DISABLE = 0b10
|
||||
REG178_MI_EXPORT_ENABLE = 0b11
|
||||
REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_MI_EXPORT_MASK
|
||||
|
||||
DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145})
|
||||
DEYE_TOU_POWER_REGS = frozenset(range(154, 160))
|
||||
DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350
|
||||
|
||||
# Neaktivní TOU bloky (3-6): Deye často 23:59 (2359) neuloží, 23:55 je stabilní.
|
||||
DEYE_TOU_INACTIVE_HHMM = 2355
|
||||
|
||||
_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
|
||||
[
|
||||
150,
|
||||
151,
|
||||
152,
|
||||
153,
|
||||
156,
|
||||
157,
|
||||
158,
|
||||
159,
|
||||
168,
|
||||
169,
|
||||
170,
|
||||
171,
|
||||
174,
|
||||
175,
|
||||
176,
|
||||
177,
|
||||
]
|
||||
)
|
||||
|
||||
DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64})
|
||||
|
||||
DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
108: "max_charge_a (max nabíjecí proud baterie)",
|
||||
109: "max_discharge_a (max vybíjecí proud baterie)",
|
||||
141: "energy_mode (0, EMS nemění)",
|
||||
142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)",
|
||||
143: "export_limit_w (max export do sítě)",
|
||||
145: "solar_sell (0=disabled, 1=enabled)",
|
||||
340: "max_solar_power_w (strop DC PV A v W; součet nominal_power_wp řiditelných polí)",
|
||||
178: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3; bits4-5 peak shaving 32/48)",
|
||||
148: "time_point_1_time",
|
||||
149: "time_point_2_time",
|
||||
154: "time_point_1_power_w",
|
||||
155: "time_point_2_power_w",
|
||||
166: "time_point_1_soc_min_pct",
|
||||
167: "time_point_2_soc_min_pct",
|
||||
172: "time_point_1_grid_charge",
|
||||
173: "time_point_2_grid_charge",
|
||||
62: "system_time_year_month",
|
||||
63: "system_time_day_hour",
|
||||
64: "system_time_min_sec",
|
||||
}
|
||||
for _tp_i in range(6):
|
||||
_n = _tp_i + 1
|
||||
DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time")
|
||||
DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w")
|
||||
DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct")
|
||||
DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge")
|
||||
|
||||
|
||||
def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
|
||||
return (int(expected_i) & REG178_VERIFY_MASK_COMBINED) == (
|
||||
int(actual_i) & REG178_VERIFY_MASK_COMBINED
|
||||
)
|
||||
|
||||
|
||||
def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool:
|
||||
"""True = po 3x mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr)."""
|
||||
return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN
|
||||
|
||||
|
||||
def _deye_tou_power_verify_match(
|
||||
expected_i: int, actual_i: int, inv: InverterConfig
|
||||
) -> bool:
|
||||
"""Firmware často clampne TOU power W na max z reg. 108/109 x 51.2 V."""
|
||||
if int(actual_i) == int(expected_i):
|
||||
return True
|
||||
max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V)
|
||||
max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
a = int(actual_i)
|
||||
return a == max_w_charge or a == max_w_discharge
|
||||
|
||||
|
||||
def _deye_reg178_verify_with_double_read(
|
||||
expected_i: int, actual_first: int, actual_second: int | None
|
||||
) -> tuple[bool, int]:
|
||||
"""
|
||||
Vrátí (shoda, hodnota_pro_journal).
|
||||
Druhé čtení použít jen když první neprojde maskou (RS485 / glitch).
|
||||
"""
|
||||
if _deye_reg178_verify_match(expected_i, actual_first):
|
||||
return True, actual_first
|
||||
if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second):
|
||||
return True, int(actual_second)
|
||||
return False, actual_first
|
||||
|
||||
|
||||
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
|
||||
if not power_w or power_w <= 0:
|
||||
return 0
|
||||
return min(32, max(0, int(power_w / (phases * voltage))))
|
||||
|
||||
|
||||
def battery_watts_to_amps(power_w: int, max_amps: int) -> int:
|
||||
"""Proud z |výkonu| baterie; max_amps z DB."""
|
||||
derived = int(abs(power_w) / BATT_VOLTAGE_V)
|
||||
return min(max(0, max_amps), max(0, derived))
|
||||
|
||||
|
||||
def current_slot_hhmm() -> int:
|
||||
"""Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM."""
|
||||
now = datetime.now(PRAGUE_TZ)
|
||||
slot_min = (now.minute // 15) * 15
|
||||
return now.hour * 100 + slot_min
|
||||
|
||||
|
||||
def next_slot_hhmm() -> int:
|
||||
"""Začátek příštího 15min slotu v Europe/Prague, formát HHMM."""
|
||||
now = datetime.now(PRAGUE_TZ)
|
||||
minutes = now.minute
|
||||
slot_minutes = ((minutes // 15) + 1) * 15
|
||||
if slot_minutes >= 60:
|
||||
next_hour = (now.hour + 1) % 24
|
||||
next_min = 0
|
||||
else:
|
||||
next_hour = now.hour
|
||||
next_min = slot_minutes
|
||||
return next_hour * 100 + next_min
|
||||
|
||||
|
||||
def compute_pv_a_reg340_max_solar_w(cap_w: int, forecast_w: int, curtail_w: int) -> int:
|
||||
"""Hodnota pro Deye reg 340 (max solar power, W) z capu a plánovaného curtailmentu pole A."""
|
||||
if curtail_w <= 0:
|
||||
return int(cap_w)
|
||||
return max(0, min(int(cap_w), int(forecast_w) - int(curtail_w)))
|
||||
|
||||
|
||||
def _prague_minute_start_utc() -> datetime:
|
||||
"""UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague."""
|
||||
p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
|
||||
return p.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None:
|
||||
"""Dekódování reg 62-64 (Deye system time v Europe/Prague)."""
|
||||
try:
|
||||
year = (int(r62) >> 8) + 2000
|
||||
month = int(r62) & 0xFF
|
||||
day = int(r63) >> 8
|
||||
hour = int(r63) & 0xFF
|
||||
minute = int(r64) >> 8
|
||||
second = int(r64) & 0xFF
|
||||
if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23):
|
||||
return None
|
||||
if not (0 <= minute <= 59 and 0 <= second <= 59):
|
||||
return None
|
||||
return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _deye_clock_registers_verify_match(
|
||||
w62: int,
|
||||
w63: int,
|
||||
w64: int,
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
) -> bool:
|
||||
w_dt = _deye_registers_to_prague_datetime(w62, w63, w64)
|
||||
a_dt = _deye_registers_to_prague_datetime(a62, a63, a64)
|
||||
if w_dt is None or a_dt is None:
|
||||
return False
|
||||
return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC
|
||||
|
||||
|
||||
def _deye_should_skip_time_sync_after_read(
|
||||
inv: InverterConfig,
|
||||
r62: int,
|
||||
r63: int,
|
||||
r64: int,
|
||||
) -> bool:
|
||||
"""
|
||||
True = nezařazovat zápis 62-64: drift je malý a od posledního úspěšného zápisu
|
||||
nebo tolerančního ověření neuplynulo 24h.
|
||||
"""
|
||||
dev = _deye_registers_to_prague_datetime(r62, r63, r64)
|
||||
if dev is None:
|
||||
return False
|
||||
wall = datetime.now(PRAGUE_TZ)
|
||||
drift = abs((wall - dev).total_seconds())
|
||||
if drift > DEYE_CLOCK_DRIFT_OK_SEC:
|
||||
return False
|
||||
last_write = inv.deye_last_system_time_sync_at
|
||||
if last_write is None:
|
||||
return False
|
||||
if last_write.tzinfo is None:
|
||||
last_write = last_write.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
last_write = last_write.astimezone(timezone.utc)
|
||||
age = datetime.now(timezone.utc) - last_write
|
||||
if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS):
|
||||
return False
|
||||
return True
|
||||
80
backend/services/control/exporter_monolith.py
Normal file
80
backend/services/control/exporter_monolith.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Zpětně kompatibilní fasáda pro původní control exporter importy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.control.deye_helpers import (
|
||||
BATT_VOLTAGE_V,
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_REGS,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC, # noqa: F401 - re-export for compatibility
|
||||
DEYE_CRITICAL_REGS_SELF_SUSTAIN, # noqa: F401 - re-export for compatibility
|
||||
DEYE_REGISTER_NAMES, # noqa: F401 - re-export for compatibility
|
||||
DEYE_TOU_INACTIVE_HHMM,
|
||||
DEYE_TOU_POWER_REGS,
|
||||
PRAGUE_TZ,
|
||||
REG178_MI_EXPORT_DISABLE,
|
||||
REG178_MI_EXPORT_ENABLE,
|
||||
REG178_MI_EXPORT_MASK,
|
||||
REG178_PASSIVE,
|
||||
REG178_SELL,
|
||||
REG178_VERIFY_MASK,
|
||||
REG178_VERIFY_MASK_COMBINED,
|
||||
_DEYE_INACTIVE_TOU_REGISTERS,
|
||||
_deye_clock_registers_verify_match,
|
||||
_deye_reg178_verify_match,
|
||||
_deye_reg178_verify_with_double_read,
|
||||
_deye_registers_to_prague_datetime, # noqa: F401 - re-export for compatibility
|
||||
_deye_should_skip_time_sync_after_read,
|
||||
_deye_tou_power_verify_match,
|
||||
_prague_minute_start_utc,
|
||||
battery_watts_to_amps,
|
||||
compute_pv_a_reg340_max_solar_w,
|
||||
current_slot_hhmm,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust, # noqa: F401 - re-export
|
||||
next_slot_hhmm,
|
||||
watts_to_amps,
|
||||
)
|
||||
from services.control.inverter import read_deye_registers_live, write_inverter_setpoints
|
||||
from services.control.models import ControlSetpoints, InverterConfig, OperatingModeInfo
|
||||
from services.control.modbus_journal import (
|
||||
_drop_registers_matching_last_verified,
|
||||
_fetch_last_verified_inverter_registers,
|
||||
create_modbus_commands,
|
||||
execute_modbus_commands,
|
||||
)
|
||||
from services.control.outputs import (
|
||||
_current_limit_for_charger,
|
||||
send_loxone_setpoints,
|
||||
write_ev_setpoints,
|
||||
write_heat_pump_setpoint,
|
||||
)
|
||||
from services.control.orchestrator import export_setpoints
|
||||
from services.control.repository import (
|
||||
_fetch_max_charge_power_w,
|
||||
_fetch_operating_mode,
|
||||
_fetch_plan_row_for_slot_offset,
|
||||
_get_current_soc,
|
||||
_load_inverter_config,
|
||||
)
|
||||
from services.control.setpoints import (
|
||||
_DictRecord,
|
||||
_apply_price_failsafe_guard,
|
||||
_build_setpoints,
|
||||
_clamp_deye_tou_soc_pct,
|
||||
_deye_passive_tou_battery_soc_pct,
|
||||
_deye_reg143_export_w,
|
||||
_deye_system_time_register_rows,
|
||||
_deye_time_point_rows,
|
||||
_deye_tou_min_soc_pct,
|
||||
_deye_tou_params,
|
||||
_deye_tou_reserve_soc_pct,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.control.verify import (
|
||||
_deye_expected_clock_triplet_for_verify,
|
||||
_modbus_cmd_register,
|
||||
_switch_to_self_sustain,
|
||||
_verify_deye_clock_written_bundle,
|
||||
verify_modbus_commands,
|
||||
)
|
||||
361
backend/services/control/inverter.py
Normal file
361
backend/services/control/inverter.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""Deye inverter writer and live register reader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.deye_helpers import (
|
||||
BATT_VOLTAGE_V,
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_REGS,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
DEYE_TOU_INACTIVE_HHMM,
|
||||
PRAGUE_TZ,
|
||||
REG178_MI_EXPORT_DISABLE,
|
||||
REG178_MI_EXPORT_ENABLE,
|
||||
REG178_MI_EXPORT_MASK,
|
||||
REG178_PASSIVE,
|
||||
REG178_SELL,
|
||||
REG178_VERIFY_MASK,
|
||||
REG178_VERIFY_MASK_COMBINED,
|
||||
_DEYE_INACTIVE_TOU_REGISTERS,
|
||||
_deye_should_skip_time_sync_after_read,
|
||||
_prague_minute_start_utc,
|
||||
current_slot_hhmm,
|
||||
next_slot_hhmm,
|
||||
)
|
||||
from services.control.modbus_journal import (
|
||||
_drop_registers_matching_last_verified,
|
||||
_fetch_last_verified_inverter_registers,
|
||||
create_modbus_commands,
|
||||
execute_modbus_commands,
|
||||
)
|
||||
from services.control.models import ControlSetpoints
|
||||
from services.control.repository import _get_current_soc, _load_inverter_config
|
||||
from services.control.setpoints import (
|
||||
_deye_reg143_export_w,
|
||||
_deye_system_time_register_rows,
|
||||
_deye_time_point_rows,
|
||||
_deye_tou_min_soc_pct,
|
||||
_deye_tou_params,
|
||||
_deye_tou_reserve_soc_pct,
|
||||
deye_battery_charge_discharge_amps,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def write_inverter_setpoints(
|
||||
site_id: int,
|
||||
setpoints_now: ControlSetpoints,
|
||||
setpoints_next: ControlSetpoints | None,
|
||||
db: asyncpg.Connection,
|
||||
planning_run_id: int | None = None,
|
||||
) -> str:
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
return "FAIL inverter: no controllable Modbus endpoint"
|
||||
|
||||
unit_id = int(inv.unit_id if inv.unit_id is not None else 1)
|
||||
raw_bat = setpoints_now.battery_w
|
||||
grid_w = int(setpoints_now.grid_setpoint_w or 0)
|
||||
no_export = inv.no_export
|
||||
export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w)
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge
|
||||
tou_min_pct = _deye_tou_min_soc_pct(inv)
|
||||
tou_reserve_pct = _deye_tou_reserve_soc_pct(inv)
|
||||
|
||||
try:
|
||||
soc_telemetry = await _get_current_soc(site_id, db)
|
||||
deye_mode = get_deye_mode(setpoints_now)
|
||||
|
||||
bat_w = int(raw_bat) if raw_bat is not None else 0
|
||||
charge_a, discharge_a = deye_battery_charge_discharge_amps(
|
||||
lock_battery=setpoints_now.lock_battery,
|
||||
deye_mode=deye_mode,
|
||||
self_sustain_local_use=setpoints_now.self_sustain_local_use,
|
||||
bat_w=bat_w,
|
||||
grid_w=grid_w,
|
||||
max_charge_a=int(inv.max_charge_a),
|
||||
max_discharge_a=int(inv.max_discharge_a),
|
||||
)
|
||||
|
||||
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
|
||||
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
|
||||
solar_sell = 0 if (setpoints_now.export_ban and deye_mode != "SELL") else 1
|
||||
export_limit = export_lim
|
||||
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
|
||||
|
||||
logger.info(
|
||||
f"[control] site={site_id} fyzický režim Deye: {deye_mode} | "
|
||||
f"battery_w={raw_bat!r} grid_w={grid_w} | "
|
||||
f"charge_a={charge_a} discharge_a={discharge_a} | "
|
||||
f"reg142={selling_mode} reg145={solar_sell} reg143={export_limit}W reg178={reg178_val}"
|
||||
)
|
||||
|
||||
now_cet, time_rows = _deye_system_time_register_rows()
|
||||
skip_time = False
|
||||
try:
|
||||
mb_clock = await get_modbus_client(inv.host, inv.port)
|
||||
tvals = await mb_clock.read_holding_registers(
|
||||
62, 3, int(inv.unit_id if inv.unit_id is not None else 1)
|
||||
)
|
||||
if len(tvals) == 3:
|
||||
skip_time = _deye_should_skip_time_sync_after_read(
|
||||
inv, int(tvals[0]), int(tvals[1]), int(tvals[2])
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Deye clock read: expected 3 registers, got %s; will sync 62-64",
|
||||
len(tvals),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Deye clock read failed (will sync 62-64): %s", e)
|
||||
|
||||
if skip_time:
|
||||
logger.info(
|
||||
"Deye clock 62-64 skipped (drift <= %ss, last sync < %sh ago): %s CET",
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
else:
|
||||
logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows)
|
||||
|
||||
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
|
||||
hh_cur = current_slot_hhmm()
|
||||
hh_nxt = next_slot_hhmm()
|
||||
p1, s1, g1 = _deye_tou_params(setpoints_now, inv)
|
||||
p2, s2, g2 = _deye_tou_params(sp_tp2, inv)
|
||||
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
|
||||
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
|
||||
|
||||
prague_date = datetime.now(PRAGUE_TZ).date()
|
||||
inactive_sig = (
|
||||
f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}"
|
||||
)
|
||||
need_inactive_tou = (
|
||||
inv.deye_last_tou_inactive_write_prague_date != prague_date
|
||||
or inv.deye_tou_inactive_signature != inactive_sig
|
||||
)
|
||||
if need_inactive_tou:
|
||||
for idx in range(2, 6):
|
||||
registers.extend(
|
||||
_deye_time_point_rows(
|
||||
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Deye TOU rows 3-6 skipped (already written today, signature unchanged)"
|
||||
)
|
||||
|
||||
registers.extend(
|
||||
[
|
||||
(108, "", charge_a),
|
||||
(109, "", discharge_a),
|
||||
(141, "energy_mode (0)", 0),
|
||||
(142, "limit_control", selling_mode),
|
||||
(143, "", export_limit),
|
||||
(145, "solar_sell", solar_sell),
|
||||
]
|
||||
)
|
||||
|
||||
if (
|
||||
bool(inv.deye_reg340_pv_a_control_enabled)
|
||||
and int(inv.pv_a_cap_w) > 0
|
||||
and setpoints_now.pv_a_allowed_w is not None
|
||||
):
|
||||
registers.append((340, "max_solar_power_w", int(setpoints_now.pv_a_allowed_w)))
|
||||
|
||||
try:
|
||||
mb178 = await get_modbus_client(inv.host, inv.port)
|
||||
r178 = await mb178.read_holding_registers(178, 1, unit_id)
|
||||
if not r178 or len(r178) < 1:
|
||||
raise OSError(f"reg178 read returned {len(r178) if r178 is not None else None} values")
|
||||
current_178 = int(r178[0])
|
||||
peak_bits = int(reg178_val) & int(REG178_VERIFY_MASK)
|
||||
if inv.deye_gen_microinverter_cutoff_enabled:
|
||||
want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL"
|
||||
mi_bits = REG178_MI_EXPORT_ENABLE if want_cutoff else REG178_MI_EXPORT_DISABLE
|
||||
else:
|
||||
mi_bits = int(current_178) & int(REG178_MI_EXPORT_MASK)
|
||||
|
||||
new_178 = (
|
||||
(int(current_178) & ~int(REG178_VERIFY_MASK_COMBINED))
|
||||
| int(peak_bits)
|
||||
| int(mi_bits)
|
||||
)
|
||||
registers.append((178, "control_board_special_1", int(new_178)))
|
||||
logger.info(
|
||||
"[control] %s: reg178 (control_board_special_1) old=%s new=%s peak_bits=0x%04X mi_bits=%s",
|
||||
inv.code,
|
||||
current_178,
|
||||
new_178,
|
||||
int(peak_bits),
|
||||
int(mi_bits),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[control] %s: reg178 RMW failed (skip reg178 write): %s", inv.code, e)
|
||||
|
||||
logger.info(
|
||||
"[control] %s: deye_mode=%s charge=%sA discharge=%sA "
|
||||
"reg142=%s reg145=%s export=%sW "
|
||||
"tp1=%s tp2=%s soc=%s%% (batt=%r grid=%sW)",
|
||||
inv.code,
|
||||
deye_mode,
|
||||
charge_a,
|
||||
discharge_a,
|
||||
selling_mode,
|
||||
solar_sell,
|
||||
export_limit,
|
||||
hh_cur,
|
||||
hh_nxt,
|
||||
soc_telemetry,
|
||||
raw_bat,
|
||||
grid_w,
|
||||
)
|
||||
|
||||
last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db)
|
||||
registers, skipped_unchanged = _drop_registers_matching_last_verified(
|
||||
registers, last_verified
|
||||
)
|
||||
if skipped_unchanged:
|
||||
logger.info(
|
||||
"[control] %s: skip %s registers (value equals last verified): %s",
|
||||
inv.code,
|
||||
len(skipped_unchanged),
|
||||
skipped_unchanged[:24],
|
||||
)
|
||||
if not registers:
|
||||
logger.info(
|
||||
"[control] %s: all Deye holding regs match last verified, no Modbus write",
|
||||
inv.code,
|
||||
)
|
||||
if need_inactive_tou:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_tou_inactive_write_prague_date = $1,
|
||||
deye_tou_inactive_signature = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
prague_date,
|
||||
inactive_sig,
|
||||
inv.id,
|
||||
)
|
||||
return (
|
||||
f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)"
|
||||
)
|
||||
|
||||
will_write_inactive = any(
|
||||
int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers
|
||||
)
|
||||
|
||||
cmd_ids = await create_modbus_commands(
|
||||
site_id,
|
||||
planning_run_id,
|
||||
"inverter",
|
||||
inv.id,
|
||||
inv.code,
|
||||
inv.host,
|
||||
inv.port,
|
||||
inv.unit_id,
|
||||
registers,
|
||||
db,
|
||||
deye_physical_mode=deye_mode,
|
||||
)
|
||||
if not await execute_modbus_commands(cmd_ids, db):
|
||||
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
|
||||
logger.info("[control] Inverter %s journal write OK", inv.code)
|
||||
|
||||
will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers)
|
||||
if will_write_time:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1,
|
||||
deye_last_system_time_sync_at = now()
|
||||
WHERE id = $2
|
||||
""",
|
||||
_prague_minute_start_utc(),
|
||||
inv.id,
|
||||
)
|
||||
|
||||
if need_inactive_tou or will_write_inactive:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_tou_inactive_write_prague_date = $1,
|
||||
deye_tou_inactive_signature = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
prague_date,
|
||||
inactive_sig,
|
||||
inv.id,
|
||||
)
|
||||
except Exception as e:
|
||||
return f"FAIL inverter: {inv.code}: {e}"
|
||||
|
||||
return (
|
||||
f"OK inverter: batt_w={raw_bat!r} "
|
||||
f"(time points + FC 0x10: 108/109/141/142/178/143/145/340 dle plánu)"
|
||||
)
|
||||
|
||||
|
||||
async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]:
|
||||
"""
|
||||
Živé čtení holding registrů Deye 108, 109, 141-145, 178, 191 a volitelně 340.
|
||||
"""
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
raise ValueError("no controllable Modbus inverter for site")
|
||||
|
||||
uid = int(inv.unit_id)
|
||||
client = await get_modbus_client(inv.host, inv.port)
|
||||
read_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with client.batch(uid) as mb:
|
||||
b108 = await mb.read_holding_registers(108, 2)
|
||||
b141 = await mb.read_holding_registers(141, 5)
|
||||
r178 = await mb.read_holding_registers(178, 1)
|
||||
r191 = await mb.read_holding_registers(191, 1)
|
||||
if inv.deye_reg340_pv_a_control_enabled:
|
||||
r340 = await mb.read_holding_registers(340, 1)
|
||||
else:
|
||||
r340 = None
|
||||
r108, r109 = b108[0], b108[1]
|
||||
r141, r142, r143 = b141[0], b141[1], b141[2]
|
||||
r145 = b141[4]
|
||||
r178 = r178[0]
|
||||
r191 = r191[0]
|
||||
r340v = int(r340[0]) if r340 is not None and len(r340) >= 1 else None
|
||||
except Exception:
|
||||
logger.exception("read_deye_registers_live site=%s failed", site_id)
|
||||
raise
|
||||
|
||||
return {
|
||||
"reg108_charge_a": int(r108),
|
||||
"reg109_discharge_a": int(r109),
|
||||
"reg141_energy_mode": int(r141),
|
||||
"reg142_limit_control": int(r142),
|
||||
"reg143_export_limit_w": int(r143),
|
||||
"reg145_solar_sell": int(r145),
|
||||
"reg178_peak_shaving_switch": int(r178),
|
||||
"reg178_control_board_special_1": int(r178),
|
||||
"reg178_mi_export_cutoff_bits": int(r178) & int(REG178_MI_EXPORT_MASK),
|
||||
"reg178_mi_export_cutoff_is_on": (int(r178) & int(REG178_MI_EXPORT_MASK))
|
||||
== int(REG178_MI_EXPORT_ENABLE),
|
||||
"reg191_peak_shaving_w": int(r191),
|
||||
"reg340_max_solar_power_w": r340v,
|
||||
"read_at": read_at.isoformat(),
|
||||
}
|
||||
243
backend/services/control/modbus_journal.py
Normal file
243
backend/services/control/modbus_journal.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Modbus command journal helpers pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.deye_helpers import DEYE_REGISTER_NAMES, _deye_reg178_verify_match
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _fetch_written_deye_clock_commands(
|
||||
site_id: int,
|
||||
asset_id: int,
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
db: asyncpg.Connection,
|
||||
) -> list[asyncpg.Record]:
|
||||
"""Všechny řádky journalu 62-64 ve stavu written pro daný invertor/endpoint."""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT * FROM ems.modbus_command
|
||||
WHERE site_id = $1
|
||||
AND asset_type = 'inverter'
|
||||
AND asset_id = $2
|
||||
AND device_host = $3
|
||||
AND device_port = $4
|
||||
AND device_unit_id = $5
|
||||
AND register IN (62, 63, 64)
|
||||
AND status = 'written'
|
||||
ORDER BY register
|
||||
""",
|
||||
site_id,
|
||||
asset_id,
|
||||
host,
|
||||
port,
|
||||
unit_id,
|
||||
)
|
||||
return list(rows)
|
||||
|
||||
|
||||
async def _fetch_last_verified_inverter_registers(
|
||||
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
|
||||
) -> dict[int, int]:
|
||||
"""
|
||||
Poslední hodnota na zařízení podle journalu (jen status verified).
|
||||
Slouží k přeskočení duplicitního zápisu stejné hodnoty.
|
||||
"""
|
||||
raw = await db.fetchval(
|
||||
"""
|
||||
select ems.fn_modbus_last_verified_map($1::int, $2::int)
|
||||
""",
|
||||
site_id,
|
||||
inverter_asset_id,
|
||||
)
|
||||
data = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
return {int(k): int(v) for k, v in data.items()}
|
||||
|
||||
|
||||
def _drop_registers_matching_last_verified(
|
||||
registers: list[tuple[int, str, int]],
|
||||
last_verified: dict[int, int],
|
||||
) -> tuple[list[tuple[int, str, int]], list[int]]:
|
||||
"""Vynechá položky s hodnotou shodnou s posledním ověřeným stavem."""
|
||||
out: list[tuple[int, str, int]] = []
|
||||
skipped: list[int] = []
|
||||
for reg, meta, val in registers:
|
||||
lv = last_verified.get(int(reg))
|
||||
if lv is not None:
|
||||
if int(reg) == 178 and _deye_reg178_verify_match(int(val), int(lv)):
|
||||
skipped.append(int(reg))
|
||||
continue
|
||||
if int(lv) == int(val):
|
||||
skipped.append(int(reg))
|
||||
continue
|
||||
out.append((reg, meta, val))
|
||||
return out, skipped
|
||||
|
||||
|
||||
async def create_modbus_commands(
|
||||
site_id: int,
|
||||
planning_run_id: int | None,
|
||||
asset_type: str,
|
||||
asset_id: int,
|
||||
asset_code: str,
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
registers: list[tuple[int, str, int]],
|
||||
db: asyncpg.Connection,
|
||||
deye_physical_mode: str | None = None,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Vytvoří záznamy v modbus_command pro sadu zápisů.
|
||||
Vrátí list command IDs.
|
||||
"""
|
||||
ids: list[int] = []
|
||||
for reg, _ignored_name, val in registers:
|
||||
register_name = DEYE_REGISTER_NAMES.get(reg, f"reg_{reg}")
|
||||
cmd_id = await db.fetchval(
|
||||
"""
|
||||
INSERT INTO ems.modbus_command
|
||||
(site_id, asset_type, asset_id, asset_code,
|
||||
device_host, device_port, device_unit_id,
|
||||
register, register_name, value_to_write,
|
||||
planning_run_id, status, deye_physical_mode)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12)
|
||||
RETURNING id
|
||||
""",
|
||||
site_id,
|
||||
asset_type,
|
||||
asset_id,
|
||||
asset_code,
|
||||
host,
|
||||
port,
|
||||
unit_id,
|
||||
reg,
|
||||
register_name,
|
||||
val,
|
||||
planning_run_id,
|
||||
deye_physical_mode,
|
||||
)
|
||||
if cmd_id is not None:
|
||||
ids.append(int(cmd_id))
|
||||
return ids
|
||||
|
||||
|
||||
def _modbus_command_contiguous_runs(cmds: list[asyncpg.Record]) -> list[list[asyncpg.Record]]:
|
||||
"""Seřadí podle adresy registru a rozdělí na souvislé úseky pro FC 0x10 / FC 3."""
|
||||
if not cmds:
|
||||
return []
|
||||
sorted_cmds = sorted(cmds, key=lambda c: int(c["register"]))
|
||||
runs: list[list[asyncpg.Record]] = []
|
||||
cur: list[asyncpg.Record] = [sorted_cmds[0]]
|
||||
for c in sorted_cmds[1:]:
|
||||
if int(c["register"]) == int(cur[-1]["register"]) + 1:
|
||||
cur.append(c)
|
||||
else:
|
||||
runs.append(cur)
|
||||
cur = [c]
|
||||
runs.append(cur)
|
||||
return runs
|
||||
|
||||
|
||||
async def execute_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
) -> bool:
|
||||
"""
|
||||
Zapíše příkazy z modbus_command do zařízení (FC 0x10 po souvislých blocích).
|
||||
Aktualizuje status na 'written' nebo 'failed'.
|
||||
"""
|
||||
max_retries = 3
|
||||
retry_delay = 0.5
|
||||
|
||||
rows: list[asyncpg.Record] = []
|
||||
for cmd_id in command_ids:
|
||||
cmd = await db.fetchrow(
|
||||
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
if cmd is not None:
|
||||
rows.append(cmd)
|
||||
|
||||
if not rows:
|
||||
return True
|
||||
|
||||
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
|
||||
for cmd in rows:
|
||||
by_gw[
|
||||
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
|
||||
].append(cmd)
|
||||
|
||||
all_ok = True
|
||||
for (host, port, unit), group in by_gw.items():
|
||||
client = await get_modbus_client(host, port)
|
||||
for run in _modbus_command_contiguous_runs(group):
|
||||
start_reg = int(run[0]["register"])
|
||||
values = [int(c["value_to_write"]) for c in run]
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
await client.write_registers(start_reg, values, unit)
|
||||
for cmd, val in zip(run, values):
|
||||
cid = int(cmd["id"])
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='written', value_written=$1, written_at=now(),
|
||||
attempt_count=attempt_count+1, error_msg=NULL
|
||||
WHERE id=$2
|
||||
""",
|
||||
val,
|
||||
cid,
|
||||
)
|
||||
logger.info(
|
||||
"[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)",
|
||||
cid,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
val,
|
||||
start_reg,
|
||||
attempt + 1,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
"Modbus batch write 0x%04X count=%s attempt %s failed: %s, retrying...",
|
||||
start_reg,
|
||||
len(values),
|
||||
attempt + 1,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
await client.force_disconnect()
|
||||
else:
|
||||
for cmd in run:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='failed', error_msg=$1,
|
||||
attempt_count=attempt_count+1
|
||||
WHERE id=$2
|
||||
""",
|
||||
str(e),
|
||||
int(cmd["id"]),
|
||||
)
|
||||
logger.error(
|
||||
"Modbus batch 0x%04X count=%s all %s attempts failed: %s",
|
||||
start_reg,
|
||||
len(values),
|
||||
max_retries,
|
||||
e,
|
||||
)
|
||||
all_ok = False
|
||||
|
||||
return all_ok
|
||||
74
backend/services/control/models.py
Normal file
74
backend/services/control/models.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Datové modely pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class InverterConfig:
|
||||
id: int
|
||||
code: str
|
||||
host: str
|
||||
port: int
|
||||
unit_id: int
|
||||
max_export_power_w: int | None
|
||||
max_import_power_w: int | None
|
||||
no_export: bool
|
||||
max_battery_charge_w: int | None
|
||||
max_battery_discharge_w: int | None
|
||||
min_soc_percent: int | None
|
||||
reserve_soc_percent: int | None
|
||||
max_soc_percent: int | None
|
||||
usable_capacity_wh: int | None
|
||||
max_charge_a: int
|
||||
max_discharge_a: int
|
||||
deye_last_system_time_sync_minute: datetime | None = None
|
||||
deye_last_system_time_sync_at: datetime | None = None
|
||||
deye_last_tou_inactive_write_prague_date: date | None = None
|
||||
deye_tou_inactive_signature: str | None = None
|
||||
deye_zero_export_mode: int = 1
|
||||
deye_gen_microinverter_cutoff_enabled: bool = False
|
||||
#: Součet nominal_power_wp controllable PV na invertoru; 0 = EMS nezapisuje reg 340.
|
||||
pv_a_cap_w: int = 0
|
||||
#: True = EMS smí řídit Deye reg 340 (max solar power); z SQL `fn_site_has_active_green_bonus_pv(site_id)`.
|
||||
deye_reg340_pv_a_control_enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlSetpoints:
|
||||
battery_w: int | None
|
||||
#: Tvrdý limit exportu do sítě v daném slotu (W), ne forecastová cílová hodnota.
|
||||
grid_export_limit: int
|
||||
ev1_current_a: int
|
||||
ev2_current_a: int
|
||||
heat_pump_enable: bool
|
||||
grid_setpoint_w: int
|
||||
ev1_power_w: int
|
||||
ev2_power_w: int
|
||||
target_soc_pct: int | None = None
|
||||
#: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE).
|
||||
deye_physical_mode: str | None = None
|
||||
#: True = zákaz exportu (BLOCK_EXPORT) pro daný slot.
|
||||
export_ban: bool = False
|
||||
#: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 178 bits0-1).
|
||||
deye_gen_cutoff_enabled: bool = False
|
||||
#: Efektivní vykupní cena slotu (Kč/kWh z plánu).
|
||||
effective_sell_price_czk_kwh: float | None = None
|
||||
#: True = reg 108/109 na 0 (PRESERVE - Deye baterii nepoužívá).
|
||||
lock_battery: bool = False
|
||||
#: Režim SELF_SUSTAIN.
|
||||
self_sustain_local_use: bool = False
|
||||
#: Deye reg 340 (max solar power, W). None = EMS reg 340 v tomto ticku neřeší.
|
||||
pv_a_allowed_w: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperatingModeInfo:
|
||||
mode_code: str
|
||||
battery_mode: str
|
||||
grid_mode: str
|
||||
ev_enabled: bool
|
||||
heat_pump_enabled_def: bool
|
||||
loxone_mode_value: int
|
||||
156
backend/services/control/orchestrator.py
Normal file
156
backend/services/control/orchestrator.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Top-level control export orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.inverter import write_inverter_setpoints
|
||||
from services.control.models import ControlSetpoints
|
||||
from services.control.outputs import (
|
||||
send_loxone_setpoints,
|
||||
write_ev_setpoints,
|
||||
write_heat_pump_setpoint,
|
||||
)
|
||||
from services.control.repository import (
|
||||
_fetch_max_charge_power_w,
|
||||
_fetch_operating_mode,
|
||||
_fetch_plan_row_for_slot_offset,
|
||||
_load_inverter_config,
|
||||
)
|
||||
from services.control.setpoints import _apply_price_failsafe_guard, _build_setpoints
|
||||
from services.signal_service import enqueue_site_signals
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
||||
mode = await _fetch_operating_mode(site_id, db)
|
||||
if mode is None:
|
||||
logger.warning("control export site=%s: no operating mode row", site_id)
|
||||
return
|
||||
|
||||
if mode.mode_code == "MANUAL":
|
||||
logger.info("control export site=%s: MANUAL, skip writes", site_id)
|
||||
return
|
||||
|
||||
try:
|
||||
inv_for_pv = await _load_inverter_config(site_id, db)
|
||||
cap_pv = int(inv_for_pv.pv_a_cap_w) if inv_for_pv is not None else 0
|
||||
reg340_en = (
|
||||
bool(inv_for_pv.deye_reg340_pv_a_control_enabled)
|
||||
if inv_for_pv is not None
|
||||
else False
|
||||
)
|
||||
pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0)
|
||||
pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1)
|
||||
sp_now = _build_setpoints(
|
||||
mode,
|
||||
pi_now,
|
||||
pv_a_cap_w=cap_pv,
|
||||
reg340_pv_a_control_enabled=reg340_en,
|
||||
)
|
||||
sp_next = _build_setpoints(
|
||||
mode,
|
||||
pi_next,
|
||||
pv_a_cap_w=cap_pv,
|
||||
reg340_pv_a_control_enabled=reg340_en,
|
||||
)
|
||||
|
||||
if mode.mode_code == "AUTO" and sp_now is None:
|
||||
if pi_now is None:
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO but no planning_interval for current slot, skip",
|
||||
site_id,
|
||||
)
|
||||
return
|
||||
|
||||
if sp_now is None:
|
||||
logger.warning(
|
||||
"control export site=%s: no setpoints for mode %s, skip",
|
||||
site_id,
|
||||
mode.mode_code,
|
||||
)
|
||||
return
|
||||
|
||||
if mode.mode_code == "CHARGE_CHEAP":
|
||||
max_ch = await _fetch_max_charge_power_w(site_id, db)
|
||||
pw = max(1, int(max_ch))
|
||||
sp_now = ControlSetpoints(
|
||||
battery_w=pw,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=pw,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
effective_sell_price_czk_kwh=None,
|
||||
)
|
||||
sp_next = sp_now
|
||||
else:
|
||||
sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now)
|
||||
if sp_next is not None:
|
||||
sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next)
|
||||
|
||||
planning_run_id = await db.fetchval(
|
||||
"""
|
||||
SELECT id FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if planning_run_id is not None:
|
||||
planning_run_id = int(planning_run_id)
|
||||
|
||||
try:
|
||||
inv_res = await write_inverter_setpoints(
|
||||
site_id, sp_now, sp_next, db, planning_run_id=planning_run_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("inverter write failed: %s", e)
|
||||
inv_res = f"FAIL inverter: {e}"
|
||||
|
||||
try:
|
||||
ev_res = await write_ev_setpoints(site_id, sp_now, db)
|
||||
except Exception as e:
|
||||
logger.error("ev write failed: %s", e)
|
||||
ev_res = f"FAIL ev: {e}"
|
||||
|
||||
try:
|
||||
hp_res = await write_heat_pump_setpoint(site_id, sp_now, db)
|
||||
except Exception as e:
|
||||
logger.error("hp write failed: %s", e)
|
||||
hp_res = f"FAIL heat pump: {e}"
|
||||
|
||||
try:
|
||||
lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db)
|
||||
except Exception as e:
|
||||
logger.error("loxone write failed: %s", e)
|
||||
lox_res = f"FAIL Loxone: {e}"
|
||||
|
||||
results = list(
|
||||
zip(
|
||||
("inverter", "ev", "heat_pump", "loxone"),
|
||||
(inv_res, ev_res, hp_res, lox_res),
|
||||
)
|
||||
)
|
||||
|
||||
for name, res in results:
|
||||
if isinstance(res, Exception):
|
||||
logger.error("control export site=%s %s: FAIL %s", site_id, name, res)
|
||||
elif isinstance(res, str) and res.startswith("FAIL"):
|
||||
logger.error("control export site=%s %s: %s", site_id, name, res)
|
||||
else:
|
||||
logger.info("control export site=%s %s: %s", site_id, name, res)
|
||||
finally:
|
||||
try:
|
||||
await enqueue_site_signals(site_id, db)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"control export site=%s: signal enqueue failed: %s", site_id, e
|
||||
)
|
||||
149
backend/services/control/outputs.py
Normal file
149
backend/services/control/outputs.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Non-Deye output writers for control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from services.control.models import ControlSetpoints, OperatingModeInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
|
||||
c = (charger_code or "").strip().lower()
|
||||
if c == "ev-charger-1":
|
||||
a = sp.ev1_current_a
|
||||
elif c == "ev-charger-2":
|
||||
a = sp.ev2_current_a
|
||||
elif c.endswith("-1") or c == "ev1":
|
||||
a = sp.ev1_current_a
|
||||
elif c.endswith("-2") or c == "ev2":
|
||||
a = sp.ev2_current_a
|
||||
else:
|
||||
a = 0
|
||||
if a < 6:
|
||||
a = 0
|
||||
return a
|
||||
|
||||
|
||||
async def write_ev_setpoints(
|
||||
site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection
|
||||
) -> str:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ec.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_ev_charger ec
|
||||
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||
WHERE ec.site_id = $1
|
||||
AND ec.schedulable = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
ORDER BY ec.code
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not rows:
|
||||
return "OK EV: no schedulable chargers"
|
||||
|
||||
for row in rows:
|
||||
code = row["code"]
|
||||
current_a = _current_limit_for_charger(code, setpoints)
|
||||
logger.info(
|
||||
"EV setpoint [%s]: %sA (TODO: Modbus registers)",
|
||||
code,
|
||||
current_a,
|
||||
)
|
||||
return f"OK EV: logged {len(rows)} charger(s) (Modbus TODO)"
|
||||
|
||||
|
||||
async def write_heat_pump_setpoint(
|
||||
site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection
|
||||
) -> str:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT hp.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_heat_pump hp
|
||||
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||||
WHERE hp.site_id = $1
|
||||
AND hp.schedulable = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not rows:
|
||||
return "OK heat pump: no schedulable unit"
|
||||
for row in rows:
|
||||
logger.info(
|
||||
"HP setpoint [%s]: enable=%s (TODO: Modbus registers)",
|
||||
row["code"],
|
||||
setpoints.heat_pump_enable,
|
||||
)
|
||||
return "OK heat pump: logged (Modbus TODO)"
|
||||
|
||||
|
||||
async def send_loxone_setpoints(
|
||||
site_id: int,
|
||||
setpoints: ControlSetpoints,
|
||||
mode: OperatingModeInfo,
|
||||
db: asyncpg.Connection,
|
||||
) -> str:
|
||||
endpoint = await db.fetchrow(
|
||||
"""
|
||||
SELECT host, port, protocol
|
||||
FROM ems.site_endpoint
|
||||
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not endpoint:
|
||||
return "OK Loxone: no endpoint, skipped"
|
||||
|
||||
proto = (endpoint["protocol"] or "http").lower()
|
||||
if proto not in ("http", "https"):
|
||||
proto = "http"
|
||||
host = endpoint["host"]
|
||||
port = int(endpoint["port"] or (443 if proto == "https" else 80))
|
||||
base = f"{proto}://{host}:{port}/dev/sps/io"
|
||||
|
||||
settings = get_settings()
|
||||
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
|
||||
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
|
||||
auth = (user, password) if user else None
|
||||
|
||||
batt_display = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
|
||||
|
||||
paths: list[tuple[str, int]] = [
|
||||
(f"{base}/EMS_Mode/{mode.loxone_mode_value}", mode.loxone_mode_value),
|
||||
(f"{base}/EMS_Battery_Setpoint_W/{batt_display}", batt_display),
|
||||
(f"{base}/EMS_Grid_Setpoint_W/{setpoints.grid_setpoint_w}", setpoints.grid_setpoint_w),
|
||||
(f"{base}/EMS_EV1_Power_W/{setpoints.ev1_power_w}", setpoints.ev1_power_w),
|
||||
(f"{base}/EMS_EV2_Power_W/{setpoints.ev2_power_w}", setpoints.ev2_power_w),
|
||||
(
|
||||
f"{base}/EMS_HeatPump_Enable/{1 if setpoints.heat_pump_enable else 0}",
|
||||
1 if setpoints.heat_pump_enable else 0,
|
||||
),
|
||||
]
|
||||
|
||||
errs: list[str] = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
for url, _ in paths:
|
||||
try:
|
||||
r = await client.get(url, auth=auth)
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
errs.append(f"{url!s}: {e}")
|
||||
except Exception as e:
|
||||
return f"FAIL Loxone: client {e}"
|
||||
|
||||
if errs:
|
||||
return "FAIL Loxone: " + "; ".join(errs[:3])
|
||||
return "OK Loxone: all virtual inputs updated"
|
||||
215
backend/services/control/repository.py
Normal file
215
backend/services/control/repository.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""DB načítání pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.deye_helpers import DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A
|
||||
from services.control.models import InverterConfig, OperatingModeInfo
|
||||
from services.control.setpoints import _DictRecord
|
||||
|
||||
|
||||
async def _fetch_operating_mode(
|
||||
site_id: int, db: asyncpg.Connection
|
||||
) -> OperatingModeInfo | None:
|
||||
sql = """
|
||||
SELECT som.mode_code, omd.battery_mode, omd.grid_mode,
|
||||
omd.ev_enabled, omd.heat_pump_enabled, omd.loxone_mode_value,
|
||||
som.valid_until
|
||||
FROM ems.site_operating_mode som
|
||||
JOIN ems.operating_mode_def omd ON omd.code = som.mode_code
|
||||
WHERE som.site_id = $1
|
||||
"""
|
||||
row = await db.fetchrow(sql, site_id)
|
||||
if row is None:
|
||||
return None
|
||||
vu = row["valid_until"]
|
||||
if vu is not None:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if vu.tzinfo is None:
|
||||
vu = vu.replace(tzinfo=timezone.utc)
|
||||
if vu <= now_utc:
|
||||
exp_rows = await db.fetch("SELECT * FROM ems.fn_expire_modes()")
|
||||
from services.notification_service import notify_operating_mode_changed
|
||||
|
||||
for er in exp_rows:
|
||||
await notify_operating_mode_changed(
|
||||
str(er["site_code"]),
|
||||
str(er["old_mode"]),
|
||||
str(er["new_mode"]),
|
||||
"system:expiry",
|
||||
"Automatické vypršení dočasného režimu",
|
||||
)
|
||||
row = await db.fetchrow(sql, site_id)
|
||||
if row is None:
|
||||
return None
|
||||
return OperatingModeInfo(
|
||||
mode_code=row["mode_code"],
|
||||
battery_mode=row["battery_mode"],
|
||||
grid_mode=row["grid_mode"],
|
||||
ev_enabled=bool(row["ev_enabled"]),
|
||||
heat_pump_enabled_def=bool(row["heat_pump_enabled"]),
|
||||
loxone_mode_value=int(row["loxone_mode_value"]),
|
||||
)
|
||||
|
||||
|
||||
async def _get_current_soc(site_id: int, db: asyncpg.Connection) -> int:
|
||||
soc = await db.fetchval(
|
||||
"""
|
||||
SELECT battery_soc_percent
|
||||
FROM ems.telemetry_inverter
|
||||
WHERE site_id = $1 AND battery_soc_percent IS NOT NULL
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
return int(soc) if soc is not None else 50
|
||||
|
||||
|
||||
async def _load_inverter_config(
|
||||
site_id: int, db: asyncpg.Connection
|
||||
) -> InverterConfig | None:
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
ai.id, ai.code,
|
||||
coalesce(ems.fn_inverter_pv_a_max_w(ai.id), 0) AS pv_a_cap_w,
|
||||
se.host, se.port, se.unit_id,
|
||||
sgc.max_export_power_w,
|
||||
sgc.max_import_power_w,
|
||||
sgc.no_export,
|
||||
ai.max_battery_charge_w,
|
||||
ai.max_battery_discharge_w,
|
||||
ab.min_soc_percent,
|
||||
ab.reserve_soc_percent,
|
||||
ab.max_soc_percent,
|
||||
ab.usable_capacity_wh,
|
||||
ai.deye_last_system_time_sync_minute,
|
||||
ai.deye_last_system_time_sync_at,
|
||||
ai.deye_last_tou_inactive_write_prague_date,
|
||||
ai.deye_tou_inactive_signature,
|
||||
COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode,
|
||||
COALESCE(ai.deye_gen_microinverter_cutoff_enabled, false) AS deye_gen_microinverter_cutoff_enabled,
|
||||
coalesce(ems.fn_site_has_active_green_bonus_pv(ai.site_id), false)
|
||||
AS deye_reg340_pv_a_control_enabled,
|
||||
COALESCE(
|
||||
ai.deye_register_max_charge_a,
|
||||
FLOOR(
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
|
||||
ai.max_battery_charge_w
|
||||
)::numeric / 51.2
|
||||
)::int
|
||||
) AS max_charge_a,
|
||||
COALESCE(
|
||||
ai.deye_register_max_discharge_a,
|
||||
FLOOR(
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w),
|
||||
ai.max_battery_discharge_w
|
||||
)::numeric / 51.2
|
||||
)::int
|
||||
) AS max_discharge_a
|
||||
FROM ems.asset_inverter ai
|
||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||
JOIN ems.asset_battery ab ON ab.inverter_id = ai.id
|
||||
LEFT JOIN ems.site_grid_connection sgc ON sgc.site_id = ai.site_id
|
||||
WHERE ai.site_id = $1
|
||||
AND ai.active = true
|
||||
AND ai.controllable = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
ORDER BY ai.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
mc = row["max_charge_a"]
|
||||
md = row["max_discharge_a"]
|
||||
max_charge_a = int(mc) if mc is not None else 0
|
||||
max_discharge_a = int(md) if md is not None else 0
|
||||
max_charge_a = min(max_charge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
|
||||
max_discharge_a = min(max_discharge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
|
||||
port = int(row["port"] or 502)
|
||||
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||
return InverterConfig(
|
||||
id=int(row["id"]),
|
||||
code=row["code"],
|
||||
host=row["host"],
|
||||
port=port,
|
||||
unit_id=uid,
|
||||
max_export_power_w=int(row["max_export_power_w"])
|
||||
if row["max_export_power_w"] is not None
|
||||
else None,
|
||||
max_import_power_w=int(row["max_import_power_w"])
|
||||
if row["max_import_power_w"] is not None
|
||||
else None,
|
||||
no_export=bool(row["no_export"] or False),
|
||||
max_battery_charge_w=int(row["max_battery_charge_w"])
|
||||
if row["max_battery_charge_w"] is not None
|
||||
else None,
|
||||
max_battery_discharge_w=int(row["max_battery_discharge_w"])
|
||||
if row["max_battery_discharge_w"] is not None
|
||||
else None,
|
||||
min_soc_percent=int(round(float(row["min_soc_percent"])))
|
||||
if row["min_soc_percent"] is not None
|
||||
else None,
|
||||
reserve_soc_percent=int(row["reserve_soc_percent"])
|
||||
if row["reserve_soc_percent"] is not None
|
||||
else None,
|
||||
max_soc_percent=int(row["max_soc_percent"])
|
||||
if row["max_soc_percent"] is not None
|
||||
else None,
|
||||
usable_capacity_wh=int(row["usable_capacity_wh"])
|
||||
if row["usable_capacity_wh"] is not None
|
||||
else None,
|
||||
max_charge_a=max_charge_a,
|
||||
max_discharge_a=max_discharge_a,
|
||||
deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"],
|
||||
deye_last_system_time_sync_at=row["deye_last_system_time_sync_at"],
|
||||
deye_last_tou_inactive_write_prague_date=row[
|
||||
"deye_last_tou_inactive_write_prague_date"
|
||||
],
|
||||
deye_tou_inactive_signature=row["deye_tou_inactive_signature"],
|
||||
deye_zero_export_mode=int(row["deye_zero_export_mode"]),
|
||||
deye_gen_microinverter_cutoff_enabled=bool(
|
||||
row["deye_gen_microinverter_cutoff_enabled"] or False
|
||||
),
|
||||
pv_a_cap_w=int(row["pv_a_cap_w"] or 0),
|
||||
deye_reg340_pv_a_control_enabled=bool(
|
||||
row["deye_reg340_pv_a_control_enabled"] or False
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_plan_row_for_slot_offset(
|
||||
site_id: int, db: asyncpg.Connection, slot_offset: int
|
||||
) -> asyncpg.Record | None:
|
||||
"""Řádek plánu pro slot z ems.fn_planning_interval_at_offset (jsonb -> Record-like dict)."""
|
||||
raw = await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_interval_at_offset($1::int, $2::int)
|
||||
""",
|
||||
site_id,
|
||||
slot_offset,
|
||||
)
|
||||
if raw is None:
|
||||
return None
|
||||
data = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
if not data:
|
||||
return None
|
||||
return _DictRecord(data)
|
||||
|
||||
|
||||
async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int:
|
||||
v = await db.fetchval(
|
||||
"select ems.fn_planning_max_effective_charge_w($1::int)",
|
||||
site_id,
|
||||
)
|
||||
return int(v or 0)
|
||||
332
backend/services/control/setpoints.py
Normal file
332
backend/services/control/setpoints.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Výpočet control setpointů a Deye TOU parametrů."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from services.control.deye_helpers import (
|
||||
BATT_VOLTAGE_V,
|
||||
PRAGUE_TZ,
|
||||
battery_watts_to_amps,
|
||||
compute_pv_a_reg340_max_solar_w,
|
||||
watts_to_amps,
|
||||
)
|
||||
from services.control.models import ControlSetpoints, InverterConfig, OperatingModeInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]:
|
||||
"""Hodnoty pro reg 62-64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis)."""
|
||||
now = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
|
||||
reg62 = ((now.year - 2000) << 8) | now.month
|
||||
reg63 = (now.day << 8) | now.hour
|
||||
reg64 = (now.minute << 8) | 0
|
||||
rows = [
|
||||
(62, "", reg62),
|
||||
(63, "", reg63),
|
||||
(64, "", reg64),
|
||||
]
|
||||
return now, rows
|
||||
|
||||
|
||||
def _deye_time_point_rows(
|
||||
slot_index: int,
|
||||
time_hhmm: int,
|
||||
power_w: int,
|
||||
soc_pct: int,
|
||||
grid_charge: bool,
|
||||
) -> list[tuple[int, str, int]]:
|
||||
g = 1 if grid_charge else 0
|
||||
return [
|
||||
(148 + slot_index, "", time_hhmm),
|
||||
(154 + slot_index, "", power_w),
|
||||
(166 + slot_index, "", soc_pct),
|
||||
(172 + slot_index, "", g),
|
||||
]
|
||||
|
||||
|
||||
class _DictRecord:
|
||||
"""Minimální asyncpg Record kompatibilita pro dict z jsonb."""
|
||||
|
||||
__slots__ = ("_d",)
|
||||
|
||||
def __init__(self, d: dict[str, Any]) -> None:
|
||||
self._d = d
|
||||
|
||||
def __getitem__(self, k: str) -> Any:
|
||||
return self._d[k]
|
||||
|
||||
def get(self, k: str, default: Any = None) -> Any:
|
||||
return self._d.get(k, default)
|
||||
|
||||
def __contains__(self, k: str) -> bool:
|
||||
return k in self._d
|
||||
|
||||
|
||||
def _build_setpoints(
|
||||
mode: OperatingModeInfo,
|
||||
pi: Any | None,
|
||||
*,
|
||||
pv_a_cap_w: int = 0,
|
||||
reg340_pv_a_control_enabled: bool = False,
|
||||
) -> ControlSetpoints | None:
|
||||
code = mode.mode_code
|
||||
if code == "MANUAL":
|
||||
return None
|
||||
|
||||
if code == "AUTO":
|
||||
if pi is None:
|
||||
return None
|
||||
grid_sp = int(pi["grid_setpoint_w"] or 0)
|
||||
export_limit_raw = pi.get("export_limit_w")
|
||||
export_limit = int(export_limit_raw) if export_limit_raw is not None else abs(min(grid_sp, 0))
|
||||
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
|
||||
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
|
||||
hp_en = bool(pi["heat_pump_enabled"])
|
||||
tgt = pi["battery_soc_target_pct"]
|
||||
target_soc = int(round(float(tgt))) if tgt is not None else None
|
||||
pm_raw = pi.get("deye_physical_mode")
|
||||
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
|
||||
export_mode_raw = pi.get("export_mode")
|
||||
export_mode = str(export_mode_raw).strip().upper() if export_mode_raw is not None else None
|
||||
if export_mode == "NONE":
|
||||
export_limit = 0
|
||||
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá.
|
||||
export_ban = sell_f is not None and float(sell_f) < 0 and grid_sp >= 0
|
||||
gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled")
|
||||
gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False
|
||||
pv_a_allowed: int | None = None
|
||||
if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0:
|
||||
forecast = int(pi.get("pv_a_forecast_solver_w") or 0)
|
||||
curtail = int(pi.get("pv_a_curtailed_w") or 0)
|
||||
pv_a_allowed = compute_pv_a_reg340_max_solar_w(int(pv_a_cap_w), forecast, curtail)
|
||||
buy_raw = pi.get("effective_buy_price")
|
||||
buy_f: float | None = float(buy_raw) if buy_raw is not None else None
|
||||
pv_b = int(pi.get("pv_b_forecast_solver_w") or 0)
|
||||
if (
|
||||
buy_f is not None
|
||||
and sell_f is not None
|
||||
and float(buy_f) < 0.0
|
||||
and float(sell_f) < 0.0
|
||||
and pv_b > 0
|
||||
):
|
||||
pv_a_allowed = 0
|
||||
return ControlSetpoints(
|
||||
battery_w=int(pi["battery_setpoint_w"] or 0),
|
||||
grid_export_limit=max(0, export_limit),
|
||||
ev1_current_a=watts_to_amps(ev1_w, phases=3),
|
||||
ev2_current_a=watts_to_amps(ev2_w, phases=1),
|
||||
heat_pump_enable=hp_en,
|
||||
grid_setpoint_w=grid_sp,
|
||||
ev1_power_w=ev1_w,
|
||||
ev2_power_w=ev2_w,
|
||||
target_soc_pct=target_soc,
|
||||
deye_physical_mode=pm,
|
||||
export_ban=bool(export_ban),
|
||||
deye_gen_cutoff_enabled=bool(gen_cutoff),
|
||||
effective_sell_price_czk_kwh=sell_f,
|
||||
pv_a_allowed_w=pv_a_allowed,
|
||||
)
|
||||
|
||||
if code == "SELF_SUSTAIN":
|
||||
return ControlSetpoints(
|
||||
battery_w=None,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
self_sustain_local_use=True,
|
||||
)
|
||||
|
||||
if code == "CHARGE_CHEAP":
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
)
|
||||
|
||||
if code == "PRESERVE":
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
lock_battery=True,
|
||||
)
|
||||
|
||||
logger.warning("Unknown mode_code %s for site export, skipping", code)
|
||||
return None
|
||||
|
||||
|
||||
def _apply_price_failsafe_guard(
|
||||
site_id: int,
|
||||
mode: OperatingModeInfo,
|
||||
pi: Any | None,
|
||||
sp: ControlSetpoints,
|
||||
) -> ControlSetpoints:
|
||||
if mode.mode_code != "AUTO" or pi is None:
|
||||
return sp
|
||||
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
|
||||
return sp
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
|
||||
site_id,
|
||||
)
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=sp.ev1_current_a,
|
||||
ev2_current_a=sp.ev2_current_a,
|
||||
heat_pump_enable=sp.heat_pump_enable,
|
||||
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
|
||||
ev1_power_w=sp.ev1_power_w,
|
||||
ev2_power_w=sp.ev2_power_w,
|
||||
target_soc_pct=sp.target_soc_pct,
|
||||
effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh,
|
||||
pv_a_allowed_w=sp.pv_a_allowed_w,
|
||||
)
|
||||
|
||||
|
||||
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
|
||||
"""Reg 143 - max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
|
||||
if no_export:
|
||||
return 0
|
||||
return max(0, int(max_export_power_w or 0))
|
||||
|
||||
|
||||
def _clamp_deye_tou_soc_pct(pct: int) -> int:
|
||||
return max(5, min(95, pct))
|
||||
|
||||
|
||||
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
|
||||
if inv.min_soc_percent is not None:
|
||||
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
|
||||
return 10
|
||||
|
||||
|
||||
def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
|
||||
if inv.reserve_soc_percent is not None:
|
||||
return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent))
|
||||
return 20
|
||||
|
||||
|
||||
def _deye_passive_tou_battery_soc_pct(
|
||||
inv: InverterConfig, _setpoints: ControlSetpoints
|
||||
) -> int:
|
||||
"""Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE."""
|
||||
return _deye_tou_min_soc_pct(inv)
|
||||
|
||||
|
||||
def _deye_zero_export_amps_for_passive(
|
||||
grid_w: int,
|
||||
bat_w: int,
|
||||
max_charge_a: int,
|
||||
max_discharge_a: int,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
PASSIVE (zero export k CT/zátěži): asymetrie jen tam, kde dává smysl pro import.
|
||||
|
||||
Export do sítě (grid_w < 0) už směr toku řeší režim / 142 / 145 — **108** jako strop zbytečně
|
||||
nenulovat na 0 (viz home-01). Jediná speciální větev: import bez nabíjení → vypnout vybíjení.
|
||||
"""
|
||||
if grid_w > 0 and bat_w <= 0:
|
||||
return max_charge_a, 0
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
|
||||
|
||||
def deye_battery_charge_discharge_amps(
|
||||
*,
|
||||
lock_battery: bool,
|
||||
deye_mode: str,
|
||||
self_sustain_local_use: bool,
|
||||
bat_w: int,
|
||||
grid_w: int,
|
||||
max_charge_a: int,
|
||||
max_discharge_a: int,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
|
||||
|
||||
PASSIVE + plán chce nabíjet (typicky z FVE, i při exportu zbytku do sítě): **108 = max_charge_a**
|
||||
z invertoru — reg. 108 je strop, ne příkaz k proudu; průměrný `battery_w` ze slotu nesmí špičku FVE
|
||||
stíhat do baterie omezovat. **109 = max_discharge_a** (domácnost z baterie při výpadku PV).
|
||||
**CHARGE** (import ze sítě + nabíjení): 108 dál z `battery_w` (řízený importní okamžik). **SELL** beze změny.
|
||||
"""
|
||||
if lock_battery:
|
||||
return 0, 0
|
||||
if deye_mode == "CHARGE":
|
||||
return battery_watts_to_amps(bat_w, max_charge_a), 0
|
||||
if deye_mode == "SELL":
|
||||
return 0, int(max_discharge_a)
|
||||
if self_sustain_local_use:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
if bat_w > 0:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
return _deye_zero_export_amps_for_passive(
|
||||
grid_w, bat_w, int(max_charge_a), int(max_discharge_a)
|
||||
)
|
||||
|
||||
|
||||
def get_deye_mode(setpoints: ControlSetpoints) -> str:
|
||||
"""Fyzický režim Deye: SELL | CHARGE | PASSIVE."""
|
||||
pm = (setpoints.deye_physical_mode or "").strip().upper()
|
||||
if pm in {"PASSIVE", "SELL", "CHARGE"}:
|
||||
return pm
|
||||
|
||||
grid_w = int(setpoints.grid_setpoint_w or 0)
|
||||
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
|
||||
|
||||
if bat_w > 0 and grid_w > 0:
|
||||
return "CHARGE"
|
||||
|
||||
if grid_w < 0 and bat_w < 0:
|
||||
return "SELL"
|
||||
|
||||
return "PASSIVE"
|
||||
|
||||
|
||||
def _deye_tou_params(
|
||||
setpoints: ControlSetpoints,
|
||||
inv: InverterConfig,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge."""
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
|
||||
tou_min = _deye_tou_min_soc_pct(inv)
|
||||
tou_reserve = _deye_tou_reserve_soc_pct(inv)
|
||||
if setpoints.lock_battery:
|
||||
return tp_discharge_w, tou_min, False
|
||||
deye_mode = get_deye_mode(setpoints)
|
||||
if deye_mode == "CHARGE":
|
||||
raw_bat = setpoints.battery_w
|
||||
battery_w = int(raw_bat) if raw_bat is not None else 0
|
||||
cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95
|
||||
target_soc = max(10, min(100, cap))
|
||||
tp_charge_w = (
|
||||
battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V)
|
||||
)
|
||||
return tp_charge_w, target_soc, True
|
||||
if deye_mode == "SELL":
|
||||
return tp_discharge_w, tou_reserve, False
|
||||
tou_soc = _deye_passive_tou_battery_soc_pct(inv, setpoints)
|
||||
return tp_discharge_w, tou_soc, False
|
||||
476
backend/services/control/verify.py
Normal file
476
backend/services/control/verify.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""Modbus verify workflow pro control export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.control.deye_helpers import (
|
||||
DEYE_CLOCK_REGS,
|
||||
DEYE_TOU_POWER_REGS,
|
||||
REG178_VERIFY_MASK,
|
||||
_deye_clock_registers_verify_match,
|
||||
_deye_reg178_verify_match,
|
||||
_deye_reg178_verify_with_double_read,
|
||||
_deye_tou_power_verify_match,
|
||||
_prague_minute_start_utc,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||
)
|
||||
from services.control.modbus_journal import (
|
||||
_fetch_last_verified_inverter_registers,
|
||||
_fetch_written_deye_clock_commands,
|
||||
_modbus_command_contiguous_runs,
|
||||
execute_modbus_commands,
|
||||
)
|
||||
from services.control.repository import _load_inverter_config
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None:
|
||||
"""Přepne lokalitu na SELF_SUSTAIN, zaloguje důvod a při změně pošle Discord."""
|
||||
from services.notification_service import run_fn_set_mode_with_discord
|
||||
|
||||
await run_fn_set_mode_with_discord(
|
||||
db,
|
||||
site_id,
|
||||
"SELF_SUSTAIN",
|
||||
"system:mismatch",
|
||||
None,
|
||||
reason,
|
||||
)
|
||||
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
|
||||
|
||||
|
||||
def _modbus_cmd_register(cmd: Any) -> int:
|
||||
"""asyncpg.Record má __getitem__; objekty s atributem .register též (testy)."""
|
||||
try:
|
||||
return int(cmd["register"])
|
||||
except (KeyError, TypeError):
|
||||
return int(cmd.register)
|
||||
|
||||
|
||||
def _deye_expected_clock_triplet_for_verify(
|
||||
bundle: list[asyncpg.Record],
|
||||
last_verified: dict[int, int],
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
) -> tuple[int, int, int]:
|
||||
"""
|
||||
Sestaví očekávané (w62,w63,w64) pro toleranční verify.
|
||||
Chybějící registry doplní poslední verified nebo aktuálním přečtením ze zařízení.
|
||||
"""
|
||||
by_reg = {_modbus_cmd_register(c): c for c in bundle}
|
||||
|
||||
def _vtw(c: Any) -> int:
|
||||
try:
|
||||
return int(c["value_to_write"])
|
||||
except (KeyError, TypeError):
|
||||
return int(c.value_to_write)
|
||||
|
||||
w62 = _vtw(by_reg[62]) if 62 in by_reg else last_verified.get(62, a62)
|
||||
w63 = _vtw(by_reg[63]) if 63 in by_reg else last_verified.get(63, a63)
|
||||
w64 = _vtw(by_reg[64]) if 64 in by_reg else last_verified.get(64, a64)
|
||||
return (int(w62), int(w63), int(w64))
|
||||
|
||||
|
||||
async def _verify_deye_clock_written_bundle(
|
||||
site_id: int,
|
||||
bundle: list[asyncpg.Record],
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
db: asyncpg.Connection,
|
||||
) -> bool:
|
||||
"""
|
||||
Toleranční ověření pro jeden až tři řádky journalu 62-64 ve stavu written.
|
||||
Při mismatch retry společně; bez přepnutí do SELF_SUSTAIN po 3 pokusech.
|
||||
"""
|
||||
from services.notification_service import (
|
||||
notify_modbus_clock_verify_exhausted,
|
||||
notify_modbus_mismatch,
|
||||
)
|
||||
|
||||
cmds_s = sorted(bundle, key=_modbus_cmd_register)
|
||||
try:
|
||||
asset_id = int(cmds_s[0]["asset_id"])
|
||||
except (KeyError, TypeError):
|
||||
asset_id = int(cmds_s[0].asset_id)
|
||||
last_v = await _fetch_last_verified_inverter_registers(site_id, asset_id, db)
|
||||
w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last_v, a62, a63, a64)
|
||||
clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64)
|
||||
actual_by_reg = {62: a62, 63: a63, 64: a64}
|
||||
|
||||
for cmd in cmds_s:
|
||||
try:
|
||||
cid = int(cmd["id"])
|
||||
except (KeyError, TypeError):
|
||||
cid = int(cmd.id)
|
||||
r = _modbus_cmd_register(cmd)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET value_verified=$1::int, verified_at=now(),
|
||||
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
|
||||
WHERE id=$3::int
|
||||
""",
|
||||
actual_by_reg[r],
|
||||
clock_ok,
|
||||
cid,
|
||||
)
|
||||
|
||||
if clock_ok:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1,
|
||||
deye_last_system_time_sync_at = now()
|
||||
WHERE id = $2
|
||||
""",
|
||||
_prague_minute_start_utc(),
|
||||
asset_id,
|
||||
)
|
||||
for cmd in cmds_s:
|
||||
try:
|
||||
cid_l = int(cmd["id"])
|
||||
except (KeyError, TypeError):
|
||||
cid_l = int(cmd.id)
|
||||
try:
|
||||
code_l = str(cmd["asset_code"])
|
||||
except (KeyError, TypeError):
|
||||
code_l = str(cmd.asset_code)
|
||||
rr = _modbus_cmd_register(cmd)
|
||||
logger.info(
|
||||
"[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s",
|
||||
cid_l,
|
||||
code_l,
|
||||
rr,
|
||||
actual_by_reg[rr],
|
||||
)
|
||||
return True
|
||||
|
||||
cmd0 = cmds_s[0]
|
||||
try:
|
||||
ac0 = str(cmd0["asset_code"])
|
||||
except (KeyError, TypeError):
|
||||
ac0 = str(cmd0.asset_code)
|
||||
logger.error(
|
||||
"[cmd clock] MISMATCH %s 62-64: written=(%s,%s,%s) actual=(%s,%s,%s)",
|
||||
ac0,
|
||||
w62,
|
||||
w63,
|
||||
w64,
|
||||
a62,
|
||||
a63,
|
||||
a64,
|
||||
)
|
||||
|
||||
attempts = 0
|
||||
for cmd in cmds_s:
|
||||
try:
|
||||
cid_q = int(cmd["id"])
|
||||
except (KeyError, TypeError):
|
||||
cid_q = int(cmd.id)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cid_q
|
||||
)
|
||||
ac = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||
attempts = max(attempts, ac)
|
||||
|
||||
await notify_modbus_mismatch(
|
||||
db,
|
||||
site_id,
|
||||
ac0,
|
||||
62,
|
||||
"system_time_62_64",
|
||||
w62,
|
||||
a62,
|
||||
attempts,
|
||||
)
|
||||
|
||||
ids_ordered = []
|
||||
for c in cmds_s:
|
||||
try:
|
||||
ids_ordered.append(int(c["id"]))
|
||||
except (KeyError, TypeError):
|
||||
ids_ordered.append(int(c.id))
|
||||
if attempts < 3:
|
||||
for cid in ids_ordered:
|
||||
await db.execute(
|
||||
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
|
||||
cid,
|
||||
)
|
||||
await execute_modbus_commands(ids_ordered, db)
|
||||
await verify_modbus_commands(ids_ordered, db, site_id)
|
||||
else:
|
||||
logger.critical(
|
||||
"[cmd clock] 3 failed verify attempts (62-64); režim se nemění automaticky"
|
||||
)
|
||||
site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id)
|
||||
await notify_modbus_clock_verify_exhausted(
|
||||
db,
|
||||
site_id,
|
||||
site["code"] if site else str(site_id),
|
||||
ac0,
|
||||
(w62, w63, w64),
|
||||
(a62, a63, a64),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def verify_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
site_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write.
|
||||
Při mismatch řeší retry a po vyčerpání kritických registrů SELF_SUSTAIN.
|
||||
"""
|
||||
from services.notification_service import notify_modbus_mismatch
|
||||
|
||||
inv_cfg = await _load_inverter_config(site_id, db)
|
||||
|
||||
async def _apply_verify_result(
|
||||
cmd: asyncpg.Record,
|
||||
actual_i: int,
|
||||
*,
|
||||
client: Any,
|
||||
unit: int,
|
||||
) -> bool:
|
||||
reg = int(cmd["register"])
|
||||
cmd_id = int(cmd["id"])
|
||||
|
||||
if reg in DEYE_CLOCK_REGS:
|
||||
asset_id = int(cmd["asset_id"])
|
||||
host = str(cmd["device_host"])
|
||||
port_i = int(cmd["device_port"])
|
||||
uid = int(cmd["device_unit_id"])
|
||||
bundle = await _fetch_written_deye_clock_commands(
|
||||
site_id, asset_id, host, port_i, uid, db
|
||||
)
|
||||
if not bundle:
|
||||
bundle = [cmd]
|
||||
try:
|
||||
cvals = await client.read_holding_registers(62, 3, uid)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"verify clock guard read 62-64 failed (reg 0x%04X): %s", reg, e
|
||||
)
|
||||
return False
|
||||
if len(cvals) != 3:
|
||||
logger.error("verify clock guard: expected 3 regs, got %s", len(cvals))
|
||||
return False
|
||||
logger.warning(
|
||||
"Clock register 0x%04X reached strict verify path; using tolerant 62-64 bundle",
|
||||
reg,
|
||||
)
|
||||
return await _verify_deye_clock_written_bundle(
|
||||
site_id,
|
||||
bundle,
|
||||
int(cvals[0]),
|
||||
int(cvals[1]),
|
||||
int(cvals[2]),
|
||||
db,
|
||||
)
|
||||
|
||||
expected_i = int(cmd["value_to_write"])
|
||||
matches = actual_i == expected_i
|
||||
if reg == 178:
|
||||
first_178 = int(actual_i)
|
||||
second_178: int | None = None
|
||||
if not _deye_reg178_verify_match(expected_i, first_178):
|
||||
try:
|
||||
r178 = await client.read_holding_registers(178, 1, unit)
|
||||
if r178 and len(r178) >= 1:
|
||||
second_178 = int(r178[0])
|
||||
except Exception as e:
|
||||
logger.warning("[cmd %s] reg178 double-read failed: %s", cmd_id, e)
|
||||
matches, actual_i = _deye_reg178_verify_with_double_read(
|
||||
expected_i, first_178, second_178
|
||||
)
|
||||
if (
|
||||
matches
|
||||
and second_178 is not None
|
||||
and not _deye_reg178_verify_match(expected_i, first_178)
|
||||
):
|
||||
logger.info(
|
||||
"[cmd %s] reg178 double-read recovered: first=%s second=%s",
|
||||
cmd_id,
|
||||
first_178,
|
||||
second_178,
|
||||
)
|
||||
if not matches and reg in DEYE_TOU_POWER_REGS and inv_cfg is not None:
|
||||
matches = _deye_tou_power_verify_match(expected_i, actual_i, inv_cfg)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET value_verified=$1::int, verified_at=now(),
|
||||
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
|
||||
WHERE id=$3::int
|
||||
""",
|
||||
actual_i,
|
||||
matches,
|
||||
cmd_id,
|
||||
)
|
||||
|
||||
if not matches:
|
||||
logger.error(
|
||||
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
reg,
|
||||
expected_i,
|
||||
actual_i,
|
||||
" (reg178 mask 0x%04X)" % REG178_VERIFY_MASK if reg == 178 else "",
|
||||
)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||
await notify_modbus_mismatch(
|
||||
db,
|
||||
site_id,
|
||||
cmd["asset_code"],
|
||||
reg,
|
||||
cmd["register_name"] or "",
|
||||
expected_i,
|
||||
actual_i,
|
||||
attempts,
|
||||
)
|
||||
|
||||
if attempts < 3:
|
||||
await db.execute(
|
||||
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
|
||||
cmd_id,
|
||||
)
|
||||
await execute_modbus_commands([cmd_id], db)
|
||||
await verify_modbus_commands([cmd_id], db, site_id)
|
||||
else:
|
||||
if deye_reg_triggers_self_sustain_after_verify_exhaust(reg):
|
||||
logger.critical(
|
||||
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
|
||||
cmd_id,
|
||||
)
|
||||
await _switch_to_self_sustain(
|
||||
site_id,
|
||||
db,
|
||||
reason=(
|
||||
f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} "
|
||||
f"reg 0x{reg:04X}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[cmd %s] 3 failed verify attempts on non-critical reg 0x%04X "
|
||||
"(no mode change): %s",
|
||||
cmd_id,
|
||||
reg,
|
||||
cmd["asset_code"],
|
||||
)
|
||||
return False
|
||||
|
||||
if reg == 178 and actual_i != expected_i:
|
||||
logger.info(
|
||||
"[cmd %s] verified OK (reg178 masked): %s 0x%04X value_to_write=%s actual=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
reg,
|
||||
expected_i,
|
||||
actual_i,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[cmd %s] verified OK: %s 0x%04X=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
reg,
|
||||
actual_i,
|
||||
)
|
||||
return True
|
||||
|
||||
cmds: list[asyncpg.Record] = []
|
||||
for cmd_id in command_ids:
|
||||
cmd = await db.fetchrow(
|
||||
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
if cmd is not None and cmd["status"] == "written":
|
||||
cmds.append(cmd)
|
||||
|
||||
if not cmds:
|
||||
return True
|
||||
|
||||
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
|
||||
for cmd in cmds:
|
||||
by_gw[
|
||||
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
|
||||
].append(cmd)
|
||||
|
||||
all_ok = True
|
||||
for (host, port, unit), group in by_gw.items():
|
||||
client = await get_modbus_client(host, port)
|
||||
clock_cmds = [c for c in group if int(c["register"]) in DEYE_CLOCK_REGS]
|
||||
rest = [c for c in group if int(c["register"]) not in DEYE_CLOCK_REGS]
|
||||
|
||||
if clock_cmds:
|
||||
asset_id = int(clock_cmds[0]["asset_id"])
|
||||
bundle = await _fetch_written_deye_clock_commands(
|
||||
site_id, asset_id, host, port, unit, db
|
||||
)
|
||||
if not bundle:
|
||||
bundle = clock_cmds
|
||||
try:
|
||||
cvals = await client.read_holding_registers(62, 3, unit)
|
||||
except Exception as e:
|
||||
logger.error("verify clock read 62-64 failed: %s", e)
|
||||
all_ok = False
|
||||
else:
|
||||
if len(cvals) != 3:
|
||||
logger.error("verify clock read: expected 3 regs, got %s", len(cvals))
|
||||
all_ok = False
|
||||
else:
|
||||
matched = await _verify_deye_clock_written_bundle(
|
||||
site_id,
|
||||
bundle,
|
||||
int(cvals[0]),
|
||||
int(cvals[1]),
|
||||
int(cvals[2]),
|
||||
db,
|
||||
)
|
||||
if not matched:
|
||||
all_ok = False
|
||||
|
||||
for run in _modbus_command_contiguous_runs(rest):
|
||||
start_reg = int(run[0]["register"])
|
||||
n = len(run)
|
||||
try:
|
||||
values = await client.read_holding_registers(start_reg, n, unit)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"verify batch read 0x%04X count=%s failed: %s", start_reg, n, e
|
||||
)
|
||||
all_ok = False
|
||||
continue
|
||||
if len(values) != n:
|
||||
logger.error(
|
||||
"verify read 0x%04X: expected %s regs, got %s",
|
||||
start_reg,
|
||||
n,
|
||||
len(values),
|
||||
)
|
||||
all_ok = False
|
||||
continue
|
||||
for cmd, actual in zip(run, values):
|
||||
matched = await _apply_verify_result(
|
||||
cmd, int(actual), client=client, unit=unit
|
||||
)
|
||||
if not matched:
|
||||
all_ok = False
|
||||
|
||||
return all_ok
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _db_azimuth_to_pvlib(surface_azimuth_db_deg: float) -> float:
|
||||
"""DB: 0=jih, 90=západ, -90=východ → pvlib (N=0, E=90, S=180, W=270)."""
|
||||
return float((surface_azimuth_db_deg + 180) % 360)
|
||||
"""
|
||||
EMS DB používá standardní azimut (kompasové stupně):
|
||||
N=0, E=90, S=180, W=270 (stejně jako pvlib).
|
||||
"""
|
||||
return float(surface_azimuth_db_deg % 360)
|
||||
|
||||
|
||||
async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
|
||||
|
||||
@@ -61,7 +61,7 @@ async def send_heartbeat(
|
||||
|
||||
status = "ok" if (not endpoint or loxone_ok) else "degraded"
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_heartbeat($1, $2, $3)",
|
||||
"select ems.fn_update_heartbeat($1, $2, $3)",
|
||||
site_id,
|
||||
status,
|
||||
EMS_BACKEND_VERSION,
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
@@ -12,6 +14,42 @@ from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WEBHOOK_CACHE: dict[tuple[int, str], str] = {}
|
||||
_OTE_IMPORT_ALERT_CACHE: dict[tuple[str, str], float] = {}
|
||||
_OTE_IMPORT_OK_CACHE: dict[str, float] = {}
|
||||
|
||||
|
||||
async def _get_site_webhook_url(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
kind: str,
|
||||
) -> str:
|
||||
"""
|
||||
kind: 'daily' | 'error'
|
||||
Fallback: settings.discord_webhook_url
|
||||
"""
|
||||
settings = get_settings()
|
||||
if site_id is None:
|
||||
return settings.discord_webhook_url
|
||||
cache_key = (int(site_id), str(kind))
|
||||
cached = _WEBHOOK_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if conn is None:
|
||||
return settings.discord_webhook_url
|
||||
col = "discord_webhook_daily_url" if kind == "daily" else "discord_webhook_error_url"
|
||||
try:
|
||||
url = await conn.fetchval(
|
||||
f"select {col} from ems.site where id = $1::int",
|
||||
int(site_id),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to load site webhook url site_id=%s kind=%s", site_id, kind)
|
||||
url = None
|
||||
final = str(url or settings.discord_webhook_url or "")
|
||||
_WEBHOOK_CACHE[cache_key] = final
|
||||
return final
|
||||
|
||||
|
||||
def _discord_level_for_mode_change(activated_by: str) -> str:
|
||||
if activated_by == "system:mismatch":
|
||||
@@ -22,6 +60,8 @@ def _discord_level_for_mode_change(activated_by: str) -> str:
|
||||
|
||||
|
||||
async def notify_operating_mode_changed(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
previous_mode: str,
|
||||
new_mode: str,
|
||||
@@ -37,7 +77,33 @@ async def notify_operating_mode_changed(
|
||||
f"**{previous_mode}** → **{new_mode}**\n"
|
||||
f"Aktivoval: `{activated_by}`{note_line}"
|
||||
)
|
||||
await send_discord(msg, level=lvl)
|
||||
await send_discord(conn, site_id, msg, level=lvl)
|
||||
|
||||
|
||||
async def _auto_rolling_replan_after_self_sustain_exit(site_id: int) -> None:
|
||||
"""Po návratu z SELF_SUSTAIN do AUTO přepočítat rolling plán (nové DB spojení)."""
|
||||
try:
|
||||
from app.deps import get_pg_pool
|
||||
from services.planning_engine import run_plan_api
|
||||
|
||||
pool = await get_pg_pool()
|
||||
except Exception as e:
|
||||
logger.warning("Auto replan after SELF_SUSTAIN→AUTO: pool unavailable: %s", e)
|
||||
return
|
||||
try:
|
||||
async with pool.acquire() as replan_conn:
|
||||
await run_plan_api(
|
||||
site_id,
|
||||
"rolling",
|
||||
replan_conn,
|
||||
triggered_by="mode:self_sustain_exit",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Auto rolling replan after SELF_SUSTAIN→AUTO failed: %s",
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def run_fn_set_mode_with_discord(
|
||||
@@ -51,32 +117,29 @@ async def run_fn_set_mode_with_discord(
|
||||
notify_level: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Zavolá ems.fn_set_mode. Při skutečné změně režimu pošle Discord (pokud je webhook).
|
||||
Zavolá ems.fn_set_mode_with_context. Při skutečné změně režimu pošle Discord (pokud je webhook).
|
||||
Vrátí aktuální mode_code z DB po volání.
|
||||
"""
|
||||
prev = await conn.fetchval(
|
||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
|
||||
raw = await conn.fetchval(
|
||||
"""
|
||||
select ems.fn_set_mode_with_context($1::int, $2::text, $3::text, $4::timestamptz, $5::text)
|
||||
""",
|
||||
site_id,
|
||||
mode_code,
|
||||
activated_by,
|
||||
valid_until,
|
||||
notes,
|
||||
)
|
||||
new = await conn.fetchval(
|
||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
ctx = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
prev = ctx.get("previous_mode")
|
||||
new = ctx.get("new_mode")
|
||||
if new is None:
|
||||
new = mode_code
|
||||
site_code = ctx.get("site_code")
|
||||
if prev is not None and prev != new:
|
||||
site_code = await conn.fetchval(
|
||||
"SELECT code FROM ems.site WHERE id = $1", site_id
|
||||
)
|
||||
await notify_operating_mode_changed(
|
||||
conn,
|
||||
site_id,
|
||||
site_code or str(site_id),
|
||||
str(prev),
|
||||
str(new),
|
||||
@@ -84,17 +147,54 @@ async def run_fn_set_mode_with_discord(
|
||||
notes,
|
||||
level=notify_level,
|
||||
)
|
||||
prev_u = str(prev).upper()
|
||||
new_u = str(new).upper()
|
||||
if prev_u == "SELF_SUSTAIN" and new_u == "AUTO":
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(
|
||||
_auto_rolling_replan_after_self_sustain_exit(site_id)
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.debug("No event loop; skip auto rolling replan")
|
||||
return str(new)
|
||||
|
||||
|
||||
async def send_discord(message: str, level: str = "info") -> bool:
|
||||
async def notify_plan_vs_actual_fatal(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
slot_label: str,
|
||||
interval_start_utc: datetime,
|
||||
plan_grid_w: int,
|
||||
actual_grid_w: int,
|
||||
deviation_grid_w: int,
|
||||
reason_code: str,
|
||||
detail: str,
|
||||
) -> None:
|
||||
"""Discord po fatální odchylce plán vs. audit (síť) pro uzavřený 15min slot."""
|
||||
utc_label = interval_start_utc.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
msg = (
|
||||
f"**Fatální odchylka plán vs. realita (síť)** – `{site_code}`\n"
|
||||
f"Slot: **{slot_label}** (`{utc_label}`)\n"
|
||||
f"**{reason_code}**: {detail}\n"
|
||||
f"Plán grid: **{plan_grid_w}** W | Skutečnost: **{actual_grid_w}** W | Δ (act−plan): **{deviation_grid_w}** W"
|
||||
)
|
||||
await send_discord(conn, site_id, msg, level="critical")
|
||||
|
||||
|
||||
async def send_discord(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
message: str,
|
||||
level: str = "info",
|
||||
) -> bool:
|
||||
"""
|
||||
Pošle notifikaci na Discord webhook.
|
||||
level: 'info', 'warning', 'error', 'critical'
|
||||
Vrátí True při úspěchu.
|
||||
"""
|
||||
settings = get_settings()
|
||||
webhook_url = settings.discord_webhook_url
|
||||
kind = "daily" if level == "info" else "error"
|
||||
webhook_url = await _get_site_webhook_url(conn, site_id, kind)
|
||||
if not webhook_url:
|
||||
logger.debug("Discord webhook not configured, skipping notification")
|
||||
return False
|
||||
@@ -116,7 +216,108 @@ async def send_discord(message: str, level: str = "info") -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _should_send_ote_alert(date_str: str, signature: str, *, cooldown_s: float) -> bool:
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
key = (str(date_str), str(signature))
|
||||
last = _OTE_IMPORT_ALERT_CACHE.get(key)
|
||||
if last is not None and (now - last) < cooldown_s:
|
||||
return False
|
||||
_OTE_IMPORT_ALERT_CACHE[key] = now
|
||||
return True
|
||||
|
||||
|
||||
async def notify_ote_import_format_changed(
|
||||
conn: asyncpg.Connection | None,
|
||||
*,
|
||||
report_date: str,
|
||||
error_detail: str,
|
||||
url: str,
|
||||
) -> None:
|
||||
"""
|
||||
Discord alert pro situaci, kdy OTE změnilo formát chart-data a import selže na parseru v DB.
|
||||
|
||||
Dedup: stejný report_date + stejná chyba se pošle max 1× za cooldown.
|
||||
"""
|
||||
signature = (error_detail or "").strip().splitlines()[0][:160]
|
||||
if not _should_send_ote_alert(report_date, signature, cooldown_s=6 * 3600):
|
||||
return
|
||||
|
||||
detail = (error_detail or "").strip()
|
||||
if len(detail) > 1600:
|
||||
detail = detail[:1600] + "…"
|
||||
msg = (
|
||||
f"**OTE import selhal – pravděpodobná změna formátu dat**\n"
|
||||
f"Report date: `{report_date}`\n"
|
||||
f"URL: `{url}`\n"
|
||||
f"Chyba: {detail}\n"
|
||||
f"Doporučení: zkontrolovat `ems.fn_ote_parse_15m_price_json` (tooltipy / struktura payloadu) "
|
||||
f"a upravit parser."
|
||||
)
|
||||
await send_discord(conn, site_id=None, message=msg, level="critical")
|
||||
|
||||
|
||||
def _should_send_ote_ok(report_date: str, *, cooldown_s: float) -> bool:
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
key = str(report_date)
|
||||
last = _OTE_IMPORT_OK_CACHE.get(key)
|
||||
if last is not None and (now - last) < cooldown_s:
|
||||
return False
|
||||
_OTE_IMPORT_OK_CACHE[key] = now
|
||||
return True
|
||||
|
||||
|
||||
async def notify_ote_import_ok_brief(
|
||||
conn: asyncpg.Connection | None,
|
||||
*,
|
||||
report_date: str,
|
||||
brief: dict,
|
||||
url: str,
|
||||
) -> None:
|
||||
"""
|
||||
Info notifikace po úspěšném importu kompletního dne OTE (stručná analýza "co čekat zítra").
|
||||
Dedup: 1× za cooldown na report_date.
|
||||
"""
|
||||
if not _should_send_ote_ok(report_date, cooldown_s=20 * 3600):
|
||||
return
|
||||
|
||||
def _f(x, default: float = 0.0) -> float:
|
||||
try:
|
||||
if x is None:
|
||||
return default
|
||||
return float(x)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
min_p = _f(brief.get("min_price"))
|
||||
max_p = _f(brief.get("max_price"))
|
||||
|
||||
raw_signals = brief.get("signals") or []
|
||||
signals: list[str] = []
|
||||
if isinstance(raw_signals, list):
|
||||
for s in raw_signals[:6]:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
title = str(s.get("title") or s.get("code") or "").strip()
|
||||
detail = str(s.get("detail") or "").strip()
|
||||
if title and detail:
|
||||
signals.append(f"{title} ({detail})")
|
||||
elif title:
|
||||
signals.append(title)
|
||||
if not signals:
|
||||
signals.append("běžný den (bez extrémů)")
|
||||
|
||||
msg = (
|
||||
f"OTE ceny staženy – `{report_date}`\n"
|
||||
f"URL: `{url}`\n"
|
||||
f"Min: **{min_p:.3f}** | Max: **{max_p:.3f}** Kč/kWh\n"
|
||||
f"Signály: " + "; ".join(f"**{s}**" for s in signals)
|
||||
)
|
||||
await send_discord(conn, site_id=None, message=msg, level="info")
|
||||
|
||||
|
||||
async def notify_modbus_mismatch(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
asset_code: str,
|
||||
register: int,
|
||||
register_name: str,
|
||||
@@ -130,18 +331,25 @@ async def notify_modbus_mismatch(
|
||||
f"Zapsáno: `{value_written}` | Přečteno: `{value_verified}`\n"
|
||||
f"Pokus č. {attempt}"
|
||||
)
|
||||
await send_discord(msg, level="error")
|
||||
await send_discord(conn, site_id, msg, level="error")
|
||||
|
||||
|
||||
async def notify_self_sustain_activated(site_code: str, reason: str) -> None:
|
||||
async def notify_self_sustain_activated(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
msg = (
|
||||
f"Přepnutí na **SELF_SUSTAIN** – lokalita `{site_code}`\n"
|
||||
f"Důvod: {reason}"
|
||||
)
|
||||
await send_discord(msg, level="critical")
|
||||
await send_discord(conn, site_id, msg, level="critical")
|
||||
|
||||
|
||||
async def notify_modbus_clock_verify_exhausted(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
asset_code: str,
|
||||
written: tuple[int, int, int],
|
||||
@@ -153,10 +361,12 @@ async def notify_modbus_clock_verify_exhausted(
|
||||
f"Zapsáno: `{written}` | Přečteno: `{actual}`\n"
|
||||
f"Doporučení: zkontrolovat firmware/RS485; režim EMS se nemění automaticky."
|
||||
)
|
||||
await send_discord(msg, level="critical")
|
||||
await send_discord(conn, site_id, msg, level="critical")
|
||||
|
||||
|
||||
async def notify_daily_economics(
|
||||
conn: asyncpg.Connection | None,
|
||||
site_id: int | None,
|
||||
site_code: str,
|
||||
day: str,
|
||||
import_kwh: float,
|
||||
@@ -183,4 +393,4 @@ async def notify_daily_economics(
|
||||
f" Plán předpokládal: {planned_balance:+.2f} Kč "
|
||||
f"(odchylka {dev_sign}{dev:.2f} Kč)"
|
||||
)
|
||||
await send_discord("\n".join(lines), level="info")
|
||||
await send_discord(conn, site_id, "\n".join(lines), level="info")
|
||||
|
||||
119
backend/services/plan_actual_slot_guard.py
Normal file
119
backend/services/plan_actual_slot_guard.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Kontrola plán vs. skutečnost po uzavření 15min slotu.
|
||||
|
||||
Pravidla a dedup INSERT drží ems.fn_plan_actual_slot_guard_site / fn_plan_actual_slot_guard_all_active
|
||||
(repeatable R__076). Python jen zavolá funkci a pošle Discord podle vrácených alertů.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from services.notification_service import notify_plan_vs_actual_fatal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PRAGUE = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
def _interval_start_utc(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
if isinstance(value, str):
|
||||
s = value.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(s)
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
raise TypeError(f"expected datetime or str for interval_start, got {type(value)!r}")
|
||||
|
||||
|
||||
def _slot_label_prague(interval_start: datetime) -> str:
|
||||
loc = interval_start.astimezone(_PRAGUE)
|
||||
return loc.strftime("%Y-%m-%d %H:%M") + " Europe/Prague"
|
||||
|
||||
|
||||
async def _dispatch_site_result(site_payload: dict[str, Any]) -> None:
|
||||
if site_payload.get("error") == "unknown_site":
|
||||
logger.warning("plan_actual_slot_guard: unknown site_id=%s", site_payload.get("site_id"))
|
||||
return
|
||||
site_code = str(site_payload.get("site_code") or site_payload.get("site_id") or "")
|
||||
site_id = int(site_payload.get("site_id") or 0) or None
|
||||
alerts = site_payload.get("alerts")
|
||||
if not isinstance(alerts, list):
|
||||
return
|
||||
for alert in alerts:
|
||||
if not isinstance(alert, dict):
|
||||
continue
|
||||
if not alert.get("notify"):
|
||||
continue
|
||||
interval_start = _interval_start_utc(alert["interval_start"])
|
||||
reason_code = str(alert.get("reason_code") or "")
|
||||
detail = str(alert.get("detail") or "")
|
||||
plan_grid_w = int(alert.get("plan_grid_w") or 0)
|
||||
actual_grid_w = int(alert.get("actual_grid_w") or 0)
|
||||
deviation_grid_w = int(alert.get("deviation_grid_w") or 0)
|
||||
slot_label = _slot_label_prague(interval_start)
|
||||
await notify_plan_vs_actual_fatal(
|
||||
None,
|
||||
site_id,
|
||||
site_code=site_code,
|
||||
slot_label=slot_label,
|
||||
interval_start_utc=interval_start,
|
||||
plan_grid_w=plan_grid_w,
|
||||
actual_grid_w=actual_grid_w,
|
||||
deviation_grid_w=deviation_grid_w,
|
||||
reason_code=reason_code,
|
||||
detail=detail,
|
||||
)
|
||||
logger.warning(
|
||||
"[site=%s] plan_actual fatal %s slot=%s: %s",
|
||||
site_payload.get("site_id"),
|
||||
reason_code,
|
||||
interval_start.isoformat(),
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
async def run_plan_actual_slot_guard_for_all_active_sites(
|
||||
pool: asyncpg.Pool,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""Scheduler: jeden dotaz přes aktivní lokality (SQL dedup + klasifikace)."""
|
||||
async with pool.acquire() as conn:
|
||||
try:
|
||||
if now is not None:
|
||||
raw = await fetch_json(
|
||||
conn,
|
||||
"SELECT ems.fn_plan_actual_slot_guard_all_active($1::timestamptz)",
|
||||
now,
|
||||
)
|
||||
else:
|
||||
raw = await fetch_json(conn, "SELECT ems.fn_plan_actual_slot_guard_all_active()")
|
||||
except Exception:
|
||||
logger.exception("plan_actual_slot_guard fn_plan_actual_slot_guard_all_active failed")
|
||||
return
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, list):
|
||||
logger.warning("plan_actual_slot_guard: unexpected payload type %s", type(raw))
|
||||
return
|
||||
for site_payload in raw:
|
||||
if not isinstance(site_payload, dict):
|
||||
continue
|
||||
try:
|
||||
await _dispatch_site_result(site_payload)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"plan_actual_slot_guard site=%s failed",
|
||||
site_payload.get("site_id"),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,32 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db_json import fetch_json
|
||||
from services.notification_service import (
|
||||
notify_ote_import_format_changed,
|
||||
notify_ote_import_ok_brief,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Běžný kalendářní den na DAM = 96 čtvrthodin; 92 při přechodu na letní čas, 100 na zimní.
|
||||
OTE_TYPICAL_SLOTS = 96
|
||||
OTE_FULL_DAY_SLOT_COUNTS: frozenset[int] = frozenset({92, 96, 100})
|
||||
# Zpětná kompatibilita ve starších importech
|
||||
OTE_EXPECTED_SLOTS = OTE_TYPICAL_SLOTS
|
||||
|
||||
|
||||
def ote_prague_day_slots_look_complete(slot_count: int) -> bool:
|
||||
"""True, pokud počet řádků odpovídá celému obchodnímu dni OTE (včetně DST)."""
|
||||
return slot_count in OTE_FULL_DAY_SLOT_COUNTS
|
||||
|
||||
OTE_URL = (
|
||||
"https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/"
|
||||
"@@chart-data?report_date={date}&time_resolution=PT15M"
|
||||
@@ -93,6 +110,155 @@ async def _fetch_ote_json(date_str: str) -> tuple[dict | None, str | None]:
|
||||
OTE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
async def _apply_ote_json_to_db(conn, payload: dict) -> int:
|
||||
"""Zapíše JSON z OTE přes ems.fn_ote_import_from_json; vrátí ROW_COUNT z funkce."""
|
||||
settings = get_settings()
|
||||
eur_czk = float(settings.eur_czk_rate)
|
||||
n = await conn.fetchval(
|
||||
"SELECT ems.fn_ote_import_from_json($1::jsonb, $2)",
|
||||
json.dumps(payload),
|
||||
eur_czk,
|
||||
)
|
||||
return int(n)
|
||||
|
||||
|
||||
async def count_ote_slots_prague_day(conn, target_day: date) -> int:
|
||||
"""Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100)."""
|
||||
stats = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||
target_day,
|
||||
)
|
||||
if not isinstance(stats, dict):
|
||||
stats = json.loads(stats)
|
||||
return int(stats.get("count") or 0)
|
||||
|
||||
|
||||
async def import_ote_prices_for_day(
|
||||
conn,
|
||||
target_day: date,
|
||||
) -> tuple[int, str, float, str | None]:
|
||||
"""
|
||||
Stáhne OTE pro jeden konkrétní report_date a uloží přes fn_ote_import_from_json.
|
||||
Stejný význam návratové hodnoty jako import_ote_prices().
|
||||
"""
|
||||
day_str = target_day.isoformat()
|
||||
payload, fetch_error = await _fetch_ote_json(day_str)
|
||||
if payload is None:
|
||||
return -1, day_str, 0.0, fetch_error or "fetch_failed"
|
||||
try:
|
||||
n = await _apply_ote_json_to_db(conn, payload)
|
||||
stats_after = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||
target_day,
|
||||
)
|
||||
if not isinstance(stats_after, dict):
|
||||
stats_after = json.loads(stats_after)
|
||||
first_price = stats_after.get("first_price")
|
||||
n_imported = int(stats_after.get("count") or 0)
|
||||
is_complete = bool(stats_after.get("is_complete"))
|
||||
if not ote_prague_day_slots_look_complete(n_imported):
|
||||
logger.warning(
|
||||
"OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)",
|
||||
n_imported,
|
||||
day_str,
|
||||
sorted(OTE_FULL_DAY_SLOT_COUNTS),
|
||||
)
|
||||
if is_complete:
|
||||
brief = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ote_day_signals_prague($1::date, $2::int)",
|
||||
target_day,
|
||||
14,
|
||||
)
|
||||
if not isinstance(brief, dict):
|
||||
brief = json.loads(brief)
|
||||
await notify_ote_import_ok_brief(
|
||||
conn,
|
||||
report_date=day_str,
|
||||
brief=brief if isinstance(brief, dict) else {},
|
||||
url=OTE_URL.format(date=day_str),
|
||||
)
|
||||
logger.info(
|
||||
"OTE import OK: %s slotů (upsert) pro %s, první cena %.4f Kč/kWh",
|
||||
n,
|
||||
day_str,
|
||||
float(first_price or 0),
|
||||
)
|
||||
return n, day_str, float(first_price or 0.0), None
|
||||
except Exception as e:
|
||||
detail = str(e).strip() or e.__class__.__name__
|
||||
logger.error("OTE import DB error pro %s: %s", day_str, detail, exc_info=True)
|
||||
if (
|
||||
"OTE price dataLine not found" in detail
|
||||
or "OTE price series:" in detail
|
||||
or "cannot parse date from graph.title" in detail
|
||||
):
|
||||
await notify_ote_import_format_changed(
|
||||
conn,
|
||||
report_date=day_str,
|
||||
error_detail=detail,
|
||||
url=OTE_URL.format(date=day_str),
|
||||
)
|
||||
short = detail[:200] if len(detail) > 200 else detail
|
||||
return -1, day_str, 0.0, f"db_import:{e.__class__.__name__}: {short}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OteBackfillStats:
|
||||
start_date: date
|
||||
end_date: date
|
||||
days_checked: int = 0
|
||||
days_imported: int = 0
|
||||
days_skipped_complete: int = 0
|
||||
days_skipped_future: int = 0
|
||||
days_failed: int = 0
|
||||
failures: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
async def backfill_ote_prices(
|
||||
conn,
|
||||
*,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
only_missing: bool = True,
|
||||
pause_between_days_s: float = 0.35,
|
||||
max_failures_logged: int = 80,
|
||||
) -> OteBackfillStats:
|
||||
"""
|
||||
Projde rozsah [start_date, end_date] (kalendář Prague) a doplní chybějící dny z OTE.
|
||||
|
||||
only_missing: přeskočí dny, kde už je „plný“ počet slotů (92/96/100 dle OTE).
|
||||
pause_between_days_s: krátká pauza mezi HTTP požadavky (ohleduplnost k OTE).
|
||||
"""
|
||||
stats = OteBackfillStats(start_date=start_date, end_date=end_date)
|
||||
today_prague = datetime.now(OTE_TZ).date()
|
||||
d = start_date
|
||||
while d <= end_date:
|
||||
stats.days_checked += 1
|
||||
if d > today_prague:
|
||||
stats.days_skipped_future += 1
|
||||
d += timedelta(days=1)
|
||||
continue
|
||||
slots = await count_ote_slots_prague_day(conn, d)
|
||||
if only_missing and ote_prague_day_slots_look_complete(slots):
|
||||
stats.days_skipped_complete += 1
|
||||
d += timedelta(days=1)
|
||||
continue
|
||||
n, day_str, _, err = await import_ote_prices_for_day(conn, d)
|
||||
if n < 0:
|
||||
stats.days_failed += 1
|
||||
if len(stats.failures) < max_failures_logged:
|
||||
stats.failures.append((day_str, err or "unknown"))
|
||||
else:
|
||||
stats.days_imported += 1
|
||||
if pause_between_days_s > 0:
|
||||
await asyncio.sleep(pause_between_days_s)
|
||||
d += timedelta(days=1)
|
||||
return stats
|
||||
|
||||
|
||||
async def import_ote_prices(
|
||||
db,
|
||||
site_id: int | None = None,
|
||||
@@ -105,11 +271,9 @@ async def import_ote_prices(
|
||||
Returns: (počet_slotů, datum_str, první_cena_kč_kwh, error_code)
|
||||
(-1, datum_str, 0.0, error_code) při chybě
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
if site_id is not None:
|
||||
row = await db.fetchrow(
|
||||
"SELECT timezone FROM ems.site WHERE id = $1", site_id
|
||||
"select timezone from ems.vw_site_directory where id = $1", site_id
|
||||
)
|
||||
if row is None:
|
||||
logger.error("OTE import: site id=%s nenalezen", site_id)
|
||||
@@ -149,35 +313,19 @@ async def import_ote_prices(
|
||||
|
||||
date_str = target_day.isoformat()
|
||||
|
||||
# Vše ostatní řeší PostgreSQL funkce
|
||||
eur_czk = float(settings.eur_czk_rate)
|
||||
try:
|
||||
n = await db.fetchval(
|
||||
"SELECT ems.fn_ote_import_from_json($1::jsonb, $2)",
|
||||
json.dumps(payload),
|
||||
eur_czk,
|
||||
)
|
||||
first_price = await db.fetchval(
|
||||
"""
|
||||
SELECT buy_raw_price_czk_kwh
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source = 'OTE_CZ'
|
||||
AND interval_start::date = $1::date
|
||||
ORDER BY interval_start
|
||||
LIMIT 1
|
||||
""",
|
||||
n = await _apply_ote_json_to_db(db, payload)
|
||||
stats_after = await fetch_json(
|
||||
db,
|
||||
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||
target_day,
|
||||
)
|
||||
n_imported = await db.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*)::int
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source = 'OTE_CZ'
|
||||
AND interval_start::date = $1::date
|
||||
""",
|
||||
target_day,
|
||||
)
|
||||
incomplete = (n_imported or 0) < 96
|
||||
if not isinstance(stats_after, dict):
|
||||
stats_after = json.loads(stats_after)
|
||||
first_price = stats_after.get("first_price")
|
||||
n_imported = int(stats_after.get("count") or 0)
|
||||
is_complete = bool(stats_after.get("is_complete"))
|
||||
incomplete = not ote_prague_day_slots_look_complete(n_imported or 0)
|
||||
if incomplete:
|
||||
now_p = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
tomorrow_p = (now_p + timedelta(days=1)).date()
|
||||
@@ -186,14 +334,47 @@ async def import_ote_prices(
|
||||
target_day == tomorrow_p
|
||||
and (now_p.hour, now_p.minute) < (14, 30)
|
||||
):
|
||||
logger.warning("OTE: jen %s/96 slotů pro %s", n_imported, date_str)
|
||||
logger.warning(
|
||||
"OTE: %s slotů pro %s (plný den = jedna z %s)",
|
||||
n_imported,
|
||||
date_str,
|
||||
sorted(OTE_FULL_DAY_SLOT_COUNTS),
|
||||
)
|
||||
if is_complete:
|
||||
brief = await fetch_json(
|
||||
db,
|
||||
"select ems.fn_ote_day_signals_prague($1::date, $2::int)",
|
||||
target_day,
|
||||
14,
|
||||
)
|
||||
if not isinstance(brief, dict):
|
||||
brief = json.loads(brief)
|
||||
await notify_ote_import_ok_brief(
|
||||
db,
|
||||
report_date=date_str,
|
||||
brief=brief if isinstance(brief, dict) else {},
|
||||
url=OTE_URL.format(date=date_str),
|
||||
)
|
||||
logger.info(
|
||||
"OTE import OK: %s slotů pro %s, první cena %.4f Kč/kWh",
|
||||
n, date_str, float(first_price or 0),
|
||||
n,
|
||||
date_str,
|
||||
float(first_price or 0),
|
||||
)
|
||||
return int(n), date_str, float(first_price or 0.0), None
|
||||
except Exception as e:
|
||||
detail = str(e).strip() or e.__class__.__name__
|
||||
logger.error("OTE import DB error: %s", detail, exc_info=True)
|
||||
if (
|
||||
"OTE price dataLine not found" in detail
|
||||
or "OTE price series:" in detail
|
||||
or "cannot parse date from graph.title" in detail
|
||||
):
|
||||
await notify_ote_import_format_changed(
|
||||
db,
|
||||
report_date=date_str,
|
||||
error_detail=detail,
|
||||
url=OTE_URL.format(date=date_str),
|
||||
)
|
||||
short = detail[:200] if len(detail) > 200 else detail
|
||||
return -1, date_str, 0.0, f"db_import:{e.__class__.__name__}: {short}"
|
||||
|
||||
718
backend/services/signal_service.py
Normal file
718
backend/services/signal_service.py
Normal file
@@ -0,0 +1,718 @@
|
||||
"""
|
||||
Odchozí signály EMS → Loxone / HTTP (journal, retry, readback verify).
|
||||
|
||||
Kritické řízení výkonu (Deye, EV, TČ) zůstává v Modbus exporteru a modbus_command.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SIGNAL_EXPORT_BAN_ACTIVE = "EXPORT_BAN_ACTIVE"
|
||||
|
||||
# Po úspěšném verify neposílat stejnou hodnotu znovu po tuto dobu (idempotence).
|
||||
_IDEMPOTENCE_TTL = timedelta(minutes=10)
|
||||
# Max pokusů před abandoned (odeslání + verify dohromady řídí attempt_count).
|
||||
_MAX_ATTEMPTS = 12
|
||||
_VERIFY_AFTER_SEND = timedelta(seconds=1)
|
||||
|
||||
|
||||
def _loxone_auth() -> tuple[str, str] | None:
|
||||
settings = get_settings()
|
||||
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
|
||||
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
|
||||
return (user, password) if user else None
|
||||
|
||||
|
||||
def _endpoint_base_url(proto: str | None, host: str, port: int | None) -> str:
|
||||
p = (proto or "http").lower()
|
||||
if p not in ("http", "https"):
|
||||
p = "http"
|
||||
prt = int(port or (443 if p == "https" else 80))
|
||||
return f"{p}://{host}:{prt}"
|
||||
|
||||
|
||||
def _bool_to_text(v: bool, transform_json: dict[str, Any] | None) -> str:
|
||||
if transform_json and "map_bool" in transform_json:
|
||||
m = transform_json["map_bool"]
|
||||
if isinstance(m, dict):
|
||||
return str(m.get("true" if v else "false", "1" if v else "0"))
|
||||
return "1" if v else "0"
|
||||
|
||||
|
||||
def _parse_loxone_io_value(body: str) -> float | None:
|
||||
"""Z odpovědi Loxone /dev/sps/io/… vytáhni číselnou hodnotu."""
|
||||
if not body:
|
||||
return None
|
||||
s = body.strip()
|
||||
# často XML nebo prostý text s číslem
|
||||
nums = re.findall(r"-?\d+(?:\.\d+)?", s)
|
||||
if not nums:
|
||||
return None
|
||||
try:
|
||||
return float(nums[-1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _http_rest_write_url(
|
||||
base: str, route_config_json: dict[str, Any] | None, value_text: str
|
||||
) -> tuple[str, str]:
|
||||
"""Vrátí (method, url) pro http_rest zápis."""
|
||||
cfg = route_config_json or {}
|
||||
method = str(cfg.get("method", "GET")).upper()
|
||||
path = str(cfg.get("path_template", ""))
|
||||
path = path.replace("{value}", value_text).replace("{v}", value_text)
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return method, f"{base.rstrip('/')}{path}"
|
||||
|
||||
|
||||
def _http_rest_verify_url(base: str, verify_cfg: dict[str, Any] | None) -> str | None:
|
||||
if not verify_cfg:
|
||||
return None
|
||||
path = str(verify_cfg.get("read_path", ""))
|
||||
if not path:
|
||||
return None
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return f"{base.rstrip('/')}{path}"
|
||||
|
||||
|
||||
def _read_json_path(data: Any, path: str | None) -> Any:
|
||||
if path is None or path == "" or path == "$":
|
||||
return data
|
||||
if path.startswith("$."):
|
||||
path = path[2:]
|
||||
cur: Any = data
|
||||
for part in path.split("."):
|
||||
if not part:
|
||||
continue
|
||||
if isinstance(cur, dict) and part in cur:
|
||||
cur = cur[part]
|
||||
else:
|
||||
return None
|
||||
return cur
|
||||
|
||||
|
||||
async def compute_export_ban_active(site_id: int, conn: asyncpg.Connection) -> bool:
|
||||
"""
|
||||
Kanonický význam EXPORT_BAN_ACTIVE (LED varianta B).
|
||||
|
||||
True pokud EMS uplatňuje zákaz exportu: no_export, block_export override,
|
||||
režimy bez exportu (SELF_SUSTAIN, CHARGE_CHEAP, PRESERVE), nebo AUTO se záporným
|
||||
výkupem při grid_setpoint_w >= 0 (soulad s _build_setpoints / export_ban), včetně
|
||||
price failsafe (predikovaná cena → pasivní ochrana).
|
||||
"""
|
||||
mode_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT som.mode_code
|
||||
FROM ems.site_operating_mode som
|
||||
WHERE som.site_id = $1::int
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if mode_row is None:
|
||||
return False
|
||||
mode_code = str(mode_row["mode_code"] or "").upper()
|
||||
|
||||
if mode_code == "MANUAL":
|
||||
return False
|
||||
|
||||
if mode_code in ("SELF_SUSTAIN", "CHARGE_CHEAP", "PRESERVE"):
|
||||
return True
|
||||
|
||||
no_export = await conn.fetchval(
|
||||
"""
|
||||
SELECT COALESCE(sgc.no_export, false)
|
||||
FROM ems.site_grid_connection sgc
|
||||
WHERE sgc.site_id = $1::int
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if bool(no_export):
|
||||
return True
|
||||
|
||||
ov = await conn.fetchval(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM ems.site_override o
|
||||
WHERE o.site_id = $1::int
|
||||
AND o.override_type = 'block_export'
|
||||
AND o.valid_from <= now()
|
||||
AND (o.valid_to IS NULL OR o.valid_to > now())
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if ov is not None:
|
||||
return True
|
||||
|
||||
if mode_code != "AUTO":
|
||||
return False
|
||||
|
||||
raw = await conn.fetchval(
|
||||
"""
|
||||
SELECT ems.fn_planning_interval_at_offset($1::int, 0)
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if raw is None:
|
||||
return False
|
||||
pi = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
if not pi:
|
||||
return False
|
||||
|
||||
if bool(pi.get("is_predicted_price")):
|
||||
return True
|
||||
|
||||
export_mode = str(pi.get("export_mode") or "").upper()
|
||||
if export_mode in ("PV_SURPLUS", "BATTERY_SELL"):
|
||||
return False
|
||||
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
grid_sp = int(pi.get("grid_setpoint_w") or 0)
|
||||
if sell_raw is None:
|
||||
return False
|
||||
try:
|
||||
sell_f = float(sell_raw)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return sell_f < 0 and grid_sp >= 0
|
||||
|
||||
|
||||
async def _should_skip_enqueue(
|
||||
conn: asyncpg.Connection,
|
||||
site_id: int,
|
||||
signal_code: str,
|
||||
destination_type: str,
|
||||
destination_key: str,
|
||||
desired_text: str,
|
||||
) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT last_sent_value_text, last_verified_value_text, last_verified_at
|
||||
FROM ems.signal_state
|
||||
WHERE site_id = $1
|
||||
AND signal_code = $2
|
||||
AND destination_type = $3
|
||||
AND destination_key = $4
|
||||
""",
|
||||
site_id,
|
||||
signal_code,
|
||||
destination_type,
|
||||
destination_key,
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
if row["last_sent_value_text"] != desired_text:
|
||||
return False
|
||||
if row["last_verified_value_text"] != desired_text:
|
||||
return False
|
||||
lv = row["last_verified_at"]
|
||||
if lv is None:
|
||||
return False
|
||||
if lv.tzinfo is None:
|
||||
lv = lv.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) - lv < _IDEMPOTENCE_TTL
|
||||
|
||||
|
||||
async def enqueue_site_signals(site_id: int, conn: asyncpg.Connection) -> None:
|
||||
"""Zařadí odchozí řádky pro všechny aktivní routy daného site (po výpočtu signálů)."""
|
||||
export_ban = await compute_export_ban_active(site_id, conn)
|
||||
desired = {SIGNAL_EXPORT_BAN_ACTIVE: export_ban}
|
||||
|
||||
routes = await conn.fetch(
|
||||
"""
|
||||
SELECT r.id, r.site_id, r.destination_type, r.endpoint_id, r.signal_code,
|
||||
r.destination_key, r.transform_json, r.verify_readback, r.verify_config_json,
|
||||
r.route_config_json, r.enabled
|
||||
FROM ems.signal_route r
|
||||
WHERE r.site_id = $1::int AND r.enabled = true
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
for r in routes:
|
||||
sig = str(r["signal_code"])
|
||||
if sig not in desired:
|
||||
continue
|
||||
dest_type = str(r["destination_type"])
|
||||
dest_key = str(r["destination_key"])
|
||||
tf = r["transform_json"]
|
||||
tfd = tf if isinstance(tf, dict) else (json.loads(tf) if tf else None)
|
||||
val_bool = bool(desired[sig])
|
||||
value_text = _bool_to_text(val_bool, tfd)
|
||||
|
||||
if await _should_skip_enqueue(
|
||||
conn, site_id, sig, dest_type, dest_key, value_text
|
||||
):
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.signal_state (
|
||||
site_id, signal_code, destination_type, destination_key,
|
||||
last_desired_value_text, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (site_id, signal_code, destination_type, destination_key)
|
||||
DO UPDATE SET
|
||||
last_desired_value_text = EXCLUDED.last_desired_value_text,
|
||||
updated_at = now()
|
||||
""",
|
||||
site_id,
|
||||
sig,
|
||||
dest_type,
|
||||
dest_key,
|
||||
value_text,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.signal_outbound_journal (
|
||||
route_id, site_id, signal_code, value_text, value_num, status,
|
||||
attempt_count, next_attempt_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'queued', 0, now())
|
||||
""",
|
||||
int(r["id"]),
|
||||
site_id,
|
||||
sig,
|
||||
value_text,
|
||||
1.0 if val_bool else 0.0,
|
||||
)
|
||||
|
||||
|
||||
async def process_signal_outbound_send(
|
||||
conn: asyncpg.Connection, *, limit: int = 30
|
||||
) -> int:
|
||||
"""Odešle až `limit` řádků ve stavu queued. Vrátí počet zpracovaných."""
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT j.id, j.route_id, j.site_id, j.signal_code, j.value_text, j.attempt_count
|
||||
FROM ems.signal_outbound_journal j
|
||||
WHERE j.status = 'queued'
|
||||
AND j.next_attempt_at <= now()
|
||||
ORDER BY j.id
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
limit,
|
||||
)
|
||||
n = 0
|
||||
for j in rows:
|
||||
jid = int(j["id"])
|
||||
route = await conn.fetchrow(
|
||||
"""
|
||||
SELECT r.*, e.host, e.port, e.protocol, e.endpoint_type
|
||||
FROM ems.signal_route r
|
||||
JOIN ems.site_endpoint e ON e.id = r.endpoint_id
|
||||
WHERE r.id = $1::int AND r.enabled = true
|
||||
""",
|
||||
int(j["route_id"]),
|
||||
)
|
||||
if route is None:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = 'abandoned', last_error = 'route missing or disabled'
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
|
||||
dest_type = str(route["destination_type"])
|
||||
base = _endpoint_base_url(
|
||||
route.get("protocol"), str(route["host"]), route.get("port")
|
||||
)
|
||||
auth = _loxone_auth()
|
||||
url: str
|
||||
method = "GET"
|
||||
cfg = route["route_config_json"]
|
||||
rcfg = cfg if isinstance(cfg, dict) else (json.loads(cfg) if cfg else None)
|
||||
|
||||
try:
|
||||
if dest_type == "loxone_vi":
|
||||
io_name = str(route["destination_key"])
|
||||
val = str(j["value_text"])
|
||||
url = f"{base}/dev/sps/io/{io_name}/{val}"
|
||||
elif dest_type == "http_rest":
|
||||
method, url = _http_rest_write_url(base, rcfg, str(j["value_text"]))
|
||||
else:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = 'abandoned',
|
||||
last_error = $2,
|
||||
attempt_count = attempt_count + 1
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
f"unknown destination_type: {dest_type}",
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
ac = int(j["attempt_count"]) + 1
|
||||
delay = min(300, 2 ** min(ac, 8))
|
||||
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
last_error = $3::text,
|
||||
attempt_count = $4::int,
|
||||
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
st,
|
||||
str(e)[:500],
|
||||
ac,
|
||||
delay,
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
|
||||
t0 = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
if method == "GET":
|
||||
resp = await client.get(url, auth=auth)
|
||||
elif method == "POST":
|
||||
body = None
|
||||
if rcfg and isinstance(rcfg.get("json_body"), dict):
|
||||
body = json.dumps(rcfg["json_body"])
|
||||
resp = await client.post(
|
||||
url,
|
||||
auth=auth,
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"} if body else None,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported HTTP method {method}")
|
||||
resp.raise_for_status()
|
||||
body_txt = (resp.text or "")[:2000]
|
||||
except Exception as e:
|
||||
ac = int(j["attempt_count"]) + 1
|
||||
delay = min(300, 2 ** min(ac, 8))
|
||||
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
attempt_count = $3::int,
|
||||
last_error = $4::text,
|
||||
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END,
|
||||
http_method = $6::text,
|
||||
request_url = $7::text
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
st,
|
||||
ac,
|
||||
str(e)[:500],
|
||||
delay,
|
||||
method,
|
||||
url,
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
|
||||
dt_ms = int(
|
||||
(datetime.now(timezone.utc) - t0).total_seconds() * 1000
|
||||
)
|
||||
vr = bool(route["verify_readback"])
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
http_method = $3::text,
|
||||
request_url = $4::text,
|
||||
http_status = $5::int,
|
||||
latency_ms = $6::int,
|
||||
response_body_trunc = $7::text,
|
||||
sent_at = now(),
|
||||
last_error = NULL,
|
||||
verified_at = CASE WHEN $2::text = 'verified' THEN now() ELSE NULL END
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
"verified" if not vr else "sent",
|
||||
method,
|
||||
url,
|
||||
200,
|
||||
dt_ms,
|
||||
(body_txt or "")[:500],
|
||||
)
|
||||
if not vr:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.signal_state (
|
||||
site_id, signal_code, destination_type, destination_key,
|
||||
last_sent_value_text, last_verified_value_text, last_sent_at, last_verified_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $5, now(), now(), now())
|
||||
ON CONFLICT (site_id, signal_code, destination_type, destination_key)
|
||||
DO UPDATE SET
|
||||
last_sent_value_text = EXCLUDED.last_sent_value_text,
|
||||
last_verified_value_text = EXCLUDED.last_verified_value_text,
|
||||
last_sent_at = now(),
|
||||
last_verified_at = now(),
|
||||
updated_at = now()
|
||||
""",
|
||||
int(j["site_id"]),
|
||||
str(j["signal_code"]),
|
||||
dest_type,
|
||||
str(route["destination_key"]),
|
||||
str(j["value_text"]),
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
async def process_signal_outbound_verify(
|
||||
conn: asyncpg.Connection, *, limit: int = 30
|
||||
) -> int:
|
||||
"""Ověří řádky ve stavu sent (readback). Vrátí počet zpracovaných."""
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT j.id, j.route_id, j.site_id, j.signal_code, j.value_text
|
||||
FROM ems.signal_outbound_journal j
|
||||
WHERE j.status = 'sent'
|
||||
AND j.verified_at IS NULL
|
||||
AND j.sent_at IS NOT NULL
|
||||
AND j.sent_at <= now() - $1::interval
|
||||
ORDER BY j.id
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
_VERIFY_AFTER_SEND,
|
||||
limit,
|
||||
)
|
||||
n = 0
|
||||
for j in rows:
|
||||
jid = int(j["id"])
|
||||
route = await conn.fetchrow(
|
||||
"""
|
||||
SELECT r.*, e.host, e.port, e.protocol
|
||||
FROM ems.signal_route r
|
||||
JOIN ems.site_endpoint e ON e.id = r.endpoint_id
|
||||
WHERE r.id = $1::int AND r.enabled = true
|
||||
""",
|
||||
int(j["route_id"]),
|
||||
)
|
||||
if route is None:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = 'abandoned', last_error = 'route missing', verified_at = now()
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
|
||||
dest_type = str(route["destination_type"])
|
||||
base = _endpoint_base_url(
|
||||
route.get("protocol"), str(route["host"]), route.get("port")
|
||||
)
|
||||
auth = _loxone_auth()
|
||||
vcfg_raw = route["verify_config_json"]
|
||||
vcfg = (
|
||||
vcfg_raw
|
||||
if isinstance(vcfg_raw, dict)
|
||||
else (json.loads(vcfg_raw) if vcfg_raw else {})
|
||||
)
|
||||
|
||||
read_url: str | None = None
|
||||
expected = str(j["value_text"])
|
||||
try:
|
||||
if dest_type == "loxone_vi":
|
||||
io_read = vcfg.get("loxone_io_name") if vcfg else None
|
||||
if not io_read:
|
||||
io_read = str(route["destination_key"]) + "_FB"
|
||||
read_url = f"{base}/dev/sps/io/{io_read}"
|
||||
elif dest_type == "http_rest":
|
||||
read_url = _http_rest_verify_url(base, vcfg)
|
||||
else:
|
||||
read_url = None
|
||||
|
||||
if not read_url:
|
||||
raise ValueError("verify_config missing read URL")
|
||||
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
rresp = await client.get(read_url, auth=auth)
|
||||
rresp.raise_for_status()
|
||||
body = rresp.text or ""
|
||||
|
||||
ok = False
|
||||
read_val: str | None = None
|
||||
if dest_type == "loxone_vi":
|
||||
fv = _parse_loxone_io_value(body)
|
||||
if fv is not None:
|
||||
read_val = str(int(round(fv)))
|
||||
try:
|
||||
ev = float(expected)
|
||||
except ValueError:
|
||||
ev = None
|
||||
if ev is not None and abs(fv - ev) < 0.51:
|
||||
ok = True
|
||||
elif dest_type == "http_rest":
|
||||
ct = (rresp.headers.get("content-type") or "").lower()
|
||||
if "json" in ct:
|
||||
data = rresp.json()
|
||||
jpath = vcfg.get("json_path") or vcfg.get("json_key")
|
||||
if isinstance(jpath, str) and jpath:
|
||||
got = _read_json_path(data, jpath)
|
||||
else:
|
||||
got = data
|
||||
if isinstance(got, bool):
|
||||
read_val = "1" if got else "0"
|
||||
elif isinstance(got, (int, float)):
|
||||
read_val = "1" if float(got) >= 0.5 else "0"
|
||||
elif got is not None:
|
||||
read_val = str(got).strip().lower()
|
||||
else:
|
||||
read_val = None
|
||||
exp_l = expected.strip().lower()
|
||||
if read_val is not None:
|
||||
if read_val in ("true", "on", "1"):
|
||||
read_norm = "1"
|
||||
elif read_val in ("false", "off", "0"):
|
||||
read_norm = "0"
|
||||
else:
|
||||
read_norm = read_val
|
||||
exp_norm = (
|
||||
"1"
|
||||
if exp_l in ("1", "true", "on")
|
||||
else "0"
|
||||
if exp_l in ("0", "false", "off")
|
||||
else expected
|
||||
)
|
||||
ok = read_norm == exp_norm
|
||||
else:
|
||||
fv = _parse_loxone_io_value(body)
|
||||
if fv is not None:
|
||||
read_val = str(int(round(fv)))
|
||||
try:
|
||||
ev = float(expected)
|
||||
except ValueError:
|
||||
ev = None
|
||||
ok = ev is not None and abs(fv - ev) < 0.51
|
||||
|
||||
if ok:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = 'verified', verified_at = now(), last_error = NULL
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.signal_state (
|
||||
site_id, signal_code, destination_type, destination_key,
|
||||
last_sent_value_text, last_verified_value_text, last_sent_at, last_verified_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $5,
|
||||
(SELECT sent_at FROM ems.signal_outbound_journal WHERE id = $6::bigint),
|
||||
now(), now())
|
||||
ON CONFLICT (site_id, signal_code, destination_type, destination_key)
|
||||
DO UPDATE SET
|
||||
last_sent_value_text = EXCLUDED.last_sent_value_text,
|
||||
last_verified_value_text = EXCLUDED.last_verified_value_text,
|
||||
last_sent_at = EXCLUDED.last_sent_at,
|
||||
last_verified_at = now(),
|
||||
updated_at = now()
|
||||
""",
|
||||
int(j["site_id"]),
|
||||
str(j["signal_code"]),
|
||||
dest_type,
|
||||
str(route["destination_key"]),
|
||||
str(j["value_text"]),
|
||||
jid,
|
||||
)
|
||||
else:
|
||||
ac_row = await conn.fetchrow(
|
||||
"SELECT attempt_count FROM ems.signal_outbound_journal WHERE id = $1",
|
||||
jid,
|
||||
)
|
||||
ac = int(ac_row["attempt_count"] or 0) + 1
|
||||
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
|
||||
delay = min(300, 2 ** min(ac, 8))
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
attempt_count = $3::int,
|
||||
last_error = $4::text,
|
||||
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END,
|
||||
sent_at = CASE WHEN $2::text = 'queued' THEN NULL ELSE sent_at END,
|
||||
verified_at = CASE WHEN $2::text != 'queued' THEN now() ELSE NULL END
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
st,
|
||||
ac,
|
||||
f"verify mismatch read={read_val!r} expected={expected!r}"[:500],
|
||||
delay,
|
||||
)
|
||||
except Exception as e:
|
||||
ac_row = await conn.fetchrow(
|
||||
"SELECT attempt_count FROM ems.signal_outbound_journal WHERE id = $1",
|
||||
jid,
|
||||
)
|
||||
ac = int(ac_row["attempt_count"] or 0) + 1
|
||||
st = "abandoned" if ac >= _MAX_ATTEMPTS else "queued"
|
||||
delay = min(300, 2 ** min(ac, 8))
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE ems.signal_outbound_journal
|
||||
SET status = $2::text,
|
||||
attempt_count = $3::int,
|
||||
last_error = $4::text,
|
||||
next_attempt_at = CASE WHEN $2::text = 'queued' THEN now() + ($5::int * interval '1 second') ELSE next_attempt_at END,
|
||||
sent_at = CASE WHEN $2::text = 'queued' THEN NULL ELSE sent_at END
|
||||
WHERE id = $1::bigint
|
||||
""",
|
||||
jid,
|
||||
st,
|
||||
ac,
|
||||
str(e)[:500],
|
||||
delay,
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
async def run_signal_outbound_send_for_active_sites(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
try:
|
||||
await process_signal_outbound_send(conn, limit=80)
|
||||
except Exception:
|
||||
logger.exception("signal_outbound_send failed")
|
||||
|
||||
|
||||
async def run_signal_outbound_verify_for_active_sites(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
try:
|
||||
await process_signal_outbound_verify(conn, limit=80)
|
||||
except Exception:
|
||||
logger.exception("signal_outbound_verify failed")
|
||||
@@ -22,8 +22,17 @@ DEYE_REG_BATTERY_POWER_FLOW = 590
|
||||
DEYE_REG_GRID_TOTAL_POWER = 625
|
||||
DEYE_REG_GEN_PORT_POWER = 667
|
||||
DEYE_REG_LOAD_TOTAL_POWER = 653
|
||||
DEYE_REG_GRID_IMPORT_TOTAL_LO = 522
|
||||
DEYE_REG_GRID_IMPORT_TOTAL_HI = 523
|
||||
DEYE_REG_GRID_EXPORT_TOTAL_LO = 524
|
||||
DEYE_REG_GRID_EXPORT_TOTAL_HI = 525
|
||||
DEYE_REG_PV1_POWER = 672
|
||||
DEYE_REG_PV2_POWER = 673
|
||||
# Solar sell (0 = přebytek řiditelné FVE nesmí do sítě) a GEN/MI cut-off (reg178 bits0–1 == 3 → cut-off ON).
|
||||
# Pozn.: v některých manuálech/UI se uvádí "register 179" (1-based), ale Modbus adresa je 178 (0-based).
|
||||
# Viz modbus-registers.md.
|
||||
DEYE_REG_SOLAR_SELL = 145
|
||||
DEYE_REG_CONTROL_BOARD_SPECIAL1 = 178
|
||||
|
||||
|
||||
def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
|
||||
@@ -34,16 +43,24 @@ def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
|
||||
return max(0, int(pv1_w)) + max(0, int(pv2_w)) + max(0, int(gen_port_w))
|
||||
|
||||
|
||||
def _export_limit_flags_from_deye_regs(reg145: int | None, reg179: int | None) -> tuple[bool | None, int | None]:
|
||||
"""Odvoď is_export_limited / pv_derating_flags z přečtených holding registrů (NULL = neznámé)."""
|
||||
if reg145 is None and reg179 is None:
|
||||
return None, None
|
||||
flags = 0
|
||||
if reg145 is not None and int(reg145) == 0:
|
||||
flags |= 1
|
||||
if reg179 is not None and (int(reg179) & 3) == 3:
|
||||
flags |= 2
|
||||
return (flags != 0), flags
|
||||
|
||||
|
||||
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ai.id, ai.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_inverter ai
|
||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||
WHERE ai.site_id = $1
|
||||
AND ai.active = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
select inverter_id as id, code, host, port, unit_id
|
||||
from ems.vw_asset_inverter_modbus_poll
|
||||
where site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
@@ -63,34 +80,24 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
|
||||
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
|
||||
grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
|
||||
load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER)
|
||||
load_power = await mb.read_register_signed(DEYE_REG_LOAD_TOTAL_POWER)
|
||||
pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER)
|
||||
pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER)
|
||||
gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER)
|
||||
grid_energy_regs = await mb.read_holding_registers(
|
||||
DEYE_REG_GRID_IMPORT_TOTAL_LO, 4
|
||||
)
|
||||
reg145 = await mb.read_register(DEYE_REG_SOLAR_SELL)
|
||||
reg179 = await mb.read_register(DEYE_REG_CONTROL_BOARD_SPECIAL1)
|
||||
pv_power_w = aggregate_pv_production_w(pv1_power, pv2_power, gen_port_power)
|
||||
grid_import_total_wh = (grid_energy_regs[1] << 16 | grid_energy_regs[0]) * 100
|
||||
grid_export_total_wh = (grid_energy_regs[3] << 16 | grid_energy_regs[2]) * 100
|
||||
is_export_limited, pv_derating_flags = _export_limit_flags_from_deye_regs(reg145, reg179)
|
||||
|
||||
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_inverter (
|
||||
site_id, inverter_id, measured_at,
|
||||
pv_power_w, pv1_power_w, pv2_power_w, gen_port_power_w,
|
||||
battery_soc_percent, battery_power_w,
|
||||
batt_charge_today_wh, batt_discharge_today_wh,
|
||||
grid_power_w, load_power_w,
|
||||
run_state
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
$4, $5, $6, $7,
|
||||
$8, $9,
|
||||
$10, $11,
|
||||
$12, $13,
|
||||
$14
|
||||
)
|
||||
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
||||
""",
|
||||
"select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int, $17::boolean, $18::int)",
|
||||
site_id,
|
||||
inv_id,
|
||||
measured_at,
|
||||
@@ -104,7 +111,11 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
batt_discharge_today,
|
||||
grid_power,
|
||||
load_power,
|
||||
grid_import_total_wh,
|
||||
grid_export_total_wh,
|
||||
run_state,
|
||||
is_export_limited,
|
||||
pv_derating_flags,
|
||||
)
|
||||
inv_temp: float | None = None
|
||||
await manager.broadcast_telemetry(
|
||||
@@ -119,6 +130,8 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
"load_power_w": load_power,
|
||||
"gen_port_power_w": gen_port_power,
|
||||
"inverter_temp_c": inv_temp,
|
||||
"is_export_limited": is_export_limited,
|
||||
"pv_derating_flags": pv_derating_flags,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -128,12 +141,9 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ec.id, ec.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_ev_charger ec
|
||||
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||
WHERE ec.site_id = $1
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
select charger_id as id, code, host, port, unit_id
|
||||
from ems.vw_asset_ev_charger_modbus_poll
|
||||
where site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
@@ -143,117 +153,52 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||
code = row["code"]
|
||||
charger_id = row["id"]
|
||||
logger.info("TODO: EV charger Modbus registry pending | %s", code)
|
||||
# Placeholder až do mapování Modbus: status zůstává available (bez falešných přechodů).
|
||||
current_status = "available"
|
||||
|
||||
previous_status = await db.fetchval(
|
||||
"""
|
||||
SELECT status
|
||||
FROM ems.telemetry_ev_charger
|
||||
WHERE charger_id = $1 AND connector_id = $2
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
select status
|
||||
from ems.telemetry_ev_charger
|
||||
where charger_id = $1 and connector_id = $2
|
||||
order by measured_at desc
|
||||
limit 1
|
||||
""",
|
||||
charger_id,
|
||||
connector_id,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_ev_charger (
|
||||
site_id, charger_id, measured_at, connector_id,
|
||||
status, power_w, energy_kwh
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 0, 0)
|
||||
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
|
||||
""",
|
||||
"select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8)",
|
||||
site_id,
|
||||
charger_id,
|
||||
measured_at,
|
||||
connector_id,
|
||||
current_status,
|
||||
0,
|
||||
0.0,
|
||||
)
|
||||
|
||||
if previous_status is not None:
|
||||
await db.fetchval(
|
||||
"select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
|
||||
site_id,
|
||||
charger_id,
|
||||
str(previous_status),
|
||||
current_status,
|
||||
measured_at,
|
||||
)
|
||||
if previous_status == "available" and current_status != "available":
|
||||
vehicle_id = await db.fetchval(
|
||||
"""
|
||||
SELECT av.id
|
||||
FROM ems.asset_vehicle av
|
||||
WHERE av.site_id = $1
|
||||
AND av.default_charger_id = $2
|
||||
AND av.active = true
|
||||
ORDER BY av.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
charger_id,
|
||||
)
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_ev_arrival_stats($1, $2, $3, $4)",
|
||||
site_id,
|
||||
charger_id,
|
||||
vehicle_id,
|
||||
measured_at,
|
||||
)
|
||||
logger.info("EV arrival detected on charger %s", code)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.ev_session (
|
||||
site_id, charger_id, vehicle_id, session_start,
|
||||
target_soc_pct, target_deadline
|
||||
)
|
||||
SELECT
|
||||
ac.site_id,
|
||||
ac.id,
|
||||
av.id,
|
||||
now(),
|
||||
av.default_target_soc_pct,
|
||||
CASE
|
||||
WHEN av.default_deadline_hour IS NOT NULL THEN
|
||||
(
|
||||
(timezone('Europe/Prague', now()))::date + interval '1 day'
|
||||
+ make_interval(hours => av.default_deadline_hour)
|
||||
)::timestamp AT TIME ZONE 'Europe/Prague'
|
||||
END
|
||||
FROM ems.asset_ev_charger ac
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT v.id, v.default_target_soc_pct, v.default_deadline_hour
|
||||
FROM ems.asset_vehicle v
|
||||
WHERE v.default_charger_id = ac.id
|
||||
AND v.site_id = ac.site_id
|
||||
AND v.active = true
|
||||
ORDER BY v.id
|
||||
LIMIT 1
|
||||
) av ON true
|
||||
WHERE ac.id = $1 AND ac.site_id = $2
|
||||
ON CONFLICT (charger_id) WHERE session_end IS NULL DO NOTHING
|
||||
""",
|
||||
charger_id,
|
||||
site_id,
|
||||
)
|
||||
|
||||
if previous_status != "available" and current_status == "available":
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.ev_session
|
||||
SET session_end = now()
|
||||
WHERE charger_id = $1 AND session_end IS NULL
|
||||
""",
|
||||
charger_id,
|
||||
)
|
||||
elif previous_status != "available" and current_status == "available":
|
||||
logger.info("EV departure detected on charger %s", code)
|
||||
|
||||
|
||||
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT hp.id, hp.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_heat_pump hp
|
||||
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||||
WHERE hp.site_id = $1
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
select heat_pump_id as id, code, host, port, unit_id
|
||||
from ems.vw_asset_heat_pump_modbus_poll
|
||||
where site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
@@ -262,18 +207,15 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||
code = row["code"]
|
||||
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_heat_pump (
|
||||
site_id, heat_pump_id, measured_at,
|
||||
power_w, outdoor_temp_c, water_outlet_temp_c, tuv_tank_temp_c,
|
||||
operating_mode
|
||||
)
|
||||
VALUES ($1, $2, $3, 0, 10.0, 45.0, 55.0, 'standby')
|
||||
ON CONFLICT (heat_pump_id, measured_at) DO NOTHING
|
||||
""",
|
||||
"select ems.fn_telemetry_heat_pump_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8, $7::float8, $8::text)",
|
||||
site_id,
|
||||
row["id"],
|
||||
measured_at,
|
||||
0,
|
||||
10.0,
|
||||
45.0,
|
||||
55.0,
|
||||
"standby",
|
||||
)
|
||||
|
||||
|
||||
@@ -284,7 +226,9 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch(
|
||||
"select id from ems.vw_site_directory where active = true"
|
||||
)
|
||||
for site in sites:
|
||||
sid = site["id"]
|
||||
try:
|
||||
|
||||
67
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
67
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""PASSIVE + plán chce nabíjet: 108 = plný strop z DB, 109 = max (PV špička + domácnost)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from services.control.setpoints import deye_battery_charge_discharge_amps
|
||||
|
||||
|
||||
class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
|
||||
def test_passive_charge_while_exporting_grid_negative(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=5000,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
)
|
||||
self.assertEqual(ch, 100)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_charge_mode_still_scales_108_from_battery_w(self) -> None:
|
||||
"""CHARGE (síť + baterie): 108 podle plánu, ne vždy plný strop."""
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="CHARGE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=2000,
|
||||
grid_w=3000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
)
|
||||
self.assertLess(ch, 100)
|
||||
self.assertGreater(ch, 0)
|
||||
self.assertEqual(dis, 0)
|
||||
|
||||
def test_passive_export_without_battery_charge_uses_max_108(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=0,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
)
|
||||
self.assertEqual(ch, 100)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_sell_unchanged(self) -> None:
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="SELL",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=-3000,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=80,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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,13 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from services.control_exporter import (
|
||||
from services.control.exporter_monolith import (
|
||||
ControlSetpoints,
|
||||
InverterConfig,
|
||||
_deye_reg178_verify_with_double_read,
|
||||
_deye_tou_params,
|
||||
_deye_tou_power_verify_match,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||
get_deye_mode,
|
||||
)
|
||||
from services.control.models import OperatingModeInfo
|
||||
from services.control.setpoints import _build_setpoints, _deye_zero_export_amps_for_passive
|
||||
|
||||
|
||||
def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterConfig:
|
||||
@@ -33,15 +39,39 @@ def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterC
|
||||
)
|
||||
|
||||
|
||||
def _inv_350a() -> InverterConfig:
|
||||
"""350 A × 51.2 V = 17920 W — typický firmware clamp pro TOU power."""
|
||||
return replace(_inv(), max_charge_a=350, max_discharge_a=350)
|
||||
|
||||
|
||||
class ModbusVerifyPolicyTests(unittest.TestCase):
|
||||
def test_tou_power_accepts_firmware_max_w_clamp(self) -> None:
|
||||
inv = _inv_350a()
|
||||
self.assertTrue(_deye_tou_power_verify_match(7752, 17920, inv))
|
||||
self.assertTrue(_deye_tou_power_verify_match(16728, 17920, inv))
|
||||
|
||||
def test_reg178_double_read_recovers_from_glitch(self) -> None:
|
||||
ok, v = _deye_reg178_verify_with_double_read(48, 12014, 48)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(v, 48)
|
||||
|
||||
def test_reg178_not_critical_for_self_sustain(self) -> None:
|
||||
self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(178))
|
||||
|
||||
def test_reg108_critical_for_self_sustain(self) -> None:
|
||||
self.assertTrue(deye_reg_triggers_self_sustain_after_verify_exhaust(108))
|
||||
|
||||
|
||||
class DeyeTouParamsTests(unittest.TestCase):
|
||||
def test_sell_uses_reserve_soc(self) -> None:
|
||||
"""SELL: záporný grid_setpoint_w i battery_w → selling first; TOU SOC = reserve."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=5000,
|
||||
battery_w=-8000,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-500,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
@@ -51,6 +81,90 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 20)
|
||||
|
||||
def test_explicit_deye_physical_mode_from_plan_overrides_detection(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-8000,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
deye_physical_mode="PASSIVE",
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
|
||||
def test_export_ban_does_not_change_deye_mode(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
export_ban=True,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
|
||||
def test_build_setpoints_uses_explicit_export_limit(self) -> None:
|
||||
mode = OperatingModeInfo(
|
||||
mode_code="AUTO",
|
||||
battery_mode="AUTO",
|
||||
grid_mode="AUTO",
|
||||
ev_enabled=False,
|
||||
heat_pump_enabled_def=False,
|
||||
loxone_mode_value=1,
|
||||
)
|
||||
pi = {
|
||||
"battery_setpoint_w": 0,
|
||||
"grid_setpoint_w": -3000,
|
||||
"export_limit_w": 13_500,
|
||||
"export_mode": "PV_SURPLUS",
|
||||
"ev1_setpoint_w": 0,
|
||||
"ev2_setpoint_w": 0,
|
||||
"heat_pump_enabled": False,
|
||||
"battery_soc_target_pct": 50,
|
||||
"effective_sell_price": 1.0,
|
||||
}
|
||||
sp = _build_setpoints(mode, pi)
|
||||
self.assertIsNotNone(sp)
|
||||
self.assertEqual(sp.grid_export_limit, 13_500)
|
||||
|
||||
def test_pv_led_export_with_small_battery_is_sell(self) -> None:
|
||||
"""Obě záporné → SELL (bez porovnání |bat| vs |grid|)."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-733,
|
||||
grid_export_limit=1294,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-1294,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "SELL")
|
||||
|
||||
def test_large_export_small_battery_is_sell(self) -> None:
|
||||
"""I když |bat| < |grid| — stále SELL při obou záporných setpointech."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-1500,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "SELL")
|
||||
|
||||
def test_passive_uses_min_soc(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=0,
|
||||
@@ -62,12 +176,51 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
effective_sell_price_czk_kwh=None,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 12)
|
||||
|
||||
def test_passive_negative_sell_tou_stays_min_soc(self) -> None:
|
||||
"""PASSIVE: záporná vykupní nenastavuje TOU na 100 — zůstává min_soc (145/export_ban řeší síť)."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-400,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=14,
|
||||
effective_sell_price_czk_kwh=-0.25,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 12)
|
||||
|
||||
def test_passive_planned_pv_charge_tou_stays_min_soc(self) -> None:
|
||||
"""PASSIVE s kladným battery_w bez grid importu: CHARGE to není — TOU je stále min_soc."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=800,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=60,
|
||||
effective_sell_price_czk_kwh=1.0,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 12)
|
||||
|
||||
def test_charge_unchanged_grid_charge(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=5000,
|
||||
@@ -85,6 +238,74 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
self.assertTrue(g)
|
||||
self.assertEqual(s, 95)
|
||||
|
||||
def test_charge_target_soc_respects_max_soc_100(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=5000,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=5000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=80,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "CHARGE")
|
||||
inv = replace(_inv(), max_soc_percent=100)
|
||||
_p, s, g = _deye_tou_params(sp, inv)
|
||||
self.assertTrue(g)
|
||||
self.assertEqual(s, 100)
|
||||
|
||||
def test_charge_any_positive_pair_without_w_threshold(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=50,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=80,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "CHARGE")
|
||||
|
||||
def test_zero_export_amps_fve_overflow(self) -> None:
|
||||
c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90)
|
||||
self.assertEqual(c, 100)
|
||||
self.assertEqual(d, 90)
|
||||
|
||||
def test_zero_export_amps_import_hold_discharge(self) -> None:
|
||||
c, d = _deye_zero_export_amps_for_passive(500, 0, 100, 90)
|
||||
self.assertEqual(c, 100)
|
||||
self.assertEqual(d, 0)
|
||||
|
||||
def test_zero_export_amps_full_when_discharge_with_export(self) -> None:
|
||||
"""Export + plánované vybíjení → plné proudy (SELL řeší režim 142 zvlášť)."""
|
||||
c, d = _deye_zero_export_amps_for_passive(-2000, -500, 100, 90)
|
||||
self.assertEqual(c, 100)
|
||||
self.assertEqual(d, 90)
|
||||
|
||||
def test_self_sustain_tou_stays_min_soc_even_if_sell_negative(self) -> None:
|
||||
"""SELF_SUSTAIN: nízké TOU (min_soc), ne 100 % z negativní vykupní — LP se nepoužívá."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=None,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
effective_sell_price_czk_kwh=-0.48,
|
||||
self_sustain_local_use=True,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 12)
|
||||
|
||||
def test_lock_battery_uses_min_soc(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=0,
|
||||
|
||||
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,96 @@ def replace_slot(
|
||||
|
||||
|
||||
class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None:
|
||||
"""
|
||||
Když:
|
||||
- aktuální slot má sell < 0 (export je náklad),
|
||||
- v horizontu existuje budoucí buy < 0,
|
||||
- a zároveň existuje PV B (necurtailable) někde v horizontu,
|
||||
solver preferuje curtail PV A (ca) místo placeného exportu ge.
|
||||
"""
|
||||
slots = [
|
||||
_slot(load=0, buy=3.0, sell=-0.1, pv_a=5000, pv_b=0),
|
||||
_slot(load=0, buy=-10.0, sell=1.0, pv_a=0, pv_b=5000),
|
||||
]
|
||||
battery = _battery(uc_wh=50_000.0)
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.50 * battery.usable_capacity_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 2)
|
||||
# Slot 0: PV A se má raději uříznout než vyvážet za zápornou cenu.
|
||||
self.assertEqual(int(results[0].pv_a_curtailed_w), 5000)
|
||||
self.assertGreaterEqual(int(results[0].grid_setpoint_w), 0)
|
||||
|
||||
def test_pv_surplus_export_uses_hard_export_cap(self) -> None:
|
||||
slots = [
|
||||
_slot(load=0, buy=3.0, sell=2.5, pv_a=20_000, pv_b=0),
|
||||
]
|
||||
battery = _battery()
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=13_500)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = battery.soc_max_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].export_mode, "PV_SURPLUS")
|
||||
self.assertEqual(results[0].export_limit_w, 13_500)
|
||||
self.assertGreater(results[0].pv_a_curtailed_w, 0)
|
||||
|
||||
def test_two_tier_soc_solves_optimal(self) -> None:
|
||||
slots = [_slot()]
|
||||
battery = _battery()
|
||||
@@ -117,7 +317,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
),
|
||||
]
|
||||
soc0 = 0.15 * battery.usable_capacity_wh
|
||||
results, ms = solve_dispatch(
|
||||
results, ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
@@ -128,7 +328,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
price_failsafe_active=False,
|
||||
)
|
||||
self.assertGreaterEqual(ms, 0)
|
||||
self.assertEqual(len(results), 1)
|
||||
@@ -158,7 +357,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
),
|
||||
]
|
||||
soc0 = 0.12 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
@@ -169,7 +368,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
price_failsafe_active=False,
|
||||
)
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
@@ -195,7 +393,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
),
|
||||
]
|
||||
soc0 = 0.5 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
@@ -206,12 +404,11 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
price_failsafe_active=False,
|
||||
)
|
||||
self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
|
||||
|
||||
def test_export_implies_end_soc_at_least_reserve(self) -> None:
|
||||
"""Při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB)."""
|
||||
"""Bez arbitrážní relaxace: při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB)."""
|
||||
slots = [
|
||||
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
|
||||
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
|
||||
@@ -236,7 +433,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
),
|
||||
]
|
||||
soc0 = 0.22 * battery.usable_capacity_wh
|
||||
results, _ms = solve_dispatch(
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
@@ -247,7 +444,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
price_failsafe_active=False,
|
||||
)
|
||||
reserve_pct = 20.0
|
||||
for r in results:
|
||||
@@ -258,6 +454,555 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
msg="export slot must end at or above reserve SoC",
|
||||
)
|
||||
|
||||
def test_export_before_extreme_negative_buy_can_end_below_reserve(self) -> None:
|
||||
"""
|
||||
Při relaxovaném soc_min (záporný buy v lookahead) smí významný export skončit u planner floor,
|
||||
ne u provozní rezervy — jinak nejde ráno vypustit do sítě a nachystat kapacitu před levným nákupem.
|
||||
"""
|
||||
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
|
||||
s0 = PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=2.5,
|
||||
sell_price=2.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=400,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
s1 = PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=-12.0,
|
||||
sell_price=-0.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=400,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
slots = [s0, s1]
|
||||
battery = _battery(uc_wh=10_000.0, min_pct=10.0, arb_pct=20.0)
|
||||
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
|
||||
battery.planner_discharge_floor_percent = 5.0
|
||||
battery.max_charge_power_w = 50_000
|
||||
battery.max_discharge_power_w = 50_000
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.88 * battery.usable_capacity_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 2)
|
||||
if results[0].grid_setpoint_w < 0:
|
||||
self.assertLess(
|
||||
results[0].battery_soc_target,
|
||||
19.0,
|
||||
msg="with relaxed soc_min, first-slot export should be able to finish below reserve %",
|
||||
)
|
||||
|
||||
def test_negative_sell_forbids_battery_export_arbitrage(self) -> None:
|
||||
"""
|
||||
Pokud sell < 0, solver nesmí vybíjet baterii do sítě pro arbitráž (dump musí proběhnout předtím).
|
||||
V okně sell<0 smí export vzniknout jen z přebytku FVE; zde ale FVE=0, takže očekáváme grid_setpoint>=0.
|
||||
"""
|
||||
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
|
||||
s0 = PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=2.0,
|
||||
sell_price=2.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
s1 = PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=2.0,
|
||||
sell_price=-0.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
s2 = PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=30),
|
||||
buy_price=-15.0,
|
||||
sell_price=-1.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
slots = [s0, s1, s2]
|
||||
battery = _battery(uc_wh=10_000.0, min_pct=10.0, arb_pct=20.0)
|
||||
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
|
||||
battery.planner_discharge_floor_percent = 5.0
|
||||
battery.max_charge_power_w = 50_000
|
||||
battery.max_discharge_power_w = 50_000
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
soc0 = 0.9 * battery.usable_capacity_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 3)
|
||||
# V sell<0 slotu bez FVE a bez zátěže nesmí být export (to by muselo být z baterie).
|
||||
self.assertGreaterEqual(results[1].grid_setpoint_w, 0)
|
||||
# A zároveň nesmí být baterie ve výboji (dump musí proběhnout předtím).
|
||||
self.assertGreaterEqual(results[1].battery_setpoint_w, 0)
|
||||
|
||||
def test_anchor_hits_floor_before_first_negative_sell(self) -> None:
|
||||
"""
|
||||
Pokud se v horizontu objeví první sell<0 a současně existuje planner floor (relaxace),
|
||||
solver má skončit už v předchozím slotu u planner floor (cca 5 %), ne na ~15 %.
|
||||
"""
|
||||
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
|
||||
# Slot 0-1: sell >= 0; slot 2: první sell < 0; slot 3: extrémně záporný buy (motivace k bufferu).
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=3.0,
|
||||
sell_price=1.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=3.0,
|
||||
sell_price=0.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=30),
|
||||
buy_price=3.0,
|
||||
sell_price=-0.2,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=45),
|
||||
buy_price=-20.0,
|
||||
sell_price=-1.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
]
|
||||
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
|
||||
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
|
||||
battery.planner_discharge_floor_percent = 5.0
|
||||
battery.max_charge_power_w = 50_000
|
||||
battery.max_discharge_power_w = 50_000
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
soc0 = 0.9 * battery.usable_capacity_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
# Slot index 1 je poslední před prvním sell<0 (index 2).
|
||||
self.assertLessEqual(
|
||||
results[1].battery_soc_target,
|
||||
6.0,
|
||||
msg="anchor should drive SoC close to planner floor before first negative sell",
|
||||
)
|
||||
|
||||
def test_anchor_uses_planner_floor_even_without_extreme_buy(self) -> None:
|
||||
"""
|
||||
Regrese: pokud v horizontu není buy <= threshold (soc_min_series by se nerelaxovala),
|
||||
kotva před sell<0 má stejně mířit na planner floor (5 %), ne na base min SoC.
|
||||
"""
|
||||
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=3.0,
|
||||
sell_price=1.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=3.0,
|
||||
sell_price=0.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=30),
|
||||
buy_price=3.0,
|
||||
sell_price=-0.2,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
),
|
||||
]
|
||||
battery = _battery(uc_wh=20_000.0, min_pct=12.0, arb_pct=20.0)
|
||||
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
|
||||
battery.planner_discharge_floor_percent = 5.0
|
||||
battery.max_charge_power_w = 50_000
|
||||
battery.max_discharge_power_w = 50_000
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
soc0 = 0.9 * battery.usable_capacity_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
# Slot index 1 je poslední před prvním sell<0 (index 2).
|
||||
self.assertLessEqual(results[1].battery_soc_target, 6.0)
|
||||
|
||||
def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None:
|
||||
"""
|
||||
Soft cap: solver může nominálně překročit breaker, ale jen pokud se to vyplatí.
|
||||
Při běžné (nezáporné) nákupní ceně by měl držet import <= breaker.
|
||||
"""
|
||||
slots = [_slot(load=3700, buy=0.4, sell=-0.3, pv_a=0, pv_b=1500)]
|
||||
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
|
||||
battery.max_charge_power_w = 18_000
|
||||
battery.max_discharge_power_w = 18_000
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.55 * battery.usable_capacity_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertLessEqual(
|
||||
results[0].grid_setpoint_w,
|
||||
grid.max_import_power_w,
|
||||
msg="soft cap: for normal buy price, planned grid import should not exceed breaker",
|
||||
)
|
||||
|
||||
def test_grid_import_soft_cap_allows_overdraw_when_extremely_negative(self) -> None:
|
||||
"""
|
||||
Regrese: při extrémně záporné nákupní ceně může solver překročit breaker (za cenu penalizace),
|
||||
aby stihl krátké okno nabíjení. Překročení nesmí být 'zadarmo' (kontrolujeme alespoň, že existuje).
|
||||
"""
|
||||
# Dvouslotový scénář: v 1. slotu extrémně záporná cena, ve 2. slotu drahá.
|
||||
# Terminal SoC kotva pak nepenalizuje držení energie (průměrná buy je ~0) a solver má motivaci
|
||||
# v 1. slotu nabít na max, i kdyby to znamenalo malé překročení breakeru.
|
||||
s0 = _slot(load=0, buy=-20.0, sell=-0.3, pv_a=0, pv_b=0)
|
||||
s1 = replace_slot(s0, load=0)
|
||||
s1 = PlanningSlot(
|
||||
interval_start=s0.interval_start + timedelta(minutes=15),
|
||||
buy_price=20.0,
|
||||
sell_price=-0.3,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
)
|
||||
slots = [s0, s1]
|
||||
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
|
||||
battery.max_charge_power_w = 18_000
|
||||
battery.max_discharge_power_w = 18_000
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
soc0 = 0.55 * battery.usable_capacity_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertGreater(
|
||||
results[0].grid_setpoint_w,
|
||||
grid.max_import_power_w,
|
||||
msg="with very negative buy price, solver may choose to exceed breaker (soft cap)",
|
||||
)
|
||||
|
||||
def test_block_export_on_negative_sell_no_grid_export_pv_surplus(self) -> None:
|
||||
"""site_grid_connection.block_export_on_negative_sell → ge=0 při sell<0."""
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc),
|
||||
buy_price=5.25,
|
||||
sell_price=-0.5,
|
||||
pv_a_forecast_w=7000,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=False,
|
||||
)
|
||||
]
|
||||
battery = _battery(uc_wh=20_000.0, arb_pct=15.0, max_pct=95.0)
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=17_000,
|
||||
max_export_power_w=8000,
|
||||
block_export_on_negative_sell=True,
|
||||
)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.34 * battery.usable_capacity_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertGreaterEqual(results[0].grid_setpoint_w, 0, "no grid export")
|
||||
self.assertGreater(results[0].battery_setpoint_w, 0, "surplus PV should charge")
|
||||
|
||||
|
||||
class TerminalSocShadowTests(unittest.TestCase):
|
||||
"""Terminal SoC shadow price v objective drží konec horizontu nad holým minimem."""
|
||||
|
||||
def test_terminal_soc_shadow_price_prevents_drain(self) -> None:
|
||||
base = datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc)
|
||||
slots = []
|
||||
for i in range(3):
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=2.0,
|
||||
sell_price=0.6,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=600,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
)
|
||||
)
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=45),
|
||||
buy_price=2.0,
|
||||
sell_price=14.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=600,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
)
|
||||
)
|
||||
battery = _battery(uc_wh=12_000.0, min_pct=12.0, arb_pct=20.0)
|
||||
hp = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=45.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
battery_capacity_kwh=1.0,
|
||||
default_target_soc_pct=80.0,
|
||||
),
|
||||
]
|
||||
soc0 = 0.5 * battery.usable_capacity_wh
|
||||
results, _ms, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 4)
|
||||
# Bez shadow price by solver mohl končit u min SoC; kotva drží znatelnou rezervu.
|
||||
self.assertGreaterEqual(
|
||||
results[-1].battery_soc_target,
|
||||
15.0,
|
||||
msg="terminal SoC shadow price should keep end-of-horizon SoC above bare minimum",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
140
backend/tests/test_planning_safety_commitment.py
Normal file
140
backend/tests/test_planning_safety_commitment.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Měkké safety SoC a rolling charge commitment v solve_dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.planning_engine import PlanningSlot, solve_dispatch
|
||||
|
||||
|
||||
def _bat(**kwargs: object) -> SimpleNamespace:
|
||||
base = dict(
|
||||
usable_capacity_wh=20_000.0,
|
||||
min_soc_wh=2000.0,
|
||||
arb_floor_wh=4000.0,
|
||||
reserve_soc_wh=4000.0,
|
||||
soc_max_wh=19_000.0,
|
||||
charge_efficiency=0.95,
|
||||
discharge_efficiency=0.95,
|
||||
degradation_cost_czk_kwh=0.1,
|
||||
max_charge_power_w=5000,
|
||||
max_discharge_power_w=5000,
|
||||
planner_terminal_soc_value_factor=0.2,
|
||||
planner_extreme_buy_threshold_czk_kwh=-5.0,
|
||||
planner_discharge_floor_percent=None,
|
||||
planner_discharge_relax_prewindow_slots=8,
|
||||
planner_daytime_charge_target_enabled=True,
|
||||
planner_charge_commitment_penalty_czk_kwh=0.5,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _grid() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
max_import_power_w=11_000,
|
||||
max_export_power_w=11_000,
|
||||
block_export_on_negative_sell=False,
|
||||
deye_gen_microinverter_cutoff_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def _hp() -> SimpleNamespace:
|
||||
return SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
|
||||
|
||||
def _slot(
|
||||
t0: datetime,
|
||||
idx: int,
|
||||
*,
|
||||
buy: float = 3.0,
|
||||
sell: float = 2.5,
|
||||
pv_a: int = 0,
|
||||
load: int = 1500,
|
||||
safety: float | None = None,
|
||||
fut_buy: float | None = None,
|
||||
fut_sell: float | None = None,
|
||||
) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=t0 + timedelta(minutes=15 * idx),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=pv_a,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=load,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
safety_soc_target_wh=safety,
|
||||
future_avoided_buy_czk_kwh=fut_buy,
|
||||
future_sell_opportunity_czk_kwh=fut_sell,
|
||||
)
|
||||
|
||||
|
||||
class PlanningSafetyCommitmentTests(unittest.TestCase):
|
||||
def test_solver_snapshot_has_version_and_masks(self) -> None:
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
slots = [_slot(t0, i, buy=2.0, sell=2.0, pv_a=6000, load=1500) for i in range(8)]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=5000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(res), 8)
|
||||
self.assertEqual(snap.get("version"), 1)
|
||||
self.assertIn("masks", snap)
|
||||
self.assertEqual(len(snap["masks"]), 8)
|
||||
|
||||
def test_charge_commitment_snapshot_populated(self) -> None:
|
||||
"""Rolling kotva: při předchozím nabíjení z PV se do snapshotu zapíše commitment."""
|
||||
t0 = datetime(2026, 5, 4, 10, 0, tzinfo=timezone.utc)
|
||||
slots = [_slot(t0, i, buy=1.5, sell=1.2, pv_a=8000, load=1000) for i in range(12)]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
prev = [None] * 12
|
||||
prev[0] = 4000.0
|
||||
_res1, _, snap1 = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=4000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
charge_commitment_prev_w=prev,
|
||||
)
|
||||
self.assertTrue(snap1["chosen_slots"]["charge_commitment"])
|
||||
_res2, _, snap2 = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=4000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
charge_commitment_prev_w=None,
|
||||
)
|
||||
self.assertEqual(snap2["chosen_slots"]["charge_commitment"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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).';
|
||||
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal file
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Parametry pro denní „safety charge“ (měkké LP penalizace) a kotvu rolling replanu.
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_daytime_charge_target_enabled boolean not null default true;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_night_baseload_buffer_percent numeric not null default 20;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_daytime_charge_price_quantile numeric not null default 0.70;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_charge_commitment_penalty_czk_kwh numeric not null default 0.20;
|
||||
|
||||
comment on column ems.asset_battery.planner_daytime_charge_target_enabled is
|
||||
'Zapíná SQL/LP měkké denní cíle SoC (safety) z fn_load_planning_slots_full; ne tvrdé allow_charge masky.';
|
||||
|
||||
comment on column ems.asset_battery.planner_night_baseload_buffer_percent is
|
||||
'Procentní přirážka k odhadu nočního baseload Wh (20 = +20 % k night_baseload_target_wh).';
|
||||
|
||||
comment on column ems.asset_battery.planner_daytime_charge_price_quantile is
|
||||
'Rezervováno pro budoucí výběr „drahých“ oken z cenové distribuce; v1 se v LP nepoužívá.';
|
||||
|
||||
comment on column ems.asset_battery.planner_charge_commitment_penalty_czk_kwh is
|
||||
'Koeficient měkké penalizace (Kč/kWh krátkého nedodržení) proti předchozímu plánu při rolling replanu.';
|
||||
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$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user