Hloubková diagnóza EV potvrdila: oportunitní ekonomika via-baterie je v LP
správně, ale okraje lhaly nebo byly nevykonatelné:
- V099 + R__039: ems.ev_session.opportunistic_value_czk_kwh (NULL = zdědit
z asset_vehicle, 0 = vypnout pro session); headroom_wh z max(target_soc,
soc_at_connect) — „nenabíjet" (nízký target) už paradoxně NEzvětšuje
oportunistickou vrstvu; vehicles JSON nese min_power_w wallboxu.
- R__015: patch klíč opportunistic_value_czk_kwh (validace >= 0).
- solver_v2: (a) deadline suma range(t_dl) — slot začínající v deadline už
nepatří „do deadline"; (b) Σ ev_direct <= gi + PV (fyzikální split);
(c) binárka ev_on → setpoint ∈ {0} ∪ [min_power_w, max] (konec 400–900 W
nevykonatelných setpointů); (d) bez session EV == 0 (stop-session i golden
fixtures — žádné pumpování při buy<0); dekompozice total == needed − unmet
+ opp i pro needed = 0; (e) battery_arbitrage_czk = via_bat kWh × oportunitní
cena (min sell exportního slotu téhož pražského dne, jinak terminal value)
místo konstantní 0. Oportunismus PO deadline zůstává POVOLENÝ (rozhodnutí:
auto často doma, odjezd řeší rolling replan).
- R__033: fn_plan_current_bundle.intervals + ev1/ev2_via_bat_w (UI nemá cenit
EV kWh z baterie slotovým buy).
Golden gate beze změny snapshotů (v1 nedotčen, fixtures bez EV sessions);
solver_v2_eval před/po identický (CELKEM −1283.5 Kč, Δ −221.9 vs v1);
tests/test_solver_v2.py +7 testů; plná sada 310 passed / 4 xfailed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
355 lines
12 KiB
PL/PgSQL
355 lines
12 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');
|
|
|
|
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
|
|
and (
|
|
coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0
|
|
or (100 - greatest(
|
|
coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric,
|
|
es.soc_at_connect_pct::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
|
|
),
|
|
-- headroom od max(target, SoC při připojení): „nenabíjet" (nízký
|
|
-- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může
|
|
-- fyzicky vzít jen energii nad svým aktuálním SoC.
|
|
'headroom_wh', case
|
|
when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
|
|
0,
|
|
(100 - greatest(
|
|
coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric,
|
|
es.soc_at_connect_pct::numeric
|
|
)) / 100.0 * (v.battery_capacity_kwh * 1000)
|
|
)
|
|
else 0
|
|
end,
|
|
'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0)
|
|
)
|
|
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
|
|
and (
|
|
coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0
|
|
or (100 - greatest(
|
|
coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric,
|
|
es.soc_at_connect_pct::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
|
|
),
|
|
-- headroom od max(target, SoC při připojení): „nenabíjet" (nízký
|
|
-- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může
|
|
-- fyzicky vzít jen energii nad svým aktuálním SoC.
|
|
'headroom_wh', case
|
|
when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
|
|
0,
|
|
(100 - greatest(
|
|
coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric,
|
|
es.soc_at_connect_pct::numeric
|
|
)) / 100.0 * (v.battery_capacity_kwh * 1000)
|
|
)
|
|
else 0
|
|
end,
|
|
'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0)
|
|
)
|
|
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,
|
|
'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: 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.';
|