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( date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z'), date_bin(interval '15 minutes', p_to, timestamptz '1970-01-01T00:00:00Z') - 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().';