gpt5.5 - odladeni dokumentace dle kodu
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 27s
CI and deploy / deploy (pull_request) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-02 19:17:04 +02:00
parent 3595b24f3b
commit 02f0ab66e4
9 changed files with 161 additions and 114 deletions

View File

@@ -116,7 +116,8 @@ Operativní predikce je v **`fn_get_baseline_forecast`** a v přímém dotazu v
**Telemetrie:**
- Stav ON/OFF (čteme z Loxone HTTP výstupu nebo Virtual Output stavu)
- Teplota zásobníku (pokud je čidlo v Loxone doporučeno)
- Aktuální výkon: není přímo měřen, používáme `max_power_w` z `asset_flexible_device`
- Aktuální výkon: zatím není reálně čten z TČ; placeholder telemetrie a plánování
používají parametry z `asset_heat_pump`, hlavně `rated_heating_power_w`
**Plánování:**
- TUV se ohřívá v době přebytku FVE nebo levného spotu

View File

@@ -5,8 +5,8 @@
- Čte aktivní plán z DB pro daný 15min interval
- Zkontroluje override záznamy
- Zapíše setpointy do Deye přes Modbus TCP
- Zapíše setpointy EV nabíječek přes Modbus TCP
- Zapíše setpointy tepelného čerpadla přes Modbus TCP
- EV nabíječky a tepelné čerpadlo zatím pouze vyhodnotí a zaloguje; konkrétní
Modbus registry jsou TODO
- Odešle potvrzovací setpointy do Loxone přes HTTP (Loxone jako exekutor fallback logiky)
- Loguje každý write pro audit
@@ -19,10 +19,10 @@ DB (planning_interval + site_override)
control_exporter.py (každých 15min nebo on-demand)
├── Modbus write → Deye (baterie, grid limit)
├── Modbus write → Teltonika EV nabíječka 1
├── Modbus write → Teltonika EV nabíječka 2
├── Modbus write → Samsung TČ
└── HTTP POST → Loxone Virtual Inputs (informační setpointy)
├── TODO/log → Teltonika EV nabíječka 1
├── TODO/log → Teltonika EV nabíječka 2
├── TODO/log → Samsung TČ
└── HTTP GET → Loxone Virtual Inputs (informační setpointy)
```
**Loxone role:** Loxone dostává setpointy jako informaci a jako fallback ochranu.
@@ -34,7 +34,7 @@ Rozhodovací logika je v EMS, ne v Loxone.
| Trigger | Čas | Popis |
|---|---|---|
| Scheduled | každých 15min (xx:00, xx:15, xx:30, xx:45) | Standardní export na začátku intervalu |
| Scheduled | každých 15min (`xx:14`, `xx:29`, `xx:44`, `xx:59`) | Export těsně před dalším slotem |
| On-demand | po vytvoření nového plánu | Okamžitý export pokud plán překrývá aktuální čas |
| On-demand | po vytvoření override | Okamžitá aplikace přepisu |
@@ -51,6 +51,8 @@ Ověření: logy backendu kolem pokusu **nebo** `select id,status,created_at fro
## Logika exportu
```python
# Zjednodušený historický náčrt. Aktuální implementace používá
# ems.fn_planning_interval_at_offset(), ControlSetpoints a Deye journal.
async def export_setpoints_for_interval(site_id: int, interval_start: datetime, db):
"""
Načte plánované setpointy pro daný interval, aplikuje overrides
@@ -84,14 +86,11 @@ async def export_setpoints_for_interval(site_id: int, interval_start: datetime,
setpoints = apply_overrides(plan, overrides)
# 3. Zapsat do zařízení (paralelně)
await asyncio.gather(
write_inverter_setpoints(site_id, setpoints, db),
write_ev_charger_setpoints(site_id, setpoints, db),
write_heat_pump_setpoints(site_id, setpoints, db),
write_loxone_setpoints(site_id, setpoints, db),
return_exceptions=True
)
# 3. Zapsat Deye, zalogovat EV/TČ TODO, poslat Loxone
await write_inverter_setpoints(site_id, setpoints, db)
await write_ev_setpoints(site_id, setpoints, db) # TODO registry
await write_heat_pump_setpoint(site_id, setpoints, db) # TODO registry
await send_loxone_setpoints(site_id, setpoints, mode, db)
def apply_overrides(plan, overrides) -> Setpoints:
@@ -153,7 +152,7 @@ bits 01). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 |
| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** dle `_deye_zero_export_amps_for_passive` (viz `operating-modes.md`) |
**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **145** (solar sell): v kódu vždy **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*).
**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*).
**SELF_SUSTAIN** zůstává **PASSIVE** v `get_deye_mode`; **108/109** jsou vždy **max z DB** (bez variant ZERO). Rozdíl je **`self_sustain_local_use=True`**: **TOU SOC** = **`min_soc_percent`**, `battery_w=None`.
@@ -165,7 +164,7 @@ bits 01). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
| **109** (discharge A) | **0** | max / **0** (import, držet bat.) | **max z DB** | dle varianty |
| **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` |
| **143** (export cap) | max z DB | max z DB | `min(max_site, max(200, \|grid_setpoint_w\|))` | max z DB |
| **145** (solar sell) | 1 | 1 | 1 | 1 |
| **145** (solar sell) | 1 / 0 při `export_ban` | 1 / 0 při `export_ban` | 1 | 1 / 0 při `export_ban` |
| **178** (peak shaving) | 48 | 48 | **32** | 48 |
U **AUTO PASSIVE** závisí **108/109** na znaménkách plánu (viz `operating-modes.md`). **SELF_SUSTAIN** drží oba **max z DB**; **TOU SOC** ve všech PASSIVE větvích je **`min_soc_percent`** (viz `_deye_passive_tou_battery_soc_pct`). Liší se především **`battery_w`** a mapování **108/109**.
@@ -184,6 +183,9 @@ Po zápisu na Modbus se hodnoty ověřují v `verify_modbus_commands` (`control_
Při přechodu **SELF_SUSTAIN → AUTO** (`run_fn_set_mode_with_discord`) se na pozadí spustí **rolling replan**, aby aktivní plán odpovídal plné optimalizaci. Viz [`modbus-command-journal.md`](modbus-command-journal.md).
```python
# Historický pseudokód. Aktuální Deye implementace používá journal
# ems.modbus_command a FC 0x10 (`write_registers`) nad registry
# 108/109/141/142/143/145/178/340 + TOU bloky.
async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db):
inverters = await db.fetch(
"SELECT ai.*, se.host, se.port, se.unit_id "
@@ -215,6 +217,10 @@ async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db):
## Zápis do Teltonika EV nabíječek (Modbus)
Aktuální implementace registry zatím nezapisuje. Funkce `write_ev_setpoints`
načte schedulable nabíječky, spočítá proud podle `ev1/ev2_setpoint_w` a jen ho
zaloguje jako `Modbus TODO`.
```python
async def write_ev_charger_setpoints(site_id: int, setpoints: Setpoints, db):
chargers = await db.fetch(
@@ -250,6 +256,10 @@ async def write_ev_charger_setpoints(site_id: int, setpoints: Setpoints, db):
## Zápis do Samsung TČ (Modbus)
Aktuální implementace registry zatím nezapisuje. Funkce
`write_heat_pump_setpoint` načte schedulable TČ a zaloguje požadované
`heat_pump_enable` jako `Modbus TODO`.
```python
async def write_heat_pump_setpoints(site_id: int, setpoints: Setpoints, db):
heat_pumps = await db.fetch(
@@ -297,20 +307,22 @@ async def write_loxone_setpoints(site_id: int, setpoints: Setpoints, db):
# Loxone Virtual HTTP Input každý setpoint = jeden HTTP GET/POST
# Formát: /dev/sps/io/{VirtualInputName}/{value}
async with aiohttp.ClientSession() as session:
await session.get(f"{base_url}/EMS_BatterySetpoint/{setpoints.battery_setpoint_w}")
await session.get(f"{base_url}/EMS_GridSetpoint/{setpoints.grid_setpoint_w or 0}")
await session.get(f"{base_url}/EMS_EVChargeTotal/{setpoints.ev_charge_power_w or 0}")
await session.get(f"{base_url}/EMS_HeatPumpEnable/{1 if setpoints.heat_pump_enabled else 0}")
await session.get(f"{base_url}/EMS_Mode/{mode.loxone_mode_value}")
await session.get(f"{base_url}/EMS_Battery_Setpoint_W/{battery_w}")
await session.get(f"{base_url}/EMS_Grid_Setpoint_W/{grid_w}")
await session.get(f"{base_url}/EMS_EV1_Power_W/{ev1_power_w}")
await session.get(f"{base_url}/EMS_EV2_Power_W/{ev2_power_w}")
await session.get(f"{base_url}/EMS_HeatPump_Enable/{heat_pump_enable}")
```
> Virtual Input jména v Loxone (`EMS_BatterySetpoint` atd.) je nutné vytvořit při konfiguraci Loxone projektu.
> Virtual Input jména v Loxone (`EMS_Battery_Setpoint_W`, `EMS_Grid_Setpoint_W`
> atd.) je nutné vytvořit při konfiguraci Loxone projektu.
---
## Konfigurace (env proměnné)
```env
CONTROL_EXPORT_LEAD_TIME_SEC=10 # kolik sekund před začátkem intervalu exportovat
CONTROL_MODBUS_TIMEOUT_SEC=5
LOXONE_USER=admin # nebo přes auth_reference v site_endpoint
LOXONE_PASSWORD=secret
@@ -331,9 +343,8 @@ Fallback: pokud per-site webhook není vyplněný, použije se env `DISCORD_WEBH
## Otevřené body
- [ ] Doplnit Modbus write registry Deye (charge/discharge/export limit)
- [ ] Doplnit Modbus write registry Teltonika (current limit, enable)
- [ ] Doplnit Modbus write registry Samsung TČ (enable, target temp)
- [ ] Loxone Virtual Input jména dohodnout a vytvořit v Loxone projektu
- [ ] Loxone Virtual Input jména z tohoto dokumentu vytvořit v Loxone projektu
- [ ] Strategie rozdělení EV výkonu mezi 2 nabíječky (rovnoměrně vs dle stavu session)
- [ ] Co dělat při selhání zápisu do jednoho zařízení (rollback ostatních?)

View File

@@ -5,7 +5,9 @@
- Stahuje meteorologická data (irradiance, teplota) pro každé FVE pole zvlášť
- Vypočítává predikovaný výkon v 15min intervalech
- Ukládá výsledek per `pv_array_id` + `run_id`
- Predikce se spouští denně a před každým plánovacím během
- Predikce se spouští každé 2 hodiny v `:05` a ručně přes API. Plánovač používá
poslední dostupné uložené forecasty; forecast nespouští implicitně před každým
plánovacím během.
---
@@ -13,12 +15,15 @@
| Pole | Výkon | Azimut | Sklon | Střídač | Řízení |
|---|---|---|---|---|---|
| A | 10 kWp | TBD | TBD | Deye 20kW | řídíme |
| B | 10 kWp | TBD | TBD | Ongridový | autonomní, **nepredikujeme odděleně** |
| A | 10 kWp | 184° | 22° | Deye 20kW | řídíme |
| B | 10 kWp | 184° | 35° | Ongridový | autonomní, predikujeme jako samostatné pole |
> **Předpoklad:** Pole B (ongridový) je zapojeno do GEN portu Deye. Jeho výkon se projeví v `pv_power_w` telemetrie jako součást celkového výkonu. Pro plánování modelujeme jen pole A. Pole B bereme jako šum / bonus který se projeví v auditu.
> **Aktuální implementace:** Forecast služba počítá všechna FVE pole lokality,
> která mají vyplněný `azimuth_deg` a `tilt_deg`; plánovač pracuje odděleně s
> `pv_a_forecast_w` i `pv_b_forecast_w`.
> Azimuty a sklony je nutné doplnit při konfiguraci lokality do `asset_pv_array`.
> Azimut je uložen v kompasové / pvlib konvenci: `0=N`, `90=E`, `180=S`,
> `270=W`.
---
@@ -36,10 +41,9 @@
GET https://api.open-meteo.com/v1/forecast
?latitude={lat}
&longitude={lon}
&hourly=shortwave_radiation,temperature_2m
&minutely_15=shortwave_radiation,temperature_2m
&timezone=Europe/Prague
&forecast_days=3
&minutely_15=direct_normal_irradiance,diffuse_radiation,shortwave_radiation,temperature_2m
&timezone=auto
&forecast_days=7
```
**Záložní / budoucí: Solcast**
@@ -51,34 +55,28 @@ GET https://api.open-meteo.com/v1/forecast
## Výpočet výkonu z irradiance
Jednoduchý fyzikální model (dostatečný pro plánování):
Implementace používá `pvlib` a model POA irradiance `haydavies`:
```python
def calculate_pv_power(
irradiance_wm2: float, # GHI ze weather service
temp_c: float,
nominal_power_wp: int,
azimuth_deg: float,
tilt_deg: float,
shading_factor: float = 1.0,
temp_coeff: float = -0.004 # typicky -0.4%/°C pro křemík
) -> int:
# 1. Korekce na teplotu panelu
panel_temp = temp_c + 25 # zjednodušený NOCT model
temp_correction = 1 + temp_coeff * (panel_temp - 25)
poa_global = pvlib.irradiance.get_total_irradiance(
surface_tilt=tilt_deg,
surface_azimuth=azimuth_deg, # 0=N, 90=E, 180=S, 270=W
solar_zenith=solar_pos["apparent_zenith"],
solar_azimuth=solar_pos["azimuth"],
dni=dni,
ghi=ghi,
dhi=dhi,
dni_extra=dni_extra,
model="haydavies",
)["poa_global"].fillna(0).clip(lower=0)
# 2. Korekce na azimut a sklon (zjednodušená, bez přesného GHI→POA)
# Přesnější model: pvlib knihovna (doporučeno pro produkci)
orientation_factor = cos_angle_of_incidence(azimuth_deg, tilt_deg)
# 3. Výsledný výkon
power_w = (irradiance_wm2 / 1000) * nominal_power_wp * temp_correction * orientation_factor * shading_factor
return max(0, int(power_w))
area_m2 = nominal_power_wp / (1000.0 * 0.20)
power_w = (poa_global * area_m2 * 0.20 * shading_factor).clip(
lower=0,
upper=nominal_power_wp * 1.1,
)
```
> **Doporučení pro implementaci:** Použít knihovnu `pvlib` (Python) pro přesný POA irradiance výpočet z GHI + azimut + sklon. Je to standardní nástroj, dobře dokumentovaný.
---
## Kdo spouští predikci
@@ -107,15 +105,15 @@ def calculate_pv_power(
## Logika běhu predikce
```python
def run_forecast(site_id: int, horizon_days: int = 2):
def run_forecast(site_id: int):
site = db.get_site(site_id)
arrays = db.get_pv_arrays(site_id, controllable=True)
arrays = db.get_pv_arrays_with_azimuth_and_tilt(site_id)
for array in arrays:
# 1. Stáhnout meteorologická data
weather = open_meteo_client.fetch(
lat=site.lat, lon=site.lon,
start=today, end=today + horizon_days
forecast_days=clamp(OPEN_METEO_FORECAST_DAYS, 2, 16)
)
# 2. Vytvořit forecast_pv_run
@@ -147,8 +145,7 @@ def run_forecast(site_id: int, horizon_days: int = 2):
temp_c=slot.temperature_2m
))
db.upsert_forecast_intervals(intervals)
db.update_forecast_run_status(run.id, "ok")
db.insert_forecast_intervals(intervals)
```
---
@@ -204,23 +201,20 @@ Hromadně: **`ems.fn_pv_forecast_sync_reference_days(site_id, p_days_local date[
```env
OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast
OPEN_METEO_FORECAST_DAYS=7
FORECAST_MAX_AGE_HOURS=2 # plánovač odmítne starší predikci
FORECAST_RETRY_COUNT=3
```
---
## Monitoring
- Alert pokud forecast pro dnešden + zítřek není k dispozici do 15:00
- Endpoint `GET /health/forecast?site_id=1&date=YYYY-MM-DD` → čerstvost a počet intervalů
- Zatím není samostatný `/health/forecast` endpoint.
- Stav se kontroluje přes logy běhu `scheduled_forecast_refresh`, přes forecast API
a přes obecné health endpointy.
- Log každého běhu (délka horizontu, počet intervalů, trvání, zdroj)
---
## Otevřené body
- [ ] Doplnit přesný azimut a sklon obou FVE polí při instalaci
- [ ] Rozhodnout: pvlib pro přesnější POA výpočet vs jednoduchý model doporučujeme pvlib od začátku
- [ ] Pole B (ongridový) zda vůbec modelovat nebo ignorovat v plánu a jen sledovat v auditu
- [ ] Ověřit přesný azimut a sklon obou FVE polí proti skutečné instalaci
- [ ] Solcast jako alternativa v budoucnu `forecast_source` to umožňuje bez DB změn

View File

@@ -2,7 +2,9 @@
## Co modul dělá
- Čte data ze střídače Deye, EV nabíječek Teltonika a tepelného čerpadla Samsung přes Modbus TCP
- Čte data ze střídače Deye přes Modbus TCP
- EV nabíječky Teltonika a tepelné čerpadlo Samsung mají zatím placeholder
vzorky; konkrétní registry jsou TODO
- Ukládá surová měření do DB (1min granularita)
- Detekuje výpadky komunikace a loguje chyby
- Agreguje 1min data na 15min průměry pro spotřebu, audit a plánování
@@ -18,15 +20,15 @@ Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI.
| Zařízení | Interval | Důvod |
|---|---|---|
| Deye střídač | 60 s | 1min granularita telemetrie |
| Teltonika EV nabíječka 1 | 60 s | |
| Teltonika EV nabíječka 2 | 60 s | |
| Samsung tepelné čerpadlo | 60 s | |
| Teltonika EV nabíječka 1 | 60 s | zatím placeholder `available`, 0 W |
| Teltonika EV nabíječka 2 | 60 s | zatím placeholder `available`, 0 W |
| Samsung tepelné čerpadlo | 60 s | zatím placeholder hodnoty |
### Chování při chybě
- Chyba komunikace: záznam se nezapíše, chyba se loguje
- 3 po sobě jdoucí chyby = alert (log WARNING)
- 10 po sobě jdoucích chyb = log ERROR + pokus o reconnect
- Kód zatím nedrží počítadlo po sobě jdoucích chyb podle zařízení; chyby se logují
při jednotlivých poll pokusech
- Data se neinterpolují chybějící minuty zůstanou prázdné (audit to pozná)
---
@@ -43,7 +45,7 @@ Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
| 514 (0x0202) | uint16 | Dnešní nabití baterie | Wh | `batt_charge_today_wh` |
| 515 (0x0203) | uint16 | Dnešní vybití baterie | Wh | `batt_discharge_today_wh` |
| 588 (0x024C) | uint16 | Battery SoC | % | `battery_soc_percent` |
| 590 (0x024E) | int16 | Tok výkonu baterie | W | signed: **+ vybíjení, nabíjení** |
| 590 (0x024E) | int16 | Tok výkonu baterie | W | signed z Deye; v DB `battery_power_w` platí **+ nabíjení, vybíjení** |
| 625 (0x0271) | int16 | Výkon sítě | W | signed: **+ import, export** |
| 653 (0x028D) | uint16 | Celková spotřeba | W | `load_power_w` |
| 667 (0x029B) | int16 | Výkon GEN portu (FVE pole B) | W (signed) | `gen_port_power_w`; záporné při zpětném toku / bez výroby — **číst signed** |
@@ -60,6 +62,13 @@ Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
**Zápis setpointů (plánování → Deye):**
Aktuální řízení Deye je popsané v [`control.md`](control.md) a
[`modbus-registers.md`](modbus-registers.md). Nepoužívá starý `write_register`
model, ale journal `ems.modbus_command` a FC 0x10 (`write_registers`) pro
registry 108/109/141/142/143/145/178/340 + TOU bloky.
Historická orientační mapa níže neplatí jako implementační kontrakt:
| Registr (hex) | Typ | Popis | Hodnota |
|---|---|---|---|
| 0x00F3 | Write Single | Battery charge power limit | W |
@@ -114,14 +123,15 @@ Komunikace: Modbus TCP přes Waveshare.
## Kód telemetrie (Python)
Implementace: `backend/services/telemetry_collector.py``poll_inverter()` používá konstanty `DEYE_REG_*` a třídu `ModbusDevice`; hlavní smyčka je `run_telemetry_loop` / `run_telemetry_loop_wrapper`.
Implementace: `backend/services/telemetry_collector.py``poll_inverter()` používá konstanty `DEYE_REG_*` a sdíleného Modbus klienta z `services.modbus_client`; hlavní smyčka je `run_telemetry_loop` / `run_telemetry_loop_wrapper`.
---
## Agregace 1min → 15min
Prováděna PostgreSQL funkcí `ems.fn_fill_audit_interval()` a `ems.fn_fill_baseline_consumption()`.
Spouštěna každých 15 minut jako scheduled task (Python APScheduler nebo pg_cron).
Prováděna PostgreSQL funkcí `ems.fn_fill_audit_interval()` a navazujícími
funkcemi pro baseline/accuracy. Spouští ji Python APScheduler: audit filler v
minutách `:01,:16,:31,:46`, forecast accuracy v `:02,:17,:32,:47`.
```sql
-- Příklad agregace telemetrie na 15min průměr
@@ -161,12 +171,13 @@ Nad `ems.telemetry_inverter` běží dva **continuous aggregate** (TimescaleDB);
```env
TELEMETRY_POLL_INTERVAL_SEC=60
TELEMETRY_ERROR_WARN_THRESHOLD=3 # počet chyb před WARNING logem
TELEMETRY_ERROR_RECONNECT_THRESHOLD=10
MODBUS_CONNECT_TIMEOUT_SEC=5
MODBUS_READ_TIMEOUT_SEC=3
```
`TELEMETRY_POLL_INTERVAL_SEC` a chybové prahy zatím nejsou v kódu používány;
smyčka běží každých 60 s přímo v `run_telemetry_loop_wrapper`.
---
## Otevřené body