191 lines
5.8 KiB
PL/PgSQL
191 lines
5.8 KiB
PL/PgSQL
-- Jednorázový JSON snapshot pro vysvětlení plánu (typicky nejbližších N hodin).
|
||
-- Volání: SELECT ems.fn_plan_explain_bundle(<site_id>, 6);
|
||
|
||
CREATE OR REPLACE FUNCTION ems.fn_plan_explain_bundle(
|
||
p_site_id INT,
|
||
p_hours NUMERIC DEFAULT 6
|
||
)
|
||
RETURNS JSONB
|
||
LANGUAGE plpgsql
|
||
STABLE
|
||
AS $$
|
||
DECLARE
|
||
v_hours NUMERIC;
|
||
v_slot TIMESTAMPTZ;
|
||
v_win_end TIMESTAMPTZ;
|
||
v_site_ok BOOLEAN;
|
||
v_run RECORD;
|
||
v_meta JSONB;
|
||
v_ivals JSONB;
|
||
v_mode JSONB;
|
||
v_batt JSONB;
|
||
v_grid JSONB;
|
||
v_hp JSONB;
|
||
v_ev JSONB;
|
||
v_fc JSONB;
|
||
v_ov JSONB;
|
||
BEGIN
|
||
IF p_site_id IS NULL THEN
|
||
RETURN jsonb_build_object('error', 'site_id_required');
|
||
END IF;
|
||
|
||
v_hours := GREATEST(0.25::NUMERIC, LEAST(COALESCE(NULLIF(p_hours, 0), 6), 96));
|
||
|
||
SELECT EXISTS(SELECT 1 FROM ems.site s WHERE s.id = p_site_id) INTO v_site_ok;
|
||
IF NOT v_site_ok THEN
|
||
RETURN jsonb_build_object(
|
||
'error', 'unknown_site_id',
|
||
'site_id', p_site_id
|
||
);
|
||
END IF;
|
||
|
||
v_slot := to_timestamp(floor(extract(epoch FROM now()) / 900) * 900)::TIMESTAMPTZ;
|
||
v_win_end := v_slot + (v_hours * INTERVAL '1 hour');
|
||
|
||
v_meta := jsonb_build_object(
|
||
'generated_at', now(),
|
||
'current_slot_start_utc', v_slot,
|
||
'window_end_utc', v_win_end,
|
||
'hours_requested', v_hours,
|
||
'slot_minutes', 15,
|
||
'note',
|
||
'interval_start je vždy UTC. Provozní čas viz ems.site.timezone (typicky Europe/Prague).'
|
||
);
|
||
|
||
SELECT * INTO v_run
|
||
FROM ems.planning_run pr
|
||
WHERE pr.site_id = p_site_id
|
||
AND pr.status = 'active'
|
||
ORDER BY pr.created_at DESC
|
||
LIMIT 1;
|
||
|
||
IF NOT FOUND THEN
|
||
RETURN jsonb_build_object(
|
||
'error', 'no_active_plan',
|
||
'site_id', p_site_id,
|
||
'meta', v_meta
|
||
);
|
||
END IF;
|
||
|
||
SELECT COALESCE(
|
||
jsonb_agg(
|
||
to_jsonb(t) || jsonb_build_object(
|
||
'hours_from_plan_horizon_start',
|
||
round(
|
||
(EXTRACT(EPOCH FROM (t.interval_start - v_run.horizon_start)) / 3600.0)::NUMERIC,
|
||
4
|
||
)
|
||
)
|
||
ORDER BY t.interval_start
|
||
),
|
||
'[]'::JSONB
|
||
)
|
||
INTO v_ivals
|
||
FROM (
|
||
SELECT pi.*
|
||
FROM ems.planning_interval pi
|
||
WHERE pi.run_id = v_run.id
|
||
AND pi.interval_start >= v_slot
|
||
AND pi.interval_start < v_win_end
|
||
) t;
|
||
|
||
SELECT to_jsonb(m.*) || jsonb_build_object('mode_name', d.name)
|
||
INTO v_mode
|
||
FROM ems.site_operating_mode m
|
||
LEFT JOIN ems.operating_mode_def d ON d.code = m.mode_code
|
||
WHERE m.site_id = p_site_id;
|
||
|
||
SELECT COALESCE(jsonb_agg(to_jsonb(b) ORDER BY b.id), '[]'::JSONB)
|
||
INTO v_batt
|
||
FROM ems.asset_battery b
|
||
WHERE b.site_id = p_site_id;
|
||
|
||
SELECT to_jsonb(g.*)
|
||
INTO v_grid
|
||
FROM ems.site_grid_connection g
|
||
WHERE g.site_id = p_site_id;
|
||
|
||
SELECT COALESCE(jsonb_agg(to_jsonb(h) ORDER BY h.id), '[]'::JSONB)
|
||
INTO v_hp
|
||
FROM ems.asset_heat_pump h
|
||
WHERE h.site_id = p_site_id;
|
||
|
||
SELECT COALESCE(jsonb_agg(to_jsonb(e) ORDER BY e.id), '[]'::JSONB)
|
||
INTO v_ev
|
||
FROM ems.ev_session e
|
||
WHERE e.site_id = p_site_id
|
||
AND e.session_end IS NULL;
|
||
|
||
SELECT COALESCE(
|
||
jsonb_agg(to_jsonb(f) ORDER BY f.logged_at DESC),
|
||
'[]'::JSONB
|
||
)
|
||
INTO v_fc
|
||
FROM (
|
||
SELECT l.*
|
||
FROM ems.forecast_correction_log l
|
||
WHERE l.site_id = p_site_id
|
||
ORDER BY l.logged_at DESC
|
||
LIMIT 5
|
||
) f;
|
||
|
||
SELECT COALESCE(
|
||
jsonb_agg(to_jsonb(o) ORDER BY o.valid_from DESC),
|
||
'[]'::JSONB
|
||
)
|
||
INTO v_ov
|
||
FROM (
|
||
SELECT o.*
|
||
FROM ems.site_override o
|
||
WHERE o.site_id = p_site_id
|
||
AND o.valid_from < v_win_end
|
||
AND (o.valid_to IS NULL OR o.valid_to > v_slot)
|
||
ORDER BY o.valid_from DESC
|
||
LIMIT 20
|
||
) o;
|
||
|
||
RETURN jsonb_build_object(
|
||
'meta', v_meta,
|
||
'site', (
|
||
SELECT jsonb_build_object(
|
||
'id', s.id,
|
||
'code', s.code,
|
||
'name', s.name,
|
||
'timezone', s.timezone,
|
||
'active', s.active
|
||
)
|
||
FROM ems.site s
|
||
WHERE s.id = p_site_id
|
||
),
|
||
'active_planning_run', (
|
||
SELECT to_jsonb(pr.*)
|
||
FROM ems.planning_run pr
|
||
WHERE pr.id = v_run.id
|
||
),
|
||
'intervals_next_window', v_ivals,
|
||
'operating_mode', v_mode,
|
||
'asset_battery', v_batt,
|
||
'site_grid_connection', v_grid,
|
||
'asset_heat_pump', v_hp,
|
||
'ev_sessions_open', v_ev,
|
||
'forecast_correction_log_recent', v_fc,
|
||
'site_overrides_active_in_window', v_ov,
|
||
'ai_readme', jsonb_build_object(
|
||
'purpose',
|
||
'Data stačí k vysvětlení „proč plán v dalších hodinách vypadá takto“: ceny v řádcích intervalů, vstupy (baseline, PV), výstupy (bat/grid/EV/TČ), režim a síťové limity.',
|
||
'code_refs',
|
||
ARRAY[
|
||
'backend/services/planning_engine.py (solve_dispatch, run_type rolling/daily)',
|
||
'backend/services/control_exporter.py (mapování na Deye)',
|
||
'CLAUDE.md bod 15–19 (baseline, horizont, režimy, MILP export)'
|
||
],
|
||
'slot_weights_hours_from_horizon_start',
|
||
'0–36: váha 1.0; 36–72: 0.7; 72–96: 0.4 (účelovka LP; sloupec hours_from_plan_horizon_start u každého intervalu).'
|
||
)
|
||
);
|
||
END;
|
||
$$;
|
||
|
||
COMMENT ON FUNCTION ems.fn_plan_explain_bundle(INT, NUMERIC) IS
|
||
'JSONB balík: aktivní planning_run, planning_interval v okně od aktuálního 15min slotu, režim, baterie/síť/TČ, otevřené EV session, poslední forecast_correction_log, platné site_override. Pro AI / diagnostiku bez opakovaného skládání dotazů.';
|