second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View File

@@ -30,7 +30,7 @@ VALUES (
-- Deye střídač přes Waveshare RS485→TCP
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
SELECT id, 'modbus_tcp', '192.168.1.100', 502, 'modbus_tcp', 1, true, 'Waveshare WS-ETH pro Deye SUN-20K. Unit ID dle DIP přepínače.'
SELECT id, 'modbus_tcp', '172.16.1.10', 502, 'modbus_tcp', 1, true, 'Waveshare WS-ETH pro Deye SUN-20K. Unit ID dle DIP přepínače.'
FROM ems.site WHERE code = 'home-01';
-- Teltonika EV nabíječka 1 přes Waveshare
@@ -88,7 +88,7 @@ INSERT INTO ems.asset_inverter (site_id, code, manufacturer, model, endpoint_id,
SELECT
s.id, 'deye-main', 'Deye', 'SUN-20K-SG01LP1-EU',
ep.id,
20000, 20000, 20000,
18000, 18000, 18000,
true,
'Hlavní hybridní střídač 20kW LV. RS485 Modbus RTU přes Waveshare.'
FROM ems.site s
@@ -129,8 +129,8 @@ SELECT
s.id, inv.id, 'pv-a', 'FVE pole A',
10000, -- 10 kWp
184,
35, -- sklon odhad; upřesnit dle střechy
NULL,
22, -- sklon odhad; upřesnit dle střechy
18,
1.0,
true,
'Hlavní FVE pole řízené Deye střídačem.'

View File

@@ -41,11 +41,7 @@ COMMENT ON COLUMN ems.site_market_config.green_bonus_asset_code IS
'Kód FVE pole (asset_pv_array.code) na které se zelený bonus vztahuje. '
'Příklad: pv-b. NULL = bonus se nevztahuje na žádné konkrétní pole.';
-- Seed: doplnit zelený bonus pro home-01
-- (hodnota bonusu bude upřesněna dle smlouvy s OTE/ERU)
UPDATE ems.site_market_config
SET
green_bonus_czk_kwh = 1.20, -- TODO: doplnit skutečnou výši bonusu ze smlouvy
green_bonus_asset_code = 'pv-b'
WHERE site_id = (SELECT id FROM ems.site WHERE code = 'home-01')
AND valid_to IS NULL;
-- Seed zeleného bonusu přesunut: V017__green_bonus.sql (ems.asset_pv_array.green_bonus_*).
-- Sloupce green_bonus_* na site_market_config odstraňuje V018__cleanup_legacy_green_bonus.sql;
-- UPDATE zde by při změně pořadí / rebuild konfliktních migrací selhal.
-- UPDATE ems.site_market_config SET green_bonus_czk_kwh = 1.20, green_bonus_asset_code = 'pv-b' ...

View File

@@ -0,0 +1,29 @@
-- Nové sloupce telemetrie Deye (GEN port, PV1/PV2, denní energie baterie, run_state).
-- V011 je již použito pro indexy/agregace.
ALTER TABLE ems.telemetry_inverter
ADD COLUMN IF NOT EXISTS pv1_power_w INT,
ADD COLUMN IF NOT EXISTS pv2_power_w INT,
ADD COLUMN IF NOT EXISTS gen_port_power_w INT,
ADD COLUMN IF NOT EXISTS batt_charge_today_wh INT,
ADD COLUMN IF NOT EXISTS batt_discharge_today_wh INT,
ADD COLUMN IF NOT EXISTS run_state INT;
COMMENT ON COLUMN ems.telemetry_inverter.pv1_power_w IS
'Výkon PV1 vstupu W (Deye holding register).';
COMMENT ON COLUMN ems.telemetry_inverter.pv2_power_w IS
'Výkon PV2 vstupu W (Deye holding register).';
COMMENT ON COLUMN ems.telemetry_inverter.gen_port_power_w IS
'Výkon GEN portu W výroba FVE pole B (ongridový střídač).
Nelze řídit, jen měřit. Klíčový pro audit zeleného bonusu.';
COMMENT ON COLUMN ems.telemetry_inverter.batt_charge_today_wh IS
'Dnešní nabití baterie Wh (denní čítač z Modbus).';
COMMENT ON COLUMN ems.telemetry_inverter.batt_discharge_today_wh IS
'Dnešní vybití baterie Wh (denní čítač z Modbus).';
COMMENT ON COLUMN ems.telemetry_inverter.run_state IS
'Provozní stav střídače (raw enum z Modbus registru run_state).';
COMMENT ON COLUMN ems.telemetry_inverter.battery_power_w IS
'Výkon baterie v W (signed). Kladné = vybíjení, záporné = nabíjení (mapování dle registru 590).';
COMMENT ON COLUMN ems.telemetry_inverter.pv_power_w IS
'Součet okamžitého výkonu FVE stringů na střídači (typicky PV1+PV2) v W.';

View File

@@ -0,0 +1,20 @@
-- Predikovaná okna záporných spotových cen (cache pro UI / API)
CREATE TABLE ems.predicted_negative_price_window (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site (id),
predicted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
predicted_date DATE NOT NULL,
window_start_hour INT NOT NULL,
window_end_hour INT NOT NULL,
probability_pct INT NOT NULL,
expected_min_price NUMERIC(10, 4),
reason TEXT,
UNIQUE (site_id, predicted_date, window_start_hour)
);
COMMENT ON TABLE ems.predicted_negative_price_window IS
'Výstup ems.fn_predict_negative_price_windows predikce intervalů s rizikem záporné nákupní ceny; obnovovat po importu cen a forecastu FVE.';
CREATE INDEX idx_predicted_neg_price_site_date
ON ems.predicted_negative_price_window (site_id, predicted_date);

View File

@@ -0,0 +1,78 @@
-- =============================================================
-- V014__asset_model_refinement.sql
-- Rozlišení limitů: AC střídač / DC FVE / baterie přes měnič / BMS+C-rate
-- =============================================================
-- Střídač: nové sloupce (staré max_charge_power_w / max_discharge_power_w ponechány kvůli kompatibilitě)
ALTER TABLE ems.asset_inverter
ADD COLUMN IF NOT EXISTS max_ac_output_w INT,
ADD COLUMN IF NOT EXISTS max_dc_input_w INT,
ADD COLUMN IF NOT EXISTS max_battery_charge_w INT,
ADD COLUMN IF NOT EXISTS max_battery_discharge_w INT,
ADD COLUMN IF NOT EXISTS gen_port_max_power_w INT;
COMMENT ON COLUMN ems.asset_inverter.max_ac_output_w IS
'Maximální AC výkon střídače v W. Deye SUN-20K = 22000 W.';
COMMENT ON COLUMN ems.asset_inverter.max_dc_input_w IS
'Maximální DC vstupní výkon z FVE v W. Deye SUN-20K = 40000 W.';
COMMENT ON COLUMN ems.asset_inverter.max_battery_charge_w IS
'Maximální výkon nabíjení baterie v W limit střídače (ne BMS).
Deye SUN-20K s LV baterií = 18000 W (350A × 51.2V).';
COMMENT ON COLUMN ems.asset_inverter.max_battery_discharge_w IS
'Maximální výkon vybíjení baterie v W limit střídače.
Deye SUN-20K s LV baterií = 18000 W.';
COMMENT ON COLUMN ems.asset_inverter.gen_port_max_power_w IS
'Maximální výkon GEN portu v W. Zahrnuje součet všech zařízení
zapojených do GEN portu (mikroinvertory, ongrid střídač).
Pro home-01 = 10080 W (pv-b pole).
Pro druhou instalaci = 4400 W (2× 2.2 kW mikroinvertory).';
-- Baterie: C-rate a BMS
ALTER TABLE ems.asset_battery
ADD COLUMN IF NOT EXISTS max_charge_c_rate NUMERIC(4,2),
ADD COLUMN IF NOT EXISTS max_discharge_c_rate NUMERIC(4,2),
ADD COLUMN IF NOT EXISTS bms_max_charge_w INT,
ADD COLUMN IF NOT EXISTS bms_max_discharge_w INT;
COMMENT ON COLUMN ems.asset_battery.max_charge_c_rate IS
'Maximální nabíjecí C-rate. 0.5C pro 64 kWh = 32 kW teoretické maximum.
Skutečný limit je min(bms_max_charge_w, inverter.max_battery_charge_w).';
COMMENT ON COLUMN ems.asset_battery.max_discharge_c_rate IS
'Maximální vybíjecí C-rate (symetricky k nabíjení).';
COMMENT ON COLUMN ems.asset_battery.bms_max_charge_w IS
'Maximální nabíjecí výkon dle BMS v W. Pokud NULL, použij C-rate výpočet.';
COMMENT ON COLUMN ems.asset_battery.bms_max_discharge_w IS
'Maximální vybíjecí výkon dle BMS v W. Pokud NULL, použij C-rate výpočet.';
-- Z existujících sloupců přejmenujeme sémantiku do nových (kde ještě nejsou vyplněné)
UPDATE ems.asset_inverter
SET
max_battery_charge_w = COALESCE(max_battery_charge_w, max_charge_power_w),
max_battery_discharge_w = COALESCE(max_battery_discharge_w, max_discharge_power_w)
WHERE max_charge_power_w IS NOT NULL
OR max_discharge_power_w IS NOT NULL;
-- Seed home-01: hlavní Deye (ne ongrid řádek)
UPDATE ems.asset_inverter inv
SET
max_ac_output_w = 22000,
max_dc_input_w = 40000,
max_battery_charge_w = 18000,
max_battery_discharge_w = 18000,
gen_port_max_power_w = 10080
FROM ems.site s
WHERE s.id = inv.site_id
AND s.code = 'home-01'
AND inv.code = 'deye-main';
UPDATE ems.asset_battery ab
SET
max_charge_c_rate = 0.28,
max_discharge_c_rate = 0.28,
bms_max_charge_w = 18000,
bms_max_discharge_w = 18000
FROM ems.asset_inverter inv
JOIN ems.site s ON s.id = inv.site_id
WHERE ab.inverter_id = inv.id
AND s.code = 'home-01'
AND inv.code = 'deye-main';

View File

@@ -0,0 +1,100 @@
-- ============================================================
-- Distribuční tarify a HDO
-- ============================================================
CREATE TABLE ems.distribution_tariff (
id SERIAL PRIMARY KEY,
distributor TEXT NOT NULL, -- 'EGD', 'CEZ', 'PRE'
code TEXT NOT NULL, -- 'D02d', 'C25d', 'custom_fve'
name TEXT NOT NULL,
has_dual_rate BOOLEAN NOT NULL DEFAULT true, -- NT/VT nebo jednotarif
vat_rate NUMERIC(5,4) NOT NULL DEFAULT 0.21,
notes TEXT,
UNIQUE (distributor, code)
);
COMMENT ON TABLE ems.distribution_tariff IS
'Číselník distribučních tarifů. Jeden záznam = jeden tarif jednoho distributora.
has_dual_rate=true znamená NT/VT dvojsazba, false = jednotarif.';
-- ============================================================
CREATE TABLE ems.distribution_tariff_rate (
id SERIAL PRIMARY KEY,
tariff_id INT NOT NULL REFERENCES ems.distribution_tariff(id),
rate_type TEXT NOT NULL, -- 'NT', 'VT', 'single'
price_czk_kwh NUMERIC(10,6) NOT NULL, -- variabilní složka bez DPH
valid_from DATE NOT NULL,
valid_to DATE, -- NULL = platí dosud
notes TEXT,
UNIQUE (tariff_id, rate_type, valid_from)
);
COMMENT ON TABLE ems.distribution_tariff_rate IS
'Sazby distribučního tarifu Kč/kWh bez DPH. Verzováno přes valid_from/valid_to.
Při roční změně tarifů: nastav valid_to na starém záznamu a přidej nový.
price_czk_kwh = pouze variabilní distribuce, BEZ systémových služeb a OTE.';
COMMENT ON COLUMN ems.distribution_tariff_rate.price_czk_kwh IS
'Variabilní distribuční složka Kč/kWh bez DPH.
Nezahrnuje: systémové služby ČEPS, poplatek OTE, silovou elektřinu (spot).
Tyto ostatní fixní složky jsou v site_market_config jako system_services_czk_kwh.';
-- ============================================================
CREATE TABLE ems.hdo_code (
id SERIAL PRIMARY KEY,
distributor TEXT NOT NULL, -- 'EGD', 'CEZ', 'PRE'
code TEXT NOT NULL, -- 'B1', 'C3', 'custom_fve_home01'
description TEXT,
valid_from DATE NOT NULL,
valid_to DATE, -- NULL = platí dosud
notes TEXT,
UNIQUE (distributor, code, valid_from)
);
COMMENT ON TABLE ems.hdo_code IS
'Číselník HDO kódů per distributor. Při roční změně přidat nový záznam
s novým valid_from starý zůstane v historii pro audit.
Kód "custom_fve_home01" pro FVE instalace bez standardního HDO kódu.';
-- ============================================================
CREATE TABLE ems.hdo_code_window (
id SERIAL PRIMARY KEY,
hdo_code_id INT NOT NULL REFERENCES ems.hdo_code(id),
day_type TEXT NOT NULL DEFAULT 'all',
-- 'all' = každý den, 'workday' = Po-Pá, 'weekend' = So-Ne
rate_type TEXT NOT NULL DEFAULT 'VT', -- 'VT' nebo 'NT'
window_from TIME NOT NULL, -- začátek okna (inclusive)
window_to TIME NOT NULL -- konec okna (exclusive)
);
COMMENT ON TABLE ems.hdo_code_window IS
'NT/VT časová okna pro HDO kód. Více řádků = více oken za den.
Logika: pokud aktuální čas spadá do VT okna → rate_type=VT, jinak NT.
day_type=all znamená stejná okna každý den (workday i weekend).
Příklad home-01: VT 09:00-10:00, 12:00-13:00, 16:00-17:00, 20:00-21:00.';
-- ============================================================
-- Rozšíření site_market_config
-- ============================================================
ALTER TABLE ems.site_market_config
ADD COLUMN IF NOT EXISTS tariff_id INT REFERENCES ems.distribution_tariff(id),
ADD COLUMN IF NOT EXISTS hdo_code_id INT REFERENCES ems.hdo_code(id),
ADD COLUMN IF NOT EXISTS system_services_czk_kwh NUMERIC(10,6) DEFAULT 0,
ADD COLUMN IF NOT EXISTS ote_fee_czk_kwh NUMERIC(10,6) DEFAULT 0;
COMMENT ON COLUMN ems.site_market_config.tariff_id IS
'Distribuční tarif přiřazený k této lokalitě. FK na distribution_tariff.';
COMMENT ON COLUMN ems.site_market_config.hdo_code_id IS
'HDO kód přiřazený k této lokalitě. Určuje NT/VT časová okna.
Při změně HDO předpisu stačí přidat nový hdo_code záznam a aktualizovat FK.';
COMMENT ON COLUMN ems.site_market_config.system_services_czk_kwh IS
'Součet systémových poplatků Kč/kWh bez DPH:
systémové služby ČEPS + poplatek OTE + příspěvek na OZE.
Orientační hodnota EG.D 2025: ~0.40 Kč/kWh. Doplnit ze smlouvy/faktury.';
COMMENT ON COLUMN ems.site_market_config.ote_fee_czk_kwh IS
'Poplatek OTE za použití trhu Kč/kWh bez DPH.
Orientačně ~0.001 Kč/kWh. Lze zahrnout do system_services_czk_kwh.';

View File

@@ -0,0 +1,55 @@
-- EG.D tarif pro home-01 (FVE speciální režim)
INSERT INTO ems.distribution_tariff
(distributor, code, name, has_dual_rate, vat_rate, notes)
VALUES
('EGD', 'custom_fve_home01',
'EG.D FVE vlastní spotřeba dvojsazba',
true, 0.21,
'Speciální tarif pro FVE instalaci home-01. VT okna dle smlouvy.');
-- Sazby placeholder hodnoty, doplnit ze smlouvy/faktury
INSERT INTO ems.distribution_tariff_rate
(tariff_id, rate_type, price_czk_kwh, valid_from)
SELECT
id, 'NT', 0.2243, '2025-01-01'
FROM ems.distribution_tariff WHERE distributor='EGD' AND code='custom_fve_home01';
INSERT INTO ems.distribution_tariff_rate
(tariff_id, rate_type, price_czk_kwh, valid_from)
SELECT
id, 'VT', 0.74987, '2025-01-01'
FROM ems.distribution_tariff WHERE distributor='EGD' AND code='custom_fve_home01';
-- HDO kód pro home-01
INSERT INTO ems.hdo_code
(distributor, code, description, valid_from, notes)
VALUES
('EGD', 'custom_fve_home01',
'FVE home-01 VT okna 09-10, 12-13, 16-17, 20-21 (každý den)',
'2025-01-01',
'Platí stejně workday i weekend. Při změně přidat nový záznam s novým valid_from.');
-- VT okna (4 okna, každý den)
INSERT INTO ems.hdo_code_window
(hdo_code_id, day_type, rate_type, window_from, window_to)
SELECT
hc.id, 'all', 'VT', w.wf::TIME, w.wt::TIME
FROM ems.hdo_code hc,
(VALUES
('09:00', '10:00'),
('12:00', '13:00'),
('16:00', '17:00'),
('20:00', '21:00')
) AS w(wf, wt)
WHERE hc.distributor = 'EGD' AND hc.code = 'custom_fve_home01';
-- Napojit home-01 na tarif a HDO kód
UPDATE ems.site_market_config SET
tariff_id = (SELECT id FROM ems.distribution_tariff
WHERE distributor='EGD' AND code='custom_fve_home01'),
hdo_code_id = (SELECT id FROM ems.hdo_code
WHERE distributor='EGD' AND code='custom_fve_home01'
ORDER BY valid_from DESC LIMIT 1),
system_services_czk_kwh = 0.192,
ote_fee_czk_kwh = 0.001
WHERE site_id = (SELECT id FROM ems.site WHERE code='home-01');

View File

@@ -0,0 +1,47 @@
-- =============================================================
-- V017__green_bonus.sql
-- Zelený bonus na úrovni FVE pole (asset_pv_array), ne v prodejní ceně
-- =============================================================
ALTER TABLE ems.asset_pv_array
ADD COLUMN IF NOT EXISTS green_bonus_czk_kwh NUMERIC(10,6),
ADD COLUMN IF NOT EXISTS green_bonus_valid_from DATE,
ADD COLUMN IF NOT EXISTS green_bonus_valid_to DATE,
ADD COLUMN IF NOT EXISTS green_bonus_meter_code TEXT;
COMMENT ON COLUMN ems.asset_pv_array.green_bonus_czk_kwh IS
'Aktuální sazba zeleného bonusu Kč/kWh za vyrobenou elektřinu.
NULL = pole nemá zelený bonus. Bonus se počítá z celkové výroby pole
bez ohledu na to kam energie šla (interní spotřeba i export).
Sazba se mění ročně při změně nastav green_bonus_valid_to na starém
záznamu a aktualizuj na novou hodnotu s novým green_bonus_valid_from.';
COMMENT ON COLUMN ems.asset_pv_array.green_bonus_valid_from IS
'Datum od kdy platí aktuální sazba zeleného bonusu (včetně).';
COMMENT ON COLUMN ems.asset_pv_array.green_bonus_valid_to IS
'Datum do kdy platí aktuální sazba zeleného bonusu (exclusive).
NULL = platí dosud. Při roční změně nastav na první den nového roku
a aktualizuj green_bonus_czk_kwh na novou sazbu.';
COMMENT ON COLUMN ems.asset_pv_array.green_bonus_meter_code IS
'Číslo zeleného elektroměru (EAN nebo číslo ze smlouvy s distributorem).
Slouží pro audit bonus se počítá z odečtů tohoto elektroměru.';
ALTER TABLE ems.audit_interval
ADD COLUMN IF NOT EXISTS green_bonus_czk NUMERIC(10,4) DEFAULT 0;
COMMENT ON COLUMN ems.audit_interval.green_bonus_czk IS
'Příjem ze zeleného bonusu za výrobu bonusových FVE polí v Kč.
Počítáno přes fn_green_bonus_revenue() v audit_filler.
Nezahrnuto v actual_cost_czk je to samostatný příjem.';
-- Seed home-01: zelený bonus jen na pv-b (ongrid střídač na GEN portu)
UPDATE ems.asset_pv_array
SET
green_bonus_czk_kwh = 7.135, -- TODO: doplnit skutečnou sazbu ze smlouvy
green_bonus_valid_from = '2026-01-01',
green_bonus_valid_to = NULL, -- platí dosud
green_bonus_meter_code = 'TODO' -- doplnit EAN zeleného elektroměru
WHERE site_id = (SELECT id FROM ems.site WHERE code = 'home-01')
AND code = 'pv-b';

View File

@@ -0,0 +1,13 @@
-- =============================================================
-- V018__cleanup_legacy_green_bonus.sql
-- Odstranění legacy sloupců zeleného bonusu ze site_market_config (nahrazeno V017 asset_pv_array)
-- =============================================================
ALTER TABLE ems.site_market_config
DROP COLUMN IF EXISTS green_bonus_czk_kwh,
DROP COLUMN IF EXISTS green_bonus_asset_code;
COMMENT ON TABLE ems.site_market_config IS
'Konfigurace tržního prostředí per site.
Zelený bonus je od V017 na ems.asset_pv_array (green_bonus_czk_kwh),
nikoliv zde bonus je vlastností fyzického FVE pole, ne site konfigurace.';

View File

@@ -0,0 +1,43 @@
-- ============================================================
-- Tabulka pro tracking přesnosti forecastu
-- ============================================================
CREATE TABLE ems.forecast_accuracy (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
pv_array_id INT NOT NULL REFERENCES ems.asset_pv_array(id),
interval_start TIMESTAMPTZ NOT NULL,
run_id INT NOT NULL REFERENCES ems.forecast_pv_run(id),
-- Forecast hodnoty
forecast_power_w INT NOT NULL,
forecast_created_at TIMESTAMPTZ NOT NULL,
lead_time_hours NUMERIC(6,2), -- kolik hodin předem byl forecast vytvořen
-- Skutečnost (doplněna zpětně z telemetrie)
actual_power_w INT,
actual_filled_at TIMESTAMPTZ,
-- Odchylka
error_w INT, -- forecast - actual
error_pct NUMERIC(8,4), -- (forecast - actual) / actual * 100
UNIQUE (run_id, interval_start)
);
COMMENT ON TABLE ems.forecast_accuracy IS
'Tracking přesnosti FVE forecastu. Každý řádek = jeden 15min slot
z jednoho forecast runu. actual_power_w se doplňuje zpětně z telemetrie
po uplynutí intervalu přes fn_fill_forecast_accuracy().
Uchovávat navždy slouží pro analýzu přesnosti a budoucí kalibraci solveru.';
COMMENT ON COLUMN ems.forecast_accuracy.lead_time_hours IS
'Kolik hodin předem byl tento forecast vytvořen.
Příklad: forecast vytvořen v pondělí 14:00, interval ve středu 12:00 = 46h.
Slouží pro analýzu: je 6h forecast přesnější než 48h forecast?';
COMMENT ON COLUMN ems.forecast_accuracy.error_pct IS
'Relativní chyba v %. Kladná = forecast nadhodnotil, záporná = podhodnotil.
NULL pokud actual_power_w = 0 (zamezení dělení nulou).';
CREATE INDEX idx_forecast_accuracy_site_time
ON ems.forecast_accuracy (site_id, interval_start DESC);
CREATE INDEX idx_forecast_accuracy_array_lead
ON ems.forecast_accuracy (pv_array_id, lead_time_hours, interval_start DESC);

View File

@@ -0,0 +1,30 @@
-- Statistika příjezdů EV (den v týdnu × hodina); plní telemetry_collector při přechodu available → nabíjení.
CREATE TABLE ems.ev_arrival_stats (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
vehicle_id INT REFERENCES ems.asset_vehicle(id),
charger_id INT NOT NULL REFERENCES ems.asset_ev_charger(id),
day_of_week INT NOT NULL,
arrival_hour INT NOT NULL,
sample_count INT NOT NULL DEFAULT 0,
last_updated TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_ev_arrival_stats_dow CHECK (day_of_week >= 0 AND day_of_week <= 6),
CONSTRAINT chk_ev_arrival_stats_hour CHECK (arrival_hour >= 0 AND arrival_hour <= 23),
UNIQUE (site_id, charger_id, day_of_week, arrival_hour)
);
CREATE INDEX idx_ev_arrival_stats_site_charger
ON ems.ev_arrival_stats (site_id, charger_id);
COMMENT ON TABLE ems.ev_arrival_stats IS
'Statistika příjezdů EV dle dne v týdnu a hodiny (časová zóna Europe/Prague).
Plní se z ev_session / telemetrie při detekci připojení (available → preparing/charging).
Po ~4 týdnech dat lze odhadovat typickou hodinu příjezdu.';
-- Nejvýše jedna otevřená session na nabíječku (pro INSERT … ON CONFLICT při startu session).
CREATE UNIQUE INDEX uidx_ev_session_charger_open
ON ems.ev_session (charger_id)
WHERE session_end IS NULL;
GRANT SELECT ON ems.ev_arrival_stats TO ems_anon;

View File

@@ -0,0 +1,27 @@
-- Historické průměry bazální spotřeby (DOW + hodina) pro solver a forecast.
CREATE TABLE ems.consumption_baseline_stats (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
day_of_week INT NOT NULL, -- 0=neděle, 1=pondělí... 6=sobota
hour_of_day INT NOT NULL, -- 0-23
avg_power_w NUMERIC(10,2) NOT NULL,
stddev_power_w NUMERIC(10,2),
sample_count INT NOT NULL DEFAULT 0,
last_updated TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (site_id, day_of_week, hour_of_day)
);
COMMENT ON TABLE ems.consumption_baseline_stats IS
'Historické průměry bazální spotřeby per den v týdnu a hodinu.
Plní se automaticky z telemetrie přes fn_update_baseline_stats().
Bazální = load_power_w - ev - tc (bez řízených zátěží).
Používá se jako vstup do solveru pro predikci spotřeby.';
COMMENT ON COLUMN ems.consumption_baseline_stats.avg_power_w IS
'Průměrný výkon bazální spotřeby W pro daný DOW+hodinu.
Exponenciální klouzavý průměr nová data mají větší váhu.';
COMMENT ON COLUMN ems.consumption_baseline_stats.stddev_power_w IS
'Směrodatná odchylka W míra variability spotřeby.
Lze použít pro konzervativní odhad: avg + 0.5*stddev.';

View File

@@ -0,0 +1,38 @@
-- Rozšířený horizont plánování: statistiky cen a TUV pro predikce za horizont OTE.
CREATE TABLE ems.market_price_stats (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
day_of_week INT NOT NULL,
hour_of_day INT NOT NULL,
avg_price NUMERIC(10,6) NOT NULL,
stddev_price NUMERIC(10,6),
p25_price NUMERIC(10,6),
p75_price NUMERIC(10,6),
sample_count INT NOT NULL DEFAULT 0,
last_updated TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (site_id, day_of_week, hour_of_day)
);
COMMENT ON TABLE ems.market_price_stats IS
'Historické průměry spotové ceny OTE per DOW+hodina.
Analogie consumption_baseline_stats pro ceny.
Používá se pro predikci cen za horizont OTE (36h+).
Min. 3 měsíce dat pro smysluplné průměry.';
CREATE TABLE ems.tuv_usage_stats (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
day_of_week INT NOT NULL,
hour_of_day INT NOT NULL,
avg_temp_delta_c NUMERIC(6,3) NOT NULL,
stddev_temp_delta NUMERIC(6,3),
sample_count INT NOT NULL DEFAULT 0,
last_updated TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (site_id, day_of_week, hour_of_day)
);
COMMENT ON TABLE ems.tuv_usage_stats IS
'Průměrná změna teploty TUV zásobníku per DOW+hodina.
Záporná hodnota = zásobník se ochlazuje (spotřeba teplé vody).
Kladná = TČ ohřívalo. Používá se pro predikci kdy bude potřeba ohřev.';

View File

@@ -0,0 +1,54 @@
-- Modbus command journal + cut-off switch audit (EMS)
CREATE TABLE ems.modbus_command (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
asset_type TEXT NOT NULL, -- 'inverter', 'ev_charger', 'heat_pump', 'cutoff'
asset_id INT NOT NULL, -- ID v příslušné asset tabulce
asset_code TEXT NOT NULL, -- např. 'deye-main' (denorm. pro čitelnost)
device_host TEXT NOT NULL,
device_port INT NOT NULL,
device_unit_id INT NOT NULL,
register INT NOT NULL, -- číslo registru (decimal)
register_name TEXT, -- 'export_limit', 'charge_limit' (pro čitelnost)
value_to_write INT NOT NULL,
value_written INT, -- skutečně zapsaná hodnota (NULL = nezapsáno)
value_verified INT, -- přečtená hodnota po verifikaci (NULL = neověřeno)
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'written', 'verified', 'failed', 'mismatch', 'retrying'
planning_run_id INT REFERENCES ems.planning_run(id),
attempt_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
written_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
error_msg TEXT
);
COMMENT ON TABLE ems.modbus_command IS
'Command journal pro Modbus zápisy do zařízení.
Každý zápis = jeden řádek. Verifikační job ověří value_verified == value_to_write.
Při mismatch: retry max 3× → přepnout na SELF_SUSTAIN + Discord alert.
asset_type+asset_id odkazuje na příslušnou asset tabulku (inverter/ev_charger/...).';
CREATE INDEX idx_modbus_command_status
ON ems.modbus_command (site_id, status, created_at DESC);
CREATE INDEX idx_modbus_command_pending
ON ems.modbus_command (status, created_at)
WHERE status IN ('pending', 'retrying');
CREATE TABLE ems.cutoff_switch_log (
id SERIAL PRIMARY KEY,
site_id INT NOT NULL REFERENCES ems.site(id),
asset_code TEXT NOT NULL, -- 'cutoff-pv-b', 'cutoff-microinverter'
switched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
new_state BOOLEAN NOT NULL, -- true=zapnuto/připojeno, false=odpojeno
previous_state BOOLEAN,
reason TEXT NOT NULL, -- 'negative_sell_price', 'manual', 'auto_restore'
sell_price_czk NUMERIC(10,6), -- spotová cena v době přepnutí
triggered_by TEXT -- 'control_exporter', 'user:jan', 'system'
);
COMMENT ON TABLE ems.cutoff_switch_log IS
'Log přepnutí cut-off přepínačů. Loguje se jen při změně stavu (edge trigger).
Používá se pro mikroinvertory na GEN portu při záporných prodejních cenách.';

View File

@@ -0,0 +1,8 @@
-- Označení intervalů plánu, kde solver použil predikovanou cenu (mimo přesné OTE v efektivní ceně).
ALTER TABLE ems.planning_interval
ADD COLUMN IF NOT EXISTS is_predicted_price BOOLEAN NOT NULL DEFAULT false;
COMMENT ON COLUMN ems.planning_interval.is_predicted_price IS
'True pokud cena pro tento slot pochází z predikce (market_price_stats)
a ne z přesných OTE dat. Sloty > 36h od now() při daily_plan běhu.';

View File

@@ -0,0 +1,8 @@
-- Fyzický režim Deye u záznamů journalu (PASSIVE / SELL / CHARGE)
ALTER TABLE ems.modbus_command
ADD COLUMN IF NOT EXISTS deye_physical_mode TEXT;
COMMENT ON COLUMN ems.modbus_command.deye_physical_mode IS
'Fyzický režim Deye při zápisu: PASSIVE / SELL / CHARGE.
Slouží pro audit a analýzu přepínání režimů.';

View File

@@ -0,0 +1,11 @@
-- Tune battery economics for planning behavior:
-- - lower reserve SOC to allow economically justified discharge
-- - lower degradation cost to avoid overly conservative cycling
--
-- Idempotent update for currently deployed sites.
UPDATE ems.asset_battery
SET
reserve_soc_percent = 10.00,
degradation_cost_czk_kwh = 0.1500
WHERE reserve_soc_percent <> 10.00
OR degradation_cost_czk_kwh <> 0.1500;

View File

@@ -0,0 +1,118 @@
CREATE OR REPLACE FUNCTION ems.fn_update_baseline_stats(
p_site_id INT,
p_lookback_days INT DEFAULT 30
)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_count INT;
BEGIN
WITH raw AS (
SELECT
EXTRACT(DOW FROM ti.measured_at AT TIME ZONE 'Europe/Prague')::INT AS dow,
EXTRACT(HOUR FROM ti.measured_at AT TIME ZONE 'Europe/Prague')::INT AS hour,
GREATEST(0,
ti.load_power_w
- COALESCE((
SELECT AVG(tev.power_w)
FROM ems.telemetry_ev_charger tev
WHERE tev.site_id = ti.site_id
AND tev.measured_at BETWEEN
ti.measured_at - INTERVAL '30 seconds'
AND ti.measured_at + INTERVAL '30 seconds'
), 0)::INT
- COALESCE((
SELECT AVG(thp.power_w)
FROM ems.telemetry_heat_pump thp
WHERE thp.site_id = ti.site_id
AND thp.measured_at BETWEEN
ti.measured_at - INTERVAL '30 seconds'
AND ti.measured_at + INTERVAL '30 seconds'
), 0)::INT
) AS baseline_w
FROM ems.telemetry_inverter ti
WHERE ti.site_id = p_site_id
AND ti.measured_at >= now() - make_interval(days => p_lookback_days)
AND ti.load_power_w IS NOT NULL
AND ti.load_power_w > 0
),
agg AS (
SELECT
dow,
hour,
AVG(baseline_w) AS avg_w,
STDDEV(baseline_w) AS stddev_w,
COUNT(*) AS samples
FROM raw
GROUP BY dow, hour
HAVING COUNT(*) >= 4
)
INSERT INTO ems.consumption_baseline_stats
(site_id, day_of_week, hour_of_day,
avg_power_w, stddev_power_w, sample_count, last_updated)
SELECT
p_site_id, dow, hour,
ROUND(avg_w::NUMERIC, 2),
ROUND(stddev_w::NUMERIC, 2),
samples,
now()
FROM agg
ON CONFLICT (site_id, day_of_week, hour_of_day) DO UPDATE SET
avg_power_w = ROUND(
0.7 * ems.consumption_baseline_stats.avg_power_w
+ 0.3 * EXCLUDED.avg_power_w, 2),
stddev_power_w = ROUND(
COALESCE(0.7 * ems.consumption_baseline_stats.stddev_power_w
+ 0.3 * EXCLUDED.stddev_power_w,
EXCLUDED.stddev_power_w), 2),
sample_count = ems.consumption_baseline_stats.sample_count
+ EXCLUDED.sample_count,
last_updated = now();
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
COMMENT ON FUNCTION ems.fn_update_baseline_stats(INT, INT) IS
'Aktualizuje průměry bazální spotřeby z telemetrie posledních N dní.
Používá exponenciální klouzavý průměr (EMA 70/30) pro postupné zpřesňování.
Volat denně po půlnoci. Pro první naplnění: fn_update_baseline_stats(2, 90).';
CREATE OR REPLACE FUNCTION ems.fn_get_baseline_forecast(
p_site_id INT,
p_from TIMESTAMPTZ,
p_to TIMESTAMPTZ
)
RETURNS TABLE (
interval_start TIMESTAMPTZ,
forecast_w INT,
confidence_w INT
)
LANGUAGE sql
STABLE
AS $$
SELECT
gs.slot AS interval_start,
COALESCE(cbs.avg_power_w, 500)::INT AS forecast_w,
COALESCE(
cbs.avg_power_w + 0.5 * COALESCE(cbs.stddev_power_w, 100),
550
)::INT AS confidence_w
FROM generate_series(p_from, p_to - INTERVAL '15 minutes',
INTERVAL '15 minutes') AS gs(slot)
LEFT JOIN ems.consumption_baseline_stats cbs
ON cbs.site_id = p_site_id
AND cbs.day_of_week = EXTRACT(DOW FROM gs.slot AT TIME ZONE 'Europe/Prague')::INT
AND cbs.hour_of_day = EXTRACT(HOUR FROM gs.slot AT TIME ZONE 'Europe/Prague')::INT
ORDER BY gs.slot;
$$;
COMMENT ON FUNCTION ems.fn_get_baseline_forecast(INT, TIMESTAMPTZ, TIMESTAMPTZ) IS
'Vrátí předpověď bazální spotřeby pro zadaný horizont jako 15min sloty.
forecast_w = průměr dle DOW+hodina z historických dat.
confidence_w = konzervativní odhad (avg + 0.5*stddev).
Fallback 500W pokud nejsou historická data.
Použití v solveru: nahrazuje pevný fallback 500W v _load_slots().';

View File

@@ -5,31 +5,122 @@
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_effective_buy_price(
p_site_id INT,
p_site_id INT,
p_interval_start TIMESTAMPTZ
)
RETURNS NUMERIC(10,6)
LANGUAGE sql
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_spot_price NUMERIC;
v_dist_rate NUMERIC;
v_system_services NUMERIC;
v_ote_fee NUMERIC;
v_vat_rate NUMERIC;
v_buy_margin_fixed NUMERIC;
v_buy_margin_pct NUMERIC;
v_buy_margin NUMERIC;
v_is_vt BOOLEAN;
v_local_time TIME;
v_dow INT;
v_hdo_code_id INT;
v_tariff_id INT;
v_rate_type TEXT;
BEGIN
SELECT
mip.buy_raw_price_czk_kwh
+ smc.buy_margin_fixed_czk
+ (mip.buy_raw_price_czk_kwh * smc.buy_margin_percent / 100.0)
FROM ems.market_interval_price mip
CROSS JOIN ems.site_market_config smc
WHERE mip.market_source = 'OTE_CZ'
AND mip.interval_start = p_interval_start
AND smc.site_id = p_site_id
smc.buy_margin_fixed_czk,
smc.buy_margin_percent,
smc.system_services_czk_kwh,
smc.ote_fee_czk_kwh,
smc.hdo_code_id,
smc.tariff_id,
dt.vat_rate
INTO
v_buy_margin_fixed,
v_buy_margin_pct,
v_system_services,
v_ote_fee,
v_hdo_code_id,
v_tariff_id,
v_vat_rate
FROM ems.site_market_config smc
LEFT JOIN ems.distribution_tariff dt ON dt.id = smc.tariff_id
WHERE smc.site_id = p_site_id
AND smc.valid_from <= p_interval_start
AND (smc.valid_to IS NULL OR smc.valid_to > p_interval_start)
ORDER BY smc.valid_from DESC
LIMIT 1;
IF NOT FOUND THEN
RETURN NULL;
END IF;
SELECT buy_raw_price_czk_kwh INTO v_spot_price
FROM ems.market_interval_price
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
AND interval_start = p_interval_start
LIMIT 1;
IF v_spot_price IS NULL THEN
RETURN NULL;
END IF;
v_local_time := (p_interval_start AT TIME ZONE 'Europe/Prague')::TIME;
v_dow := EXTRACT(DOW FROM p_interval_start AT TIME ZONE 'Europe/Prague');
-- 0=neděle, 6=sobota
IF v_hdo_code_id IS NOT NULL THEN
SELECT EXISTS (
SELECT 1
FROM ems.hdo_code_window w
WHERE w.hdo_code_id = v_hdo_code_id
AND (
w.day_type = 'all'
OR (w.day_type = 'workday' AND v_dow BETWEEN 1 AND 5)
OR (w.day_type = 'weekend' AND v_dow IN (0, 6))
)
AND w.rate_type = 'VT'
AND v_local_time >= w.window_from
AND v_local_time < w.window_to
) INTO v_is_vt;
ELSE
v_is_vt := false;
END IF;
v_rate_type := CASE WHEN v_is_vt THEN 'VT' ELSE 'NT' END;
IF v_tariff_id IS NOT NULL THEN
SELECT price_czk_kwh INTO v_dist_rate
FROM ems.distribution_tariff_rate
WHERE tariff_id = v_tariff_id
AND rate_type = v_rate_type
AND valid_from <= p_interval_start::DATE
AND (valid_to IS NULL OR valid_to > p_interval_start::DATE)
ORDER BY valid_from DESC
LIMIT 1;
END IF;
v_dist_rate := COALESCE(v_dist_rate, 0);
v_system_services := COALESCE(v_system_services, 0);
v_ote_fee := COALESCE(v_ote_fee, 0);
v_buy_margin_fixed := COALESCE(v_buy_margin_fixed, 0);
v_buy_margin_pct := COALESCE(v_buy_margin_pct, 0);
v_buy_margin := v_buy_margin_fixed + (v_spot_price * v_buy_margin_pct / 100.0);
v_vat_rate := COALESCE(v_vat_rate, 0.21);
RETURN ROUND(
(v_spot_price + v_dist_rate + v_system_services + v_ote_fee + v_buy_margin)
* (1 + v_vat_rate),
6
);
END;
$$;
COMMENT ON FUNCTION ems.fn_effective_buy_price(INT, TIMESTAMPTZ) IS
'Vrátí efektivní nákupní cenu elektřiny v Kč/kWh pro danou lokalitu a 15min interval.
Přičítá fixní a procentní nákupní marži dle aktuálně platné site_market_config.';
'Efektivní nákupní cena elektřiny Kč/kWh včetně DPH.
Složky: spot OTE + distribuce NT/VT (dle HDO) + systémové služby + OTE poplatek + marže (fix + % ze spotu).
DPH aplikováno na celou částku. Distribuce závisí na HDO kódu site.';
-- ------------------------------------------------------------
@@ -38,24 +129,86 @@ CREATE OR REPLACE FUNCTION ems.fn_effective_sell_price(
p_interval_start TIMESTAMPTZ
)
RETURNS NUMERIC(10,6)
LANGUAGE sql
LANGUAGE plpgsql
STABLE
AS $$
SELECT
mip.sell_raw_price_czk_kwh
+ smc.sell_margin_fixed_czk
+ (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100.0)
FROM ems.market_interval_price mip
CROSS JOIN ems.site_market_config smc
WHERE mip.market_source = 'OTE_CZ'
AND mip.interval_start = p_interval_start
AND smc.site_id = p_site_id
AND smc.valid_from <= p_interval_start
AND (smc.valid_to IS NULL OR smc.valid_to > p_interval_start)
ORDER BY smc.valid_from DESC
DECLARE
v_spot_price NUMERIC;
v_sell_margin_fixed NUMERIC;
v_sell_margin_pct NUMERIC;
BEGIN
SELECT sell_margin_fixed_czk, sell_margin_percent
INTO v_sell_margin_fixed, v_sell_margin_pct
FROM ems.site_market_config
WHERE site_id = p_site_id
AND valid_from <= p_interval_start
AND (valid_to IS NULL OR valid_to > p_interval_start)
ORDER BY valid_from DESC
LIMIT 1;
IF NOT FOUND THEN
RETURN NULL;
END IF;
SELECT sell_raw_price_czk_kwh INTO v_spot_price
FROM ems.market_interval_price
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
AND interval_start = p_interval_start
LIMIT 1;
IF v_spot_price IS NULL THEN
RETURN NULL;
END IF;
RETURN ROUND(
v_spot_price
+ COALESCE(v_sell_margin_fixed, 0)
+ (v_spot_price * COALESCE(v_sell_margin_pct, 0) / 100.0),
6
);
END;
$$;
COMMENT ON FUNCTION ems.fn_effective_sell_price(INT, TIMESTAMPTZ) IS
'Vrátí efektivní prodejní cenu elektřiny v Kč/kWh pro danou lokalitu a 15min interval.
Aplikuje fixní a procentní prodejní marži (záporná marže = srážka z prodejní ceny).';
'Efektivní prodejní cena elektřiny Kč/kWh bez DPH (neplátce DPH).
Složky: spot OTE + fixní/procentní prodejní marže (záporná = srážka).
Zelený bonus není součástí ceny počítá se z výroby přes fn_green_bonus_revenue().
Záporná hodnota = platíme za export (záporné spotové ceny).';
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION ems.fn_green_bonus_revenue(
p_pv_array_id INT,
p_interval_start TIMESTAMPTZ,
p_production_wh NUMERIC
)
RETURNS NUMERIC
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_bonus_rate NUMERIC;
BEGIN
SELECT green_bonus_czk_kwh INTO v_bonus_rate
FROM ems.asset_pv_array
WHERE id = p_pv_array_id
AND green_bonus_czk_kwh IS NOT NULL
AND green_bonus_valid_from <= p_interval_start::DATE
AND (green_bonus_valid_to IS NULL
OR green_bonus_valid_to > p_interval_start::DATE);
IF v_bonus_rate IS NULL OR p_production_wh IS NULL OR p_production_wh <= 0 THEN
RETURN 0;
END IF;
RETURN ROUND((p_production_wh / 1000.0) * v_bonus_rate, 6);
END;
$$;
COMMENT ON FUNCTION ems.fn_green_bonus_revenue(INT, TIMESTAMPTZ, NUMERIC) IS
'Příjem ze zeleného bonusu za výrobu FVE pole v daném intervalu.
Bonus plyne z celkové výroby bez ohledu na to kam energie šla
(interní spotřeba, baterie, EV, TČ i export do sítě).
Sazba se načítá dle platnosti (valid_from/valid_to) ročně aktualizovatelné.
Vrátí 0 pokud pole nemá zelený bonus nebo výroba je nulová.
Použití: SELECT ems.fn_green_bonus_revenue(pv_array_id, interval_start, production_wh);';

View File

@@ -0,0 +1,65 @@
CREATE OR REPLACE FUNCTION ems.fn_update_ev_arrival_stats(
p_site_id INT,
p_charger_id INT,
p_vehicle_id INT,
p_arrived_at TIMESTAMPTZ
)
RETURNS VOID
LANGUAGE sql
AS $$
INSERT INTO ems.ev_arrival_stats
(site_id, charger_id, vehicle_id, day_of_week, arrival_hour, sample_count, last_updated)
VALUES (
p_site_id,
p_charger_id,
p_vehicle_id,
EXTRACT(DOW FROM p_arrived_at AT TIME ZONE 'Europe/Prague')::INT,
EXTRACT(HOUR FROM p_arrived_at AT TIME ZONE 'Europe/Prague')::INT,
1,
now()
)
ON CONFLICT (site_id, charger_id, day_of_week, arrival_hour) DO UPDATE SET
sample_count = ems.ev_arrival_stats.sample_count + 1,
last_updated = now();
$$;
COMMENT ON FUNCTION ems.fn_update_ev_arrival_stats(INT, INT, INT, TIMESTAMPTZ) IS
'Přidá jeden příjezd do statistiky. Volat při otevření nové EV session
(telemetry_collector: přechod status available → preparing/charging).';
CREATE OR REPLACE FUNCTION ems.fn_ev_expected_arrival(
p_site_id INT,
p_charger_id INT,
p_for_date DATE DEFAULT (
(CURRENT_TIMESTAMP AT TIME ZONE 'Europe/Prague')::date + 1
)
)
RETURNS TABLE (
expected_hour INT,
confidence_pct INT,
sample_count INT
)
LANGUAGE sql
STABLE
AS $$
SELECT
s.arrival_hour,
ROUND(
s.sample_count::NUMERIC
/ NULLIF(SUM(s.sample_count) OVER (PARTITION BY s.day_of_week), 0)
* 100
)::INT,
s.sample_count
FROM ems.ev_arrival_stats s
WHERE s.site_id = p_site_id
AND s.charger_id = p_charger_id
AND s.day_of_week = EXTRACT(DOW FROM p_for_date)::INT
AND s.sample_count >= 2
ORDER BY s.sample_count DESC
LIMIT 3;
$$;
COMMENT ON FUNCTION ems.fn_ev_expected_arrival(INT, INT, DATE) IS
'Top 3 nejčastější hodiny příjezdu EV pro den v týdnu odpovídající kalendářnímu datu p_for_date.
Backend předává „zítřek“ v časové zóně lokality. Použití: notifikace, později solver.';

View File

@@ -0,0 +1,153 @@
-- fn_update_market_price_stats, fn_update_tuv_usage_stats, fn_get_predicted_price
-- (rozšířený horizont plánování predikce cen a TUV)
CREATE OR REPLACE FUNCTION ems.fn_update_market_price_stats(
p_site_id INT,
p_lookback_days INT DEFAULT 90
)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE v_count INT;
BEGIN
INSERT INTO ems.market_price_stats
(site_id, day_of_week, hour_of_day,
avg_price, stddev_price, p25_price, p75_price,
sample_count, last_updated)
SELECT
p_site_id,
EXTRACT(DOW FROM interval_start AT TIME ZONE 'Europe/Prague')::INT,
EXTRACT(HOUR FROM interval_start AT TIME ZONE 'Europe/Prague')::INT,
AVG(buy_raw_price_czk_kwh),
STDDEV(buy_raw_price_czk_kwh),
PERCENTILE_CONT(0.25) WITHIN GROUP (
ORDER BY buy_raw_price_czk_kwh),
PERCENTILE_CONT(0.75) WITHIN GROUP (
ORDER BY buy_raw_price_czk_kwh),
COUNT(*)::INT,
now()
FROM ems.market_interval_price
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
AND interval_start >= now() - make_interval(days => p_lookback_days)
GROUP BY
EXTRACT(DOW FROM interval_start AT TIME ZONE 'Europe/Prague'),
EXTRACT(HOUR FROM interval_start AT TIME ZONE 'Europe/Prague')
HAVING COUNT(*) >= 4
ON CONFLICT (site_id, day_of_week, hour_of_day) DO UPDATE SET
avg_price = 0.7 * ems.market_price_stats.avg_price
+ 0.3 * EXCLUDED.avg_price,
stddev_price = COALESCE(
0.7 * ems.market_price_stats.stddev_price
+ 0.3 * EXCLUDED.stddev_price,
EXCLUDED.stddev_price),
p25_price = EXCLUDED.p25_price,
p75_price = EXCLUDED.p75_price,
sample_count = ems.market_price_stats.sample_count
+ EXCLUDED.sample_count,
last_updated = now();
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
COMMENT ON FUNCTION ems.fn_update_market_price_stats IS
'Aktualizuje historické průměry spotové ceny per DOW+hodina.
EMA 70/30 pro postupné zpřesňování. Volat denně po importu OTE.
Pro první naplnění: SELECT ems.fn_update_market_price_stats(2, 180);';
CREATE OR REPLACE FUNCTION ems.fn_update_tuv_usage_stats(
p_site_id INT,
p_lookback_days INT DEFAULT 30
)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE v_count INT;
BEGIN
INSERT INTO ems.tuv_usage_stats
(site_id, day_of_week, hour_of_day,
avg_temp_delta_c, stddev_temp_delta,
sample_count, last_updated)
WITH deltas AS (
SELECT
measured_at,
EXTRACT(DOW FROM measured_at AT TIME ZONE 'Europe/Prague')::INT AS dow,
EXTRACT(HOUR FROM measured_at AT TIME ZONE 'Europe/Prague')::INT AS hour,
tuv_tank_temp_c - LAG(tuv_tank_temp_c) OVER (
PARTITION BY site_id ORDER BY measured_at
) AS temp_delta_c
FROM ems.telemetry_heat_pump
WHERE site_id = p_site_id
AND measured_at >= now() - make_interval(days => p_lookback_days)
AND tuv_tank_temp_c IS NOT NULL
)
SELECT
p_site_id, dow, hour,
AVG(temp_delta_c),
STDDEV(temp_delta_c),
COUNT(*)::INT,
now()
FROM deltas
WHERE temp_delta_c IS NOT NULL
AND ABS(temp_delta_c) < 5
GROUP BY dow, hour
HAVING COUNT(*) >= 4
ON CONFLICT (site_id, day_of_week, hour_of_day) DO UPDATE SET
avg_temp_delta_c = 0.7 * ems.tuv_usage_stats.avg_temp_delta_c
+ 0.3 * EXCLUDED.avg_temp_delta_c,
stddev_temp_delta = COALESCE(
0.7 * ems.tuv_usage_stats.stddev_temp_delta
+ 0.3 * EXCLUDED.stddev_temp_delta,
EXCLUDED.stddev_temp_delta),
sample_count = ems.tuv_usage_stats.sample_count
+ EXCLUDED.sample_count,
last_updated = now();
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
COMMENT ON FUNCTION ems.fn_update_tuv_usage_stats IS
'Aktualizuje statistiku poklesu teploty TUV zásobníku per DOW+hodina.
Záporné avg_temp_delta_c = zásobník se ochlazuje (spotřeba teplé vody).
Potřeba min. 1 měsíc telemetrie TČ. Volat denně.';
CREATE OR REPLACE FUNCTION ems.fn_get_predicted_price(
p_site_id INT,
p_interval_start TIMESTAMPTZ
)
RETURNS NUMERIC
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_dow INT;
v_hour INT;
v_price NUMERIC;
v_corr NUMERIC := 1.0;
BEGIN
v_dow := EXTRACT(DOW FROM p_interval_start AT TIME ZONE 'Europe/Prague')::INT;
v_hour := EXTRACT(HOUR FROM p_interval_start AT TIME ZONE 'Europe/Prague')::INT;
SELECT avg_price INTO v_price
FROM ems.market_price_stats
WHERE site_id = p_site_id
AND day_of_week = v_dow
AND hour_of_day = v_hour;
IF v_price IS NULL THEN
RETURN 2.50;
END IF;
RETURN ROUND(v_price * v_corr, 6);
END;
$$;
COMMENT ON FUNCTION ems.fn_get_predicted_price IS
'Vrátí predikovanou cenu pro slot za horizontem OTE.
Zatím používá jen sezónní průměr. Fáze 3d přidá korekci počasím.
Fallback 2.50 Kč/kWh pokud nejsou historická data (min. 3 měsíce).';

View File

@@ -12,19 +12,23 @@ RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_interval_end TIMESTAMPTZ := p_interval_start + INTERVAL '15 minutes';
v_run_id INT;
v_avg_pv_power_w INT;
v_avg_battery_power_w INT;
v_avg_grid_power_w INT;
v_avg_load_power_w INT;
v_last_soc NUMERIC(5,2);
v_sum_ev_power_w INT;
v_avg_hp_power_w INT;
v_plan ems.planning_interval%ROWTYPE;
v_buy_price NUMERIC;
v_sell_price NUMERIC;
v_actual_cost NUMERIC := NULL;
v_interval_end TIMESTAMPTZ := p_interval_start + INTERVAL '15 minutes';
v_run_id INT;
v_avg_pv_power_w INT;
v_avg_battery_power_w INT;
v_avg_grid_power_w INT;
v_avg_load_power_w INT;
v_last_soc NUMERIC(5,2);
v_sum_ev_power_w INT;
v_avg_hp_power_w INT;
v_plan ems.planning_interval%ROWTYPE;
v_buy_price NUMERIC;
v_sell_price NUMERIC;
v_actual_cost NUMERIC := NULL;
v_green_bonus_czk NUMERIC := 0;
v_pv_b_production_wh NUMERIC;
v_array_prod_wh NUMERIC;
r_bonus RECORD;
BEGIN
-- Najít aktivní plán pro tento interval
SELECT pi.* INTO v_plan
@@ -88,6 +92,37 @@ BEGIN
ELSE COALESCE(v_sell_price, 0) END;
END IF;
-- Zelený bonus: výroba bonusových polí z poslední ok predikce pro slot (Wh = W × 0,25 h)
v_pv_b_production_wh := NULL;
FOR r_bonus IN
SELECT id
FROM ems.asset_pv_array
WHERE site_id = p_site_id
AND green_bonus_czk_kwh IS NOT NULL
LOOP
SELECT fpi.power_w * 0.25
INTO v_array_prod_wh
FROM ems.forecast_pv_interval fpi
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
WHERE fpr.site_id = p_site_id
AND fpr.pv_array_id = r_bonus.id
AND fpi.interval_start = p_interval_start
AND fpr.status = 'ok'
ORDER BY fpr.created_at DESC
LIMIT 1;
v_array_prod_wh := COALESCE(v_array_prod_wh, 0);
IF v_pv_b_production_wh IS NULL THEN
v_pv_b_production_wh := 0;
END IF;
v_pv_b_production_wh := v_pv_b_production_wh + v_array_prod_wh;
v_green_bonus_czk := v_green_bonus_czk + ems.fn_green_bonus_revenue(
r_bonus.id,
p_interval_start,
v_array_prod_wh
);
END LOOP;
-- Upsert do audit_interval
INSERT INTO ems.audit_interval (
site_id, interval_start, planning_run_id,
@@ -97,6 +132,8 @@ BEGIN
actual_ev_power_w,
actual_heat_pump_power_w,
actual_cost_czk,
pv_b_production_wh,
green_bonus_czk,
deviation_grid_w,
deviation_cost_czk
) VALUES (
@@ -109,6 +146,8 @@ BEGIN
v_sum_ev_power_w,
v_avg_hp_power_w,
ROUND(v_actual_cost, 4),
v_pv_b_production_wh,
ROUND(v_green_bonus_czk, 4),
CASE WHEN v_plan.run_id IS NOT NULL
THEN v_avg_grid_power_w - v_plan.grid_setpoint_w
ELSE NULL END,
@@ -126,6 +165,8 @@ BEGIN
actual_ev_power_w = EXCLUDED.actual_ev_power_w,
actual_heat_pump_power_w = EXCLUDED.actual_heat_pump_power_w,
actual_cost_czk = EXCLUDED.actual_cost_czk,
pv_b_production_wh = EXCLUDED.pv_b_production_wh,
green_bonus_czk = EXCLUDED.green_bonus_czk,
deviation_grid_w = EXCLUDED.deviation_grid_w,
deviation_cost_czk = EXCLUDED.deviation_cost_czk;
END;
@@ -134,6 +175,7 @@ $$;
COMMENT ON FUNCTION ems.fn_fill_audit_interval(INT, TIMESTAMPTZ) IS
'Naplní nebo aktualizuje jeden řádek v audit_interval pro danou lokalitu a 15min interval.
Agreguje průměry z telemetrie (střídač, EV, TČ), porovná se skutečným plánem a spočítá odchylky.
Zelený bonus: součet přes všechna pole s nastaveným bonusem, výroba z poslední ok forecast_pv_interval.
Volat každých 15 minut pro interval který právě skončil.';
-- ============================================================

View File

@@ -0,0 +1,75 @@
CREATE OR REPLACE FUNCTION ems.fn_fill_forecast_accuracy(
p_site_id INT,
p_lookback_hours INT DEFAULT 48
)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_count INT := 0;
BEGIN
INSERT INTO ems.forecast_accuracy (
site_id, pv_array_id, interval_start, run_id,
forecast_power_w, forecast_created_at, lead_time_hours,
actual_power_w, actual_filled_at,
error_w, error_pct
)
SELECT
fpr.site_id,
fpr.pv_array_id,
fpi.interval_start,
fpi.run_id,
fpi.power_w AS forecast_power_w,
fpr.created_at AS forecast_created_at,
ROUND(
EXTRACT(EPOCH FROM (fpi.interval_start - fpr.created_at))
/ 3600.0, 2
) AS lead_time_hours,
slot.avg_actual_w::INT AS actual_power_w,
now() AS actual_filled_at,
fpi.power_w - COALESCE(slot.avg_actual_w::INT, 0) AS error_w,
CASE
WHEN slot.avg_actual_w IS NOT NULL
AND slot.avg_actual_w > 0
THEN ROUND(
(fpi.power_w::NUMERIC - slot.avg_actual_w::NUMERIC)
/ slot.avg_actual_w::NUMERIC * 100,
4
)
ELSE NULL
END AS error_pct
FROM ems.forecast_pv_interval fpi
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
JOIN ems.asset_pv_array pa ON pa.id = fpr.pv_array_id
LEFT JOIN LATERAL (
SELECT AVG(
CASE
WHEN pa.controllable = false THEN ti.gen_port_power_w::NUMERIC
ELSE (COALESCE(ti.pv1_power_w, 0) + COALESCE(ti.pv2_power_w, 0))::NUMERIC
END
) AS avg_actual_w
FROM ems.telemetry_inverter ti
WHERE ti.site_id = fpr.site_id
AND ti.measured_at >= fpi.interval_start
AND ti.measured_at < fpi.interval_start + INTERVAL '15 minutes'
) slot ON true
WHERE fpr.site_id = p_site_id
AND fpr.status = 'ok'
AND fpi.interval_start < now() - INTERVAL '15 minutes'
AND fpi.interval_start >= now() - make_interval(hours => p_lookback_hours)
ON CONFLICT (run_id, interval_start) DO UPDATE SET
actual_power_w = EXCLUDED.actual_power_w,
actual_filled_at = EXCLUDED.actual_filled_at,
error_w = EXCLUDED.error_w,
error_pct = EXCLUDED.error_pct;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
COMMENT ON FUNCTION ems.fn_fill_forecast_accuracy(INT, INT) IS
'Doplní skutečné hodnoty výroby do forecast_accuracy z telemetrie.
Volat každých 15 minut (spolu s audit_filler) pro inkrementální plnění.
p_lookback_hours: kolik hodin zpět zpracovat (default 48h pro catch-up).
Pro první backfill: SELECT ems.fn_fill_forecast_accuracy(2, 8760) -- 1 rok';

View File

@@ -0,0 +1,135 @@
-- =============================================================
-- R__fn_ote_import.sql
-- OTE CZ import parser a import funkce
-- Repeatable migration při změně funkce stačí upravit tento soubor
-- =============================================================
-- Parser: raw jsonb → 96 cenových řádků
CREATE OR REPLACE FUNCTION ems.fn_ote_parse_15m_price_json(
in_payload jsonb,
in_czk_per_eur numeric DEFAULT 25.000
)
RETURNS TABLE (
interval_start timestamptz,
interval_end timestamptz,
raw_price_czk_kwh numeric(10,6)
)
LANGUAGE plpgsql
AS $$
DECLARE
v_date_text text;
v_market_date date;
BEGIN
IF in_payload IS NULL THEN
RAISE EXCEPTION 'in_payload must not be null';
END IF;
IF in_czk_per_eur IS NULL OR in_czk_per_eur <= 0 THEN
RAISE EXCEPTION 'in_czk_per_eur must be > 0, got: %', in_czk_per_eur;
END IF;
-- Datum z graph.title ve formátu "... DD.MM.YYYY"
v_date_text := substring(
in_payload #>> '{graph,title}'
FROM '([0-9]{2}\.[0-9]{2}\.[0-9]{4})'
);
IF v_date_text IS NULL THEN
RAISE EXCEPTION 'cannot parse date from graph.title: %',
in_payload #>> '{graph,title}';
END IF;
v_market_date := to_date(v_date_text, 'DD.MM.YYYY');
RETURN QUERY
WITH price_line AS (
-- Správná série: 15min ceny (tooltip rozlišuje od Množství a 60min)
SELECT dl
FROM jsonb_array_elements(in_payload #> '{data,dataLine}') AS t(dl)
WHERE dl ->> 'tooltip' = 'flash_chart_01_y_15m_price_tooltip'
LIMIT 1
),
points AS (
SELECT
(p ->> 'x')::int AS block_no,
(p ->> 'y')::numeric AS price_eur_mwh
FROM price_line pl
CROSS JOIN LATERAL jsonb_array_elements(pl.dl -> 'point') AS p
)
SELECT
((v_market_date::timestamp
+ ((block_no - 1) * INTERVAL '15 minutes'))
AT TIME ZONE 'Europe/Prague') AS interval_start,
((v_market_date::timestamp
+ (block_no * INTERVAL '15 minutes'))
AT TIME ZONE 'Europe/Prague') AS interval_end,
ROUND(
(price_eur_mwh * in_czk_per_eur / 1000.0)::numeric, 6
)::numeric(10,6) AS raw_price_czk_kwh
FROM points
ORDER BY block_no;
IF NOT FOUND THEN
RAISE EXCEPTION
'dataLine tooltip=flash_chart_01_y_15m_price_tooltip not found; '
'dostupné tooltips: %',
(SELECT jsonb_agg(dl ->> 'tooltip')
FROM jsonb_array_elements(in_payload #> '{data,dataLine}') dl);
END IF;
END;
$$;
COMMENT ON FUNCTION ems.fn_ote_parse_15m_price_json(jsonb, numeric) IS
'Parsuje raw JSON z OTE @@chart-data?time_resolution=PT15M.
Datum extrahuje z graph.title (DD.MM.YYYY).
Série: flash_chart_01_y_15m_price_tooltip (EUR/MWh → Kč/kWh přes kurz).
Výstup: 96 řádků, interval_start/end jako timestamptz (UTC), cena Kč/kWh.
Testování přímo v DB:
COPY tmp_ote FROM ''/tmp/ote.json'';
SELECT * FROM ems.fn_ote_parse_15m_price_json(pg_read_file(''/tmp/ote.json'')::jsonb, 25.0) LIMIT 5;';
CREATE OR REPLACE FUNCTION ems.fn_ote_import_from_json(
in_payload jsonb,
in_czk_per_eur numeric DEFAULT 25.000
)
RETURNS integer
LANGUAGE plpgsql
AS $$
DECLARE
v_rowcount integer;
BEGIN
INSERT INTO ems.market_interval_price (
market_source,
interval_start,
interval_end,
buy_raw_price_czk_kwh,
sell_raw_price_czk_kwh,
currency,
imported_at
)
SELECT
'OTE_CZ',
p.interval_start,
p.interval_end,
p.raw_price_czk_kwh,
p.raw_price_czk_kwh, -- spot trh: buy = sell
'CZK',
now()
FROM ems.fn_ote_parse_15m_price_json(in_payload, in_czk_per_eur) AS p
ON CONFLICT (market_source, interval_start) DO UPDATE SET
interval_end = EXCLUDED.interval_end,
buy_raw_price_czk_kwh = EXCLUDED.buy_raw_price_czk_kwh,
sell_raw_price_czk_kwh = EXCLUDED.sell_raw_price_czk_kwh,
imported_at = EXCLUDED.imported_at;
GET DIAGNOSTICS v_rowcount = ROW_COUNT;
RETURN v_rowcount;
END;
$$;
COMMENT ON FUNCTION ems.fn_ote_import_from_json(jsonb, numeric) IS
'Uloží výstup fn_ote_parse_15m_price_json do ems.market_interval_price.
Python předá raw jsonb z HTTP response + kurz EUR/CZK.
Vrátí počet upsertnutých řádků (očekáváno 96).
Testování přímo v DB:
SELECT ems.fn_ote_import_from_json(
pg_read_file(''/tmp/ote.json'')::jsonb, 25.0
);';

View File

@@ -0,0 +1,227 @@
-- =============================================================
-- R__fn_predict_negative_prices.sql
-- Predikce oken se zvýšeným rizikem záporné spotové ceny (OTE).
-- Volat denně po importu cen a po forecastu FVE; výsledky ukládá do
-- ems.predicted_negative_price_window.
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_predict_negative_price_windows(
p_site_id INT,
p_days_ahead INT DEFAULT 7
)
RETURNS TABLE (
predicted_date DATE,
window_start_hour INT,
window_end_hour INT,
probability_pct INT,
expected_min_price NUMERIC(10, 4),
reason TEXT
)
LANGUAGE plpgsql
VOLATILE
AS $$
DECLARE
v_start DATE;
v_end DATE;
v_days INT;
BEGIN
v_days := COALESCE(p_days_ahead, 7);
IF v_days < 1 OR v_days > 60 THEN
RAISE EXCEPTION 'p_days_ahead must be between 1 and 60, got %', p_days_ahead;
END IF;
v_start := (CURRENT_TIMESTAMP AT TIME ZONE 'Europe/Prague')::DATE + 1;
v_end := (CURRENT_TIMESTAMP AT TIME ZONE 'Europe/Prague')::DATE + v_days;
IF NOT EXISTS (SELECT 1 FROM ems.site s WHERE s.id = p_site_id) THEN
RAISE EXCEPTION 'site not found: %', p_site_id;
END IF;
DELETE FROM ems.predicted_negative_price_window p
WHERE p.site_id = p_site_id
AND p.predicted_date BETWEEN v_start AND v_end;
RETURN QUERY
WITH hist_price AS (
SELECT
EXTRACT(DOW FROM mip.interval_start AT TIME ZONE 'Europe/Prague')::INT AS dow,
EXTRACT(HOUR FROM mip.interval_start AT TIME ZONE 'Europe/Prague')::INT AS hour,
COUNT(*)::INT AS total_slots,
COUNT(*) FILTER (WHERE mip.buy_raw_price_czk_kwh < 0)::INT AS neg_slots,
AVG(mip.buy_raw_price_czk_kwh) AS avg_price,
MIN(mip.buy_raw_price_czk_kwh) AS min_price
FROM ems.market_interval_price mip
WHERE mip.market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
AND mip.interval_start >= NOW() - INTERVAL '6 months'
GROUP BY 1, 2
HAVING COUNT(*) >= 4
),
latest_run AS (
SELECT fpr.id
FROM ems.forecast_pv_run fpr
WHERE fpr.site_id = p_site_id
AND fpr.status = 'ok'
ORDER BY fpr.created_at DESC NULLS LAST
LIMIT 1
),
slot_power_hist AS (
SELECT
fpi.interval_start,
SUM(fpi.power_w)::BIGINT AS total_w
FROM ems.forecast_pv_interval fpi
INNER JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
WHERE fpr.site_id = p_site_id
AND fpr.status = 'ok'
AND fpi.interval_start >= NOW() - INTERVAL '6 months'
GROUP BY fpi.interval_start
),
pv_max_by_hour AS (
SELECT
EXTRACT(HOUR FROM sp.interval_start AT TIME ZONE 'Europe/Prague')::INT AS hour,
MAX(sp.total_w)::BIGINT AS hist_max_w
FROM slot_power_hist sp
GROUP BY 1
),
pred_slot AS (
SELECT
fpi.interval_start,
SUM(fpi.power_w)::BIGINT AS total_w
FROM ems.forecast_pv_interval fpi
INNER JOIN latest_run lr ON lr.id = fpi.run_id
GROUP BY fpi.interval_start
),
pred_by_hour AS (
SELECT
(ps.interval_start AT TIME ZONE 'Europe/Prague')::DATE AS d,
EXTRACT(HOUR FROM ps.interval_start AT TIME ZONE 'Europe/Prague')::INT AS hour,
MAX(ps.total_w)::BIGINT AS pred_max_w
FROM pred_slot ps
GROUP BY 1, 2
),
future_days AS (
SELECT gs::DATE AS d
FROM generate_series(v_start, v_end, INTERVAL '1 day') AS gs
),
hourly_base AS (
SELECT
fd.d AS predicted_date,
h.hour,
EXTRACT(DOW FROM fd.d)::INT AS dow,
LEAST(
100,
GREATEST(
0,
(100.0 * hp.neg_slots / NULLIF(hp.total_slots, 0))::INT
)
) AS base_prob,
hp.min_price
FROM future_days fd
CROSS JOIN generate_series(0, 23) AS h (hour)
INNER JOIN hist_price hp
ON hp.dow = EXTRACT(DOW FROM fd.d)::INT
AND hp.hour = h.hour
),
hourly_adj AS (
SELECT
hb.predicted_date,
hb.hour,
hb.dow,
hb.base_prob,
hb.min_price,
CASE
WHEN pm.hist_max_w IS NULL OR pm.hist_max_w <= 0 THEN hb.base_prob
WHEN ph.pred_max_w IS NULL THEN hb.base_prob
WHEN ph.pred_max_w::NUMERIC > 0.8 * pm.hist_max_w::NUMERIC THEN
LEAST(100, hb.base_prob + 15)
WHEN ph.pred_max_w::NUMERIC < 0.4 * pm.hist_max_w::NUMERIC THEN
GREATEST(0, hb.base_prob - 20)
ELSE hb.base_prob
END AS probability_pct
FROM hourly_base hb
LEFT JOIN pred_by_hour ph
ON ph.d = hb.predicted_date
AND ph.hour = hb.hour
LEFT JOIN pv_max_by_hour pm ON pm.hour = hb.hour
),
qualified AS (
SELECT
ha.predicted_date,
ha.hour,
ha.probability_pct,
ha.min_price AS expected_hour_min,
ha.hour
- ROW_NUMBER() OVER (
PARTITION BY ha.predicted_date, ha.probability_pct
ORDER BY ha.hour
) AS grp
FROM hourly_adj ha
WHERE ha.probability_pct >= 30
),
windows AS (
SELECT
q.predicted_date,
q.probability_pct,
MIN(q.hour) AS window_start_hour,
MAX(q.hour) AS window_end_hour,
MIN(q.expected_hour_min) AS expected_min_price
FROM qualified q
GROUP BY q.predicted_date, q.probability_pct, q.grp
),
final_rows AS (
SELECT
w.predicted_date,
w.window_start_hour,
w.window_end_hour,
w.probability_pct,
ROUND(w.expected_min_price::NUMERIC, 4) AS expected_min_price,
CASE
WHEN w.probability_pct >= 70 THEN
'Historicky vysoká FVE výroba v tento čas velmi pravděpodobné'
WHEN w.probability_pct >= 50 THEN
'Víkendový vzor + dobrá předpověď FVE'
ELSE
'Možné na základě historických dat'
END AS reason
FROM windows w
WHERE w.probability_pct >= 25
),
ins AS (
INSERT INTO ems.predicted_negative_price_window (
site_id,
predicted_date,
window_start_hour,
window_end_hour,
probability_pct,
expected_min_price,
reason
)
SELECT
p_site_id,
fr.predicted_date,
fr.window_start_hour,
fr.window_end_hour,
fr.probability_pct,
fr.expected_min_price,
fr.reason
FROM final_rows fr
RETURNING
predicted_negative_price_window.predicted_date,
predicted_negative_price_window.window_start_hour,
predicted_negative_price_window.window_end_hour,
predicted_negative_price_window.probability_pct,
predicted_negative_price_window.expected_min_price,
predicted_negative_price_window.reason
)
SELECT
ins.predicted_date,
ins.window_start_hour,
ins.window_end_hour,
ins.probability_pct,
ins.expected_min_price,
ins.reason
FROM ins;
END;
$$;
COMMENT ON FUNCTION ems.fn_predict_negative_price_windows(INT, INT) IS
'Predikuje okna se zvýšeným rizikem záporné nákupní ceny z historie OTE (6 měsíců), upraví podle forecastu FVE a zapíše do ems.predicted_negative_price_window. Volat denně po importu cen a forecastu.';

View File

@@ -21,12 +21,15 @@ SELECT
ROUND(SUM(actual_cost_czk), 2) AS actual_cost_czk,
ROUND(SUM(deviation_cost_czk), 2) AS total_deviation_czk,
-- Počet intervalů s velkými odchylkami (>1kW)
COUNT(*) FILTER (WHERE ABS(deviation_grid_w) > 1000) AS high_deviation_count
COUNT(*) FILTER (WHERE ABS(deviation_grid_w) > 1000) AS high_deviation_count,
ROUND(SUM(green_bonus_czk), 4) AS green_bonus_czk,
ROUND(COALESCE(SUM(actual_cost_czk), 0) * -1 + COALESCE(SUM(green_bonus_czk), 0), 2) AS total_revenue_czk
FROM ems.audit_interval
GROUP BY site_id, date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague');
COMMENT ON VIEW ems.vw_audit_daily IS
'Denní souhrn auditu per lokalita. Energie v kWh, náklady v Kč.
total_revenue_czk = -actual_cost_czk + green_bonus_czk (export/síť vs. bonus).
Používat pro dashboard denního přehledu a reporty.';
-- ============================================================

View File

@@ -0,0 +1,78 @@
CREATE OR REPLACE VIEW ems.vw_forecast_accuracy_by_lead_time AS
SELECT *
FROM (
SELECT
site_id,
pv_array_id,
CASE
WHEN lead_time_hours <= 6 THEN '0-6h'
WHEN lead_time_hours <= 12 THEN '6-12h'
WHEN lead_time_hours <= 24 THEN '12-24h'
WHEN lead_time_hours <= 48 THEN '24-48h'
ELSE '48h+'
END AS lead_time_bucket,
COUNT(*) AS slot_count,
COUNT(*) FILTER (WHERE actual_power_w > 100) AS daylight_slots,
ROUND(AVG(error_pct)
FILTER (WHERE actual_power_w > 100), 2) AS avg_error_pct,
ROUND(AVG(ABS(error_pct))
FILTER (WHERE actual_power_w > 100), 2) AS avg_abs_error_pct,
ROUND(STDDEV(error_pct)
FILTER (WHERE actual_power_w > 100), 2) AS stddev_error_pct,
CASE
WHEN AVG(error_pct) FILTER (WHERE actual_power_w > 100) > 10
THEN 'nadhodnocuje'
WHEN AVG(error_pct) FILTER (WHERE actual_power_w > 100) < -10
THEN 'podhodnocuje'
ELSE 'ok'
END AS bias
FROM ems.forecast_accuracy
WHERE actual_power_w IS NOT NULL
GROUP BY
site_id,
pv_array_id,
CASE
WHEN lead_time_hours <= 6 THEN '0-6h'
WHEN lead_time_hours <= 12 THEN '6-12h'
WHEN lead_time_hours <= 24 THEN '12-24h'
WHEN lead_time_hours <= 48 THEN '24-48h'
ELSE '48h+'
END
) bucketed
ORDER BY
site_id,
pv_array_id,
CASE lead_time_bucket
WHEN '0-6h' THEN 1
WHEN '6-12h' THEN 2
WHEN '12-24h' THEN 3
WHEN '24-48h' THEN 4
WHEN '48h+' THEN 5
END;
COMMENT ON VIEW ems.vw_forecast_accuracy_by_lead_time IS
'Přesnost FVE forecastu dle lead time (jak daleko předem byl forecast vytvořen).
Ignoruje noční sloty (actual < 100W). avg_error_pct > 0 = forecast nadhodnocuje.
Po 4+ týdnech dat lze použít pro kalibraci safety_factor v solveru.';
CREATE OR REPLACE VIEW ems.vw_forecast_accuracy_daily AS
SELECT
site_id,
pv_array_id,
DATE(interval_start AT TIME ZONE 'Europe/Prague') AS day,
COUNT(*) FILTER (WHERE actual_power_w IS NOT NULL
AND actual_power_w > 100) AS daylight_slots,
ROUND(SUM(forecast_power_w)::NUMERIC / 4000, 2) AS forecast_kwh,
ROUND(SUM(actual_power_w)::NUMERIC / 4000, 2) AS actual_kwh,
ROUND((SUM(forecast_power_w) - SUM(COALESCE(actual_power_w,0)))
::NUMERIC / NULLIF(SUM(actual_power_w),0) * 100, 2) AS day_error_pct
FROM ems.forecast_accuracy
GROUP BY
site_id,
pv_array_id,
DATE(interval_start AT TIME ZONE 'Europe/Prague')
ORDER BY day DESC;
COMMENT ON VIEW ems.vw_forecast_accuracy_daily IS
'Denní souhrn přesnosti FVE forecastu v kWh. forecast_kwh vs actual_kwh.
day_error_pct > 0 = forecast nadhodnotil denní výrobu.';

View File

@@ -18,7 +18,13 @@ SELECT DISTINCT ON (t.inverter_id)
t.inverter_temp_c,
t.operating_mode,
t.fault_code,
now() - t.measured_at AS data_age
now() - t.measured_at AS data_age,
t.pv1_power_w,
t.pv2_power_w,
t.gen_port_power_w,
t.batt_charge_today_wh,
t.batt_discharge_today_wh,
t.run_state
FROM ems.telemetry_inverter t
JOIN ems.asset_inverter inv ON inv.id = t.inverter_id
ORDER BY t.inverter_id, t.measured_at DESC;

View File

@@ -5,38 +5,80 @@
-- =============================================================
CREATE OR REPLACE VIEW ems.vw_site_effective_price AS
WITH cfg_price AS (
SELECT
smc.site_id,
smc.tariff_id,
smc.hdo_code_id,
smc.system_services_czk_kwh,
smc.buy_margin_fixed_czk,
smc.buy_margin_percent,
smc.sell_margin_fixed_czk,
smc.sell_margin_percent,
mip.interval_start,
mip.interval_end,
mip.market_source,
mip.buy_raw_price_czk_kwh,
mip.sell_raw_price_czk_kwh,
(mip.interval_start AT TIME ZONE 'Europe/Prague')::time AS local_prague_time,
EXTRACT(DOW FROM mip.interval_start AT TIME ZONE 'Europe/Prague')::integer AS prague_dow
FROM ems.market_interval_price mip
CROSS JOIN ems.site_market_config smc
WHERE smc.valid_from <= mip.interval_start
AND (smc.valid_to IS NULL OR smc.valid_to > mip.interval_start)
),
rated AS (
SELECT
cp.*,
CASE
WHEN cp.hdo_code_id IS NOT NULL AND EXISTS (
SELECT 1
FROM ems.hdo_code_window w
WHERE w.hdo_code_id = cp.hdo_code_id
AND (
w.day_type = 'all'
OR (w.day_type = 'workday' AND cp.prague_dow BETWEEN 1 AND 5)
OR (w.day_type = 'weekend' AND cp.prague_dow IN (0, 6))
)
AND w.rate_type = 'VT'
AND cp.local_prague_time >= w.window_from
AND cp.local_prague_time < w.window_to
) THEN 'VT'::text
ELSE 'NT'::text
END AS rate_type
FROM cfg_price cp
)
SELECT
smc.site_id,
mip.interval_start,
mip.interval_end,
mip.market_source,
-- Raw ceny
mip.buy_raw_price_czk_kwh,
mip.sell_raw_price_czk_kwh,
-- Marže
smc.buy_margin_fixed_czk,
smc.buy_margin_percent,
smc.sell_margin_fixed_czk,
smc.sell_margin_percent,
-- Efektivní ceny
ROUND(
mip.buy_raw_price_czk_kwh
+ smc.buy_margin_fixed_czk
+ (mip.buy_raw_price_czk_kwh * smc.buy_margin_percent / 100.0),
6
) AS effective_buy_price_czk_kwh,
ROUND(
mip.sell_raw_price_czk_kwh
+ smc.sell_margin_fixed_czk
+ (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100.0),
6
) AS effective_sell_price_czk_kwh
FROM ems.market_interval_price mip
CROSS JOIN ems.site_market_config smc
WHERE smc.valid_from <= mip.interval_start
AND (smc.valid_to IS NULL OR smc.valid_to > mip.interval_start);
r.site_id,
r.interval_start,
r.interval_end,
r.market_source,
r.buy_raw_price_czk_kwh,
r.sell_raw_price_czk_kwh,
r.buy_margin_fixed_czk,
r.buy_margin_percent,
r.sell_margin_fixed_czk,
r.sell_margin_percent,
ems.fn_effective_buy_price(r.site_id, r.interval_start) AS effective_buy_price_czk_kwh,
ems.fn_effective_sell_price(r.site_id, r.interval_start) AS effective_sell_price_czk_kwh,
r.rate_type,
COALESCE(
(
SELECT dtr.price_czk_kwh
FROM ems.distribution_tariff_rate dtr
WHERE dtr.tariff_id = r.tariff_id
AND dtr.rate_type = r.rate_type
AND dtr.valid_from <= r.interval_start::date
AND (dtr.valid_to IS NULL OR dtr.valid_to > r.interval_start::date)
ORDER BY dtr.valid_from DESC
LIMIT 1
),
0::numeric
) AS dist_rate_czk_kwh,
COALESCE(r.system_services_czk_kwh, 0::numeric) AS system_services_czk_kwh
FROM rated r;
COMMENT ON VIEW ems.vw_site_effective_price IS
'Efektivní nákupní a prodejní ceny elektřiny per lokalita a 15min interval.
Dopočítává marže z site_market_config na raw ceny z market_interval_price.
Nezahrnuje data bez platné market_config. Používat pro plánování a audit.';
rate_type NT/VT dle HDO oken; dist_rate = variabilní distribuce bez DPH.
effective_* z fn_effective_buy_price / fn_effective_sell_price (marže, DPH u nákupu dle tarifu).';

View File

@@ -11,3 +11,10 @@ GRANT SELECT ON ems.vw_mode_log_recent TO ems_anon;
GRANT SELECT ON ems.vw_operating_mode TO ems_anon;
GRANT SELECT ON ems.telemetry_inverter_hourly TO ems_anon;
GRANT SELECT ON ems.vw_telemetry_hourly_7d TO ems_anon;
GRANT SELECT ON ems.telemetry_heat_pump TO ems_anon;
GRANT SELECT ON ems.forecast_accuracy TO ems_anon;
GRANT SELECT ON ems.vw_forecast_accuracy_by_lead_time TO ems_anon;
GRANT SELECT ON ems.vw_forecast_accuracy_daily TO ems_anon;
GRANT SELECT ON ems.consumption_baseline_stats TO ems_anon;
GRANT SELECT ON ems.market_price_stats TO ems_anon;
GRANT SELECT ON ems.tuv_usage_stats TO ems_anon;