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

@@ -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.';