fix(planner): živé EV SoC z integrálu power_w — konec phantom 11 kW oken

needed_wh i headroom z live_soc (soc_at_connect + integrál power_w), ne ze
zamrzlého soc_at_connect. energy_delivered_wh se během session nikdy nezapisoval
(→ needed konstantní, plánovač slepý k pokroku), counter energy_kwh (Telto reg 39)
je rozbitý (17.4 kWh nabito → counter 0.18). Nový fn_ev_session_delivered_wh
integruje power_w (dt cap 120 s), clamp 99 %, fallback drží staré chování bez
telemetrie. Ověřeno živě: needed_wh 18750→1329, live_soc 97.9 %.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-14 20:33:08 +02:00
parent 1ef8630302
commit 8ffe5460f1
4 changed files with 324 additions and 29 deletions

View File

@@ -8,12 +8,53 @@
-- dokud má oportunistický headroom (cena rozhodne, jestli se nabíjí) — měkký
-- cíl řeší solver dekompozicí Σ == needed unmet + opp.
--
-- ŽIVÉ SoC (fix 2026-06-14, phantom okna): needed_wh i headroom se počítají z
-- ŽIVÉHO SoC = soc_at_connect + integrovaná dodaná energie (fn_ev_session_delivered_wh),
-- ne ze zamrzlého soc_at_connect. Dřív se odečítalo es.energy_delivered_wh, JENŽE
-- ten sloupec se během session NIKDY nezapisoval (trvale 0) → needed_wh konstantní
-- → plánovač slepý k pokroku nabíjení → 11 kW phantom okna i u plného auta.
-- NEpoužíváme energy_kwh counter (Telto reg 39 na TeltoCharge neakumuluje —
-- ověřeno: 17.4 kWh nabito, counter stál na 0.18 kWh), proto integrál power_w.
-- live_soc clamp 99 % (finální taper k 100 % ignorujeme). Fallback na
-- energy_delivered_wh drží staré fixtures bez telemetrie identické.
--
-- 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).
-- Dodaná energie do auta za session = time-weighted integrál power_w z
-- telemetry_ev_charger (1min). dt cap 120 s ať výpadek telemetrie nezkresluje.
-- Wh (AC, bez korekce na AC→DC ztráty — mírně optimistické = méně phantom,
-- žádoucí směr). Vrací 0 bez telemetrie (drží staré chování).
drop function if exists ems.fn_ev_session_delivered_wh;
create or replace function ems.fn_ev_session_delivered_wh(
p_charger_id int,
p_since timestamptz
)
returns numeric
language sql
stable
as $fn$
select coalesce(sum(
power_w * least(coalesce(dt, 60), 120)
) / 3600.0, 0)::numeric
from (
select power_w,
extract(epoch from (
measured_at - lag(measured_at) over (order by measured_at)
)) as dt
from ems.telemetry_ev_charger
where charger_id = p_charger_id
and measured_at >= p_since
) q;
$fn$;
comment on function ems.fn_ev_session_delivered_wh is
'Dodaná energie do EV za session (Wh, AC) = time-weighted integrál power_w z telemetry_ev_charger (dt cap 120 s). NEpoužívá energy_kwh counter (Telto reg 39 neakumuluje). Vstup živého SoC ve fn_ev_session_planning_json. 0 bez telemetrie.';
drop function if exists ems.fn_ev_session_planning_json;
create or replace function ems.fn_ev_session_planning_json(
@@ -24,53 +65,74 @@ returns jsonb
language sql
stable
as $fn$
with s as (
select
es.soc_at_connect_pct,
es.target_soc_pct,
es.target_deadline,
es.energy_delivered_wh,
es.opportunistic_value_czk_kwh,
v.battery_capacity_kwh,
v.default_target_soc_pct,
v.opportunistic_value_czk_kwh as v_opp,
ems.fn_ev_session_delivered_wh(es.charger_id, es.session_start) as live_delivered_wh
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
),
c as (
select s.*,
-- živé SoC: SoC při připojení + integrovaná dodaná energie, clamp 99 %.
-- coalesce(live, energy_delivered_wh, 0): bez telemetrie = staré chování.
least(99.0, s.soc_at_connect_pct::numeric
+ coalesce(s.live_delivered_wh, s.energy_delivered_wh, 0)::numeric
/ (s.battery_capacity_kwh * 1000) * 100.0) as live_soc_pct
from s
)
select case
when v.battery_capacity_kwh is null then null::jsonb
when es.soc_at_connect_pct is null then null::jsonb
when c.battery_capacity_kwh is null then null::jsonb
when c.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
when coalesce(c.target_soc_pct, c.default_target_soc_pct) is null then null
else c.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
when c.target_deadline is null then 0::numeric
when coalesce(c.target_soc_pct, c.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
(coalesce(c.target_soc_pct, c.default_target_soc_pct)::numeric
- c.live_soc_pct) / 100.0
* (c.battery_capacity_kwh * 1000)
)
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 do 99 % od max(target, ŽIVÉ SoC): „nenabíjet" (nízký target)
-- nesmí ZVĚTŠIT oportunistickou vrstvu; auto fyzicky bere jen energii nad
-- aktuálním SoC. Plné auto (live_soc → 99) → headroom 0. Při vypnutém
-- oportunismu (value <= 0) headroom = 0.
'headroom_wh', case
when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
when coalesce(c.opportunistic_value_czk_kwh, c.v_opp, 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)
(99 - greatest(
coalesce(c.target_soc_pct, c.default_target_soc_pct, c.live_soc_pct)::numeric,
c.live_soc_pct
)) / 100.0 * (c.battery_capacity_kwh * 1000)
)
else 0
end,
'opportunistic_value_czk_kwh',
coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0)
coalesce(c.opportunistic_value_czk_kwh, c.v_opp, 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;
from c;
$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).';
'EV session objekt pro LP (fn_planning_site_context). needed_wh i headroom z ŽIVÉHO SoC = soc_at_connect + fn_ev_session_delivered_wh (integrál power_w), clamp 99 % — ne ze zamrzlého soc_at_connect (energy_delivered_wh se nikdy nezapisoval → phantom 11 kW okna). Session se NEvyřazuje při needed_wh=0 (zůstává jako známá zátěž + oportunistický headroom). Null jen bez použitelných dat (kapacita / soc_at_connect). target_deadline smí být NULL.';