Files
ems/db/routines/R__039_fn_planning_site_context.sql
Dusan Vojacek d81a150014 fix(planner): EV session viditelna i bez deadline / nad targetem (BUG2)
Zivy incident home-01: aktivni plan mel ev_sessions:0, ac session bezela
(target 70 %). Planovac neviděl ~6 kW zatez auta a spatne rozvrhl baterii
(zbytecny vecerni import).

Root cause (dve pasti):
- fn_planning_site_context vracela session jako null, kdyz needed_wh=0
  (auto nad targetem) i kdyz target_deadline is null.
- _ev_session_from_json (Python) zahazovala session bez deadline.

Fix:
- R__038 fn_ev_session_planning_json: session se vyradi (null) JEN bez tvrdych
  dat (kapacita vozidla / soc_at_connect). target_deadline smi byt NULL --
  solver hard deadline constraint aplikuje jen pri needed>0; oportunisticka
  vrstva bezi i bez deadline. Auto nad targetem zustava v planu jako znama
  zatez i s headroomem k levnemu doplneni. R__039 vola helper (deduplikace
  dvou inline poddotazu, SQL-first).
- _ev_session_from_json si NULL deadline ponecha (energy_needed_wh default 0).
- testy test_ev_session_parse.py; docs ev-charging + planning-changelog;
  CLAUDE.md funkce.

Navrh agresivnejsiho oportunistickeho algoritmu (P50 levnych oken z
market_price_stats misto konstanty 1 Kc/kWh) -- NEnasazeno, k rozhodnuti,
sepsano v docs/04-modules/planning.md (EV oportunismus); riziko regrese
golden ekonomiky, nutny EV fixture + eval.

Overeni: pytest -q 362 passed; golden replay gate 7 passed; solver_v2_eval
beze zmeny (fixtures bez EV session).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:03:27 +02:00

254 lines
8.4 KiB
PL/PgSQL

-- 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_market 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,
'planner_soc_max_wh', (
coalesce(ab.planner_max_soc_percent, ab.max_soc_percent) / 100.0 * ab.usable_capacity_wh
)::numeric,
'planner_extreme_buy_threshold_czk_kwh', coalesce(ab.planner_extreme_buy_threshold_czk_kwh, -5.0),
'planner_discharge_floor_percent', ab.planner_discharge_floor_percent,
'planner_discharge_relax_prewindow_slots', coalesce(ab.planner_discharge_relax_prewindow_slots, 8),
'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,
'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor,
'planner_daytime_charge_target_enabled', coalesce(ab.planner_daytime_charge_target_enabled, true),
'planner_night_baseload_buffer_percent', coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric),
'planner_daytime_charge_price_quantile', coalesce(ab.planner_daytime_charge_price_quantile, 0.70::numeric),
'planner_charge_commitment_penalty_czk_kwh', coalesce(ab.planner_charge_commitment_penalty_czk_kwh, 0.20::numeric),
'planner_neg_sell_prep_soc_percent', coalesce(ab.planner_neg_sell_prep_soc_percent, 80::numeric),
'planner_neg_sell_full_soc_tail_slots', coalesce(ab.planner_neg_sell_full_soc_tail_slots, 4),
'planner_neg_sell_vent_min_sell_czk_kwh', ab.planner_neg_sell_vent_min_sell_czk_kwh,
'planner_pv_risk_frontload_czk_kwh', coalesce(ab.planner_pv_risk_frontload_czk_kwh, 0.01),
'planner_safety_soc_risk_factor', coalesce(ab.planner_safety_soc_risk_factor, 0.05)
)
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,
'block_export_on_negative_sell', coalesce(sgc.block_export_on_negative_sell, false),
'deye_gen_microinverter_cutoff_enabled', coalesce(
(
select ai.deye_gen_microinverter_cutoff_enabled
from ems.asset_inverter ai
where ai.site_id = p_site_id
and ai.code = 'deye-main'
limit 1
),
false
)
)
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 jsonb_build_object(
'purchase_pricing_mode', lower(trim(coalesce(smc.purchase_pricing_mode, 'spot'))),
'sale_pricing_mode', lower(trim(coalesce(smc.sale_pricing_mode, 'spot')))
)
into v_market
from ems.site_market_config smc
where smc.site_id = p_site_id
and smc.valid_to is null
order by smc.valid_from desc
limit 1;
v_market := coalesce(
v_market,
jsonb_build_object(
'purchase_pricing_mode', 'spot',
'sale_pricing_mode', 'spot'
)
);
select coalesce(
jsonb_agg(
jsonb_build_object(
'max_charge_power_w', v.max_charge_power_w,
'min_power_w', coalesce(ch.min_power_w, 0),
'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');
-- EV session per wallbox — logika v ems.fn_ev_session_planning_json
-- (R__038): session se NEvyřazuje při needed_wh=0 (auto nad targetem),
-- zůstává v plánu kvůli oportunistickému headroomu i jako známá zátěž.
v_ev := jsonb_build_array(
ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-1'),
ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-2')
);
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,
'market', v_market,
'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 is
'Kontext pro planning_engine / LP (bez samotného solveru). EV session přes fn_ev_session_planning_json: session se nevyřazuje při needed_wh=0 (auto nad targetem) — zůstává v plánu kvůli oportunismu i jako známá zátěž; opportunistic_value = coalesce(session, vehicle); headroom_wh od max(target, soc_at_connect), 0 při vypnutém oportunismu; vehicles nesou min_power_w wallboxu.';