v2: měkký EV cíl — oportunistické nabíjení nad target (+ strop energie)
All checks were successful
CI and deploy / migration-check (push) Successful in 44s
CI and deploy / deploy (push) Has been skipped

Uživatel: 'potřebuju do X % (tvrdý), ale klidně dobij na 100 % když je to
skoro zadarmo; při záporných cenách radši do auta než nechat na střeše'.

- V094 asset_vehicle.opportunistic_value_czk_kwh (default 1.0; = hodnota
  ušetřeného BUDOUCÍHO nabíjení — auto neumí zpět, žádný noční prodej)
- R__039 ev_sessions: + headroom_wh ((100−target) % kapacity) + opp value;
  session se nenuluje po dosažení targetu, dokud má headroom
- solver_v2: dekompozice Σ(EV) == needed − unmet + opp, opp ∈ [0, headroom],
  odměna opp×value; zároveň FIX latentního bugu — při buy<0 chyběl strop
  celkové energie do auta (model mohl pumpovat bez limitu)
- 3 testy (neg ceny sají nad target po strop; běžné ceny ne; cap při opp=0);
  eval fixtures beze změny (sessions null)

Víkend (pátek nízký tvrdý cíl + víkendová negativa → samo doplní do 100 %)
vyplývá z mechanismu, žádná speciální logika.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-12 12:17:59 +02:00
parent 2325bbcbd6
commit 85dff7f13e
6 changed files with 117 additions and 4 deletions

View File

@@ -37,6 +37,8 @@ def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
return SimpleNamespace(
target_deadline=td,
energy_needed_wh=float(obj["energy_needed_wh"]),
headroom_wh=float(obj.get("headroom_wh") or 0.0),
opportunistic_value_czk_kwh=float(obj.get("opportunistic_value_czk_kwh") or 0.0),
)
async def _load_site_context(site_id: int, db):

View File

@@ -26,6 +26,11 @@
# indiference v čase; odložení ale spoléhá na predikci (večerní mrak).
# Malá prémie za držení energie dřív (DB planner_pv_risk_frontload_czk_kwh)
# vede k "nabít plným výkonem hned, pak řezat A" — emergentně, bez rampy.
# - oportunistické EV („měkký cíl"): nad tvrdý target smí auto vzít až
# headroom_wh (do 100 %), oceněno opportunistic_value_czk_kwh (= budoucí
# ušetřené nabíjení, DB) — kupuje jen velmi levnou/zápornou energii.
# Dekompozice Σ(EV energie) == needed unmet + opp zároveň stropuje
# celkovou energii do auta (dřív při buy<0 bez stropu).
# - denní SoC rampa: deficit pod slot.safety_soc_target_wh (R__063: reserve →
# reserve+noc, 619 h) platí za slot nájem buy×faktor (DB
# planner_safety_soc_risk_factor) — ráno se nejdřív dotáhne rezerva
@@ -160,6 +165,7 @@ def solve_dispatch_v2(
for e in range(EV)
]
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
ev_opp: list = [] # (var, value_czk_kwh) — energie nad target (měkký cíl)
nb_buffer_wh = [max(0.0, float(s.night_baseload_buffer_wh or 0.0)) for s in slots]
safety_risk = float(getattr(battery, "planner_safety_soc_risk_factor", 0.0) or 0.0)
safety_tgt_wh = [
@@ -281,6 +287,20 @@ def solve_dispatch_v2(
>= float(sess.energy_needed_wh)
), f"ev_deadline_{e}"
# měkký cíl: dekompozice celkové energie == needed unmet + opp
headroom = max(0.0, float(getattr(sess, "headroom_wh", 0.0) or 0.0))
opp_val = float(getattr(sess, "opportunistic_value_czk_kwh", 0.0) or 0.0)
opp = pulp.LpVariable(f"ev_opp_{e}", 0, headroom if opp_val > 0 else 0.0)
ev_opp.append((opp, opp_val))
prob += (
pulp.lpSum(
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
for t in range(T)
if _connected(e, t)
)
== float(sess.energy_needed_wh) - unmet + opp
), f"ev_total_{e}"
# TUV look-ahead (převzato z v1 — komfortní constraint, ne heuristika)
rated_hp = float(heat_pump.rated_heating_power_w)
if tuv_delta_stats and rated_hp > 0 and getattr(heat_pump, "tuv_min_temp_c", None):
@@ -322,6 +342,8 @@ def solve_dispatch_v2(
)
if ev_unmet:
extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in ev_unmet)
if ev_opp:
extras -= pulp.lpSum(o / 1000.0 * val for o, val in ev_opp if val > 0)
nb_terms = [
nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
for t in range(T)
@@ -453,6 +475,7 @@ def solve_dispatch_v2(
"extras_czk": round(float(pulp.value(extras)), 3) if not isinstance(extras, float) else 0.0,
"terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet],
"ev_opp_wh": [round(_val(o), 1) for o, _v in ev_opp],
},
"solver_duration_ms": duration_ms,
"solver_status": status_str,

View File

@@ -247,6 +247,41 @@ class PvRiskFrontloadTests(unittest.TestCase):
)
class EvOpportunisticTests(unittest.TestCase):
def _session(self, needed=4000.0, headroom=20000.0, opp=1.0):
return SimpleNamespace(
target_deadline=_BASE + timedelta(hours=2),
energy_needed_wh=needed,
headroom_wh=headroom,
opportunistic_value_czk_kwh=opp,
)
def test_negative_prices_fill_beyond_target(self) -> None:
# buy<0 celé okno → nad target se vyplatí brát (hodnota 1 Kč/kWh + platí ti síť)
slots = [_slot(_BASE, i, buy=-1.0, sell=-0.5, ev1=True, load=300) for i in range(16)]
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertGreater(delivered, 4000.0 + 2000.0, "měkký cíl má nasávat")
self.assertLessEqual(delivered, 4000.0 + 20000.0 + 1.0, "strop headroom")
self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0)
def test_normal_prices_no_opportunistic(self) -> None:
# běžné ceny (buy 3) > hodnota 1 Kč/kWh → jen tvrdý cíl
slots = [_slot(_BASE, i, buy=3.0, sell=2.0, ev1=True, load=300) for i in range(16)]
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertLess(delivered, 4000.0 + 200.0)
self.assertLess(snap["objective_terms"]["ev_opp_wh"][0], 100.0)
def test_total_energy_capped_even_at_negative_buy(self) -> None:
# fix latentního bugu: bez headroom (opp=0) nesmí buy<0 pumpovat nad needed
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(16)]
sess = self._session(needed=3000.0, headroom=0.0, opp=0.0)
results, _, _ = _solve(slots, ev_sessions=(sess, None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertLessEqual(delivered, 3000.0 + 1.0)
class EvDeadlineTests(unittest.TestCase):
def test_ev_energy_delivered_before_deadline(self) -> None:
slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]

View File

@@ -0,0 +1,13 @@
-- Oportunistické EV nabíjení („měkký cíl"): nad tvrdý target smí auto nasát
-- přebytky až do 100 %, oceněné hodnotou BUDOUCÍHO ušetřeného nabíjení
-- (~1 Kč/kWh — budoucí nabíjení je stejně v levných slotech). Uplatní se
-- hlavně při záporných cenách / plné domácí baterce (lepší než curtail);
-- běžné ceny ho nezaplatí. 0 = vypnuto. Víkend: páteční malý tvrdý cíl
-- + víkendové negativní ceny → auto se doplní samo, bez speciální logiky.
alter table ems.asset_vehicle
add column if not exists opportunistic_value_czk_kwh numeric(6, 3)
not null default 1.0;
comment on column ems.asset_vehicle.opportunistic_value_czk_kwh is
'v2: hodnota kWh nabité NAD target session (do 100 %) = ušetřené budoucí nabíjení. Solver ji zaplatí jen při velmi levné/záporné energii. 0 = vypnuto.';

View File

@@ -191,7 +191,11 @@ begin
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
) <= 0 then null::jsonb
) <= 0
and (
coalesce(v.opportunistic_value_czk_kwh, 0) <= 0
or (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct)) <= 0
) then null::jsonb
else jsonb_build_object(
'target_deadline', es.target_deadline,
'energy_needed_wh', greatest(
@@ -200,7 +204,16 @@ begin
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
)
),
'headroom_wh', case
when coalesce(v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
0,
(100 - coalesce(es.target_soc_pct, v.default_target_soc_pct))::numeric
/ 100.0 * (v.battery_capacity_kwh * 1000)
)
else 0
end,
'opportunistic_value_czk_kwh', coalesce(v.opportunistic_value_czk_kwh, 0)
)
end
from ems.ev_session es
@@ -223,7 +236,11 @@ begin
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
) <= 0 then null::jsonb
) <= 0
and (
coalesce(v.opportunistic_value_czk_kwh, 0) <= 0
or (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct)) <= 0
) then null::jsonb
else jsonb_build_object(
'target_deadline', es.target_deadline,
'energy_needed_wh', greatest(
@@ -232,7 +249,16 @@ begin
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
)
),
'headroom_wh', case
when coalesce(v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
0,
(100 - coalesce(es.target_soc_pct, v.default_target_soc_pct))::numeric
/ 100.0 * (v.battery_capacity_kwh * 1000)
)
else 0
end,
'opportunistic_value_czk_kwh', coalesce(v.opportunistic_value_czk_kwh, 0)
)
end
from ems.ev_session es

View File

@@ -342,3 +342,17 @@ Po detekci příjezdu + Tesla SoC + replanu odejde na site webhook souhrn:
stav baterie auta → cíl (+kWh), deadline, plánovaná nabíjecí okna s ø cenou
(`_notify_ev_arrival_plan` v telemetry_collector). Interaktivní fáze B
(tlačítka „odjíždím za 2 h" → patch session + replan): `docs/discord-ev-interaction.md`.
## Měkký cíl — oportunistické nabíjení nad target (2026-06-12, dev)
Tvrdý cíl (deadline) = „bez tohohle neodjedu"; měkký cíl = „klidně doplň
do 100 %, když je energie skoro zadarmo". Implementace: dekompozice
Σ(EV energie) == needed unmet + opp; `opp ∈ [0, headroom]`
(headroom = (100 target) % kapacity, jen když `asset_vehicle.
opportunistic_value_czk_kwh > 0`; default 1 Kč/kWh, 0 = vypnuto).
Hodnota = ušetřené BUDOUCÍ nabíjení (auto neumí zpět — žádný noční prodej),
proto nízká → uplatní se při záporných cenách / plné domácí baterce
(lepší než curtail), běžné ceny ji nezaplatí. Víkendový vzor „pátek
nemusím do plna, víkend doplní zadarmo" z toho plyne sám. Dekompozice
zároveň stropuje celkovou energii do auta (dřív při buy<0 chyběl strop).
Session zůstává v plánu i po dosažení targetu, dokud má headroom.