-- sloty pro LP: ceny, forecast, baseline, EV připojení + masky allow_charge / allow_discharge_export
-- DROP: změna RETURNS TABLE (nové sloupce) — CREATE OR REPLACE na rozdílný row type v PG neprojde.
-- Musí být plná signatura (pg_proc ukládá int jako integer); DROP bez () funkci se směrem nemaže.

drop function if exists ems.fn_load_planning_slots_full(
  integer, timestamp with time zone, timestamp with time zone, numeric
);

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,
  night_baseload_target_wh numeric,
  night_baseload_buffer_wh numeric,
  safety_soc_target_wh numeric,
  future_avoided_buy_czk_kwh numeric,
  future_sell_opportunity_czk_kwh numeric,
  is_daytime_pv_surplus_slot boolean,
  charge_acquisition_buy_czk_kwh numeric,
  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,
  charge_target_wh numeric,
  pre_window_wh numeric,
  in_window_wh numeric,
  charge_slot_wh numeric,
  charge_cum_wh numeric,
  charge_layer text,
  charge_slot_reason text
)
language plpgsql
volatile
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;
  v_n_am int;
  v_n_pm int;
  v_chg_am_wh numeric;
  v_chg_pm_wh numeric;
  v_reserve_wh numeric;
  v_daytime_en boolean;
  v_night_buf_pct numeric;
  v_degrad_czk_kwh numeric;
  v_ref_buy_czk_kwh numeric;
  v_ref_buy_am_czk_kwh numeric;
  v_ref_buy_pm_czk_kwh numeric;
  v_purchase_pricing_mode text;
  v_block_export_neg_sell boolean;
  v_lookahead_slots int := 4;
  v_grid_charge_cap_am int;
  v_grid_charge_cap_pm int;
  v_buy_lookahead_eps numeric := 0.05;
  v_buy_charge_band_czk_kwh numeric := 0.40;
  v_grid_filled_wh numeric := 0;
  v_pv_layer_cap_wh numeric;
  v_grid_slots_am int := 0;
  v_grid_slots_pm int := 0;
  v_acquisition_cutoff timestamptz;
  v_first_neg_sell_ord int;
  v_first_neg_prague_date date;
  v_pre_neg_peak_sell_ord int;
  v_preneg_export_min_soc_wh numeric;
  v_morning_zone_peak_sell numeric;
  v_morning_preneg_start_hour int := 5;
  v_morning_preneg_end_hour int := 11;
  v_evening_peak_start_hour int := 17;
  v_charge_acquisition numeric;
  v_est_grid_wh numeric;
  v_est_pv_wh numeric;
  v_est_grid_cost numeric;
  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;
  v_charge_target_wh numeric;
  v_pre_window_wh numeric := 0;
  v_in_window_wh numeric := 0;
  v_charge_reliability_factor numeric := 0.85;
  r_unlock record;
begin
  v_plan_day_prague := (p_from at time zone 'Europe/Prague')::date;
  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
  ),
  pv_canon as (
    select
      r.interval_start,
      coalesce(r.pv_a_forecast_canonical_w, 0)::int as pv_a_forecast_w,
      coalesce(r.pv_b_forecast_canonical_w, 0)::int as pv_b_forecast_w
    from jsonb_to_recordset(
      ems.fn_forecast_pv_slots_range_canonical_ab(
        p_site_id,
        p_from,
        p_to,
        now(),
        greatest(p_from, now() - interval '120 days'),
        now()
      )
    ) as r(
      interval_start timestamptz,
      pv_a_forecast_canonical_w bigint,
      pv_b_forecast_canonical_w bigint
    )
  )
  select
    (row_number() over (order by s.interval_start) - 1)::int 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(pv.pv_a_forecast_w, 0)::int as pv_a_forecast_w,
    coalesce(pv.pv_b_forecast_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(pv.pv_a_forecast_w, 0) + coalesce(pv.pv_b_forecast_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 pv_canon pv on pv.interval_start = s.interval_start
  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 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,
    (coalesce(ab.planner_max_soc_percent, 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),
    (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
    coalesce(ab.planner_daytime_charge_target_enabled, true),
    coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric),
    coalesce(ab.degradation_cost_czk_kwh, 0.15::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,
    v_reserve_wh,
    v_daytime_en,
    v_night_buf_pct,
    v_degrad_czk_kwh
  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;

  select coalesce(smc.purchase_pricing_mode, 'spot')
  into v_purchase_pricing_mode
  from ems.site_market_config smc
  where smc.site_id = p_site_id
    and smc.valid_from <= p_from
    and (smc.valid_to is null or smc.valid_to > p_from)
  order by smc.valid_from desc
  limit 1;

  v_purchase_pricing_mode := coalesce(v_purchase_pricing_mode, 'spot');

  select coalesce(sgc.block_export_on_negative_sell, false)
  into v_block_export_neg_sell
  from ems.site_grid_connection sgc
  where sgc.site_id = p_site_id;

  v_block_export_neg_sell := coalesce(v_block_export_neg_sell, false);

  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;
  -- Rozpočet masek: charge_slot_buffer zvětší Wh cíl (do soc_max) i cap počtu grid slotů.
  if v_charge_buf > 0 then
    v_grid_target_wh := least(
      greatest(v_energy_to_fill, 0) * v_charge_buf,
      greatest(v_soc_max_wh - p_current_soc_wh, 0)
    );
  elsif p_current_soc_wh >= v_reserve_wh then
    v_grid_target_wh := greatest(v_energy_to_fill, 0);
  else
    v_grid_target_wh := least(
      greatest(v_energy_to_fill, 0) * v_charge_buf,
      greatest(v_energy_to_fill, 0)
    );
  end if;
  v_discharge_target_wh := v_exportable * v_discharge_buf;
  v_charge_target_wh := greatest(v_grid_target_wh, 0);

  -- Referenční nákup: globální min (export brána) + per AM/PM pás (grid nabíjení).
  select coalesce(min(wk.buy_price), 0)
  into v_ref_buy_czk_kwh
  from _ems_plan_slot_wk wk;

  select coalesce(min(wk.buy_price), v_ref_buy_czk_kwh)
  into v_ref_buy_am_czk_kwh
  from _ems_plan_slot_wk wk
  where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12;

  select coalesce(min(wk.buy_price), v_ref_buy_czk_kwh)
  into v_ref_buy_pm_czk_kwh
  from _ems_plan_slot_wk wk
  where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12;

  -- Lookahead min buy (VT→NT) a store_score pro vrstvu A.
  alter table _ems_plan_slot_wk
    add column if not exists future_sell_lookahead numeric,
    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 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,
    add column if not exists charge_slot_wh numeric default 0,
    add column if not exists charge_cum_wh numeric,
    add column if not exists charge_layer text,
    add column if not exists charge_slot_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í 00–06 už „po okně“).
  update _ems_plan_slot_wk wk
  set export_window_start_at = (
    select min(wx.interval_start)
    from _ems_plan_slot_wk wx
    where (wx.interval_start at time zone 'Europe/Prague')::date
        = (wk.interval_start at time zone 'Europe/Prague')::date
      and wx.sell_price > (
        select coalesce(min(w2.buy_price), wx.buy_price) + v_degrad_czk_kwh
        from _ems_plan_slot_wk w2
        where (w2.interval_start at time zone 'Europe/Prague')::date
          = (wx.interval_start at time zone 'Europe/Prague')::date
      )
  );

  select min(wk.export_window_start_at)
  into v_export_window_start
  from _ems_plan_slot_wk wk;

  update _ems_plan_slot_wk wk
  set
    future_sell_lookahead = coalesce(
      (
        select max(w2.sell_price)
        from _ems_plan_slot_wk w2
        where w2.slot_ord > wk.slot_ord
      ),
      wk.sell_price
    ),
    buy_min_next_n = (
      select min(w2.buy_price)
      from _ems_plan_slot_wk w2
      where w2.slot_ord > wk.slot_ord
        and w2.slot_ord <= wk.slot_ord + v_lookahead_slots
        and (
          wk.export_window_start_at is null
          or w2.interval_start < wk.export_window_start_at
        )
    ),
    store_score =
      coalesce(
        (
          select max(w2.sell_price)
          from _ems_plan_slot_wk w2
          where w2.slot_ord > wk.slot_ord
        ),
        wk.sell_price
      )
      - wk.sell_price
      - greatest(0::numeric, wk.buy_price - wk.sell_price);

  -- AM/PM rozpočet grid charging (Europe/Prague 00–12 vs 12–24).
  -- Chybějící segment dostane celý budget.
  select
    coalesce(count(*) filter (
      where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
    ), 0)::int,
    coalesce(count(*) filter (
      where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
    ), 0)::int
  into v_n_am, v_n_pm
  from _ems_plan_slot_wk wk;

  if v_n_am <= 0 then
    v_chg_am_wh := 0;
    v_chg_pm_wh := v_grid_target_wh;
  elsif v_n_pm <= 0 then
    v_chg_am_wh := v_grid_target_wh;
    v_chg_pm_wh := 0;
  else
    v_chg_am_wh := v_grid_target_wh / 2.0;
    v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh;
  end if;

  if v_per_slot_charge_wh > 0 then
    if v_charge_buf > 0 then
      v_grid_charge_cap_am := greatest(
        1,
        least(24, ceil((v_chg_am_wh / v_per_slot_charge_wh) * v_charge_buf)::int)
      );
      v_grid_charge_cap_pm := greatest(
        1,
        least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int)
      );
    else
      v_grid_charge_cap_am := greatest(
        1,
        least(24, ceil(v_chg_am_wh / v_per_slot_charge_wh)::int)
      );
      v_grid_charge_cap_pm := greatest(
        1,
        least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int)
      );
    end if;
  else
    v_grid_charge_cap_am := 6;
    v_grid_charge_cap_pm := 6;
  end if;

  -- charge mask: grid arbitráž (B) před FVE (A); AM/PM rozpočet Wh zůstává 50/50.
  --
  --   B) Grid ze sítě: spot, nejlevnější sloty v AM/PM do Wh rozpočtu (den plánu → před exportním oknem → buy ASC);
  --      i při pv_surplus>0; cap slotů ∝ rozpočet Wh / per_slot_charge_wh.
  --   A) PV-surplus: store_score DESC, doplní jen zbytek do energy_to_fill po vrstvě B.
  if v_charge_buf <= 0 then
    update _ems_plan_slot_wk wk set allow_charge = true;
  elsif v_energy_to_fill <= 0 then
    update _ems_plan_slot_wk wk set allow_charge = true;
  else
    update _ems_plan_slot_wk wk set allow_charge = false;
    v_grid_filled_wh := 0;

    if v_purchase_pricing_mode <> 'fixed' then
      -- B) Grid AM (dříve než PV, vlastní 50 % rozpočtu Wh)
      v_cum := 0;
      v_grid_slots_am := 0;
      for r_slot in
        select wk.slot_ord
        from _ems_plan_slot_wk wk
        where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
        order by
          case
            when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague
            then 0
            else 1
          end,
          case
            when wk.export_window_start_at is not null
              and wk.interval_start < wk.export_window_start_at
            then 0
            else 1
          end,
          wk.is_predicted_price::int,
          wk.buy_price,
          wk.slot_ord
      loop
        exit when v_cum >= v_chg_am_wh;
        exit when v_per_slot_charge_wh <= 0;
        exit when v_grid_slots_am >= v_grid_charge_cap_am;
        update _ems_plan_slot_wk wk
        set allow_charge = true,
            allow_grid_charge = true,
            charge_layer = 'grid_am',
            charge_slot_reason = 'grid_layer_b',
            charge_slot_wh = v_per_slot_charge_wh,
            charge_cum_wh = v_cum + v_per_slot_charge_wh
        where wk.slot_ord = r_slot.slot_ord;
        v_cum := v_cum + v_per_slot_charge_wh;
        v_grid_slots_am := v_grid_slots_am + 1;
      end loop;
      v_grid_filled_wh := v_grid_filled_wh + v_cum;

      -- PM dostane i nevyčerpaný AM rozpočet (levné NT dopoledne ≠ vyčerpání celého grid_target).
      v_chg_pm_wh := greatest(v_chg_pm_wh, v_grid_target_wh - v_grid_filled_wh);
      if v_per_slot_charge_wh > 0 and v_charge_buf > 0 then
        v_grid_charge_cap_pm := greatest(
          v_grid_charge_cap_pm,
          least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int)
        );
      elsif v_per_slot_charge_wh > 0 then
        v_grid_charge_cap_pm := greatest(
          v_grid_charge_cap_pm,
          least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int)
        );
      end if;

      -- B) Grid PM
      v_cum := 0;
      v_grid_slots_pm := 0;
      for r_slot in
        select wk.slot_ord
        from _ems_plan_slot_wk wk
        where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
        order by
          case
            when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague
            then 0
            else 1
          end,
          case
            when wk.export_window_start_at is not null
              and wk.interval_start < wk.export_window_start_at
            then 0
            else 1
          end,
          wk.is_predicted_price::int,
          wk.buy_price,
          wk.slot_ord
      loop
        exit when v_cum >= v_chg_pm_wh;
        exit when v_per_slot_charge_wh <= 0;
        exit when v_grid_slots_pm >= v_grid_charge_cap_pm;
        update _ems_plan_slot_wk wk
        set allow_charge = true,
            allow_grid_charge = true,
            charge_layer = 'grid_pm',
            charge_slot_reason = 'grid_layer_b',
            charge_slot_wh = v_per_slot_charge_wh,
            charge_cum_wh = v_cum + v_per_slot_charge_wh
        where wk.slot_ord = r_slot.slot_ord;
        v_cum := v_cum + v_per_slot_charge_wh;
        v_grid_slots_pm := v_grid_slots_pm + 1;
      end loop;
      v_grid_filled_wh := v_grid_filled_wh + v_cum;

      -- Spot: záporný buy → grid nabíjení ve všech slotech (maximální arbitráž), mimo AM/PM rozpočet.
      update _ems_plan_slot_wk wk
      set allow_charge = true,
          allow_grid_charge = true,
          charge_layer = coalesce(wk.charge_layer, 'buy_negative'),
          charge_slot_reason = coalesce(wk.charge_slot_reason, 'buy_negative'),
          charge_slot_wh = greatest(wk.charge_slot_wh, v_per_slot_charge_wh)
      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 wk.buy_price >= 0
                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 wk.buy_price >= 0
                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
      where w2.sell_price > w2.buy_price + v_degrad_czk_kwh
    ) then
      -- Fixní nákup (BA81/KV1): grid jen u nejnižšího sell v horizontu (ne slot_ord → 22h za 6 Kč).
      v_cum := 0;
      v_grid_slots_am := 0;
      for r_slot in
        select wk.slot_ord
        from _ems_plan_slot_wk wk
        where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
          and wk.sell_price >= 0
          and wk.sell_price <= (
            select min(w3.sell_price)
            from _ems_plan_slot_wk w3
            where w3.sell_price >= 0
          ) + v_degrad_czk_kwh + 0.05
        order by
          case
            when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague
            then 0
            else 1
          end,
          case
            when wk.export_window_start_at is not null
              and wk.interval_start < wk.export_window_start_at
            then 0
            else 1
          end,
          wk.is_predicted_price::int,
          wk.sell_price asc,
          wk.slot_ord
      loop
        exit when v_cum >= v_chg_am_wh;
        exit when v_per_slot_charge_wh <= 0;
        exit when v_grid_slots_am >= v_grid_charge_cap_am;
        update _ems_plan_slot_wk wk
        set allow_charge = true,
            allow_grid_charge = true,
            charge_layer = 'grid_am',
            charge_slot_reason = 'grid_layer_b',
            charge_slot_wh = v_per_slot_charge_wh,
            charge_cum_wh = v_cum + v_per_slot_charge_wh
        where wk.slot_ord = r_slot.slot_ord;
        v_cum := v_cum + v_per_slot_charge_wh;
        v_grid_slots_am := v_grid_slots_am + 1;
      end loop;
      v_grid_filled_wh := v_grid_filled_wh + v_cum;

      v_chg_pm_wh := greatest(v_chg_pm_wh, v_grid_target_wh - v_grid_filled_wh);
      if v_per_slot_charge_wh > 0 and v_charge_buf > 0 then
        v_grid_charge_cap_pm := greatest(
          v_grid_charge_cap_pm,
          least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int)
        );
      elsif v_per_slot_charge_wh > 0 then
        v_grid_charge_cap_pm := greatest(
          v_grid_charge_cap_pm,
          least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int)
        );
      end if;

      v_cum := 0;
      v_grid_slots_pm := 0;
      for r_slot in
        select wk.slot_ord
        from _ems_plan_slot_wk wk
        where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
          and wk.sell_price >= 0
          and wk.sell_price <= (
            select min(w3.sell_price)
            from _ems_plan_slot_wk w3
            where w3.sell_price >= 0
          ) + v_degrad_czk_kwh + 0.05
        order by
          case
            when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague
            then 0
            else 1
          end,
          case
            when wk.export_window_start_at is not null
              and wk.interval_start < wk.export_window_start_at
            then 0
            else 1
          end,
          wk.is_predicted_price::int,
          wk.sell_price asc,
          wk.slot_ord
      loop
        exit when v_cum >= v_chg_pm_wh;
        exit when v_per_slot_charge_wh <= 0;
        exit when v_grid_slots_pm >= v_grid_charge_cap_pm;
        update _ems_plan_slot_wk wk
        set allow_charge = true,
            allow_grid_charge = true,
            charge_layer = 'grid_pm',
            charge_slot_reason = 'grid_layer_b',
            charge_slot_wh = v_per_slot_charge_wh,
            charge_cum_wh = v_cum + v_per_slot_charge_wh
        where wk.slot_ord = r_slot.slot_ord;
        v_cum := v_cum + v_per_slot_charge_wh;
        v_grid_slots_pm := v_grid_slots_pm + 1;
      end loop;
      v_grid_filled_wh := v_grid_filled_wh + v_cum;
    end if;

    -- A) PV-surplus: jen zbytek kapacity po grid vrstvě B
    v_pv_layer_cap_wh := greatest(v_energy_to_fill - v_grid_filled_wh, 0);

    -- Dodávka z forecastu v sell<0 okně snižuje potřebu nabíjení před oknem.
    select coalesce(sum(least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25), 0)
    into v_in_window_wh
    from _ems_plan_slot_wk wk
    where wk.sell_price < 0
      and wk.pv_surplus_w > 0;

    if v_in_window_wh > 0 then
      v_pv_layer_cap_wh := greatest(v_pv_layer_cap_wh - v_in_window_wh, 0);
      v_pre_window_wh := greatest(
        0,
        v_charge_target_wh - v_in_window_wh * v_charge_reliability_factor
      );
    end if;

    v_cum := 0;
    for r_slot in
      select wk.slot_ord, wk.pv_surplus_w
      from _ems_plan_slot_wk wk
      where wk.pv_surplus_w > 0
        and (
          v_purchase_pricing_mode = 'fixed'
          or wk.sell_price >= wk.buy_price - v_degrad_czk_kwh
        )
        -- Spot: neukládat do bat při výrazně lepším sell později; fixed: řazení sell ASC (§ charge-slot-budget).
        and (
          v_purchase_pricing_mode = 'fixed'
          or wk.sell_price < 0
          or wk.sell_price >= wk.future_sell_lookahead - v_degrad_czk_kwh
        )
      order by
        case when v_purchase_pricing_mode = 'fixed' then wk.sell_price end asc nulls last,
        wk.store_score desc nulls last,
        wk.slot_ord
    loop
      exit when v_cum >= v_pv_layer_cap_wh;
      update _ems_plan_slot_wk wk
      set allow_charge = true,
          charge_layer = coalesce(wk.charge_layer, 'pv_a'),
          charge_slot_reason = coalesce(wk.charge_slot_reason, 'pv_layer_a'),
          charge_slot_wh = greatest(
            wk.charge_slot_wh,
            least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25
          ),
          charge_cum_wh = v_cum
            + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25
      where wk.slot_ord = r_slot.slot_ord;
      v_cum := v_cum + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25;
    end loop;
  end if;

  -- První záporný výkup v horizontu (od p_from = „budoucí“ sloty od replanu).
  -- Peak před ním a acquisition cutoff se vážou na STEJNÝ kalendářní den (Prague),
  -- ne na včerejší večer v tomže horizontu.
  select min(wk.slot_ord)
  into v_first_neg_sell_ord
  from _ems_plan_slot_wk wk
  where wk.sell_price < 0;

  if v_first_neg_sell_ord is not null then
    select (wk.interval_start at time zone 'Europe/Prague')::date
    into v_first_neg_prague_date
    from _ems_plan_slot_wk wk
    where wk.slot_ord = v_first_neg_sell_ord;
  end if;

  v_preneg_export_min_soc_wh := v_min_soc_wh + greatest(v_per_slot_discharge_wh, 1000::numeric);

  -- discharge-export mask
  if v_discharge_buf <= 0 then
    update _ems_plan_slot_wk wk set allow_discharge_export = true;
  elsif v_exportable <= 0 then
    update _ems_plan_slot_wk wk set allow_discharge_export = false;
  else
    update _ems_plan_slot_wk wk set allow_discharge_export = false;
    v_cum := 0;
    for r_slot in
      select wk.slot_ord
      from _ems_plan_slot_wk wk
      where (
        case
          when v_purchase_pricing_mode = 'fixed' then
            wk.sell_price > wk.buy_price + v_degrad_czk_kwh
          else
            wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
        end
      )
        -- Před prvním sell<0: do rozpočtu exportu jen sloty bez lepšího sell později tentýž den
        -- (OTE), ne pevné hodiny 00–04 (home-01: půlnoc 3,7 vs. 07:00 3,06).
        and not (
          v_first_neg_sell_ord is not null
          and wk.slot_ord < v_first_neg_sell_ord
          and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
          and exists (
            select 1
            from _ems_plan_slot_wk w2
            where w2.slot_ord > wk.slot_ord
              and w2.slot_ord < v_first_neg_sell_ord
              and (w2.interval_start at time zone 'Europe/Prague')::date
                = (wk.interval_start at time zone 'Europe/Prague')::date
              and w2.sell_price > wk.sell_price + v_degrad_czk_kwh
          )
        )
      order by wk.sell_price desc, wk.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 wk set allow_discharge_export = true where wk.slot_ord = r_slot.slot_ord;
      v_cum := v_cum + v_per_slot_discharge_wh;
    end loop;
  end if;

  -- Večerní špičky per kalendářní den (≥17:00 Prague): ne globální max horizontu (jinak půlnoc).
  for r_slot in
    select
      (wk.interval_start at time zone 'Europe/Prague')::date as plan_date,
      coalesce(max(wk.sell_price), 0) as evening_peak_sell
    from _ems_plan_slot_wk wk
    where extract(hour from wk.interval_start at time zone 'Europe/Prague')
      >= v_evening_peak_start_hour
    group by (wk.interval_start at time zone 'Europe/Prague')::date
  loop
    if r_slot.evening_peak_sell > 0 then
      update _ems_plan_slot_wk wk
      set allow_discharge_export = true
      where (wk.interval_start at time zone 'Europe/Prague')::date = r_slot.plan_date
        and extract(hour from wk.interval_start at time zone 'Europe/Prague')
          >= v_evening_peak_start_hour
        and (
          case
            when v_purchase_pricing_mode = 'fixed'
              and v_block_export_neg_sell then
              -- KV1: fixní buy ~6,3; večerní sell často < buy — vývoz ve všech kladných sell slotech ≥17h.
              wk.sell_price > 0
            else
              wk.sell_price >= r_slot.evening_peak_sell - v_degrad_czk_kwh
          end
        );
    end if;
  end loop;

  -- v43: levný grid před prvním sell<0 jen na dnech BEZ sell<0 (normální arbitráž odpoledne→večer).
  update _ems_plan_slot_wk wk
  set allow_charge = true,
      allow_grid_charge = true,
      grid_charge_suppressed_reason = coalesce(
        wk.grid_charge_suppressed_reason,
        'evening_arbitrage_unlock'
      )
  from (
    select
      (wk2.interval_start at time zone 'Europe/Prague')::date as plan_date,
      max(wk2.sell_price) filter (
        where extract(hour from wk2.interval_start at time zone 'Europe/Prague')
          >= v_evening_peak_start_hour
          and wk2.sell_price > 0
      ) as evening_peak_sell
    from _ems_plan_slot_wk wk2
    group by 1
  ) ep
  where (wk.interval_start at time zone 'Europe/Prague')::date = ep.plan_date
    and ep.evening_peak_sell is not null
    and ep.evening_peak_sell > v_degrad_czk_kwh + 0.05
    and wk.buy_price >= 0
    and wk.buy_price + v_degrad_czk_kwh < ep.evening_peak_sell
    and extract(hour from wk.interval_start at time zone 'Europe/Prague')
      between 11 and 16
    and not exists (
      select 1
      from _ems_plan_slot_wk wn
      where wn.sell_price < 0
        and (wn.interval_start at time zone 'Europe/Prague')::date = ep.plan_date
    );

  -- v44: neg den — žádné grid nabíjení před 1. sell<0 (místo pro FVE; ne 3 Kč místo 0,5 Kč v okně).
  if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null then
    update _ems_plan_slot_wk wk
    set allow_charge = false,
        allow_grid_charge = false,
        grid_charge_suppressed_reason = 'neg_day_no_grid_before_neg_sell'
    where wk.slot_ord < v_first_neg_sell_ord
      and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
      and wk.allow_grid_charge;
  end if;

  -- Ranní pásmo před prvním sell<0 (5–11 Prague): lokální peak, ne půlnoc celého dne.
  if v_first_neg_sell_ord is not null
    and v_first_neg_prague_date is not null
    and p_current_soc_wh >= v_preneg_export_min_soc_wh
  then
    select coalesce(max(wk.sell_price), 0)
    into v_morning_zone_peak_sell
    from _ems_plan_slot_wk wk
    where wk.slot_ord < v_first_neg_sell_ord
      and wk.sell_price >= 0
      and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
      and extract(hour from wk.interval_start at time zone 'Europe/Prague')
        between v_morning_preneg_start_hour and v_morning_preneg_end_hour;

    if v_morning_zone_peak_sell > 0 then
      update _ems_plan_slot_wk wk
      set allow_discharge_export = true
      where wk.slot_ord < v_first_neg_sell_ord
        and wk.sell_price >= 0
        and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
        and extract(hour from wk.interval_start at time zone 'Europe/Prague')
          between v_morning_preneg_start_hour and v_morning_preneg_end_hour
        and wk.sell_price >= v_morning_zone_peak_sell - v_degrad_czk_kwh;

      select wk.slot_ord
      into v_pre_neg_peak_sell_ord
      from _ems_plan_slot_wk wk
      where wk.slot_ord < v_first_neg_sell_ord
        and wk.sell_price >= 0
        and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
        and extract(hour from wk.interval_start at time zone 'Europe/Prague')
          between v_morning_preneg_start_hour and v_morning_preneg_end_hour
      order by wk.sell_price desc, wk.slot_ord
      limit 1;
    end if;
  end if;

  -- Mezi ranní peak a prvním sell<0: zákaz „pozdního dumpu“ při nízkém sell (07:30 za 2 Kč).
  if v_first_neg_sell_ord is not null
    and v_first_neg_prague_date is not null
    and v_morning_zone_peak_sell is not null
    and v_morning_zone_peak_sell > 0
  then
    update _ems_plan_slot_wk wk
    set allow_discharge_export = false
    where wk.slot_ord < v_first_neg_sell_ord
      and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
      and extract(hour from wk.interval_start at time zone 'Europe/Prague')
        between v_morning_preneg_start_hour and v_morning_preneg_end_hour
      and wk.sell_price < v_morning_zone_peak_sell - v_degrad_czk_kwh;

    update _ems_plan_slot_wk wk
    set allow_discharge_export = false
    where wk.slot_ord < v_first_neg_sell_ord
      and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
      and extract(hour from wk.interval_start at time zone 'Europe/Prague')
        >= v_morning_preneg_start_hour
      and extract(hour from wk.interval_start at time zone 'Europe/Prague')
        < v_evening_peak_start_hour
      and wk.sell_price < v_morning_zone_peak_sell - v_degrad_czk_kwh;
  end if;

  -- Ranní peak před sell<0: jen export baterie, ne souběžné nabíjení (LP jinak „nabije“ v 07:00).
  if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null then
    update _ems_plan_slot_wk wk
    set allow_charge = false
    where wk.allow_discharge_export
      and wk.slot_ord < v_first_neg_sell_ord
      and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
      and extract(hour from wk.interval_start at time zone 'Europe/Prague')
        between v_morning_preneg_start_hour and v_morning_preneg_end_hour;
  end if;

  -- Záporný buy: vždy grid nabíjení (mimo rozpočet 6 slotů / PV vrstvu A).
  update _ems_plan_slot_wk wk
  set allow_charge = true,
      allow_grid_charge = true,
      charge_layer = coalesce(wk.charge_layer, 'buy_negative'),
      charge_slot_reason = coalesce(wk.charge_slot_reason, 'buy_negative'),
      charge_slot_wh = greatest(wk.charge_slot_wh, v_per_slot_charge_wh)
  where wk.buy_price < 0;

  -- Záporný výkup + PV přebytek: nabíjení z FVE (KV1/BA81 block_export), bez filtru future_sell.
  update _ems_plan_slot_wk wk
  set allow_charge = true,
      charge_layer = coalesce(wk.charge_layer, 'neg_window'),
      charge_slot_reason = coalesce(wk.charge_slot_reason, 'neg_window_pv'),
      charge_slot_wh = greatest(
        wk.charge_slot_wh,
        least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25
      )
  where wk.sell_price < 0
    and wk.pv_surplus_w > 0;

  -- v45: v sell<0 okně neg dne — grid nabíjení při kladném buy (prep), i bez pv_surplus (ranní 07:45).
  if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null then
    update _ems_plan_slot_wk wk
    set allow_charge = true,
        allow_grid_charge = true,
        charge_layer = coalesce(wk.charge_layer, 'neg_window'),
        charge_slot_reason = coalesce(wk.charge_slot_reason, 'neg_window_grid_charge'),
        charge_slot_wh = greatest(wk.charge_slot_wh, v_per_slot_charge_wh),
        grid_charge_suppressed_reason = coalesce(
          wk.grid_charge_suppressed_reason,
          'neg_window_grid_charge'
        )
    where wk.slot_ord >= v_first_neg_sell_ord
      and wk.sell_price < 0
      and wk.buy_price >= 0
      and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date;
  end if;

  -- Acquisition: grid nabíjení před prvním exportem ve STEJNÝ den jako záporné výkupní okno
  -- (ne dřívější večerní export v horizontu rolling replanu).
  select min(wk.interval_start)
  into v_acquisition_cutoff
  from _ems_plan_slot_wk wk
  where wk.allow_discharge_export
    and (
      v_first_neg_prague_date is null
      or (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
    );

  if v_acquisition_cutoff is null then
    select min(wk.interval_start)
    into v_acquisition_cutoff
    from _ems_plan_slot_wk wk
    where wk.allow_discharge_export;
  end if;

  -- Acquisition: jen grid vrstva B (ne odpolední FVE z vrstvy A) před 1. exportem.
  select
    coalesce(sum(
      case
        when wk.allow_grid_charge
          and wk.buy_price >= 0
          and (
            v_acquisition_cutoff is null
            or wk.interval_start < v_acquisition_cutoff
          )
        then v_per_slot_charge_wh
        else 0
      end
    ), 0),
    coalesce(sum(
      case
        when wk.allow_grid_charge
          and wk.buy_price >= 0
          and (
            v_acquisition_cutoff is null
            or wk.interval_start < v_acquisition_cutoff
          )
        then wk.buy_price * v_per_slot_charge_wh
        else 0
      end
    ), 0),
    min(
      case
        when wk.allow_grid_charge
          and wk.buy_price >= 0
          and (
            v_acquisition_cutoff is null
            or wk.interval_start < v_acquisition_cutoff
          )
        then wk.buy_price
        else null
      end
    )
  into
    v_est_grid_wh,
    v_est_grid_cost,
    v_charge_acquisition
  from _ems_plan_slot_wk wk;

  v_est_pv_wh := 0;
  v_est_pv_cost := 0;

  if v_est_grid_wh > 0 then
    v_charge_acquisition := v_est_grid_cost / v_est_grid_wh;
  elsif v_charge_acquisition is null then
    -- Fallback: nejnizsi positivni buy v horizontu (nikoli avg ref_buy_am/pm,
    -- ktery muze byt < 0 kdyz PM zahrnuje zaporne OTE sloty 13-15h).
    -- Cena akvizice baterie nikdy nesmi byt < 0 (jinak rozhazuje arbitraz
    -- objective + two_pass divergence).
    select coalesce(min(wk.buy_price), 0)
    into v_charge_acquisition
    from _ems_plan_slot_wk wk
    where wk.buy_price >= 0;
  end if;
  v_charge_acquisition := greatest(v_charge_acquisition, 0);
  -- v_charge_acquisition z min(grid) zůstane, pokud je jen jeden grid slot před exportem

  return query
  with night_tot as (
    select coalesce(sum(w2.load_baseline_w), 0) * 0.25 as night_wh
    from _ems_plan_slot_wk w2
    where extract(hour from w2.interval_start at time zone 'Europe/Prague') >= 20
       or extract(hour from w2.interval_start at time zone 'Europe/Prague') < 6
  ),
  enriched as (
    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,
      nt.night_wh as night_baseload_target_wh,
      nt.night_wh * (v_night_buf_pct / 100.0) as night_baseload_buffer_wh,
      case
        when not v_daytime_en then null::numeric
        when extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 19 then
          least(
            v_soc_max_wh,
            v_reserve_wh + (nt.night_wh + nt.night_wh * (v_night_buf_pct / 100.0))
              * greatest(
                0::numeric,
                least(
                  1::numeric,
                  (
                    extract(hour from w.interval_start at time zone 'Europe/Prague')::numeric
                    + (
                      extract(minute from w.interval_start at time zone 'Europe/Prague')::numeric
                      / 60.0
                    )
                    - 6.0
                  ) / 14.0
                )
              )
          )
        else null::numeric
      end as safety_soc_target_wh,
      coalesce(
        max(w.buy_price) over (
          order by w.slot_ord rows between 1 following and unbounded following
        ),
        w.buy_price
      ) as future_avoided_buy_czk_kwh,
      coalesce(
        max(w.sell_price) over (
          order by w.slot_ord rows between 1 following and unbounded following
        ),
        w.sell_price
      ) as future_sell_opportunity_czk_kwh,
      (
        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,
      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,
      v_charge_target_wh as charge_target_wh,
      v_pre_window_wh as pre_window_wh,
      v_in_window_wh as in_window_wh,
      coalesce(w.charge_slot_wh, 0) as charge_slot_wh,
      w.charge_cum_wh,
      w.charge_layer,
      w.charge_slot_reason
    from _ems_plan_slot_wk w
    cross join night_tot nt
  )
  select
    e.slot_ord,
    e.interval_start,
    e.buy_price,
    e.sell_price,
    e.is_predicted_price,
    e.pv_a_forecast_w,
    e.pv_b_forecast_w,
    e.load_baseline_w,
    e.ev1_connected,
    e.ev2_connected,
    e.allow_charge,
    e.allow_discharge_export,
    e.night_baseload_target_wh,
    e.night_baseload_buffer_wh,
    e.safety_soc_target_wh,
    e.future_avoided_buy_czk_kwh,
    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,
    e.min_buy_before_cutoff_czk_kwh,
    e.pv_charge_wh_ahead,
    e.neg_buy_wh_ahead,
    e.grid_charge_suppressed_reason,
    e.charge_target_wh,
    e.pre_window_wh,
    e.in_window_wh,
    e.charge_slot_wh,
    e.charge_cum_wh,
    e.charge_layer,
    e.charge_slot_reason
  from enriched e
  order by e.slot_ord;
end;
$fn$;

comment on function ems.fn_load_planning_slots_full is
  '15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). '
  'Charge-slot budget: charge_target_wh, pre_window_wh (deficit − forecast v sell<0), in_window_wh; '
  'debug charge_layer / charge_slot_reason / charge_cum_wh. '
  'Charge mask A: spot = store_score DESC; fixed = sell ASC + Wh kumulace pv_surplus. '
  'Charge mask B: spot nejlevnější buy v AM/PM; fixed nejnižší sell v AM/PM do Wh rozpočtu. '
  'ref_buy = min(buy) horizontu. Discharge-export: nejdražší sell kde sell>ref_buy+degrad (spot). '
  'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). '
  'Denní safety vstupy: night_baseload_* (20:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
  'lookahead max buy/sell pro měkké LP penalizace. '
  'charge_acquisition_buy_czk_kwh: vážený buy v allow_grid_charge slotech před charge_acquisition_cutoff_at. '
  'Grid maska B běží před PV vrstvou A; AM/PM rozpočet Wh 50/50; cap slotů z rozpočtu / per_slot_charge_wh.';
