204 lines
9.1 KiB
PL/PgSQL
204 lines
9.1 KiB
PL/PgSQL
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,
|
|
learning_eligible, learning_exclude_reason
|
|
)
|
|
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,
|
|
CASE
|
|
WHEN v.exclude_actual_for_learning THEN NULL
|
|
ELSE slot.avg_actual_w::INT
|
|
END AS actual_power_w,
|
|
now() AS actual_filled_at,
|
|
CASE
|
|
WHEN v.exclude_actual_for_learning THEN NULL
|
|
ELSE fpi.power_w - COALESCE(slot.avg_actual_w::INT, 0)
|
|
END AS error_w,
|
|
CASE
|
|
WHEN v.exclude_actual_for_learning THEN NULL
|
|
WHEN slot.avg_actual_w IS NOT NULL
|
|
AND slot.avg_actual_w >= 50
|
|
AND fpi.power_w >= 50
|
|
THEN ROUND(
|
|
(fpi.power_w::NUMERIC - slot.avg_actual_w::NUMERIC)
|
|
/ slot.avg_actual_w::NUMERIC * 100,
|
|
4
|
|
)
|
|
ELSE NULL
|
|
END AS error_pct,
|
|
v.learning_eligible,
|
|
v.learning_exclude_reason
|
|
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
|
|
AND pa.site_id = fpr.site_id
|
|
LEFT JOIN ems.site_pv_forecast_calibration cal
|
|
ON cal.site_id = fpr.site_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
coalesce(cal.delta_learn_min_ts, timestamptz '2026-04-11T22:00:00Z') AS delta_learn_min_ts,
|
|
cal.pv_curtailment_policy_effective_from AS policy_from
|
|
) cal_eff ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT pi.pv_a_curtailed_w, pi.deye_gen_cutoff_enabled
|
|
FROM ems.planning_interval pi
|
|
JOIN ems.planning_run pr ON pr.id = pi.run_id
|
|
WHERE pr.site_id = fpr.site_id
|
|
AND pr.status = 'active'
|
|
AND pi.interval_start = fpi.interval_start
|
|
LIMIT 1
|
|
) ap ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
(fpi.interval_start < cal_eff.delta_learn_min_ts) AS before_learn_cutoff,
|
|
(
|
|
cal_eff.policy_from IS NOT NULL
|
|
AND fpi.interval_start >= cal_eff.policy_from
|
|
AND (
|
|
coalesce(ap.pv_a_curtailed_w, 0) > 50
|
|
OR coalesce(ap.deye_gen_cutoff_enabled, false) IS TRUE
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM ems.cutoff_switch_log l
|
|
WHERE l.site_id = fpr.site_id
|
|
AND l.switched_at >= fpi.interval_start
|
|
AND l.switched_at < fpi.interval_start + INTERVAL '15 minutes'
|
|
AND l.new_state IS FALSE
|
|
)
|
|
)
|
|
) AS is_curtailed_learning_slot,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM ems.telemetry_inverter ti_d
|
|
WHERE ti_d.site_id = fpr.site_id
|
|
AND ti_d.measured_at >= fpi.interval_start
|
|
AND ti_d.measured_at < fpi.interval_start + INTERVAL '15 minutes'
|
|
AND (
|
|
coalesce(ti_d.is_export_limited, false) IS TRUE
|
|
OR (ti_d.pv_derating_flags IS NOT NULL AND ti_d.pv_derating_flags <> 0)
|
|
)
|
|
) AS is_telemetry_derated_slot
|
|
) flags ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
CASE
|
|
WHEN flags.before_learn_cutoff THEN false
|
|
WHEN flags.is_curtailed_learning_slot THEN false
|
|
WHEN flags.is_telemetry_derated_slot THEN false
|
|
ELSE true
|
|
END AS learning_eligible,
|
|
CASE
|
|
WHEN flags.before_learn_cutoff THEN 'before_delta_learn_min'
|
|
WHEN flags.is_telemetry_derated_slot THEN 'telemetry_derating'
|
|
WHEN flags.is_curtailed_learning_slot THEN 'curtailment_or_export_cutoff'
|
|
ELSE NULL
|
|
END AS learning_exclude_reason,
|
|
(flags.is_curtailed_learning_slot OR flags.is_telemetry_derated_slot) AS exclude_actual_for_learning
|
|
) v ON true
|
|
LEFT JOIN LATERAL (
|
|
WITH base AS (
|
|
SELECT AVG(
|
|
CASE coalesce(pa.telemetry_source, '')
|
|
WHEN 'pv1' THEN ti.pv1_power_w::NUMERIC
|
|
WHEN 'pv2' THEN ti.pv2_power_w::NUMERIC
|
|
WHEN 'pv_strings' THEN (COALESCE(ti.pv1_power_w, 0) + COALESCE(ti.pv2_power_w, 0))::NUMERIC
|
|
WHEN 'pv_total' THEN ti.pv_power_w::NUMERIC
|
|
WHEN 'gen_port' THEN ti.gen_port_power_w::NUMERIC
|
|
ELSE NULL
|
|
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'
|
|
),
|
|
grp AS (
|
|
-- Pokud více pv_array sdílí stejné měření (např. GEN port rozdělený do více orientací),
|
|
-- rozdělíme actual proporčně podle forecastu v daném slotu.
|
|
SELECT
|
|
sum(fpi2.power_w)::numeric AS forecast_group_w
|
|
FROM ems.forecast_pv_interval fpi2
|
|
JOIN ems.forecast_pv_run fpr2 ON fpr2.id = fpi2.run_id
|
|
JOIN ems.asset_pv_array pa2
|
|
ON pa2.id = fpi2.pv_array_id
|
|
AND pa2.site_id = fpr2.site_id
|
|
WHERE pa.telemetry_group IS NOT NULL
|
|
AND pa2.site_id = fpr.site_id
|
|
AND pa2.telemetry_group = pa.telemetry_group
|
|
AND pa2.telemetry_source = pa.telemetry_source
|
|
AND fpi2.interval_start = fpi.interval_start
|
|
AND fpr2.status = 'ok'
|
|
-- Forecast runs jsou per pv_array_id, ale typicky vznikají v jednom batchi.
|
|
-- Aby group součet seděl, párujeme runy podle "stejného okamžiku vytvoření" (1min bucket).
|
|
AND date_bin(
|
|
interval '1 minute',
|
|
fpr2.created_at,
|
|
timestamptz '1970-01-01T00:00:00Z'
|
|
)
|
|
= date_bin(
|
|
interval '1 minute',
|
|
fpr.created_at,
|
|
timestamptz '1970-01-01T00:00:00Z'
|
|
)
|
|
)
|
|
SELECT
|
|
CASE
|
|
WHEN pa.telemetry_group IS NULL THEN (SELECT avg_actual_w FROM base)
|
|
WHEN (SELECT forecast_group_w FROM grp) IS NULL THEN NULL
|
|
WHEN (SELECT forecast_group_w FROM grp) <= 0 THEN NULL
|
|
WHEN (SELECT avg_actual_w FROM base) IS NULL THEN NULL
|
|
ELSE (SELECT avg_actual_w FROM base) * (fpi.power_w::numeric / (SELECT forecast_group_w FROM grp))
|
|
END AS avg_actual_w
|
|
) 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,
|
|
learning_eligible = EXCLUDED.learning_eligible,
|
|
learning_exclude_reason = EXCLUDED.learning_exclude_reason;
|
|
|
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
|
|
perform ems.fn_refresh_site_pv_delta_profile_cache(p_site_id);
|
|
|
|
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.
|
|
learning_eligible / learning_exclude_reason: před delta_learn_min_ts (kalibrace site) se nepočítá do učení delty;
|
|
po pv_curtailment_policy_effective_from sloty s curtailment / gen cutoff / cutoff_switch_log (export off) mají NULL actual a jsou vyloučeny z učení;
|
|
telemetrie: is_export_limited nebo pv_derating_flags <> 0 v okně slotu → stejné vyloučení (telemetry_derating).
|
|
Po úspěšném INSERT volá fn_refresh_site_pv_delta_profile_cache (V079 cache pro /plan/current).
|
|
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';
|