Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce30dbd4a4 | ||
|
|
daf7ed4d4b | ||
|
|
17147ca412 | ||
|
|
c27e1cbe6d | ||
|
|
1479572569 | ||
|
|
b052c9c0e7 | ||
|
|
c03f9dd9d6 | ||
|
|
fc6d9833a7 | ||
|
|
a32839bf67 | ||
|
|
fd7012e23d | ||
|
|
a9a6a88a88 | ||
|
|
f70111f44b | ||
|
|
3e369606b4 | ||
|
|
8ffe5460f1 |
@@ -110,7 +110,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
|
||||
|
||||
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`.
|
||||
|
||||
18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **`export_mode=PV_SURPLUS`** → **108=0**, **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10–100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** reg **108** EMS **nezapisuje** (selling first = **142**), **109**=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
|
||||
18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **`export_mode=PV_SURPLUS`** → reg **108 sleduje charge intent plánu** (fix 2026-06-16): `bat_w>0` → **108=max** (baterka nabere kolik fyzicky zvládne, přebytek **nad nabíjecí rychlost** do sítě — případ „výroba > rychlost baterky", BA81); SoC u maxima (`>= max_soc − BATTERY_CALIB_TOPOFF_MARGIN_PCT`) + přebytek → **108=max** (BMS kalibrace na 100 %); jen `bat_w<=0` daleko od maxima → **108=0** (prodej PV, drž baterku). **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10–100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** reg **108** EMS **nezapisuje** (selling first = **142**), **109**=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
|
||||
|
||||
19. **HARD LIMIT exportu na fakturačním elektroměru — NIKDY nepřekročit.** Překročení rezervovaného exportního výkonu (home-01: 13.5 kW) byť o desetiny kW = smluvní pokuta v řádu desítek tisíc Kč za kW. Jediný bezpečný invariant: **reg 143 (limit na svorkách střídače) <= max_export_power_w (limit ulice) VŽDY** — v nejhorším případě (spotřeba mezi střídačem a CT odpadne) je ulice rovna svorkám. **ZAKÁZÁNO** jakékoli feed-forward navyšování terminálového limitu o měřenou spotřebu (výpadek spotřeby = přestřelení ulice). Vyšší vytěžení smí přinést jedině interní regulace střídače proti CT (firmware smyčka), nikdy náš software s 1min telemetrií a 15min ticky.
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from services.signal_service import (
|
||||
run_signal_outbound_send_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__)
|
||||
|
||||
@@ -161,6 +162,34 @@ async def lifespan(app: FastAPI):
|
||||
except Exception:
|
||||
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:
|
||||
# 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ž
|
||||
# existuje signal_route). Doručení řeší signal_outbound_send. Žádné Modbus.
|
||||
try:
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
rows = await conn.fetch("select * from ems.fn_pool_control_tick()")
|
||||
for r in rows:
|
||||
logger.info(
|
||||
"pool control site=%s pump=%s on=%s runtime_min=%s route=%s enq=%s",
|
||||
r["site_id"], r["pump_id"], r["desired_on"],
|
||||
r["runtime_min"], r["has_route"], r["enqueued"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("scheduled_pool_control failed")
|
||||
|
||||
async def scheduled_verify_modbus() -> None:
|
||||
"""
|
||||
Ověří příkazy ve stavu written z posledních 20 minut.
|
||||
@@ -413,6 +442,22 @@ async def lifespan(app: FastAPI):
|
||||
id="signal_outbound_verify",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
scheduled_pool_control,
|
||||
"cron",
|
||||
minute="*/15",
|
||||
second=2,
|
||||
id="pool_control",
|
||||
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_rolling_replan,
|
||||
|
||||
@@ -91,6 +91,8 @@ async def write_inverter_setpoints(
|
||||
max_discharge_a=int(inv.max_discharge_a),
|
||||
export_mode=setpoints_now.export_mode,
|
||||
export_ban=bool(setpoints_now.export_ban),
|
||||
current_soc_pct=soc_telemetry,
|
||||
max_soc_pct=inv.max_soc_percent,
|
||||
)
|
||||
|
||||
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
|
||||
|
||||
@@ -17,6 +17,10 @@ from services.control.models import ControlSetpoints, InverterConfig, OperatingM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Tolerance pod max SoC, v rámci níž se v PV přebytku nechá baterka dojet na max
|
||||
#: (reg 108 = max) kvůli BMS rekalibraci SoC (LiFePO4 potřebuje občas na 100 %).
|
||||
BATTERY_CALIB_TOPOFF_MARGIN_PCT = 3.0
|
||||
|
||||
|
||||
def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]:
|
||||
"""Hodnoty pro reg 62-64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis)."""
|
||||
@@ -437,12 +441,20 @@ def deye_battery_charge_discharge_amps(
|
||||
max_discharge_a: int,
|
||||
export_mode: str | None = None,
|
||||
export_ban: bool = False,
|
||||
current_soc_pct: float | None = None,
|
||||
max_soc_pct: int | None = None,
|
||||
) -> tuple[int | None, int]:
|
||||
"""
|
||||
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
|
||||
|
||||
**PV_SURPLUS** (PASSIVE, export FVE): **108 = 0**, **109 = max** — baterie se přes limit
|
||||
nabíjení neplní, přebytek jde do sítě (142 = zero-export dle instalace, 145 = 1).
|
||||
**PV_SURPLUS** (PASSIVE, export FVE) — reg 108 SLEDUJE charge intent plánu (fix 2026-06-16):
|
||||
- `bat_w > 0` (plán chce nabíjet z přebytku) → **108 = max**: baterie nabere kolik fyzicky
|
||||
zvládne (nabíjecí rychlost), přebytek NAD ni jde do sítě (BA81: výroba 12 kW > rychlost
|
||||
6 kW → 6 do baterky, 6 ven). Dřív tvrdě 108=0 i při bat_w>0 → baterka nenabíjela ani
|
||||
levné ranní PV (control bug).
|
||||
- kalibrace: SoC u maxima (`>= max_soc − margin`) + přebytek → **108 = max**, ať dojede na
|
||||
100 % (BMS rekalibrace SoC). Strop drží Deye max_soc.
|
||||
- jen „prodej PV a drž baterku" daleko od maxima (`bat_w <= 0`) → **108 = 0**, přebytek ven.
|
||||
|
||||
PASSIVE + nabíjení bez exportního záměru (`battery_w > 0`, export_mode NONE): **108 = max**.
|
||||
**CHARGE** ze sítě: 108 z `battery_w`.
|
||||
@@ -464,6 +476,16 @@ def deye_battery_charge_discharge_amps(
|
||||
export_ban=export_ban,
|
||||
grid_w=grid_w,
|
||||
):
|
||||
# reg 108 sleduje charge intent: nabíjet z přebytku (bat_w>0) nebo dojet na max
|
||||
# kvůli BMS kalibraci (SoC u maxima + přebytek) → 108 = max; jinak 108 = 0 (přebytek
|
||||
# ven). Strop SoC drží Deye max_soc, takže 108=max nepřebije nad povolené.
|
||||
near_full_calib = (
|
||||
current_soc_pct is not None
|
||||
and max_soc_pct is not None
|
||||
and float(current_soc_pct) >= float(max_soc_pct) - BATTERY_CALIB_TOPOFF_MARGIN_PCT
|
||||
)
|
||||
if bat_w > 0 or near_full_calib:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
return 0, int(max_discharge_a)
|
||||
if bat_w > 0:
|
||||
return int(max_charge_a), int(max_discharge_a)
|
||||
|
||||
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) ---
|
||||
MORNING_PRENEG_START_HOUR = 5
|
||||
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(
|
||||
max_charge_power_w=int(v["max_charge_power_w"]),
|
||||
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"]),
|
||||
default_target_soc_pct=float(v["default_target_soc_pct"]),
|
||||
)
|
||||
@@ -150,6 +157,8 @@ async def _load_site_context(site_id: int, db):
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=0,
|
||||
min_power_w=0,
|
||||
phases=3,
|
||||
planner_ev_start_penalty_czk=0.0,
|
||||
battery_capacity_kwh=1.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
|
||||
# (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í.
|
||||
# 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 →
|
||||
# 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
|
||||
@@ -60,6 +68,9 @@ from typing import Any, Optional
|
||||
import pulp
|
||||
|
||||
from services.planning.constants import (
|
||||
EV_MIN_CHARGE_CURRENT_A,
|
||||
EV_MULTIPHASE_FLOOR_MIN_PHASES,
|
||||
EV_PHASE_VOLTAGE_V,
|
||||
INTERVAL_H,
|
||||
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_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)
|
||||
ev_start_terms: list = [] # (ev_start var, penalta Kč) — anti-fragmentace (Fix B)
|
||||
|
||||
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]
|
||||
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:
|
||||
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):
|
||||
on_t = ev_on[e][t] if ev_on[e] is not None else None
|
||||
if not _connected(e, t):
|
||||
prob += ev_direct[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:
|
||||
ev_max_w = float(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}"
|
||||
if on_t is not None and ev_max_w > 0:
|
||||
# on=1 nutné kdykoli ev_total > 0 (start penalta i floor to potřebují)
|
||||
prob += ev_total <= ev_max_w * on_t, 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:
|
||||
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)
|
||||
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)
|
||||
if ev_opp:
|
||||
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_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
|
||||
for t in range(T)
|
||||
@@ -521,6 +594,8 @@ def solve_dispatch_v2(
|
||||
"slot_count": T,
|
||||
"ev_sessions": sum(1 for x in ev_sessions if x is not None),
|
||||
"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,
|
||||
"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,
|
||||
@@ -535,6 +610,12 @@ def solve_dispatch_v2(
|
||||
"terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
|
||||
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet],
|
||||
"ev_opp_wh": [round(_val(o), 1) for o, _v in ev_opp],
|
||||
"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_status": status_str,
|
||||
|
||||
@@ -760,6 +760,27 @@ _EV_PRESENCE_LAST_DATA: dict[int, float] = {}
|
||||
_EV_PRESENCE_LAST_STATE: dict[int, str] = {}
|
||||
_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:
|
||||
"""Č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
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
charging_state = None
|
||||
shift_state = None
|
||||
st = None
|
||||
if api_state == "online" and (woke_up or data_due):
|
||||
_EV_PRESENCE_LAST_DATA[int(veh["id"])] = loop_now
|
||||
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,
|
||||
)
|
||||
|
||||
# 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)
|
||||
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:
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""PASSIVE + PV_SURPLUS: 108=0 (nepoužívat baterii), 109=max; 142 zůstává zero-export (1/2)."""
|
||||
"""PASSIVE + PV_SURPLUS: reg 108 sleduje charge intent (fix 2026-06-16).
|
||||
|
||||
bat_w>0 (plán chce nabíjet z přebytku) → 108=max (baterka nabere co zvládne, zbytek ven);
|
||||
SoC u maxima + přebytek → 108=max (BMS kalibrace na 100 %); jen "prodej PV a drž baterku"
|
||||
daleko od maxima (bat_w<=0) → 108=0. 109=max, 142 zůstává zero-export (1/2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,8 +28,11 @@ class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 90)
|
||||
|
||||
def test_pv_surplus_even_if_lp_shows_positive_battery_w(self) -> None:
|
||||
"""Plán může mít kladný battery_w; exportní záměr je PV_SURPLUS → 108=0."""
|
||||
def test_pv_surplus_with_positive_battery_w_charges_at_max(self) -> None:
|
||||
"""Fix 2026-06-16: plán chce nabíjet z přebytku (bat_w>0) → 108=max (ne 0).
|
||||
|
||||
Baterka nabere kolik zvládne, přebytek nad nabíjecí rychlost jde do sítě (BA81).
|
||||
"""
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
@@ -36,6 +44,41 @@ class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
)
|
||||
self.assertEqual(ch, 100)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
def test_pv_surplus_near_full_tops_off_for_calibration(self) -> None:
|
||||
"""SoC u maxima (97 >= 100-3) + přebytek → 108=max i při bat_w<=0 (BMS kalibrace)."""
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=0,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
current_soc_pct=97.0,
|
||||
max_soc_pct=100,
|
||||
)
|
||||
self.assertEqual(ch, 100)
|
||||
|
||||
def test_pv_surplus_sell_hold_far_from_full_zeros_charge(self) -> None:
|
||||
"""Prodej PV a drž baterku daleko od maxima (bat_w<=0, SoC nízko) → 108=0."""
|
||||
ch, dis = deye_battery_charge_discharge_amps(
|
||||
lock_battery=False,
|
||||
deye_mode="PASSIVE",
|
||||
self_sustain_local_use=False,
|
||||
bat_w=0,
|
||||
grid_w=-2000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
export_mode="PV_SURPLUS",
|
||||
export_ban=False,
|
||||
current_soc_pct=60.0,
|
||||
max_soc_pct=100,
|
||||
)
|
||||
self.assertEqual(ch, 0)
|
||||
self.assertEqual(dis, 100)
|
||||
|
||||
|
||||
10
db/migration/V107__ev_charge_done_tolerance.sql
Normal file
10
db/migration/V107__ev_charge_done_tolerance.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- EV: tolerance „dost dobré" pro deadline charging — nehonit posledních pár % do
|
||||
-- targetu (taper region u plného auta). Řeší věčné mini-dobíjení odhalené live-SoC
|
||||
-- fixem (live_soc clamp 99 vs target 100 → needed nikdy neklesne na 0 → cyklování
|
||||
-- nabíječky, Tesla notifikace). needed_wh = 0 když live_soc >= least(target,99) − tolerance.
|
||||
|
||||
alter table ems.asset_vehicle
|
||||
add column if not exists charge_done_tolerance_pct numeric(4, 2) not null default 3.0;
|
||||
|
||||
comment on column ems.asset_vehicle.charge_done_tolerance_pct is
|
||||
'Tolerance „dost dobré" pro deadline charging (procentní body). needed_wh=0 když live_soc >= least(target,99) − tato tolerance — nehonit poslední taper k 100 % (zbytečné start/stop nabíječky a Tesla notifikace). 0 = tvrdě na target. Default 3 p.b.';
|
||||
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.';
|
||||
10
db/migration/V111__activate_ev_presence_nudge_home01.sql
Normal file
10
db/migration/V111__activate_ev_presence_nudge_home01.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Aktivace proaktivní notifikace „píchni auto" pro Teslu na home-01.
|
||||
-- V110 přidala flag asset_vehicle.presence_nudge_enabled (default false = inertní);
|
||||
-- tato migrace ho operačně zapíná pro tesla-my. Job ev_presence_notify pak pošle
|
||||
-- Discord nudge, když je auto doma + odpojené + (SoC < cíl−tolerance) + levné/přebytek.
|
||||
-- Vypnutí později: nová migrace nebo operační update.
|
||||
|
||||
update ems.asset_vehicle
|
||||
set presence_nudge_enabled = true
|
||||
where code = 'tesla-my'
|
||||
and site_id = (select id from ems.site where code = 'home-01');
|
||||
38
db/migration/V112__activate_pool_control_home01.sql
Normal file
38
db/migration/V112__activate_pool_control_home01.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Aktivace řízení bazénového čerpadla na home-01 (Phase 1).
|
||||
-- Kód: R__101 (fn_pool_schedule_slot / fn_pool_control_tick) + V087 (asset/telemetrie)
|
||||
-- + R__094 (fn_signal_enqueue_bool) + signal_service. Tato migrace ho operačně zapíná:
|
||||
-- 1) signal_route POOL_PUMP_ON → Shelly Gen2 RPC Switch.Set (http_rest), map_bool
|
||||
-- true/false; switch id z asset_pool_pump.shelly_switch_id. verify_readback=false
|
||||
-- (ověřuje se přes telemetry_pool_pump.is_on; readback verify lze doplnit později).
|
||||
-- 2) schedulable = true → control tick (á 15 min) řídí relé dle nejlevnějšího okna
|
||||
-- denního runtime budgetu (fn_pool_daily_runtime_min) + dump-load při sell<=0.
|
||||
-- Tím se i opraví bazálový odečet (R__003): bazén je nyní řízený + plánovaný.
|
||||
-- Idempotentní: route se vloží jen pokud ještě neexistuje.
|
||||
|
||||
insert into ems.signal_route
|
||||
(site_id, signal_code, destination_type, endpoint_id, destination_key,
|
||||
route_config_json, transform_json, verify_readback, enabled)
|
||||
select
|
||||
pp.site_id,
|
||||
'POOL_PUMP_ON',
|
||||
'http_rest',
|
||||
pp.endpoint_id,
|
||||
'pool-pump',
|
||||
jsonb_build_object(
|
||||
'method', 'GET',
|
||||
'path_template', '/rpc/Switch.Set?id=' || pp.shelly_switch_id || '&on={value}'
|
||||
),
|
||||
'{"map_bool": {"true": "true", "false": "false"}}'::jsonb,
|
||||
false,
|
||||
true
|
||||
from ems.asset_pool_pump pp
|
||||
where pp.site_id = (select id from ems.site where code = 'home-01')
|
||||
and pp.endpoint_id is not null
|
||||
and not exists (
|
||||
select 1 from ems.signal_route sr
|
||||
where sr.site_id = pp.site_id and sr.signal_code = 'POOL_PUMP_ON'
|
||||
);
|
||||
|
||||
update ems.asset_pool_pump
|
||||
set schedulable = true
|
||||
where site_id = (select id from ems.site where code = 'home-01');
|
||||
10
db/migration/V113__activate_ev_start_penalty_home01.sql
Normal file
10
db/migration/V113__activate_ev_start_penalty_home01.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Aktivace anti-fragmentační start penalty EV na home-01 (konzervativně 0.5 Kč/start).
|
||||
-- V108 přidala sloupec asset_ev_charger.planner_ev_start_penalty_czk (default 0 = no-op);
|
||||
-- tato migrace zapíná malou penaltu → solver slepí EV nabíjení do souvislejší dávky
|
||||
-- místo rozsekání. 3f floor (Fix B) už 1f trickle vyřešil; tohle je doladění.
|
||||
-- Hodnota je laditelná — pokud by penalta přebíjela reálný cenový spread, snížit/zrušit
|
||||
-- novou migrací nebo operačním updatem.
|
||||
|
||||
update ems.asset_ev_charger
|
||||
set planner_ev_start_penalty_czk = 0.5
|
||||
where site_id = (select id from ems.site where code = 'home-01');
|
||||
@@ -8,12 +8,53 @@
|
||||
-- dokud má oportunistický headroom (cena rozhodne, jestli se nabíjí) — měkký
|
||||
-- cíl řeší solver dekompozicí Σ == needed − unmet + opp.
|
||||
--
|
||||
-- ŽIVÉ SoC (fix 2026-06-14, phantom okna): needed_wh i headroom se počítají z
|
||||
-- ŽIVÉHO SoC = soc_at_connect + integrovaná dodaná energie (fn_ev_session_delivered_wh),
|
||||
-- ne ze zamrzlého soc_at_connect. Dřív se odečítalo es.energy_delivered_wh, JENŽE
|
||||
-- ten sloupec se během session NIKDY nezapisoval (trvale 0) → needed_wh konstantní
|
||||
-- → plánovač slepý k pokroku nabíjení → 11 kW phantom okna i u plného auta.
|
||||
-- NEpoužíváme energy_kwh counter (Telto reg 39 na TeltoCharge neakumuluje —
|
||||
-- ověřeno: 17.4 kWh nabito, counter stál na 0.18 kWh), proto integrál power_w.
|
||||
-- live_soc clamp 99 % (finální taper k 100 % ignorujeme). Fallback na
|
||||
-- energy_delivered_wh drží staré fixtures bez telemetrie identické.
|
||||
--
|
||||
-- Vyřazení (null) jen když chybí tvrdá data:
|
||||
-- - žádná otevřená session na wallboxu, nebo
|
||||
-- - neznámá kapacita vozidla / SoC při připojení (nelze spočítat Wh).
|
||||
-- target_deadline SMÍ být NULL (žádný tvrdý cíl) — solver to zvládá
|
||||
-- (deadline constraint se aplikuje jen při needed_wh > 0).
|
||||
|
||||
-- Dodaná energie do auta za session = time-weighted integrál power_w z
|
||||
-- telemetry_ev_charger (1min). dt cap 120 s ať výpadek telemetrie nezkresluje.
|
||||
-- Wh (AC, bez korekce na AC→DC ztráty — mírně optimistické = méně phantom,
|
||||
-- žádoucí směr). Vrací 0 bez telemetrie (drží staré chování).
|
||||
drop function if exists ems.fn_ev_session_delivered_wh;
|
||||
|
||||
create or replace function ems.fn_ev_session_delivered_wh(
|
||||
p_charger_id int,
|
||||
p_since timestamptz
|
||||
)
|
||||
returns numeric
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
select coalesce(sum(
|
||||
power_w * least(coalesce(dt, 60), 120)
|
||||
) / 3600.0, 0)::numeric
|
||||
from (
|
||||
select power_w,
|
||||
extract(epoch from (
|
||||
measured_at - lag(measured_at) over (order by measured_at)
|
||||
)) as dt
|
||||
from ems.telemetry_ev_charger
|
||||
where charger_id = p_charger_id
|
||||
and measured_at >= p_since
|
||||
) q;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_ev_session_delivered_wh is
|
||||
'Dodaná energie do EV za session (Wh, AC) = time-weighted integrál power_w z telemetry_ev_charger (dt cap 120 s). NEpoužívá energy_kwh counter (Telto reg 39 neakumuluje). Vstup živého SoC ve fn_ev_session_planning_json. 0 bez telemetrie.';
|
||||
|
||||
drop function if exists ems.fn_ev_session_planning_json;
|
||||
|
||||
create or replace function ems.fn_ev_session_planning_json(
|
||||
@@ -24,53 +65,83 @@ returns jsonb
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
with s as (
|
||||
select
|
||||
es.soc_at_connect_pct,
|
||||
es.target_soc_pct,
|
||||
es.target_deadline,
|
||||
es.energy_delivered_wh,
|
||||
es.opportunistic_value_czk_kwh,
|
||||
v.battery_capacity_kwh,
|
||||
v.default_target_soc_pct,
|
||||
v.opportunistic_value_czk_kwh as v_opp,
|
||||
coalesce(v.charge_done_tolerance_pct, 3.0) as charge_done_tolerance_pct,
|
||||
ems.fn_ev_session_delivered_wh(es.charger_id, es.session_start) as live_delivered_wh
|
||||
from ems.ev_session es
|
||||
join ems.asset_ev_charger ch on ch.id = es.charger_id
|
||||
left join ems.asset_vehicle v on v.id = es.vehicle_id
|
||||
where es.site_id = p_site_id
|
||||
and es.session_end is null
|
||||
and ch.code = p_charger_code
|
||||
limit 1
|
||||
),
|
||||
c as (
|
||||
select s.*,
|
||||
-- živé SoC: SoC při připojení + integrovaná dodaná energie, clamp 99 %.
|
||||
-- coalesce(live, energy_delivered_wh, 0): bez telemetrie = staré chování.
|
||||
least(99.0, s.soc_at_connect_pct::numeric
|
||||
+ coalesce(s.live_delivered_wh, s.energy_delivered_wh, 0)::numeric
|
||||
/ (s.battery_capacity_kwh * 1000) * 100.0) as live_soc_pct
|
||||
from s
|
||||
)
|
||||
select case
|
||||
when v.battery_capacity_kwh is null then null::jsonb
|
||||
when es.soc_at_connect_pct is null then null::jsonb
|
||||
when c.battery_capacity_kwh is null then null::jsonb
|
||||
when c.soc_at_connect_pct is null then null::jsonb
|
||||
else jsonb_build_object(
|
||||
-- tvrdý cíl: jen pokud je nastaven deadline I cílový SoC (jinak null →
|
||||
-- solver hard constraint vynechá, energy_needed_wh = 0).
|
||||
'target_deadline', case
|
||||
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null
|
||||
else es.target_deadline
|
||||
when coalesce(c.target_soc_pct, c.default_target_soc_pct) is null then null
|
||||
else c.target_deadline
|
||||
end,
|
||||
-- effective target zastropovaný na 99 (clamp live_soc) → bez věčného
|
||||
-- mini-dobíjení u plného auta. „Dost dobré" tolerance: needed=0 když je
|
||||
-- live_soc ve vzdálenosti tolerance od targetu (nehonit poslední taper →
|
||||
-- žádné zbytečné start/stop nabíječky). 0 = tvrdě na target.
|
||||
'energy_needed_wh', case
|
||||
when es.target_deadline is null then 0::numeric
|
||||
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then 0::numeric
|
||||
when c.target_deadline is null then 0::numeric
|
||||
when coalesce(c.target_soc_pct, c.default_target_soc_pct) is null then 0::numeric
|
||||
when c.live_soc_pct >=
|
||||
least(coalesce(c.target_soc_pct, c.default_target_soc_pct)::numeric, 99)
|
||||
- c.charge_done_tolerance_pct
|
||||
then 0::numeric
|
||||
else greatest(
|
||||
0,
|
||||
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
|
||||
- es.soc_at_connect_pct::numeric) / 100.0
|
||||
* (v.battery_capacity_kwh * 1000)
|
||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
||||
(least(coalesce(c.target_soc_pct, c.default_target_soc_pct)::numeric, 99)
|
||||
- c.live_soc_pct) / 100.0
|
||||
* (c.battery_capacity_kwh * 1000)
|
||||
)
|
||||
end,
|
||||
-- headroom do 100 % od max(target, SoC při připojení): „nenabíjet" (nízký
|
||||
-- target) nesmí ZVĚTŠIT oportunistickou vrstvu; auto fyzicky bere jen
|
||||
-- energii nad svým aktuálním SoC. Při vypnutém oportunismu (value <= 0)
|
||||
-- headroom = 0 — session zůstane v plánu, ale solver ji nebude doplňovat.
|
||||
-- headroom do 99 % od max(target, ŽIVÉ SoC): „nenabíjet" (nízký target)
|
||||
-- nesmí ZVĚTŠIT oportunistickou vrstvu; auto fyzicky bere jen energii nad
|
||||
-- aktuálním SoC. Plné auto (live_soc → 99) → headroom 0. Při vypnutém
|
||||
-- oportunismu (value <= 0) headroom = 0.
|
||||
'headroom_wh', case
|
||||
when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
|
||||
when coalesce(c.opportunistic_value_czk_kwh, c.v_opp, 0) > 0 then greatest(
|
||||
0,
|
||||
(100 - greatest(
|
||||
coalesce(es.target_soc_pct, v.default_target_soc_pct, es.soc_at_connect_pct)::numeric,
|
||||
es.soc_at_connect_pct::numeric
|
||||
)) / 100.0 * (v.battery_capacity_kwh * 1000)
|
||||
(99 - greatest(
|
||||
coalesce(c.target_soc_pct, c.default_target_soc_pct, c.live_soc_pct)::numeric,
|
||||
c.live_soc_pct
|
||||
)) / 100.0 * (c.battery_capacity_kwh * 1000)
|
||||
)
|
||||
else 0
|
||||
end,
|
||||
'opportunistic_value_czk_kwh',
|
||||
coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0)
|
||||
coalesce(c.opportunistic_value_czk_kwh, c.v_opp, 0)
|
||||
)
|
||||
end
|
||||
from ems.ev_session es
|
||||
join ems.asset_ev_charger ch on ch.id = es.charger_id
|
||||
left join ems.asset_vehicle v on v.id = es.vehicle_id
|
||||
where es.site_id = p_site_id
|
||||
and es.session_end is null
|
||||
and ch.code = p_charger_code
|
||||
limit 1;
|
||||
from c;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_ev_session_planning_json is
|
||||
'EV session objekt pro LP (fn_planning_site_context). Session se NEvyřazuje při needed_wh=0 (auto nad targetem) — zůstává v plánu kvůli oportunistickému headroomu i jako známá zátěž. Null jen bez použitelných dat (kapacita / soc_at_connect). target_deadline smí být NULL (bez tvrdého cíle).';
|
||||
'EV session objekt pro LP (fn_planning_site_context). needed_wh i headroom z ŽIVÉHO SoC = soc_at_connect + fn_ev_session_delivered_wh (integrál power_w), clamp 99 % — ne ze zamrzlého soc_at_connect (energy_delivered_wh se nikdy nezapisoval → phantom 11 kW okna). Session se NEvyřazuje při needed_wh=0 (zůstává jako známá zátěž + oportunistický headroom). Null jen bez použitelných dat (kapacita / soc_at_connect). target_deadline smí být NULL.';
|
||||
|
||||
@@ -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(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'max_charge_power_w', v.max_charge_power_w,
|
||||
'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,
|
||||
'default_target_soc_pct', v.default_target_soc_pct
|
||||
)
|
||||
@@ -259,4 +265,4 @@ end;
|
||||
$fn$;
|
||||
|
||||
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
|
||||
from ems.ev_vehicle_obs a
|
||||
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.odometer_km is not null
|
||||
order by a.observed_at
|
||||
@@ -79,6 +79,9 @@ begin
|
||||
end;
|
||||
$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 —
|
||||
-- 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(
|
||||
|
||||
127
db/routines/R__101_fn_pool_control.sql
Normal file
127
db/routines/R__101_fn_pool_control.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- Řízení bazénového čerpadla (Phase 1, bez solveru): denní runtime budget z
|
||||
-- fn_pool_daily_runtime_min (teplotní nebo statický fallback) rozvržený do
|
||||
-- NEJLEVNĚJŠÍHO souvislého okna dne (efektivní nákupní cena), + dump-load overlay
|
||||
-- (záporná/nulová výkupní cena → absorbuj přebytek místo exportu se ztrátou).
|
||||
-- Výstup řídí Shelly relé přes signál POOL_PUMP_ON (fn_signal_enqueue_bool →
|
||||
-- signal_service). Žádné Modbus. Bazál (R__003) bazén odečítá → s tímto řízením
|
||||
-- se odečet stává správným (řízená + plánovaná zátěž).
|
||||
|
||||
-- Rozhodnutí ON/OFF pro daný 15min slot.
|
||||
create or replace function ems.fn_pool_schedule_slot(
|
||||
p_pump_id int,
|
||||
p_slot_start timestamptz
|
||||
)
|
||||
returns boolean
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
with cfg as (
|
||||
select pp.id, pp.site_id, pp.schedulable,
|
||||
greatest(0, coalesce(
|
||||
(ems.fn_pool_daily_runtime_min(pp.id) ->> 'runtime_min')::int, 0
|
||||
)) as runtime_min
|
||||
from ems.asset_pool_pump pp
|
||||
where pp.id = p_pump_id
|
||||
),
|
||||
win as (
|
||||
select c.site_id, ceil(c.runtime_min::numeric / 15.0)::int as w
|
||||
from cfg c
|
||||
),
|
||||
-- sloty kalendářního dne slotu (Europe/Prague) s efektivní cenou
|
||||
day_slots as (
|
||||
select ep.interval_start,
|
||||
ep.effective_buy_price_czk_kwh as buy,
|
||||
ep.effective_sell_price_czk_kwh as sell,
|
||||
row_number() over (order by ep.interval_start) as rn
|
||||
from ems.vw_site_effective_price ep
|
||||
join cfg c on c.site_id = ep.site_id
|
||||
where (ep.interval_start at time zone 'Europe/Prague')::date
|
||||
= (p_slot_start at time zone 'Europe/Prague')::date
|
||||
),
|
||||
-- nejlevnější souvislé okno délky w slotů (self-join, ~96×w řádků = triviální)
|
||||
best as (
|
||||
select s1.rn as start_rn
|
||||
from day_slots s1
|
||||
join day_slots s2
|
||||
on s2.rn >= s1.rn and s2.rn < s1.rn + (select w from win)
|
||||
where (select w from win) > 0
|
||||
group by s1.rn
|
||||
having count(*) = (select w from win)
|
||||
order by sum(s2.buy) asc, s1.rn asc
|
||||
limit 1
|
||||
)
|
||||
select coalesce((select schedulable from cfg), false)
|
||||
and coalesce(
|
||||
-- v nejlevnějším souvislém okně budgetu
|
||||
exists (
|
||||
select 1 from day_slots ds
|
||||
cross join best b
|
||||
where ds.interval_start = p_slot_start
|
||||
and ds.rn >= b.start_rn
|
||||
and ds.rn < b.start_rn + (select w from win)
|
||||
)
|
||||
-- NEBO dump-load: záporná/nulová výkupní cena ⇒ raději zkonzumuj než exportuj se ztrátou
|
||||
or exists (
|
||||
select 1 from day_slots ds
|
||||
where ds.interval_start = p_slot_start and ds.sell <= 0
|
||||
),
|
||||
false
|
||||
);
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_pool_schedule_slot is
|
||||
'Pool ON/OFF pro 15min slot (Phase 1, bez solveru): nejlevnější souvislé okno délky = daily runtime budget (fn_pool_daily_runtime_min) z vw_site_effective_price, NEBO dump-load při sell<=0. false když pump není schedulable / není cena pro den.';
|
||||
|
||||
-- Control tick: pro každý aktivní řiditelný bazén spočti stav slotu a zařaď signál
|
||||
-- POOL_PUMP_ON (idempotentně). Volá control smyčka každých 15 min (hranice slotu).
|
||||
-- Enqueue jen když existuje signal_route (jinak bezpečně nic — route se seeduje provozně).
|
||||
create or replace function ems.fn_pool_control_tick(
|
||||
p_now timestamptz default now()
|
||||
)
|
||||
returns table(
|
||||
pump_id int,
|
||||
site_id int,
|
||||
desired_on boolean,
|
||||
runtime_min int,
|
||||
has_route boolean,
|
||||
enqueued int
|
||||
)
|
||||
language plpgsql
|
||||
as $fn$
|
||||
declare
|
||||
v_slot timestamptz;
|
||||
r record;
|
||||
v_on boolean;
|
||||
v_route boolean;
|
||||
begin
|
||||
v_slot := date_bin(interval '15 minutes', p_now, timestamptz '1970-01-01T00:00:00Z');
|
||||
for r in
|
||||
select pp.id as pid, pp.site_id as sid,
|
||||
greatest(0, coalesce(
|
||||
(ems.fn_pool_daily_runtime_min(pp.id) ->> 'runtime_min')::int, 0
|
||||
)) as rt
|
||||
from ems.asset_pool_pump pp
|
||||
join ems.site s on s.id = pp.site_id
|
||||
where s.active = true and pp.schedulable = true
|
||||
loop
|
||||
v_on := coalesce(ems.fn_pool_schedule_slot(r.pid, v_slot), false);
|
||||
v_route := exists (
|
||||
select 1 from ems.signal_route sr
|
||||
where sr.site_id = r.sid and sr.signal_code = 'POOL_PUMP_ON'
|
||||
);
|
||||
pump_id := r.pid;
|
||||
site_id := r.sid;
|
||||
desired_on := v_on;
|
||||
runtime_min := r.rt;
|
||||
has_route := v_route;
|
||||
enqueued := case when v_route
|
||||
then ems.fn_signal_enqueue_bool(r.sid, 'POOL_PUMP_ON', v_on)
|
||||
else 0
|
||||
end;
|
||||
return next;
|
||||
end loop;
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_pool_control_tick is
|
||||
'Control tick bazénu (každých 15 min): pro aktivní řiditelné pumpy spočte fn_pool_schedule_slot a zařadí POOL_PUMP_ON (jen když existuje signal_route). Shelly relé pak řídí signal_service. Bez Modbus.';
|
||||
@@ -237,7 +237,41 @@ Uložit do `ev_session` při připojení/odpojení.
|
||||
### Renault Zoe
|
||||
|
||||
Žádné API. Stav připojení čteme výhradně z WB Modbus (`status != 'available'`).
|
||||
SoC Zoe neznáme přesně – použijeme energii dodanou v session (kumulativní kWh z WB).
|
||||
SoC Zoe neznáme přesně – použijeme energii dodanou v session.
|
||||
|
||||
### Živé SoC během session (needed_wh, fix 2026-06-14)
|
||||
|
||||
`fn_ev_session_planning_json` (R__038) počítá `energy_needed_wh` i `headroom_wh` z
|
||||
**živého SoC** = `soc_at_connect + dodaná_energie/kapacita`, clamp 99 % (finální taper
|
||||
ignorujeme) — ne ze zamrzlého `soc_at_connect`. Dodaná energie je **time-weighted integrál
|
||||
`power_w`** (`ems.fn_ev_session_delivered_wh`, dt cap 120 s), NE counter `energy_kwh`:
|
||||
ten je na TeltoCharge (Telto reg 39) **rozbitý** — neakumuluje (ověřeno: 17.4 kWh nabito,
|
||||
counter 0.18). Bez toho byl `energy_delivered_wh` trvale 0 → needed_wh konstantní →
|
||||
plánovač slepý k pokroku → phantom 11 kW okna i u plného auta. Funguje pro Teslu i Zoe
|
||||
(power-based, bez API). Pozn.: reg 39 rozbitý ⇒ i EV audit/ekonomika z něj jede naslepo.
|
||||
|
||||
**Tolerance „dost dobré" (V107):** `energy_needed_wh = 0` když
|
||||
`live_soc >= least(target, 99) − asset_vehicle.charge_done_tolerance_pct` (default
|
||||
3 p.b.). Effective target je zastropovaný na 99 (= clamp live_soc), takže se nehoní
|
||||
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.
|
||||
|
||||
### 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í).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivn
|
||||
|
||||
Režim **CHARGE_CHEAP** nastaví oba setpointy na stejný kladný výkon (min. 1 W), aby byl výsledek **CHARGE**.
|
||||
|
||||
**PASSIVE (ZERO):** u slotu **`export_mode = PV_SURPLUS`** exporter nastaví **108 = 0** (nabíjecí proud), **109 = max** — baterie nemá kam brát přebytek FVE, jde do sítě při **145 = 1**; **142** zůstává **`deye_zero_export_mode`** (u CT často **2** = zero export k měření zátěže, ne selling first z baterie). Detail: `operating-modes.md`.
|
||||
**PASSIVE (ZERO):** u slotu **`export_mode = PV_SURPLUS`** reg **108** (nabíjecí proud) **sleduje charge intent plánu** (fix 2026-06-16): `bat_w > 0` → **108 = max** (baterka nabere kolik fyzicky zvládne, přebytek **nad nabíjecí rychlost** jde do sítě při **145 = 1** — řeší případ „výroba > rychlost baterky" na export-omezených i běžných lokalitách); SoC u maxima (`>= max_soc − 3 p.b.`, `BATTERY_CALIB_TOPOFF_MARGIN_PCT`) + přebytek → **108 = max** (BMS rekalibrace na 100 %); jen „prodej PV a drž baterku" daleko od maxima (`bat_w <= 0`) → **108 = 0**. **109 = max**; **142** zůstává **`deye_zero_export_mode`** (u CT často **2**). Dřív tvrdě **108 = 0** i při `bat_w > 0` → baterka nenabíjela ani levné ranní PV (control bug, BA81). Detail: `operating-modes.md`, changelog 2026-06-16.
|
||||
|
||||
### BA81: GEN port cut-off (reg 178 bits0–1) z plánu
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- **Žádné wattové prahy pro výběr SELL / CHARGE** — mapování z MILP setpointů je čistě ze **znamének** `battery_setpoint_w` a `grid_setpoint_w` (viz `get_deye_mode` v `exporter_monolith.py`).
|
||||
- **Přetok FVE do sítě** se neodvozuje z forecastového capu: plán nese explicitní `export_limit_w` jako tvrdý limit lokality / invertoru, ne jako tipované maximum z předpovědi.
|
||||
- **ZERO (PASSIVE)** = **142** = `deye_zero_export_mode` (1/2, ne selling first). **PV_SURPLUS:** **108 = 0**, **109 = max** — přebytek FVE do sítě (**145 = 1**), ne do baterie. Jinak **108/109** typicky max; výjimka import bez vybíjení → **109 = 0**.
|
||||
- **ZERO (PASSIVE)** = **142** = `deye_zero_export_mode` (1/2, ne selling first). **PV_SURPLUS** (fix 2026-06-16): reg **108 sleduje charge intent plánu** — `bat_w > 0` → **108 = max** (baterka nabere kolik fyzicky zvládne, přebytek **nad nabíjecí rychlost** jde do sítě, **145 = 1**); SoC u maxima (`>= max_soc − 3 p.b.`) + přebytek → **108 = max** (BMS kalibrace na 100 %); jen „prodej PV a drž baterku" daleko od maxima (`bat_w <= 0`) → **108 = 0**. **109 = max**. Jinak **108/109** typicky max; výjimka import bez vybíjení → **109 = 0**.
|
||||
- **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); reg **108** EMS **nemění** (export řídí 142, ne vynucené 0 A). Po návratu do ZERO/CHARGE zase **178** = 48.
|
||||
- Novou logiku vždy ověřit proti **reálnému řádku plánu** (audit / `planning_interval`).
|
||||
|
||||
|
||||
59
docs/04-modules/pool-pump.md
Normal file
59
docs/04-modules/pool-pump.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Bazénové čerpadlo (Shelly) — řízení
|
||||
|
||||
Řízená zátěž: filtrační čerpadlo bazénu přes Shelly Plug S Gen3 (relé). EMS ho
|
||||
spíná podle cen, čte telemetrii a započítává do plánu.
|
||||
|
||||
## Datový model (V087, V092)
|
||||
|
||||
- `ems.asset_pool_pump` — `rated_power_w`, `min_run_min`, `daily_runtime_min`
|
||||
(statický cíl filtrace/den), `schedulable`, `shelly_switch_id`, `endpoint_id`,
|
||||
teplotní parametry: `runtime_base_min`, `runtime_min_per_c`, `runtime_ref_temp_c`,
|
||||
`runtime_min_min`, `runtime_max_min`, `water_temp_sensor_id`.
|
||||
- `ems.telemetry_pool_pump` — 1min: `is_on`, `power_w`, `energy_wh_total` (hypertable).
|
||||
|
||||
## Denní runtime budget — `fn_pool_daily_runtime_min(pump_id)`
|
||||
|
||||
`clamp(runtime_base_min + runtime_min_per_c × (teplota − runtime_ref_temp_c),
|
||||
runtime_min_min, runtime_max_min)` z poslední teploty vody (`telemetry_loxone_sensor`,
|
||||
< 24 h). **Bez čidla** → fallback `daily_runtime_min` (např. 480 = 8 h). Teplotní
|
||||
režim se zapne pouhým napojením `water_temp_sensor_id` — žádný kód navíc.
|
||||
|
||||
## Rozvrh do slotů (Phase 1, bez solveru) — `fn_pool_schedule_slot(pump_id, slot)`
|
||||
|
||||
Vrací ON/OFF pro 15min slot:
|
||||
- **Nejlevnější souvislé okno** délky = runtime budget (z `vw_site_effective_price`,
|
||||
kalendářní den slotu v Europe/Prague, řazeno dle efektivní nákupní ceny). PV/záporné
|
||||
dny → okno padne automaticky přes poledne.
|
||||
- **+ dump-load overlay:** `sell <= 0` (záporná/nulová výkupní cena) → ON i mimo okno
|
||||
(zkonzumuj přebytek místo exportu se ztrátou).
|
||||
- `false` když pump není `schedulable` nebo nejsou ceny pro den.
|
||||
|
||||
## Control smyčka — `fn_pool_control_tick()` + APScheduler
|
||||
|
||||
Job `pool_control` (každých 15 min, hranice slotu, `lifespan.py`) volá
|
||||
`fn_pool_control_tick()` → pro každý aktivní řiditelný bazén spočte
|
||||
`fn_pool_schedule_slot` a **idempotentně** zařadí signál `POOL_PUMP_ON`
|
||||
(`fn_signal_enqueue_bool`) — **jen když existuje `signal_route`** (jinak bezpečně
|
||||
nic). Doručení na Shelly (`Switch.Set`) + readback verify (`Switch.GetStatus`) řeší
|
||||
`signal_service` (každých 15 s). Žádné Modbus.
|
||||
|
||||
## Bazál
|
||||
|
||||
`fn_update_baseline_stats` (R__003) bazén **odečítá** z bazálu — to je správné
|
||||
**jen** když ho zároveň řídíme (řízená + plánovaná zátěž). Bez řízení by to
|
||||
plánovač oslepilo. S tímto řízením je odečet korektní.
|
||||
|
||||
## Aktivace (provozní, per site)
|
||||
|
||||
1. `asset_pool_pump.daily_runtime_min` = cílové minuty (480 = 8 h), `schedulable = true`.
|
||||
2. Seed `signal_route` (`POOL_PUMP_ON` → `http_rest` na Shelly endpoint, `map_bool`
|
||||
true/false → on/off; `verify_config_json` přes `Switch.GetStatus`).
|
||||
3. Ověřit `fn_pool_schedule_slot` vrací rozumné sloty + telemetrii Shelly, pak teprve
|
||||
ostře (control tick enqueueuje až s existující route).
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **Phase 2:** `pool_on[t]` do solveru (`solver_v2`) — co-optimalizace proti
|
||||
baterii/exportu (golden gate). Dump-load pak z živého SoC/PV, ne jen z ceny.
|
||||
- Teplotní čidlo: napojit `water_temp_sensor_id` → runtime se prodlouží/zkrátí dle
|
||||
teploty vody automaticky.
|
||||
213
docs/ev-improvement-plan-2026-06-14.md
Normal file
213
docs/ev-improvement-plan-2026-06-14.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# EV plán zlepšení — konsolidace (2026-06-14)
|
||||
|
||||
Triáž z živého provozu home-01 (Tesla na TeltoCharge). Sjednocuje pět propojených
|
||||
pozorování do jednoho prioritizovaného plánu. Cílem je odstranit phantom nabíjení,
|
||||
fragmentaci a slepotu plánovače k reálnému stavu auta — **bez nové „přežvykovací"
|
||||
vrstvy nad plánem** (bolístka z v1). Veškerá logika zůstává v solveru / SQL,
|
||||
control vrstva je hloupý vykonavatel.
|
||||
|
||||
## Průřezové zásady (platí pro všechny body)
|
||||
|
||||
- **Nebudit auto.** Tesla `vehicle_data` (SoC/odometer/poloha) se čte jen když je
|
||||
auto vzhůru z vlastní vůle — `get_vehicle_api_state` (`tesla_client.py:183`,
|
||||
online/asleep/offline, nebudí) gatuje budicí `get_charge_state`
|
||||
(`tesla_client.py:125`). Výjimka bez buzení: **auto, které aktivně nabíjí, je
|
||||
vzhůru** → během session lze SoC číst bezpečně.
|
||||
- **SQL-first.** Živé SoC i „full" stav musí být v DB sloupci/tabulce; čte je
|
||||
`fn_ev_session_planning_json` / `fn_ev_session_defaults`. Žádné skládání v Pythonu.
|
||||
- **Golden gate.** Cokoliv mění `planning_interval` (solver, needed_wh) musí projít
|
||||
golden gate. Solver změny za DB flagem s default = no-op, pak kalibrace harnessem.
|
||||
Pozor: golden fixtures dnes EV **nulují** → nutná EV fixture z home-01 (chce DB).
|
||||
- **Žádné hardcoded kódy zařízení.** Vybírat podle `site_id` + `id`.
|
||||
- **Import/export tvrdé limity** (§7/§19): control nesmí jednostranně zvednout EV
|
||||
výkon nad plán (rozbilo by garanci `import ≤ max_import`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Živé SoC auta do plánovače — ✅ IMPLEMENTOVÁNO na dev (2026-06-14)
|
||||
|
||||
> **KOREKCE po ověření na živé DB:** plánovaný coulomb z `vw_latest_ev_charger.energy_kwh`
|
||||
> NEFUNGUJE — ten counter (Telto reg 39) je **rozbitý** (17.4 kWh reálně nabito →
|
||||
> counter stál na 0.18). Fix proto integruje **`power_w`** (spolehlivý signál), ne counter.
|
||||
> Hotovo v `R__038`: nový `fn_ev_session_delivered_wh` (time-weighted integrál power_w,
|
||||
> dt cap 120 s) + přepočet needed_wh/headroom z `live_soc = soc_at_connect + delivered/cap`
|
||||
> (clamp 99 %), fallback `coalesce(live, energy_delivered_wh, 0)`. **Ověřeno živě:**
|
||||
> needed_wh 18750 → **1329 Wh**, live_soc 97.9 %. **Nenasazeno na prod** (čeká deploy).
|
||||
> Detail: `docs/planning-changelog.md` 2026-06-14.
|
||||
|
||||
**Pozorování:** auto na 99 %, ale plán do rána ukazuje 4× 11 kW okna (phantom).
|
||||
|
||||
**Příčina (ostrá, z workflow `wqikxa47f` — horší, než vypadala):** plánovač počítá
|
||||
`needed_wh` i headroom **výhradně ze zamrzlého `soc_at_connect_pct`** (zapsán jednou
|
||||
při příjezdu) mínus `energy_delivered_wh` — JENŽE **`energy_delivered_wh` se během
|
||||
session NIKDY nezapíše** (V006:53, NOT NULL DEFAULT 0; `fn_ev_session_transition` jen
|
||||
otevře/zavře, `telemetry_collector` píše kumulativní energii jen do
|
||||
`telemetry_ev_charger.energy_kwh`, ne do session). Takže delivered je **trvale 0** →
|
||||
**`needed_wh = (target − soc_at_connect)/100 × cap` je KONSTANTA po celou session,
|
||||
neklesá jak auto nabíjí.** Plánovač je k pokroku nabíjení **úplně slepý** → rolling
|
||||
replan každých 15 min znovu emituje plný deficit (4× 11 kW okno). (R__038:37–61.)
|
||||
|
||||
**Klíč: živý progres už v DB MÁME** — `telemetry_ev_charger.energy_kwh` (Teltonika
|
||||
reg 39, kumulativní kWh per session, reset na novou session, poll 60s), vystavený přes
|
||||
`vw_latest_ev_charger.energy_kwh`. **Hardwarově měřený, funguje pro všechna auta
|
||||
(i Zoe), bez Tesla API, bez buzení.**
|
||||
|
||||
**Fix #1 (primární — čistě SQL, žádná nová vrstva/tabulka/job, žádné buzení):**
|
||||
v `fn_ev_session_planning_json` (R__038) nahradit zamrzlý `coalesce(es.energy_delivered_wh, 0)`
|
||||
**živým** `coalesce((select energy_kwh from vw_latest_ev_charger … otevřená session) × 1000,
|
||||
es.energy_delivered_wh, 0)`. Odvodit `live_soc = soc_at_connect + delivered/(cap_wh)×100`,
|
||||
**clamp 99 %** (taper ignorujeme). `needed_wh = greatest(0, (target − live_soc)/100 × cap_wh)`
|
||||
a `headroom = greatest(0, (99 − live_soc)/100 × cap_wh)` z živého SoC. → needed i headroom
|
||||
klesají s nabíjením a **kolabují na 0 při plném autě** → phantom okna zmizí.
|
||||
- **Fallback `coalesce(vw, es.energy_delivered_wh, 0)`** drží staré golden fixtures
|
||||
beze změny (bez WB telemetrie = delivered 0 = soc_at_connect = dnešní chování) →
|
||||
**golden gate zůstane zelená by-construction.** Tj. #1 lze nakódovat a lokálně
|
||||
dokázat ne-regresi **bez živé DB**; DB chce jen živé ověření na home-01.
|
||||
|
||||
**Komplement — ne-Tesla (Zoe):** dnes se session při `soc_at_connect_pct IS NULL`
|
||||
**úplně vyřadí z LP** (R__038:29). Změkčit: startovní SoC z kaskády (ruční UI patch
|
||||
přes R__015 → zděděný `soc_at_disconnect` minulé session → konzervativní default ~20 %),
|
||||
pak coulomb delta dá použitelné absolutní SoC. *(samostatný krok po ověření #1.)*
|
||||
|
||||
**Odloženo (Fix #3, opt-in):** mid-session Tesla refresh živého SoC — JEN když coulomb
|
||||
counter nestačí (auto nabito mimo WB / chybí WB telemetrie). Budí auto (vampire drain,
|
||||
proti dnešní zásadě) → nedělat, dokud se coulomb fix neukáže jako nedostatečný. Wallbox
|
||||
`charging_state` „full" je univerzální brzda zdarma navrch (auto přestalo brát → needed
|
||||
spadne i kdyby coulomb plaval).
|
||||
|
||||
**Soubory:** `R__038_fn_ev_session_planning_json.sql` (jádro), `R__015` (Zoe patch),
|
||||
`docs/04-modules/ev-charging.md`, `docs/planning-changelog.md`. **Golden:** ANO (mění
|
||||
needed_wh) — fallback drží fixtures bez EV telemetrie identické; fixtures s nenulovou WB
|
||||
telemetrií se přegenerují (phantom byl bug, nová čísla správná). **Roll-forward deficitu**
|
||||
vyjde emergentně: nenabito dnes → SoC nízký → další deadline dožene.
|
||||
|
||||
**Ověřit na živé DB (chce IP):** že `vw_latest_ev_charger.energy_kwh` sedí na AKTUÁLNÍ
|
||||
session (counter per connector — spolehlivě resetuje na session?); reálná AC→DC účinnost
|
||||
(~8–12 % ztrát → live SoC mírně optimistické, žádoucí směr — méně phantom); porovnat
|
||||
odvozené `live_soc` vs 99 % na displeji auta a že needed_wh/headroom spadnou na ~0.
|
||||
|
||||
---
|
||||
|
||||
## 2. Předehřev / 0 A logika — PRIORITA Č. 2 (control, bez golden)
|
||||
|
||||
**Princip:** wallbox neumí oddělit „proud na topení" od „proudu na nabití".
|
||||
|
||||
- **SoC ≥ target → NEřezat na 0 A.** Pusť proud → Tesla se předehřeje z WB (levná
|
||||
síť/baterka) místo z vlastní (vožené, drahé) baterie; protože je na targetu,
|
||||
baterku stejně nenabije → nulové riziko. Hlavní zimní případ.
|
||||
- **SoC < target → řídí plán** (nabít v levných, 0 A v drahých). Konflikt
|
||||
předehřevu v drahém slotu je vzácný (auto obvykle dosáhne targetu přes noc) →
|
||||
nepřekomplikovávat.
|
||||
- **Operačně:** odpojení → **jednorázové 0 A** (auto pryč, failsafe je jedno,
|
||||
žádné periodické psaní); připojení → notifikace → plán + amps; po dobu připojení
|
||||
→ re-assert amps každý tick (Fáze-0 oprava proti driftu WB watchdogu na failsafe).
|
||||
- **Fáze z DB** (`asset_ev_charger.phases`), ne hardcoded 3/1 (`setpoints.py:185-186`).
|
||||
|
||||
**Závisí na #1** (potřebuje znát SoC ≥ target). **Soubory:** `setpoints.py`,
|
||||
`outputs.py`, `docs/04-modules/ev-charging.md`. **Golden:** NE (jen překlad
|
||||
watt→amp při zápisu; control nečte/nepíše planning_interval).
|
||||
|
||||
---
|
||||
|
||||
## 3. Anti-fragmentace + plný výkon v solveru — PRIORITA Č. 3 (za flagem)
|
||||
|
||||
**Pozorování:** nabíjení rozsekané přes 21:15 / 1:30 / 1:45 / 5:30 / 6:00, navíc
|
||||
dílčí 1,3–1,4 kW. „Z baterky je solveru jedno kdy" (uživatel) = indiference →
|
||||
náhodný scatter.
|
||||
|
||||
**Příčina:** EV je v solveru spojitá energie po slotech bez jakéhokoliv časového
|
||||
členu — žádná start/stop ani commitment penalta (tu má jen baterie). Pro LP je
|
||||
souvislý i roztříštěný profil ekonomicky identický (`solver_v2.py:292-337`).
|
||||
Dílčí výkon = marginální slot dolitý na zbytek (spojitá proměnná, `:162-175`).
|
||||
|
||||
**Fix (jeden člen v objektivu, žádná nová vrstva):**
|
||||
- **Block-start penalta:** `ev_start[t] ≥ on[t] − on[t-1]`, objektiv
|
||||
`+ Σ ev_start × planner_ev_start_penalty_czk`. Min počet startů = jedna várka.
|
||||
Protože scatter z baterie je čistá remíza, **malá penalta slepí blok zadarmo** a
|
||||
**nikdy nepřebije reálný cenový spread** (auto-splnění obavy „ať to nehrne přes
|
||||
extrémní cenu"). DB param na `asset_ev_charger`, default 0 = no-op.
|
||||
- **`min_power_w` → třífázový floor** (6 A × 3 × 230 ≈ 4140 W) místo jednofázových
|
||||
1380 → zruší sub-6 A drobky i tiché shození pod minimem (`outputs.py:49` je
|
||||
správně, problém je nefyzikální setpoint z plánu).
|
||||
|
||||
**Soubory:** `solver_v2.py`, `db/migration/V1xx__asset_ev_charger_ev_start_penalty.sql`,
|
||||
`R__039`, `db_io.py`, golden fixtures, `docs/04-modules/planning.md`,
|
||||
`docs/planning-changelog.md`. **Golden:** ANO (za flagem default 0 → no-op).
|
||||
**Odloženo zvlášť:** explicitní round-trip cena EV-z-baterie v LP (citlivé na
|
||||
arbitráž §8; na scatter nemá vliv).
|
||||
|
||||
---
|
||||
|
||||
## 4. Trip/usage forecast — aktivace (PRIORITA Č. 4, většinou jen config)
|
||||
|
||||
**Stav: postaveno** (V089 + R__096), chytřejší než původní nápad:
|
||||
- `ev_vehicle_obs` (Tesla obs při příjezdu/odjezdu), `ev_trip` (km z odometru,
|
||||
kWh z ΔSoC, `charged_away` vyloučí nabíjení cestou), `ev_usage_stats` (týdenní
|
||||
DOW rytmus), job 00:50 (`lifespan.py:276 fn_update_ev_usage_stats`).
|
||||
- `fn_ev_required_soc` = **P80 spotřeby toho DOW + 10 p.b.**, clamp
|
||||
`[min_target_soc_pct, 100]`; `fn_ev_next_departure` = typický odjezd.
|
||||
- Model je **DOW-based, ne GPS-route** — GPS okruhy zatím nedělá (refinement,
|
||||
nízká priorita; DOW na dojíždění většinou stačí).
|
||||
|
||||
**Co dotáhnout:**
|
||||
- Ověřit **objem dat** (≥4 vzorky/DOW; telemetrie od ~3/2026 → po 3 měsících by
|
||||
mělo stačit) — chce živou DB.
|
||||
- Zvážit zapnutí `asset_vehicle.target_soc_forecast_enabled` (default false =
|
||||
sbírá, ale session jede na defaultech).
|
||||
|
||||
**Golden:** NE (jen nastaví target/deadline session). **Soubory:** žádné nové,
|
||||
jen verifikace + flag.
|
||||
|
||||
---
|
||||
|
||||
## 5. Geofence arrival trigger — PRIORITA Č. 5 (schváleno uživatelem)
|
||||
|
||||
**Motivace:** dnes je celý arrival/trip ukotvený na **píchnutí do wallboxu**.
|
||||
Když uživatel nepíchne (zaparkuje doma bez nabíjení), wallbox nevidí nic → žádný
|
||||
trip, žádná obs. Presence cesta (V095) přitom „je doma" **detekuje přes GPS
|
||||
geofence** i bez píchnutí (`telemetry_collector.py:840-849 at_home`).
|
||||
|
||||
**Fix:** přechod `at_home` false→true (auto vzhůru, nepíchnuté) brát jako
|
||||
**arrival home event**:
|
||||
- zapsat obs pro trip-building (i bez píchnutí), s `trigger` rozlišujícím zdroj
|
||||
(wallbox vs geofence);
|
||||
- umožnit proaktivní notifikaci (bod #6) i bez píchnutí.
|
||||
|
||||
**Caveaty:** oportunistické (jen když je auto vzhůru → ne instantní); **debounce**
|
||||
(2–3 vzorky); **dedup s wallbox arrival** (když píchneš, wallbox je autoritativní,
|
||||
geofence se nepočítá dvakrát); trip se páruje s nejbližším relevantním
|
||||
odjezd-eventem. **Soubory:** `telemetry_collector.py` (presence cesta),
|
||||
`R__096` (`fn_ev_build_trips` přijme geofence arrivals), případně nový `trigger`
|
||||
enum ve `V089` schématu (nová migrace). **Golden:** NE (jen sběr dat/notifikace).
|
||||
|
||||
---
|
||||
|
||||
## 6. Proaktivní notifikace „doma + nenabito + levné" — PRIORITA Č. 6
|
||||
|
||||
**Datový základ existuje** (`ev_presence_obs`: `at_home`, `charging_state`, SoC,
|
||||
vše bez buzení). Logika: `at_home=true` ∧ nepíchnuto (`charging_state` disconnected)
|
||||
∧ SoC < target ∧ (přebytek PV NEBO záporná/levná cena) → Discord nudge „píchni ho,
|
||||
je levno". Oportunistické (čeká, až je auto vzhůru). Napojí se na #5 (geofence
|
||||
arrival) a stávající `ev_notify` / `discord_bot`. **Golden:** NE.
|
||||
|
||||
---
|
||||
|
||||
## Pořadí nasazení
|
||||
|
||||
1. **#1 živé SoC** — odstraní phantom okna a plýtvání; enabler pro #2. (golden)
|
||||
2. **#2 předehřev/0 A** — control, hned po #1, bez golden.
|
||||
3. **#3 anti-fragmentace** — za flagem default 0, kalibrace harnessem + EV fixture.
|
||||
4. **#5 geofence arrival** + **#6 notifikace** — sběr/notifikace, samostatné PR.
|
||||
5. **#4 forecast aktivace** — až je dat dost (verifikace na DB).
|
||||
|
||||
**Blokery:** #1, #3, #4 chtějí **živou DB** (EV fixture, objem dat, ověření) —
|
||||
potřebuju IP serveru (`frank` se neresolvuje). Lokálně umím dokázat jen
|
||||
ne-regresi (golden default off) + unit testy.
|
||||
|
||||
## Rozhodnutí (z rozhovoru 2026-06-14)
|
||||
|
||||
- 3 fáze (ne 1f surplus — pokryje velká baterka).
|
||||
- Anti-fragmentace = malá ekonomická penalta v solveru, ne tvrdá priorita ani nová
|
||||
vrstva; control zůstává hloupý (žádný max-amps override — rozbil by §7).
|
||||
- Geofence arrival ANO (robustnost bez píchnutí).
|
||||
- DOW forecast stačí; GPS-route clustering odloženo.
|
||||
@@ -70,6 +70,7 @@ Systém řídí produkci přes v2 solver, ale backlog stojí na **jedné tiché
|
||||
| **FastAPI write auth → plný RBAC + PostgREST RLS/JWT** | API-key gate (Tier 1) je dočasná záplata. `ems_anon` read-only na views bez RLS → vidí všechny sites. | Bezpečnost před multi-user produkcí. | RLS policy per site + JWT; `GET /me/sites` filtr. | §2.2 — **autorizační logika/RLS NESMÍ do Pythonu**. **Rozhodnout:** kdy 2. tenant / externí přístup (jinak parkovat, ale Tier 1 API-key gate udělat hned). |
|
||||
| **pgbouncer connection pooling** | `max_connections` (deploy/docker-compose.yml:17 `${POSTGRES_MAX_CONNECTIONS:-100}`) na slabém nočním serveru: skok na 250 = +~1.5 GB RAM (250×~10 MB) → OOM/swap riziko místo občasného timeoutu. | Řeší „remaining connection slots" bez RAM nárůstu. | Zavést pgbouncer; `max_connections` může zůstat nízko. | **Nešvihat tvrdě na 250 na slabém serveru.** Pooling je správné řešení; mezitím ops-checklist: zvednout na 150-180 + sledovat `pg_stat_activity` a RAM. |
|
||||
| **Termo-flex blok (TČ + spirála + bazén)** | TČ reg 74 + spirála + bazén = jeden produktový balík „flexibilní zátěže". | Konzistentní řízení flex zátěží. | Pořadí: TČ zápis (Tier 2) → spirála → bazén. | Žádná sezónní okna (v2 filozofie); každá zátěž opt-in per site config. |
|
||||
| **Export-constrained lokalita — curtailment-min use-case (TEST)** | Hypotetická lokalita: **malý export limit (~4.5 kW), velký instal (~10 kW)**, konečná baterka. Otestovat, že MILP **drží baterce rezervu na polední peak** (ráno export na limitu + nabíjení zbytkem, baterka se plní pomalu) místo naivního „plná baterka ráno → v poledne se peak curtailuje". Přes kladné ceny: export limit + zbytek do baterky; přes záporné/peak: export off, vše do baterky + curtail zbytku. | Ověření, že na export-omezených sitech **minimalizujeme curtailment vs naivní Deye** (méně „škoda na střeše"); připravenost na 2. typ lokality. | Syntetický golden fixture (bell-curve PV >> export limit, konečná kapacita+rychlost baterky) + assert: `Σ curtailment < naivní baseline` a `SoC nenajede na plno před peakem`. | **Stojí a padá na kvalitě forecastu peaku** (podstřel → málo rezervy → zbytečný curtail; viz PV forecast review — canonical rolling_factor/delta) → **až po něm**. Curtail nad `(export+load+battery_charge_RATE)` je fyzicky nevyhnutelný, ne bug. Reaktivní řez nechat na Deye (CT smyčka), EMS jen strategie (viz [[ems-not-realtime-inverter-battery-buffer]]). v2 filozofie (žádná sezónní okna). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,6 +5,37 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-16 — control: reg 108 v PV_SURPLUS sleduje charge intent (BA81 nenabíjelo levné ráno)
|
||||
|
||||
- **Problém (triáž BA81):** výroba 12 kW (= ~2× nabíjecí rychlost baterky 6 kW), levné ranní výkupní ceny, baterka stála celé ráno na 29 % a vše šlo do sítě; nabíjet začala až odpoledne (dražší). Plán PŘITOM chtěl nabíjet (soc_tgt rostl), ale realita ne → promeškaná levná ranní arbitráž (~0.7 Kč/kWh). NEbyl to forecast (canonical ≈ realita) ani planner — **exekuce.**
|
||||
- **Příčina:** `deye_battery_charge_discharge_amps` (setpoints.py) v PASSIVE + `export_mode=PV_SURPLUS` vracela tvrdě **`108=0` i když `bat_w>0`** (záměrné, testem podchycené chování — ale chyba pro „výroba > nabíjecí rychlost"). Deye pak prodával vše, baterku nenabil. `get_deye_mode`: `bat_w>0 & grid<0` (export) → PASSIVE, ne CHARGE.
|
||||
- **Mechanismus (fix):** reg 108 v PV_SURPLUS **sleduje charge intent plánu**: `bat_w>0` → **108=max** (baterka nabere kolik fyzicky zvládne, přebytek nad rychlost do sítě); SoC u maxima (`>= max_soc − 3 p.b.`) + přebytek → **108=max** (BMS rekalibrace na 100 %); jen `bat_w<=0` daleko od maxima → **108=0**. Sell/discharge beze změny (mód + 109, 108 neřešíme — díky DV za korekci). Strop SoC drží Deye max_soc.
|
||||
- **Soubory:** `setpoints.py` (`deye_battery_charge_discharge_amps` + konstanta `BATTERY_CALIB_TOPOFF_MARGIN_PCT`), `inverter.py` (napojení živého SoC + max_soc), `test_control_deye_passive_pv_charge.py` (vědomě přepsán test starého chování + 2 nové), CLAUDE.md §18, operating-modes.md, modbus-registers.md.
|
||||
- **Ověření:** plná sada **365 passed, 4 xfailed**. Mimo solver → golden gate beze změny. Platí pro všechny Deye lokality (BA81 i hypotetická malá s nízkým export limitem).
|
||||
|
||||
## 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 %
|
||||
|
||||
- **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í).
|
||||
- **Mechanismus (fix):** effective target zastropovaný na 99 (= clamp live_soc); `energy_needed_wh = 0` když `live_soc >= least(target,99) − tolerance`. Tolerance per-vozidlo: nový sloupec `asset_vehicle.charge_done_tolerance_pct` (default 3 p.b., V107). 0 = tvrdě na target. Ponecháno: anti-fragmentace + 3f `min_power_w` floor (scattered 1f trickle) jako další solver fix (plán bod #3).
|
||||
- **Soubory:** `V107__ev_charge_done_tolerance.sql`, `R__038_fn_ev_session_planning_json.sql`, `docs/04-modules/ev-charging.md`.
|
||||
- **Ověření (živá DB):** session #6 home-01 (live_soc 97.9, target 100): `energy_needed_wh` 1329 → **0** (97.9 ≥ 99−3 = 96). Golden gate: R__038 je upstream solveru (frozen JSON fixtures) → netýká se ho.
|
||||
|
||||
## 2026-06-14 — phantom 11 kW okna: plánovač slepý k pokroku nabíjení EV (živé SoC)
|
||||
|
||||
- **Problém:** Tesla připojená na 70 %, dotankovaná na ~98 %, ale plán emitoval **15 oken po 11 kW** (20:15–23:45) — phantom. `fn_ev_session_planning_json` vracela `energy_needed_wh = 18750 Wh` konstantně po celou session.
|
||||
- **Příčina:** needed_wh = (target − soc_at_connect)/100 × cap − `energy_delivered_wh`, JENŽE `energy_delivered_wh` se během session **NIKDY nezapisuje** (V006 DEFAULT 0, žádný updater) → needed_wh konstantní, plánovač slepý k pokroku nabíjení; headroom navíc ze zamrzlého soc_at_connect. **Counter `energy_kwh` (Telto reg 39) je ROZBITÝ** — ověřeno živě: 17.4 kWh reálně nabito, counter stál na 0.18 kWh → coulomb z něj nejde.
|
||||
- **Mechanismus (fix):** nový `ems.fn_ev_session_delivered_wh(charger_id, since)` = time-weighted integrál **`power_w`** z telemetry_ev_charger (dt cap 120 s; power_w je spolehlivý). R__038 počítá `live_soc = soc_at_connect + delivered/cap`, clamp 99 %; needed_wh i headroom z živého SoC místo zamrzlého soc_at_connect. Fallback `coalesce(live, energy_delivered_wh, 0)` drží staré chování bez telemetrie. Žádné buzení Tesly, funguje i pro Zoe (power-based, bez API).
|
||||
- **Soubory:** `db/routines/R__038_fn_ev_session_planning_json.sql` (helper fn + přepočet), `docs/04-modules/ev-charging.md`, `docs/ev-improvement-plan-2026-06-14.md`.
|
||||
- **Ověření (živá DB, read-only psql):** session #6 home-01 — integrál power_w = 17.42 kWh → live_soc 97.9 % (sedí na realitu i na „99 %" z displeje); nová fn `energy_needed_wh` 18750 → **1329 Wh**, headroom 0. Golden gate testuje Python solver downstream R__038 (frozen JSON fixtures), takže SQL změna se ho netýká; fallback drží případné re-extrakce identické.
|
||||
- **Zbývá (backlog, plán bod 2–6):** předehřev/0 A (nepouštět 0 A při SoC≥target), anti-fragmentace v solveru (block-start penalta), geofence arrival, proaktivní notifikace, aktivace usage forecast. **Counter reg 39** rozbitý = i audit/ekonomika EV jede naslepo — zvážit fix čtení nebo přepnout audit na integrál power_w.
|
||||
|
||||
## 2026-06-14 — HOTFIX: plánovač oslepl k autu po přejmenování wallboxu (hardcoded kódy)
|
||||
|
||||
- **Problém:** uživatel přejmenoval wallboxy `ev-charger-1/2` → `vt-ev-charger-1/2`. fn_planning_site_context (R__039) a fn_load_planning_slots_full (R__063) měly kódy NATVRDO → ctx.vehicles=[], ev_sessions=[null,null], ev1/ev2_connected vždy false → plánovač auto NEVIDĚL → žádné nabíjení ani v záporných cenách (Tesla 70%, okno −0.32 Kč nevyužito).
|
||||
|
||||
Reference in New Issue
Block a user