second version
This commit is contained in:
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.';
|
||||
Reference in New Issue
Block a user