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>
This commit is contained in:
Dusan Vojacek
2026-06-13 22:03:27 +02:00
parent 54288ee2fd
commit d81a150014
8 changed files with 224 additions and 111 deletions

View File

@@ -0,0 +1,76 @@
-- jeden EV session objekt pro LP (fn_planning_site_context).
-- Vrací jsonb objekt session na daném wallboxu, nebo null::jsonb pokud session
-- není nebo nemá použitelná data (kapacita vozidla, SoC při připojení).
--
-- KLÍČOVÝ ROZDÍL oproti dřívější inline logice (bug 2026-06-13): session se
-- NEVYŘAZUJE jen proto, že needed_wh = 0 (auto už nad targetem). Plánovač pak
-- neviděl ~6 kW zátěž auta a špatně rozvrhl baterii. Session zůstává v plánu,
-- dokud má oportunistický headroom (cena rozhodne, jestli se nabíjí) — měkký
-- cíl řeší solver dekompozicí Σ == needed unmet + opp.
--
-- Vyřazení (null) jen když chybí tvrdá data:
-- - žádná otevřená session na wallboxu, nebo
-- - neznámá kapacita vozidla / SoC při připojení (nelze spočítat Wh).
-- target_deadline SMÍ být NULL (žádný tvrdý cíl) — solver to zvládá
-- (deadline constraint se aplikuje jen při needed_wh > 0).
drop function if exists ems.fn_ev_session_planning_json;
create or replace function ems.fn_ev_session_planning_json(
p_site_id int,
p_charger_code text
)
returns jsonb
language sql
stable
as $fn$
select case
when v.battery_capacity_kwh is null then null::jsonb
when es.soc_at_connect_pct is null then null::jsonb
else jsonb_build_object(
-- tvrdý cíl: jen pokud je nastaven deadline I cílový SoC (jinak null →
-- solver hard constraint vynechá, energy_needed_wh = 0).
'target_deadline', case
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null
else es.target_deadline
end,
'energy_needed_wh', case
when es.target_deadline is null then 0::numeric
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then 0::numeric
else 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,
-- headroom do 100 % od max(target, SoC při připojení): „nenabíjet" (nízký
-- target) nesmí ZVĚTŠIT oportunistickou vrstvu; auto fyzicky bere jen
-- energii nad svým aktuálním SoC. Při vypnutém oportunismu (value <= 0)
-- headroom = 0 — session zůstane v plánu, ale solver ji nebude doplňovat.
'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, es.soc_at_connect_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 = p_charger_code
limit 1;
$fn$;
comment on function ems.fn_ev_session_planning_json is
'EV session objekt pro LP (fn_planning_site_context). 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ěž. Null jen bez použitelných dat (kapacita / soc_at_connect). target_deadline smí být NULL (bez tvrdého cíle).';

View File

@@ -179,113 +179,12 @@ begin
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(
(
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
)
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
@@ -351,4 +250,4 @@ 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.';
'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.';