merge dev → main: EV tolerance 'dost dobré' + pool control Phase 1 (inertní)
- fix(planner): EV needed_wh=0 v toleranci targetu (V107) — konec mini-dobíjení/cyklování - feat(pool): řízení bazénu Phase 1 (fn_pool_schedule_slot/control_tick) — inertní dokud schedulable=false + chybí signal_route; aktivace provozně Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
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.';
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
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.';
|
||||
@@ -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ů
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user