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