template pro novou site
All checks were successful
deploy / deploy (push) Successful in 24s
test / smoke-test (push) Successful in 6s

This commit is contained in:
Dusan Vojacek
2026-04-12 17:01:20 +02:00
parent 4e81a36371
commit 015c81a8cb

View File

@@ -0,0 +1,378 @@
# Šablona: založení nové lokality (`site_id`)
Použij jako checklist při přidávání dalšího objektu do EMS. Odkazy: datový model [`03-data-model.md`](03-data-model.md), referenční seed [`db/migration/V003__seed_site_home01.sql`](../db/migration/V003__seed_site_home01.sql), tarif/HDO u existující lokality [`db/migration/V016__seed_distribution_home01.sql`](../db/migration/V016__seed_distribution_home01.sql).
---
## 1. Identita a přístup
| Krok | Tabulka / akce | Poznámka |
|------|------------------|----------|
| ☐ | `ems.site` | `code` (unikátní), `name`, `timezone` (IANA), volitelně `latitude` / `longitude` (forecast/počasí) |
| ☐ | `ems.site_operating_mode` | Jedna řádek na `site_id` (`mode_code` FK na `operating_mode_def`). Bez řádku control export varuje a plánovač bere režim jako prázdný → v solveru efektivně jako AUTO. **Doporučení:** po vytvoření site explicitně nastavit režim (`ems.fn_set_mode` nebo `POST /api/v1/sites/{id}/mode`). |
| ☐ | UI / API | `GET /api/v1/me/sites` vrací jen **`site.active = true`** — neaktivní lokalita se v comboboxu neobjeví. |
---
## 2. Síť, trh, ekonomika
| Krok | Tabulka | Poznámka |
|------|---------|----------|
| ☐ | `ems.site_grid_connection` | Jeden záznam na site: `max_import_power_w`, `max_export_power_w`, `no_export`, `reserved_capacity_w` |
| ☐ | `ems.site_market_config` | Marže, režimy cenění, platnost `valid_from` / `valid_to`; volitelně `tariff_id`, `hdo_code_id`, poplatky (viz V016 u home-01) |
| ☐ | `ems.distribution_tariff` (+ `distribution_tariff_rate`, `hdo_code`, `hdo_code_window`) | Jen pokud potřebuješ distribuční složku / HDO v efektivní ceně — jinak lze doplnit později |
---
## 3. Endpointy (Modbus, HTTP, …)
| Krok | Tabulka | Poznámka |
|------|---------|----------|
| ☐ | `ems.site_endpoint` | Pro každé zařízení: `endpoint_type` (`modbus_tcp`, `loxone_http`, …), `host`, `port`, `unit_id`, **`enabled`**. Nehotové zařízení: `enabled = false` nebo zatím bez řádku — telemetrie daného typu se nebude dotazovat. |
---
## 4. Aktiva (minimálně podle toho, co má objekt mít)
| Krok | Tabulka | Poznámka |
|------|---------|----------|
| ☐ | `ems.asset_inverter` | Vazba na `endpoint_id` kde je potřeba; `controllable`, `active`; výkonové limity |
| ☐ | `ems.asset_battery` | Vazba na střídač; SoC limity, účinnosti, degradace — **solver očekává baterii**, jinak denní/rolling plán padá |
| ☐ | `ems.asset_pv_array` | Min. jedno pole k FVE forecastu / solveru; `controllable` u pole A vs B dle [`CLAUDE.md`](../CLAUDE.md) |
| ☐ | `ems.asset_ev_charger` | Volitelné; `endpoint_id`, výkony |
| ☐ | `ems.asset_heat_pump` | Volitelné; TČ parametry, TUV |
| ☐ | `ems.asset_vehicle` | Až **dva** záznamy na site (EV1/EV2 sloty ve solveru), pokud řešíš nabíjení |
---
## 5. Provoz backendu (joby)
Pro **`site.active = true`** scheduler zpracovává mimo jiné: telemetrii, denní plán, rolling replan, control export, audit, forecast refresh, baseline/statistiky. Konkrétní seznam: [`CLAUDE.md`](../CLAUDE.md) sekce periodické úlohy.
---
## 6. Po nasazení (ověření)
| Krok | Akce |
|------|------|
| ☐ | Telemetrie: řádky v `telemetry_*` hypertables, dashboard / `vw_latest_*` |
| ☐ | Ceny: `vw_site_effective_price` pro daný `site_id` |
| ☐ | Plán: `planning_run` + `planning_interval` po denním jobu nebo ručním spuštění |
| ☐ | Režim: `vw_operating_mode` / API režimu |
| ☐ | Modbus journal: při AUTO očekávej zápisy v `modbus_command` po exportu |
---
## 7. Poznámky k migracím
- Nová data pro novou lokalitu: **nový Flyway soubor** `Vxxx__seed_site_<kód>.sql` (neupravovat už aplikované `V00x__*.sql`).
- Repeatable SQL (`db/routines`, `db/views`) se nemění kvůli jedné nové site, pokud nepotřebuješ obecnou úpravu.
---
## 8. SQL šablona (kopie do verzované Flyway migrace)
Jeden `DO $$ … $$` blok: **`v_site_id`** (a další ID endpointů / invertoru) se naplní v `DECLARE`, dál se používají v insertech.
### Idempotence (opakovaný běh migrace)
| Objekt | Mechanismus | Poznámka |
|--------|-------------|----------|
| `ems.site` | `ON CONFLICT (code) DO UPDATE` | Unikátní je `code`; opakovaný běh **aktualizuje** název, TZ, souřadnice, `active`, `notes` z šablony. |
| `ems.site_grid_connection` | `ON CONFLICT (site_id) DO UPDATE` | Unikátní je `site_id`; limity se při re-run **přepíší** hodnotami z migrace. |
| `ems.site_operating_mode` | `ON CONFLICT (site_id) DO NOTHING` | Druhý běh **nepřepíše** režim (např. už máš `AUTO`). |
| `ems.site_market_config` | `IF NOT EXISTS … valid_to IS NULL` | Tabulka nemá jednoznačný unikátní klíč pro „aktuální“ řádek; vloží se jen pokud pro site **ještě není** otevřená konfigurace. |
| `ems.site_endpoint` | výběr + `IF v_… IS NULL THEN INSERT` | Na `(site_id, …)` **není** UNIQUE constraint; duplicitu řešíme detekcí existujícího řádku (Deye = `modbus_tcp` + `notes ILIKE '%Deye%'`, Loxone = první `loxone_http`). |
| `ems.asset_inverter` / `battery` / `pv_array` / `ev_charger` / `heat_pump` | `IF NOT EXISTS (site_id + code) THEN INSERT` | V schématu **není** `UNIQUE (site_id, code)` u těchto tabulek (kromě vozidel). |
| `ems.asset_vehicle` | `ON CONFLICT (site_id, code) DO NOTHING` | Unikátní je `(site_id, code)` (V006). |
Režim **`MANUAL`** = bez EMS zápisů na hardware; po ověření přepni na `AUTO` přes API / `ems.fn_set_mode`. **`tariff_id` / `hdo_code_id`**: `NULL` v šabloně nebo doplníš později jako ve [`V016__seed_distribution_home01.sql`](../db/migration/V016__seed_distribution_home01.sql). **Zelený bonus** je na `ems.asset_pv_array`, ne v `site_market_config`.
```sql
-- =============================================================
-- V0xx__seed_site_home02.sql ← přejmenuj číslo + název souboru
-- Idempotentní seed nové lokality (bez duplicit při opakovaném běhu).
-- =============================================================
DO $$
DECLARE
v_site_code TEXT := 'home-02';
v_host_deye TEXT := '192.168.1.10';
v_host_loxone TEXT := '192.168.1.20';
v_site_id INT;
v_ep_deye INT;
v_ep_loxone INT;
v_inv_main INT;
BEGIN
-- --- Site ----------------------------------------------------------------
INSERT INTO ems.site (code, name, timezone, latitude, longitude, active, notes)
VALUES (
v_site_code,
'Název objektu',
'Europe/Prague',
49.200000,
17.400000,
true,
'TODO: poznámka k instalaci.'
)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
timezone = EXCLUDED.timezone,
latitude = EXCLUDED.latitude,
longitude = EXCLUDED.longitude,
active = EXCLUDED.active,
notes = EXCLUDED.notes
RETURNING id INTO v_site_id;
-- --- Endpoint: Deye (modbus) --------------------------------------------
SELECT se.id INTO v_ep_deye
FROM ems.site_endpoint se
WHERE se.site_id = v_site_id
AND se.endpoint_type = 'modbus_tcp'
AND se.notes ILIKE '%Deye%'
ORDER BY se.id
LIMIT 1;
IF v_ep_deye IS NULL THEN
INSERT INTO ems.site_endpoint (
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
)
VALUES (
v_site_id, 'modbus_tcp', v_host_deye, 502, 'modbus_tcp', 1, true,
'Deye hlavní střídač Modbus TCP (marker pro tento seed).'
)
RETURNING id INTO v_ep_deye;
END IF;
-- --- Endpoint: Loxone ---------------------------------------------------
SELECT se.id INTO v_ep_loxone
FROM ems.site_endpoint se
WHERE se.site_id = v_site_id
AND se.endpoint_type = 'loxone_http'
ORDER BY se.id
LIMIT 1;
IF v_ep_loxone IS NULL THEN
INSERT INTO ems.site_endpoint (
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
)
VALUES (
v_site_id, 'loxone_http', v_host_loxone, 80, 'http', NULL, true,
'Loxone Miniserver.'
)
RETURNING id INTO v_ep_loxone;
END IF;
-- --- Grid ----------------------------------------------------------------
INSERT INTO ems.site_grid_connection (
site_id, max_import_power_w, max_export_power_w, no_export, reserved_capacity_w, notes
)
VALUES (
v_site_id, 22000, 20000, false, 0,
'Limity dle jističe / připojení upřesni.'
)
ON CONFLICT (site_id) DO UPDATE SET
max_import_power_w = EXCLUDED.max_import_power_w,
max_export_power_w = EXCLUDED.max_export_power_w,
no_export = EXCLUDED.no_export,
reserved_capacity_w = EXCLUDED.reserved_capacity_w,
notes = EXCLUDED.notes;
-- --- Market config (jen pokud nemáš otevřený řádek valid_to NULL) -------
IF NOT EXISTS (
SELECT 1 FROM ems.site_market_config smc
WHERE smc.site_id = v_site_id AND smc.valid_to IS NULL
) THEN
INSERT INTO ems.site_market_config (
site_id,
purchase_pricing_mode, sale_pricing_mode,
buy_margin_fixed_czk, buy_margin_percent,
sell_margin_fixed_czk, sell_margin_percent,
currency, valid_from, valid_to, notes,
tariff_id, hdo_code_id, system_services_czk_kwh, ote_fee_czk_kwh
)
VALUES (
v_site_id,
'spot', 'spot',
0.050, 0,
-0.020, 0,
'CZK', now(), NULL, 'Výchozí marže upřesni ze smlouvy.',
NULL, NULL, 0, 0
);
END IF;
-- --- Operating mode (nešťouchej uživatelem změněný režim) ---------------
INSERT INTO ems.site_operating_mode (site_id, mode_code, activated_by, notes)
VALUES (
v_site_id,
'MANUAL',
'migration:V0xx_seed_site',
'Po spuštění ověř Modbus/Loxone, pak AUTO.'
)
ON CONFLICT (site_id) DO NOTHING;
-- --- Hlavní střídač ------------------------------------------------------
SELECT ai.id INTO v_inv_main
FROM ems.asset_inverter ai
WHERE ai.site_id = v_site_id AND ai.code = 'deye-main'
LIMIT 1;
IF v_inv_main IS NULL THEN
INSERT INTO ems.asset_inverter (
site_id, code, manufacturer, model, endpoint_id,
max_charge_power_w, max_discharge_power_w, max_export_power_w,
max_ac_output_w, max_dc_input_w, max_battery_charge_w, max_battery_discharge_w,
gen_port_max_power_w,
controllable, active, notes
)
VALUES (
v_site_id,
'deye-main',
'Deye',
'SUN-20K-SG01LP1-EU',
v_ep_deye,
18000, 18000, 18000,
22000, 40000, 18000, 18000,
NULL,
true, true,
'Hlavní hybridní střídač.'
)
RETURNING id INTO v_inv_main;
END IF;
-- --- Baterie -------------------------------------------------------------
IF NOT EXISTS (
SELECT 1 FROM ems.asset_battery ab
WHERE ab.site_id = v_site_id AND ab.code = 'bat-main'
) THEN
INSERT INTO ems.asset_battery (
site_id, inverter_id, code,
usable_capacity_wh, min_soc_percent, reserve_soc_percent, max_soc_percent,
charge_efficiency, discharge_efficiency, degradation_cost_czk_kwh,
max_charge_c_rate, max_discharge_c_rate, bms_max_charge_w, bms_max_discharge_w
)
VALUES (
v_site_id, v_inv_main, 'bat-main',
64000,
10, 20, 95,
0.95, 0.95,
0.50,
NULL, NULL, NULL, NULL
);
END IF;
-- --- FVE pole A ----------------------------------------------------------
IF NOT EXISTS (
SELECT 1 FROM ems.asset_pv_array ap
WHERE ap.site_id = v_site_id AND ap.code = 'pv-a'
) THEN
INSERT INTO ems.asset_pv_array (
site_id, inverter_id, code, name,
nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor,
controllable, telemetry_source, notes,
green_bonus_czk_kwh, green_bonus_valid_from, green_bonus_valid_to, green_bonus_meter_code
)
VALUES (
v_site_id, v_inv_main, 'pv-a', 'FVE pole A',
10000,
180, 25, NULL, 1.0,
true,
'pv_strings',
'Hlavní stringy na MPPT.',
NULL, NULL, NULL, NULL
);
END IF;
-- Volitelné: druhý invertor (ongrid) — odkomentuj celý IF NOT EXISTS blok
/*
IF NOT EXISTS (
SELECT 1 FROM ems.asset_inverter ai
WHERE ai.site_id = v_site_id AND ai.code = 'ongrid-gen'
) THEN
INSERT INTO ems.asset_inverter (
site_id, code, manufacturer, model, endpoint_id,
max_export_power_w, controllable, active, notes
)
VALUES (
v_site_id, 'ongrid-gen', NULL, NULL, NULL,
10000, false, true,
'Ongen na GEN EMS necurtailuje.'
);
END IF;
*/
-- Volitelné: EV / TČ / vozidlo — nejdřív endpoint (get-or-insert jako výše), pak:
/*
IF NOT EXISTS (
SELECT 1 FROM ems.asset_ev_charger c
WHERE c.site_id = v_site_id AND c.code = 'ev-charger-1'
) THEN
INSERT INTO ems.asset_ev_charger (
site_id, code, manufacturer, model, endpoint_id,
max_power_w, min_power_w, phases, connector_count, schedulable, notes
)
VALUES (
v_site_id, 'ev-charger-1', 'Teltonika', 'TeltoCharge', NULL,
22000, 1380, 3, 1, true,
'WB #1 doplň endpoint_id UPDATEm nebo vlož ep před tím.'
);
END IF;
INSERT INTO ems.asset_vehicle (
site_id, code, name, make, model,
battery_capacity_kwh, max_charge_power_w, default_charger_id, api_type,
default_target_soc_pct, default_deadline_hour, active
)
SELECT
v_site_id, 'car-1', 'Auto 1', 'Make', 'Model',
60.0, 11000, c.id, 'none', 80, 7, true
FROM ems.asset_ev_charger c
WHERE c.site_id = v_site_id AND c.code = 'ev-charger-1'
ON CONFLICT (site_id, code) DO NOTHING;
*/
END;
$$;
```
---
# Režim „nachystat, zatím neřídit“ a „jen číst data“
## `site.active = false` — **není** režim „jen číst“
Sloupec `ems.site.active` má význam: *lokalita se přeskočí při plánování a sběru dat* (komentář ve schématu). V backendu se na `active = true` váže mimo jiné **telemetrická smyčka** — při `active = false` **EMS typicky nebude číst Modbus** pro tuto lokalitu. Neobjeví se ani v `GET /api/v1/me/sites`.
**Shrnutí:** `active = false` = lokalita mimo provoz (žádný sběr, žádné joby pro ni). Nevhodné pro fázi „už sbíráme data, ale neřídíme“.
## `MANUAL` — EMS neexportuje setpointy (vhodné pro „neřídit z EMS“)
V `control_exporter.export_setpoints()` při `mode_code = 'MANUAL'` proběhne **okamžitý návrat bez zápisů** na střídač, EV, TČ a bez Loxone setpointů (log: `MANUAL, skip writes`).
**Doporučená kombinace pro přípravu + čtení dat:**
1. `site.active = true` — běží telemetrie a ostatní joby (včetně plánování, pokud je DB konfigurace kompletní).
2. `site_operating_mode.mode_code = 'MANUAL'`**EMS neposílá řídicí výstupy**.
**Upozornění:**
- Přepnutí do `MANUAL` přes API volá `fn_set_mode` a může **poslat hodnotu režimu do Loxone** (`loxone_mode_value` pro MANUAL = 0). Loxone pak pracuje podle vlastní šablony pro manuální/servisní režim — ověř v [`docs/loxone-integration.md`](loxone-integration.md).
- Dokumentace režimů říká, že v MANUAL „solver neběží“; **v aktuální implementaci** `planning_engine` i nadále spouští LP, pokud není režim ošetřen jinak (MANUAL nespadá do větví extra constraintů jako SELF_SUSTAIN). Plán se tedy může počítat a ukládat, ale **do zařízení se neaplikuje**. Pro čistě přípravnou fázi to obvykle stačí; pokud chceš šetřit CPU, řeší se to spíš budoucí úpravou jobů.
## Další páky (volitelně)
- **`site_endpoint.enabled = false`** nebo chybějící endpoint — žádný polling daného zařízení.
- **`asset_inverter.active = false`** — daný střídač se v telemetrii přeskočí (viz dotaz v `telemetry_collector`).
- **`asset_inverter.controllable = false`** — solver/logika „pole B“; zápisů Deye se stejně netýká v režimu MANUAL celkově.
---
## Rychlá odpověď na časté otázky
| Cíl | Doporučení |
|-----|------------|
| Jen sbírat telemetrii, EMS neřídit hardware | `active = true`, režim **`MANUAL`** |
| Lokalitu dočasně vypnout úplně (žádné joby, žádná telemetrie) | **`active = false`** |
| Plná automatizace | `active = true`, režim **`AUTO`** po ověření Modbus/Loxone |