prepsani s opusem dle planu
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-24 22:44:21 +02:00
parent 2d021b15c3
commit 8bef1c6da6
11 changed files with 720 additions and 16 deletions

View File

@@ -0,0 +1,25 @@
-- Rozsireni ekonomickeho rozpadu planu (audit transparence: cashflow vs arbitraz vs penalizace vs bonus).
-- Drive byl v planning_interval jen expected_cost_czk = gi*buy - ge*sell (bez penalizaci a bez acquisition).
alter table ems.planning_interval
add column if not exists cashflow_czk numeric,
add column if not exists battery_arbitrage_czk numeric,
add column if not exists penalty_czk numeric,
add column if not exists green_bonus_czk numeric;
comment on column ems.planning_interval.cashflow_czk is
'Net penezni tok ze site v slotu: gi*buy_price*h - ge*sell_price*h (Kc). '
'Kladne = platba EMS, zaporne = prijem. Shodne s expected_cost_czk (ponechano jako legacy).';
comment on column ems.planning_interval.battery_arbitrage_czk is
'Marze z exportu baterie do site: ge_bat * (sell_price - acquisition_used) * h (Kc). '
'Kladne = zisk arbitraze (cena prodeje > vazeny nakup zasoby).';
comment on column ems.planning_interval.penalty_czk is
'Soucet penalizaci v slotu (Kc): shortfall (peak_export, pv_charge, neg_sell_dump) + safety_deficit '
'+ curtailment + commitment. Neviditelne v cashflow_czk, ale solver je optimalizuje.';
comment on column ems.planning_interval.green_bonus_czk is
'Planovany zeleny bonus z vyroby poli s active green_bonus_czk_kwh (Kc). '
'pv_*_forecast_solver_w * green_bonus_czk_kwh * h, scitano pres vsechna pole se zelenym bonusem '
'platnym v slotu (ems.asset_pv_array.green_bonus_*).';

View File

@@ -24,6 +24,7 @@ DECLARE
v_ev JSONB;
v_fc JSONB;
v_ov JSONB;
v_econ JSONB;
BEGIN
IF p_site_id IS NULL THEN
RETURN jsonb_build_object('error', 'site_id_required');
@@ -89,6 +90,49 @@ BEGIN
AND pi.interval_start < v_win_end
) t;
select jsonb_build_object(
'window_start_utc', v_slot,
'window_end_utc', v_win_end,
'total_import_kwh', coalesce(sum(
case when pi.grid_setpoint_w > 0
then pi.grid_setpoint_w * 0.25 / 1000.0 else 0 end
), 0),
'total_export_kwh', coalesce(sum(
case when pi.grid_setpoint_w < 0
then -pi.grid_setpoint_w * 0.25 / 1000.0 else 0 end
), 0),
'total_buy_cost_czk', coalesce(sum(
case when pi.grid_setpoint_w > 0
then pi.grid_setpoint_w * pi.effective_buy_price * 0.25 / 1000.0
else 0 end
), 0),
'total_sell_revenue_czk', coalesce(sum(
case when pi.grid_setpoint_w < 0
then -pi.grid_setpoint_w * pi.effective_sell_price * 0.25 / 1000.0
else 0 end
), 0),
'total_cashflow_czk', coalesce(sum(pi.cashflow_czk), 0),
'total_battery_arbitrage_czk', coalesce(sum(pi.battery_arbitrage_czk), 0),
'total_penalty_czk', coalesce(sum(pi.penalty_czk), 0),
'total_green_bonus_czk', coalesce(sum(pi.green_bonus_czk), 0),
'net_economic_czk',
coalesce(-sum(pi.cashflow_czk), 0)
+ coalesce(sum(pi.battery_arbitrage_czk), 0)
- coalesce(sum(pi.penalty_czk), 0)
+ coalesce(sum(pi.green_bonus_czk), 0),
'neg_sell_export_slots', count(*) filter (
where pi.effective_sell_price < 0 and pi.grid_setpoint_w < -500
),
'first_grid_charge_slot_utc', min(pi.interval_start) filter (
where pi.grid_setpoint_w > 500
)
)
into v_econ
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;
SELECT to_jsonb(m.*) || jsonb_build_object('mode_name', d.name)
INTO v_mode
FROM ems.site_operating_mode m
@@ -170,6 +214,7 @@ BEGIN
'ev_sessions_open', v_ev,
'forecast_correction_log_recent', v_fc,
'site_overrides_active_in_window', v_ov,
'economics_summary', v_econ,
'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.',

View File

@@ -68,7 +68,11 @@ begin
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
pv_a_forecast_solver_w, pv_b_forecast_solver_w,
cashflow_czk,
battery_arbitrage_czk,
penalty_czk,
green_bonus_czk
) values (
v_run_id,
(r.value->>'interval_start')::timestamptz,
@@ -94,7 +98,11 @@ begin
(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
(r.value->>'pv_b_forecast_solver_w')::int,
nullif(r.value->>'cashflow_czk', '')::numeric,
nullif(r.value->>'battery_arbitrage_czk', '')::numeric,
nullif(r.value->>'penalty_czk', '')::numeric,
nullif(r.value->>'green_bonus_czk', '')::numeric
);
else
insert into ems.planning_interval (
@@ -109,7 +117,11 @@ begin
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
effective_buy_price, effective_sell_price,
is_predicted_price
is_predicted_price,
cashflow_czk,
battery_arbitrage_czk,
penalty_czk,
green_bonus_czk
) values (
v_run_id,
(r.value->>'interval_start')::timestamptz,
@@ -130,7 +142,11 @@ begin
(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)
coalesce((r.value->>'is_predicted_price')::boolean, false),
nullif(r.value->>'cashflow_czk', '')::numeric,
nullif(r.value->>'battery_arbitrage_czk', '')::numeric,
nullif(r.value->>'penalty_czk', '')::numeric,
nullif(r.value->>'green_bonus_czk', '')::numeric
);
end if;
end loop;

View File

@@ -32,7 +32,11 @@ returns table (
future_sell_opportunity_czk_kwh numeric,
is_daytime_pv_surplus_slot boolean,
charge_acquisition_buy_czk_kwh numeric,
charge_acquisition_cutoff_at timestamptz
charge_acquisition_cutoff_at timestamptz,
min_buy_before_cutoff_czk_kwh numeric,
pv_charge_wh_ahead numeric,
neg_buy_wh_ahead numeric,
grid_charge_suppressed_reason text
)
language plpgsql
volatile
@@ -92,6 +96,14 @@ declare
v_est_pv_cost numeric;
v_export_window_start timestamptz;
v_plan_day_prague date;
v_acq_v2 numeric;
v_acq_prev numeric := -999;
v_iter int;
v_affected int;
v_cum_allowed numeric;
v_pv_ahead_total numeric;
v_target_deficit numeric;
r_unlock record;
begin
v_plan_day_prague := (p_from at time zone 'Europe/Prague')::date;
drop table if exists _ems_plan_slot_wk;
@@ -310,7 +322,11 @@ begin
add column if not exists buy_min_next_n numeric,
add column if not exists store_score numeric,
add column if not exists allow_grid_charge boolean default false,
add column if not exists export_window_start_at timestamptz;
add column if not exists export_window_start_at timestamptz,
add column if not exists min_buy_before_cutoff numeric,
add column if not exists pv_charge_wh_ahead numeric,
add column if not exists neg_buy_wh_ahead numeric,
add column if not exists grid_charge_suppressed_reason text;
-- První výkupní okno **per kalendářní den** (Prague). Globální min přes dny by
-- zablokoval NT grid nabíjení (včerejší večerní peak → dnešní 0006 už „po okně“).
@@ -512,6 +528,127 @@ begin
update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true
where wk.buy_price < 0;
-- Self-konzistentni filtr vrstvy B (spot): vyloucit drahe grid sloty, pokud PV / buy<0
-- alternativa pokryje deficit SoC pred prvnim exportem.
update _ems_plan_slot_wk wk
set pv_charge_wh_ahead = sub.pv_wh_ahead,
neg_buy_wh_ahead = sub.neg_buy_wh_ahead,
min_buy_before_cutoff = sub.min_buy_ahead
from (
select
wk.slot_ord,
least(
coalesce(sum(
case
when w2.slot_ord >= wk.slot_ord
and (v_first_neg_sell_ord is null or w2.slot_ord < v_first_neg_sell_ord)
and w2.pv_surplus_w > 0
and (w2.sell_price < 0 or w2.buy_price < 0)
then least(w2.pv_surplus_w::numeric, v_max_charge_w)
* v_charge_eff * 0.25
else 0
end
), 0),
v_soc_max_wh - p_current_soc_wh
) as pv_wh_ahead,
coalesce(sum(
case
when w2.slot_ord >= wk.slot_ord
and (v_first_neg_sell_ord is null or w2.slot_ord < v_first_neg_sell_ord)
and w2.buy_price < 0
then v_per_slot_charge_wh
else 0
end
), 0) as neg_buy_wh_ahead,
min(
case
when w2.slot_ord > wk.slot_ord
and (v_first_neg_sell_ord is null or w2.slot_ord < v_first_neg_sell_ord)
then w2.buy_price
else null
end
) as min_buy_ahead
from _ems_plan_slot_wk wk
cross join _ems_plan_slot_wk w2
group by wk.slot_ord
) sub
where wk.slot_ord = sub.slot_ord;
v_iter := 0;
loop
v_iter := v_iter + 1;
exit when v_iter > 5;
select coalesce(
sum(wk.buy_price * v_per_slot_charge_wh)
filter (
where wk.allow_grid_charge
and (v_first_neg_sell_ord is null or wk.slot_ord < v_first_neg_sell_ord)
)
/ nullif(sum(v_per_slot_charge_wh)
filter (
where wk.allow_grid_charge
and (v_first_neg_sell_ord is null or wk.slot_ord < v_first_neg_sell_ord)
), 0),
v_ref_buy_czk_kwh
)
into v_acq_v2
from _ems_plan_slot_wk wk;
exit when abs(v_acq_v2 - v_acq_prev) < 0.05;
v_acq_prev := v_acq_v2;
update _ems_plan_slot_wk wk
set allow_charge = false,
allow_grid_charge = false,
grid_charge_suppressed_reason =
case
when wk.pv_charge_wh_ahead + wk.neg_buy_wh_ahead
>= greatest(0, v_soc_max_wh - p_current_soc_wh) * 0.6
then 'cheaper_pv_ahead'
else 'cheaper_neg_buy_ahead'
end
where wk.allow_grid_charge
and wk.buy_price > v_acq_v2 - v_degrad_czk_kwh
and wk.buy_price >= 0
and (
wk.pv_charge_wh_ahead + wk.neg_buy_wh_ahead
>= greatest(0, v_soc_max_wh - p_current_soc_wh) * 0.6
);
get diagnostics v_affected = row_count;
exit when v_affected = 0;
end loop;
select coalesce(sum(v_per_slot_charge_wh) filter (where wk.allow_grid_charge), 0)
into v_cum_allowed
from _ems_plan_slot_wk wk;
select coalesce(min(wk.pv_charge_wh_ahead), 0)
into v_pv_ahead_total
from _ems_plan_slot_wk wk
where wk.slot_ord = 0;
v_target_deficit := greatest(0, v_soc_max_wh - p_current_soc_wh) - v_pv_ahead_total;
if v_cum_allowed < v_target_deficit * 0.6 then
for r_unlock in
select wk.slot_ord
from _ems_plan_slot_wk wk
where wk.grid_charge_suppressed_reason is not null
and wk.buy_price < 2 * v_acq_v2
order by wk.buy_price, wk.slot_ord
loop
update _ems_plan_slot_wk wk
set allow_charge = true,
allow_grid_charge = true,
grid_charge_suppressed_reason = 'safety_failsafe_unlock'
where wk.slot_ord = r_unlock.slot_ord;
v_cum_allowed := v_cum_allowed + v_per_slot_charge_wh;
exit when v_cum_allowed >= v_target_deficit * 0.6;
end loop;
end if;
elsif exists (
select 1
from _ems_plan_slot_wk w2
@@ -925,7 +1062,11 @@ begin
(
extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 18
and w.pv_surplus_w > 0
) as is_daytime_pv_surplus_slot
) as is_daytime_pv_surplus_slot,
w.min_buy_before_cutoff as min_buy_before_cutoff_czk_kwh,
coalesce(w.pv_charge_wh_ahead, 0) as pv_charge_wh_ahead,
coalesce(w.neg_buy_wh_ahead, 0) as neg_buy_wh_ahead,
w.grid_charge_suppressed_reason
from _ems_plan_slot_wk w
cross join night_tot nt
)
@@ -949,7 +1090,11 @@ begin
e.future_sell_opportunity_czk_kwh,
e.is_daytime_pv_surplus_slot,
v_charge_acquisition as charge_acquisition_buy_czk_kwh,
v_acquisition_cutoff as charge_acquisition_cutoff_at
v_acquisition_cutoff as charge_acquisition_cutoff_at,
e.min_buy_before_cutoff_czk_kwh,
e.pv_charge_wh_ahead,
e.neg_buy_wh_ahead,
e.grid_charge_suppressed_reason
from enriched e
order by e.slot_ord;
end;