From f70111f44b7271f2403947b7516b0b2616662bf2 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 14 Jun 2026 22:00:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(pool):=20=C5=99=C3=ADzen=C3=AD=20baz=C3=A9?= =?UTF-8?q?nu=20Phase=201=20=E2=80=94=20nejlevn=C4=9Bj=C5=A1=C3=AD=20okno?= =?UTF-8?q?=20+=20dump-load=20(bez=20solveru)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fn_pool_schedule_slot: nejlevnější souvislé okno denního runtime budgetu (fn_pool_daily_runtime_min) z vw_site_effective_price + dump-load při sell<=0. fn_pool_control_tick: každých 15 min spočte stav a zařadí POOL_PUMP_ON (jen když existuje signal_route → bezpečné před aktivací). lifespan job pool_control. Shelly přes signal_service, žádné Modbus. Bazál odečet (R__003) se tím stává správným (řízená+plánovaná zátěž). Aktivace provozně: daily_runtime_min=480, schedulable, signal_route. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/lifespan.py | 24 +++++ db/routines/R__101_fn_pool_control.sql | 127 +++++++++++++++++++++++++ docs/04-modules/pool-pump.md | 59 ++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 db/routines/R__101_fn_pool_control.sql create mode 100644 docs/04-modules/pool-pump.md 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/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/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.