diff --git a/db/routines/R__fn_plan_explain_bundle.sql b/db/routines/R__fn_plan_explain_bundle.sql new file mode 100644 index 0000000..8192a8b --- /dev/null +++ b/db/routines/R__fn_plan_explain_bundle.sql @@ -0,0 +1,190 @@ +-- 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)' + ], + '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ů.';