diff --git a/backend/services/planning/db_io.py b/backend/services/planning/db_io.py index 92ccd69..dec0fa7 100644 --- a/backend/services/planning/db_io.py +++ b/backend/services/planning/db_io.py @@ -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): diff --git a/backend/services/planning/solver_v2.py b/backend/services/planning/solver_v2.py index 017cc05..bffb1cd 100644 --- a/backend/services/planning/solver_v2.py +++ b/backend/services/planning/solver_v2.py @@ -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, diff --git a/backend/tests/test_solver_v2.py b/backend/tests/test_solver_v2.py index 9760754..efbaa07 100644 --- a/backend/tests/test_solver_v2.py +++ b/backend/tests/test_solver_v2.py @@ -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)] diff --git a/db/migration/V094__ev_opportunistic.sql b/db/migration/V094__ev_opportunistic.sql new file mode 100644 index 0000000..7ba2661 --- /dev/null +++ b/db/migration/V094__ev_opportunistic.sql @@ -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.'; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index cc6fc81..3dfed86 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -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 diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 6d8c2f6..14dfc15 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -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.