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(
|
return SimpleNamespace(
|
||||||
target_deadline=td,
|
target_deadline=td,
|
||||||
energy_needed_wh=float(obj["energy_needed_wh"]),
|
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):
|
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).
|
# 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)
|
# 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.
|
# 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 →
|
# - 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
|
# 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
|
# 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)
|
for e in range(EV)
|
||||||
]
|
]
|
||||||
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
|
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]
|
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_risk = float(getattr(battery, "planner_safety_soc_risk_factor", 0.0) or 0.0)
|
||||||
safety_tgt_wh = [
|
safety_tgt_wh = [
|
||||||
@@ -281,6 +287,20 @@ def solve_dispatch_v2(
|
|||||||
>= float(sess.energy_needed_wh)
|
>= float(sess.energy_needed_wh)
|
||||||
), f"ev_deadline_{e}"
|
), 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)
|
# TUV look-ahead (převzato z v1 — komfortní constraint, ne heuristika)
|
||||||
rated_hp = float(heat_pump.rated_heating_power_w)
|
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):
|
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:
|
if ev_unmet:
|
||||||
extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in 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_terms = [
|
||||||
nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
|
nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
|
||||||
for t in range(T)
|
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,
|
"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),
|
"terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
|
||||||
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet],
|
"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_duration_ms": duration_ms,
|
||||||
"solver_status": status_str,
|
"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):
|
class EvDeadlineTests(unittest.TestCase):
|
||||||
def test_ev_energy_delivered_before_deadline(self) -> None:
|
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)]
|
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
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
* (v.battery_capacity_kwh * 1000)
|
* (v.battery_capacity_kwh * 1000)
|
||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- 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(
|
else jsonb_build_object(
|
||||||
'target_deadline', es.target_deadline,
|
'target_deadline', es.target_deadline,
|
||||||
'energy_needed_wh', greatest(
|
'energy_needed_wh', greatest(
|
||||||
@@ -200,7 +204,16 @@ begin
|
|||||||
- es.soc_at_connect_pct::numeric) / 100.0
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
* (v.battery_capacity_kwh * 1000)
|
* (v.battery_capacity_kwh * 1000)
|
||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- 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
|
end
|
||||||
from ems.ev_session es
|
from ems.ev_session es
|
||||||
@@ -223,7 +236,11 @@ begin
|
|||||||
- es.soc_at_connect_pct::numeric) / 100.0
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
* (v.battery_capacity_kwh * 1000)
|
* (v.battery_capacity_kwh * 1000)
|
||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- 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(
|
else jsonb_build_object(
|
||||||
'target_deadline', es.target_deadline,
|
'target_deadline', es.target_deadline,
|
||||||
'energy_needed_wh', greatest(
|
'energy_needed_wh', greatest(
|
||||||
@@ -232,7 +249,16 @@ begin
|
|||||||
- es.soc_at_connect_pct::numeric) / 100.0
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
* (v.battery_capacity_kwh * 1000)
|
* (v.battery_capacity_kwh * 1000)
|
||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- 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
|
end
|
||||||
from ems.ev_session es
|
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
|
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
|
(`_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`.
|
(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