Bazén: sezóna (schedulable), filtrace dle teploty vody, Loxone čidla
- V092: ems.loxone_sensor + telemetry_loxone_sensor (hypertable) — generické čtení Loxone hodnot (poslouží i ohřevu/akumulačce); pool sloupce teplotní funkce (ref/base/per_c/min/max) + water_temp_sensor_id - R__098 fn_pool_daily_runtime_min: clamp(base+per_c×(t−ref)) z poslední teploty <24 h, fallback daily_runtime_min; JSON detail pro UI/solver - collector poll_loxone_sensors: /jdev/sps/io/<name>/state, LL.value parse, no-op bez čidel - sezóna = schedulable přepínač (dokumentováno vč. SQL); hranice filtrace × ohřev TČ (oddělené logiky, sdílí jen čidlo) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -444,6 +444,61 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def poll_loxone_sensors(site_id: int, db: asyncpg.Connection) -> None:
|
||||
"""Čidla z Loxone (teplota bazénu, akumulační nádrže…): GET /jdev/sps/io/<name>/state.
|
||||
|
||||
Endpoint = site loxone_http; auth LOXONE_USER/PASSWORD (env). Hodnota
|
||||
z LL.value ("23.5°" → 23.5). Bez čidel v ems.loxone_sensor no-op.
|
||||
"""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
select ls.id, ls.loxone_name, se.host, se.port, se.protocol
|
||||
from ems.loxone_sensor ls
|
||||
join ems.site_endpoint se
|
||||
on se.site_id = ls.site_id and se.endpoint_type = 'loxone_http' and se.enabled
|
||||
where ls.site_id = $1 and ls.enabled
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not rows:
|
||||
return
|
||||
import os
|
||||
import re as _re
|
||||
|
||||
import httpx
|
||||
|
||||
auth = None
|
||||
user = os.getenv("LOXONE_USER") or ""
|
||||
if user:
|
||||
auth = (user, os.getenv("LOXONE_PASSWORD") or "")
|
||||
measured_at = datetime.now(timezone.utc)
|
||||
async with httpx.AsyncClient(timeout=5.0, auth=auth) as client:
|
||||
for r in rows:
|
||||
proto = (r["protocol"] or "http").lower()
|
||||
port = int(r["port"] or (443 if proto == "https" else 80))
|
||||
url = f"{proto}://{r['host']}:{port}/jdev/sps/io/{r['loxone_name']}/state"
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
raw = str((resp.json().get("LL") or {}).get("value", ""))
|
||||
m = _re.search(r"-?\d+(?:[.,]\d+)?", raw)
|
||||
if m is None:
|
||||
continue
|
||||
value = float(m.group(0).replace(",", "."))
|
||||
except Exception as e:
|
||||
logger.warning("Loxone sensor %s read failed: %s", r["loxone_name"], e)
|
||||
continue
|
||||
await db.execute(
|
||||
"""
|
||||
insert into ems.telemetry_loxone_sensor (sensor_id, measured_at, value)
|
||||
values ($1, $2, $3) on conflict do nothing
|
||||
""",
|
||||
int(r["id"]),
|
||||
measured_at,
|
||||
value,
|
||||
)
|
||||
|
||||
|
||||
async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
||||
"""Jeden průchod smyčky; vrátí uplynulý čas v sekundách (pro sleep).
|
||||
|
||||
@@ -460,6 +515,7 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
||||
await poll_inverter(sid, conn)
|
||||
await poll_ev_chargers(sid, conn)
|
||||
await poll_heat_pump(sid, conn)
|
||||
await poll_loxone_sensors(sid, conn)
|
||||
await poll_pool_pumps(sid, conn)
|
||||
except Exception as e:
|
||||
logger.error("Telemetry loop error site %s: %s", sid, e)
|
||||
|
||||
55
db/migration/V092__pool_season_temp_loxone.sql
Normal file
55
db/migration/V092__pool_season_temp_loxone.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- Bazén: sezóna, délka filtrace dle teploty vody, čtení čidel z Loxone.
|
||||
--
|
||||
-- Sezóna: přepínač = existující asset_pool_pump.schedulable (true = plánovač
|
||||
-- řídí; konec sezóny -> false: telemetrie běží dál, signály/solver ne).
|
||||
-- Viz docs/04-modules/pool-shelly.md § Sezóna.
|
||||
--
|
||||
-- Teplotní funkce (slaná voda, chlorinátor potřebuje průtok; teplejší voda =
|
||||
-- delší filtrace): runtime_min(t) = clamp(base + per_c × (t − ref), min, max).
|
||||
-- Defaulty pro 30 m³ / 8 m³/h (obrátka 3.75 h): 20 °C → 4.5 h, 26 °C → 7.5 h,
|
||||
-- 28 °C → 8.5 h, strop 10 h. Bez čidla / starého měření → fallback
|
||||
-- daily_runtime_min. Vše per čerpadlo v DB (pravidlo 16).
|
||||
|
||||
create table ems.loxone_sensor (
|
||||
id serial primary key,
|
||||
site_id int not null references ems.site (id),
|
||||
code text not null,
|
||||
loxone_name text not null,
|
||||
unit text,
|
||||
enabled boolean not null default true,
|
||||
notes text,
|
||||
constraint uq_loxone_sensor_site_code unique (site_id, code)
|
||||
);
|
||||
|
||||
comment on table ems.loxone_sensor is
|
||||
'Čidla čtená z Loxone Miniserveru (GET /jdev/sps/io/<loxone_name>/state přes loxone_http endpoint site). Telemetrie 60 s do telemetry_loxone_sensor.';
|
||||
|
||||
create table ems.telemetry_loxone_sensor (
|
||||
sensor_id int not null references ems.loxone_sensor (id),
|
||||
measured_at timestamptz not null,
|
||||
value numeric(10, 2),
|
||||
primary key (sensor_id, measured_at)
|
||||
);
|
||||
|
||||
select create_hypertable(
|
||||
'ems.telemetry_loxone_sensor',
|
||||
'measured_at',
|
||||
chunk_time_interval => interval '1 week',
|
||||
if_not_exists => true
|
||||
);
|
||||
|
||||
comment on table ems.telemetry_loxone_sensor is
|
||||
'1min hodnoty Loxone čidel (teplota bazénu, akumulační nádrže, ...).';
|
||||
|
||||
alter table ems.asset_pool_pump
|
||||
add column if not exists water_temp_sensor_id int references ems.loxone_sensor (id),
|
||||
add column if not exists runtime_ref_temp_c numeric(4, 1) not null default 20.0,
|
||||
add column if not exists runtime_base_min int not null default 270,
|
||||
add column if not exists runtime_min_per_c int not null default 30,
|
||||
add column if not exists runtime_min_min int not null default 180,
|
||||
add column if not exists runtime_max_min int not null default 600;
|
||||
|
||||
comment on column ems.asset_pool_pump.water_temp_sensor_id is
|
||||
'Loxone čidlo teploty vody; NULL = teplotní funkce vypnutá (fallback daily_runtime_min).';
|
||||
comment on column ems.asset_pool_pump.runtime_base_min is
|
||||
'Minuty filtrace/den při runtime_ref_temp_c; nad ní +runtime_min_per_c za °C, clamp [runtime_min_min, runtime_max_min].';
|
||||
45
db/routines/R__098_fn_pool_runtime.sql
Normal file
45
db/routines/R__098_fn_pool_runtime.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Denní cíl filtrace bazénu: dle teploty vody (poslední měření < 24 h),
|
||||
-- jinak fallback daily_runtime_min. Vstup pro solver (pool_on[t] budget).
|
||||
|
||||
create or replace function ems.fn_pool_daily_runtime_min(p_pump_id int)
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
select jsonb_build_object(
|
||||
'runtime_min',
|
||||
coalesce(
|
||||
case
|
||||
when t.value is not null then
|
||||
least(
|
||||
pp.runtime_max_min,
|
||||
greatest(
|
||||
pp.runtime_min_min,
|
||||
round(
|
||||
pp.runtime_base_min
|
||||
+ pp.runtime_min_per_c * greatest(0, t.value - pp.runtime_ref_temp_c)
|
||||
)::int
|
||||
)
|
||||
)
|
||||
end,
|
||||
pp.daily_runtime_min
|
||||
),
|
||||
'water_temp_c', t.value,
|
||||
'temp_measured_at', t.measured_at,
|
||||
'source', case when t.value is not null then 'temp_function' else 'static' end,
|
||||
'schedulable', pp.schedulable
|
||||
)
|
||||
from ems.asset_pool_pump pp
|
||||
left join lateral (
|
||||
select ts.value, ts.measured_at
|
||||
from ems.telemetry_loxone_sensor ts
|
||||
where ts.sensor_id = pp.water_temp_sensor_id
|
||||
and ts.measured_at > now() - interval '24 hours'
|
||||
order by ts.measured_at desc
|
||||
limit 1
|
||||
) t on true
|
||||
where pp.id = p_pump_id;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_pool_daily_runtime_min is
|
||||
'Cíl minut filtrace/den: clamp(base + per_c×(teplota−ref), min, max) z poslední teploty vody (<24 h), jinak daily_runtime_min. JSON s detailem pro UI/solver.';
|
||||
@@ -119,3 +119,35 @@ V `solver_v2.py` je TČ spojitá proměnná `hp[t] ∈ [0, rated_w]` vstupujíc
|
||||
- Dashboard: `PoolCard` (frontend/src/components/PoolCard.tsx) pod StatePanel —
|
||||
stav (běží/stojí/stale), aktuální W, dnešní kWh a hodiny běhu, mini sloupce
|
||||
7 dní. Poll 60 s.
|
||||
|
||||
## Sezóna a teplotní funkce (2026-06-12, dev)
|
||||
|
||||
**Sezóna = jeden přepínač** (`asset_pool_pump.schedulable`):
|
||||
```sql
|
||||
update ems.asset_pool_pump set schedulable = false where code = 'pool-pump-1'; -- konec sezóny
|
||||
update ems.asset_pool_pump set schedulable = true where code = 'pool-pump-1'; -- začátek
|
||||
```
|
||||
Off-season: telemetrie běží dál (zimování čerpadla vidíš), plánovač a signály ne.
|
||||
(Později tlačítko v Konfiguraci.)
|
||||
|
||||
**Délka filtrace dle teploty vody** (`fn_pool_daily_runtime_min`, V092):
|
||||
`runtime = clamp(base + per_°C × (t − ref), min, max)`; defaulty pro 30 m³ /
|
||||
8 m³/h (obrátka 3.75 h, slaná voda — chlorinátor potřebuje průtok):
|
||||
20 °C→4.5 h, 24 °C→6.5 h, 28 °C→8.5 h, strop 10 h. Bez čidla / měření
|
||||
staršího 24 h → fallback `daily_runtime_min`. Vše sloupce na čerpadle.
|
||||
|
||||
**Čidlo teploty**: až přidáš do Loxonu, jeden INSERT:
|
||||
```sql
|
||||
insert into ems.loxone_sensor (site_id, code, loxone_name, unit)
|
||||
values ((select id from ems.site where code='home-01'), 'pool-water-temp', '<JmenoVLoxonu>', '°C');
|
||||
update ems.asset_pool_pump set water_temp_sensor_id =
|
||||
(select id from ems.loxone_sensor where code='pool-water-temp') where code='pool-pump-1';
|
||||
```
|
||||
Collector čte každou minutu (`poll_loxone_sensors` — generické, poslouží i
|
||||
akumulační nádrži pro ohřev).
|
||||
|
||||
**Hranice s ohřevem přes TČ (žádný šelmostroj):** filtrace = denní rozpočet
|
||||
minut pro solver; ohřev = samostatný explicitní program (docs z 12. 6.:
|
||||
sekvence Shelly pump → HEX pump → TČ), který si čerpadlo prostě zapne
|
||||
(interlock) — minuty běhu se započtou samy přes telemetrii. Jediná vazba je
|
||||
teplotní čidlo, které sdílí obě logiky.
|
||||
|
||||
Reference in New Issue
Block a user