sql first refactor
Some checks failed
CI and deploy / migration-check (push) Successful in 5s
CI and deploy / deploy (push) Failing after 20s

This commit is contained in:
Dusan Vojacek
2026-04-19 20:02:20 +02:00
parent a02e11ee13
commit 93f883f5e0
74 changed files with 6022 additions and 4014 deletions

View File

@@ -0,0 +1,51 @@
-- audit „ekvivalent plných cyklů“ z 1min telemetrie battery_power_w (bez LP constraintu)
create or replace function ems.fn_battery_cycle_audit(
p_site_id int,
p_from timestamptz,
p_to timestamptz
)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_usable numeric;
v_throughput_wh numeric;
v_full_cycles numeric;
begin
select coalesce(sum(ab.usable_capacity_wh), 0)::numeric
into v_usable
from ems.asset_battery ab
where ab.site_id = p_site_id;
if v_usable is null or v_usable <= 0 then
return jsonb_build_object('error', 'no_battery', 'full_cycles', 0);
end if;
select coalesce(
sum(abs(ti.battery_power_w::numeric) / 60.0),
0
)
into v_throughput_wh
from ems.telemetry_inverter ti
where ti.site_id = p_site_id
and ti.measured_at >= p_from
and ti.measured_at < p_to
and ti.battery_power_w is not null;
v_full_cycles := case
when v_usable * 2 > 0 then v_throughput_wh / (v_usable * 2)
else 0
end;
return jsonb_build_object(
'full_cycles', round(v_full_cycles::numeric, 4),
'throughput_wh', round(v_throughput_wh, 2),
'throughput_vs_usable_ratio', round((v_throughput_wh / nullif(v_usable, 0))::numeric, 4),
'usable_capacity_wh', v_usable,
'window_start', p_from,
'window_end', p_to
);
end;
$fn$;

View File

@@ -0,0 +1,16 @@
create or replace function ems.fn_deye_clock_drift_sec(
p_device_ts timestamptz,
p_reference_ts timestamptz
)
returns int
language sql
immutable
as $fn$
select case
when p_device_ts is null or p_reference_ts is null then null::int
else abs(extract(epoch from (p_device_ts - p_reference_ts)))::int
end;
$fn$;
comment on function ems.fn_deye_clock_drift_sec(timestamptz, timestamptz) is
'Absolutní odchylka hodin Deye vs referenční UTC (sekundy).';

View File

@@ -0,0 +1,17 @@
-- pack reg 6264 (Europe/Prague wall time, seconds = 0) stejně jako _deye_system_time_register_rows
create or replace function ems.fn_deye_pack_system_time(p_ts timestamptz)
returns int[]
language sql
stable
as $fn$
with loc as (
select (p_ts at time zone 'Europe/Prague') as t
)
select array[
((extract(year from t)::int - 2000) << 8) | extract(month from t)::int,
(extract(day from t)::int << 8) | extract(hour from t)::int,
(extract(minute from t)::int << 8) | 0
]
from loc;
$fn$;

View File

@@ -0,0 +1,27 @@
-- pole registrů pro jeden TOU time point (čistá logika čísel; zápis řeší control exporter)
create or replace function ems.fn_deye_time_point_regs(
p_slot_index int,
p_hhmm int,
p_power_w int,
p_soc_pct int,
p_grid_charge_bit int
)
returns int[]
language sql
immutable
as $fn$
select array[
148 + p_slot_index * 6,
p_hhmm,
154 + p_slot_index * 6,
p_power_w,
166 + p_slot_index * 6,
p_soc_pct,
172 + p_slot_index * 6,
p_grid_charge_bit
];
$fn$;
comment on function ems.fn_deye_time_point_regs(int, int, int, int, int) is
'Adresy a hodnoty pro jeden Deye TOU blok (reg páry 148/154/166/172 + offset slotu).';

View File

@@ -0,0 +1,21 @@
create or replace function ems.fn_deye_tou_inactive_signature(
p_hhmm_inactive int,
p_min_soc_pct numeric,
p_reserve_soc_pct numeric,
p_tp_discharge_w int
)
returns text
language sql
immutable
as $fn$
select concat_ws(
'|',
p_hhmm_inactive::text,
round(p_min_soc_pct, 2)::text,
round(p_reserve_soc_pct, 2)::text,
p_tp_discharge_w::text
);
$fn$;
comment on function ems.fn_deye_tou_inactive_signature(int, numeric, numeric, int) is
'Podpis neaktivních TOU slotů (shoda s asset_inverter.deye_tou_inactive_signature).';

View File

@@ -0,0 +1,69 @@
create or replace function ems.fn_economics_daily_month(
p_site_id int,
p_month_start date,
p_month_end date
)
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'has_green_bonus',
exists (
select 1
from ems.asset_pv_array apv
where apv.site_id = p_site_id
and apv.green_bonus_czk_kwh is not null
),
'days',
coalesce(
(
select jsonb_agg(sub.day_row order by sub.day_local)
from (
select
r.day_local,
jsonb_build_object(
'day', r.day_local,
'interval_count', r.interval_count,
'import_kwh', r.import_kwh,
'export_kwh', r.export_kwh,
'pv_kwh', r.pv_kwh,
'load_kwh', r.load_kwh,
'pv_self_consumption_kwh', r.pv_self_consumption_kwh,
'ev_kwh', r.ev_kwh,
'hp_kwh', r.hp_kwh,
'import_cost_czk',
case when l.site_id is not null then l.import_cost_czk else r.import_cost_czk end,
'export_revenue_czk',
case when l.site_id is not null then l.export_revenue_czk else r.export_revenue_czk end,
'grid_import_cashflow_czk',
coalesce(l.grid_import_cashflow_czk, r.grid_import_cashflow_czk),
'grid_export_revenue_czk',
coalesce(l.grid_export_revenue_czk, r.grid_export_revenue_czk),
'net_cost_czk',
case when l.site_id is not null then l.net_cost_czk else r.net_cost_czk end,
'green_bonus_czk',
case when l.site_id is not null then l.green_bonus_czk else r.green_bonus_czk end,
'total_balance_czk',
case when l.site_id is not null then l.total_balance_czk else r.total_balance_czk end,
'planned_balance_czk', r.planned_balance_czk,
'deviation_cost_czk', r.deviation_cost_czk,
'is_locked', (l.site_id is not null)
) as day_row
from ems.vw_economics_daily r
left join ems.audit_day_lock l
on l.site_id = r.site_id
and l.day_local = r.day_local
where r.site_id = p_site_id
and r.day_local >= p_month_start
and r.day_local < p_month_end
order by r.day_local
) sub
),
'[]'::jsonb
)
);
$fn$;
comment on function ems.fn_economics_daily_month(int, date, date) is
'Měsíční denní ekonomika + lock merge jako JSON (GET /economics/daily).';

View File

@@ -0,0 +1,74 @@
create or replace function ems.fn_economics_lock_day(p_site_id int, p_day date)
returns jsonb
language plpgsql
as $fn$
declare
v_import_cost numeric;
v_export_rev numeric;
v_net numeric;
v_green numeric;
v_total numeric;
v_gic numeric;
v_ger numeric;
begin
select
r.import_cost_czk,
r.export_revenue_czk,
r.net_cost_czk,
r.green_bonus_czk,
r.total_balance_czk,
r.grid_import_cashflow_czk,
r.grid_export_revenue_czk
into strict
v_import_cost,
v_export_rev,
v_net,
v_green,
v_total,
v_gic,
v_ger
from ems.vw_economics_daily r
where r.site_id = p_site_id
and r.day_local = p_day;
insert into ems.audit_day_lock (
site_id,
day_local,
import_cost_czk,
export_revenue_czk,
net_cost_czk,
green_bonus_czk,
total_balance_czk,
grid_import_cashflow_czk,
grid_export_revenue_czk
)
values (
p_site_id,
p_day,
v_import_cost,
v_export_rev,
v_net,
v_green,
v_total,
v_gic,
v_ger
)
on conflict (site_id, day_local) do update set
import_cost_czk = excluded.import_cost_czk,
export_revenue_czk = excluded.export_revenue_czk,
net_cost_czk = excluded.net_cost_czk,
green_bonus_czk = excluded.green_bonus_czk,
total_balance_czk = excluded.total_balance_czk,
grid_import_cashflow_czk = excluded.grid_import_cashflow_czk,
grid_export_revenue_czk = excluded.grid_export_revenue_czk,
locked_at = now();
return jsonb_build_object('locked', true, 'day', p_day);
exception
when no_data_found then
return jsonb_build_object('locked', false, 'error', 'no_economics_data');
end;
$fn$;
comment on function ems.fn_economics_lock_day(int, date) is
'Zamkne den ekonomiky podle aktuálního vw_economics_daily (POST lock).';

View File

@@ -0,0 +1,62 @@
create or replace function ems.fn_economics_monthly_chart(
p_site_id int,
p_month_start date,
p_month_end date
)
returns jsonb
language sql
stable
as $fn$
with base as (
select
r.day_local,
case when l.site_id is not null then l.total_balance_czk else r.total_balance_czk end as tb,
case when l.site_id is not null then l.net_cost_czk else r.net_cost_czk end as nc,
case when l.site_id is not null then l.green_bonus_czk else r.green_bonus_czk end as gb,
coalesce(l.grid_import_cashflow_czk, r.grid_import_cashflow_czk) as gic,
coalesce(l.grid_export_revenue_czk, r.grid_export_revenue_czk) as ger
from ems.vw_economics_daily r
left join ems.audit_day_lock l
on l.site_id = r.site_id
and l.day_local = r.day_local
where r.site_id = p_site_id
and r.day_local >= p_month_start
and r.day_local < p_month_end
),
w as (
select
day_local,
round(tb::numeric, 2) as daily_balance_czk,
round((-nc)::numeric, 2) as daily_grid_balance_czk,
round(gb::numeric, 2) as daily_green_bonus_czk,
round(gic::numeric, 2) as daily_import_cost_czk,
round(ger::numeric, 2) as daily_export_revenue_czk,
sum(round(tb::numeric, 2)) over (
order by day_local rows between unbounded preceding and current row
) as cumb,
sum(round((-nc)::numeric, 2)) over (
order by day_local rows between unbounded preceding and current row
) as cumg
from base
)
select coalesce(
jsonb_agg(
jsonb_build_object(
'day', day_local,
'daily_balance_czk', daily_balance_czk,
'daily_grid_balance_czk', daily_grid_balance_czk,
'daily_green_bonus_czk', daily_green_bonus_czk,
'daily_import_cost_czk', daily_import_cost_czk,
'daily_export_revenue_czk', daily_export_revenue_czk,
'cumulative_balance_czk', round(cumb::numeric, 2),
'cumulative_grid_balance_czk', round(cumg::numeric, 2)
)
order by day_local
),
'[]'::jsonb
)
from w;
$fn$;
comment on function ems.fn_economics_monthly_chart(int, date, date) is
'Křivka měsíční bilance s běžícími součty (GET /economics/monthly-chart).';

View File

@@ -0,0 +1,15 @@
create or replace function ems.fn_economics_unlock_day(p_site_id int, p_day date)
returns jsonb
language plpgsql
as $fn$
begin
delete from ems.audit_day_lock
where site_id = p_site_id
and day_local = p_day;
return jsonb_build_object('locked', false, 'day', p_day);
end;
$fn$;
comment on function ems.fn_economics_unlock_day(int, date) is
'Odebere zámek dne ekonomiky (DELETE lock).';

View File

@@ -0,0 +1,82 @@
create or replace function ems.fn_energy_flows_daily_month(
p_site_id int,
p_month_start date,
p_month_end date
)
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'days',
coalesce(
jsonb_agg(t.row_json order by t.day_local),
'[]'::jsonb
)
)
from (
select
(date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date as day_local,
jsonb_build_object(
'day', (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date,
'interval_count', count(*)::int,
'pv_production_kwh', round(sum(coalesce(ai.actual_pv_production_wh, 0)) / 1000, 3),
'grid_import_kwh', round(sum(coalesce(ai.actual_grid_import_wh, 0)) / 1000, 3),
'grid_export_kwh', round(sum(coalesce(ai.actual_grid_export_wh, 0)) / 1000, 3),
'batt_charge_kwh', round(sum(coalesce(ai.actual_batt_charge_wh, 0)) / 1000, 3),
'batt_discharge_kwh', round(sum(coalesce(ai.actual_batt_discharge_wh, 0)) / 1000, 3),
'load_kwh', round(sum(coalesce(ai.actual_load_consumption_wh, 0)) / 1000, 3),
'pv_to_load_kwh', round(sum(coalesce(ai.flow_pv_to_load_wh, 0)) / 1000, 3),
'pv_to_batt_kwh', round(sum(coalesce(ai.flow_pv_to_batt_wh, 0)) / 1000, 3),
'pv_to_grid_kwh', round(sum(coalesce(ai.flow_pv_to_grid_wh, 0)) / 1000, 3),
'batt_to_load_kwh', round(sum(coalesce(ai.flow_batt_to_load_wh, 0)) / 1000, 3),
'batt_to_grid_kwh', round(sum(coalesce(ai.flow_batt_to_grid_wh, 0)) / 1000, 3),
'grid_to_load_kwh', round(sum(coalesce(ai.flow_grid_to_load_wh, 0)) / 1000, 3),
'grid_to_batt_kwh', round(sum(coalesce(ai.flow_grid_to_batt_wh, 0)) / 1000, 3),
'grid_import_cashflow_czk',
round(
sum(
coalesce(ai.actual_grid_import_wh, 0) / 1000.0
* coalesce(ep.effective_buy_price_czk_kwh, 0)
),
2
),
'grid_export_revenue_czk',
round(
sum(
coalesce(ai.actual_grid_export_wh, 0) / 1000.0
* coalesce(ep.effective_sell_price_czk_kwh, 0)
),
2
),
'grid_to_load_cost_czk',
round(
sum(
coalesce(ai.flow_grid_to_load_wh, 0) / 1000.0
* coalesce(ep.effective_buy_price_czk_kwh, 0)
),
2
),
'grid_to_batt_cost_czk',
round(
sum(
coalesce(ai.flow_grid_to_batt_wh, 0) / 1000.0
* coalesce(ep.effective_buy_price_czk_kwh, 0)
),
2
)
) as row_json
from ems.audit_interval ai
left join ems.vw_site_effective_price ep
on ep.site_id = ai.site_id
and ep.interval_start = ai.interval_start
where ai.site_id = p_site_id
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date >= p_month_start
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date < p_month_end
group by 1
order by 1
) t;
$fn$;
comment on function ems.fn_energy_flows_daily_month(int, date, date) is
'Denní agregace energy flows za měsíc jako JSON pole řádků.';

View File

@@ -0,0 +1,86 @@
create or replace function ems.fn_energy_flows_intervals_day(p_site_id int, p_day date)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(
jsonb_build_object(
'interval_start', ai.interval_start,
'pv_production_kwh',
case
when ai.actual_pv_production_wh is null then null
else round(ai.actual_pv_production_wh::numeric / 1000, 4)
end,
'grid_import_kwh',
case
when ai.actual_grid_import_wh is null then null
else round(ai.actual_grid_import_wh::numeric / 1000, 4)
end,
'grid_export_kwh',
case
when ai.actual_grid_export_wh is null then null
else round(ai.actual_grid_export_wh::numeric / 1000, 4)
end,
'batt_charge_kwh',
case
when ai.actual_batt_charge_wh is null then null
else round(ai.actual_batt_charge_wh::numeric / 1000, 4)
end,
'batt_discharge_kwh',
case
when ai.actual_batt_discharge_wh is null then null
else round(ai.actual_batt_discharge_wh::numeric / 1000, 4)
end,
'load_kwh',
case
when ai.actual_load_consumption_wh is null then null
else round(ai.actual_load_consumption_wh::numeric / 1000, 4)
end,
'pv_to_load_kwh',
case
when ai.flow_pv_to_load_wh is null then null
else round(ai.flow_pv_to_load_wh::numeric / 1000, 4)
end,
'pv_to_batt_kwh',
case
when ai.flow_pv_to_batt_wh is null then null
else round(ai.flow_pv_to_batt_wh::numeric / 1000, 4)
end,
'pv_to_grid_kwh',
case
when ai.flow_pv_to_grid_wh is null then null
else round(ai.flow_pv_to_grid_wh::numeric / 1000, 4)
end,
'batt_to_load_kwh',
case
when ai.flow_batt_to_load_wh is null then null
else round(ai.flow_batt_to_load_wh::numeric / 1000, 4)
end,
'batt_to_grid_kwh',
case
when ai.flow_batt_to_grid_wh is null then null
else round(ai.flow_batt_to_grid_wh::numeric / 1000, 4)
end,
'grid_to_load_kwh',
case
when ai.flow_grid_to_load_wh is null then null
else round(ai.flow_grid_to_load_wh::numeric / 1000, 4)
end,
'grid_to_batt_kwh',
case
when ai.flow_grid_to_batt_wh is null then null
else round(ai.flow_grid_to_batt_wh::numeric / 1000, 4)
end
)
order by ai.interval_start
),
'[]'::jsonb
)
from ems.audit_interval ai
where ai.site_id = p_site_id
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date = p_day;
$fn$;
comment on function ems.fn_energy_flows_intervals_day(int, date) is
'15min energy flows pro jeden kalendářní den (Prague) jako JSON pole.';

View File

@@ -0,0 +1,70 @@
create or replace function ems.fn_ev_arrival_prediction_bundle(p_site_id int)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_tz text;
v_tomorrow date;
v_n_sessions int;
v_insufficient boolean;
v_chargers jsonb := '{}'::jsonb;
r record;
v_rows jsonb;
begin
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague')
into v_tz
from ems.site s
where s.id = p_site_id;
if not found then
return jsonb_build_object('error', 'site_not_found');
end if;
v_tomorrow := (
(current_timestamp at time zone v_tz)::date + 1
);
select count(*)::int
into v_n_sessions
from ems.ev_session
where site_id = p_site_id;
v_insufficient := coalesce(v_n_sessions, 0) < 5;
for r in
select id, code
from ems.asset_ev_charger
where site_id = p_site_id
order by id
loop
select coalesce(
jsonb_agg(
jsonb_build_object(
'hour', x.expected_hour,
'confidence_pct', x.confidence_pct,
'samples', x.sample_count
)
order by x.expected_hour
),
'[]'::jsonb
)
into v_rows
from ems.fn_ev_expected_arrival(p_site_id, r.id, v_tomorrow) x;
v_chargers := v_chargers || jsonb_build_object(
r.code::text,
jsonb_build_object('tomorrow', coalesce(v_rows, '[]'::jsonb))
);
end loop;
return jsonb_build_object(
'insufficient_data', v_insufficient,
'tomorrow_date', v_tomorrow,
'chargers', v_chargers
);
end;
$fn$;
comment on function ems.fn_ev_arrival_prediction_bundle(int) is
'Predikce příjezdů pro všechny nabíječky (nahrazuje N+1 volání fn_ev_expected_arrival).';

View File

@@ -0,0 +1,49 @@
create or replace function ems.fn_ev_session_apply_patch(
p_site_id int,
p_session_id int,
p_patch jsonb
)
returns jsonb
language plpgsql
as $fn$
declare
v_id int;
begin
if not (p_patch ? 'target_soc_pct') and not (p_patch ? 'target_deadline') then
return jsonb_build_object('success', false, 'error', 'no_fields');
end if;
update ems.ev_session es
set
target_soc_pct = case
when p_patch ? 'target_soc_pct' then
case
when p_patch->'target_soc_pct' is null
or jsonb_typeof(p_patch->'target_soc_pct') = 'null' then null
else (p_patch->>'target_soc_pct')::double precision
end
else es.target_soc_pct
end,
target_deadline = case
when p_patch ? 'target_deadline' then
case
when p_patch->'target_deadline' is null
or jsonb_typeof(p_patch->'target_deadline') = 'null' then null
else (p_patch->>'target_deadline')::timestamptz
end
else es.target_deadline
end
where es.id = p_session_id
and es.site_id = p_site_id
returning es.id into v_id;
if v_id is null then
return jsonb_build_object('success', false, 'session_id', null);
end if;
return jsonb_build_object('success', true, 'session_id', v_id);
end;
$fn$;
comment on function ems.fn_ev_session_apply_patch(int, int, jsonb) is
'PATCH EV session jen klíče přítomné v JSON (ISO string pro deadline).';

View File

@@ -0,0 +1,87 @@
create or replace function ems.fn_ev_session_transition(
p_site_id int,
p_charger_id int,
p_prev_status text,
p_new_status text,
p_measured_at timestamptz
)
returns jsonb
language plpgsql
as $fn$
declare
v_vehicle_id int;
begin
if p_prev_status is not distinct from p_new_status then
return jsonb_build_object('action', 'none');
end if;
if p_prev_status = 'available' and p_new_status is distinct from 'available' then
select av.id
into v_vehicle_id
from ems.asset_vehicle av
where av.site_id = p_site_id
and av.default_charger_id = p_charger_id
and av.active = true
order by av.id
limit 1;
perform ems.fn_update_ev_arrival_stats(
p_site_id,
p_charger_id,
v_vehicle_id,
p_measured_at
);
insert into ems.ev_session (
site_id,
charger_id,
vehicle_id,
session_start,
target_soc_pct,
target_deadline
)
select
ac.site_id,
ac.id,
av.id,
now(),
av.default_target_soc_pct,
case
when av.default_deadline_hour is not null then
(
(timezone('Europe/Prague', now()))::date + interval '1 day'
+ make_interval(hours => av.default_deadline_hour)
)::timestamp at time zone 'Europe/Prague'
end
from ems.asset_ev_charger ac
left join lateral (
select v.id, v.default_target_soc_pct, v.default_deadline_hour
from ems.asset_vehicle v
where v.default_charger_id = ac.id
and v.site_id = ac.site_id
and v.active = true
order by v.id
limit 1
) av on true
where ac.id = p_charger_id
and ac.site_id = p_site_id
on conflict (charger_id) where session_end is null do nothing;
return jsonb_build_object('action', 'arrival');
end if;
if p_prev_status is distinct from 'available' and p_new_status = 'available' then
update ems.ev_session es
set session_end = now()
where es.charger_id = p_charger_id
and es.session_end is null;
return jsonb_build_object('action', 'departure');
end if;
return jsonb_build_object('action', 'none');
end;
$fn$;
comment on function ems.fn_ev_session_transition(int, int, text, text, timestamptz) is
'Detekce příjezdu/odjezdu EV po změně statusu nabíječky (telemetry_collector).';

View File

@@ -0,0 +1,49 @@
create or replace function ems.fn_ev_sessions_active(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(
jsonb_build_object(
'id', es.id,
'charger_id', es.charger_id,
'vehicle_id', es.vehicle_id,
'session_start', es.session_start,
'energy_delivered_wh', es.energy_delivered_wh,
'target_soc_pct', es.target_soc_pct,
'target_deadline', es.target_deadline,
'make', av.make,
'model', av.model,
'battery_capacity_kwh', av.battery_capacity_kwh,
'default_target_soc_pct', av.default_target_soc_pct,
'default_deadline_hour', av.default_deadline_hour,
'charger_code', ac.code,
'charger_name',
coalesce(
nullif(
trim(
concat_ws(
' ',
nullif(trim(ac.manufacturer), ''),
nullif(trim(ac.model), '')
)
),
''
),
ac.code
)
)
order by es.session_start desc
),
'[]'::jsonb
)
from ems.ev_session es
left join ems.asset_vehicle av on av.id = es.vehicle_id
join ems.asset_ev_charger ac on ac.id = es.charger_id
where es.site_id = p_site_id
and es.session_end is null;
$fn$;
comment on function ems.fn_ev_sessions_active(int) is
'Aktivní EV session pro site (GET /ev/sessions/active).';

View File

@@ -0,0 +1,39 @@
-- doplní chybějící audit_interval za posledních p_hours hodin (15min sloty)
create or replace function ems.fn_fill_audit_for_site_window(
p_site_id int,
p_hours int default 6
)
returns int
language plpgsql
as $fn$
declare
v_last timestamptz;
v_slot timestamptz;
v_cnt int := 0;
begin
v_last := date_trunc('minute', now())
- (extract(minute from now())::int % 15) * interval '1 minute';
v_last := v_last - interval '15 minutes';
for v_slot in
select gs.slot
from generate_series(
v_last - make_interval(hours => p_hours),
v_last,
interval '15 minutes'
) as gs(slot)
where not exists (
select 1 from ems.audit_interval ai
where ai.site_id = p_site_id
and ai.interval_start = gs.slot
)
loop
perform ems.fn_fill_audit_interval(p_site_id, v_slot);
perform ems.fn_fill_baseline_load_forecast_accuracy(p_site_id, v_slot);
v_cnt := v_cnt + 1;
end loop;
return v_cnt;
end;
$fn$;

View File

@@ -0,0 +1,74 @@
create or replace function ems.fn_forecast_pv_split(p_site_id int, p_day date)
returns jsonb
language sql
stable
as $fn$
with latest as (
select distinct on (fpi.interval_start, fpr.pv_array_id)
fpi.run_id,
fpi.pv_array_id,
fpi.interval_start,
fpi.power_w,
fpi.irradiance_wm2,
fpi.temp_c,
apa.code as pv_array_code,
apa.controllable
from ems.forecast_pv_interval fpi
join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
join ems.asset_pv_array apa
on apa.id = fpr.pv_array_id
and apa.site_id = fpr.site_id
where fpr.site_id = p_site_id
and (
fpi.interval_start at time zone coalesce(
nullif(trim((select timezone from ems.site s where s.id = p_site_id)), ''),
'Europe/Prague'
)
)::date = p_day
and fpr.status = 'ok'
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
),
rows as (
select
case
when controllable then 'a'
else 'b'
end as pole,
jsonb_build_object(
'run_id', run_id,
'pv_array_id', pv_array_id,
'interval_start', interval_start,
'power_w', power_w,
'irradiance_wm2', irradiance_wm2,
'temp_c', temp_c,
'pv_array_code', pv_array_code
) as j,
controllable,
pv_array_code,
interval_start
from latest
)
select jsonb_build_object(
'pv_a',
coalesce(
(
select jsonb_agg(j order by pv_array_code, interval_start)
from rows
where controllable
),
'[]'::jsonb
),
'pv_b',
coalesce(
(
select jsonb_agg(j order by pv_array_code, interval_start)
from rows
where not controllable
),
'[]'::jsonb
)
);
$fn$;
comment on function ems.fn_forecast_pv_split(int, date) is
'Predikce FVE rozsplitěná na pole A (controllable) a B pro UI.';

View File

@@ -0,0 +1,69 @@
create or replace function ems.fn_inverter_modbus_caps_patch(
p_site_id int,
p_inverter_id int,
p_patch jsonb
)
returns jsonb
language plpgsql
as $fn$
declare
v_charge int;
v_discharge int;
r record;
begin
if not (p_patch ? 'deye_register_max_charge_a')
and not (p_patch ? 'deye_register_max_discharge_a') then
return jsonb_build_object('ok', false, 'error', 'no_fields');
end if;
v_charge := case
when p_patch ? 'deye_register_max_charge_a' then
case
when p_patch->'deye_register_max_charge_a' is null
or jsonb_typeof(p_patch->'deye_register_max_charge_a') = 'null' then null
else (p_patch->>'deye_register_max_charge_a')::int
end
else null
end;
v_discharge := case
when p_patch ? 'deye_register_max_discharge_a' then
case
when p_patch->'deye_register_max_discharge_a' is null
or jsonb_typeof(p_patch->'deye_register_max_discharge_a') = 'null' then null
else (p_patch->>'deye_register_max_discharge_a')::int
end
else null
end;
update ems.asset_inverter ai
set
deye_register_max_charge_a = case
when p_patch ? 'deye_register_max_charge_a' then v_charge
else ai.deye_register_max_charge_a
end,
deye_register_max_discharge_a = case
when p_patch ? 'deye_register_max_discharge_a' then v_discharge
else ai.deye_register_max_discharge_a
end
where ai.id = p_inverter_id
and ai.site_id = p_site_id
returning ai.id, ai.code, ai.deye_register_max_charge_a, ai.deye_register_max_discharge_a
into r;
if r.id is null then
return jsonb_build_object('ok', false, 'error', 'not_found');
end if;
return jsonb_build_object(
'ok', true,
'inverter_id', r.id,
'code', r.code,
'deye_register_max_charge_a', r.deye_register_max_charge_a,
'deye_register_max_discharge_a', r.deye_register_max_discharge_a
);
end;
$fn$;
comment on function ems.fn_inverter_modbus_caps_patch(int, int, jsonb) is
'PATCH stropů proudu reg 108/109 explicitní JSON null maže strop.';

View File

@@ -0,0 +1,23 @@
create or replace function ems.fn_latest_ote_day_stats()
returns jsonb
language sql
stable
as $fn$
select to_jsonb(sub)
from (
select
(mip.interval_start at time zone 'Europe/Prague')::date as latest_date,
count(*)::int as slots,
min(mip.buy_raw_price_czk_kwh)::float as min_price,
max(mip.buy_raw_price_czk_kwh)::float as max_price,
avg(mip.buy_raw_price_czk_kwh)::float as avg_price
from ems.market_interval_price mip
where mip.market_source in ('OTE_CZ', 'OTE_CZ_DAM')
group by (mip.interval_start at time zone 'Europe/Prague')::date
order by latest_date desc
limit 1
) sub;
$fn$;
comment on function ems.fn_latest_ote_day_stats() is
'Agregace posledního kalendářního dne s OTE daty (globální, bez site_id).';

View File

@@ -0,0 +1,285 @@
-- sloty pro LP: ceny, forecast, baseline, EV připojení + masky allow_charge / allow_discharge_export
create or replace function ems.fn_load_planning_slots_full(
p_site_id int,
p_from timestamptz,
p_to timestamptz,
p_current_soc_wh numeric
)
returns table (
slot_ord int,
interval_start timestamptz,
buy_price numeric,
sell_price numeric,
is_predicted_price boolean,
pv_a_forecast_w int,
pv_b_forecast_w int,
load_baseline_w int,
ev1_connected boolean,
ev2_connected boolean,
allow_charge boolean,
allow_discharge_export boolean
)
language plpgsql
stable
as $fn$
declare
v_charge_buf numeric;
v_discharge_buf numeric;
v_usable numeric;
v_min_soc_wh numeric;
v_soc_max_wh numeric;
v_energy_to_fill numeric;
v_exportable numeric;
v_charge_eff numeric;
v_discharge_eff numeric;
v_max_charge_w numeric;
v_max_discharge_w numeric;
v_per_slot_charge_wh numeric;
v_per_slot_discharge_wh numeric;
v_grid_target_wh numeric;
v_discharge_target_wh numeric;
v_cum numeric;
r_slot record;
begin
drop table if exists _ems_plan_slot_wk;
create temp table _ems_plan_slot_wk on commit drop as
with slot_spine as (
select gs as interval_start
from generate_series(
p_from,
(p_to - interval '15 minutes')::timestamptz,
interval '15 minutes'
) as gs
)
select
row_number() over (order by s.interval_start) - 1 as slot_ord,
s.interval_start,
coalesce(
ep.effective_buy_price_czk_kwh,
ems.fn_get_predicted_price(p_site_id, s.interval_start)
) as buy_price,
coalesce(
ep.effective_sell_price_czk_kwh,
ems.fn_get_predicted_price(p_site_id, s.interval_start) * 0.85
) as sell_price,
(ep.effective_buy_price_czk_kwh is null) as is_predicted_price,
coalesce(fpi_a.power_w, 0)::int as pv_a_forecast_w,
coalesce(fpi_b.power_w, 0)::int as pv_b_forecast_w,
coalesce(
(
select bs.avg_power_w
from ems.consumption_baseline_stats bs
where bs.site_id = p_site_id
and bs.day_of_week = extract(
dow from s.interval_start at time zone 'Europe/Prague'
)::int
and bs.hour_of_day = extract(
hour from s.interval_start at time zone 'Europe/Prague'
)::int
limit 1
),
500
)::int as load_baseline_w,
(coalesce(ev1.status, 'available') not in ('available', 'unavailable')) as ev1_connected,
(coalesce(ev2.status, 'available') not in ('available', 'unavailable')) as ev2_connected,
greatest(
0,
coalesce(fpi_a.power_w, 0) + coalesce(fpi_b.power_w, 0)
- coalesce(
(
select bs.avg_power_w
from ems.consumption_baseline_stats bs
where bs.site_id = p_site_id
and bs.day_of_week = extract(
dow from s.interval_start at time zone 'Europe/Prague'
)::int
and bs.hour_of_day = extract(
hour from s.interval_start at time zone 'Europe/Prague'
)::int
limit 1
),
500
)
)::int as pv_surplus_w,
false::boolean as allow_charge,
false::boolean as allow_discharge_export
from slot_spine s
left join ems.vw_site_effective_price ep
on ep.site_id = p_site_id and ep.interval_start = s.interval_start
left join lateral (
select coalesce(sum(u.power_w), 0)::int as power_w
from (
select distinct on (apa.id)
fpi.power_w
from ems.asset_pv_array apa
join ems.forecast_pv_run fpr
on fpr.pv_array_id = apa.id
and fpr.site_id = apa.site_id
and fpr.status = 'ok'
join ems.forecast_pv_interval fpi
on fpi.run_id = fpr.id
and fpi.pv_array_id = apa.id
and fpi.interval_start = s.interval_start
where apa.site_id = p_site_id
and apa.controllable is true
order by apa.id, fpr.created_at desc
) u
) fpi_a on true
left join lateral (
select coalesce(sum(u.power_w), 0)::int as power_w
from (
select distinct on (apa.id)
fpi.power_w
from ems.asset_pv_array apa
join ems.forecast_pv_run fpr
on fpr.pv_array_id = apa.id
and fpr.site_id = apa.site_id
and fpr.status = 'ok'
join ems.forecast_pv_interval fpi
on fpi.run_id = fpr.id
and fpi.pv_array_id = apa.id
and fpi.interval_start = s.interval_start
where apa.site_id = p_site_id
and apa.controllable is false
order by apa.id, fpr.created_at desc
) u
) fpi_b on true
left join lateral (
select t.status
from ems.telemetry_ev_charger t
join ems.asset_ev_charger ch on ch.id = t.charger_id
where t.site_id = p_site_id and ch.code = 'ev-charger-1'
order by t.measured_at desc
limit 1
) ev1 on true
left join lateral (
select t.status
from ems.telemetry_ev_charger t
join ems.asset_ev_charger ch on ch.id = t.charger_id
where t.site_id = p_site_id and ch.code = 'ev-charger-2'
order by t.measured_at desc
limit 1
) ev2 on true;
if not exists (select 1 from _ems_plan_slot_wk) then
raise exception 'No planning slots available check market prices and horizon settings';
end if;
select
coalesce(ab.charge_slot_buffer, 0::numeric),
coalesce(ab.discharge_slot_buffer, 0::numeric),
ab.usable_capacity_wh::numeric,
(ab.min_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
(ab.max_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
greatest(coalesce(ab.charge_efficiency, 1::numeric), 0.0001::numeric),
least(
coalesce(ai.max_battery_charge_w, ai.max_charge_power_w),
coalesce(
ab.bms_max_charge_w,
case when ab.max_charge_c_rate is not null
then (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
end,
coalesce(ai.max_battery_charge_w, ai.max_charge_power_w)
)
)::numeric,
least(
coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w),
coalesce(
ab.bms_max_discharge_w,
case when ab.max_discharge_c_rate is not null
then (ab.max_discharge_c_rate * ab.usable_capacity_wh)::bigint
end,
coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w)
)
)::numeric,
greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric)
into
v_charge_buf,
v_discharge_buf,
v_usable,
v_min_soc_wh,
v_soc_max_wh,
v_charge_eff,
v_max_charge_w,
v_max_discharge_w,
v_discharge_eff
from ems.asset_battery ab
join ems.asset_inverter ai on ai.id = ab.inverter_id and ai.site_id = ab.site_id
where ab.site_id = p_site_id
order by ab.id
limit 1;
if v_usable is null then
raise exception 'No asset_battery for site_id=%', p_site_id;
end if;
v_per_slot_charge_wh := v_max_charge_w * v_charge_eff * 0.25;
v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25;
v_energy_to_fill := v_soc_max_wh - p_current_soc_wh;
v_exportable := v_soc_max_wh - v_min_soc_wh;
v_grid_target_wh := v_energy_to_fill * v_charge_buf;
v_discharge_target_wh := v_exportable * v_discharge_buf;
-- charge mask
if v_charge_buf <= 0 then
update _ems_plan_slot_wk set allow_charge = true;
elsif v_energy_to_fill <= 0 then
update _ems_plan_slot_wk set allow_charge = false;
else
update _ems_plan_slot_wk set allow_charge = (pv_surplus_w > 0);
v_cum := 0;
for r_slot in
select slot_ord
from _ems_plan_slot_wk
where pv_surplus_w <= 0
order by buy_price, slot_ord
loop
exit when v_cum >= v_grid_target_wh;
exit when v_per_slot_charge_wh <= 0;
update _ems_plan_slot_wk set allow_charge = true where slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_charge_wh;
end loop;
end if;
-- discharge-export mask
if v_discharge_buf <= 0 then
update _ems_plan_slot_wk set allow_discharge_export = true;
elsif v_exportable <= 0 then
update _ems_plan_slot_wk set allow_discharge_export = false;
else
update _ems_plan_slot_wk set allow_discharge_export = false;
v_cum := 0;
for r_slot in
select slot_ord
from _ems_plan_slot_wk
order by sell_price desc, slot_ord desc
loop
exit when v_cum >= v_discharge_target_wh;
exit when v_per_slot_discharge_wh <= 0;
update _ems_plan_slot_wk set allow_discharge_export = true where slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_discharge_wh;
end loop;
end if;
return query
select
w.slot_ord,
w.interval_start,
w.buy_price,
w.sell_price,
w.is_predicted_price,
w.pv_a_forecast_w,
w.pv_b_forecast_w,
w.load_baseline_w,
w.ev1_connected,
w.ev2_connected,
w.allow_charge,
w.allow_discharge_export
from _ems_plan_slot_wk w
order by w.slot_ord;
end;
$fn$;
comment on function ems.fn_load_planning_slots_full(int, timestamptz, timestamptz, numeric) is
'15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export).';

View File

@@ -0,0 +1,15 @@
create or replace function ems.fn_modbus_commands_by_ids(p_ids int[])
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(to_jsonb(m.*) order by m.id),
'[]'::jsonb
)
from ems.modbus_command m
where m.id = any(p_ids);
$fn$;
comment on function ems.fn_modbus_commands_by_ids(int[]) is
'Řádky modbus_command pro seznam ID (ověření / journal detail).';

View File

@@ -0,0 +1,42 @@
create or replace function ems.fn_modbus_journal_list(p_site_id int, p_limit int)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(
jsonb_build_object(
'id', q.id,
'register', q.register,
'register_name', q.register_name,
'value_to_write', q.value_to_write,
'value_written', q.value_written,
'value_verified', q.value_verified,
'status', q.status,
'attempt_count', q.attempt_count,
'created_at', q.created_at
)
order by q.created_at desc
),
'[]'::jsonb
)
from (
select
mc.id,
mc.register,
mc.register_name,
mc.value_to_write,
mc.value_written,
mc.value_verified,
mc.status,
mc.attempt_count,
mc.created_at
from ems.modbus_command mc
where mc.site_id = p_site_id
order by mc.created_at desc
limit p_limit
) q;
$fn$;
comment on function ems.fn_modbus_journal_list(int, int) is
'Poslední Modbus příkazy pro site (GET control/journal).';

View File

@@ -0,0 +1,24 @@
-- map register -> value_verified z modbus_command (poslední verified řádek per register)
create or replace function ems.fn_modbus_last_verified_map(
p_site_id int,
p_asset_id int
)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_object_agg(register::text, to_jsonb(value_verified)),
'{}'::jsonb
)
from (
select
v.register,
v.value_verified
from ems.vw_modbus_last_verified v
where v.site_id = p_site_id
and v.asset_type = 'inverter'
and v.asset_id = p_asset_id
) t;
$fn$;

View File

@@ -0,0 +1,20 @@
create or replace function ems.fn_modbus_written_command_ids(
p_site_id int,
p_lookback interval
)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(mc.id order by mc.written_at),
'[]'::jsonb
)
from ems.modbus_command mc
where mc.site_id = p_site_id
and mc.status = 'written'
and mc.written_at >= now() - p_lookback;
$fn$;
comment on function ems.fn_modbus_written_command_ids(int, interval) is
'ID written příkazů k ruční verifikaci (GET control/verify).';

View File

@@ -0,0 +1,59 @@
create or replace function ems.fn_negative_price_predictions(p_site_id int)
returns jsonb
language sql
stable
as $fn$
with hist as (
select count(distinct (mip.interval_start at time zone 'Europe/Prague')::date)::int as ndays
from ems.market_interval_price mip
where mip.market_source in ('OTE_CZ', 'OTE_CZ_DAM')
and mip.interval_start >= now() - interval '400 days'
),
rows as (
select
p.predicted_date,
p.window_start_hour,
p.window_end_hour,
p.probability_pct,
p.expected_min_price,
p.reason
from ems.predicted_negative_price_window p
where p.site_id = p_site_id
and p.predicted_date > (
current_timestamp at time zone coalesce(
nullif(trim((select s.timezone from ems.site s where s.id = p_site_id)), ''),
'Europe/Prague'
)
)::date
and p.predicted_date <= (
current_timestamp at time zone coalesce(
nullif(trim((select s.timezone from ems.site s where s.id = p_site_id)), ''),
'Europe/Prague'
)
)::date + 7
order by p.predicted_date, p.window_start_hour
)
select jsonb_build_object(
'predictions',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'predicted_date', r.predicted_date,
'window_start_hour', r.window_start_hour,
'window_end_hour', r.window_end_hour,
'probability_pct', r.probability_pct,
'expected_min_price', r.expected_min_price,
'reason', coalesce(r.reason, '')
)
)
from rows r
),
'[]'::jsonb
),
'insufficient_history', (select ndays < 28 from hist)
);
$fn$;
comment on function ems.fn_negative_price_predictions(int) is
'Predikovaná okna záporných cen + flag nedostatečné historie OTE.';

View File

@@ -0,0 +1,42 @@
-- statistiky importovaných OTE slotů pro kalendářní den v Europe/Prague
create or replace function ems.fn_ote_day_slot_stats_prague(p_day date)
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'count',
coalesce(
(
select count(*)::int
from ems.market_interval_price mip
where mip.market_source = 'OTE_CZ'
and (mip.interval_start at time zone 'Europe/Prague')::date = p_day
),
0
),
'first_price',
(
select mip.buy_raw_price_czk_kwh
from ems.market_interval_price mip
where mip.market_source = 'OTE_CZ'
and (mip.interval_start at time zone 'Europe/Prague')::date = p_day
order by mip.interval_start
limit 1
),
'is_complete',
coalesce(
(
select count(*)::int
from ems.market_interval_price mip
where mip.market_source = 'OTE_CZ'
and (mip.interval_start at time zone 'Europe/Prague')::date = p_day
),
0
) in (92, 96, 100)
);
$fn$;
comment on function ems.fn_ote_day_slot_stats_prague(date) is
'Počet slotů OTE_CZ, první cena a zda den vypadá kompletně (92/96/100) v TZ Praha.';

View File

@@ -0,0 +1,26 @@
create or replace function ems.fn_ote_list_missing_days(p_from date, p_to date)
returns table(day_local date)
language sql
stable
as $fn$
with days as (
select gs::date as d
from generate_series(p_from::timestamp, p_to::timestamp, interval '1 day') gs
),
counts as (
select
(mip.interval_start at time zone 'Europe/Prague')::date as d,
count(*)::int as n
from ems.market_interval_price mip
where mip.market_source = 'OTE_CZ'
and (mip.interval_start at time zone 'Europe/Prague')::date between p_from and p_to
group by 1
)
select days.d as day_local
from days
left join counts c on c.d = days.d
where coalesce(c.n, 0) not in (92, 96, 100);
$fn$;
comment on function ems.fn_ote_list_missing_days(date, date) is
'Kalendářní dny v rozsahu bez „plného“ počtu OTE slotů (backfill).';

View File

@@ -0,0 +1,177 @@
create or replace function ems.fn_plan_current_bundle(p_site_id int)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_run jsonb;
v_run_id int;
v_batt_wh float;
v_intervals jsonb;
v_total_cost numeric;
v_curtailed numeric;
v_charge bigint;
v_discharge bigint;
v_export bigint;
v_pv_kwh numeric;
v_cap numeric;
v_cov numeric;
v_scarcity numeric;
begin
select to_jsonb(pr)
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 v_run is null then
return jsonb_build_object('error', 'no_active_plan');
end if;
v_run_id := (v_run->>'id')::int;
select coalesce(sum(ab.usable_capacity_wh), 0)::float
into v_batt_wh
from ems.asset_battery ab
where ab.site_id = p_site_id;
with fc_slot as (
select
u.interval_start,
coalesce(sum(u.power_w), 0)::bigint as pv_forecast_total_w
from (
select distinct on (fpi.interval_start, fpr.pv_array_id)
fpi.interval_start,
fpi.power_w
from ems.forecast_pv_interval fpi
join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
join ems.asset_pv_array apa
on apa.id = fpr.pv_array_id
and apa.site_id = fpr.site_id
where fpr.site_id = p_site_id
and fpr.status = 'ok'
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
) u
group by u.interval_start
),
joined as (
select
to_jsonb(pi.*)
|| jsonb_build_object(
'pv_power_w', ai.actual_pv_power_w,
'pv_forecast_total_w', fs.pv_forecast_total_w
) as j,
pi.interval_start,
pi.expected_cost_czk,
pi.pv_a_curtailed_w,
pi.battery_setpoint_w,
pi.grid_setpoint_w,
fs.pv_forecast_total_w
from ems.planning_interval pi
left join ems.audit_interval ai
on ai.site_id = p_site_id
and ai.interval_start = pi.interval_start
left join fc_slot fs on fs.interval_start = pi.interval_start
where pi.run_id = v_run_id
),
agg as (
select
coalesce(jsonb_agg(j order by interval_start), '[]'::jsonb) as intervals,
coalesce(
sum(
case
when expected_cost_czk is not null then expected_cost_czk::numeric
else 0::numeric
end
),
0::numeric
) as total_cost,
coalesce(
sum(coalesce(pv_a_curtailed_w, 0)::numeric * 0.25 / 1000.0),
0::numeric
) as curtailed_kwh,
coalesce(
sum(
case
when battery_setpoint_w is not null and battery_setpoint_w > 0 then 1
else 0
end
),
0::bigint
) as charge_slots,
coalesce(
sum(
case
when battery_setpoint_w is not null and battery_setpoint_w < 0 then 1
else 0
end
),
0::bigint
) as discharge_slots,
coalesce(
sum(
case
when grid_setpoint_w is not null and grid_setpoint_w < 0 then 1
else 0
end
),
0::bigint
) as export_slots
from joined
),
pv96 as (
select coalesce(
sum(
greatest(0::numeric, coalesce(pv_forecast_total_w, 0)::numeric) * 0.25 / 1000.0
),
0::numeric
) as pv_kwh
from (
select pv_forecast_total_w
from joined
order by interval_start
limit 96
) z
)
select
a.intervals,
a.total_cost,
a.curtailed_kwh,
a.charge_slots,
a.discharge_slots,
a.export_slots,
p.pv_kwh
into strict
v_intervals,
v_total_cost,
v_curtailed,
v_charge,
v_discharge,
v_export,
v_pv_kwh
from agg a
cross join pv96 p;
v_cap := greatest(1::numeric, coalesce(v_batt_wh, 0::float)::numeric / 1000.0);
v_cov := least(1::numeric, greatest(0::numeric, coalesce(v_pv_kwh, 0) / v_cap));
v_scarcity := round(0.65::numeric + 0.35 * v_cov, 4);
return jsonb_build_object(
'run', v_run,
'intervals', v_intervals,
'summary', jsonb_build_object(
'total_expected_cost_czk', round(v_total_cost, 4),
'total_pv_curtailed_kwh', round(v_curtailed, 6),
'charge_slots', v_charge,
'discharge_slots', v_discharge,
'export_slots', v_export,
'pv_scarcity_factor', v_scarcity
)
);
end;
$fn$;
comment on function ems.fn_plan_current_bundle(int) is
'Aktivní planning_run + intervaly + souhrn (GET /plan/current).';

View File

@@ -0,0 +1,29 @@
-- aktivní planning_run pro site (rolling horizon)
create or replace function ems.fn_planning_active_run(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select case
when not exists (select 1 from ems.site s where s.id = p_site_id) then
jsonb_build_object('error', 'unknown_site')
when not exists (
select 1 from ems.planning_run pr
where pr.site_id = p_site_id and pr.status = 'active'
) then
jsonb_build_object('error', 'no_active_plan')
else (
select jsonb_build_object(
'id', pr.id,
'horizon_end', pr.horizon_end,
'horizon_start', pr.horizon_start,
'created_at', pr.created_at
)
from ems.planning_run pr
where pr.site_id = p_site_id and pr.status = 'active'
order by pr.created_at desc
limit 1
)
end;
$fn$;

View File

@@ -0,0 +1,14 @@
create or replace function ems.fn_planning_future_price_days()
returns int
language sql
stable
as $fn$
select count(distinct (mip.interval_start at time zone 'Europe/Prague')::date)::int
from ems.market_interval_price mip
where mip.market_source in ('OTE_CZ', 'OTE_CZ_DAM')
and mip.interval_start >= now()
and mip.interval_start < now() + interval '48 hours';
$fn$;
comment on function ems.fn_planning_future_price_days() is
'Počet kalendářních dní s OTE daty v okně now..now+48h (před spuštěním plánu).';

View File

@@ -0,0 +1,45 @@
-- jeden řádek planning_interval jako jsonb pro aktivní plán a slot offset (Prague 15min)
create or replace function ems.fn_planning_interval_at_offset(
p_site_id int,
p_offset_slots int default 0
)
returns jsonb
language sql
stable
as $fn$
select to_jsonb(pi)
from ems.planning_interval pi
join ems.planning_run pr on pr.id = pi.run_id
where pr.site_id = p_site_id
and pr.status = 'active'
and pi.interval_start = ems.fn_planning_slot_boundary_prague(p_offset_slots)
limit 1;
$fn$;
create or replace function ems.fn_planning_max_effective_charge_w(p_site_id int)
returns int
language sql
stable
as $fn$
select coalesce(
least(
coalesce(ai.max_battery_charge_w, ai.max_charge_power_w),
coalesce(
ab.bms_max_charge_w,
case when ab.max_charge_c_rate is not null
then (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
end,
coalesce(ai.max_battery_charge_w, ai.max_charge_power_w)
)
)::int,
0
)
from ems.asset_battery ab
join ems.asset_inverter ai on ai.id = ab.inverter_id and ai.site_id = ab.site_id
where ab.site_id = p_site_id
and ai.controllable is true
and ai.active is true
order by ab.id
limit 1;
$fn$;

View File

@@ -0,0 +1,148 @@
-- uložení planning_run + planning_interval v jedné transakci
create or replace function ems.fn_planning_run_commit(
p_site_id int,
p_horizon_start timestamptz,
p_horizon_end timestamptz,
p_run_meta jsonb,
p_intervals jsonb
)
returns int
language plpgsql
as $fn$
declare
v_run_id int;
r record;
v_has_slot_inputs boolean;
begin
v_has_slot_inputs := coalesce(
(jsonb_typeof(p_intervals) = 'array' and jsonb_array_length(p_intervals) > 0
and (p_intervals->0) ? 'load_baseline_w'),
false
);
insert into ems.planning_run (
site_id, horizon_start, horizon_end, status,
run_type, triggered_by, replan_from,
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor
) values (
p_site_id,
p_horizon_start,
p_horizon_end,
'draft',
nullif(trim(p_run_meta->>'run_type'), ''),
nullif(trim(p_run_meta->>'triggered_by'), ''),
case
when p_run_meta ? 'replan_from' and (p_run_meta->>'replan_from') is not null
and (p_run_meta->>'replan_from') <> 'null'
then (p_run_meta->>'replan_from')::timestamptz
else null::timestamptz
end,
(p_run_meta->>'soc_at_replan_wh')::numeric,
(p_run_meta->>'solver_duration_ms')::int,
(p_run_meta->>'forecast_correction_factor')::numeric
)
returning id into v_run_id;
for r in select * from jsonb_array_elements(p_intervals) as elem(value)
loop
if v_has_slot_inputs then
insert into ems.planning_interval (
run_id, interval_start,
battery_setpoint_w, battery_soc_target_pct,
grid_setpoint_w,
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
effective_buy_price, effective_sell_price,
is_predicted_price,
load_baseline_w,
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
pv_a_forecast_solver_w, pv_b_forecast_solver_w
) values (
v_run_id,
(r.value->>'interval_start')::timestamptz,
(r.value->>'battery_setpoint_w')::int,
(r.value->>'battery_soc_target_pct')::numeric,
(r.value->>'grid_setpoint_w')::int,
nullif(r.value->>'ev1_setpoint_w', '')::int,
nullif(r.value->>'ev2_setpoint_w', '')::int,
coalesce((r.value->>'ev1_via_bat_w')::int, 0),
coalesce((r.value->>'ev2_via_bat_w')::int, 0),
coalesce((r.value->>'heat_pump_enabled')::boolean, false),
(r.value->>'heat_pump_setpoint_w')::int,
(r.value->>'pv_a_curtailed_w')::int,
(r.value->>'expected_cost_czk')::numeric,
(r.value->>'effective_buy_price')::numeric,
(r.value->>'effective_sell_price')::numeric,
coalesce((r.value->>'is_predicted_price')::boolean, false),
(r.value->>'load_baseline_w')::int,
(r.value->>'pv_a_forecast_raw_w')::int,
(r.value->>'pv_b_forecast_raw_w')::int,
(r.value->>'pv_a_forecast_solver_w')::int,
(r.value->>'pv_b_forecast_solver_w')::int
);
else
insert into ems.planning_interval (
run_id, interval_start,
battery_setpoint_w, battery_soc_target_pct,
grid_setpoint_w,
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
effective_buy_price, effective_sell_price,
is_predicted_price
) values (
v_run_id,
(r.value->>'interval_start')::timestamptz,
(r.value->>'battery_setpoint_w')::int,
(r.value->>'battery_soc_target_pct')::numeric,
(r.value->>'grid_setpoint_w')::int,
nullif(r.value->>'ev1_setpoint_w', '')::int,
nullif(r.value->>'ev2_setpoint_w', '')::int,
coalesce((r.value->>'ev1_via_bat_w')::int, 0),
coalesce((r.value->>'ev2_via_bat_w')::int, 0),
coalesce((r.value->>'heat_pump_enabled')::boolean, false),
(r.value->>'heat_pump_setpoint_w')::int,
(r.value->>'pv_a_curtailed_w')::int,
(r.value->>'expected_cost_czk')::numeric,
(r.value->>'effective_buy_price')::numeric,
(r.value->>'effective_sell_price')::numeric,
coalesce((r.value->>'is_predicted_price')::boolean, false)
);
end if;
end loop;
update ems.planning_run
set status = 'superseded'
where site_id = p_site_id
and status = 'active'
and id <> v_run_id;
update ems.planning_run
set status = 'active'
where id = v_run_id;
return v_run_id;
end;
$fn$;
create or replace function ems.fn_forecast_correction_log_insert(
p_site_id int,
p_window_start timestamptz,
p_window_end timestamptz,
p_actual_pv_wh numeric,
p_forecast_pv_wh numeric,
p_correction_factor numeric,
p_applied_to_run_id int
)
returns void
language sql
as $fn$
insert into ems.forecast_correction_log (
site_id, window_start, window_end,
actual_pv_wh, forecast_pv_wh, correction_factor, applied_to_run_id
) values (
p_site_id, p_window_start, p_window_end,
p_actual_pv_wh, p_forecast_pv_wh, p_correction_factor, p_applied_to_run_id
);
$fn$;

View File

@@ -0,0 +1,15 @@
create or replace function ems.fn_planning_run_horizon(p_run_id int)
returns jsonb
language sql
stable
as $fn$
select jsonb_build_object(
'horizon_start', pr.horizon_start,
'horizon_end', pr.horizon_end
)
from ems.planning_run pr
where pr.id = p_run_id;
$fn$;
comment on function ems.fn_planning_run_horizon(int) is
'Horizont po úspěšném POST /plan/run (read-model).';

View File

@@ -0,0 +1,263 @@
-- jeden jsonb snapshot pro LP: režim, baterie, síť, EV, TČ, tuv stats
create or replace function ems.fn_planning_site_context(p_site_id int)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_mode text;
v_b jsonb;
v_hp jsonb;
v_grid jsonb;
v_veh jsonb;
v_ev jsonb;
v_soc_pct numeric;
v_soc_wh numeric;
v_tuv numeric;
v_tuv_stats jsonb;
v_uc numeric;
v_min_soc_wh numeric;
v_arb_wh numeric;
v_soc_max_wh numeric;
begin
if not exists (select 1 from ems.site s where s.id = p_site_id) then
return jsonb_build_object('error', 'unknown_site');
end if;
select som.mode_code
into v_mode
from ems.site_operating_mode som
where som.site_id = p_site_id;
select jsonb_build_object(
'usable_capacity_wh', ab.usable_capacity_wh,
'min_soc_wh', (ab.min_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
'arb_floor_wh', (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
'reserve_soc_wh', (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
'soc_max_wh', (ab.max_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
'charge_efficiency', ab.charge_efficiency,
'discharge_efficiency', ab.discharge_efficiency,
'degradation_cost_czk_kwh', ab.degradation_cost_czk_kwh,
'max_charge_power_w', least(
coalesce(ai.max_battery_charge_w, ai.max_charge_power_w),
coalesce(
ab.bms_max_charge_w,
case when ab.max_charge_c_rate is not null
then (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
end,
coalesce(ai.max_battery_charge_w, ai.max_charge_power_w)
)
)::int,
'max_discharge_power_w', least(
coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w),
coalesce(
ab.bms_max_discharge_w,
case when ab.max_discharge_c_rate is not null
then (ab.max_discharge_c_rate * ab.usable_capacity_wh)::bigint
end,
coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w)
)
)::int,
'charge_slot_buffer', ab.charge_slot_buffer,
'discharge_slot_buffer', ab.discharge_slot_buffer
)
into v_b
from ems.asset_battery ab
join ems.asset_inverter ai on ai.id = ab.inverter_id and ai.site_id = ab.site_id
where ab.site_id = p_site_id
order by ab.id
limit 1;
if v_b is null then
raise exception 'No asset_battery for site_id=%', p_site_id;
end if;
v_uc := (v_b->>'usable_capacity_wh')::numeric;
v_min_soc_wh := (v_b->>'min_soc_wh')::numeric;
v_soc_max_wh := (v_b->>'soc_max_wh')::numeric;
if (v_b->>'max_charge_power_w')::int <= 0 or (v_b->>'max_discharge_power_w')::int <= 0 then
raise exception 'Invalid battery effective limits for site_id=%', p_site_id;
end if;
select jsonb_build_object(
'rated_heating_power_w', greatest(coalesce(hp.rated_heating_power_w, 8000), 0)::int,
'tuv_min_temp_c', coalesce(hp.tuv_min_temp_c, 45)::numeric,
'tuv_target_temp_c', coalesce(hp.tuv_target_temp_c, 55)::numeric
)
into v_hp
from ems.asset_heat_pump hp
where hp.site_id = p_site_id
order by hp.id
limit 1;
if v_hp is null then
v_hp := jsonb_build_object(
'rated_heating_power_w', 0,
'tuv_min_temp_c', 0,
'tuv_target_temp_c', 55
);
end if;
select jsonb_build_object(
'max_import_power_w', sgc.max_import_power_w,
'max_export_power_w', sgc.max_export_power_w
)
into v_grid
from ems.site_grid_connection sgc
where sgc.site_id = p_site_id
order by sgc.id
limit 1;
if v_grid is null then
raise exception 'No site_grid_connection for site_id=%', p_site_id;
end if;
select coalesce(
jsonb_agg(
jsonb_build_object(
'max_charge_power_w', v.max_charge_power_w,
'battery_capacity_kwh', v.battery_capacity_kwh,
'default_target_soc_pct', v.default_target_soc_pct
)
order by ch.code
),
'[]'::jsonb
)
into v_veh
from ems.asset_vehicle v
join ems.asset_ev_charger ch on ch.id = v.default_charger_id
where v.site_id = p_site_id
and ch.code in ('ev-charger-1', 'ev-charger-2');
v_ev := jsonb_build_array(
(
select case
when es.target_deadline is null then null::jsonb
when v.battery_capacity_kwh is null then null::jsonb
when es.soc_at_connect_pct is null then null::jsonb
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb
when greatest(
0,
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
) <= 0 then null::jsonb
else jsonb_build_object(
'target_deadline', es.target_deadline,
'energy_needed_wh', greatest(
0,
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
)
)
end
from ems.ev_session es
join ems.asset_ev_charger ch on ch.id = es.charger_id
left join ems.asset_vehicle v on v.id = es.vehicle_id
where es.site_id = p_site_id
and es.session_end is null
and ch.code = 'ev-charger-1'
limit 1
),
(
select case
when es.target_deadline is null then null::jsonb
when v.battery_capacity_kwh is null then null::jsonb
when es.soc_at_connect_pct is null then null::jsonb
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb
when greatest(
0,
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
) <= 0 then null::jsonb
else jsonb_build_object(
'target_deadline', es.target_deadline,
'energy_needed_wh', greatest(
0,
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
)
)
end
from ems.ev_session es
join ems.asset_ev_charger ch on ch.id = es.charger_id
left join ems.asset_vehicle v on v.id = es.vehicle_id
where es.site_id = p_site_id
and es.session_end is null
and ch.code = 'ev-charger-2'
limit 1
)
);
select ti.battery_soc_percent
into v_soc_pct
from ems.telemetry_inverter ti
where ti.site_id = p_site_id
order by ti.measured_at desc
limit 1;
if v_soc_pct is null then
v_soc_wh := v_uc * 0.5;
else
v_soc_wh := v_soc_pct::numeric / 100.0 * v_uc;
end if;
v_soc_wh := greatest(v_min_soc_wh, least(v_soc_wh, v_soc_max_wh));
select thp.tuv_tank_temp_c
into v_tuv
from ems.telemetry_heat_pump thp
where thp.site_id = p_site_id
order by thp.measured_at desc
limit 1;
v_tuv := coalesce(v_tuv::numeric, 50::numeric);
select coalesce(
jsonb_agg(
jsonb_build_object(
'dow', tu.day_of_week,
'hour', tu.hour_of_day,
'delta', tu.avg_temp_delta_c
)
),
'[]'::jsonb
)
into v_tuv_stats
from ems.tuv_usage_stats tu
where tu.site_id = p_site_id;
return jsonb_build_object(
'operating_mode', v_mode,
'battery', v_b,
'heat_pump', v_hp,
'grid', v_grid,
'vehicles', v_veh,
'ev_sessions', v_ev,
'soc_wh', v_soc_wh,
'tuv_temp', v_tuv,
'tuv_delta_stats', v_tuv_stats,
'planning_config', coalesce(
(
select pc.config
from ems.planning_config pc
where pc.site_id = p_site_id
limit 1
),
'{}'::jsonb
)
);
end;
$fn$;
comment on function ems.fn_planning_site_context(int) is
'Kontext pro planning_engine / LP (bez samotného solveru).';

View File

@@ -0,0 +1,20 @@
-- začátek aktuálního (+offset) 15min slotu v Europe/Prague jako timestamptz (UTC instants)
create or replace function ems.fn_planning_slot_boundary_prague(p_offset_slots int default 0)
returns timestamptz
language sql
stable
as $fn$
select (
(date_trunc('day', loc.ts)
+ make_interval(
hours => extract(hour from loc.ts)::int,
mins => (floor(extract(minute from loc.ts) / 15) * 15)::int
)
)::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;
$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.';

View File

@@ -0,0 +1,74 @@
-- korekční faktor FVE forecast vs telemetrie (stejná logika jako compute_correction_factor v Pythonu)
create or replace function ems.fn_pv_forecast_correction_factor(
p_site_id int,
p_window_start timestamptz,
p_window_end timestamptz,
p_min_clamp numeric default 0.5,
p_max_clamp numeric default 1.5
)
returns jsonb
language plpgsql
stable
as $fn$
declare
v_actual numeric;
v_forecast numeric;
v_raw numeric;
v_factor numeric := 1.0;
v_clamped boolean := false;
begin
select coalesce(sum(ti.pv_power_w) * 0.25 / 1000.0, 0)
into v_actual
from ems.telemetry_inverter ti
where ti.site_id = p_site_id
and ti.measured_at >= p_window_start
and ti.measured_at < p_window_end;
select coalesce(sum(fpi.power_w) * 0.25 / 1000.0, 0)
into v_forecast
from ems.forecast_pv_interval fpi
join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
where fpr.site_id = p_site_id
and fpi.interval_start >= p_window_start
and fpi.interval_start < p_window_end
and fpr.status = 'ok'
and fpr.id = (
select fpr2.id
from ems.forecast_pv_run fpr2
where fpr2.site_id = p_site_id
and fpr2.status = 'ok'
and fpr2.created_at <= p_window_start
order by fpr2.created_at desc
limit 1
);
if v_forecast < 0.1 or coalesce(v_actual, 0) < 0.05 then
return jsonb_build_object(
'correction_factor', 1.0,
'raw_factor', null,
'clamped', false,
'reason', 'insufficient_data',
'actual_pv_wh', coalesce(v_actual, 0) * 1000,
'forecast_pv_wh', coalesce(v_forecast, 0) * 1000,
'window_start', p_window_start,
'window_end', p_window_end
);
end if;
v_raw := v_actual / nullif(v_forecast, 0);
v_factor := greatest(p_min_clamp, least(p_max_clamp, v_raw));
v_clamped := (v_factor <> v_raw);
return jsonb_build_object(
'correction_factor', v_factor,
'raw_factor', v_raw,
'clamped', v_clamped,
'reason', 'ok',
'actual_pv_wh', v_actual * 1000,
'forecast_pv_wh', v_forecast * 1000,
'window_start', p_window_start,
'window_end', p_window_end
);
end;
$fn$;

View File

@@ -0,0 +1,36 @@
-- jedno volání: předchozí režim, fn_set_mode, nový režim, site.code
create or replace function ems.fn_set_mode_with_context(
p_site_id int,
p_mode_code text,
p_activated_by text default 'system',
p_valid_until timestamptz default null,
p_notes text default null
)
returns jsonb
language plpgsql
as $fn$
declare
v_prev text;
v_out text;
v_code text;
begin
select mode_code into v_prev
from ems.site_operating_mode
where site_id = p_site_id;
v_out := ems.fn_set_mode(
p_site_id, p_mode_code, p_activated_by, p_valid_until, p_notes
);
select s.code into v_code
from ems.site s
where s.id = p_site_id;
return jsonb_build_object(
'previous_mode', v_prev,
'new_mode', v_out,
'site_code', v_code
);
end;
$fn$;

View File

@@ -0,0 +1,265 @@
create or replace function ems.fn_site_configuration(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select
case
when not exists (select 1 from ems.site s0 where s0.id = p_site_id) then null::jsonb
else jsonb_build_object(
'site',
(
select to_jsonb(x)
from (
select
s.id,
s.code,
s.name,
s.timezone,
s.latitude::float8 as latitude,
s.longitude::float8 as longitude,
s.active,
s.notes,
s.created_at
from ems.site s
where s.id = p_site_id
) x
),
'grid_connection',
(
select to_jsonb(g.*)
from ems.site_grid_connection g
where g.site_id = p_site_id
limit 1
),
'market_config',
(
select to_jsonb(m.*)
from ems.site_market_config m
where m.site_id = p_site_id
and m.valid_from <= now()
and (m.valid_to is null or m.valid_to > now())
order by m.valid_from desc
limit 1
),
'market_config_note',
'Zelený bonus za výrobu je u FVE polí (asset_pv_array), ne v obchodní konfiguraci.',
'endpoints',
coalesce(
(
select jsonb_agg(
to_jsonb(e.*)
|| jsonb_build_object(
'auth_reference',
case
when e.auth_reference is null or btrim(e.auth_reference::text) = '' then null::text
when length(btrim(e.auth_reference::text)) <= 4 then 'nastaveno'::text
else concat('', right(btrim(e.auth_reference::text), 2))
end
)
order by e.id
)
from ems.site_endpoint e
where e.site_id = p_site_id
),
'[]'::jsonb
),
'inverters',
coalesce(
(
select jsonb_agg(
(
(
to_jsonb(ai.*)
- 'deye_last_system_time_sync_at'::text
- 'deye_last_system_time_sync_minute'::text
- 'deye_last_tou_inactive_write_prague_date'::text
- 'deye_tou_inactive_signature'::text
)
|| jsonb_build_object(
'endpoint_connection',
(
select ep.host || case
when ep.port is not null then ':' || ep.port::text
else ''
end
from ems.site_endpoint ep
where ep.id = ai.endpoint_id
),
'deye_meta',
case
when jsonb_strip_nulls(
jsonb_build_object(
'deye_last_system_time_sync_at', to_jsonb(ai.deye_last_system_time_sync_at),
'deye_last_system_time_sync_minute', to_jsonb(ai.deye_last_system_time_sync_minute),
'deye_last_tou_inactive_write_prague_date',
to_jsonb(ai.deye_last_tou_inactive_write_prague_date),
'deye_tou_inactive_signature', to_jsonb(ai.deye_tou_inactive_signature)
)
) = '{}'::jsonb then null::jsonb
else jsonb_strip_nulls(
jsonb_build_object(
'deye_last_system_time_sync_at', to_jsonb(ai.deye_last_system_time_sync_at),
'deye_last_system_time_sync_minute', to_jsonb(ai.deye_last_system_time_sync_minute),
'deye_last_tou_inactive_write_prague_date',
to_jsonb(ai.deye_last_tou_inactive_write_prague_date),
'deye_tou_inactive_signature', to_jsonb(ai.deye_tou_inactive_signature)
)
)
end
)
)
order by ai.id
)
from ems.asset_inverter ai
where ai.site_id = p_site_id
),
'[]'::jsonb
),
'batteries',
coalesce(
(
select jsonb_agg(to_jsonb(b.*) order by b.id)
from ems.asset_battery b
where b.site_id = p_site_id
),
'[]'::jsonb
),
'pv_arrays',
coalesce(
(
select jsonb_agg(to_jsonb(p.*) order by p.id)
from ems.asset_pv_array p
where p.site_id = p_site_id
),
'[]'::jsonb
),
'ev_chargers',
coalesce(
(
select jsonb_agg(
to_jsonb(ec.*)
|| jsonb_build_object(
'endpoint_connection',
se.host || case
when se.port is not null then ':' || se.port::text
else ''
end
)
order by ec.id
)
from ems.asset_ev_charger ec
left join ems.site_endpoint se on se.id = ec.endpoint_id
where ec.site_id = p_site_id
),
'[]'::jsonb
),
'vehicles',
coalesce(
(
select jsonb_agg(
to_jsonb(v.*)
|| jsonb_build_object(
'api_reference',
case
when v.api_reference is null or btrim(v.api_reference::text) = '' then null::text
when length(btrim(v.api_reference::text)) <= 4 then 'nastaveno'::text
else concat('', right(btrim(v.api_reference::text), 2))
end
)
order by v.code
)
from ems.asset_vehicle v
where v.site_id = p_site_id
),
'[]'::jsonb
),
'heat_pumps',
coalesce(
(
select jsonb_agg(
to_jsonb(hp.*)
|| jsonb_build_object(
'endpoint_connection',
se.host || case
when se.port is not null then ':' || se.port::text
else ''
end
)
order by hp.id
)
from ems.asset_heat_pump hp
left join ems.site_endpoint se on se.id = hp.endpoint_id
where hp.site_id = p_site_id
),
'[]'::jsonb
),
'operating_mode',
(
select to_jsonb(om)
from (
select
m.mode_code,
m.activated_at,
m.activated_by,
m.valid_until,
m.previous_mode,
m.notes,
d.name as mode_name,
d.description as mode_description,
d.loxone_mode_value,
d.ev_enabled,
d.heat_pump_enabled,
d.battery_mode,
d.grid_mode,
d.is_autonomous
from ems.site_operating_mode m
join ems.operating_mode_def d on d.code = m.mode_code
where m.site_id = p_site_id
) om
),
'active_overrides',
coalesce(
(
select jsonb_agg(to_jsonb(o.*) order by o.valid_from desc)
from (
select *
from ems.site_override so
where so.site_id = p_site_id
and so.valid_from <= now()
and (so.valid_to is null or so.valid_to > now())
order by so.valid_from desc
limit 50
) o
),
'[]'::jsonb
),
'operational',
jsonb_build_object(
'heartbeat_last_seen',
(select hb.last_seen from ems.site_heartbeat hb where hb.site_id = p_site_id limit 1),
'heartbeat_status',
(select hb.status from ems.site_heartbeat hb where hb.site_id = p_site_id limit 1),
'has_active_plan',
exists (
select 1
from ems.planning_run pr
where pr.site_id = p_site_id
and pr.status = 'active'
),
'active_plan_created_at',
(
select pr.created_at
from ems.planning_run pr
where pr.site_id = p_site_id
and pr.status = 'active'
order by pr.created_at desc
limit 1
)
)
)
end;
$fn$;
comment on function ems.fn_site_configuration(int) is
'GET /configuration jako jeden JSON bundle (maskované reference, deye_meta).';

View File

@@ -0,0 +1,28 @@
create or replace function ems.fn_site_effective_prices_day_prague(
p_site_id int,
p_day date
)
returns jsonb
language sql
stable
as $fn$
select coalesce(
jsonb_agg(u.j order by u.interval_start),
'[]'::jsonb
)
from (
select
t.interval_start,
to_jsonb(t) as j
from (
select v.*
from ems.vw_site_effective_price v
where v.site_id = p_site_id
and (v.interval_start at time zone 'Europe/Prague')::date = p_day
order by v.interval_start
) t
) u;
$fn$;
comment on function ems.fn_site_effective_prices_day_prague(int, date) is
'Efektivní ceny pro kalendářní den v TZ Praha jako pole JSON řádků view.';

View File

@@ -0,0 +1,158 @@
create or replace function ems.fn_site_full_status(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select
case
when not exists (select 1 from ems.site s0 where s0.id = p_site_id) then
jsonb_build_object('error', 'not_found')
else jsonb_build_object(
'site',
(
select jsonb_build_object(
'id', s.id,
'code', s.code,
'name', s.name,
'timezone', s.timezone
)
from ems.site s
where s.id = p_site_id
),
'operating_mode',
(
select jsonb_build_object(
'mode_code', m.mode_code,
'mode_name', d.name,
'activated_at', m.activated_at,
'activated_by', m.activated_by
)
from ems.site_operating_mode m
join ems.operating_mode_def d on d.code = m.mode_code
where m.site_id = p_site_id
),
'heartbeat',
(
select jsonb_build_object(
'last_seen', hb.last_seen,
'status', hb.status
)
from ems.site_heartbeat hb
where hb.site_id = p_site_id
),
'inverter_latest',
(
select to_jsonb(li.*)
from ems.vw_latest_inverter li
where li.site_id = p_site_id
order by li.measured_at desc nulls last
limit 1
),
'ev_chargers',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'code', v.code,
'status', v.status,
'power_w', v.power_w,
'measured_at', v.measured_at
)
order by v.measured_at desc nulls last
)
from (
select distinct on (evc.charger_id)
evc.charger_code as code,
evc.status,
evc.power_w,
evc.measured_at
from ems.vw_latest_ev_charger evc
where evc.site_id = p_site_id
order by evc.charger_id, evc.measured_at desc nulls last
) v
),
'[]'::jsonb
),
'heat_pump_latest',
(
select jsonb_build_object(
'power_w', hp.power_w,
'tuv_tank_temp_c', hp.tuv_tank_temp_c,
'measured_at', hp.measured_at
)
from ems.vw_latest_heat_pump hp
where hp.site_id = p_site_id
order by hp.measured_at desc nulls last
limit 1
),
'battery_limits',
(
select jsonb_build_object(
'reserve_soc', min(ab.reserve_soc_percent)::float,
'min_soc', min(ab.min_soc_percent)::float
)
from ems.asset_battery ab
where ab.site_id = p_site_id
),
'active_plan',
(
select jsonb_build_object(
'id', pr.id,
'created_at', pr.created_at
)
from ems.planning_run pr
where pr.site_id = p_site_id
and pr.status = 'active'
order by pr.created_at desc
limit 1
),
'planning_intervals',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'interval_start', pi.interval_start,
'battery_setpoint_w', pi.battery_setpoint_w,
'load_baseline_w', pi.load_baseline_w,
'pv_a_forecast_raw_w', pi.pv_a_forecast_raw_w,
'pv_b_forecast_raw_w', pi.pv_b_forecast_raw_w,
'pv_a_forecast_solver_w', pi.pv_a_forecast_solver_w,
'pv_b_forecast_solver_w', pi.pv_b_forecast_solver_w
)
order by pi.interval_start
)
from ems.planning_interval pi
where pi.run_id = (
select pr2.id
from ems.planning_run pr2
where pr2.site_id = p_site_id
and pr2.status = 'active'
order by pr2.created_at desc
limit 1
)
),
'[]'::jsonb
),
'tomorrow_price_slot_count',
(
select count(*)::int
from ems.vw_site_effective_price vep
where vep.site_id = p_site_id
and (vep.interval_start at time zone coalesce(
nullif(trim((select s2.timezone from ems.site s2 where s2.id = p_site_id)), ''),
'Europe/Prague'
))::date = (
(
current_timestamp at time zone coalesce(
nullif(trim((select s3.timezone from ems.site s3 where s3.id = p_site_id)), ''),
'Europe/Prague'
)
)::date + 1
)
)
)
end;
$fn$;
comment on function ems.fn_site_full_status(int) is
'Raw data pro GET /status/full (věk telemetrie a alerty dopočítá Python).';

View File

@@ -0,0 +1,138 @@
create or replace function ems.fn_site_notifications_context(p_site_id int)
returns jsonb
language sql
stable
as $fn$
select
case
when not exists (select 1 from ems.site s0 where s0.id = p_site_id) then
jsonb_build_object('error', 'not_found')
else jsonb_build_object(
'timezone',
coalesce(
nullif(trim((select s.timezone from ems.site s where s.id = p_site_id)), ''),
'Europe/Prague'
),
'mode_code',
(select m.mode_code from ems.site_operating_mode m where m.site_id = p_site_id),
'has_plan',
exists (
select 1
from ems.planning_run pr
where pr.site_id = p_site_id
and pr.status = 'active'
),
'tomorrow_slots',
(
select count(*)::int
from ems.vw_site_effective_price v
where v.site_id = p_site_id
and (v.interval_start at time zone coalesce(
nullif(trim((select s2.timezone from ems.site s2 where s2.id = p_site_id)), ''),
'Europe/Prague'
))::date = (
(
current_timestamp at time zone coalesce(
nullif(trim((select s3.timezone from ems.site s3 where s3.id = p_site_id)), ''),
'Europe/Prague'
)
)::date + 1
)
),
'reserve_soc',
(select min(ab.reserve_soc_percent)::float from ems.asset_battery ab where ab.site_id = p_site_id),
'min_soc',
(select min(ab.min_soc_percent)::float from ems.asset_battery ab where ab.site_id = p_site_id),
'soc_pct',
(select li.battery_soc_percent::float from ems.vw_latest_inverter li where li.site_id = p_site_id order by li.measured_at desc nulls last limit 1),
'inv_measured_at',
(select li.measured_at from ems.vw_latest_inverter li where li.site_id = p_site_id order by li.measured_at desc nulls last limit 1),
'hb_last_seen',
(select hb.last_seen from ems.site_heartbeat hb where hb.site_id = p_site_id limit 1),
'price_slots',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'interval_start', v.interval_start,
'effective_buy_price_czk_kwh', v.effective_buy_price_czk_kwh,
'effective_sell_price_czk_kwh', v.effective_sell_price_czk_kwh
)
order by v.interval_start
)
from ems.vw_site_effective_price v
where v.site_id = p_site_id
and v.interval_start >= now()
and v.interval_start < now() + interval '48 hours'
),
'[]'::jsonb
),
'avg_buy',
(
select avg(v.effective_buy_price_czk_kwh)::float
from ems.vw_site_effective_price v
where v.site_id = p_site_id
and v.interval_start::date in (current_date, current_date + 1)
),
'usable_wh',
(
select coalesce(sum(ab.usable_capacity_wh), 0)::float
from ems.asset_battery ab
join ems.asset_inverter ai on ai.id = ab.inverter_id
where ai.site_id = p_site_id
),
'ev_sessions',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'id', es.id,
'charger_id', es.charger_id,
'energy_delivered_wh', es.energy_delivered_wh,
'target_soc_pct', es.target_soc_pct,
'session_start', es.session_start,
'soc_at_connect_pct', es.soc_at_connect_pct,
'battery_capacity_kwh', coalesce(av_id.battery_capacity_kwh, av_def.battery_capacity_kwh),
'make', coalesce(av_id.make, av_def.make),
'model', coalesce(av_id.model, av_def.model),
'default_target_soc_pct', coalesce(av_id.default_target_soc_pct, av_def.default_target_soc_pct),
'charger_code', ac.code
)
order by es.id
)
from ems.ev_session es
join ems.asset_ev_charger ac on ac.id = es.charger_id
left join ems.asset_vehicle av_id on av_id.id = es.vehicle_id
left join ems.asset_vehicle av_def
on av_def.default_charger_id = ac.id
and es.vehicle_id is null
where es.site_id = p_site_id
and es.session_end is null
),
'[]'::jsonb
),
'neg_windows',
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'predicted_date', p.predicted_date,
'window_start_hour', p.window_start_hour,
'window_end_hour', p.window_end_hour,
'probability_pct', p.probability_pct
)
order by p.predicted_date, p.window_start_hour
)
from ems.predicted_negative_price_window p
where p.site_id = p_site_id
and p.predicted_date between current_date and current_date + 2
and p.probability_pct >= 50
),
'[]'::jsonb
)
)
end;
$fn$;
comment on function ems.fn_site_notifications_context(int) is
'Vstupy pro build_smart_notifications + infra pravidla (GET /notifications).';

View File

@@ -0,0 +1,35 @@
create or replace function ems.fn_telemetry_ev_charger_sample(
p_site_id int,
p_charger_id int,
p_measured_at timestamptz,
p_connector_id int,
p_status text,
p_power_w int,
p_energy_kwh double precision
)
returns void
language sql
as $fn$
insert into ems.telemetry_ev_charger (
site_id,
charger_id,
measured_at,
connector_id,
status,
power_w,
energy_kwh
)
values (
p_site_id,
p_charger_id,
p_measured_at,
p_connector_id,
p_status,
p_power_w,
p_energy_kwh
)
on conflict (charger_id, connector_id, measured_at) do nothing;
$fn$;
comment on function ems.fn_telemetry_ev_charger_sample is
'Insert telemetrie nabíječky EV (placeholder Modbus).';

View File

@@ -0,0 +1,38 @@
create or replace function ems.fn_telemetry_heat_pump_sample(
p_site_id int,
p_heat_pump_id int,
p_measured_at timestamptz,
p_power_w int,
p_outdoor_temp_c double precision,
p_water_outlet_temp_c double precision,
p_tuv_tank_temp_c double precision,
p_operating_mode text
)
returns void
language sql
as $fn$
insert into ems.telemetry_heat_pump (
site_id,
heat_pump_id,
measured_at,
power_w,
outdoor_temp_c,
water_outlet_temp_c,
tuv_tank_temp_c,
operating_mode
)
values (
p_site_id,
p_heat_pump_id,
p_measured_at,
p_power_w,
p_outdoor_temp_c,
p_water_outlet_temp_c,
p_tuv_tank_temp_c,
p_operating_mode
)
on conflict (heat_pump_id, measured_at) do nothing;
$fn$;
comment on function ems.fn_telemetry_heat_pump_sample is
'Insert telemetrie TČ (placeholder Modbus).';

View File

@@ -0,0 +1,62 @@
create or replace function ems.fn_telemetry_inverter_sample(
p_site_id int,
p_inverter_id int,
p_measured_at timestamptz,
p_pv_power_w int,
p_pv1_power_w int,
p_pv2_power_w int,
p_gen_port_power_w int,
p_battery_soc_percent double precision,
p_battery_power_w int,
p_batt_charge_today_wh int,
p_batt_discharge_today_wh int,
p_grid_power_w int,
p_load_power_w int,
p_grid_import_total_wh bigint,
p_grid_export_total_wh bigint,
p_run_state int
)
returns void
language sql
as $fn$
insert into ems.telemetry_inverter (
site_id,
inverter_id,
measured_at,
pv_power_w,
pv1_power_w,
pv2_power_w,
gen_port_power_w,
battery_soc_percent,
battery_power_w,
batt_charge_today_wh,
batt_discharge_today_wh,
grid_power_w,
load_power_w,
grid_import_total_wh,
grid_export_total_wh,
run_state
)
values (
p_site_id,
p_inverter_id,
p_measured_at,
p_pv_power_w,
p_pv1_power_w,
p_pv2_power_w,
p_gen_port_power_w,
p_battery_soc_percent,
p_battery_power_w,
p_batt_charge_today_wh,
p_batt_discharge_today_wh,
p_grid_power_w,
p_load_power_w,
p_grid_import_total_wh,
p_grid_export_total_wh,
p_run_state
)
on conflict (inverter_id, measured_at) do nothing;
$fn$;
comment on function ems.fn_telemetry_inverter_sample is
'Insert jednoho vzorku telemetrie střídače (telemetry_collector).';