diff --git a/CLAUDE.md b/CLAUDE.md index 0959492..fe8edb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index 3c9139f..52e28af 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -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 (bits0–1 == 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: diff --git a/backend/tests/test_telemetry_export_limit_flags.py b/backend/tests/test_telemetry_export_limit_flags.py new file mode 100644 index 0000000..bfd2396 --- /dev/null +++ b/backend/tests/test_telemetry_export_limit_flags.py @@ -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 diff --git a/db/routines/R__049_fn_telemetry_inverter_sample.sql b/db/routines/R__049_fn_telemetry_inverter_sample.sql index 4cfb8df..ee6162c 100644 --- a/db/routines/R__049_fn_telemetry_inverter_sample.sql +++ b/db/routines/R__049_fn_telemetry_inverter_sample.sql @@ -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.'; diff --git a/docs/04-modules/forecast.md b/docs/04-modules/forecast.md index e5c4288..0a15f0e 100644 --- a/docs/04-modules/forecast.md +++ b/docs/04-modules/forecast.md @@ -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`. --- diff --git a/docs/05-todo.md b/docs/05-todo.md index e817b18..be7c138 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -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** (bits0–1 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 | ---