feat(pool): řízení bazénu Phase 1 — nejlevnější okno + dump-load (bez solveru)

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) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-14 22:00:49 +02:00
parent 8ffe5460f1
commit f70111f44b
3 changed files with 210 additions and 0 deletions

View File

@@ -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,

View 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.';

View 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.