Files
ems/db/routines/R__042_fn_predict_negative_prices.sql
2026-04-19 20:15:46 +02:00

228 lines
7.7 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- =============================================================
-- R__042_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.';