Initial commit
Made-with: Cursor
This commit is contained in:
187
docs/04-modules/consumption.md
Normal file
187
docs/04-modules/consumption.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Modul: Consumption (Spotřeba)
|
||||
|
||||
## Členění spotřeby
|
||||
|
||||
Systém rozlišuje dva typy spotřeby:
|
||||
|
||||
### 1. Bazální (neflexibilní) spotřeba
|
||||
- Spotřeba kterou nelze odložit ani řídit
|
||||
- Příklady: osvětlení, elektronika, vaření, cirkulační čerpadla
|
||||
- **Zdroj:** měřená telemetrie ze střídače (`load_power_w` - suma flexibilní spotřeby)
|
||||
- **Použití v plánování:** jako pevný vstup (musí být pokryta)
|
||||
|
||||
### 2. Flexibilní spotřeba
|
||||
- Spotřeba kterou lze časově přesunout nebo regulovat
|
||||
- Příklady: nabíjení EV, ohřev TUV, tepelné čerpadlo (při přetopení zásobníku)
|
||||
- **Zdroj:** telemetrie z konkrétních zařízení (EV nabíječky, stavové vstupy Loxone)
|
||||
- **Použití v plánování:** jako optimalizovatelná proměnná
|
||||
|
||||
---
|
||||
|
||||
## Jak se měří celková spotřeba
|
||||
|
||||
Střídač Deye poskytuje přes Modbus registr `load_power_w` = celková okamžitá spotřeba objektu (vše za hlavním jističem na AC straně střídače).
|
||||
|
||||
```
|
||||
load_power_w (Deye) = bazální_spotřeba + EV_nabíjení + TUV + ostatní flexibilní
|
||||
```
|
||||
|
||||
### Odvození bazální spotřeby
|
||||
|
||||
```
|
||||
bazální_w = load_power_w - sum(flexibilní zařízení aktuální výkon)
|
||||
```
|
||||
|
||||
V praxi:
|
||||
```
|
||||
bazální_w = load_power_w
|
||||
- ev_charger_1_power_w
|
||||
- ev_charger_2_power_w
|
||||
- tuv_power_w (pokud je měřitelná zvlášť)
|
||||
```
|
||||
|
||||
> **Předpoklad:** TUV výkon není přímo měřen, pouze víme že je ON/OFF (přes Loxone). Pokud je ON, odečítáme `asset_flexible_device.max_power_w`. Toto je zjednodušení – lze zpřesnit později podružným měřením.
|
||||
|
||||
---
|
||||
|
||||
## Ukládání spotřeby
|
||||
|
||||
### Real-time telemetrie
|
||||
Celková spotřeba je součástí `telemetry_inverter.load_power_w` (1min záznamy).
|
||||
|
||||
EV nabíječky mají vlastní tabulku `telemetry_ev_charger` s přesným výkonem.
|
||||
|
||||
### Agregovaná spotřeba pro plánování
|
||||
Tabulka `consumption_baseline_interval` ukládá 15min průměry bazální spotřeby:
|
||||
|
||||
- `data_type = 'actual'` – historická skutečnost (zpětně dopočítáno z telemetrie)
|
||||
- `data_type = 'forecast'` – predikce pro plánování
|
||||
|
||||
---
|
||||
|
||||
## Predikce bazální spotřeby
|
||||
|
||||
### Metoda: historický průměr + denní profil
|
||||
|
||||
Jednoduchý model pro začátek:
|
||||
|
||||
```python
|
||||
def forecast_baseline_consumption(site_id: int, target_date: date):
|
||||
"""
|
||||
Predikce bazální spotřeby na základě průměru posledních N podobných dní.
|
||||
Podobnost: stejný den v týdnu, přibližně stejná roční doba.
|
||||
"""
|
||||
lookback_weeks = 4
|
||||
day_of_week = target_date.weekday()
|
||||
|
||||
# Stáhnout historické bazální hodnoty pro stejné dny v týdnu
|
||||
historical = db.query("""
|
||||
SELECT interval_start, power_w
|
||||
FROM consumption_baseline_interval
|
||||
WHERE site_id = %s
|
||||
AND data_type = 'actual'
|
||||
AND EXTRACT(dow FROM interval_start) = %s
|
||||
AND interval_start >= %s
|
||||
ORDER BY interval_start
|
||||
""", site_id, day_of_week, target_date - timedelta(weeks=lookback_weeks))
|
||||
|
||||
# Průměr per 15min slot
|
||||
profile = aggregate_by_time_of_day(historical) # 96 hodnot (15min sloty)
|
||||
return profile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flexibilní zařízení – detailní popis
|
||||
|
||||
### EV nabíječky (Teltonika TeltoCharge 22kW)
|
||||
|
||||
**Komunikace:** Teltonika poskytuje REST API a/nebo OCPP protokol.
|
||||
|
||||
| Parametr | Hodnota |
|
||||
|---|---|
|
||||
| Max výkon | 22 000 W (třífázové) |
|
||||
| Min výkon (1 fáze) | 1 380 W |
|
||||
| Počet na home-01 | 2 |
|
||||
| Protokol | OCPP 1.6 nebo Teltonika REST API |
|
||||
|
||||
**Co systém řídí:**
|
||||
- Povolení/zakázání nabíjení (smart charging on/off)
|
||||
- Omezení výkonu (charge current limit v Amperech)
|
||||
- Časový plán nabíjení (nastavit okno kdy smí nabíjet)
|
||||
|
||||
**Telemetrie (stahuje se každou minutu):**
|
||||
- stav konektoru (available / charging / faulted)
|
||||
- aktuální výkon [W]
|
||||
- kumulativní energie [kWh]
|
||||
- proud [A], napětí [V]
|
||||
- session ID
|
||||
|
||||
**Plánování:**
|
||||
- EV se nabíjí v době levné energie nebo přebytku FVE
|
||||
- Respektuje požadavek uživatele: "nabitý na X % do Y hodin"
|
||||
- Pokud není požadavek nastaven → nabíjí při přebytku nebo nejlevnějším spotu
|
||||
|
||||
> **Otevřený bod:** Teltonika API vs OCPP – rozhodnout při první integraci. Doporučujeme OCPP pro standardizaci.
|
||||
|
||||
---
|
||||
|
||||
### TUV / Tepelné čerpadlo
|
||||
|
||||
**Komunikace:** přes Loxone (HTTP Virtual Input – zapnout/vypnout)
|
||||
|
||||
**Co systém řídí:**
|
||||
- Povolení ohřevu (Loxone přepne výstupní relé)
|
||||
- Systém pošle setpoint do Loxone, Loxone provede
|
||||
|
||||
**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`
|
||||
|
||||
**Plánování:**
|
||||
- TUV se ohřívá v době přebytku FVE nebo levného spotu
|
||||
- Minimální a maximální teplota zásobníku je respektována (pokud máme čidlo)
|
||||
- Nouzová priorita: pokud teplota pod minimum → ohřát bez ohledu na cenu
|
||||
|
||||
---
|
||||
|
||||
## Výpočet bazální spotřeby v auditu
|
||||
|
||||
```sql
|
||||
-- Agregovaná skutečná bazální spotřeba za 15min interval
|
||||
CREATE VIEW consumption_vw_actual_baseline AS
|
||||
SELECT
|
||||
t.site_id,
|
||||
time_bucket('15 minutes', t.measured_at) AS interval_start,
|
||||
AVG(
|
||||
t.load_power_w
|
||||
- COALESCE(ev1.power_w, 0)
|
||||
- COALESCE(ev2.power_w, 0)
|
||||
-- TUV: odečíst max_power pokud byl v daném intervalu aktivní
|
||||
) AS baseline_power_w
|
||||
FROM telemetry_inverter t
|
||||
-- JOIN na EV telemetrii
|
||||
GROUP BY t.site_id, time_bucket('15 minutes', t.measured_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konfigurace (env proměnné)
|
||||
|
||||
```env
|
||||
CONSUMPTION_FORECAST_LOOKBACK_WEEKS=4
|
||||
TELTONIKA_API_URL_1=http://192.168.x.x/api # charger 1
|
||||
TELTONIKA_API_URL_2=http://192.168.x.x/api # charger 2
|
||||
TELTONIKA_POLL_INTERVAL_SEC=60
|
||||
TUV_DEFAULT_POWER_W=2000 # fallback pokud není měřeno
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Teltonika: OCPP vs REST API – rozhodnout před implementací
|
||||
- [ ] TUV teplota zásobníku: přidat čidlo do Loxone pro přesnější řízení
|
||||
- [ ] Bazální spotřeba: zpřesnit odečítání TUV výkonu (ON/OFF × čas vs pevný výkon)
|
||||
- [ ] Sezónní korekce predikce spotřeby (léto vs zima) – fáze 2
|
||||
254
docs/04-modules/control.md
Normal file
254
docs/04-modules/control.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Modul: Control (Export setpointů)
|
||||
|
||||
## Co modul dělá
|
||||
|
||||
- Č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
|
||||
- Odešle potvrzovací setpointy do Loxone přes HTTP (Loxone jako exekutor fallback logiky)
|
||||
- Loguje každý write pro audit
|
||||
|
||||
---
|
||||
|
||||
## Architektura řízení
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
**Loxone role:** Loxone dostává setpointy jako informaci a jako fallback ochranu.
|
||||
Rozhodovací logika je v EMS, ne v Loxone.
|
||||
|
||||
---
|
||||
|
||||
## Spouštění
|
||||
|
||||
| Trigger | Čas | Popis |
|
||||
|---|---|---|
|
||||
| Scheduled | každých 15min (xx:00, xx:15, xx:30, xx:45) | Standardní export na začátku intervalu |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Logika exportu
|
||||
|
||||
```python
|
||||
async def export_setpoints_for_interval(site_id: int, interval_start: datetime, db):
|
||||
"""
|
||||
Načte plánované setpointy pro daný interval, aplikuje overrides
|
||||
a zapíše do všech zařízení.
|
||||
"""
|
||||
|
||||
# 1. Načíst aktivní plán
|
||||
plan = await db.fetchrow("""
|
||||
SELECT pi.*
|
||||
FROM ems.planning_interval pi
|
||||
JOIN ems.planning_run pr ON pr.id = pi.run_id
|
||||
WHERE pr.site_id = $1
|
||||
AND pi.interval_start = $2
|
||||
AND pr.status = 'active'
|
||||
ORDER BY pr.created_at DESC
|
||||
LIMIT 1
|
||||
""", site_id, interval_start)
|
||||
|
||||
if not plan:
|
||||
logger.warning(f"No active plan for site {site_id} at {interval_start}, skipping export")
|
||||
return
|
||||
|
||||
# 2. Načíst a aplikovat overrides
|
||||
overrides = await db.fetch("""
|
||||
SELECT override_type, value_json
|
||||
FROM ems.site_override
|
||||
WHERE site_id = $1
|
||||
AND valid_from <= $2
|
||||
AND (valid_to IS NULL OR valid_to > $2)
|
||||
""", site_id, interval_start)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def apply_overrides(plan, overrides) -> Setpoints:
|
||||
"""Aplikuje override záznamy na plánované setpointy. Override má vždy přednost."""
|
||||
s = Setpoints.from_plan(plan)
|
||||
|
||||
for ov in overrides:
|
||||
if ov.override_type == 'force_charge':
|
||||
s.battery_setpoint_w = ov.value_json.get('power_w', 20000)
|
||||
elif ov.override_type == 'force_discharge':
|
||||
s.battery_setpoint_w = -abs(ov.value_json.get('power_w', 20000))
|
||||
elif ov.override_type == 'block_export':
|
||||
s.grid_setpoint_w = max(0, s.grid_setpoint_w) # jen import povolen
|
||||
elif ov.override_type == 'block_heat_pump':
|
||||
s.heat_pump_enabled = False
|
||||
elif ov.override_type == 'manual_setpoint':
|
||||
s = Setpoints(**ov.value_json) # plný manuální přepis
|
||||
|
||||
return s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zápis do Deye (Modbus)
|
||||
|
||||
```python
|
||||
async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db):
|
||||
inverters = await db.fetch(
|
||||
"SELECT ai.*, 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.controllable = true", site_id
|
||||
)
|
||||
|
||||
for inv in inverters:
|
||||
async with AsyncModbusTcpClient(inv.host, port=inv.port) as client:
|
||||
# Nabíjecí/vybíjecí výkon baterie
|
||||
if setpoints.battery_setpoint_w >= 0:
|
||||
await client.write_register(0x00F3, setpoints.battery_setpoint_w,
|
||||
slave=inv.unit_id) # charge limit
|
||||
await client.write_register(0x00F4, 0, slave=inv.unit_id) # discharge = 0
|
||||
else:
|
||||
await client.write_register(0x00F3, 0, slave=inv.unit_id)
|
||||
await client.write_register(0x00F4, abs(setpoints.battery_setpoint_w),
|
||||
slave=inv.unit_id)
|
||||
|
||||
# Export limit
|
||||
export_limit = max(0, -setpoints.grid_setpoint_w) if setpoints.grid_setpoint_w < 0 else 0
|
||||
await client.write_register(0x00F6, export_limit, slave=inv.unit_id)
|
||||
|
||||
logger.info(f"Inverter {inv.code} setpoints written: batt={setpoints.battery_setpoint_w}W")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zápis do Teltonika EV nabíječek (Modbus)
|
||||
|
||||
```python
|
||||
async def write_ev_charger_setpoints(site_id: int, setpoints: Setpoints, db):
|
||||
chargers = await db.fetch(
|
||||
"SELECT ac.*, se.host, se.port, se.unit_id "
|
||||
"FROM ems.asset_ev_charger ac "
|
||||
"JOIN ems.site_endpoint se ON se.id = ac.endpoint_id "
|
||||
"WHERE ac.site_id = $1 AND ac.schedulable = true", site_id
|
||||
)
|
||||
|
||||
# Rozdělit celkový EV výkon rovnoměrně mezi aktivní nabíječky
|
||||
# (nebo dle stavu session – upřesnit)
|
||||
active_chargers = [c for c in chargers] # TODO: filtrovat dle stavu session
|
||||
power_per_charger = (setpoints.ev_charge_power_w or 0) // max(len(active_chargers), 1)
|
||||
|
||||
for charger in active_chargers:
|
||||
current_limit_a = power_per_charger // (charger.phases * 230) # W → A
|
||||
current_limit_a = max(charger.min_power_w // (charger.phases * 230),
|
||||
min(32, current_limit_a)) # 6–32A dle IEC 61851
|
||||
|
||||
async with AsyncModbusTcpClient(charger.host, port=charger.port) as client:
|
||||
# Zápis limitu proudu (registr dle Teltonika dokumentace)
|
||||
await client.write_register(
|
||||
TBD_CURRENT_LIMIT_REGISTER, current_limit_a, slave=charger.unit_id
|
||||
)
|
||||
# Povolení/zakázání nabíjení
|
||||
enable = 1 if power_per_charger >= charger.min_power_w else 0
|
||||
await client.write_register(
|
||||
TBD_ENABLE_REGISTER, enable, slave=charger.unit_id
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zápis do Samsung TČ (Modbus)
|
||||
|
||||
```python
|
||||
async def write_heat_pump_setpoints(site_id: int, setpoints: Setpoints, db):
|
||||
heat_pumps = await db.fetch(
|
||||
"SELECT ahp.*, se.host, se.port, se.unit_id "
|
||||
"FROM ems.asset_heat_pump ahp "
|
||||
"JOIN ems.site_endpoint se ON se.id = ahp.endpoint_id "
|
||||
"WHERE ahp.site_id = $1 AND ahp.schedulable = true", site_id
|
||||
)
|
||||
|
||||
for hp in heat_pumps:
|
||||
async with AsyncModbusTcpClient(hp.host, port=hp.port) as client:
|
||||
enable = 1 if setpoints.heat_pump_enabled else 0
|
||||
await client.write_register(
|
||||
TBD_HP_ENABLE_REGISTER, enable, slave=hp.unit_id
|
||||
)
|
||||
if setpoints.heat_pump_enabled and setpoints.heat_pump_setpoint_w:
|
||||
# Nastavit cílovou teplotu TUV (pokud podporuje Modbus zápis)
|
||||
await client.write_register(
|
||||
TBD_HP_TARGET_TEMP_REGISTER,
|
||||
int(hp.tuv_target_temp_c * 10), # 0.1°C jednotky
|
||||
slave=hp.unit_id
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loxone HTTP Virtual Inputs
|
||||
|
||||
Loxone dostává setpointy jako informaci. Slouží pro:
|
||||
- Zobrazení v Loxone UI
|
||||
- Fallback logiku v Loxone (pokud EMS nedostupné)
|
||||
- Vizualizaci plánovaného stavu
|
||||
|
||||
```python
|
||||
async def write_loxone_setpoints(site_id: int, setpoints: Setpoints, db):
|
||||
endpoint = await db.fetchrow(
|
||||
"SELECT host, port, auth_reference FROM ems.site_endpoint "
|
||||
"WHERE site_id = $1 AND endpoint_type = 'loxone_http'", site_id
|
||||
)
|
||||
if not endpoint:
|
||||
return
|
||||
|
||||
base_url = f"http://{endpoint.host}:{endpoint.port}/dev/sps/io"
|
||||
|
||||
# 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}")
|
||||
```
|
||||
|
||||
> Virtual Input jména v Loxone (`EMS_BatterySetpoint` 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- [ ] 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?)
|
||||
285
docs/04-modules/ev-charging.md
Normal file
285
docs/04-modules/ev-charging.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Modul: EV Nabíjení
|
||||
|
||||
## Přehled vozidel na home-01
|
||||
|
||||
| Vozidlo | Nabíječka | Max výkon | Řízení | API |
|
||||
|---|---|---|---|---|
|
||||
| Tesla | ev-charger-1 (Teltonika 22kW) | 22 kW | WB proud limit + Tesla API | Zatím nerozhodnuto (Tessie nebo přímé) |
|
||||
| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max ~7-11kW) | WB proud limit (Zoe respektuje) | Žádné – Zoe jako fixní zátěž při připojení |
|
||||
|
||||
---
|
||||
|
||||
## Klíčové principy
|
||||
|
||||
### 1. Přímé FVE nabíjení preferováno před průchodem přes baterii
|
||||
|
||||
Energie která jde FVE → baterie → EV má round-trip ztráty:
|
||||
```
|
||||
η_round_trip = η_charge × η_discharge ≈ 0.95 × 0.95 ≈ 0.90
|
||||
```
|
||||
|
||||
Přímé napájení FVE → EV (nebo síť → EV) je ~10 % efektivnější.
|
||||
Solver to vidí přes vyšší efektivní cenu energie procházející baterií (degradation_cost + round-trip loss).
|
||||
|
||||
### 2. Deadline charging
|
||||
|
||||
Každé vozidlo může mít nastaven:
|
||||
- **cílový SoC** (%)
|
||||
- **deadline** (do kdy musí být dosažen)
|
||||
|
||||
Solver garantuje dosažení SoC do deadline jako hard constraint.
|
||||
Ekonomická optimalizace probíhá v rámci tohoto omezení.
|
||||
|
||||
### 3. Zoe – řízení přes WB proud limit
|
||||
|
||||
Zoe respektuje maximální proud nastavený na WB (Teltonika Modbus).
|
||||
Solver nastaví `current_limit_a` pro daný slot.
|
||||
Zoe vždy nabíjí pokud je připojena a proud > 6A.
|
||||
|
||||
Scheduler v Zoe se nepoužívá – WB proud limit je jediný řídicí prvek.
|
||||
|
||||
### 4. Tesla – WB + volitelně Tesla API
|
||||
|
||||
V první fázi stejný přístup jako Zoe – proud limit přes WB.
|
||||
Tesla API (Tessie nebo přímé) přidáme ve fázi 2 pro:
|
||||
- čtení aktuálního SoC bez dotazování WB
|
||||
- čtení stavu připojení
|
||||
- případné spuštění/zastavení nabíjení přímo v autě
|
||||
|
||||
---
|
||||
|
||||
## DB rozšíření – EV session a deadline
|
||||
|
||||
### Tabulka `ems.ev_session`
|
||||
|
||||
```sql
|
||||
CREATE TABLE ems.ev_session (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site_id INT NOT NULL REFERENCES ems.site(id),
|
||||
charger_id INT NOT NULL REFERENCES ems.asset_ev_charger(id),
|
||||
vehicle_id INT REFERENCES ems.asset_vehicle(id),
|
||||
session_start TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
session_end TIMESTAMPTZ,
|
||||
-- Stav při připojení
|
||||
soc_at_connect_pct NUMERIC(5,2),
|
||||
-- Deadline požadavek (nastavuje uživatel nebo API)
|
||||
target_soc_pct NUMERIC(5,2),
|
||||
target_deadline TIMESTAMPTZ,
|
||||
-- Výsledek
|
||||
soc_at_disconnect_pct NUMERIC(5,2),
|
||||
energy_delivered_kwh NUMERIC(10,3),
|
||||
cost_czk NUMERIC(10,4)
|
||||
);
|
||||
```
|
||||
|
||||
### Tabulka `ems.asset_vehicle`
|
||||
|
||||
```sql
|
||||
CREATE TABLE ems.asset_vehicle (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site_id INT NOT NULL REFERENCES ems.site(id),
|
||||
code TEXT NOT NULL,
|
||||
name TEXT,
|
||||
make TEXT, -- 'Tesla', 'Renault'
|
||||
model TEXT, -- 'Model Y', 'Zoe'
|
||||
battery_capacity_kwh NUMERIC(6,2), -- Tesla ~75, Zoe ~52
|
||||
max_charge_power_w INT, -- max přijímaný výkon vozidla
|
||||
default_charger_id INT REFERENCES ems.asset_ev_charger(id),
|
||||
api_type TEXT, -- 'tesla', 'none'
|
||||
api_reference TEXT, -- odkaz na credentials v env
|
||||
default_target_soc_pct NUMERIC(5,2) DEFAULT 80,
|
||||
default_deadline_hour INT DEFAULT 7 -- 7:00 ráno jako výchozí deadline
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solver rozšíření – EV s round-trip a deadline
|
||||
|
||||
### Nové proměnné pro každý slot t a každé EV e
|
||||
|
||||
```python
|
||||
ev_direct[e][t] # W – přímé napájení EV z FVE nebo sítě (bez průchodu baterií)
|
||||
ev_via_bat[e][t] # W – napájení EV přes baterii (vyšší efektivní cena)
|
||||
|
||||
# Celkový výkon EV (co jde do auta)
|
||||
ev_charge[e][t] = ev_direct[e][t] + ev_via_bat[e][t]
|
||||
|
||||
# Co ev_via_bat stojí energeticky navíc:
|
||||
# ev_via_bat musí být "nakoupeno" z baterie s round-trip ztrátou
|
||||
# solver to vidí přes účelovou funkci – viz níže
|
||||
```
|
||||
|
||||
### Energetická bilance rozšířená o přímé EV
|
||||
|
||||
```python
|
||||
# Zdroje = Spotřeba
|
||||
pv_a_net[t] + pv_b[t] + grid_import[t] + batt_discharge[t]
|
||||
== load_baseline[t]
|
||||
+ Σ_e ev_direct[e][t] # přímá spotřeba EV
|
||||
+ Σ_e ev_via_bat[e][t] # EV přes baterii (z discharge)
|
||||
+ heat_pump[t]
|
||||
+ batt_charge[t]
|
||||
+ grid_export[t]
|
||||
|
||||
# Vazba: ev_via_bat[e][t] musí pokrýt batt_discharge[t]
|
||||
# (solver to vyřeší sám – discharge jde buď do ev_via_bat nebo do load)
|
||||
```
|
||||
|
||||
### Účelová funkce – efektivní cena EV přes baterii
|
||||
|
||||
```python
|
||||
# Nabíjení přes baterii je dražší o round-trip ztrátu a degradaci:
|
||||
EV_VIA_BAT_COST_FACTOR = 1.0 / (charge_efficiency * discharge_efficiency)
|
||||
# ≈ 1.0 / (0.95 * 0.95) ≈ 1.108
|
||||
|
||||
# V objective function:
|
||||
+ ev_via_bat[e][t] * buy_price[t] * EV_VIA_BAT_COST_FACTOR * H / 1000
|
||||
+ ev_direct[e][t] * buy_price[t] * H / 1000 # přímé – bez navýšení
|
||||
|
||||
# Solver přirozeně preferuje přímé nabíjení kde je to možné
|
||||
```
|
||||
|
||||
### Deadline constraint
|
||||
|
||||
```python
|
||||
# Pro každé EV e s nastaveným deadline:
|
||||
if ev_session[e].target_deadline is not None:
|
||||
|
||||
# Kolik energie ještě potřebujeme dodat
|
||||
energy_needed_wh = (
|
||||
(ev_session[e].target_soc_pct - ev_session[e].current_soc_pct)
|
||||
/ 100.0 * vehicle[e].battery_capacity_kwh * 1000
|
||||
)
|
||||
|
||||
# Deadline slot index
|
||||
t_deadline = slot_index_for(ev_session[e].target_deadline)
|
||||
|
||||
# Hard constraint: součet dodané energie do deadline musí být >= potřebná
|
||||
prob += pulp.lpSum(
|
||||
ev_charge[e][t] * H # Wh za 15min slot
|
||||
for t in range(t_deadline + 1)
|
||||
if ev_connected[e][t] # jen sloty kdy je auto připojeno
|
||||
) >= energy_needed_wh
|
||||
|
||||
# Zoe má tvrdší deadline (menší baterie, kritičtější)
|
||||
# Tesla může mít měkčí deadline nebo vyšší flexibility okno
|
||||
```
|
||||
|
||||
### Připojení EV – vstupní podmínka
|
||||
|
||||
```python
|
||||
# ev_connected[e][t] = True/False
|
||||
# Pokud auto není připojeno → ev_charge[e][t] = 0
|
||||
|
||||
for t in range(T):
|
||||
if not ev_connected[e][t]:
|
||||
prob += ev_charge[e][t] == 0
|
||||
prob += ev_direct[e][t] == 0
|
||||
prob += ev_via_bat[e][t] == 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Jak solver rozhoduje (příklady)
|
||||
|
||||
### Přebytek FVE přes poledne, Zoe připojena, baterie poloprázdná
|
||||
|
||||
```
|
||||
Solver volí:
|
||||
ev_direct[zoe][t] = max(min(surplus_w, zoe_max_w), 0) ← přímé z FVE
|
||||
batt_charge[t] = zbývající surplus ← do baterie až pak
|
||||
|
||||
Protože přímé nabíjení Zoe je levnější než FVE → baterie → Zoe.
|
||||
```
|
||||
|
||||
### Noc, Zoe má deadline 7:00 s SoC 20% (potřeba 30 kWh)
|
||||
|
||||
```
|
||||
Solver:
|
||||
- Rozloží nabíjení do nejlevnějších nočních slotů
|
||||
- Garantuje dodání 30 kWh do 7:00 (hard constraint)
|
||||
- Pokud jsou sloty se zápornou cenou → nabíjí naplno v těch slotech
|
||||
- Vyhýbá se nabíjení přes baterii pokud není přebytek
|
||||
```
|
||||
|
||||
### Tesla připojena, SoC 70%, deadline není nastaven
|
||||
|
||||
```
|
||||
Solver:
|
||||
- Tesla je "oportunistická" – nabíjí jen při přebytku FVE nebo levné ceně
|
||||
- Bez deadline = měkká optimalizace, ne hard constraint
|
||||
- Nastavit default_target_soc = 80% s default_deadline = zítra 7:00
|
||||
(konfigurovatelné v asset_vehicle)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zjištění stavu připojení
|
||||
|
||||
### Teltonika WB (oba vozy)
|
||||
|
||||
Modbus registr stavu konektoru (status):
|
||||
- `available` = žádné auto
|
||||
- `preparing` / `charging` = auto připojeno
|
||||
|
||||
Polling každou minutu z `telemetry_ev_charger.status`.
|
||||
|
||||
### Tesla API (fáze 2)
|
||||
|
||||
Přes Tessie nebo přímé Tesla API:
|
||||
- SoC baterie auta
|
||||
- Stav připojení (plugged_in)
|
||||
- Nabíjecí stav (charging / stopped)
|
||||
|
||||
Uložit do `ev_session` při připojení/odpojení.
|
||||
|
||||
### Renault Zoe
|
||||
|
||||
Žádné API. Stav připojení čteme výhradně z WB Modbus (`status != 'available'`).
|
||||
SoC Zoe neznáme přesně – použijeme energii dodanou v session (kumulativní kWh z WB).
|
||||
|
||||
---
|
||||
|
||||
## Seed data – vozidla home-01
|
||||
|
||||
```sql
|
||||
-- V006__vehicles.sql
|
||||
|
||||
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)
|
||||
SELECT
|
||||
s.id, 'tesla-my', 'Tesla Model Y', 'Tesla', 'Model Y',
|
||||
75.0, 11000, -- Tesla Model Y AC max ~11kW
|
||||
ch.id, 'none', -- Tesla API fáze 2
|
||||
80, 7
|
||||
FROM ems.site s
|
||||
JOIN ems.asset_ev_charger ch ON ch.site_id = s.id AND ch.code = 'ev-charger-1'
|
||||
WHERE s.code = 'home-01';
|
||||
|
||||
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)
|
||||
SELECT
|
||||
s.id, 'zoe-r135', 'Renault Zoe R135', 'Renault', 'Zoe R135',
|
||||
52.0, 7400, -- Zoe max 7.4kW AC
|
||||
ch.id, 'none',
|
||||
90, 7 -- Zoe: vyšší target SoC (menší baterie, kritičtější)
|
||||
FROM ems.site s
|
||||
JOIN ems.asset_ev_charger ch ON ch.site_id = s.id AND ch.code = 'ev-charger-2'
|
||||
WHERE s.code = 'home-01';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Tesla API: Tessie vs přímé API – rozhodnout ve fázi 2
|
||||
- [ ] Ověřit Zoe max nabíjecí výkon (7.4 kW nebo méně dle podmínek)
|
||||
- [ ] Ověřit round-trip efficiency na reálných datech po prvních týdnech provozu
|
||||
- [ ] UI pro nastavení deadline a target SoC uživatelem (před odjezdem)
|
||||
- [ ] Notifikace pokud deadline nelze splnit (nedostatek kapacity WB nebo energie)
|
||||
- [ ] Zoe SoC estimace z kumulativní energie session – přesnost ověřit
|
||||
180
docs/04-modules/forecast.md
Normal file
180
docs/04-modules/forecast.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Modul: Forecast (Predikce výroby FVE)
|
||||
|
||||
## Co modul dělá
|
||||
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## FVE pole na první instalaci (home-01)
|
||||
|
||||
| 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ě** |
|
||||
|
||||
> **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.
|
||||
|
||||
> Azimuty a sklony je nutné doplnit při konfiguraci lokality do `asset_pv_array`.
|
||||
|
||||
---
|
||||
|
||||
## Zdroj meteorologických dat
|
||||
|
||||
**Primární: Open-Meteo (open-meteo.com)**
|
||||
|
||||
- Zdarma pro nekomerční použití, API bez registrace
|
||||
- Poskytuje GHI (Global Horizontal Irradiance), DNI, teplotu, oblačnost
|
||||
- Historická data + forecast na 7–16 dní dopředu
|
||||
- 15min granularita nativně ✓
|
||||
|
||||
**Endpoint:**
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
**Záložní / budoucí: Solcast**
|
||||
- Přesnější pro FVE, ale placený
|
||||
- Podporuje per-array predikci s azimutem a sklonem přímo
|
||||
- Zatím neimplementujeme, architektura to umožňuje přes `forecast_source`
|
||||
|
||||
---
|
||||
|
||||
## Výpočet výkonu z irradiance
|
||||
|
||||
Jednoduchý fyzikální model (dostatečný pro plánování):
|
||||
|
||||
```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)
|
||||
|
||||
# 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))
|
||||
```
|
||||
|
||||
> **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
|
||||
|
||||
**Python service: `forecast_service`**
|
||||
|
||||
### Kdy se spouští
|
||||
|
||||
| Trigger | Čas | Popis |
|
||||
|---|---|---|
|
||||
| Scheduled (cron) | každý den 14:30 CET | Po importu cen, před plánováním |
|
||||
| Scheduled (cron) | každý den 06:00 CET | Aktualizace predikce na dnešní den |
|
||||
| Před plánováním | automaticky | Plánovač zkontroluje čerstvost, spustí pokud starší než 2h |
|
||||
| Manual trigger | na vyžádání | `POST /admin/run-forecast?site_id=1&date=YYYY-MM-DD` |
|
||||
|
||||
---
|
||||
|
||||
## Logika běhu predikce
|
||||
|
||||
```python
|
||||
def run_forecast(site_id: int, horizon_days: int = 2):
|
||||
site = db.get_site(site_id)
|
||||
arrays = db.get_pv_arrays(site_id, controllable=True)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# 2. Vytvořit forecast_pv_run
|
||||
run = db.create_forecast_run(
|
||||
site_id=site_id,
|
||||
pv_array_id=array.id,
|
||||
forecast_source="open_meteo",
|
||||
horizon_start=today_00,
|
||||
horizon_end=today_end + horizon_days
|
||||
)
|
||||
|
||||
# 3. Vypočítat a uložit intervaly (15min)
|
||||
intervals = []
|
||||
for slot in weather.slots_15min:
|
||||
power = calculate_pv_power(
|
||||
irradiance_wm2=slot.shortwave_radiation,
|
||||
temp_c=slot.temperature_2m,
|
||||
nominal_power_wp=array.nominal_power_wp,
|
||||
azimuth_deg=array.azimuth_deg,
|
||||
tilt_deg=array.tilt_deg,
|
||||
shading_factor=array.shading_factor
|
||||
)
|
||||
intervals.append(ForecastInterval(
|
||||
run_id=run.id,
|
||||
pv_array_id=array.id,
|
||||
interval_start=slot.time,
|
||||
power_w=power,
|
||||
irradiance_wm2=slot.shortwave_radiation,
|
||||
temp_c=slot.temperature_2m
|
||||
))
|
||||
|
||||
db.upsert_forecast_intervals(intervals)
|
||||
db.update_forecast_run_status(run.id, "ok")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DB struktura
|
||||
|
||||
Viz `03-data-model.md`:
|
||||
- `forecast_pv_run` – každý běh predikce
|
||||
- `forecast_pv_interval` – 15min výsledky per pole a běh
|
||||
|
||||
---
|
||||
|
||||
## Konfigurace (env proměnné)
|
||||
|
||||
```env
|
||||
OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast
|
||||
FORECAST_HORIZON_DAYS=3
|
||||
FORECAST_MAX_AGE_HOURS=2 # plánovač odmítne starší predikci
|
||||
FORECAST_RETRY_COUNT=3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Alert pokud forecast pro dnešní 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ů
|
||||
- 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
|
||||
- [ ] Solcast jako alternativa v budoucnu – `forecast_source` to umožňuje bez DB změn
|
||||
107
docs/04-modules/heat-pump.md
Normal file
107
docs/04-modules/heat-pump.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Modul: Tepelné čerpadlo (Heat Pump)
|
||||
|
||||
## Zařízení
|
||||
|
||||
**Samsung tepelné čerpadlo** s Modbus modulem pro dálkové řízení.
|
||||
Komunikace: Modbus RTU → Waveshare WS-ETH → Modbus TCP.
|
||||
Loxone šablona k dispozici (reference pro Modbus registry).
|
||||
|
||||
## Co systém řídí
|
||||
|
||||
- Povolení/zakázání provozu (Modbus příkaz)
|
||||
- Požadovaná teplota TUV zásobníku (Modbus setpoint)
|
||||
- Plánování okna provozu na základě COP a ceny elektřiny
|
||||
|
||||
## Co systém nečte (z Loxone šablony nebo Modbus registrů)
|
||||
|
||||
- Venkovní teplota čerpadla (`outdoor_temp_c`)
|
||||
- Teplota zásobníku TUV (`tuv_tank_temp_c`)
|
||||
- Příkon (`power_w`)
|
||||
- Provozní režim (`operating_mode`)
|
||||
- Alarm kód (`alarm_code`)
|
||||
- Stav odmrazování (`defrost_active`)
|
||||
|
||||
---
|
||||
|
||||
## Logika řízení
|
||||
|
||||
### Prioritní pravidla (v pořadí)
|
||||
|
||||
1. **Override blokování** (`site_override.override_type = 'block_heat_pump'`) → TČ se nespouští
|
||||
2. **Nouzový ohřev** – teplota zásobníku pod `tuv_min_temp_c` → spustit bez ohledu na cenu
|
||||
3. **Zásobník plný** – teplota nad `tuv_max_temp_c` → neohřívat
|
||||
4. **Ekonomické rozhodnutí** – spustit pokud cena tepla ≤ prahová hodnota
|
||||
|
||||
Logika je implementována v PostgreSQL funkci `ems.fn_heat_pump_should_run()`.
|
||||
|
||||
### Výpočet COP
|
||||
|
||||
COP závisí primárně na **venkovní teplotě**:
|
||||
- Vyšší venkovní teplota = lepší COP = levnější teplo
|
||||
- V chladných měsících je přes poledne venkovní teplota nejvyšší → optimální čas pro ohřev TUV
|
||||
|
||||
```
|
||||
COP(t_venkovní) ≈ COP_rated + (t_venkovní - t_reference) × 0.10
|
||||
```
|
||||
|
||||
Funkce: `ems.fn_cop_estimate(heat_pump_id, outdoor_temp_c)`
|
||||
|
||||
Cena tepla = cena elektřiny / COP → funkce `ems.fn_heat_pump_cost_per_kwh_heat()`
|
||||
|
||||
### Denní provozní okno
|
||||
|
||||
Typický scénář v chladných měsících (říjen–březen):
|
||||
- Přes poledne (11:00–14:00) je venkovní teplota nejvyšší → COP nejlepší
|
||||
- Pokud je zároveň spot cena nízká nebo FVE přebytek → ideální okno
|
||||
- TČ potřebuje cca 1–2 hodiny denně pro nahrání TUV zásobníku (závisí na objemu a teplotním rozdílu)
|
||||
|
||||
Plánovací horizont: TČ se plánuje v rámci standardního 15min plánování stejně jako ostatní flexibilní spotřebiče.
|
||||
|
||||
---
|
||||
|
||||
## Omezení kompresoru
|
||||
|
||||
| Parametr | Hodnota | Důvod |
|
||||
|---|---|---|
|
||||
| `min_run_duration_min` | 30 min | Ochrana před krátkými cykly |
|
||||
| `min_stop_duration_min` | 15 min | Vyrovnání tlaku před restartem |
|
||||
|
||||
Plánování musí respektovat tato omezení – nevytvářet plán s kratšími ON/OFF cykly.
|
||||
|
||||
---
|
||||
|
||||
## Modbus registry (doplnit z dokumentace Samsung)
|
||||
|
||||
> Konkrétní registry doplnit z Loxone šablony a Samsung Modbus dokumentace.
|
||||
|
||||
| Registr | Typ | Popis |
|
||||
|---|---|---|
|
||||
| TBD | Read | Venkovní teplota |
|
||||
| TBD | Read | Teplota zásobníku TUV |
|
||||
| TBD | Read | Příkon |
|
||||
| TBD | Read | Provozní režim |
|
||||
| TBD | Read | Alarm kód |
|
||||
| TBD | Write | Povolení provozu (on/off) |
|
||||
| TBD | Write | Požadovaná teplota TUV |
|
||||
|
||||
---
|
||||
|
||||
## Integrace s Loxone
|
||||
|
||||
Alternativní cesta (pokud Modbus přímý přístup není z nějakého důvodu vhodný):
|
||||
- Loxone čte Modbus a vystavuje stav přes Virtual Outputs
|
||||
- EMS posílá setpointy do Loxone přes HTTP Virtual Inputs
|
||||
- Loxone přepisuje Modbus registry
|
||||
|
||||
Pro začátek doporučujeme **přímý Modbus TCP** (přes Waveshare) bez Loxone prostředníka pro řídící příkazy, Loxone nechat jako fallback.
|
||||
|
||||
---
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Doplnit konkrétní Modbus registry ze Samsung dokumentace / Loxone šablony
|
||||
- [ ] Doplnit model Samsung a jmenovitý výkon do seed dat
|
||||
- [ ] Ověřit `min_run_duration_min` a `min_stop_duration_min` z dokumentace
|
||||
- [ ] Kalibrovat COP model na reálná historická data po prvních 4–6 týdnech provozu
|
||||
- [ ] Rozhodnout: přímý Modbus TCP nebo přes Loxone jako prostředník
|
||||
- [ ] Doplnit objem zásobníku TUV pro výpočet doby ohřevu
|
||||
128
docs/04-modules/market-prices.md
Normal file
128
docs/04-modules/market-prices.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Modul: Market Prices (Spotové ceny OTE CZ)
|
||||
|
||||
## Co modul dělá
|
||||
|
||||
- Stahuje spotové ceny elektřiny z OTE CZ
|
||||
- Ukládá raw data bez vazby na lokalitu (sdílená tabulka)
|
||||
- Efektivní ceny (s marží) se dopočítávají per site přes view
|
||||
- Granularita: **15 minut** nativně (OTE CZ publikuje po hodinách → konvertujeme na 15min replikací)
|
||||
|
||||
---
|
||||
|
||||
## Zdroj dat: OTE CZ
|
||||
|
||||
**URL:** `https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh`
|
||||
|
||||
OTE CZ publikuje denní ceny zpravidla **den předem (D-1)** okolo 13:00–14:00 středoevropského času.
|
||||
|
||||
### Formát dat OTE CZ
|
||||
|
||||
OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky:
|
||||
1. Stáhnout XML/JSON feed nebo scrape HTML tabulky
|
||||
2. Převést EUR/MWh → CZK/kWh (kurz ČNB nebo fixní koeficient dle konfigurace)
|
||||
3. Rozložit hodinový interval na 4× 15min sloty (stejná hodnota)
|
||||
4. Uložit do `market_interval_price`
|
||||
|
||||
### Alternativní API
|
||||
|
||||
- **OTE XML feed:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?date=YYYY-MM-DD&market=DAM&type=FIN`
|
||||
- Autentikace: nepotřebná pro veřejná data
|
||||
|
||||
---
|
||||
|
||||
## Kdo stahuje data
|
||||
|
||||
**Python service: `price_importer`**
|
||||
|
||||
Samostatný modul (ne součást FastAPI, ale může být volán z ní jako task).
|
||||
|
||||
### Kdy se spouští
|
||||
|
||||
| Trigger | Čas | Popis |
|
||||
|---|---|---|
|
||||
| Scheduled (cron) | každý den 14:00 CET | Stažení cen na zítřek (D+1) |
|
||||
| Scheduled (cron) | každý den 00:05 CET | Kontrola – ověření že dnešní data jsou v DB |
|
||||
| Manual trigger | na vyžádání | API endpoint `POST /admin/import-prices?date=YYYY-MM-DD` |
|
||||
| Retry | při chybě, 3× s backoffem | Automatický opakovaný pokus |
|
||||
|
||||
### Logika importu
|
||||
|
||||
```python
|
||||
# Pseudologika importu (implementace v price_importer.py)
|
||||
|
||||
def import_prices_for_date(date: date, source: str = "OTE_CZ"):
|
||||
# 1. Zkontrolovat jestli data pro daný den už existují
|
||||
existing = db.query("SELECT COUNT(*) FROM market_interval_price WHERE interval_start::date = %s AND market_source = %s", date, source)
|
||||
if existing > 0 and not force_reimport:
|
||||
log.info("Data already exist, skipping")
|
||||
return
|
||||
|
||||
# 2. Stáhnout z OTE API
|
||||
raw_data = ote_client.fetch_dam_prices(date) # vrátí list hodinových cen v EUR/MWh
|
||||
|
||||
# 3. Konvertovat EUR/MWh → CZK/kWh
|
||||
eur_czk_rate = get_exchange_rate() # z konfigurace nebo ČNB API
|
||||
czk_per_kwh = [(price_eur_mwh * eur_czk_rate) / 1000 for price in raw_data]
|
||||
|
||||
# 4. Rozložit na 15min intervaly (1 hodina = 4 sloty se stejnou cenou)
|
||||
intervals = expand_hourly_to_15min(czk_per_kwh, date)
|
||||
|
||||
# 5. Upsert do DB (idempotentní)
|
||||
db.upsert_many("market_interval_price", intervals, conflict_keys=["market_source", "interval_start"])
|
||||
log.info(f"Imported {len(intervals)} intervals for {date}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Struktura DB záznamu
|
||||
|
||||
Viz `03-data-model.md` → tabulka `market_interval_price`.
|
||||
|
||||
Klíčové body:
|
||||
- `buy_raw_price_czk_kwh` a `sell_raw_price_czk_kwh` jsou **oddělené**
|
||||
- Pro OTE CZ je v první verzi `sell_raw_price = buy_raw_price` (reference cena)
|
||||
- `imported_at` slouží pro audit importů
|
||||
|
||||
---
|
||||
|
||||
## Efektivní ceny per site
|
||||
|
||||
Viz view `market_vw_site_effective_price` v `03-data-model.md`.
|
||||
|
||||
Marže se konfigurují v `site_market_config`:
|
||||
|
||||
| Parametr | Typ | Příklad |
|
||||
|---|---|---|
|
||||
| `buy_margin_fixed_czk` | Kč/kWh | 0.05 (5 haléřů/kWh) |
|
||||
| `buy_margin_percent` | % | 2.5 |
|
||||
| `sell_margin_fixed_czk` | Kč/kWh | -0.02 (srážka) |
|
||||
| `sell_margin_percent` | % | 0 |
|
||||
|
||||
---
|
||||
|
||||
## Konfigurace (env proměnné)
|
||||
|
||||
```env
|
||||
OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam
|
||||
OTE_IMPORT_HOUR=14 # hodina kdy se spouští denní import
|
||||
EUR_CZK_RATE=25.0 # fallback kurz pokud ČNB API nedostupné
|
||||
CNB_API_URL=https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml
|
||||
PRICE_IMPORT_RETRY_COUNT=3
|
||||
PRICE_IMPORT_RETRY_BACKOFF_SEC=300
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring a alerting
|
||||
|
||||
- Alert pokud do 16:00 nejsou v DB ceny na zítřek
|
||||
- Log každého importu (datum, počet intervalů, zdroj, trvání)
|
||||
- Endpoint `GET /health/prices?date=YYYY-MM-DD` → vrátí počet importovaných intervalů
|
||||
|
||||
---
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Kurz EUR/CZK: fixní hodnota vs denní stahování z ČNB – rozhodnout před implementací
|
||||
- [ ] OTE nabízí i intraday ceny – zatím neimplementujeme
|
||||
- [ ] Sell price: OTE nemá oddělenou nákupní a prodejní raw cenu, obě = DAM cena; může se lišit u jiných zdrojů
|
||||
132
docs/04-modules/operating-modes.md
Normal file
132
docs/04-modules/operating-modes.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Modul: Operating Modes (Provozní režimy)
|
||||
|
||||
## Koncept
|
||||
|
||||
EMS a Loxone komunikují přes **provozní režimy** – pojmenované stavy které mají smysl pro obě strany.
|
||||
|
||||
EMS rozhoduje a přepíná režimy. Loxone dostane kód režimu jako číslo přes Virtual Input a ví jak se v daném režimu chovat **autonomně a nezávisle na EMS**.
|
||||
|
||||
```
|
||||
EMS backend (každou minutu)
|
||||
→ HTTP GET /dev/sps/io/EMS_Heartbeat/1 ← pulz do Loxone
|
||||
|
||||
EMS backend (při přepnutí režimu)
|
||||
→ ems.fn_set_mode(site_id, 'SELF_SUSTAIN') ← zapsat do DB
|
||||
→ HTTP GET /dev/sps/io/EMS_Mode/2 ← informovat Loxone
|
||||
|
||||
Loxone (zcela nezávisle na EMS)
|
||||
→ sleduje přítomnost EMS_Heartbeat pulzů
|
||||
→ pokud 5min žádný pulz → sám přepne na SELF_SUSTAIN
|
||||
→ řídí střídač dle aktivního režimu bez čekání na setpointy
|
||||
```
|
||||
|
||||
**Klíčový princip:** Loxone watchdog nečte DB. Sleduje pouze HTTP pulzy přímo.
|
||||
Pokud padne celý server (RPi, Docker, síť) – Loxone to pozná sám a přepne bezpečný režim.
|
||||
|
||||
Viz `docs/loxone-integration.md` pro kompletní popis Loxone implementace.
|
||||
|
||||
---
|
||||
|
||||
## Přehled režimů
|
||||
|
||||
| Kód | Loxone int | EV | TČ | Baterie | Síť | Loxone autonomní |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `AUTO` | 1 | dle plánu | dle plánu | dle plánu | dle plánu | **ne** – čeká na setpointy |
|
||||
| `SELF_SUSTAIN` | 2 | ❌ stop | ❌ stop | vybíjí do domu | bez exportu | **ano** |
|
||||
| `CHARGE_CHEAP` | 3 | ❌ stop | ❌ stop | max nabíjení | import ok | **ne** – EMS posílá výkon |
|
||||
| `PRESERVE` | 4 | ❌ stop | ❌ stop | drží SoC | import ok | **ano** |
|
||||
| `MANUAL` | 0 | ❌ stop | ❌ stop | žádné akce | žádné akce | **ano** |
|
||||
|
||||
### `AUTO`
|
||||
Normální provoz. EMS posílá přesné setpointy W každých 15 minut.
|
||||
Loxone je čistý exekutor – přijme číslo a zapíše do střídače.
|
||||
Pokud setpoint nepřijde (výpadek EMS) → Loxone watchdog přepne na `SELF_SUSTAIN`.
|
||||
|
||||
### `SELF_SUSTAIN` ← výchozí stav + fallback
|
||||
Aktivuje se:
|
||||
- automaticky watchdogem při výpadku EMS (5min bez pulzu)
|
||||
- manuálně uživatelem z UI (dovolená, odchod z domu)
|
||||
- při prvním startu systému (seed data)
|
||||
|
||||
Loxone sám bez EMS:
|
||||
- FVE pokrývá spotřebu
|
||||
- baterie vybíjí do domu (ne do sítě)
|
||||
- blokuje export do sítě
|
||||
- zastavuje EV nabíjení a TČ
|
||||
|
||||
### `CHARGE_CHEAP`
|
||||
Manuální přepis. EMS posílá max charge setpoint.
|
||||
Použít při levné ceně nebo přetoku FVE ze sousedství (pokud víš o levné ceně dopředu).
|
||||
|
||||
### `PRESERVE`
|
||||
Dovolená / servis. Loxone drží baterii na aktuálním SoC, žádné optimalizace.
|
||||
Autonomní – Loxone nevyžaduje setpointy od EMS.
|
||||
|
||||
### `MANUAL`
|
||||
Technické práce. Žádná logika neřídí střídač. Pouze pro servis.
|
||||
|
||||
---
|
||||
|
||||
## Přepínání z UI (React)
|
||||
|
||||
```
|
||||
POST /api/sites/{site_id}/mode
|
||||
{
|
||||
"mode": "SELF_SUSTAIN",
|
||||
"valid_until": null, // nebo "2025-03-15T06:00:00+01:00" pro dočasný přepis
|
||||
"notes": "Odjezd na dovolenou"
|
||||
}
|
||||
```
|
||||
|
||||
Backend při přepnutí:
|
||||
1. Zavolá `ems.fn_set_mode(site_id, mode, 'user:'+username)` → zápis do DB + log
|
||||
2. Okamžitě odešle HTTP do Loxone: `/dev/sps/io/EMS_Mode/{loxone_mode_value}`
|
||||
3. Pokud `CHARGE_CHEAP` nebo návrat na `AUTO` → spustí replanning
|
||||
|
||||
**Dočasný přepis s automatickým návratem:**
|
||||
`fn_expire_modes()` běží každou minutu a přepíná zpět lokality s prosahlým `valid_until`.
|
||||
|
||||
---
|
||||
|
||||
## EMS restart / reconnect
|
||||
|
||||
Při startu backendu:
|
||||
1. Přečíst z Loxone aktuální `EMS_Mode_Active` (Virtual Output) přes HTTP GET
|
||||
2. Porovnat s `ems.site_operating_mode` v DB
|
||||
3. Pokud Loxone přepnul na `SELF_SUSTAIN` během výpadku → logovat, informovat, spustit nový plán
|
||||
4. Přepnout na `AUTO` a začít posílat setpointy + heartbeat pulzy
|
||||
|
||||
---
|
||||
|
||||
## Heartbeat v DB – pouze informační
|
||||
|
||||
Tabulka `ems.site_heartbeat` zaznamenává kdy EMS naposledy úspěšně odeslal pulz do Loxone.
|
||||
Slouží pro EMS dashboard (`vw_site_status.ems_heartbeat_status`) a případný alerting.
|
||||
|
||||
**Neplní funkci watchdogu** – to je čistě na Loxone straně.
|
||||
|
||||
```python
|
||||
# backend/services/control_exporter.py – každou minutu
|
||||
async def send_heartbeat(site_id: int, loxone_endpoint, db):
|
||||
try:
|
||||
await loxone_http.get(f"/dev/sps/io/EMS_Heartbeat/1")
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_heartbeat($1, 'ok', $2)",
|
||||
site_id, EMS_VERSION
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat failed for site {site_id}: {e}")
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_heartbeat($1, 'error', $2)",
|
||||
site_id, EMS_VERSION
|
||||
)
|
||||
# EMS nemůže nic dělat – Loxone watchdog to vyřeší sám
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Ověřit Deye Modbus registry pro přepnutí Self-Consumption / Grid-First modu (pro SELF_SUSTAIN)
|
||||
- [ ] Implementace Loxone watchdog – viz `docs/loxone-integration.md`
|
||||
- [ ] Alert notifikace (email / push) pokud `ems_heartbeat_status = 'stale'` déle než 10 minut
|
||||
423
docs/04-modules/planning.md
Normal file
423
docs/04-modules/planning.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Modul: Planning (LP Optimalizace)
|
||||
|
||||
## Přístup
|
||||
|
||||
**PuLP + HiGHS solver** – lineární programování (LP) s uvolněním binárních proměnných.
|
||||
|
||||
Solver optimalizuje celý horizont (typicky 36h) najednou, čímž přirozeně zvládá:
|
||||
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
|
||||
- kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu
|
||||
|
||||
---
|
||||
|
||||
## Klíčové předpoklady a specifika home-01
|
||||
|
||||
### FVE pole A (10 kWp, řízené Deye)
|
||||
- Curtailment povolen přes Modbus (Output Power Limit)
|
||||
- Solver může omezit výrobu pokud export nevychází a není kam ukládat
|
||||
- Curtailment má nulový přímý náklad, ale ztrátu příležitosti
|
||||
|
||||
### FVE pole B (10 kWp, ongridový na GEN portu)
|
||||
- **Nelze omezit ani řídit**
|
||||
- Má **zelený bonus** (dotace za každé vyrobené kWh bez ohledu na cenu)
|
||||
- Výroba pole B musí být vždy plně spotřebována nebo uložena
|
||||
- Při záporné prodejní ceně má nejvyšší prioritu ukládání (baterie → EV → TČ)
|
||||
- Solver nikdy neexportuje výrobu pole B pokud je prodejní cena záporná
|
||||
|
||||
### Export / import limity (home-01)
|
||||
- Max export do sítě: **13.5 kW** (smlouva s distributorem)
|
||||
- Max import ze sítě: dle `site_grid_connection.max_import_power_w`
|
||||
- Konfigurovatelné per site v DB
|
||||
|
||||
---
|
||||
|
||||
## Energetická bilance (pro každý 15min slot t)
|
||||
|
||||
```
|
||||
pv_a_actual[t] + pv_b[t] + grid_import[t] + battery_discharge[t]
|
||||
= load_baseline[t]
|
||||
+ Σ_e (ev_direct[e][t] + ev_via_bat[e][t])
|
||||
+ heat_pump[t]
|
||||
+ battery_charge[t] + grid_export[t] + pv_a_curtailed[t]
|
||||
```
|
||||
|
||||
kde:
|
||||
- `pv_a_actual[t]` = `pv_a_forecast[t] − pv_a_curtailed[t]`
|
||||
- `pv_b[t]` = predikce pole B (pevná, nekontrolovatelná)
|
||||
- `grid_import[t]`, `grid_export[t]` ≥ 0 (oddělené proměnné, ne signed)
|
||||
- `ev_direct[e][t]` = přímé napájení EV e ze zdrojů (FVE, síť) – bez průchodu baterií
|
||||
- `ev_via_bat[e][t]` = napájení EV e přes baterii (kryta z `battery_discharge[t]`)
|
||||
|
||||
**Round-trip efektivita:** Přímé napájení EV je ~10 % levnější než přes baterii
|
||||
(η_charge × η_discharge ≈ 0.95 × 0.95 ≈ 0.90). Solver to vidí v účelové funkci.
|
||||
|
||||
---
|
||||
|
||||
## Proměnné solveru
|
||||
|
||||
| Proměnná | Typ | Rozsah | Popis |
|
||||
|---|---|---|---|
|
||||
| `grid_import[t]` | kontinuální | 0 – max_import | Nákup ze sítě v W |
|
||||
| `grid_export[t]` | kontinuální | 0 – max_export (13500) | Prodej do sítě v W |
|
||||
| `battery_charge[t]` | kontinuální | 0 – max_charge | Nabíjení baterie v W |
|
||||
| `battery_discharge[t]` | kontinuální | 0 – max_discharge | Vybíjení baterie v W |
|
||||
| `soc[t]` | kontinuální | soc_min – soc_max | Stav nabití baterie v Wh |
|
||||
| `pv_a_curtailed[t]` | kontinuální | 0 – pv_a_forecast[t] | Omezení výroby pole A v W |
|
||||
| `ev_direct[e][t]` | kontinuální | 0 – min(ev_max, pv_surplus) | Přímé napájení EV e z FVE/sítě (bez průchodu baterií) |
|
||||
| `ev_via_bat[e][t]` | kontinuální | 0 – ev_max | Napájení EV e přes baterii (s round-trip ztrátou) |
|
||||
| `heat_pump[t]` | kontinuální | 0 – hp_rated | Výkon TČ v W (relaxováno z binární) |
|
||||
|
||||
> **TČ relaxace:** TČ je v realitě ON/OFF (binární). Pro LP ho relaxujeme na spojitou proměnnou 0–rated_power. Post-processing pravidlo pak zaokrouhlí na ON/OFF a zkontroluje `min_run_duration`. V praxi výsledek LP vychází blízko binárnímu řešení.
|
||||
|
||||
---
|
||||
|
||||
## Účelová funkce (minimalizace nákladů)
|
||||
|
||||
```python
|
||||
EV_ROUNDTRIP_FACTOR = 1.0 / (charge_efficiency * discharge_efficiency) # ≈ 1.108
|
||||
|
||||
minimize:
|
||||
Σ_t [
|
||||
# Náklady na nákup ze sítě
|
||||
grid_import[t] * buy_price[t] * interval_h
|
||||
|
||||
# Příjem z prodeje (záporný náklad)
|
||||
- grid_export[t] * sell_price[t] * interval_h
|
||||
|
||||
# Náklad degradace baterie (nabíjení i vybíjení)
|
||||
+ (battery_charge[t] + battery_discharge[t]) * degradation_cost * interval_h
|
||||
|
||||
# EV přímé napájení – standardní cena energie
|
||||
+ Σ_e ev_direct[e][t] * buy_price[t] * interval_h
|
||||
|
||||
# EV přes baterii – navýšeno o round-trip ztrátu + degradaci
|
||||
# Solver tak přirozeně preferuje přímé nabíjení nad průchodem baterií
|
||||
+ Σ_e ev_via_bat[e][t] * buy_price[t] * EV_ROUNDTRIP_FACTOR * interval_h
|
||||
|
||||
# Malá penalizace curtailmentu pole A (preferujeme využití FVE)
|
||||
+ pv_a_curtailed[t] * CURTAILMENT_PENALTY
|
||||
]
|
||||
```
|
||||
|
||||
kde `interval_h = 0.25` (15 min = 0.25 h), ceny v Kč/kWh, výkony ve W.
|
||||
|
||||
---
|
||||
|
||||
## Omezení solveru
|
||||
|
||||
### Energetická bilance
|
||||
```python
|
||||
pv_a_forecast[t] - pv_a_curtailed[t] + pv_b[t] + grid_import[t] + battery_discharge[t]
|
||||
== load_baseline[t]
|
||||
+ Σ_e (ev_direct[e][t] + ev_via_bat[e][t])
|
||||
+ heat_pump[t] + battery_charge[t] + grid_export[t]
|
||||
```
|
||||
|
||||
### Vazba ev_via_bat na battery_discharge
|
||||
```python
|
||||
# ev_via_bat musí být kryto z vybíjení baterie
|
||||
Σ_e ev_via_bat[e][t] <= battery_discharge[t]
|
||||
```
|
||||
|
||||
### Limit výkonu EV per vozidlo
|
||||
```python
|
||||
# Celkový výkon do EV e nesmí překročit min(WB limit, vozidlo max)
|
||||
ev_direct[e][t] + ev_via_bat[e][t] <= min(charger_max_w[e], vehicle_max_w[e])
|
||||
|
||||
# Pokud auto není připojeno → nula
|
||||
if not ev_connected[e][t]:
|
||||
ev_direct[e][t] == 0
|
||||
ev_via_bat[e][t] == 0
|
||||
```
|
||||
|
||||
### Deadline charging – hard constraint
|
||||
```python
|
||||
# Pro každé EV e s nastaveným deadline a known SoC:
|
||||
if ev_session[e].target_deadline and ev_session[e].soc_at_connect_pct is not None:
|
||||
energy_needed_wh = (
|
||||
(target_soc_pct - soc_at_connect_pct) / 100.0
|
||||
* vehicle_capacity_wh[e]
|
||||
)
|
||||
t_deadline = slot_index(ev_session[e].target_deadline)
|
||||
|
||||
pulp.lpSum(
|
||||
(ev_direct[e][t] + ev_via_bat[e][t]) * interval_h
|
||||
for t in range(t_deadline + 1)
|
||||
if ev_connected[e][t]
|
||||
) >= energy_needed_wh
|
||||
|
||||
# Pro Zoe (SoC neznámý) – deadline constraint na kumulativní dodanou energii:
|
||||
# energy_needed = (default_target_soc - estimated_soc_from_session) * capacity
|
||||
```
|
||||
|
||||
### SoC kontinuita
|
||||
```python
|
||||
soc[t] == soc[t-1]
|
||||
+ battery_charge[t] * charge_efficiency * interval_h
|
||||
- battery_discharge[t] / discharge_efficiency * interval_h
|
||||
|
||||
soc[0] == current_soc_wh # počáteční podmínka z telemetrie
|
||||
```
|
||||
|
||||
### SoC limity
|
||||
```python
|
||||
soc_min_wh <= soc[t] <= soc_max_wh
|
||||
|
||||
# Rezerva pro výpadek sítě – nikdy nesahat
|
||||
soc_reserve_wh = battery.reserve_soc_percent / 100 * battery.usable_capacity_wh
|
||||
soc[t] >= soc_reserve_wh # za normálních podmínek
|
||||
```
|
||||
|
||||
### Limity výkonu
|
||||
```python
|
||||
0 <= battery_charge[t] <= battery.max_charge_power_w
|
||||
0 <= battery_discharge[t] <= battery.max_discharge_power_w
|
||||
0 <= grid_import[t] <= grid.max_import_power_w
|
||||
0 <= grid_export[t] <= grid.max_export_power_w # = 13500 pro home-01
|
||||
0 <= pv_a_curtailed[t] <= pv_a_forecast[t]
|
||||
0 <= ev_charge[t] <= ev_max_total_w
|
||||
0 <= heat_pump[t] <= heat_pump.rated_heating_power_w
|
||||
```
|
||||
|
||||
### Nelze současně nabíjet a vybíjet baterii
|
||||
```python
|
||||
# Přirozeně vyplyne z optimalizace díky degradation_cost.
|
||||
# Pokud ne, přidat: battery_charge[t] * battery_discharge[t] == 0
|
||||
# (to by ale byl QP, ne LP – raději nechat degradation_cost dělat práci)
|
||||
```
|
||||
|
||||
### Záporná prodejní cena – zákaz exportu
|
||||
```python
|
||||
if sell_price[t] < 0:
|
||||
grid_export[t] == 0 # přidat jako constraint pro daný slot
|
||||
```
|
||||
|
||||
### Záporná prodejní cena – pole B má prioritu v ukládání
|
||||
```python
|
||||
# Pokud sell_price[t] < 0, výroba pole B nesmí jít do exportu.
|
||||
# Formulace: grid_export[t] <= grid_import[t] + battery_discharge[t] ...
|
||||
# Jednodušeji: pokud sell_price < 0, přidat constraint grid_export[t] == 0
|
||||
# (export stejně zakázán výše) a solver automaticky uloží přebytek.
|
||||
```
|
||||
|
||||
### Záporná nákupní cena – nabíjet ze sítě je výhodné
|
||||
```python
|
||||
# Pokud buy_price[t] < 0, grid_import[t] je příjem → solver automaticky maximalizuje import.
|
||||
# Omezit maximálním výkonem baterie (aby to mělo smysl):
|
||||
# grid_import[t] <= battery.max_charge_power_w + ev_max_total_w + heat_pump.rated_heating_power_w
|
||||
# (nechceme kupovat víc než spotřebujeme / uložíme)
|
||||
```
|
||||
|
||||
### TUV minimální teplota – nouzový ohřev vždy
|
||||
```python
|
||||
# Pokud aktuální teplota zásobníku < tuv_min_temp_c:
|
||||
# heat_pump[t=0] >= heat_pump.rated_heating_power_w * 0.8 # minimálně 80% výkonu v prvním slotu
|
||||
# Toto je tvrdé omezení nezávislé na ceně.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementace (Python / PuLP)
|
||||
|
||||
```python
|
||||
# backend/services/planning_engine.py
|
||||
|
||||
import pulp
|
||||
from pulp import HiGHS_CMD
|
||||
|
||||
def solve_dispatch(
|
||||
site_id: int,
|
||||
slots: list[PlanningSlot], # 15min sloty s cenami, forecasty
|
||||
battery: AssetBattery,
|
||||
heat_pump: AssetHeatPump,
|
||||
grid: SiteGridConnection,
|
||||
current_soc_wh: float,
|
||||
current_tuv_temp_c: float,
|
||||
ev_max_total_w: int,
|
||||
) -> list[DispatchResult]:
|
||||
|
||||
T = len(slots)
|
||||
H = 0.25 # interval v hodinách
|
||||
CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace aby solver preferoval využití
|
||||
|
||||
prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)
|
||||
|
||||
# --- Proměnné ---
|
||||
grid_import = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w) for t in range(T)]
|
||||
grid_export = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)]
|
||||
batt_charge = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)]
|
||||
batt_discharge = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)]
|
||||
soc = [pulp.LpVariable(f"soc_{t}",
|
||||
battery.reserve_soc_wh,
|
||||
battery.soc_max_wh) for t in range(T)]
|
||||
curtail_a = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)]
|
||||
ev_charge = [pulp.LpVariable(f"ev_{t}", 0, ev_max_total_w) for t in range(T)]
|
||||
heat_pump_p = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)]
|
||||
|
||||
# --- Účelová funkce ---
|
||||
prob += pulp.lpSum(
|
||||
grid_import[t] * slots[t].buy_price * H / 1000 # Kč (W→kW)
|
||||
- grid_export[t] * slots[t].sell_price * H / 1000
|
||||
+ (batt_charge[t] + batt_discharge[t]) * battery.degradation_cost_czk_kwh * H / 1000
|
||||
+ curtail_a[t] * CURTAILMENT_PENALTY
|
||||
for t in range(T)
|
||||
)
|
||||
|
||||
# --- Omezení ---
|
||||
for t in range(T):
|
||||
s = slots[t]
|
||||
pv_a_net = s.pv_a_forecast_w - curtail_a[t]
|
||||
|
||||
# Energetická bilance
|
||||
prob += (
|
||||
pv_a_net + s.pv_b_forecast_w + grid_import[t] + batt_discharge[t]
|
||||
== s.load_baseline_w + ev_charge[t] + heat_pump_p[t] + batt_charge[t] + grid_export[t]
|
||||
)
|
||||
|
||||
# SoC kontinuita
|
||||
soc_prev = current_soc_wh if t == 0 else soc[t-1]
|
||||
prob += soc[t] == (
|
||||
soc_prev
|
||||
+ batt_charge[t] * battery.charge_efficiency * H
|
||||
- batt_discharge[t] / battery.discharge_efficiency * H
|
||||
)
|
||||
|
||||
# Záporná prodejní cena → zakázat export
|
||||
if s.sell_price < 0:
|
||||
prob += grid_export[t] == 0
|
||||
|
||||
# Záporná nákupní cena → omezit import na to co reálně spotřebujeme/uložíme
|
||||
if s.buy_price < 0:
|
||||
prob += grid_import[t] <= (
|
||||
battery.max_charge_power_w
|
||||
+ ev_max_total_w
|
||||
+ heat_pump.rated_heating_power_w
|
||||
)
|
||||
|
||||
# Nouzový ohřev TUV – pokud zásobník pod minimem
|
||||
if current_tuv_temp_c < heat_pump.tuv_min_temp_c:
|
||||
prob += heat_pump_p[0] >= heat_pump.rated_heating_power_w * 0.8
|
||||
|
||||
# --- Řešení ---
|
||||
solver = HiGHS_CMD(msg=False, timeLimit=10)
|
||||
status = prob.solve(solver)
|
||||
|
||||
if pulp.LpStatus[status] != 'Optimal':
|
||||
raise PlanningError(f"Solver nenašel optimální řešení: {pulp.LpStatus[status]}")
|
||||
|
||||
# --- Post-processing TČ: relaxovaná → ON/OFF ---
|
||||
results = []
|
||||
for t in range(T):
|
||||
hp_raw = pulp.value(heat_pump_p[t])
|
||||
hp_enabled = hp_raw > heat_pump.rated_heating_power_w * 0.3 # threshold pro ON
|
||||
hp_power = heat_pump.rated_heating_power_w if hp_enabled else 0
|
||||
|
||||
results.append(DispatchResult(
|
||||
interval_start = slots[t].interval_start,
|
||||
battery_setpoint_w = round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])),
|
||||
battery_soc_target = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1),
|
||||
grid_setpoint_w = round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])),
|
||||
ev_charge_power_w = round(pulp.value(ev_charge[t])),
|
||||
heat_pump_enabled = hp_enabled,
|
||||
heat_pump_setpoint_w = hp_power,
|
||||
pv_a_curtailed_w = round(pulp.value(curtail_a[t])),
|
||||
expected_cost_czk = round(
|
||||
pulp.value(grid_import[t]) * slots[t].buy_price * H / 1000
|
||||
- pulp.value(grid_export[t]) * slots[t].sell_price * H / 1000,
|
||||
4
|
||||
),
|
||||
effective_buy_price = slots[t].buy_price,
|
||||
effective_sell_price = slots[t].sell_price,
|
||||
))
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scénáře které solver řeší správně
|
||||
|
||||
### Ráno – vysoká FVE předpověď, přes poledne záporná cena
|
||||
```
|
||||
Solver ráno (vysoká cena):
|
||||
→ vybíjí baterii do sítě (prodej při high price)
|
||||
→ exportuje FVE přebytek
|
||||
|
||||
Přes poledne (záporná nebo nízká cena):
|
||||
→ zakáže export (grid_export == 0)
|
||||
→ nabíjí baterii z FVE + ze sítě (dostane zaplaceno)
|
||||
→ spouští TČ a EV (spotřebovává levnou/zápornou energii)
|
||||
→ případně curtailuje pole A pokud je baterie plná a není kam ukládat
|
||||
```
|
||||
|
||||
### Pole B + záporná cena
|
||||
```
|
||||
Pole B vyrábí 10 kWp, sell_price < 0:
|
||||
→ grid_export == 0 (constraint)
|
||||
→ solver musí interně spotřebovat vše z pole B
|
||||
→ prioritně: nabíjení baterie, pak EV, pak TČ
|
||||
→ pokud nic nestačí → baterie je plná, EV nepřipojeno, TČ na max:
|
||||
solver ukáže že zbývající výroba pole B nejde spotřebovat
|
||||
→ tuto situaci logovat (přebytek nevyužit, bonus přesto inkasován)
|
||||
```
|
||||
|
||||
### Záporná nákupní cena (platíme za odběr)
|
||||
```
|
||||
→ solver maximalizuje grid_import (je to příjem)
|
||||
→ omezen na max_charge + ev_max + hp_rated (nechceme kupovat zbytečně)
|
||||
→ nabíjí baterii na maximum
|
||||
→ spouští EV a TČ naplno
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DB – rozšíření planning_interval
|
||||
|
||||
Přidat sloupec `pv_a_curtailed_w` do tabulky:
|
||||
|
||||
```sql
|
||||
-- V005__planning_curtailment.sql
|
||||
ALTER TABLE ems.planning_interval
|
||||
ADD COLUMN pv_a_curtailed_w INT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS
|
||||
'Plánované omezení výroby FVE pole A v W (curtailment). 0 = žádné omezení. '
|
||||
'Hodnota > 0 znamená že solver rozhodl omezit výrobu pole A přes Modbus.';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konfigurace (env proměnné)
|
||||
|
||||
```env
|
||||
PLANNING_HORIZON_HOURS=36
|
||||
PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout
|
||||
PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE
|
||||
PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu
|
||||
PV_B_GREEN_BONUS_CZK_KWH=1.20 # zelený bonus Kč/kWh (informativní, do účelové funkce přidat pokud chceš)
|
||||
```
|
||||
|
||||
> **Zelený bonus v účelové funkci:** Pokud chceš bonus explicitně zahrnout, přidat do objective function:
|
||||
> `- pv_b[t] * GREEN_BONUS_CZK_KWH * H / 1000` jako konstantní příjem (pole B vždy vyrábí).
|
||||
> Protože je to konstanta, neovlivní optimalizaci – ale správně zobrazí ekonomiku v auditu.
|
||||
|
||||
---
|
||||
|
||||
## Závislosti (requirements.txt)
|
||||
|
||||
```
|
||||
pulp>=2.8.0
|
||||
highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD)
|
||||
```
|
||||
|
||||
> Preferovat `import highspy` přímý binding místo `HiGHS_CMD` shell volání – výrazně rychlejší.
|
||||
|
||||
---
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Post-processing min_run_duration pro TČ – po LP výsledku zkontrolovat a upravit krátké ON/OFF sekvence
|
||||
- [ ] Zelený bonus zahrnout do auditního výpočtu nákladů (ne jen do objective)
|
||||
- [ ] EV rozdělení výkonu mezi 2 nabíječky – zatím řešeno jako agregát
|
||||
- [ ] Curtailment pole A – ověřit Modbus registr pro Output Power Limit na Deye SUN-20K
|
||||
- [ ] Testovat solver na reálných datech – ověřit čas výpočtu pro 36h horizont (144 slotů)
|
||||
216
docs/04-modules/telemetry.md
Normal file
216
docs/04-modules/telemetry.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Modul: Telemetry (Sběr dat ze zařízení)
|
||||
|
||||
## 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
|
||||
- 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í
|
||||
|
||||
---
|
||||
|
||||
## Komponenta: `telemetry_collector` (Python service)
|
||||
|
||||
Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI.
|
||||
|
||||
### Polling intervaly
|
||||
|
||||
| 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 | |
|
||||
|
||||
### 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
|
||||
- Data se neinterpolují – chybějící minuty zůstanou prázdné (audit to pozná)
|
||||
|
||||
---
|
||||
|
||||
## Deye SUN-20K – Modbus registry
|
||||
|
||||
Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1).
|
||||
|
||||
> Registry jsou specifické pro Deye SUN-20K-SG01LP1-EU.
|
||||
> Finální hodnoty ověřit z Deye Modbus protokolu / Loxone šablony.
|
||||
|
||||
| Registr (hex) | Typ | Popis | Jednotka | Přepočet |
|
||||
|---|---|---|---|---|
|
||||
| 0x0215 | Read Holding | PV celkový výkon | W | ×1 |
|
||||
| 0x0103 | Read Holding | Battery SoC | % | ×1 |
|
||||
| 0x0105 | Read Holding | Battery power | W | signed, kladné=nabíjení |
|
||||
| 0x0101 | Read Holding | Battery voltage | 0.1V | ×0.1 |
|
||||
| 0x0169 | Read Holding | Grid power | W | signed, kladné=import |
|
||||
| 0x016F | Read Holding | Grid voltage L1 | 0.1V | ×0.1 |
|
||||
| 0x0213 | Read Holding | Load power | W | ×1 |
|
||||
| 0x0220 | Read Holding | Inverter temperature | 0.1°C | ×0.1 |
|
||||
| 0x0168 | Read Holding | Operating mode | enum | viz tabulka módů |
|
||||
| 0x0180 | Read Holding | Fault code | bitfield | 0=ok |
|
||||
|
||||
**Zápis setpointů (plánování → Deye):**
|
||||
|
||||
| Registr (hex) | Typ | Popis | Hodnota |
|
||||
|---|---|---|---|
|
||||
| 0x00F3 | Write Single | Battery charge power limit | W |
|
||||
| 0x00F4 | Write Single | Battery discharge power limit | W |
|
||||
| 0x00F6 | Write Single | Grid export power limit | W |
|
||||
| 0x00F0 | Write Single | Work mode | enum (viz tabulka) |
|
||||
|
||||
> **TODO:** Přesné registry doplnit z Deye SUN-20K Modbus protokolu PDF.
|
||||
> Loxone šablona pro Deye je dobrý výchozí bod pro mapování registrů.
|
||||
|
||||
---
|
||||
|
||||
## Teltonika TeltoCharge – Modbus registry
|
||||
|
||||
Komunikace: Modbus TCP přes Waveshare, Unit ID = 1 (ověřit).
|
||||
|
||||
> Registry doplnit z Teltonika TeltoCharge Modbus dokumentace / Loxone šablony.
|
||||
|
||||
| Registr | Typ | Popis | Jednotka |
|
||||
|---|---|---|---|
|
||||
| TBD | Read | Stav konektoru (OCPP status enum) | enum |
|
||||
| TBD | Read | Aktuální výkon | W |
|
||||
| TBD | Read | Kumulativní energie session | Wh |
|
||||
| TBD | Read | Proud L1/L2/L3 | 0.1A |
|
||||
| TBD | Read | Napětí | 0.1V |
|
||||
| TBD | Read | Session ID | uint |
|
||||
| TBD | Read | Error code | uint |
|
||||
| TBD | Write | Max proud (charge limit) | A (6–32A) |
|
||||
| TBD | Write | Povolení nabíjení (on/off) | bool |
|
||||
|
||||
---
|
||||
|
||||
## Samsung tepelné čerpadlo – Modbus registry
|
||||
|
||||
Komunikace: Modbus TCP přes Waveshare.
|
||||
|
||||
> Registry doplnit ze Samsung NASA Modbus dokumentace / Loxone šablony.
|
||||
|
||||
| Registr | Typ | Popis | Jednotka |
|
||||
|---|---|---|---|
|
||||
| TBD | Read | Venkovní teplota | 0.1°C |
|
||||
| TBD | Read | Teplota vody vstup | 0.1°C |
|
||||
| TBD | Read | Teplota vody výstup | 0.1°C |
|
||||
| TBD | Read | Teplota zásobníku TUV | 0.1°C |
|
||||
| TBD | Read | Příkon | W |
|
||||
| TBD | Read | Provozní režim | enum |
|
||||
| TBD | Read | Alarm kód | uint |
|
||||
| TBD | Read | Odmrazování aktivní | bool |
|
||||
| TBD | Write | Povolení provozu | bool |
|
||||
| TBD | Write | Požadovaná teplota TUV | °C |
|
||||
|
||||
---
|
||||
|
||||
## Kód telemetrie (Python)
|
||||
|
||||
```python
|
||||
# backend/services/telemetry_collector.py
|
||||
|
||||
import asyncio
|
||||
from pymodbus.client import AsyncModbusTcpClient
|
||||
from datetime import datetime, timezone
|
||||
|
||||
async def poll_inverter(site_id: int, inverter: AssetInverter, endpoint: SiteEndpoint, db):
|
||||
"""Přečte všechny registry Deye a uloží záznam do telemetry_inverter."""
|
||||
async with AsyncModbusTcpClient(endpoint.host, port=endpoint.port) as client:
|
||||
try:
|
||||
# Čtení bloku registrů (optimalizovat jako jeden read multiple)
|
||||
pv_power = await read_register(client, 0x0215, endpoint.unit_id)
|
||||
batt_soc = await read_register(client, 0x0103, endpoint.unit_id)
|
||||
batt_power = await read_register_signed(client, 0x0105, endpoint.unit_id)
|
||||
batt_voltage = await read_register(client, 0x0101, endpoint.unit_id) / 10.0
|
||||
grid_power = await read_register_signed(client, 0x0169, endpoint.unit_id)
|
||||
grid_voltage = await read_register(client, 0x016F, endpoint.unit_id) / 10.0
|
||||
load_power = await read_register(client, 0x0213, endpoint.unit_id)
|
||||
inv_temp = await read_register(client, 0x0220, endpoint.unit_id) / 10.0
|
||||
op_mode = await read_register(client, 0x0168, endpoint.unit_id)
|
||||
fault_code = await read_register(client, 0x0180, endpoint.unit_id)
|
||||
|
||||
await db.execute("""
|
||||
INSERT INTO ems.telemetry_inverter
|
||||
(site_id, inverter_id, measured_at,
|
||||
pv_power_w, battery_soc_percent, battery_power_w, battery_voltage_v,
|
||||
grid_power_w, grid_voltage_v, load_power_w,
|
||||
inverter_temp_c, operating_mode, fault_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
||||
""",
|
||||
site_id, inverter.id, datetime.now(timezone.utc),
|
||||
pv_power, batt_soc, batt_power, batt_voltage,
|
||||
grid_power, grid_voltage, load_power,
|
||||
inv_temp, str(op_mode), fault_code
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Inverter poll failed [{inverter.code}]: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def run_collector(db):
|
||||
"""Hlavní smyčka – každých 60s sbírá data ze všech aktivních zařízení."""
|
||||
while True:
|
||||
start = asyncio.get_event_loop().time()
|
||||
|
||||
sites = await db.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
await asyncio.gather(
|
||||
poll_all_inverters(site.id, db),
|
||||
poll_all_ev_chargers(site.id, db),
|
||||
poll_all_heat_pumps(site.id, db),
|
||||
return_exceptions=True # jeden výpadek nezastaví ostatní
|
||||
)
|
||||
|
||||
elapsed = asyncio.get_event_loop().time() - start
|
||||
await asyncio.sleep(max(0, 60 - elapsed))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
```sql
|
||||
-- Příklad agregace telemetrie na 15min průměr
|
||||
-- (součást fn_fill_audit_interval)
|
||||
SELECT
|
||||
site_id,
|
||||
time_bucket('15 minutes', measured_at) AS interval_start,
|
||||
AVG(pv_power_w)::INT AS avg_pv_power_w,
|
||||
AVG(battery_power_w)::INT AS avg_battery_power_w,
|
||||
AVG(grid_power_w)::INT AS avg_grid_power_w,
|
||||
AVG(load_power_w)::INT AS avg_load_power_w,
|
||||
LAST(battery_soc_percent, measured_at) AS last_soc_pct
|
||||
FROM ems.telemetry_inverter
|
||||
WHERE measured_at >= $1 AND measured_at < $1 + INTERVAL '15 minutes'
|
||||
AND site_id = $2
|
||||
GROUP BY site_id, time_bucket('15 minutes', measured_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konfigurace (env proměnné)
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Otevřené body
|
||||
|
||||
- [ ] Doplnit přesné Modbus registry Deye z PDF protokolu
|
||||
- [ ] Doplnit Modbus registry Teltonika z dokumentace / Loxone šablony
|
||||
- [ ] Doplnit Modbus registry Samsung z dokumentace / Loxone šablony
|
||||
- [ ] Ověřit Unit ID všech zařízení při instalaci
|
||||
- [ ] Optimalizovat čtení Deye jako jeden `read_holding_registers` blok místo jednotlivých registrů
|
||||
Reference in New Issue
Block a user