-- Jednorázový JSON snapshot pro vysvětlení plánu (typicky nejbližších N hodin). -- Volání: SELECT ems.fn_plan_explain_bundle(, 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)' ], 'horizon_and_objective', 'Dynamický horizont: ems.fn_planning_horizon_end (OTE + strop/min v SQL). Účelovka LP bez vzdálenostních vah; terminal SoC shadow price (avg buy × 0,9 / 1000 × soc_end). Sloupec hours_from_plan_horizon_start u intervalů jen pro čtení.' ) ); 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ů.';