diff --git a/backend/app/lifespan.py b/backend/app/lifespan.py index e5fba86..74b4d9c 100644 --- a/backend/app/lifespan.py +++ b/backend/app/lifespan.py @@ -161,6 +161,22 @@ async def lifespan(app: FastAPI): except Exception: logger.exception("scheduled_signal_outbound_verify 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 +429,14 @@ 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_daily_plan, "cron", hour=15, minute=0, id="daily_plan") scheduler.add_job( scheduled_rolling_replan, diff --git a/db/migration/V107__ev_charge_done_tolerance.sql b/db/migration/V107__ev_charge_done_tolerance.sql new file mode 100644 index 0000000..a4905d7 --- /dev/null +++ b/db/migration/V107__ev_charge_done_tolerance.sql @@ -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.'; diff --git a/db/routines/R__038_fn_ev_session_planning_json.sql b/db/routines/R__038_fn_ev_session_planning_json.sql index 1abfbbb..b432e81 100644 --- a/db/routines/R__038_fn_ev_session_planning_json.sql +++ b/db/routines/R__038_fn_ev_session_planning_json.sql @@ -75,6 +75,7 @@ as $fn$ 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 @@ -103,12 +104,20 @@ as $fn$ 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 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(c.target_soc_pct, c.default_target_soc_pct)::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) ) diff --git a/db/routines/R__101_fn_pool_control.sql b/db/routines/R__101_fn_pool_control.sql new file mode 100644 index 0000000..8ec3077 --- /dev/null +++ b/db/routines/R__101_fn_pool_control.sql @@ -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.'; diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 6b67c47..999861c 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -250,6 +250,12 @@ counter 0.18). Bez toho byl `energy_delivered_wh` trvale 0 → needed_wh konstan 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. + --- ## Statistika příjezdů diff --git a/docs/04-modules/pool-pump.md b/docs/04-modules/pool-pump.md new file mode 100644 index 0000000..f2653c1 --- /dev/null +++ b/docs/04-modules/pool-pump.md @@ -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. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 0fce650..18e5bb6 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,13 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 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.