merge dev → main: EV Fix B (anti-fragmentace 3f floor) + geofence arrival + proaktivní notifikace
- feat(planner): EV 3f power floor (aktivní, korektnost) + block-start penalta (V108, default 0) - feat(ev): geofence arrival trigger (V109, default-off) - feat(ev): proaktivní 'píchni auto' notifikace (V110, default-off) golden gate + full suite 363 passed; živě ověřeny constraint name + phases sloupec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ from services.signal_service import (
|
|||||||
run_signal_outbound_send_for_active_sites,
|
run_signal_outbound_send_for_active_sites,
|
||||||
run_signal_outbound_verify_for_active_sites,
|
run_signal_outbound_verify_for_active_sites,
|
||||||
)
|
)
|
||||||
|
from services.ev_presence_notify import run_ev_presence_nudge_for_all_active_sites
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -161,6 +162,18 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("scheduled_signal_outbound_verify failed")
|
logger.exception("scheduled_signal_outbound_verify failed")
|
||||||
|
|
||||||
|
async def scheduled_ev_presence_nudge() -> None:
|
||||||
|
"""Proaktivní "auto doma + nepíchnuté + levné/přebytek → píchni ho".
|
||||||
|
|
||||||
|
SQL-first rozhodnutí + dedup v ems.fn_ev_presence_nudge_due (insert do
|
||||||
|
ev_presence_nudge_sent). Default-off per vozidlo (presence_nudge_enabled),
|
||||||
|
takže job běží inertně, dokud se na nějakém vozidle nezapne.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await run_ev_presence_nudge_for_all_active_sites(app.state.pg_pool)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("scheduled_ev_presence_nudge failed")
|
||||||
|
|
||||||
async def scheduled_pool_control() -> None:
|
async def scheduled_pool_control() -> None:
|
||||||
# Bazén: SQL-first rozhodnutí (fn_pool_control_tick) — nejlevnější souvislé
|
# Bazén: SQL-first rozhodnutí (fn_pool_control_tick) — nejlevnější souvislé
|
||||||
# okno denního runtime + dump-load při sell<=0; zařadí POOL_PUMP_ON (jen když
|
# okno denního runtime + dump-load při sell<=0; zařadí POOL_PUMP_ON (jen když
|
||||||
@@ -437,6 +450,14 @@ async def lifespan(app: FastAPI):
|
|||||||
id="pool_control",
|
id="pool_control",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
scheduled_ev_presence_nudge,
|
||||||
|
"cron",
|
||||||
|
minute="5,30,55",
|
||||||
|
second=10,
|
||||||
|
id="ev_presence_nudge",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan")
|
scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan")
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
scheduled_rolling_replan,
|
scheduled_rolling_replan,
|
||||||
|
|||||||
112
backend/services/ev_presence_notify.py
Normal file
112
backend/services/ev_presence_notify.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho".
|
||||||
|
|
||||||
|
Tenký orchestrátor: veškerá doménová logika (kdo je doma, odpojený, výhodná cena,
|
||||||
|
SoC pod cílem) i dedup jsou v ems.fn_ev_presence_nudge_due(). Python jen zavolá
|
||||||
|
funkci pro každou aktivní lokalitu a pro každý vrácený (= nově due, ještě
|
||||||
|
neposlaný) řádek pošle jeden Discord nudge.
|
||||||
|
|
||||||
|
Dedup je čistě v DB: funkce zapíše řádek do ems.ev_presence_nudge_sent
|
||||||
|
(on conflict do nothing) a vrátí jen ty, kterým insert skutečně prošel — tedy
|
||||||
|
jeden nudge na "epizodu" auta doma+odpojeno. Opakované 20–30min ticky proto
|
||||||
|
nespamují, dokud se auto nepíchne nebo neodjede (čímž se klíč epizody změní).
|
||||||
|
|
||||||
|
DEFAULT-OFF: funkce nevrátí nic, dokud není na vozidle
|
||||||
|
asset_vehicle.presence_nudge_enabled = true. Job tedy běží inertně.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.db_json import fetch_json
|
||||||
|
from services.notification_service import send_discord
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_price(value: Any) -> str:
|
||||||
|
try:
|
||||||
|
return f"{float(value):.2f}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_message(row: asyncpg.Record) -> str:
|
||||||
|
name = row["vehicle_name"] or "EV"
|
||||||
|
reason = str(row["trigger_reason"] or "")
|
||||||
|
sell = row["effective_sell_price_czk_kwh"]
|
||||||
|
buy = row["effective_buy_price_czk_kwh"]
|
||||||
|
soc = row["battery_level_pct"]
|
||||||
|
tgt = row["target_soc_pct"]
|
||||||
|
|
||||||
|
if reason == "NEG_OR_ZERO_SELL":
|
||||||
|
why = f"výkup je teď {_fmt_price(sell)} Kč/kWh (≤ 0) — přebytek se hodí do auta"
|
||||||
|
else:
|
||||||
|
why = f"nákup je teď levný: {_fmt_price(buy)} Kč/kWh"
|
||||||
|
|
||||||
|
soc_line = ""
|
||||||
|
if soc is not None:
|
||||||
|
soc_line = f"\nBaterie auta: **{_fmt_price(soc)} %**" + (
|
||||||
|
f" (cíl {_fmt_price(tgt)} %)" if tgt is not None else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"🚗 **{name} je doma a nepíchnuté** — {why}.{soc_line}\n"
|
||||||
|
f"Píchni ho a plán se o zbytek postará (přebytky / levné sloty)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_ev_presence_nudge_for_site(
|
||||||
|
site_id: int, conn: asyncpg.Connection
|
||||||
|
) -> int:
|
||||||
|
"""Jedna lokalita: zavolá fn (dedup v DB) a pošle Discord pro každé due vozidlo.
|
||||||
|
|
||||||
|
Vrátí počet odeslaných notifikací.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"select * from ems.fn_ev_presence_nudge_due($1::int)",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"ev_presence_nudge: fn_ev_presence_nudge_due failed site=%s", site_id
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
await send_discord(conn, site_id, _build_message(row), level="info")
|
||||||
|
sent += 1
|
||||||
|
logger.info(
|
||||||
|
"ev_presence_nudge sent site=%s vehicle=%s reason=%s",
|
||||||
|
site_id,
|
||||||
|
row["vehicle_id"],
|
||||||
|
row["trigger_reason"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"ev_presence_nudge: Discord send failed site=%s vehicle=%s",
|
||||||
|
site_id,
|
||||||
|
row["vehicle_id"],
|
||||||
|
)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_ev_presence_nudge_for_all_active_sites(pool: asyncpg.Pool) -> None:
|
||||||
|
"""Scheduler entrypoint: projde aktivní lokality a pošle proaktivní nudge."""
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()")
|
||||||
|
sites = raw if isinstance(raw, list) else []
|
||||||
|
for site in sites:
|
||||||
|
if not isinstance(site, dict) or site.get("id") is None:
|
||||||
|
continue
|
||||||
|
site_id = int(site["id"])
|
||||||
|
try:
|
||||||
|
await run_ev_presence_nudge_for_site(site_id, conn)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("ev_presence_nudge site=%s failed", site_id)
|
||||||
@@ -116,3 +116,13 @@ _PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
|||||||
# --- Konstanty původně roztroušené mezi funkcemi planning_engine.py (Fáze 1) ---
|
# --- Konstanty původně roztroušené mezi funkcemi planning_engine.py (Fáze 1) ---
|
||||||
MORNING_PRENEG_START_HOUR = 5
|
MORNING_PRENEG_START_HOUR = 5
|
||||||
MORNING_PRENEG_END_HOUR = 11
|
MORNING_PRENEG_END_HOUR = 11
|
||||||
|
|
||||||
|
# --- EV anti-fragmentace (Fix B, solver_v2) ---
|
||||||
|
# IEC 61851 min. nabíjecí proud (A) na fázi. 3f wallbox NEumí jet 1f trickle pod
|
||||||
|
# 6 A na všech fázích → fyzikální dolní mez dávky je 6 A × phases × napětí.
|
||||||
|
EV_MIN_CHARGE_CURRENT_A = 6.0
|
||||||
|
# Síťové napětí fáze (V) pro odhad 3f power floor (3f wallbox: 6 A × 3 × 230 ≈ 4140 W).
|
||||||
|
EV_PHASE_VOLTAGE_V = 230.0
|
||||||
|
# Práh, od kolika fází považujeme wallbox za vícefázový (≥ tato hodnota → power floor
|
||||||
|
# z fází; jinak držíme min_power_w z DB). 3 = jen čistě 3f wallbox dostane 3f floor.
|
||||||
|
EV_MULTIPHASE_FLOOR_MIN_PHASES = 3
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ async def _load_site_context(site_id: int, db):
|
|||||||
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),
|
min_power_w=int(v.get("min_power_w") or 0),
|
||||||
|
# phases / planner_ev_start_penalty_czk: parametry wallboxu pro
|
||||||
|
# anti-fragmentaci EV v solver_v2 (Fix B). Default phases=3 (typický
|
||||||
|
# 3f wallbox), start penalta 0 = no-op (golden-safe).
|
||||||
|
phases=int(v.get("phases") or 3),
|
||||||
|
planner_ev_start_penalty_czk=float(
|
||||||
|
v.get("planner_ev_start_penalty_czk") or 0.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"]),
|
||||||
)
|
)
|
||||||
@@ -150,6 +157,8 @@ async def _load_site_context(site_id: int, db):
|
|||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
max_charge_power_w=0,
|
max_charge_power_w=0,
|
||||||
min_power_w=0,
|
min_power_w=0,
|
||||||
|
phases=3,
|
||||||
|
planner_ev_start_penalty_czk=0.0,
|
||||||
battery_capacity_kwh=1.0,
|
battery_capacity_kwh=1.0,
|
||||||
default_target_soc_pct=80.0,
|
default_target_soc_pct=80.0,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,7 +38,15 @@
|
|||||||
# binárka ev_on → setpoint ∈ {0} ∪ [min_power_w, max]; ev_direct ≤ gi + PV
|
# 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í
|
# (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,
|
# battery_arbitrage_czk oportunitní cenou (min sell exportního slotu dne,
|
||||||
# jinak terminal value) — slotový buy pro ně neplatí.
|
# jinak terminal value) — slotový buy pro ně neplatí. U TŘÍFÁZOVÉHO wallboxu
|
||||||
|
# (asset_ev_charger.phases ≥ 3) je floor zvednut na 6 A × fáze × 230 V (≈ 4140
|
||||||
|
# W pro 3f) místo 1f ~1380 W → ruší sub-6A 1f trickle drobky (cap = max výkon
|
||||||
|
# vozidla). Fáze/min jdou z DB přes vehicle kontext (R__039).
|
||||||
|
# - anti-fragmentace EV (Fix B): per-slot binárka ev_on (vždy při floor NEBO
|
||||||
|
# start penaltě) + hrana ev_start[t] ≥ ev_on[t] − ev_on[t−1]; objektiv +=
|
||||||
|
# Σ ev_start × asset_ev_charger.planner_ev_start_penalty_czk (Kč). Drobná
|
||||||
|
# penalta (filozofie v2: nejistota/opotřebení = cena, ne tvrdá priorita) →
|
||||||
|
# souvislá dávka místo rozsekání. Default 0 = no-op (golden-safe).
|
||||||
# - 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
|
||||||
@@ -60,6 +68,9 @@ from typing import Any, Optional
|
|||||||
import pulp
|
import pulp
|
||||||
|
|
||||||
from services.planning.constants import (
|
from services.planning.constants import (
|
||||||
|
EV_MIN_CHARGE_CURRENT_A,
|
||||||
|
EV_MULTIPHASE_FLOOR_MIN_PHASES,
|
||||||
|
EV_PHASE_VOLTAGE_V,
|
||||||
INTERVAL_H,
|
INTERVAL_H,
|
||||||
SOLVER_TIME_LIMIT,
|
SOLVER_TIME_LIMIT,
|
||||||
)
|
)
|
||||||
@@ -175,9 +186,52 @@ 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_start_terms: list = [] # (ev_start var, penalta Kč) — anti-fragmentace (Fix B)
|
||||||
ev_min_w = [
|
|
||||||
max(0.0, float(getattr(vehicles[e], "min_power_w", 0) or 0)) for e in range(EV)
|
def _ev_min_power_w(e: int) -> float:
|
||||||
|
"""Dolní mez nabíjecí dávky (W): u 3f wallboxu fyzikální 6 A × fáze × napětí
|
||||||
|
(≈ 4140 W) místo 1f ~1380 W → zruší sub-6A 1f trickle. Stropuje se max
|
||||||
|
výkonem vozidla (jinak by připojený slot byl infeasible). Bez spolehlivého
|
||||||
|
počtu fází padá zpět na min_power_w z DB."""
|
||||||
|
veh = vehicles[e]
|
||||||
|
base_min = max(0.0, float(getattr(veh, "min_power_w", 0) or 0))
|
||||||
|
phases = int(getattr(veh, "phases", 0) or 0)
|
||||||
|
ev_max = float(veh.max_charge_power_w)
|
||||||
|
if phases >= EV_MULTIPHASE_FLOOR_MIN_PHASES:
|
||||||
|
floor = EV_MIN_CHARGE_CURRENT_A * phases * EV_PHASE_VOLTAGE_V
|
||||||
|
base_min = max(base_min, floor)
|
||||||
|
# strop max výkonem vozidla — floor nesmí překročit, co auto/wallbox umí
|
||||||
|
if ev_max > 0:
|
||||||
|
base_min = min(base_min, ev_max)
|
||||||
|
return base_min
|
||||||
|
|
||||||
|
def _ev_start_penalty_czk(e: int) -> float:
|
||||||
|
return max(0.0, float(getattr(vehicles[e], "planner_ev_start_penalty_czk", 0.0) or 0.0))
|
||||||
|
|
||||||
|
ev_min_w = [_ev_min_power_w(e) for e in range(EV)]
|
||||||
|
ev_start_pen = [_ev_start_penalty_czk(e) for e in range(EV)]
|
||||||
|
# ev_on[e][t]: zapnutost wallboxu v slotu. Vždy potřeba, pokud platí min-power
|
||||||
|
# floor (gate) NEBO start penalta (anti-fragmentace). ev_start[e][t]: náběžná
|
||||||
|
# hrana ev_on (start nové dávky) — jen když je start penalta > 0 (jinak žádný
|
||||||
|
# extra MILP balast a default 0 = no-op, golden-safe).
|
||||||
|
ev_needs_on = [(ev_min_w[e] > 0.0) or (ev_start_pen[e] > 0.0) for e in range(EV)]
|
||||||
|
ev_on = [
|
||||||
|
[
|
||||||
|
pulp.LpVariable(f"evon_{e}_{t}", cat=pulp.LpBinary)
|
||||||
|
for t in range(T)
|
||||||
|
]
|
||||||
|
if ev_needs_on[e]
|
||||||
|
else None
|
||||||
|
for e in range(EV)
|
||||||
|
]
|
||||||
|
ev_start = [
|
||||||
|
[
|
||||||
|
pulp.LpVariable(f"evstart_{e}_{t}", 0, 1)
|
||||||
|
for t in range(T)
|
||||||
|
]
|
||||||
|
if ev_start_pen[e] > 0.0
|
||||||
|
else None
|
||||||
|
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)
|
||||||
@@ -263,20 +317,30 @@ 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 + min. výkon wallboxu (binárka jen kde je min > 0)
|
# EV dostupnost + min. výkon wallboxu (binárka ev_on) + start hrana.
|
||||||
|
# ev_on existuje, když platí min-power floor NEBO start penalta.
|
||||||
for e in range(EV):
|
for e in range(EV):
|
||||||
|
on_t = ev_on[e][t] if ev_on[e] is not None else None
|
||||||
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
|
||||||
|
if on_t is not None:
|
||||||
|
prob += on_t == 0, f"ev_off_{e}_{t}"
|
||||||
else:
|
else:
|
||||||
ev_max_w = float(vehicles[e].max_charge_power_w)
|
ev_max_w = float(vehicles[e].max_charge_power_w)
|
||||||
ev_total = ev_direct[e][t] + ev_via_bat[e][t]
|
ev_total = ev_direct[e][t] + ev_via_bat[e][t]
|
||||||
if 0 < ev_min_w[e] <= ev_max_w:
|
if on_t is not None and ev_max_w > 0:
|
||||||
on = pulp.LpVariable(f"evon_{e}_{t}", cat=pulp.LpBinary)
|
# on=1 nutné kdykoli ev_total > 0 (start penalta i floor to potřebují)
|
||||||
prob += ev_total >= ev_min_w[e] * on, f"ev_min_{e}_{t}"
|
prob += ev_total <= ev_max_w * on_t, f"ev_max_{e}_{t}"
|
||||||
prob += ev_total <= ev_max_w * on, f"ev_max_{e}_{t}"
|
if 0 < ev_min_w[e] <= ev_max_w:
|
||||||
|
prob += ev_total >= ev_min_w[e] * on_t, f"ev_min_{e}_{t}"
|
||||||
else:
|
else:
|
||||||
prob += ev_total <= ev_max_w
|
prob += ev_total <= ev_max_w
|
||||||
|
# start = náběžná hrana ev_on (≥ on[t] − on[t−1]); slot 0 startuje vždy,
|
||||||
|
# když je on (žádný předchozí stav v horizontu).
|
||||||
|
if ev_start[e] is not None and on_t is not None:
|
||||||
|
prev_on = ev_on[e][t - 1] if t > 0 else 0
|
||||||
|
prob += ev_start[e][t] >= on_t - prev_on, f"ev_start_{e}_{t}"
|
||||||
|
|
||||||
# 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":
|
||||||
@@ -379,6 +443,15 @@ def solve_dispatch_v2(
|
|||||||
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:
|
if ev_opp:
|
||||||
extras -= pulp.lpSum(o / 1000.0 * val for o, val in ev_opp if val > 0)
|
extras -= pulp.lpSum(o / 1000.0 * val for o, val in ev_opp if val > 0)
|
||||||
|
# anti-fragmentace EV (Fix B): Σ ev_start × start_penalta (Kč). Default 0 → no-op.
|
||||||
|
ev_start_terms = [
|
||||||
|
ev_start[e][t] * ev_start_pen[e]
|
||||||
|
for e in range(EV)
|
||||||
|
if ev_start[e] is not None and ev_start_pen[e] > 0.0
|
||||||
|
for t in range(T)
|
||||||
|
]
|
||||||
|
if ev_start_terms:
|
||||||
|
extras += pulp.lpSum(ev_start_terms)
|
||||||
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)
|
||||||
@@ -521,6 +594,8 @@ def solve_dispatch_v2(
|
|||||||
"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,
|
"ev_min_power_w": ev_min_w,
|
||||||
|
"ev_phases": [int(getattr(vehicles[e], "phases", 0) or 0) for e in range(EV)],
|
||||||
|
"ev_start_penalty_czk": ev_start_pen,
|
||||||
"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,
|
||||||
@@ -535,6 +610,12 @@ def solve_dispatch_v2(
|
|||||||
"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],
|
"ev_opp_wh": [round(_val(o), 1) for o, _v in ev_opp],
|
||||||
|
"ev_starts": [
|
||||||
|
int(round(sum(_val(ev_start[e][t]) for t in range(T))))
|
||||||
|
if ev_start[e] is not None
|
||||||
|
else 0
|
||||||
|
for e in range(EV)
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"solver_duration_ms": duration_ms,
|
"solver_duration_ms": duration_ms,
|
||||||
"solver_status": status_str,
|
"solver_status": status_str,
|
||||||
|
|||||||
@@ -760,6 +760,27 @@ _EV_PRESENCE_LAST_DATA: dict[int, float] = {}
|
|||||||
_EV_PRESENCE_LAST_STATE: dict[int, str] = {}
|
_EV_PRESENCE_LAST_STATE: dict[int, str] = {}
|
||||||
_EV_PLUG_NUDGE_LAST: dict[int, float] = {}
|
_EV_PLUG_NUDGE_LAST: dict[int, float] = {}
|
||||||
|
|
||||||
|
#: Geofence arrival obs (trigger='geofence_arrival') — příjezd domů BEZ píchnutí
|
||||||
|
#: do wallboxu. DEFAULT VYPNUTO (env EV_GEOFENCE_ARRIVAL_OBS_ENABLED=true zapne);
|
||||||
|
#: vypnuté = funkce běží jako dřív, jen se nový obs nezapisuje (golden gate /
|
||||||
|
#: plánovač beze změny). Debounce: vyžaduje N po sobě jdoucích čtení at_home=true
|
||||||
|
#: (GPS jitter u 150m hranice nesmí jeden flip brát jako příjezd). Dedup: emituje
|
||||||
|
#: jen jednou na epizodu (po emitu se "odzbrojí", znovu se "nabije" až po odjezdu);
|
||||||
|
#: a vůbec neběží, když je auto na wallboxu (plug-in cesta je autoritativní —
|
||||||
|
#: poll_tesla_presence se při otevřené session vrací dřív, viz `plugged`).
|
||||||
|
EV_GEOFENCE_ARRIVAL_CONFIRM_SAMPLES = 2
|
||||||
|
_EV_GEOFENCE_HOME_STREAK: dict[int, int] = {}
|
||||||
|
_EV_GEOFENCE_ARMED: dict[int, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _ev_geofence_obs_enabled() -> bool:
|
||||||
|
"""Feature flag: zápis geofence_arrival obs (default false → inertní)."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
return (os.getenv("EV_GEOFENCE_ARRIVAL_OBS_ENABLED") or "").strip().lower() in (
|
||||||
|
"1", "true", "yes", "on",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ev_presence_transition(prev_at_home: bool | None, new_at_home: bool | None) -> str | None:
|
def ev_presence_transition(prev_at_home: bool | None, new_at_home: bool | None) -> str | None:
|
||||||
"""Čistá detekce přechodu: 'arrived' / 'left' / None (testovatelné)."""
|
"""Čistá detekce přechodu: 'arrived' / 'left' / None (testovatelné)."""
|
||||||
@@ -772,6 +793,41 @@ def ev_presence_transition(prev_at_home: bool | None, new_at_home: bool | None)
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ev_geofence_arrival_decision(
|
||||||
|
vehicle_id: int,
|
||||||
|
at_home: bool | None,
|
||||||
|
confirm_samples: int = EV_GEOFENCE_ARRIVAL_CONFIRM_SAMPLES,
|
||||||
|
) -> bool:
|
||||||
|
"""Debounce + dedup geofence příjezdu (čistá, testovatelná funkce nad stavem).
|
||||||
|
|
||||||
|
Vstup `at_home` je výsledek aktuálního geofence čtení (None = poloha neznámá,
|
||||||
|
např. auto spí → stav se NEMĚNÍ). Vrací True právě jednou za epizodu příjezdu,
|
||||||
|
a to až po `confirm_samples` po sobě jdoucích čteních at_home=true:
|
||||||
|
|
||||||
|
- at_home is None → neznámé, streak ani armed se nemění (žádné rozhodnutí).
|
||||||
|
- at_home is False → auto je pryč: vynuluj streak, "nabij" (armed=True), aby
|
||||||
|
příští potvrzený příjezd mohl emitovat.
|
||||||
|
- at_home is True → inkrementuj streak; pokud streak dosáhl prahu a jsme
|
||||||
|
armed, "odzbroj" (armed=False) a vrať True (emituj jednou).
|
||||||
|
|
||||||
|
Tím se jeden GPS flip u hranice nepočítá jako příjezd a opakovaná at_home=true
|
||||||
|
čtení během stání doma negenerují duplicitní obs.
|
||||||
|
"""
|
||||||
|
if at_home is None:
|
||||||
|
return False
|
||||||
|
if at_home is False:
|
||||||
|
_EV_GEOFENCE_HOME_STREAK[vehicle_id] = 0
|
||||||
|
_EV_GEOFENCE_ARMED[vehicle_id] = True
|
||||||
|
return False
|
||||||
|
# at_home is True
|
||||||
|
streak = _EV_GEOFENCE_HOME_STREAK.get(vehicle_id, 0) + 1
|
||||||
|
_EV_GEOFENCE_HOME_STREAK[vehicle_id] = streak
|
||||||
|
if streak >= confirm_samples and _EV_GEOFENCE_ARMED.get(vehicle_id, False):
|
||||||
|
_EV_GEOFENCE_ARMED[vehicle_id] = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None:
|
async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None:
|
||||||
"""Přítomnost vozidla: /vehicles state (nebudí) + při online poloha → geofence.
|
"""Přítomnost vozidla: /vehicles state (nebudí) + při online poloha → geofence.
|
||||||
|
|
||||||
@@ -830,6 +886,7 @@ async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None:
|
|||||||
distance_m = None
|
distance_m = None
|
||||||
charging_state = None
|
charging_state = None
|
||||||
shift_state = None
|
shift_state = None
|
||||||
|
st = None
|
||||||
if api_state == "online" and (woke_up or data_due):
|
if api_state == "online" and (woke_up or data_due):
|
||||||
_EV_PRESENCE_LAST_DATA[int(veh["id"])] = loop_now
|
_EV_PRESENCE_LAST_DATA[int(veh["id"])] = loop_now
|
||||||
try:
|
try:
|
||||||
@@ -865,6 +922,34 @@ async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None:
|
|||||||
int(veh["id"]), api_state, at_home, distance_m, charging_state, shift_state,
|
int(veh["id"]), api_state, at_home, distance_m, charging_state, shift_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Geofence příjezd (auto přijelo domů, NEpíchnuté — sem se dostaneme jen když
|
||||||
|
# NENÍ otevřená session, viz `plugged` výše: wallbox je autoritativní). Debounce
|
||||||
|
# + dedup řeší ev_geofence_arrival_decision; zápis je za feature flagem (default
|
||||||
|
# off → inertní). Zapisuje se z presence readu (st), proto jen když máme st se
|
||||||
|
# SoC i odometrem, ať jízda (km z odometru) dostane platný arrival.
|
||||||
|
if _ev_geofence_obs_enabled():
|
||||||
|
emit = ev_geofence_arrival_decision(int(veh["id"]), at_home)
|
||||||
|
if emit and st is not None and st.get("battery_level") is not None:
|
||||||
|
try:
|
||||||
|
await db.execute(
|
||||||
|
"select ems.fn_ev_vehicle_obs_insert($1::int, $2::int, 'geofence_arrival', $3::numeric, $4::numeric, $5::text)",
|
||||||
|
site_id,
|
||||||
|
int(veh["id"]),
|
||||||
|
st.get("odometer_km"),
|
||||||
|
float(st["battery_level"]),
|
||||||
|
st.get("charging_state"),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"EV geofence arrival obs (site=%s, vehicle=%s): soc=%s%%, odo=%s km",
|
||||||
|
site_id, veh["id"],
|
||||||
|
st["battery_level"], st.get("odometer_km"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"EV geofence arrival obs failed (site=%s, vehicle=%s)",
|
||||||
|
site_id, veh["id"],
|
||||||
|
)
|
||||||
|
|
||||||
trans = ev_presence_transition(prev["at_home"] if prev else None, at_home)
|
trans = ev_presence_transition(prev["at_home"] if prev else None, at_home)
|
||||||
if trans == "arrived" and charging_state == "Disconnected":
|
if trans == "arrived" and charging_state == "Disconnected":
|
||||||
if loop_now - _EV_PLUG_NUDGE_LAST.get(int(veh["id"]), 0.0) < EV_PLUG_NUDGE_COOLDOWN_S:
|
if loop_now - _EV_PLUG_NUDGE_LAST.get(int(veh["id"]), 0.0) < EV_PLUG_NUDGE_COOLDOWN_S:
|
||||||
|
|||||||
11
db/migration/V108__asset_ev_charger_start_penalty.sql
Normal file
11
db/migration/V108__asset_ev_charger_start_penalty.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- EV anti-fragmentace (Fix B): per-wallbox cena za START nabíjecí dávky.
|
||||||
|
-- solver_v2 zavádí per-slot binárku ev_on a hranu ev_start[t] >= ev_on[t] - ev_on[t-1];
|
||||||
|
-- do objektivu přidá Σ ev_start × tato cena. Drobná penalta (filozofie v2: nejistota /
|
||||||
|
-- opotřebení = cena) tlačí solver k SOUVISLÉ dávce místo rozsekaného nabíjení přes
|
||||||
|
-- nesouvislé sloty. Default 0 = no-op (golden gate beze změny); kalibruje se per site.
|
||||||
|
|
||||||
|
alter table ems.asset_ev_charger
|
||||||
|
add column if not exists planner_ev_start_penalty_czk numeric(6, 3) not null default 0;
|
||||||
|
|
||||||
|
comment on column ems.asset_ev_charger.planner_ev_start_penalty_czk is
|
||||||
|
'Cena (Kč) za START nabíjecí dávky v solver_v2: do objektivu jde Σ ev_start × tato hodnota (ev_start = náběžná hrana ev_on mezi sloty). Drobná penalta proti fragmentaci nabíjení (rozsekané nesouvislé sloty) — souvislá dávka na 3f místo scattered 1f trickle. 0 = vypnuto (no-op, golden-safe). Kalibruje se per wallbox.';
|
||||||
26
db/migration/V109__ev_obs_geofence_trigger.sql
Normal file
26
db/migration/V109__ev_obs_geofence_trigger.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Geofence arrival trigger pro EV pozorování.
|
||||||
|
--
|
||||||
|
-- Dosud arrival obs (ems.ev_vehicle_obs) vznikalo JEN z wallboxu (plug-in přes
|
||||||
|
-- fn_ev_session_transition). Když uživatel nepíchne, jízda se nezaznamenala a
|
||||||
|
-- spotřební forecast (ev_trip → ev_usage_stats) o ní nevěděl.
|
||||||
|
--
|
||||||
|
-- Telemetry_collector už dnes z Tesla polohy (geofence, scope location, BEZ
|
||||||
|
-- buzení auta) detekuje přechod pryč→domů do ems.ev_presence_obs. Tato migrace
|
||||||
|
-- rozšiřuje povolené hodnoty ev_vehicle_obs.trigger o 'geofence_arrival', aby
|
||||||
|
-- presence cesta mohla zapsat příjezd i bez píchnutí do wallboxu.
|
||||||
|
--
|
||||||
|
-- Zpětná kompatibilita: stávající hodnoty 'arrival' / 'departure' / 'manual'
|
||||||
|
-- zůstávají platné; přidává se jen nová hodnota. Žádná data se nemění.
|
||||||
|
-- Párování jízd (fn_ev_build_trips) bere 'geofence_arrival' jako platný arrival
|
||||||
|
-- (R__096); wallbox 'arrival' zůstává autoritativní, geofence je doplněk pro
|
||||||
|
-- případy, kdy auto stojí doma nepíchnuté.
|
||||||
|
|
||||||
|
alter table ems.ev_vehicle_obs
|
||||||
|
drop constraint if exists ev_vehicle_obs_trigger_check;
|
||||||
|
|
||||||
|
alter table ems.ev_vehicle_obs
|
||||||
|
add constraint ev_vehicle_obs_trigger_check
|
||||||
|
check (trigger in ('arrival', 'departure', 'manual', 'geofence_arrival'));
|
||||||
|
|
||||||
|
comment on column ems.ev_vehicle_obs.trigger is
|
||||||
|
'Zdroj pozorování: arrival/departure z wallboxu (plug-in/out, autoritativní), manual ruční, geofence_arrival z Tesla polohy (přijel domů, nepíchnutý — auto vzhůru, čtení nebudí). geofence_arrival se páruje jako příjezd v fn_ev_build_trips.';
|
||||||
224
db/migration/V110__ev_presence_notify.sql
Normal file
224
db/migration/V110__ev_presence_notify.sql
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
-- Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho".
|
||||||
|
--
|
||||||
|
-- Na rozdíl od arrival nudge v telemetry_collector (jen edge příjezd) tohle běží
|
||||||
|
-- periodicky (scheduler ~20–30 min) a upozorní, i když auto bylo doma už dřív,
|
||||||
|
-- ale je výhodné ho teď píchnout (sell<=0 NEBO velmi levný buy).
|
||||||
|
--
|
||||||
|
-- SQL-first: rozhodnutí + dedup je v ems.fn_ev_presence_nudge_due(); Python jen IO/Discord.
|
||||||
|
-- DEFAULT-OFF: per vozidlo flag asset_vehicle.presence_nudge_enabled (default false) →
|
||||||
|
-- funkce nikoho nevrátí, dokud se na vozidle explicitně nezapne. Inertní pro golden gate.
|
||||||
|
|
||||||
|
-- 1) SoC z presence pozorování (zatím plní jen budoucí telemetrie; NULL = neznámé).
|
||||||
|
alter table ems.ev_presence_obs
|
||||||
|
add column if not exists battery_level_pct numeric(5, 2);
|
||||||
|
|
||||||
|
comment on column ems.ev_presence_obs.battery_level_pct is
|
||||||
|
'SoC trakční baterie vozidla v % z presence pollu (jen když je auto online a vrací charge_state). NULL = neznámé (auto spí / poloha bez SoC).';
|
||||||
|
|
||||||
|
-- 2) Per vozidlo: zapnutí proaktivní notifikace + cílový SoC práh pro nudge.
|
||||||
|
alter table ems.asset_vehicle
|
||||||
|
add column if not exists presence_nudge_enabled boolean not null default false;
|
||||||
|
|
||||||
|
alter table ems.asset_vehicle
|
||||||
|
add column if not exists presence_nudge_soc_tolerance_pct numeric(5, 2) not null default 5;
|
||||||
|
|
||||||
|
comment on column ems.asset_vehicle.presence_nudge_enabled is
|
||||||
|
'Zapne proaktivní Discord notifikaci "auto doma a nepíchnuté + levné/přebytek → píchni ho" (job ev_presence_notify). Default false = inertní.';
|
||||||
|
|
||||||
|
comment on column ems.asset_vehicle.presence_nudge_soc_tolerance_pct is
|
||||||
|
'Tolerance pod cílovým SoC: nudge se pošle jen když známé SoC < (default_target_soc_pct − tato tolerance). Pokud je SoC neznámé (NULL), prahem se neblokuje.';
|
||||||
|
|
||||||
|
-- 3) Dedup: jedno potvrzení odeslaného nudge na "epizodu" (vozidlo + klíč stavu).
|
||||||
|
-- nudge_key = observed_at začátku epizody, kdy auto JE doma a JE odpojené.
|
||||||
|
-- Dokud epizoda trvá (stejný start), klíč se nemění → on conflict do nothing tlumí
|
||||||
|
-- opakování každých 20–30 min. Po píchnutí / odjezdu epizoda končí; nový příjezd =
|
||||||
|
-- nový observed_at = nový klíč = nudge se znovu nabije.
|
||||||
|
create table if not exists ems.ev_presence_nudge_sent (
|
||||||
|
vehicle_id int not null references ems.asset_vehicle (id),
|
||||||
|
nudge_key timestamptz not null,
|
||||||
|
sent_at timestamptz not null default now(),
|
||||||
|
primary key (vehicle_id, nudge_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_ev_presence_nudge_sent_sent_at
|
||||||
|
on ems.ev_presence_nudge_sent (sent_at desc);
|
||||||
|
|
||||||
|
comment on table ems.ev_presence_nudge_sent is
|
||||||
|
'Dedup proaktivních "píchni auto" notifikací: PK vehicle_id + nudge_key (start epizody doma+odpojeno). Jeden nudge na epizodu; po píchnutí/odjezdu se klíč přirozeně změní.';
|
||||||
|
|
||||||
|
-- 4) Rozhodovací funkce: vrátí vozidla, kde je teď výhodné píchnout, a zapíše dedup.
|
||||||
|
-- Podmínky (vše musí platit):
|
||||||
|
-- - vozidlo aktivní a presence_nudge_enabled = true,
|
||||||
|
-- - poslední pozorování se známou polohou: at_home = true,
|
||||||
|
-- - charging_state značí odpojeno (Disconnected / NoPower / null po příjezdu),
|
||||||
|
-- - žádná otevřená ev_session (auto reálně není na wallboxu),
|
||||||
|
-- - SoC neznámé NEBO SoC < (target − tolerance),
|
||||||
|
-- - aktuální 15min slot: efektivní sell <= 0 NEBO efektivní buy <= práh
|
||||||
|
-- (levný buy = pod p_cheap_buy_max_czk_kwh; statický práh, žádný kód zařízení).
|
||||||
|
-- Dedup: insert do ev_presence_nudge_sent (on conflict do nothing); vrací jen řádky,
|
||||||
|
-- pro které insert skutečně proběhl (= ještě neposláno pro tuto epizodu).
|
||||||
|
create or replace function ems.fn_ev_presence_nudge_due(
|
||||||
|
p_site_id int,
|
||||||
|
p_now timestamptz default now(),
|
||||||
|
p_cheap_buy_max_czk_kwh numeric default 1.50
|
||||||
|
)
|
||||||
|
returns table (
|
||||||
|
vehicle_id int,
|
||||||
|
vehicle_name text,
|
||||||
|
site_id int,
|
||||||
|
site_code text,
|
||||||
|
at_home boolean,
|
||||||
|
battery_level_pct numeric,
|
||||||
|
target_soc_pct numeric,
|
||||||
|
charging_state text,
|
||||||
|
effective_buy_price_czk_kwh numeric,
|
||||||
|
effective_sell_price_czk_kwh numeric,
|
||||||
|
trigger_reason text,
|
||||||
|
nudge_key timestamptz
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
volatile
|
||||||
|
as $fn$
|
||||||
|
with slot as (
|
||||||
|
-- aktuální 15min slot v UTC (zarovnání po Europe/Prague hranicích řeší boundary fn)
|
||||||
|
select ems.fn_planning_slot_boundary_prague(0, p_now) as interval_start
|
||||||
|
),
|
||||||
|
veh as (
|
||||||
|
select
|
||||||
|
v.id as vehicle_id,
|
||||||
|
v.name as vehicle_name,
|
||||||
|
v.site_id,
|
||||||
|
v.default_target_soc_pct,
|
||||||
|
v.presence_nudge_soc_tolerance_pct
|
||||||
|
from ems.asset_vehicle v
|
||||||
|
where v.site_id = p_site_id
|
||||||
|
and v.active = true
|
||||||
|
and v.presence_nudge_enabled = true
|
||||||
|
),
|
||||||
|
last_obs as (
|
||||||
|
-- poslední pozorování se ZNÁMOU polohou (at_home not null) per vozidlo
|
||||||
|
select distinct on (o.vehicle_id)
|
||||||
|
o.vehicle_id,
|
||||||
|
o.observed_at,
|
||||||
|
o.at_home,
|
||||||
|
o.charging_state,
|
||||||
|
o.battery_level_pct
|
||||||
|
from ems.ev_presence_obs o
|
||||||
|
join veh on veh.vehicle_id = o.vehicle_id
|
||||||
|
where o.at_home is not null
|
||||||
|
order by o.vehicle_id, o.observed_at desc
|
||||||
|
),
|
||||||
|
episode as (
|
||||||
|
-- poslední "zlom" epizody: nejnovější pozorování, kdy auto bylo pryč nebo připojené.
|
||||||
|
select
|
||||||
|
lo.vehicle_id,
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select max(o2.observed_at)
|
||||||
|
from ems.ev_presence_obs o2
|
||||||
|
where o2.vehicle_id = lo.vehicle_id
|
||||||
|
and o2.observed_at <= lo.observed_at
|
||||||
|
and (
|
||||||
|
o2.at_home is distinct from true
|
||||||
|
or (
|
||||||
|
o2.charging_state is not null
|
||||||
|
and lower(o2.charging_state) not in ('disconnected', 'nopower')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'-infinity'::timestamptz
|
||||||
|
) as last_break_at
|
||||||
|
from last_obs lo
|
||||||
|
),
|
||||||
|
episode_start as (
|
||||||
|
select
|
||||||
|
ep.vehicle_id,
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select min(o3.observed_at)
|
||||||
|
from ems.ev_presence_obs o3
|
||||||
|
where o3.vehicle_id = ep.vehicle_id
|
||||||
|
and o3.observed_at > ep.last_break_at
|
||||||
|
and o3.at_home = true
|
||||||
|
),
|
||||||
|
-- fallback: žádný explicitní zlom v historii → ber poslední pozorování
|
||||||
|
(select observed_at from last_obs lo where lo.vehicle_id = ep.vehicle_id)
|
||||||
|
) as nudge_key
|
||||||
|
from episode ep
|
||||||
|
),
|
||||||
|
price as (
|
||||||
|
select
|
||||||
|
ep.site_id,
|
||||||
|
ep.effective_buy_price_czk_kwh,
|
||||||
|
ep.effective_sell_price_czk_kwh
|
||||||
|
from ems.vw_site_effective_price ep, slot
|
||||||
|
where ep.site_id = p_site_id
|
||||||
|
and ep.interval_start = slot.interval_start
|
||||||
|
),
|
||||||
|
due as (
|
||||||
|
select
|
||||||
|
v.vehicle_id,
|
||||||
|
v.vehicle_name,
|
||||||
|
v.site_id,
|
||||||
|
lo.at_home,
|
||||||
|
lo.battery_level_pct,
|
||||||
|
v.default_target_soc_pct as target_soc_pct,
|
||||||
|
lo.charging_state,
|
||||||
|
pr.effective_buy_price_czk_kwh,
|
||||||
|
pr.effective_sell_price_czk_kwh,
|
||||||
|
es.nudge_key,
|
||||||
|
case
|
||||||
|
when pr.effective_sell_price_czk_kwh <= 0 then 'NEG_OR_ZERO_SELL'
|
||||||
|
else 'CHEAP_BUY'
|
||||||
|
end as trigger_reason
|
||||||
|
from veh v
|
||||||
|
join last_obs lo on lo.vehicle_id = v.vehicle_id
|
||||||
|
join episode_start es on es.vehicle_id = v.vehicle_id
|
||||||
|
cross join price pr
|
||||||
|
where lo.at_home = true
|
||||||
|
and (
|
||||||
|
lo.charging_state is null
|
||||||
|
or lower(lo.charging_state) in ('disconnected', 'nopower')
|
||||||
|
)
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from ems.ev_session sess
|
||||||
|
where sess.vehicle_id = v.vehicle_id
|
||||||
|
and sess.session_end is null
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
lo.battery_level_pct is null
|
||||||
|
or lo.battery_level_pct
|
||||||
|
< (coalesce(v.default_target_soc_pct, 80) - coalesce(v.presence_nudge_soc_tolerance_pct, 5))
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
pr.effective_sell_price_czk_kwh <= 0
|
||||||
|
or pr.effective_buy_price_czk_kwh <= p_cheap_buy_max_czk_kwh
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ins as (
|
||||||
|
insert into ems.ev_presence_nudge_sent (vehicle_id, nudge_key)
|
||||||
|
select d.vehicle_id, d.nudge_key
|
||||||
|
from due d
|
||||||
|
on conflict (vehicle_id, nudge_key) do nothing
|
||||||
|
returning vehicle_id, nudge_key
|
||||||
|
)
|
||||||
|
select
|
||||||
|
d.vehicle_id,
|
||||||
|
d.vehicle_name,
|
||||||
|
d.site_id,
|
||||||
|
(select s.code from ems.site s where s.id = d.site_id) as site_code,
|
||||||
|
d.at_home,
|
||||||
|
d.battery_level_pct,
|
||||||
|
d.target_soc_pct,
|
||||||
|
d.charging_state,
|
||||||
|
d.effective_buy_price_czk_kwh,
|
||||||
|
d.effective_sell_price_czk_kwh,
|
||||||
|
d.trigger_reason,
|
||||||
|
d.nudge_key
|
||||||
|
from due d
|
||||||
|
join ins on ins.vehicle_id = d.vehicle_id and ins.nudge_key = d.nudge_key;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_ev_presence_nudge_due is
|
||||||
|
'Proaktivní "píchni auto" notifikace: vozidla doma + odpojená + (SoC neznámé nebo < cíl−tolerance) + (efektivní sell<=0 nebo buy<=práh) v aktuálním 15min slotu. Default-off (asset_vehicle.presence_nudge_enabled). Dedup zápisem do ev_presence_nudge_sent (1 nudge na epizodu doma+odpojeno). Vrací jen nově due řádky pro Discord.';
|
||||||
@@ -162,11 +162,17 @@ begin
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- vehicles nesou parametry SVÉHO wallboxu (join přes default_charger_id,
|
||||||
|
-- výběr DYNAMICKY podle site_id + id, NE podle kódu): min_power_w, počet fází
|
||||||
|
-- (phases — solver_v2 z něj odvozuje 3f power floor proti 1f trickle) a
|
||||||
|
-- planner_ev_start_penalty_czk (anti-fragmentace nabíjení, Fix B; default 0 = no-op).
|
||||||
select coalesce(
|
select coalesce(
|
||||||
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),
|
'min_power_w', coalesce(ch.min_power_w, 0),
|
||||||
|
'phases', coalesce(ch.phases, 3),
|
||||||
|
'planner_ev_start_penalty_czk', coalesce(ch.planner_ev_start_penalty_czk, 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
|
||||||
)
|
)
|
||||||
@@ -259,4 +265,4 @@ end;
|
|||||||
$fn$;
|
$fn$;
|
||||||
|
|
||||||
comment on function ems.fn_planning_site_context is
|
comment on function ems.fn_planning_site_context is
|
||||||
'Kontext pro planning_engine / LP (bez samotného solveru). EV session přes fn_ev_session_planning_json: session se nevyřazuje při needed_wh=0 (auto nad targetem) — zůstává v plánu kvůli oportunismu i jako známá zátěž; 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.';
|
'Kontext pro planning_engine / LP (bez samotného solveru). EV session přes fn_ev_session_planning_json: session se nevyřazuje při needed_wh=0 (auto nad targetem) — zůstává v plánu kvůli oportunismu i jako známá zátěž; opportunistic_value = coalesce(session, vehicle); headroom_wh od max(target, soc_at_connect), 0 při vypnutém oportunismu; vehicles nesou parametry svého wallboxu (min_power_w, phases, planner_ev_start_penalty_czk — anti-fragmentace EV v solver_v2, default 0 = no-op).';
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ begin
|
|||||||
select a.* into v_arr
|
select a.* into v_arr
|
||||||
from ems.ev_vehicle_obs a
|
from ems.ev_vehicle_obs a
|
||||||
where a.vehicle_id = r.vehicle_id
|
where a.vehicle_id = r.vehicle_id
|
||||||
and a.trigger = 'arrival'
|
and a.trigger in ('arrival', 'geofence_arrival')
|
||||||
and a.observed_at > r.observed_at
|
and a.observed_at > r.observed_at
|
||||||
and a.odometer_km is not null
|
and a.odometer_km is not null
|
||||||
order by a.observed_at
|
order by a.observed_at
|
||||||
@@ -79,6 +79,9 @@ begin
|
|||||||
end;
|
end;
|
||||||
$fn$;
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_ev_build_trips is
|
||||||
|
'Spáruje každý nespárovaný odjezd (trigger=departure) s nejbližším následujícím příjezdem téhož vozidla. Příjezd = trigger ''arrival'' (wallbox plug-in, autoritativní) NEBO ''geofence_arrival'' (Tesla poloha, auto přijelo domů nepíchnuté). km z odometru, kWh z ΔSoC.';
|
||||||
|
|
||||||
-- Přepočet týdenního rytmu z jízd za lookback okno (plný přepočet, ne EMA —
|
-- Přepočet týdenního rytmu z jízd za lookback okno (plný přepočet, ne EMA —
|
||||||
-- rebuild-friendly; jízdy s nabíjením cestou se počítají do km, ne do kWh).
|
-- rebuild-friendly; jízdy s nabíjením cestou se počítají do km, ne do kWh).
|
||||||
create or replace function ems.fn_update_ev_usage_stats(
|
create or replace function ems.fn_update_ev_usage_stats(
|
||||||
|
|||||||
@@ -256,6 +256,23 @@ plánovač slepý k pokroku → phantom 11 kW okna i u plného auta. Funguje pro
|
|||||||
poslední taper k 100 % (jinak věčné mini-dobíjení → cyklování nabíječky / Tesla
|
poslední taper k 100 % (jinak věčné mini-dobíjení → cyklování nabíječky / Tesla
|
||||||
notifikace). `charge_done_tolerance_pct = 0` → tvrdě na target.
|
notifikace). `charge_done_tolerance_pct = 0` → tvrdě na target.
|
||||||
|
|
||||||
|
### Geofence arrival trigger (V109, default-off)
|
||||||
|
|
||||||
|
Příjezd domů se dnes zaznamenává jen z wallboxu (plug-in). `ev_vehicle_obs.trigger`
|
||||||
|
nově povoluje **`geofence_arrival`**: presence cesta (`telemetry_collector`, z Tesla
|
||||||
|
polohy bez buzení) při přechodu pryč→domů u **nepíchnutého** auta zapíše obs (SoC,
|
||||||
|
odometr) → `fn_ev_build_trips` (R__096) ji spáruje jako příjezd → spotřební forecast
|
||||||
|
ví o jízdě i bez píchnutí. Za env flagem `EV_GEOFENCE_ARRIVAL_OBS_ENABLED` (default
|
||||||
|
OFF), debounce 2 vzorky, dedup s wallbox arrival (plugged = wallbox autoritativní).
|
||||||
|
|
||||||
|
### Proaktivní notifikace „píchni auto" (V110, default-off)
|
||||||
|
|
||||||
|
Job `ev_presence_notify` (~25 min) pošle Discord nudge, když je auto **doma +
|
||||||
|
nepíchnuté + (SoC neznámé nebo < target − tolerance) + (efektivní sell ≤ 0 nebo buy
|
||||||
|
pod prahem)**. SQL-first: rozhodnutí + dedup v `ems.fn_ev_presence_nudge_due` (dedup
|
||||||
|
přes `ev_presence_nudge_sent`, 1 nudge na epizodu doma+odpojeno). Per-vozidlo flag
|
||||||
|
`asset_vehicle.presence_nudge_enabled` (default **false** = inertní).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Statistika příjezdů
|
## Statistika příjezdů
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-14 — EV anti-fragmentace + 3f power floor (Fix B, solver_v2)
|
||||||
|
|
||||||
|
- **Problém:** EV nabíjení v solveru spojité po slotech bez start/stop penalty → rozsekané přes nesouvislé sloty + dílčí 1f trickle (sub-6A, který control stejně shazoval na 0 A) → cyklování nabíječky, Tesla notifikace.
|
||||||
|
- **Mechanismus (fix):** (a) **3f power floor** — pro `asset_ev_charger.phases >= 3` je min nabíjecí dávka 6 A × fáze × 230 V (≈4140 W) místo 1f ~1380 W (strop = max výkon vozidla); ruší sub-6A 1f drobky (fyzikálně realizovatelné dávky). (b) **block-start penalta** — per-slot binárka `ev_on`, hrana `ev_start[t] >= ev_on[t]−ev_on[t−1]`, objektiv += Σ ev_start × `asset_ev_charger.planner_ev_start_penalty_czk` (V108, **default 0 = no-op**, kalibruje se per wallbox). Drží v2 filozofii „nejistota/opotřebení = cena".
|
||||||
|
- **Soubory:** V108, R__039 (phases + start_penalty do kontextu), db_io.py, constants.py, solver_v2.py.
|
||||||
|
- **Ověření:** golden gate 7 passed + full suite 363 passed (fixtures EV nulují → start penalta inertní). Živě ověřeno: `asset_ev_charger.phases=3`, `min_power_w=1380` (1f) → 3f floor opraví na 4140. **Pozn.:** 3f floor je AKTIVNÍ v prod (ne za flagem) — korektnostní fix; start penalta default-off do kalibrace. Postaveno paralelním worktree agentem, integrováno + zvalidováno sériově.
|
||||||
|
|
||||||
## 2026-06-14 — EV: tolerance „dost dobré" — konec honění posledních % do 100 %
|
## 2026-06-14 — EV: tolerance „dost dobré" — konec honění posledních % do 100 %
|
||||||
|
|
||||||
- **Problém:** po live-SoC fixu zůstalo malé deadline dobití (~1.33 kWh v 05:00) honící posledních ~2 % k targetu 100 %. live_soc clampnuté na 99 % vs target 100 % → needed_wh nikdy neklesne na 0 → **věčné mini-dobíjení = start/stop nabíječky, Tesla notifikace, zbytečné Modbus zápisy** (cyklování).
|
- **Problém:** po live-SoC fixu zůstalo malé deadline dobití (~1.33 kWh v 05:00) honící posledních ~2 % k targetu 100 %. live_soc clampnuté na 99 % vs target 100 % → needed_wh nikdy neklesne na 0 → **věčné mini-dobíjení = start/stop nabíječky, Tesla notifikace, zbytečné Modbus zápisy** (cyklování).
|
||||||
|
|||||||
Reference in New Issue
Block a user