Merge branch 'worktree-agent-a53f3277d55fecfcb' into dev
This commit is contained in:
@@ -137,6 +137,7 @@ async def _load_site_context(site_id: int, db):
|
|||||||
vehicles.append(
|
vehicles.append(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
max_charge_power_w=int(v["max_charge_power_w"]),
|
max_charge_power_w=int(v["max_charge_power_w"]),
|
||||||
|
min_power_w=int(v.get("min_power_w") or 0),
|
||||||
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
|
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
|
||||||
default_target_soc_pct=float(v["default_target_soc_pct"]),
|
default_target_soc_pct=float(v["default_target_soc_pct"]),
|
||||||
)
|
)
|
||||||
@@ -145,6 +146,7 @@ async def _load_site_context(site_id: int, db):
|
|||||||
vehicles.append(
|
vehicles.append(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
max_charge_power_w=0,
|
max_charge_power_w=0,
|
||||||
|
min_power_w=0,
|
||||||
battery_capacity_kwh=1.0,
|
battery_capacity_kwh=1.0,
|
||||||
default_target_soc_pct=80.0,
|
default_target_soc_pct=80.0,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,9 +28,17 @@
|
|||||||
# 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ž
|
# - 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í
|
# headroom_wh (do 100 %), oceněno opportunistic_value_czk_kwh (= budoucí
|
||||||
# ušetřené nabíjení, DB) — kupuje jen velmi levnou/zápornou energii.
|
# ušetřené nabíjení, session override → vozidlo, DB) — kupuje jen velmi
|
||||||
# Dekompozice Σ(EV energie) == needed − unmet + opp zároveň stropuje
|
# levnou/zápornou energii. Dekompozice Σ(EV energie) == needed − unmet + opp
|
||||||
# celkovou energii do auta (dřív při buy<0 bez stropu).
|
# zároveň stropuje celkovou energii do auta (dřív při buy<0 bez stropu);
|
||||||
|
# opp vrstva NENÍ vázaná deadline (auto bývá doma dál, odjezd řeší rolling
|
||||||
|
# replan); bez session je EV == 0 (stop-session). Deadline suma jde po
|
||||||
|
# slot PŘED deadline (slot začínající v deadline už nepatří „do deadline").
|
||||||
|
# - min. výkon wallboxu (asset_ev_charger.min_power_w, 6 A ≈ 1380 W):
|
||||||
|
# binárka ev_on → setpoint ∈ {0} ∪ [min_power_w, max]; ev_direct ≤ gi + PV
|
||||||
|
# (fyzikální split direct/via_bat). Reporting: kWh přes ev_via_bat plní
|
||||||
|
# battery_arbitrage_czk oportunitní cenou (min sell exportního slotu dne,
|
||||||
|
# jinak terminal value) — slotový buy pro ně neplatí.
|
||||||
# - 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
|
||||||
@@ -58,13 +66,14 @@ from services.planning.constants import (
|
|||||||
from services.planning.types import (
|
from services.planning.types import (
|
||||||
DispatchResult,
|
DispatchResult,
|
||||||
PlanningSlot,
|
PlanningSlot,
|
||||||
|
_prague_calendar_date,
|
||||||
_prague_dow_hour,
|
_prague_dow_hour,
|
||||||
)
|
)
|
||||||
from services.planning.heuristics import _dispatch_grid_setpoint_w
|
from services.planning.heuristics import _dispatch_grid_setpoint_w
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
V2_BUILD_TAG = "v2-clean-2026-06-11"
|
V2_BUILD_TAG = "v2-ev-accounting-2026-06-12"
|
||||||
|
|
||||||
# Cena za vypnutí GEN portu (mikroinvertory pole B): reálné riziko/opotřebení
|
# Cena za vypnutí GEN portu (mikroinvertory pole B): reálné riziko/opotřebení
|
||||||
# cyklování stykače — drobná, ale nenulová, aby cutoff platil jen při sell < 0.
|
# cyklování stykače — drobná, ale nenulová, aby cutoff platil jen při sell < 0.
|
||||||
@@ -166,6 +175,10 @@ def solve_dispatch_v2(
|
|||||||
]
|
]
|
||||||
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)
|
ev_opp: list = [] # (var, value_czk_kwh) — energie nad target (měkký cíl)
|
||||||
|
# min. výkon wallboxu (IEC 61851: 6 A ≈ 1380 W) — setpoint ∈ {0} ∪ [min, max]
|
||||||
|
ev_min_w = [
|
||||||
|
max(0.0, float(getattr(vehicles[e], "min_power_w", 0) or 0)) for e in range(EV)
|
||||||
|
]
|
||||||
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 = [
|
||||||
@@ -222,6 +235,11 @@ def solve_dispatch_v2(
|
|||||||
prob += bc_gi[t] <= gi[t], f"bcgi_src_{t}"
|
prob += bc_gi[t] <= gi[t], f"bcgi_src_{t}"
|
||||||
# vybíjení kryje dům + EV-via-bat + export z baterie
|
# vybíjení kryje dům + EV-via-bat + export z baterie
|
||||||
prob += ge_bat[t] + pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t], f"bd_split_{t}"
|
prob += ge_bat[t] + pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t], f"bd_split_{t}"
|
||||||
|
# ev_direct fyzicky jen ze sítě + PV (ne z baterie) — split direct/via_bat
|
||||||
|
# není arbitrární, ekonomiku nemění (bilance platí stejně)
|
||||||
|
prob += (
|
||||||
|
pulp.lpSum(ev_direct[e][t] for e in range(EV)) <= gi[t] + pv_a_net + pv_b_eff
|
||||||
|
), f"evd_src_{t}"
|
||||||
|
|
||||||
# zákaz současného importu a exportu
|
# zákaz současného importu a exportu
|
||||||
prob += gi[t] <= max_imp * y_imp[t], f"imp_excl_{t}"
|
prob += gi[t] <= max_imp * y_imp[t], f"imp_excl_{t}"
|
||||||
@@ -245,15 +263,20 @@ def solve_dispatch_v2(
|
|||||||
if float(s.sell_price) < 0.0 and block_neg_sell:
|
if float(s.sell_price) < 0.0 and block_neg_sell:
|
||||||
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_sell_block_{t}"
|
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_sell_block_{t}"
|
||||||
|
|
||||||
# EV dostupnost
|
# EV dostupnost + min. výkon wallboxu (binárka jen kde je min > 0)
|
||||||
for e in range(EV):
|
for e in range(EV):
|
||||||
if not _connected(e, t):
|
if not _connected(e, t):
|
||||||
prob += ev_direct[e][t] == 0
|
prob += ev_direct[e][t] == 0
|
||||||
prob += ev_via_bat[e][t] == 0
|
prob += ev_via_bat[e][t] == 0
|
||||||
else:
|
else:
|
||||||
prob += ev_direct[e][t] + ev_via_bat[e][t] <= float(
|
ev_max_w = float(vehicles[e].max_charge_power_w)
|
||||||
vehicles[e].max_charge_power_w
|
ev_total = ev_direct[e][t] + ev_via_bat[e][t]
|
||||||
)
|
if 0 < ev_min_w[e] <= ev_max_w:
|
||||||
|
on = pulp.LpVariable(f"evon_{e}_{t}", cat=pulp.LpBinary)
|
||||||
|
prob += ev_total >= ev_min_w[e] * on, f"ev_min_{e}_{t}"
|
||||||
|
prob += ev_total <= ev_max_w * on, f"ev_max_{e}_{t}"
|
||||||
|
else:
|
||||||
|
prob += ev_total <= ev_max_w
|
||||||
|
|
||||||
# provozní režimy (tvrdé constraints dle operating-modes.md)
|
# provozní režimy (tvrdé constraints dle operating-modes.md)
|
||||||
if om == "SELF_SUSTAIN":
|
if om == "SELF_SUSTAIN":
|
||||||
@@ -266,28 +289,40 @@ def solve_dispatch_v2(
|
|||||||
prob += ge_pv[t] + ge_bat[t] == 0
|
prob += ge_pv[t] + ge_bat[t] == 0
|
||||||
prob += bd[t] == 0
|
prob += bd[t] == 0
|
||||||
|
|
||||||
# EV deadline (s placeným slackem místo infeasibility)
|
# EV deadline (s placeným slackem místo infeasibility) + měkký cíl.
|
||||||
|
# Bez session není mandát nabíjet: připojené auto bez session (stop-session,
|
||||||
|
# golden fixtures s vynulovanými sessions) nesmí při buy<0 „pumpovat" energii.
|
||||||
for e in range(EV):
|
for e in range(EV):
|
||||||
sess = ev_sessions[e] if e < len(ev_sessions) else None
|
sess = ev_sessions[e] if e < len(ev_sessions) else None
|
||||||
if sess is None or not getattr(sess, "energy_needed_wh", 0):
|
if sess is None:
|
||||||
|
for t in range(T):
|
||||||
|
if _connected(e, t):
|
||||||
|
prob += ev_direct[e][t] == 0, f"ev_nosess_d_{e}_{t}"
|
||||||
|
prob += ev_via_bat[e][t] == 0, f"ev_nosess_b_{e}_{t}"
|
||||||
continue
|
continue
|
||||||
|
needed = max(0.0, float(getattr(sess, "energy_needed_wh", 0.0) or 0.0))
|
||||||
|
unmet = pulp.LpVariable(f"ev_unmet_{e}", 0, needed)
|
||||||
|
ev_unmet.append(unmet)
|
||||||
|
if needed > 0:
|
||||||
|
# první slot s interval_start >= deadline už do deadline NEPATŘÍ
|
||||||
|
# (slot [deadline, deadline+15min) dodává energii až po odjezdu)
|
||||||
t_dl = next(
|
t_dl = next(
|
||||||
(t for t in range(T) if slots[t].interval_start >= sess.target_deadline),
|
(t for t in range(T) if slots[t].interval_start >= sess.target_deadline),
|
||||||
T - 1,
|
T,
|
||||||
)
|
)
|
||||||
unmet = pulp.LpVariable(f"ev_unmet_{e}", 0, float(sess.energy_needed_wh))
|
|
||||||
ev_unmet.append(unmet)
|
|
||||||
prob += (
|
prob += (
|
||||||
pulp.lpSum(
|
pulp.lpSum(
|
||||||
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
|
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
|
||||||
for t in range(t_dl + 1)
|
for t in range(t_dl)
|
||||||
if _connected(e, t)
|
if _connected(e, t)
|
||||||
)
|
)
|
||||||
+ unmet
|
+ unmet
|
||||||
>= float(sess.energy_needed_wh)
|
>= needed
|
||||||
), f"ev_deadline_{e}"
|
), f"ev_deadline_{e}"
|
||||||
|
|
||||||
# měkký cíl: dekompozice celkové energie == needed − unmet + opp
|
# měkký cíl: dekompozice celkové energie == needed − unmet + opp.
|
||||||
|
# Oportunistická vrstva NENÍ omezená deadline — auto bývá doma dál,
|
||||||
|
# odjezd řeší rolling replan (rozhodnutí 2026-06-12).
|
||||||
headroom = max(0.0, float(getattr(sess, "headroom_wh", 0.0) or 0.0))
|
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_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)
|
opp = pulp.LpVariable(f"ev_opp_{e}", 0, headroom if opp_val > 0 else 0.0)
|
||||||
@@ -298,7 +333,7 @@ def solve_dispatch_v2(
|
|||||||
for t in range(T)
|
for t in range(T)
|
||||||
if _connected(e, t)
|
if _connected(e, t)
|
||||||
)
|
)
|
||||||
== float(sess.energy_needed_wh) - unmet + opp
|
== needed - unmet + opp
|
||||||
), f"ev_total_{e}"
|
), 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)
|
||||||
@@ -384,9 +419,32 @@ def solve_dispatch_v2(
|
|||||||
v = pulp.value(var)
|
v = pulp.value(var)
|
||||||
return float(v) if v is not None else 0.0
|
return float(v) if v is not None else 0.0
|
||||||
|
|
||||||
|
# Reporting EV-via-bat: kWh do auta z baterie neplatí slotový buy (jdou
|
||||||
|
# z baterie), ale ušlou příležitost. Aproximace oportunitní ceny: nejnižší
|
||||||
|
# sell slotu, kde plán exportuje, v témže pražském dni; bez exportu ten den
|
||||||
|
# terminal value (Kč/kWh). Plní battery_arbitrage_czk (dřív konstantní 0).
|
||||||
|
day_min_export_sell: dict[Any, float] = {}
|
||||||
|
for t in range(T):
|
||||||
|
if _val(ge_pv[t]) + _val(ge_bat[t]) >= 1.0:
|
||||||
|
d_key = _prague_calendar_date(slots[t])
|
||||||
|
sp = float(slots[t].sell_price)
|
||||||
|
if d_key not in day_min_export_sell or sp < day_min_export_sell[d_key]:
|
||||||
|
day_min_export_sell[d_key] = sp
|
||||||
|
|
||||||
results: list[DispatchResult] = []
|
results: list[DispatchResult] = []
|
||||||
for t in range(T):
|
for t in range(T):
|
||||||
s = slots[t]
|
s = slots[t]
|
||||||
|
via1_w = _val(ev_via_bat[0][t]) if EV > 0 else 0.0
|
||||||
|
via2_w = _val(ev_via_bat[1][t]) if EV > 1 else 0.0
|
||||||
|
via_kwh = (via1_w + via2_w) * wh
|
||||||
|
if via_kwh > 1e-9:
|
||||||
|
opp_price = max(
|
||||||
|
0.0,
|
||||||
|
day_min_export_sell.get(_prague_calendar_date(s), terminal * 1000.0),
|
||||||
|
)
|
||||||
|
arb_czk = via_kwh * opp_price
|
||||||
|
else:
|
||||||
|
arb_czk = 0.0
|
||||||
bc_tot = _val(bc_pv[t]) + _val(bc_gi[t])
|
bc_tot = _val(bc_pv[t]) + _val(bc_gi[t])
|
||||||
bd_v = _val(bd[t])
|
bd_v = _val(bd[t])
|
||||||
batt_w = round(bc_tot - bd_v)
|
batt_w = round(bc_tot - bd_v)
|
||||||
@@ -434,8 +492,8 @@ def solve_dispatch_v2(
|
|||||||
if EV > 1 and s.ev2_connected
|
if EV > 1 and s.ev2_connected
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
ev1_via_bat_w=round(_val(ev_via_bat[0][t])) if EV > 0 else 0,
|
ev1_via_bat_w=round(via1_w),
|
||||||
ev2_via_bat_w=round(_val(ev_via_bat[1][t])) if EV > 1 else 0,
|
ev2_via_bat_w=round(via2_w),
|
||||||
heat_pump_enabled=hp_on,
|
heat_pump_enabled=hp_on,
|
||||||
heat_pump_setpoint_w=int(rated_hp) if hp_on else 0,
|
heat_pump_setpoint_w=int(rated_hp) if hp_on else 0,
|
||||||
pv_a_curtailed_w=round(_val(ca[t])),
|
pv_a_curtailed_w=round(_val(ca[t])),
|
||||||
@@ -444,7 +502,7 @@ def solve_dispatch_v2(
|
|||||||
effective_sell_price=float(s.sell_price),
|
effective_sell_price=float(s.sell_price),
|
||||||
is_predicted_price=bool(s.is_predicted_price),
|
is_predicted_price=bool(s.is_predicted_price),
|
||||||
cashflow_czk=round(cash_t, 4),
|
cashflow_czk=round(cash_t, 4),
|
||||||
battery_arbitrage_czk=0.0,
|
battery_arbitrage_czk=round(arb_czk, 4),
|
||||||
penalty_czk=round(pen_t, 4),
|
penalty_czk=round(pen_t, 4),
|
||||||
green_bonus_czk=float(getattr(s, "green_bonus_czk_per_slot", 0.0) or 0.0),
|
green_bonus_czk=float(getattr(s, "green_bonus_czk_per_slot", 0.0) or 0.0),
|
||||||
)
|
)
|
||||||
@@ -462,6 +520,7 @@ def solve_dispatch_v2(
|
|||||||
"gen_cutoff_available": gen_cutoff_avail,
|
"gen_cutoff_available": gen_cutoff_avail,
|
||||||
"slot_count": T,
|
"slot_count": T,
|
||||||
"ev_sessions": sum(1 for x in ev_sessions if x is not None),
|
"ev_sessions": sum(1 for x in ev_sessions if x is not None),
|
||||||
|
"ev_min_power_w": ev_min_w,
|
||||||
"masks_ignored": True,
|
"masks_ignored": True,
|
||||||
"night_buffer_slots": sum(1 for b in nb_buffer_wh if b > 0),
|
"night_buffer_slots": sum(1 for b in nb_buffer_wh if b > 0),
|
||||||
"pv_risk_frontload_czk_kwh": frontload if neg_idx else 0.0,
|
"pv_risk_frontload_czk_kwh": frontload if neg_idx else 0.0,
|
||||||
|
|||||||
@@ -66,7 +66,16 @@ _VEHICLES = [
|
|||||||
_BASE = datetime(2026, 6, 10, 0, 0, tzinfo=timezone.utc)
|
_BASE = datetime(2026, 6, 10, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _solve(slots, *, battery=None, grid=None, ev_sessions=(None, None), soc0=None, mode="AUTO"):
|
def _solve(
|
||||||
|
slots,
|
||||||
|
*,
|
||||||
|
battery=None,
|
||||||
|
grid=None,
|
||||||
|
ev_sessions=(None, None),
|
||||||
|
soc0=None,
|
||||||
|
mode="AUTO",
|
||||||
|
vehicles=None,
|
||||||
|
):
|
||||||
bat = battery or _battery()
|
bat = battery or _battery()
|
||||||
return solve_dispatch_v2(
|
return solve_dispatch_v2(
|
||||||
slots,
|
slots,
|
||||||
@@ -74,7 +83,7 @@ def _solve(slots, *, battery=None, grid=None, ev_sessions=(None, None), soc0=Non
|
|||||||
_HP,
|
_HP,
|
||||||
grid or _grid(),
|
grid or _grid(),
|
||||||
list(ev_sessions),
|
list(ev_sessions),
|
||||||
_VEHICLES,
|
vehicles if vehicles is not None else _VEHICLES,
|
||||||
soc0 if soc0 is not None else 0.5 * bat.usable_capacity_wh,
|
soc0 if soc0 is not None else 0.5 * bat.usable_capacity_wh,
|
||||||
50.0,
|
50.0,
|
||||||
operating_mode=mode,
|
operating_mode=mode,
|
||||||
@@ -296,6 +305,144 @@ class EvOpportunisticTests(unittest.TestCase):
|
|||||||
self.assertLessEqual(delivered, 3000.0 + 1.0)
|
self.assertLessEqual(delivered, 3000.0 + 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class EvAccountingTests(unittest.TestCase):
|
||||||
|
"""EV účtování 2026-06-12: deadline boundary, stop-session, fyzikální split,
|
||||||
|
min. výkon wallboxu, opp po deadline, battery_arbitrage_czk reporting."""
|
||||||
|
|
||||||
|
def test_deadline_boundary_slot_excluded(self) -> None:
|
||||||
|
# slot začínající přesně v deadline (slot 4) už do deadline nepatří;
|
||||||
|
# levné sloty 4..7 nesmí krýt tvrdý cíl (dřív off-by-one t_dl+1)
|
||||||
|
slots = [
|
||||||
|
_slot(_BASE, i, buy=5.0 if i < 4 else 0.5, sell=0.2, ev1=True)
|
||||||
|
for i in range(8)
|
||||||
|
]
|
||||||
|
session = SimpleNamespace(
|
||||||
|
target_deadline=_BASE + timedelta(hours=1), # = start slotu 4
|
||||||
|
energy_needed_wh=4000.0,
|
||||||
|
headroom_wh=0.0,
|
||||||
|
opportunistic_value_czk_kwh=0.0,
|
||||||
|
)
|
||||||
|
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||||||
|
before = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[:4])
|
||||||
|
after = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:])
|
||||||
|
self.assertGreaterEqual(before, 4000.0 - 1.0, "tvrdý cíl jen sloty PŘED deadline")
|
||||||
|
self.assertLessEqual(after, 1.0, "slot v deadline a dál nekryje tvrdý cíl")
|
||||||
|
self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0])
|
||||||
|
|
||||||
|
def test_stop_session_zero_everywhere(self) -> None:
|
||||||
|
# needed 0 + opp 0 (stop-session) → EV nula i při záporných cenách
|
||||||
|
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)]
|
||||||
|
session = SimpleNamespace(
|
||||||
|
target_deadline=_BASE + timedelta(hours=2),
|
||||||
|
energy_needed_wh=0.0,
|
||||||
|
headroom_wh=0.0,
|
||||||
|
opportunistic_value_czk_kwh=0.0,
|
||||||
|
)
|
||||||
|
results, _, _ = _solve(slots, ev_sessions=(session, None))
|
||||||
|
for r in results:
|
||||||
|
self.assertEqual(r.ev1_setpoint_w or 0, 0)
|
||||||
|
|
||||||
|
def test_no_session_zero_even_at_negative_buy(self) -> None:
|
||||||
|
# připojené auto BEZ session nemá mandát nabíjet (golden fixtures)
|
||||||
|
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)]
|
||||||
|
results, _, _ = _solve(slots, ev_sessions=(None, None))
|
||||||
|
for r in results:
|
||||||
|
self.assertEqual(r.ev1_setpoint_w or 0, 0)
|
||||||
|
|
||||||
|
def test_ev_direct_within_grid_plus_pv(self) -> None:
|
||||||
|
# fyzikální split: direct (= setpoint − via_bat) nesmí překročit gi + PV
|
||||||
|
slots = [
|
||||||
|
_slot(_BASE, i, buy=2.0, sell=1.0, pv_a=(3000 if i < 4 else 0), ev1=True)
|
||||||
|
for i in range(12)
|
||||||
|
]
|
||||||
|
bat = _battery()
|
||||||
|
session = SimpleNamespace(
|
||||||
|
target_deadline=_BASE + timedelta(hours=3),
|
||||||
|
energy_needed_wh=10000.0,
|
||||||
|
headroom_wh=0.0,
|
||||||
|
opportunistic_value_czk_kwh=0.0,
|
||||||
|
)
|
||||||
|
results, _, _ = _solve(
|
||||||
|
slots, battery=bat, soc0=0.9 * bat.usable_capacity_wh,
|
||||||
|
ev_sessions=(session, None),
|
||||||
|
)
|
||||||
|
for i, r in enumerate(results):
|
||||||
|
direct = (r.ev1_setpoint_w or 0) - r.ev1_via_bat_w
|
||||||
|
gi_w = max(0, r.grid_setpoint_w)
|
||||||
|
pv_w = slots[i].pv_a_forecast_w + slots[i].pv_b_forecast_w
|
||||||
|
self.assertLessEqual(direct, gi_w + pv_w + 2, f"slot {i}: direct > gi+pv")
|
||||||
|
|
||||||
|
def test_min_power_setpoints_zero_or_above_min(self) -> None:
|
||||||
|
# wallbox min 1380 W (6 A): setpoint ∈ {0} ∪ [1380, max] — žádné 400–900 W
|
||||||
|
vehicles = [
|
||||||
|
SimpleNamespace(
|
||||||
|
max_charge_power_w=11_000, min_power_w=1380,
|
||||||
|
battery_capacity_kwh=60.0, default_target_soc_pct=80.0,
|
||||||
|
),
|
||||||
|
_VEHICLES[1],
|
||||||
|
]
|
||||||
|
# ceny nutí rozprostřít malé množství energie → bez binárky by vyšlo ~86 W/slot
|
||||||
|
slots = [_slot(_BASE, i, buy=2.0 + 0.01 * i, sell=1.0, ev1=True) for i in range(8)]
|
||||||
|
session = SimpleNamespace(
|
||||||
|
target_deadline=_BASE + timedelta(hours=2),
|
||||||
|
energy_needed_wh=690.0, # 2 sloty × 1380 W × 0.25 h
|
||||||
|
headroom_wh=0.0,
|
||||||
|
opportunistic_value_czk_kwh=0.0,
|
||||||
|
)
|
||||||
|
results, _, _ = _solve(slots, ev_sessions=(session, None), vehicles=vehicles)
|
||||||
|
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||||
|
self.assertGreaterEqual(delivered, 690.0 - 1.0)
|
||||||
|
for i, r in enumerate(results):
|
||||||
|
sp = r.ev1_setpoint_w or 0
|
||||||
|
self.assertTrue(
|
||||||
|
sp == 0 or sp >= 1379,
|
||||||
|
f"slot {i}: setpoint {sp} W je pod minimem wallboxu",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_opportunistic_after_deadline_allowed(self) -> None:
|
||||||
|
# ROZHODNUTO 2026-06-12: opp vrstva NENÍ omezená deadline — záporné ceny
|
||||||
|
# po deadline smí téct do auta (odjezd řeší rolling replan)
|
||||||
|
slots = [
|
||||||
|
_slot(_BASE, i, buy=(3.0 if i < 4 else -1.5), sell=(1.0 if i < 4 else -0.5),
|
||||||
|
ev1=True, load=300)
|
||||||
|
for i in range(16)
|
||||||
|
]
|
||||||
|
session = SimpleNamespace(
|
||||||
|
target_deadline=_BASE + timedelta(hours=1), # slot 4
|
||||||
|
energy_needed_wh=2000.0,
|
||||||
|
headroom_wh=20000.0,
|
||||||
|
opportunistic_value_czk_kwh=1.0,
|
||||||
|
)
|
||||||
|
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||||||
|
after_deadline = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:])
|
||||||
|
total = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||||||
|
self.assertGreater(after_deadline, 0.0, "opp po deadline musí zůstat povolené")
|
||||||
|
self.assertLessEqual(total, 2000.0 + 20000.0 + 1.0, "strop needed + headroom")
|
||||||
|
self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0.0)
|
||||||
|
|
||||||
|
def test_battery_arbitrage_reported_for_via_bat(self) -> None:
|
||||||
|
# EV kryté z baterie (noc, drahý buy, plná baterie) → via_bat > 0 a
|
||||||
|
# battery_arbitrage_czk nese oportunitní cenu (ne konstantní 0)
|
||||||
|
bat = _battery()
|
||||||
|
slots = [_slot(_BASE, i, buy=8.0, sell=1.0, ev1=True, load=300) for i in range(8)]
|
||||||
|
session = SimpleNamespace(
|
||||||
|
target_deadline=_BASE + timedelta(hours=2),
|
||||||
|
energy_needed_wh=6000.0,
|
||||||
|
headroom_wh=0.0,
|
||||||
|
opportunistic_value_czk_kwh=0.0,
|
||||||
|
)
|
||||||
|
results, _, _ = _solve(
|
||||||
|
slots, battery=bat, soc0=bat.soc_max_wh, ev_sessions=(session, None)
|
||||||
|
)
|
||||||
|
via = sum(r.ev1_via_bat_w for r in results)
|
||||||
|
self.assertGreater(via, 0, "drahý buy + plná baterie → EV z baterie")
|
||||||
|
arb = sum(r.battery_arbitrage_czk for r in results)
|
||||||
|
self.assertGreater(arb, 0.0, "via_bat sloty musí reportovat oportunitní Kč")
|
||||||
|
for r in results:
|
||||||
|
if r.ev1_via_bat_w == 0:
|
||||||
|
self.assertEqual(r.battery_arbitrage_czk, 0.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)]
|
||||||
|
|||||||
16
db/migration/V099__ev_session_opportunistic.sql
Normal file
16
db/migration/V099__ev_session_opportunistic.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Per-session override oportunistického EV nabíjení (V094 zavedl hodnotu
|
||||||
|
-- na asset_vehicle). NULL = zdědit z vozidla; 0 = oportunismus pro tuto
|
||||||
|
-- session vypnut („nenabíjet nad target"); > 0 = vlastní ocenění kWh.
|
||||||
|
-- Efektivní hodnota se skládá v ems.fn_planning_site_context
|
||||||
|
-- (coalesce(session, vehicle)); patch přes ems.fn_ev_session_apply_patch.
|
||||||
|
|
||||||
|
alter table ems.ev_session
|
||||||
|
add column if not exists opportunistic_value_czk_kwh numeric(6, 3) null;
|
||||||
|
|
||||||
|
comment on column ems.ev_session.opportunistic_value_czk_kwh is
|
||||||
|
'Per-session override hodnoty kWh nabité NAD target (Kč/kWh). NULL = zdědit z asset_vehicle.opportunistic_value_czk_kwh; 0 = oportunistické nabíjení pro tuto session vypnuto (headroom_wh = 0 v plánovacím kontextu).';
|
||||||
|
|
||||||
|
-- v2 reporting: battery_arbitrage_czk nese oportunitní hodnotu kWh z baterie
|
||||||
|
-- do EV (via_bat × oportunitní cena), ne konstantní 0 / v1 marži exportu.
|
||||||
|
comment on column ems.planning_interval.battery_arbitrage_czk is
|
||||||
|
'Ekonomika baterie mimo slotový cashflow (Kč). v1: marže exportu baterie ge_bat × (sell − acquisition) × h. v2: oportunitní cena EV energie z baterie — ev_via_bat × (nejnižší sell exportního slotu téhož pražského dne, jinak terminal value); slotový buy pro tyto kWh neplatí.';
|
||||||
@@ -11,10 +11,19 @@ declare
|
|||||||
begin
|
begin
|
||||||
if not (p_patch ? 'target_soc_pct')
|
if not (p_patch ? 'target_soc_pct')
|
||||||
and not (p_patch ? 'target_deadline')
|
and not (p_patch ? 'target_deadline')
|
||||||
and not (p_patch ? 'soc_at_connect_pct') then
|
and not (p_patch ? 'soc_at_connect_pct')
|
||||||
|
and not (p_patch ? 'opportunistic_value_czk_kwh') then
|
||||||
return jsonb_build_object('success', false, 'error', 'no_fields');
|
return jsonb_build_object('success', false, 'error', 'no_fields');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
|
if (p_patch ? 'opportunistic_value_czk_kwh')
|
||||||
|
and jsonb_typeof(p_patch->'opportunistic_value_czk_kwh') <> 'null'
|
||||||
|
and (p_patch->>'opportunistic_value_czk_kwh')::numeric < 0 then
|
||||||
|
return jsonb_build_object(
|
||||||
|
'success', false, 'error', 'opportunistic_value_negative'
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
update ems.ev_session es
|
update ems.ev_session es
|
||||||
set
|
set
|
||||||
target_soc_pct = case
|
target_soc_pct = case
|
||||||
@@ -44,6 +53,16 @@ begin
|
|||||||
else (p_patch->>'target_deadline')::timestamptz
|
else (p_patch->>'target_deadline')::timestamptz
|
||||||
end
|
end
|
||||||
else es.target_deadline
|
else es.target_deadline
|
||||||
|
end,
|
||||||
|
-- NULL = zdědit z asset_vehicle; 0 = oportunismus pro session vypnut
|
||||||
|
opportunistic_value_czk_kwh = case
|
||||||
|
when p_patch ? 'opportunistic_value_czk_kwh' then
|
||||||
|
case
|
||||||
|
when p_patch->'opportunistic_value_czk_kwh' is null
|
||||||
|
or jsonb_typeof(p_patch->'opportunistic_value_czk_kwh') = 'null' then null
|
||||||
|
else (p_patch->>'opportunistic_value_czk_kwh')::numeric
|
||||||
|
end
|
||||||
|
else es.opportunistic_value_czk_kwh
|
||||||
end
|
end
|
||||||
where es.id = p_session_id
|
where es.id = p_session_id
|
||||||
and es.site_id = p_site_id
|
and es.site_id = p_site_id
|
||||||
@@ -57,5 +76,5 @@ begin
|
|||||||
end;
|
end;
|
||||||
$fn$;
|
$fn$;
|
||||||
|
|
||||||
comment on function ems.fn_ev_session_apply_patch(int, int, jsonb) is
|
comment on function ems.fn_ev_session_apply_patch is
|
||||||
'PATCH EV session – jen klíče přítomné v JSON (ISO string pro deadline; soc_at_connect_pct z Tesla API).';
|
'PATCH EV session – jen klíče přítomné v JSON (ISO string pro deadline; soc_at_connect_pct z Tesla API; opportunistic_value_czk_kwh >= 0, NULL = zdědit z vozidla, 0 = vypnout).';
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ begin
|
|||||||
'deye_gen_cutoff_enabled', pi.deye_gen_cutoff_enabled,
|
'deye_gen_cutoff_enabled', pi.deye_gen_cutoff_enabled,
|
||||||
'ev1_setpoint_w', pi.ev1_setpoint_w,
|
'ev1_setpoint_w', pi.ev1_setpoint_w,
|
||||||
'ev2_setpoint_w', pi.ev2_setpoint_w,
|
'ev2_setpoint_w', pi.ev2_setpoint_w,
|
||||||
|
'ev1_via_bat_w', pi.ev1_via_bat_w,
|
||||||
|
'ev2_via_bat_w', pi.ev2_via_bat_w,
|
||||||
'heat_pump_enabled', pi.heat_pump_enabled,
|
'heat_pump_enabled', pi.heat_pump_enabled,
|
||||||
'pv_a_curtailed_w', pi.pv_a_curtailed_w,
|
'pv_a_curtailed_w', pi.pv_a_curtailed_w,
|
||||||
'expected_cost_czk', pi.expected_cost_czk,
|
'expected_cost_czk', pi.expected_cost_czk,
|
||||||
@@ -123,6 +125,8 @@ begin
|
|||||||
'deye_gen_cutoff_enabled', null,
|
'deye_gen_cutoff_enabled', null,
|
||||||
'ev1_setpoint_w', null,
|
'ev1_setpoint_w', null,
|
||||||
'ev2_setpoint_w', null,
|
'ev2_setpoint_w', null,
|
||||||
|
'ev1_via_bat_w', null,
|
||||||
|
'ev2_via_bat_w', null,
|
||||||
'heat_pump_enabled', null,
|
'heat_pump_enabled', null,
|
||||||
'pv_a_curtailed_w', null,
|
'pv_a_curtailed_w', null,
|
||||||
'expected_cost_czk', null,
|
'expected_cost_czk', null,
|
||||||
@@ -248,5 +252,5 @@ begin
|
|||||||
end;
|
end;
|
||||||
$fn$;
|
$fn$;
|
||||||
|
|
||||||
comment on function ems.fn_plan_current_bundle(int) is
|
comment on function ems.fn_plan_current_bundle is
|
||||||
'Aktivní planning_run + intervaly + souhrn (GET /plan/current). PV za horizont plánu z canonical forecast; delta profil z cache.';
|
'Aktivní planning_run + intervaly + souhrn (GET /plan/current). PV za horizont plánu z canonical forecast; delta profil z cache; intervals nesou ev1/ev2_via_bat_w (EV energie z baterie — UI nemá cenit slotovým buy).';
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ begin
|
|||||||
jsonb_agg(
|
jsonb_agg(
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
'max_charge_power_w', v.max_charge_power_w,
|
'max_charge_power_w', v.max_charge_power_w,
|
||||||
|
'min_power_w', coalesce(ch.min_power_w, 0),
|
||||||
'battery_capacity_kwh', v.battery_capacity_kwh,
|
'battery_capacity_kwh', v.battery_capacity_kwh,
|
||||||
'default_target_soc_pct', v.default_target_soc_pct
|
'default_target_soc_pct', v.default_target_soc_pct
|
||||||
)
|
)
|
||||||
@@ -193,8 +194,11 @@ begin
|
|||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- coalesce(es.energy_delivered_wh, 0)::numeric
|
||||||
) <= 0
|
) <= 0
|
||||||
and (
|
and (
|
||||||
coalesce(v.opportunistic_value_czk_kwh, 0) <= 0
|
coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0
|
||||||
or (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct)) <= 0
|
or (100 - greatest(
|
||||||
|
coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric,
|
||||||
|
es.soc_at_connect_pct::numeric
|
||||||
|
)) <= 0
|
||||||
) then null::jsonb
|
) then null::jsonb
|
||||||
else jsonb_build_object(
|
else jsonb_build_object(
|
||||||
'target_deadline', es.target_deadline,
|
'target_deadline', es.target_deadline,
|
||||||
@@ -205,15 +209,20 @@ begin
|
|||||||
* (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 od max(target, SoC při připojení): „nenabíjet" (nízký
|
||||||
|
-- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může
|
||||||
|
-- fyzicky vzít jen energii nad svým aktuálním SoC.
|
||||||
'headroom_wh', case
|
'headroom_wh', case
|
||||||
when coalesce(v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
|
when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
|
||||||
0,
|
0,
|
||||||
(100 - coalesce(es.target_soc_pct, v.default_target_soc_pct))::numeric
|
(100 - greatest(
|
||||||
/ 100.0 * (v.battery_capacity_kwh * 1000)
|
coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric,
|
||||||
|
es.soc_at_connect_pct::numeric
|
||||||
|
)) / 100.0 * (v.battery_capacity_kwh * 1000)
|
||||||
)
|
)
|
||||||
else 0
|
else 0
|
||||||
end,
|
end,
|
||||||
'opportunistic_value_czk_kwh', coalesce(v.opportunistic_value_czk_kwh, 0)
|
'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
from ems.ev_session es
|
from ems.ev_session es
|
||||||
@@ -238,8 +247,11 @@ begin
|
|||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- coalesce(es.energy_delivered_wh, 0)::numeric
|
||||||
) <= 0
|
) <= 0
|
||||||
and (
|
and (
|
||||||
coalesce(v.opportunistic_value_czk_kwh, 0) <= 0
|
coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0
|
||||||
or (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct)) <= 0
|
or (100 - greatest(
|
||||||
|
coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric,
|
||||||
|
es.soc_at_connect_pct::numeric
|
||||||
|
)) <= 0
|
||||||
) then null::jsonb
|
) then null::jsonb
|
||||||
else jsonb_build_object(
|
else jsonb_build_object(
|
||||||
'target_deadline', es.target_deadline,
|
'target_deadline', es.target_deadline,
|
||||||
@@ -250,15 +262,20 @@ begin
|
|||||||
* (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 od max(target, SoC při připojení): „nenabíjet" (nízký
|
||||||
|
-- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může
|
||||||
|
-- fyzicky vzít jen energii nad svým aktuálním SoC.
|
||||||
'headroom_wh', case
|
'headroom_wh', case
|
||||||
when coalesce(v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
|
when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
|
||||||
0,
|
0,
|
||||||
(100 - coalesce(es.target_soc_pct, v.default_target_soc_pct))::numeric
|
(100 - greatest(
|
||||||
/ 100.0 * (v.battery_capacity_kwh * 1000)
|
coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric,
|
||||||
|
es.soc_at_connect_pct::numeric
|
||||||
|
)) / 100.0 * (v.battery_capacity_kwh * 1000)
|
||||||
)
|
)
|
||||||
else 0
|
else 0
|
||||||
end,
|
end,
|
||||||
'opportunistic_value_czk_kwh', coalesce(v.opportunistic_value_czk_kwh, 0)
|
'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
from ems.ev_session es
|
from ems.ev_session es
|
||||||
@@ -333,5 +350,5 @@ begin
|
|||||||
end;
|
end;
|
||||||
$fn$;
|
$fn$;
|
||||||
|
|
||||||
comment on function ems.fn_planning_site_context(int) is
|
comment on function ems.fn_planning_site_context is
|
||||||
'Kontext pro planning_engine / LP (bez samotného solveru).';
|
'Kontext pro planning_engine / LP (bez samotného solveru). EV session: opportunistic_value = coalesce(session, vehicle); headroom_wh od max(target, soc_at_connect), 0 při vypnutém oportunismu; vehicles nesou min_power_w wallboxu.';
|
||||||
|
|||||||
@@ -376,12 +376,33 @@ stav baterie auta → cíl (+kWh), deadline, plánovaná nabíjecí okna s ø ce
|
|||||||
|
|
||||||
Tvrdý cíl (deadline) = „bez tohohle neodjedu"; měkký cíl = „klidně doplň
|
Tvrdý cíl (deadline) = „bez tohohle neodjedu"; měkký cíl = „klidně doplň
|
||||||
do 100 %, když je energie skoro zadarmo". Implementace: dekompozice
|
do 100 %, když je energie skoro zadarmo". Implementace: dekompozice
|
||||||
Σ(EV energie) == needed − unmet + opp; `opp ∈ [0, headroom]`
|
Σ(EV energie) == needed − unmet + opp; `opp ∈ [0, headroom]`.
|
||||||
(headroom = (100 − target) % kapacity, jen když `asset_vehicle.
|
**Headroom = (100 − max(target, soc_at_connect)) % kapacity** (fix paradoxu
|
||||||
opportunistic_value_czk_kwh > 0`; default 1 Kč/kWh, 0 = vypnuto).
|
„nižší target → větší headroom": auto fyzicky bere jen energii nad svým
|
||||||
|
aktuálním SoC). **Hodnota kWh:** `coalesce(ev_session.opportunistic_value_czk_kwh,
|
||||||
|
asset_vehicle.opportunistic_value_czk_kwh)` — V099 přidal per-session override
|
||||||
|
(NULL = zdědit z vozidla, 0 = vypnout pro session ⇒ headroom_wh = 0; patch
|
||||||
|
klíčem `opportunistic_value_czk_kwh` ve `fn_ev_session_apply_patch`, validace ≥ 0).
|
||||||
|
Default vozidla 1 Kč/kWh, 0 = vypnuto.
|
||||||
Hodnota = ušetřené BUDOUCÍ nabíjení (auto neumí zpět — žádný noční prodej),
|
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
|
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
|
(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
|
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).
|
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.
|
a **bez session je EV == 0** (stop-session nevypíná jen tvrdý cíl, ale i
|
||||||
|
oportunismus). Session zůstává v plánu i po dosažení targetu, dokud má headroom;
|
||||||
|
**oportunistická vrstva není omezená deadline** (auto bývá doma dál, odjezd
|
||||||
|
řeší rolling replan — rozhodnutí 2026-06-12).
|
||||||
|
|
||||||
|
### Min. výkon wallboxu a účtování via-bat (2026-06-12, dev)
|
||||||
|
|
||||||
|
- **`asset_ev_charger.min_power_w`** (1380 W = 6 A IEC 61851) jde přes
|
||||||
|
`fn_planning_site_context` do solver_v2: binárka `ev_on[e][t]`,
|
||||||
|
`setpoint ∈ {0} ∪ [min_power_w, max]` — žádné nevykonatelné 400–900 W.
|
||||||
|
- **Tvrdý cíl** sčítá jen sloty **před** deadline (slot začínající v deadline
|
||||||
|
už nepatří „do deadline" — oprava off-by-one).
|
||||||
|
- **`ev_direct ≤ gi + PV`** (fyzikální split; via_bat kryje vybíjení baterie).
|
||||||
|
- **Reporting:** kWh do EV z baterie (via_bat) neplatí slotový buy; solver_v2
|
||||||
|
je oceňuje oportunitní cenou v `planning_interval.battery_arbitrage_czk`
|
||||||
|
(min sell exportního slotu téhož pražského dne, jinak terminal value) a
|
||||||
|
`fn_plan_current_bundle.intervals` nese `ev1/ev2_via_bat_w` pro UI.
|
||||||
|
|||||||
@@ -819,4 +819,30 @@ Plánovač má dvě implementace, přepínané env proměnnými (`backend/app/co
|
|||||||
front-load v sell<0 (`planner_pv_risk_frontload_czk_kwh`, V090), denní SoC
|
front-load v sell<0 (`planner_pv_risk_frontload_czk_kwh`, V090), denní SoC
|
||||||
rampa (`safety_soc_target_wh` × `planner_safety_soc_risk_factor`, V091).
|
rampa (`safety_soc_target_wh` × `planner_safety_soc_risk_factor`, V091).
|
||||||
Detail: hlavička `solver_v2.py` + changelog 2026-06-12.
|
Detail: hlavička `solver_v2.py` + changelog 2026-06-12.
|
||||||
|
- **EV ve v2 — účtování, headroom, min. výkon (2026-06-12):**
|
||||||
|
- **Deadline boundary:** tvrdý cíl sčítá energii jen ve slotech `t < t_deadline`
|
||||||
|
(první slot s `interval_start >= deadline` už energii dodává po odjezdu —
|
||||||
|
dřívější off-by-one `range(t_dl + 1)` opraven).
|
||||||
|
- **Měkký cíl (oportunismus):** dekompozice `Σ(EV energie) == needed − unmet + opp`;
|
||||||
|
`opp ∈ [0, headroom_wh]`. **Headroom** = `(100 − max(target_soc_pct,
|
||||||
|
soc_at_connect_pct)) % kapacity` (R__039) — „nenabíjet" (nízký target) už
|
||||||
|
paradoxně NEzvětšuje oportunistickou vrstvu; auto fyzicky bere jen nad svým
|
||||||
|
SoC. Hodnota kWh = `coalesce(ev_session.opportunistic_value_czk_kwh,
|
||||||
|
asset_vehicle.opportunistic_value_czk_kwh)` (V099: session override; NULL =
|
||||||
|
zdědit, 0 = vypnout pro session → `headroom_wh = 0`); patch klíčem
|
||||||
|
`opportunistic_value_czk_kwh` v `fn_ev_session_apply_patch` (validace ≥ 0).
|
||||||
|
**Oportunistická vrstva NENÍ omezená deadline** (rozhodnutí 2026-06-12:
|
||||||
|
auto bývá doma dál, odjezd řeší rolling replan). **Bez session je EV == 0**
|
||||||
|
i při `buy < 0` (stop-session; dřív neomezené „pumpování").
|
||||||
|
- **Min. výkon wallboxu:** `asset_ev_charger.min_power_w` (6 A ≈ 1380 W) jde
|
||||||
|
přes `fn_planning_site_context` (vehicles JSON) do binárky `ev_on[e][t]` —
|
||||||
|
setpoint ∈ {0} ∪ [min, max]; konec nevykonatelných 400–900 W setpointů.
|
||||||
|
- **Fyzikální split:** `Σ_e ev_direct[e][t] ≤ gi[t] + pv_a_net + pv_b_eff`
|
||||||
|
(direct jen ze sítě + PV; via_bat kryje `bd`). Ekonomiku nemění, ale split
|
||||||
|
direct/via_bat už není arbitrární.
|
||||||
|
- **Reporting via_bat:** kWh do EV z baterie neplatí slotový buy — solver_v2
|
||||||
|
plní `battery_arbitrage_czk` oportunitní cenou (min `sell` exportního slotu
|
||||||
|
téhož pražského dne, jinak terminal value; clamp ≥ 0) × via_bat energie
|
||||||
|
(dřív konstantní 0). `fn_plan_current_bundle` nese `ev1/ev2_via_bat_w`
|
||||||
|
v `intervals`, aby UI necenilo EV kWh z baterie slotovým buy.
|
||||||
- Regresní brána a měření: `scripts/harness/README.md` (golden replay, economics report, penalty audit, `solver_v2_eval.py`); plán refaktoru: `docs/refactor-clean-planner.md`.
|
- Regresní brána a měření: `scripts/harness/README.md` (golden replay, economics report, penalty audit, `solver_v2_eval.py`); plán refaktoru: `docs/refactor-clean-planner.md`.
|
||||||
|
|||||||
@@ -5,6 +5,45 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-12 — EV účtování v2: headroom, deadline boundary, min. výkon WB, via-bat reporting
|
||||||
|
|
||||||
|
**Problém (hloubková diagnóza EV):** (a) „nenabíjet" (nízký target) oportunismus
|
||||||
|
nevypnul a paradoxně ZVĚTŠIL headroom (= 100 − target); session bez mandátu
|
||||||
|
(nebo žádná session) při `buy < 0` pumpovala energii bez stropu; (b) off-by-one
|
||||||
|
v deadline sumě (`range(t_dl + 1)` — slot začínající v deadline se počítal „do
|
||||||
|
deadline"); (c) reporting lhal: `battery_arbitrage_czk` konstantně 0, via_bat se
|
||||||
|
nepropagoval do bundle, UI cenilo EV kWh z baterie slotovým buy; (d) split
|
||||||
|
ev_direct/via_bat byl arbitrární (direct nesvázán s gi + PV); (e) min. výkon
|
||||||
|
wallboxu (`asset_ev_charger.min_power_w`, 1380 W = 6 A) ignorován → setpointy
|
||||||
|
400–900 W nevykonatelné.
|
||||||
|
|
||||||
|
**Mechanismus:** headroom z `max(target_soc_pct, soc_at_connect_pct)` a
|
||||||
|
opportunistic_value = `coalesce(session, vehicle)` v `fn_planning_site_context`
|
||||||
|
(V099: `ev_session.opportunistic_value_czk_kwh`, NULL = zdědit, 0 = vypnout;
|
||||||
|
patch přes `fn_ev_session_apply_patch`, validace ≥ 0); solver_v2: deadline suma
|
||||||
|
`range(t_dl)`, bez session EV == 0, dekompozice `total == needed − unmet + opp`
|
||||||
|
i pro needed = 0; binárka `ev_on` → setpoint ∈ {0} ∪ [min_power_w, max]
|
||||||
|
(min_power_w nově ve vehicles JSON kontextu); `Σ ev_direct ≤ gi + pv_a_net +
|
||||||
|
pv_b_eff`; `battery_arbitrage_czk` = via_bat kWh × oportunitní cena (min sell
|
||||||
|
exportního slotu téhož pražského dne, jinak terminal value, clamp ≥ 0);
|
||||||
|
`fn_plan_current_bundle.intervals` + `ev1/ev2_via_bat_w`. **Oportunismus PO
|
||||||
|
deadline zůstává POVOLENÝ** (rozhodnutí: auto často doma, odjezd řeší rolling
|
||||||
|
replan). Fixtures: `extract_fixtures.py --keep-ev` (default dál EV nuluje).
|
||||||
|
|
||||||
|
**Soubory:** `V099__ev_session_opportunistic.sql`, `R__039_fn_planning_site_context.sql`,
|
||||||
|
`R__015_fn_ev_session_patch.sql`, `R__033_fn_plan_current_bundle.sql`,
|
||||||
|
`services/planning/solver_v2.py`, `services/planning/db_io.py`,
|
||||||
|
`scripts/harness/extract_fixtures.py` + README, `docs/04-modules/ev-charging.md`,
|
||||||
|
`docs/04-modules/planning.md`.
|
||||||
|
|
||||||
|
**Ověření:** `tests/test_solver_v2.py` +7 (deadline boundary, stop-session,
|
||||||
|
no-session při buy<0, direct ≤ gi+pv, setpoint ∈ {0}∪[1380, max], opp po
|
||||||
|
deadline > 0, battery_arbitrage_czk > 0 u via_bat); golden gate beze změny
|
||||||
|
snapshotů (v1 nedotčen, fixtures bez EV); `solver_v2_eval.py` před/po identický
|
||||||
|
(CELKEM −1283.5 Kč, Δ −221.9 vs v1); plná sada 310 passed / 4 xfailed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-06-12 — idle-skip telemetrie: TUV delta normalizovaná na °C/min
|
## 2026-06-12 — idle-skip telemetrie: TUV delta normalizovaná na °C/min
|
||||||
|
|
||||||
**Problém:** telemetry_collector nově přeskakuje 1min zápisy idle zařízení
|
**Problém:** telemetry_collector nově přeskakuje 1min zápisy idle zařízení
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ python3 scripts/harness/extract_fixtures.py --site-code home-01 --day 2026-06-07
|
|||||||
cd backend && GOLDEN_UPDATE=1 python3 -m pytest tests/test_golden_replay.py -q
|
cd backend && GOLDEN_UPDATE=1 python3 -m pytest tests/test_golden_replay.py -q
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--keep-ev` zmrazí do fixture i otevřené EV sessions z doby extrakce (default je
|
||||||
|
vynulovat — historické okno bez session). Hodí se pro EV scénáře (deadline,
|
||||||
|
měkký cíl / oportunistické nabíjení, min. výkon wallboxu); `meta.keep_ev`
|
||||||
|
ve fixture říká, jak byla pořízena.
|
||||||
|
|
||||||
Pokryté scénáře (v4 fixtures): home-01 hluboký neg-sell (sell −1.57, buy −0.89),
|
Pokryté scénáře (v4 fixtures): home-01 hluboký neg-sell (sell −1.57, buy −0.89),
|
||||||
home-01 běžný spot den, BA81 běžný den, KV1 fixní nákup. Při změnách heuristik
|
home-01 běžný spot den, BA81 běžný den, KV1 fixní nákup. Při změnách heuristik
|
||||||
přidávej scénář, který změnu pokrývá.
|
přidávej scénář, který změnu pokrývá.
|
||||||
|
|||||||
@@ -154,9 +154,11 @@ async def extract(args: argparse.Namespace) -> Path:
|
|||||||
|
|
||||||
# Determinismus replay:
|
# Determinismus replay:
|
||||||
# - SoC/TUV fixujeme do contextu (přepis aktuálních hodnot historickými / extrakčními),
|
# - SoC/TUV fixujeme do contextu (přepis aktuálních hodnot historickými / extrakčními),
|
||||||
# - otevřené EV sessions z doby extrakce nepatří k historickému oknu → vynulovat,
|
# - otevřené EV sessions z doby extrakce nepatří k historickému oknu → default
|
||||||
|
# vynulovat; --keep-ev je zmrazí do fixture (EV scénáře: deadline, měkký cíl),
|
||||||
# - operating_mode fixně AUTO (plný solver, srovnatelnost napříč fixtures).
|
# - operating_mode fixně AUTO (plný solver, srovnatelnost napříč fixtures).
|
||||||
ctx["soc_wh"] = soc_wh
|
ctx["soc_wh"] = soc_wh
|
||||||
|
if not args.keep_ev:
|
||||||
ctx["ev_sessions"] = []
|
ctx["ev_sessions"] = []
|
||||||
ctx["operating_mode"] = "AUTO"
|
ctx["operating_mode"] = "AUTO"
|
||||||
|
|
||||||
@@ -172,12 +174,18 @@ async def extract(args: argparse.Namespace) -> Path:
|
|||||||
"soc_wh": round(soc_wh, 1),
|
"soc_wh": round(soc_wh, 1),
|
||||||
"soc_source": soc_source,
|
"soc_source": soc_source,
|
||||||
"tag": args.tag,
|
"tag": args.tag,
|
||||||
|
"keep_ev": bool(args.keep_ev),
|
||||||
"extracted_at": datetime.now(tz=PRAGUE).isoformat(),
|
"extracted_at": datetime.now(tz=PRAGUE).isoformat(),
|
||||||
"dsn_host": dsn.split("@")[-1].split("/")[0] if "@" in dsn else "?",
|
"dsn_host": dsn.split("@")[-1].split("/")[0] if "@" in dsn else "?",
|
||||||
"note": (
|
"note": (
|
||||||
"Vstupy plánovače zmrazené k okamžiku extrakce (context = aktuální konfigurace, "
|
"Vstupy plánovače zmrazené k okamžiku extrakce (context = aktuální konfigurace, "
|
||||||
"sloty = fn_load_planning_slots_full nad historickými cenami/forecasty). "
|
"sloty = fn_load_planning_slots_full nad historickými cenami/forecasty). "
|
||||||
"EV sessions vynulovány, operating_mode=AUTO."
|
+ (
|
||||||
|
"EV sessions zmrazeny (--keep-ev)"
|
||||||
|
if args.keep_ev
|
||||||
|
else "EV sessions vynulovány"
|
||||||
|
)
|
||||||
|
+ ", operating_mode=AUTO."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"context_json": ctx,
|
"context_json": ctx,
|
||||||
@@ -209,6 +217,11 @@ def main() -> None:
|
|||||||
p.add_argument("--day", required=True, help="Pražský den YYYY-MM-DD (začátek okna 00:00)")
|
p.add_argument("--day", required=True, help="Pražský den YYYY-MM-DD (začátek okna 00:00)")
|
||||||
p.add_argument("--hours", type=int, default=36, help="Délka okna v hodinách (default 36)")
|
p.add_argument("--hours", type=int, default=36, help="Délka okna v hodinách (default 36)")
|
||||||
p.add_argument("--tag", required=True, help="Krátký popis scénáře (neg_sell_deep, normal, …)")
|
p.add_argument("--tag", required=True, help="Krátký popis scénáře (neg_sell_deep, normal, …)")
|
||||||
|
p.add_argument(
|
||||||
|
"--keep-ev",
|
||||||
|
action="store_true",
|
||||||
|
help="Zachovat otevřené EV sessions v contextu (default: vynulovat — historické okno)",
|
||||||
|
)
|
||||||
p.add_argument("--dsn", default=None, help="postgresql:// DSN (jinak EMS_DB_DSN / DB_* env)")
|
p.add_argument("--dsn", default=None, help="postgresql:// DSN (jinak EMS_DB_DSN / DB_* env)")
|
||||||
p.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR), help="Cílový adresář fixtures")
|
p.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR), help="Cílový adresář fixtures")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|||||||
Reference in New Issue
Block a user