119 lines
4.3 KiB
PL/PgSQL
119 lines
4.3 KiB
PL/PgSQL
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().';
|