v2: měkký EV cíl — oportunistické nabíjení nad target (+ strop energie)
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:
@@ -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):
|
||||
|
||||
@@ -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, 6–19 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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
13
db/migration/V094__ev_opportunistic.sql
Normal file
13
db/migration/V094__ev_opportunistic.sql
Normal 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.';
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user