second version
This commit is contained in:
@@ -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.'
|
||||
|
||||
@@ -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' ...
|
||||
|
||||
29
db/migration/V012__telemetry_inverter_columns.sql
Normal file
29
db/migration/V012__telemetry_inverter_columns.sql
Normal 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.';
|
||||
20
db/migration/V013__predicted_negative_price_window.sql
Normal file
20
db/migration/V013__predicted_negative_price_window.sql
Normal 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);
|
||||
78
db/migration/V014__asset_model_refinement.sql
Normal file
78
db/migration/V014__asset_model_refinement.sql
Normal 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';
|
||||
100
db/migration/V015__distribution_tariff.sql
Normal file
100
db/migration/V015__distribution_tariff.sql
Normal 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.';
|
||||
55
db/migration/V016__seed_distribution_home01.sql
Normal file
55
db/migration/V016__seed_distribution_home01.sql
Normal 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');
|
||||
47
db/migration/V017__green_bonus.sql
Normal file
47
db/migration/V017__green_bonus.sql
Normal 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';
|
||||
13
db/migration/V018__cleanup_legacy_green_bonus.sql
Normal file
13
db/migration/V018__cleanup_legacy_green_bonus.sql
Normal 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.';
|
||||
43
db/migration/V019__forecast_accuracy.sql
Normal file
43
db/migration/V019__forecast_accuracy.sql
Normal 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);
|
||||
30
db/migration/V020__ev_arrival_stats.sql
Normal file
30
db/migration/V020__ev_arrival_stats.sql
Normal 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;
|
||||
27
db/migration/V021__baseline_consumption.sql
Normal file
27
db/migration/V021__baseline_consumption.sql
Normal 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.';
|
||||
38
db/migration/V022__extended_planning.sql
Normal file
38
db/migration/V022__extended_planning.sql
Normal 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.';
|
||||
54
db/migration/V023__modbus_command_journal.sql
Normal file
54
db/migration/V023__modbus_command_journal.sql
Normal 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.';
|
||||
8
db/migration/V024__planning_predicted_price.sql
Normal file
8
db/migration/V024__planning_predicted_price.sql
Normal 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.';
|
||||
8
db/migration/V025__deye_physical_mode.sql
Normal file
8
db/migration/V025__deye_physical_mode.sql
Normal 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ů.';
|
||||
11
db/migration/V026__battery_economics_tuning.sql
Normal file
11
db/migration/V026__battery_economics_tuning.sql
Normal 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;
|
||||
118
db/routines/R__fn_baseline_consumption.sql
Normal file
118
db/routines/R__fn_baseline_consumption.sql
Normal 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().';
|
||||
@@ -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);';
|
||||
|
||||
65
db/routines/R__fn_ev_arrival_stats.sql
Normal file
65
db/routines/R__fn_ev_arrival_stats.sql
Normal 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.';
|
||||
153
db/routines/R__fn_extended_planning.sql
Normal file
153
db/routines/R__fn_extended_planning.sql
Normal 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).';
|
||||
@@ -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.';
|
||||
|
||||
-- ============================================================
|
||||
|
||||
75
db/routines/R__fn_fill_forecast_accuracy.sql
Normal file
75
db/routines/R__fn_fill_forecast_accuracy.sql
Normal 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';
|
||||
135
db/routines/R__fn_ote_import.sql
Normal file
135
db/routines/R__fn_ote_import.sql
Normal 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
|
||||
);';
|
||||
227
db/routines/R__fn_predict_negative_prices.sql
Normal file
227
db/routines/R__fn_predict_negative_prices.sql
Normal 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.';
|
||||
@@ -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.';
|
||||
|
||||
-- ============================================================
|
||||
|
||||
78
db/views/R__vw_forecast_accuracy.sql
Normal file
78
db/views/R__vw_forecast_accuracy.sql
Normal 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.';
|
||||
@@ -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;
|
||||
|
||||
@@ -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).';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user