implmentace plan guardu
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-19 23:10:25 +02:00
parent d8221e3169
commit e3776226a4
9 changed files with 369 additions and 7 deletions

View File

@@ -0,0 +1,18 @@
-- Jednorázové potvrzení odeslání fatálního Discord alertu plán vs. skutečnost (deduplikace po slotu).
create table ems.plan_fatal_deviation_sent (
site_id int not null references ems.site (id),
interval_start timestamptz not null,
reason_code text not null,
sent_at timestamptz not null default now(),
primary key (site_id, interval_start)
);
create index idx_plan_fatal_deviation_sent_sent_at
on ems.plan_fatal_deviation_sent (sent_at desc);
comment on table ems.plan_fatal_deviation_sent is
'Backend job po uzavření 15min slotu: při fatální odchylce grid plán vs. audit jednou pošle Discord a zapíše řádek (PK site_id + interval_start).';
comment on column ems.plan_fatal_deviation_sent.reason_code is
'Kód z ems.fn_plan_actual_slot_guard_site (např. GRID_SIGN_MISMATCH, GRID_EXPORT_SPIKE).';

View File

@@ -1,6 +1,12 @@
-- začátek aktuálního (+offset) 15min slotu v Europe/Prague jako timestamptz (UTC instants)
-- Začátek aktuálního (+offset) 15min slotu v Europe/Prague jako timestamptz (UTC instants).
-- Volitelné p_at (např. job po uzavření slotu); null = now().
create or replace function ems.fn_planning_slot_boundary_prague(p_offset_slots int default 0)
drop function if exists ems.fn_planning_slot_boundary_prague(int);
create or replace function ems.fn_planning_slot_boundary_prague(
p_offset_slots int default 0,
p_at timestamptz default null
)
returns timestamptz
language sql
stable
@@ -13,8 +19,10 @@ as $fn$
)
)::timestamp at time zone 'Europe/Prague'
) + make_interval(mins => coalesce(p_offset_slots, 0) * 15)
from (select now() at time zone 'Europe/Prague' as ts) loc;
from (
select coalesce(p_at, now()) at time zone 'Europe/Prague' as ts
) loc;
$fn$;
comment on function ems.fn_planning_slot_boundary_prague(int) is
'Začátek 15min slotu v časové zóně site provozu (Europe/Prague floor); offset v násobcích 15 min.';
comment on function ems.fn_planning_slot_boundary_prague(int, timestamptz) is
'Začátek 15min slotu (Europe/Prague floor); offset v násobcích 15 min; p_at volitelně místo now().';

View File

@@ -0,0 +1,177 @@
-- Kontrola plán vs. audit (síť) po uzavření slotu: pravidla v DB, dedup insert, výstup pro Discord z Pythonu.
create or replace function ems.fn_plan_actual_slot_guard_site(
p_site_id int,
p_now timestamptz default now()
)
returns jsonb
language sql
volatile
as $fn$
with v_code as (
select s.code as site_code
from ems.site s
where s.id = p_site_id
),
slots as (
select distinct unnest(array[
ems.fn_planning_slot_boundary_prague(-1, p_now),
ems.fn_planning_slot_boundary_prague(-2, p_now)
]) as interval_start
),
base as (
select
s.interval_start,
ai.actual_grid_power_w,
ai.deviation_grid_w,
pi.grid_setpoint_w as plan_grid_w
from slots s
inner join ems.audit_interval ai
on ai.site_id = p_site_id
and ai.interval_start = s.interval_start
left join ems.planning_interval pi
on pi.run_id = ai.planning_run_id
and pi.interval_start = ai.interval_start
),
cls as (
select
b.interval_start,
b.plan_grid_w,
coalesce(b.actual_grid_power_w, 0) as actual_grid_w,
b.deviation_grid_w,
case
when b.plan_grid_w is null or b.deviation_grid_w is null then null::text
when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500
then 'GRID_IMPORT_VS_EXPORT_PLAN'
when b.plan_grid_w <> 0
and coalesce(b.actual_grid_power_w, 0) <> 0
and (b.plan_grid_w > 0)
<> (coalesce(b.actual_grid_power_w, 0) > 0)
and least(
abs(b.plan_grid_w),
abs(coalesce(b.actual_grid_power_w, 0))
) >= 400
then 'GRID_SIGN_MISMATCH'
when b.plan_grid_w > -1000 and coalesce(b.actual_grid_power_w, 0) < -4000
then 'GRID_EXPORT_SPIKE'
when abs(b.deviation_grid_w) >= 10000 and abs(b.plan_grid_w) <= 2500
then 'GRID_LARGE_DEVIATION'
else null::text
end as reason_code,
case
when b.plan_grid_w is null or b.deviation_grid_w is null then null::text
when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500
then format(
'plán síť %s W vs skutečnost %s W (plán vývoz, skutečnost silný odběr)',
b.plan_grid_w,
coalesce(b.actual_grid_power_w, 0)
)
when b.plan_grid_w <> 0
and coalesce(b.actual_grid_power_w, 0) <> 0
and (b.plan_grid_w > 0)
<> (coalesce(b.actual_grid_power_w, 0) > 0)
and least(
abs(b.plan_grid_w),
abs(coalesce(b.actual_grid_power_w, 0))
) >= 400
then format(
'plán síť %s W vs skutečnost %s W (opačný směr import/export)',
b.plan_grid_w,
coalesce(b.actual_grid_power_w, 0)
)
when b.plan_grid_w > -1000 and coalesce(b.actual_grid_power_w, 0) < -4000
then format(
'plán síť %s W vs skutečnost %s W (neočekávaný silný vývoz)',
b.plan_grid_w,
coalesce(b.actual_grid_power_w, 0)
)
when abs(b.deviation_grid_w) >= 10000 and abs(b.plan_grid_w) <= 2500
then format(
'odchylka výkonu sítě %s W (plán %s W, skutečnost %s W)',
b.deviation_grid_w,
b.plan_grid_w,
coalesce(b.actual_grid_power_w, 0)
)
else null::text
end as detail_cs
from base b
),
ins as (
insert into ems.plan_fatal_deviation_sent (site_id, interval_start, reason_code)
select
p_site_id,
c.interval_start,
c.reason_code
from cls c
where c.reason_code is not null
on conflict (site_id, interval_start) do nothing
returning interval_start, reason_code
),
notified as (
select
c.interval_start,
c.reason_code,
c.detail_cs,
c.plan_grid_w,
c.actual_grid_w,
c.deviation_grid_w
from cls c
inner join ins i
on i.interval_start = c.interval_start
and i.reason_code = c.reason_code
)
select
case
when not exists (select 1 from v_code) then
jsonb_build_object('error', 'unknown_site', 'site_id', p_site_id)
else
jsonb_build_object(
'site_id', p_site_id,
'site_code', (select vc.site_code from v_code vc),
'alerts', coalesce(
(
select coalesce(
jsonb_agg(
jsonb_build_object(
'interval_start', n.interval_start,
'reason_code', n.reason_code,
'detail', n.detail_cs,
'plan_grid_w', n.plan_grid_w,
'actual_grid_w', n.actual_grid_w,
'deviation_grid_w', n.deviation_grid_w,
'notify', true
)
order by n.interval_start
),
'[]'::jsonb
)
from notified n
),
'[]'::jsonb
)
)
end;
$fn$;
comment on function ems.fn_plan_actual_slot_guard_site(int, timestamptz) is
'Poslední 2 uzavřené 15min sloty: fatální odchylka síť plán vs. audit → insert plan_fatal_deviation_sent (dedup); vrátí JSON s alerts k odeslání na Discord.';
create or replace function ems.fn_plan_actual_slot_guard_all_active(
p_now timestamptz default now()
)
returns jsonb
language sql
volatile
as $fn$
select coalesce(
jsonb_agg(
ems.fn_plan_actual_slot_guard_site((elem->>'id')::int, p_now)
order by (elem->>'id')::int
),
'[]'::jsonb
)
from jsonb_array_elements(ems.fn_vw_site_directory_active()) as t(elem);
$fn$;
comment on function ems.fn_plan_actual_slot_guard_all_active(timestamptz) is
'Projde aktivní lokality (fn_vw_site_directory_active) a zavolá fn_plan_actual_slot_guard_site; pole výsledků pro scheduler.';