diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index b3f5223..d748d36 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -26,6 +26,14 @@ DEYE_REG_PV1_POWER = 672 DEYE_REG_PV2_POWER = 673 +def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int: + """ + Okamžitá „výroba FVE“ pro dashboard / audit součtu: Deye registry 672/673/667 + jsou int16 W; záporné hodnoty (např. večer při exportu) nejsou DC výroba. + """ + return max(0, int(pv1_w)) + max(0, int(pv2_w)) + max(0, int(gen_port_w)) + + async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: rows = await db.fetch( """ @@ -54,13 +62,12 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: battery_power = await mb.read_register_signed(DEYE_REG_BATTERY_POWER_FLOW) batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY) batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY) - gen_port_power = await mb.read_register(DEYE_REG_GEN_PORT_POWER) grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER) load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER) - pv1_power = await mb.read_register(DEYE_REG_PV1_POWER) - pv2_power = await mb.read_register(DEYE_REG_PV2_POWER) - # Celková výroba FVE na této instalaci = stringy PV + výkon přes GEN port. - pv_power_w = int(pv1_power) + int(pv2_power) + int(gen_port_power) + pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER) + pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER) + gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER) + pv_power_w = aggregate_pv_production_w(pv1_power, pv2_power, gen_port_power) logger.debug("inverter:%s Deye run_state raw=%s", code, run_state) diff --git a/backend/tests/test_telemetry_pv_signed.py b/backend/tests/test_telemetry_pv_signed.py new file mode 100644 index 0000000..c017829 --- /dev/null +++ b/backend/tests/test_telemetry_pv_signed.py @@ -0,0 +1,34 @@ +"""Signed PV kanály Deye → agregovaná pv_power_w (kladné příspěvky jen).""" + +from __future__ import annotations + +import unittest + +from services.telemetry_collector import aggregate_pv_production_w + + +class TestAggregatePvProductionW(unittest.TestCase): + def test_daytime_typical(self) -> None: + self.assertEqual(aggregate_pv_production_w(6000, 4000, 0), 10_000) + + def test_negative_gen_ignored_in_total(self) -> None: + self.assertEqual(aggregate_pv_production_w(0, 0, -61), 0) + + def test_false_uint16_gen_becomes_small_negative(self) -> None: + raw = 65472 + signed = raw - 65536 if raw > 32767 else raw + self.assertEqual(signed, -64) + self.assertEqual(aggregate_pv_production_w(0, 0, signed), 0) + + def test_false_uint16_pv1_like_grid_export_clamped(self) -> None: + raw = 52038 + signed = raw - 65536 if raw > 32767 else raw + self.assertEqual(signed, -13_498) + self.assertEqual(aggregate_pv_production_w(signed, 0, 0), 0) + + def test_mixed_one_negative(self) -> None: + self.assertEqual(aggregate_pv_production_w(8000, -100, 2000), 10_000) + + +if __name__ == "__main__": + unittest.main() diff --git a/db/migration/V038__telemetry_pv_signed_semantics.sql b/db/migration/V038__telemetry_pv_signed_semantics.sql new file mode 100644 index 0000000..117b2dc --- /dev/null +++ b/db/migration/V038__telemetry_pv_signed_semantics.sql @@ -0,0 +1,12 @@ +-- PV1/PV2/GEN na Deye jsou int16 W; sběr ukládá signed hodnoty do sloupců. +-- pv_power_w = součet max(0, kanál) pro metriku okamžité výroby FVE. + +COMMENT ON COLUMN ems.telemetry_inverter.pv1_power_w IS + 'Výkon PV1 v W (signed int16 z Modbus). Záporné = není kladná DC výroba tohoto vstupu.'; +COMMENT ON COLUMN ems.telemetry_inverter.pv2_power_w IS + 'Výkon PV2 v W (signed int16 z Modbus). Záporné = není kladná DC výroba tohoto vstupu.'; +COMMENT ON COLUMN ems.telemetry_inverter.gen_port_power_w IS + 'Výkon GEN portu v W (signed int16 z Modbus). FVE pole B (ongrid); záporné při zpětném toku / režimu bez výroby. +Nelze curtailovat. Klíčový pro audit zeleného bonusu.'; +COMMENT ON COLUMN ems.telemetry_inverter.pv_power_w IS + 'Okamžitá výroba FVE v W: max(0,pv1)+max(0,pv2)+max(0,gen_port) — součet kladných příspěvků kanálů po signed čtení z Deye.'; diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 214f3e8..3a14b8a 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -128,9 +128,9 @@ Limity nabíjení/vybíjení v ampérech a export z **site_grid_connection** / * | 590 | Battery power | 1 W S16 | + vybíjení / − nabíjení | | 625 | Grid total power | 1 W S16 | + import / − export | | 653 | Load total power | 1 W S16 | | -| 667 | GEN port power | 1 W | FVE pole B | -| 672 | PV1 power | 1 W | | -| 673 | PV2 power | 1 W | | +| 667 | GEN port power | 1 W S16 | FVE pole B; signed — záporné při zpětném toku / bez výroby | +| 672 | PV1 power | 1 W S16 | signed; EMS ukládá raw signed W, do `pv_power_w` jen max(0, kanál) | +| 673 | PV2 power | 1 W S16 | jako PV1 | ## Přepočty diff --git a/docs/04-modules/telemetry.md b/docs/04-modules/telemetry.md index 516f8f8..cd7d11c 100644 --- a/docs/04-modules/telemetry.md +++ b/docs/04-modules/telemetry.md @@ -46,12 +46,17 @@ Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1). | 590 (0x024E) | int16 | Tok výkonu baterie | W | signed: **+ vybíjení, − nabíjení** | | 625 (0x0271) | int16 | Výkon sítě | W | signed: **+ import, − export** | | 653 (0x028D) | uint16 | Celková spotřeba | W | `load_power_w` | -| 667 (0x029B) | uint16 | Výkon GEN portu (FVE pole B) | W | `gen_port_power_w`, nelze curtailovat | -| 672 (0x02A0) | uint16 | Výkon PV1 | W | `pv1_power_w` | -| 673 (0x02A1) | uint16 | Výkon PV2 | W | `pv2_power_w` | +| 667 (0x029B) | int16 | Výkon GEN portu (FVE pole B) | W (signed) | `gen_port_power_w`; záporné při zpětném toku / bez výroby — **číst signed** | +| 672 (0x02A0) | int16 | Výkon PV1 | W (signed) | `pv1_power_w`; při špatném unsigned čtení se záporné hodnoty jeví jako desítky kW | +| 673 (0x02A1) | int16 | Výkon PV2 | W (signed) | `pv2_power_w` | -`pv_power_w` v DB = **PV1 + PV2 + GEN port** (celková výroba na instalaci home-01). -`gen_port_power_w` zůstává i nadále uložen samostatně pro audit a detailní diagnostiku. +`pv1_power_w` / `pv2_power_w` / `gen_port_power_w` v DB = **signed W** z Modbus (mohou být záporné). + +`pv_power_w` = **max(0, PV1) + max(0, PV2) + max(0, GEN)** — okamžitá **kladná** výroba FVE pro dashboard a souhrny; záporné kanály do součtu nepatří (typicky večer při exportu z baterie do sítě). + +`gen_port_power_w` zůstává uložen samostatně pro audit zeleného bonusu a diagnostiku. + +**Ověření po změně sběru:** za denního SVitu zkontrolovat, že `pv_power_w` a jednotlivé kanály odpovídají očekávanému max. výkonu instalace (logika: `aggregate_pv_production_w` v `telemetry_collector.py`, unit testy `tests/test_telemetry_pv_signed.py`). **Zápis setpointů (plánování → Deye):** diff --git a/docs/05-todo.md b/docs/05-todo.md index b0e09ea..c02e702 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -15,7 +15,7 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec | **Import OTE – robustní provoz:** timeouty + retry/backoff v `price_importer.py`, detailní error kódy v API, fallback D+1 → dnešek, scheduler importů 13:30 / 14:00 / 00:05. | | **Fail-safe bez OTE dat:** při predikovaných cenách v kritickém okně je zákaz exportu; vybíjení baterie omezeno jen v predikovaných slotech; runtime guard v `control_exporter.py` brání SELL v nejistém stavu. | | **Forecast provoz:** refresh každé 2 hodiny (`:05`), konfigurovatelný Open-Meteo horizont (`OPEN_METEO_FORECAST_DAYS`, default 7, clamp 2..16), endpoint pro UI vrací latest-run bez duplicit slotů. | -| **Telemetry – výroba FVE:** `pv_power_w` je součet `pv1 + pv2 + gen_port`, takže dashboard reflektuje obě pole i GEN větev instalace home-01. | +| **Telemetry – výroba FVE:** Registry 672/673/667 jsou **signed** W; `pv_power_w` = max(0,pv1)+max(0,pv2)+max(0,gen) (dashboard); sloupce pv1/pv2/gen ukládají signed pro audit. | | **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. | | **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. | diff --git a/scripts/test_modbus_deye.py b/scripts/test_modbus_deye.py index 40859e5..a7be725 100644 --- a/scripts/test_modbus_deye.py +++ b/scripts/test_modbus_deye.py @@ -18,11 +18,11 @@ REGISTERS: dict[str, tuple[int, str, int, str, str]] = { "battery_power_w": (590, "sint", 1, "W", "+ vybíjení / − nabíjení"), "batt_charge_today_wh": (514, "uint", 1, "Wh", "dnešní nabití baterie"), "batt_discharge_today_wh": (515, "uint", 1, "Wh", "dnešní vybití baterie"), - "gen_port_power_w": (667, "uint", 1, "W", "GEN port – FVE pole B"), + "gen_port_power_w": (667, "sint", 1, "W", "GEN port – FVE pole B (signed)"), "grid_total_power_w": (625, "sint", 1, "W", "+ import ze sítě / − export"), "load_total_power_w": (653, "uint", 1, "W", "celková spotřeba"), - "pv1_power_w": (672, "uint", 1, "W", "výkon PV1"), - "pv2_power_w": (673, "uint", 1, "W", "výkon PV2"), + "pv1_power_w": (672, "sint", 1, "W", "výkon PV1 (signed)"), + "pv2_power_w": (673, "sint", 1, "W", "výkon PV2 (signed)"), }