Implement telemetry enhancements: add reading of Deye registers 145 and 179 in telemetry collector to derive is_export_limited and pv_derating_flags. Update fn_telemetry_inverter_sample to store these flags, and adjust related documentation and API endpoints accordingly.
Some checks failed
CI and deploy / migration-check (push) Failing after 19s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-22 23:02:14 +02:00
parent 1dfab8c7a1
commit c928e2234d
6 changed files with 74 additions and 15 deletions

View File

@@ -98,7 +98,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
13. **Endpoint `GET …/forecast/pv`** vrací `DISTINCT ON (interval_start, pv_array_id)` seřazené podle nejnovějšího `forecast_pv_run.created_at`, aby UI nemělo duplikáty slotů; plná historie běhů zůstává v tabulkách.
13a. **PV delta kalibrace:** `GET …/forecast/pv-delta-profile` vrací JSON z `fn_pv_forecast_delta_profile`; `GET …/configuration` obsahuje `pv_forecast_calibration` z `ems.site_pv_forecast_calibration`; `PATCH …/configuration/pv-forecast-calibration` mění cutoff / policy / přepsání parametrů delty. Telemetrie `telemetry_inverter.is_export_limited` / `pv_derating_flags` (V058) řídí vyloučení slotu z učení v `fn_fill_forecast_accuracy` (`telemetry_derating`).
13a. **PV delta kalibrace:** `GET …/forecast/pv-delta-profile` vrací JSON z `fn_pv_forecast_delta_profile`; `GET …/configuration` obsahuje `pv_forecast_calibration` z `ems.site_pv_forecast_calibration`; `PATCH …/configuration/pv-forecast-calibration` mění cutoff / policy / přepsání parametrů delty. Telemetrie `telemetry_inverter.is_export_limited` / `pv_derating_flags` (V058) řídí vyloučení slotu z učení v `fn_fill_forecast_accuracy` (`telemetry_derating`); `telemetry_collector` je plní čtením Deye reg **145** a **179** při poll střídače.
14. **Příchod a odjezd EV** detekuje `telemetry_collector` z telemetrie nabíječky: přechod `available``preparing` / `charging` (resp. jakýkoli stav ≠ `available`) znamená příjezd; přechod na `available` uzavře `ev_session`. Tabulka `ev_arrival_stats` se při příjezdu doplňuje přes `fn_update_ev_arrival_stats` a **nemá se mazat** (dlouhodobá historická statistika).

View File

@@ -28,6 +28,9 @@ DEYE_REG_GRID_EXPORT_TOTAL_LO = 524
DEYE_REG_GRID_EXPORT_TOTAL_HI = 525
DEYE_REG_PV1_POWER = 672
DEYE_REG_PV2_POWER = 673
# Solar sell (0 = přebytek řiditelné FVE nesmí do sítě) a GEN/MI cut-off (bits01 == 2 → cut-off ON); viz modbus-registers.md
DEYE_REG_SOLAR_SELL = 145
DEYE_REG_CONTROL_BOARD_SPECIAL1 = 179
def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
@@ -38,6 +41,18 @@ def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
return max(0, int(pv1_w)) + max(0, int(pv2_w)) + max(0, int(gen_port_w))
def _export_limit_flags_from_deye_regs(reg145: int | None, reg179: int | None) -> tuple[bool | None, int | None]:
"""Odvoď is_export_limited / pv_derating_flags z přečtených holding registrů (NULL = neznámé)."""
if reg145 is None and reg179 is None:
return None, None
flags = 0
if reg145 is not None and int(reg145) == 0:
flags |= 1
if reg179 is not None and (int(reg179) & 3) == 2:
flags |= 2
return (flags != 0), flags
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
rows = await db.fetch(
"""
@@ -70,14 +85,17 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
grid_energy_regs = await mb.read_holding_registers(
DEYE_REG_GRID_IMPORT_TOTAL_LO, 4
)
reg145 = await mb.read_register(DEYE_REG_SOLAR_SELL)
reg179 = await mb.read_register(DEYE_REG_CONTROL_BOARD_SPECIAL1)
pv_power_w = aggregate_pv_production_w(pv1_power, pv2_power, gen_port_power)
grid_import_total_wh = (grid_energy_regs[1] << 16 | grid_energy_regs[0]) * 100
grid_export_total_wh = (grid_energy_regs[3] << 16 | grid_energy_regs[2]) * 100
is_export_limited, pv_derating_flags = _export_limit_flags_from_deye_regs(reg145, reg179)
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
await db.execute(
"select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int)",
"select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int, $17::boolean, $18::int)",
site_id,
inv_id,
measured_at,
@@ -94,6 +112,8 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
grid_import_total_wh,
grid_export_total_wh,
run_state,
is_export_limited,
pv_derating_flags,
)
inv_temp: float | None = None
await manager.broadcast_telemetry(
@@ -108,6 +128,8 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
"load_power_w": load_power,
"gen_port_power_w": gen_port_power,
"inverter_temp_c": inv_temp,
"is_export_limited": is_export_limited,
"pv_derating_flags": pv_derating_flags,
}
)
except Exception as e:

View File

@@ -0,0 +1,28 @@
"""Logika is_export_limited / pv_derating_flags z Deye reg 145 a 179."""
from services.telemetry_collector import _export_limit_flags_from_deye_regs
def test_both_none_unknown() -> None:
lim, flags = _export_limit_flags_from_deye_regs(None, None)
assert lim is None and flags is None
def test_solar_sell_disabled() -> None:
lim, flags = _export_limit_flags_from_deye_regs(0, None)
assert lim is True and flags == 1
def test_solar_sell_enabled_only() -> None:
lim, flags = _export_limit_flags_from_deye_regs(1, None)
assert lim is False and flags == 0
def test_gen_mi_cutoff_bits() -> None:
lim, flags = _export_limit_flags_from_deye_regs(None, 2)
assert lim is True and flags == 2
def test_combined_flags() -> None:
lim, flags = _export_limit_flags_from_deye_regs(0, 2)
assert lim is True and flags == 3

View File

@@ -1,4 +1,6 @@
create or replace function ems.fn_telemetry_inverter_sample(
DROP FUNCTION IF EXISTS ems.fn_telemetry_inverter_sample;
CREATE OR REPLACE FUNCTION ems.fn_telemetry_inverter_sample(
p_site_id int,
p_inverter_id int,
p_measured_at timestamptz,
@@ -14,12 +16,14 @@ create or replace function ems.fn_telemetry_inverter_sample(
p_load_power_w int,
p_grid_import_total_wh bigint,
p_grid_export_total_wh bigint,
p_run_state int
p_run_state int,
p_is_export_limited boolean DEFAULT NULL,
p_pv_derating_flags int DEFAULT NULL
)
returns void
language sql
as $fn$
insert into ems.telemetry_inverter (
RETURNS void
LANGUAGE sql
AS $fn$
INSERT INTO ems.telemetry_inverter (
site_id,
inverter_id,
measured_at,
@@ -35,9 +39,11 @@ as $fn$
load_power_w,
grid_import_total_wh,
grid_export_total_wh,
run_state
run_state,
is_export_limited,
pv_derating_flags
)
values (
VALUES (
p_site_id,
p_inverter_id,
p_measured_at,
@@ -53,10 +59,12 @@ as $fn$
p_load_power_w,
p_grid_import_total_wh,
p_grid_export_total_wh,
p_run_state
p_run_state,
p_is_export_limited,
p_pv_derating_flags
)
on conflict (inverter_id, measured_at) do nothing;
ON CONFLICT (inverter_id, measured_at) DO NOTHING;
$fn$;
comment on function ems.fn_telemetry_inverter_sample is
'Insert jednoho vzorku telemetrie střídače (telemetry_collector).';
COMMENT ON FUNCTION ems.fn_telemetry_inverter_sample IS
'Insert jednoho vzorku telemetrie střídače (telemetry_collector). Volitelně is_export_limited / pv_derating_flags (Deye reg 145/179) pro vyloučení slotů z učení PV delty.';

View File

@@ -100,6 +100,7 @@ def calculate_pv_power(
- Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů.
- **Kalibrace delty:** `GET /api/v1/sites/{site_id}/forecast/pv-delta-profile?from=…&to=…` vrací JSON z `ems.fn_pv_forecast_delta_profile` (`deltas`, `deltas_by_array`, `delta_learn_min_ts` z `ems.site_pv_forecast_calibration`). Volitelné query parametry: `half_life_days`, `threshold_w`, `top_n_days`, `non_top_day_factor`, `day_weight_gamma` (NULL u numerických přepsání = hodnota z kalibrační tabulky / default funkce).
- **Úprava kalibrace z API:** `PATCH /api/v1/sites/{site_id}/configuration/pv-forecast-calibration` s JSON tělem (částečný update); odpověď je aktuální řádek kalibrace. Souhrn konfigurace v `GET …/configuration` obsahuje klíč `pv_forecast_calibration`.
- **Telemetrie pro učení delty:** `telemetry_collector` při Modbus poll přidá čtení reg. **145** a **179**; `fn_telemetry_inverter_sample` ukládá `is_export_limited` / `pv_derating_flags` (bity 1 = solar sell off, 2 = GEN/MI cut-off aktivní dle masky `(reg179 & 3) == 2`). `fn_fill_forecast_accuracy` sloty s těmito signály označí `telemetry_derating`.
---

View File

@@ -26,7 +26,7 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec
| Popis | Kde | Kdo |
|-------|-----|-----|
| Sloupce `telemetry_inverter.is_export_limited` / `pv_derating_flags` jsou v DB (V058) a `fn_fill_forecast_accuracy` je respektuje; **doplnit zápis z collectoru** (režim / reg 145/179). Volitelné rozšíření API o korigovaný výkon per `pv_array_id` v grafu. | `telemetry_collector`, `db/routines/R__022_fn_fill_forecast_accuracy.sql` | programátor |
| **Telemetry collector** čte Deye reg **145** (solar sell) a **179** (bits01 MI cut-off) do `is_export_limited` / `pv_derating_flags`. Volitelné rozšíření API o korigovaný výkon per `pv_array_id` v grafu. | `backend/services/telemetry_collector.py` | programátor |
---