From fffe6c7185e7b7d2a8e13c6f4a12be3586b997ea Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 2 May 2026 09:31:45 +0200 Subject: [PATCH] fix rizeni pole pres reg340 jen home01 --- CLAUDE.md | 4 +- backend/services/control/exporter_monolith.py | 49 +++++++++++++------ backend/tests/test_control_exporter_reg340.py | 32 ++++++------ db/routines/R__083_fn_inverter_pv_a_max_w.sql | 4 +- ..._084_fn_site_has_active_green_bonus_pv.sql | 25 ++++++++++ docs/03-data-model.md | 2 +- docs/04-modules/control.md | 4 +- docs/04-modules/modbus-registers.md | 6 +-- docs/04-modules/planning.md | 2 +- 9 files changed, 86 insertions(+), 42 deletions(-) create mode 100644 db/routines/R__084_fn_site_has_active_green_bonus_pv.sql diff --git a/CLAUDE.md b/CLAUDE.md index b2c5d14..8d60412 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st 17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`. -18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `_deye_zero_export_amps_for_passive`; **TOU** z plánu. **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen **Deye** + `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`; hodnota z `pv_a_forecast_solver_w` / `pv_a_curtailed_w` (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**. +18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `_deye_zero_export_amps_for_passive`; **TOU** z plánu. **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (lokalita se zeleným bonusem na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`; hodnota z `pv_a_forecast_solver_w` / `pv_a_curtailed_w` (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**. 19. **Baterie – export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 11–12 %, migrace V029 + komentář sloupce). @@ -159,7 +159,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st | `signal_state` | Poslední požadovaná / odeslaná / ověřená hodnota na cíli (idempotence). | | `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. | -**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`. +**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`. --- diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 5804f74..db4462b 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -203,9 +203,10 @@ class InverterConfig: deye_tou_inactive_signature: str | None = None deye_zero_export_mode: int = 1 deye_gen_microinverter_cutoff_enabled: bool = False - manufacturer: str = "" #: Součet nominal_power_wp controllable PV na invertoru; 0 = EMS nezapisuje reg 340. pv_a_cap_w: int = 0 + #: True = EMS smí řídit Deye reg 340 (max solar power); z SQL `fn_site_has_active_green_bonus_pv(site_id)` — není DB sloupec na invertoru. + deye_reg340_pv_a_control_enabled: bool = False def compute_pv_a_reg340_max_solar_w(cap_w: int, forecast_w: int, curtail_w: int) -> int: @@ -1075,7 +1076,6 @@ async def _load_inverter_config( """ SELECT ai.id, ai.code, - coalesce(ai.manufacturer, '') AS manufacturer, coalesce(ems.fn_inverter_pv_a_max_w(ai.id), 0) AS pv_a_cap_w, se.host, se.port, se.unit_id, sgc.max_export_power_w, @@ -1093,6 +1093,8 @@ async def _load_inverter_config( ai.deye_tou_inactive_signature, COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode, COALESCE(ai.deye_gen_microinverter_cutoff_enabled, false) AS deye_gen_microinverter_cutoff_enabled, + coalesce(ems.fn_site_has_active_green_bonus_pv(ai.site_id), false) + AS deye_reg340_pv_a_control_enabled, COALESCE( ai.deye_register_max_charge_a, FLOOR( @@ -1177,8 +1179,10 @@ async def _load_inverter_config( deye_tou_inactive_signature=row["deye_tou_inactive_signature"], deye_zero_export_mode=int(row["deye_zero_export_mode"]), deye_gen_microinverter_cutoff_enabled=bool(row["deye_gen_microinverter_cutoff_enabled"] or False), - manufacturer=str(row["manufacturer"] or ""), pv_a_cap_w=int(row["pv_a_cap_w"] or 0), + deye_reg340_pv_a_control_enabled=bool( + row["deye_reg340_pv_a_control_enabled"] or False + ), ) @@ -1262,7 +1266,7 @@ def _build_setpoints( pi: asyncpg.Record | None, *, pv_a_cap_w: int = 0, - inverter_manufacturer: str = "", + reg340_pv_a_control_enabled: bool = False, ) -> ControlSetpoints | None: code = mode.mode_code if code == "MANUAL": @@ -1286,7 +1290,7 @@ def _build_setpoints( gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled") gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False pv_a_allowed: int | None = None - if (inverter_manufacturer or "").strip().lower() == "deye" and int(pv_a_cap_w) > 0: + if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0: forecast = int(pi.get("pv_a_forecast_solver_w") or 0) curtail = int(pi.get("pv_a_curtailed_w") or 0) pv_a_allowed = compute_pv_a_reg340_max_solar_w(int(pv_a_cap_w), forecast, curtail) @@ -1677,9 +1681,8 @@ async def write_inverter_setpoints( ] ) - mfr = (inv.manufacturer or "").strip().lower() if ( - mfr == "deye" + bool(inv.deye_reg340_pv_a_control_enabled) and int(inv.pv_a_cap_w) > 0 and setpoints_now.pv_a_allowed_w is not None ): @@ -1826,7 +1829,8 @@ async def write_inverter_setpoints( async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]: """ - Živé čtení holding registrů Deye 108, 109, 141–145, 178, 191, 340 (stejné TCP spojení jako telemetrie/export). + Živé čtení holding registrů Deye 108, 109, 141–145, 178, 191 a volitelně 340 + (jen pokud `deye_reg340_pv_a_control_enabled`, jinak `reg340_max_solar_power_w` = null). Vše pod jedním mutexem + sdružené FC3 bloky — mezi jednotlivými read_register dřív telemetrie střídavě brala lock a RS485 brány házely cizí transaction_id / I/O timeouty. """ @@ -1843,13 +1847,20 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict b141 = await mb.read_holding_registers(141, 5) r178 = await mb.read_holding_registers(178, 1) r191 = await mb.read_holding_registers(191, 1) - r340 = await mb.read_holding_registers(340, 1) + if inv.deye_reg340_pv_a_control_enabled: + r340 = await mb.read_holding_registers(340, 1) + else: + r340 = None r108, r109 = b108[0], b108[1] r141, r142, r143 = b141[0], b141[1], b141[2] r145 = b141[4] r178 = r178[0] r191 = r191[0] - r340v = int(r340[0]) if r340 and len(r340) >= 1 else 0 + r340v = ( + int(r340[0]) + if r340 is not None and len(r340) >= 1 + else None + ) except Exception: logger.exception("read_deye_registers_live site=%s failed", site_id) raise @@ -1866,7 +1877,7 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict "reg178_mi_export_cutoff_bits": int(r178) & int(REG178_MI_EXPORT_MASK), "reg178_mi_export_cutoff_is_on": (int(r178) & int(REG178_MI_EXPORT_MASK)) == int(REG178_MI_EXPORT_ENABLE), "reg191_peak_shaving_w": int(r191), - "reg340_max_solar_power_w": int(r340v), + "reg340_max_solar_power_w": r340v, "read_at": read_at.isoformat(), } @@ -2012,14 +2023,24 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: try: inv_for_pv = await _load_inverter_config(site_id, db) cap_pv = int(inv_for_pv.pv_a_cap_w) if inv_for_pv is not None else 0 - mfr_pv = (inv_for_pv.manufacturer or "") if inv_for_pv is not None else "" + reg340_en = ( + bool(inv_for_pv.deye_reg340_pv_a_control_enabled) + if inv_for_pv is not None + else False + ) pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0) pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1) sp_now = _build_setpoints( - mode, pi_now, pv_a_cap_w=cap_pv, inverter_manufacturer=mfr_pv + mode, + pi_now, + pv_a_cap_w=cap_pv, + reg340_pv_a_control_enabled=reg340_en, ) sp_next = _build_setpoints( - mode, pi_next, pv_a_cap_w=cap_pv, inverter_manufacturer=mfr_pv + mode, + pi_next, + pv_a_cap_w=cap_pv, + reg340_pv_a_control_enabled=reg340_en, ) if mode.mode_code == "AUTO" and sp_now is None: diff --git a/backend/tests/test_control_exporter_reg340.py b/backend/tests/test_control_exporter_reg340.py index 681f6cf..8550465 100644 --- a/backend/tests/test_control_exporter_reg340.py +++ b/backend/tests/test_control_exporter_reg340.py @@ -53,12 +53,12 @@ class ComputePvAReg340Tests(unittest.TestCase): class BuildSetpointsReg340Tests(unittest.TestCase): - def test_deye_with_cap_sets_pv_a_allowed(self) -> None: + def test_with_cap_sets_pv_a_allowed(self) -> None: sp = _build_setpoints( _auto_mode(), _pi_base(pv_a_forecast_solver_w=8000, pv_a_curtailed_w=2000), pv_a_cap_w=10_000, - inverter_manufacturer="Deye", + reg340_pv_a_control_enabled=True, ) assert sp is not None self.assertEqual(sp.pv_a_allowed_w, 6000) @@ -68,17 +68,7 @@ class BuildSetpointsReg340Tests(unittest.TestCase): _auto_mode(), _pi_base(), pv_a_cap_w=0, - inverter_manufacturer="Deye", - ) - assert sp is not None - self.assertIsNone(sp.pv_a_allowed_w) - - def test_skipped_for_non_deye(self) -> None: - sp = _build_setpoints( - _auto_mode(), - _pi_base(), - pv_a_cap_w=10_000, - inverter_manufacturer="Foo", + reg340_pv_a_control_enabled=True, ) assert sp is not None self.assertIsNone(sp.pv_a_allowed_w) @@ -92,9 +82,7 @@ class BuildSetpointsReg340Tests(unittest.TestCase): heat_pump_enabled_def=False, loxone_mode_value=0, ) - sp = _build_setpoints( - mode, None, pv_a_cap_w=10_000, inverter_manufacturer="Deye" - ) + sp = _build_setpoints(mode, None, pv_a_cap_w=10_000) assert sp is not None self.assertIsNone(sp.pv_a_allowed_w) @@ -109,11 +97,21 @@ class BuildSetpointsReg340Tests(unittest.TestCase): pv_a_curtailed_w=0, ), pv_a_cap_w=3333, - inverter_manufacturer="Deye", + reg340_pv_a_control_enabled=True, ) assert sp is not None self.assertEqual(sp.pv_a_allowed_w, 0) + def test_skipped_when_reg340_control_disabled(self) -> None: + sp = _build_setpoints( + _auto_mode(), + _pi_base(pv_a_forecast_solver_w=8000, pv_a_curtailed_w=2000), + pv_a_cap_w=10_000, + reg340_pv_a_control_enabled=False, + ) + assert sp is not None + self.assertIsNone(sp.pv_a_allowed_w) + class Reg340VerifyPolicyTests(unittest.TestCase): def test_reg340_not_critical_for_self_sustain(self) -> None: diff --git a/db/routines/R__083_fn_inverter_pv_a_max_w.sql b/db/routines/R__083_fn_inverter_pv_a_max_w.sql index b41450b..ca44821 100644 --- a/db/routines/R__083_fn_inverter_pv_a_max_w.sql +++ b/db/routines/R__083_fn_inverter_pv_a_max_w.sql @@ -1,4 +1,4 @@ --- Cap pro Deye reg 340 (max solar power, W): součet nominal_power_wp řiditelných PV polí na invertoru. +-- Cap pro reg 340 max solar power (W): součet nominal_power_wp řiditelných PV polí na invertoru. create or replace function ems.fn_inverter_pv_a_max_w(p_inverter_id int) returns int language sql @@ -11,4 +11,4 @@ as $$ $$; comment on function ems.fn_inverter_pv_a_max_w(int) is - 'Cap pro Deye reg 340 (max solar power, W) = součet nominal_power_wp řiditelných PV polí na daném invertoru. 0 = EMS reg 340 neaktivní (skip zápisu).'; + 'Cap pro reg 340 (max solar power, W) = součet nominal_power_wp řiditelných PV polí na daném invertoru. 0 = EMS reg 340 neaktivní (skip zápisu).'; diff --git a/db/routines/R__084_fn_site_has_active_green_bonus_pv.sql b/db/routines/R__084_fn_site_has_active_green_bonus_pv.sql new file mode 100644 index 0000000..65a0418 --- /dev/null +++ b/db/routines/R__084_fn_site_has_active_green_bonus_pv.sql @@ -0,0 +1,25 @@ +-- True pokud má lokalita aspoň jedno PV pole se zeleným bonusem v aktuálním kalendářním dni (Europe/Prague). +create or replace function ems.fn_site_has_active_green_bonus_pv(p_site_id int) +returns boolean +language sql +stable +as $$ + select exists ( + select 1 + from ems.asset_pv_array apa + where apa.site_id = p_site_id + and apa.green_bonus_czk_kwh is not null + and apa.green_bonus_czk_kwh > 0 + and ( + apa.green_bonus_valid_from is null + or (timezone('Europe/Prague', now()))::date >= apa.green_bonus_valid_from + ) + and ( + apa.green_bonus_valid_to is null + or (timezone('Europe/Prague', now()))::date < apa.green_bonus_valid_to + ) + ); +$$; + +comment on function ems.fn_site_has_active_green_bonus_pv(int) is + 'EMS reg 340: true pokud existuje PV pole na site se zeleným bonusem (green_bonus_czk_kwh > 0) platné dnes v Europe/Prague.'; diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 42511af..445427d 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -125,7 +125,7 @@ CREATE TABLE asset_battery ( ### `asset_pv_array` Každé FVE pole zvlášť – důležité pro predikci (azimut, sklon). **Zelený bonus** (dotace za vyrobenou elektřinu) se váže na **úroveň pole**, ne na celou lokalitu: které pole má bonus, jaká je sazba Kč/kWh a platnost, určují sloupce `green_bonus_*`. Výpočet příjmu za interval probíhá funkcí `ems.fn_green_bonus_revenue` z výroby v Wh; není součástí efektivní prodejní ceny ze sítě. -**Deye reg 340 (max solar power, W):** strop pro řiditelné DC pole A na hybridu počítá `ems.fn_inverter_pv_a_max_w(inverter_id)` jako **součet `nominal_power_wp`** řádků s `controllable = true` vázaných na daný invertor. Exportér (`exporter_monolith.write_inverter_setpoints`) tento cap použije jen u `asset_inverter.manufacturer` = Deye a **pouze pokud součet > 0**; při součtu 0 se reg 340 z EMS nezapisuje (nezasahuje do ručního nastavení v invertoru). +**Deye reg 340 (max solar power, W):** strop pro řiditelné DC pole A na hybridu počítá `ems.fn_inverter_pv_a_max_w(inverter_id)` jako **součet `nominal_power_wp`** řádků s `controllable = true` vázaných na daný invertor. Zápis z EMS je povolen jen na lokalitách se **zeleným bonusem na PV poli** (`ems.fn_site_has_active_green_bonus_pv(site_id)` — aktivní `asset_pv_array.green_bonus_*` v kalendářním dni Europe/Prague); jinak EMS reg 340 nemění (invertor zůstane na poslední hodnotě). ```sql CREATE TABLE asset_pv_array ( diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index e053e09..11bd597 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -134,9 +134,9 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg ### PV A curtailment — zápis reg 340 (max solar power) -- **Implementace:** `backend/services/control/exporter_monolith.py` — `export_setpoints` načte cap v `_load_inverter_config` (`ems.fn_inverter_pv_a_max_w(ai.id)`), `_build_setpoints` v režimu **AUTO** dopočítá `ControlSetpoints.pv_a_allowed_w`, `write_inverter_setpoints` zařadí **reg 340**, pokud je výrobce invertoru Deye, cap > 0 a `pv_a_allowed_w` je vyplněné. +- **Implementace:** `backend/services/control/exporter_monolith.py` — `export_setpoints` načte cap v `_load_inverter_config` (`ems.fn_inverter_pv_a_max_w(ai.id)`), `_build_setpoints` v režimu **AUTO** dopočítá `ControlSetpoints.pv_a_allowed_w`, `write_inverter_setpoints` zařadí **reg 340**, pokud je `fn_site_has_active_green_bonus_pv` aktivní, cap > 0 a `pv_a_allowed_w` je vyplněné. - **Data:** `pv_a_forecast_solver_w` / `pv_a_curtailed_w` z aktivního `planning_interval` (json z `ems.fn_planning_interval_at_offset`); cap = součet `nominal_power_wp` řiditelných polí na invertoru (bez nového sloupce v DB). -- **Home-01 policy (PV A off):** pokud jsou v aktuálním slotu zároveň `effective_buy_price < 0` a `effective_sell_price < 0` a zároveň `pv_b_forecast_solver_w > 0` (PV B vyrábí), exporter nastaví `pv_a_allowed_w = 0` (reg 340) i když je forecast PV A nulový — cílem je držet headroom v baterii pro PV B / další záporný nákup. +- **Policy PV A off (jen na site se zeleným bonusem na PV):** pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` a v aktuálním slotu zároveň `effective_buy_price < 0` a `effective_sell_price < 0` a `pv_b_forecast_solver_w > 0` (PV B vyrábí), exporter nastaví `pv_a_allowed_w = 0` (reg 340) i když je forecast PV A nulový — cílem je držet headroom v baterii pro PV B / další záporný nákup. - **Verify:** reg **340** není kritický → po 3× mismatch verify **bez** přepnutí do SELF_SUSTAIN (stejně jako reg 178); viz [`modbus-command-journal.md`](modbus-command-journal.md). #### Ověření po nasazení (smoke) diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index be66e0e..4219201 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -19,7 +19,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) | | 142 | Limit control (System work mode) | 0/1/2 | — | **0** = selling first, **1** = zero export to load, **2** = zero export to CT. Hodnota v non-SELL režimech pochází z `asset_inverter.deye_zero_export_mode` (závisí na instalaci – viz tabulka níže). V režimu SELL vždy **0**. | | 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; při 108 = 0 a 145 = 1 přebytky z řiditelného stringu typicky tečou do sítě (viz pass-through níže). | -| 340 | Max solar power | 0 … cap (W) | 1 W | Strop výkonu DC PV řízeného střídačem (pole A). EMS zapisuje jen pokud `manufacturer` = Deye a `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (součet `nominal_power_wp` controllable polí). Hodnota z aktivního `planning_interval`: bez curtailmentu = cap; s `pv_a_curtailed_w > 0` = `clamp(0, cap, pv_a_forecast_solver_w − pv_a_curtailed_w)`. **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. | +| 340 | Max solar power | 0 … cap (W) | 1 W | Strop výkonu DC PV řízeného střídačem (pole A). EMS zapisuje jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (zelený bonus na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`. Hodnota z aktivního `planning_interval`: bez curtailmentu = cap; s `pv_a_curtailed_w > 0` = `clamp(0, cap, pv_a_forecast_solver_w − pv_a_curtailed_w)`. **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. | | 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` | | 178 | Control board special 1 | bitmask | — | Bitové pole pro více funkcí. **EMS používá:** (a) bits **4–5** pro peak shaving switch: **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE/CHARGE**. (b) **BA81:** bits **0–1** pro „MI export to Grid cutoff“ (AC coupling / GEN): **2** = disable (cutoff OFF), **3** = enable (cutoff ON). EMS zapisuje jako **read-modify-write** (zachová ostatní bity). V některých manuálech/UI je to označené jako „register 179“ (1-based). | | 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu | @@ -30,9 +30,9 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi ### Reg 340 (max solar power) - **FC 0x10**, jednotka **W**; firmware omezuje strop výroby z řiditelných stringů (pole A na hybridu). -- **Kdy EMS zapisuje:** `asset_inverter.manufacturer` odpovídá Deye **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (součet `nominal_power_wp` z `asset_pv_array` kde `controllable = true`). Při součtu **0** EMS reg 340 **nezapisuje** (např. odpojené pole A s `nominal_power_wp = 0` v DB — ruční hodnota v invertoru zůstane). +- **Kdy EMS zapisuje:** `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (součet `nominal_power_wp` z `asset_pv_array` kde `controllable = true`). Při součtu **0** nebo bez aktivního zeleného bonusu EMS reg 340 **nezapisuje** (ruční hodnota v invertoru zůstane). - **Hodnota:** z `ControlSetpoints.pv_a_allowed_w` (AUTO): bez curtailmentu = plný cap; při `pv_a_curtailed_w > 0` viz tabulka výše. Režimy **SELF_SUSTAIN / PRESERVE / CHARGE_CHEAP** mají `pv_a_allowed_w = None` → žádný zápis 340 z EMS v daném ticku. -- **Živé čtení:** `read_deye_registers_live` vrací **`reg340_max_solar_power_w`**. +- **Živé čtení:** `read_deye_registers_live` vrací **`reg340_max_solar_power_w`** (integer) jen pokud je přepínač zapnutý; jinak **`null`** (bez extra FC3 čtení reg 340). ### Reg 191 (výkon grid peak shaving) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 982c16c..9c491a9 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -489,7 +489,7 @@ COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS 'Hodnota > 0 znamená že solver rozhodl omezit výrobu pole A přes Modbus.'; ``` -**Fyzická realizace na Deye (bez změny solveru):** u hybridu s `manufacturer = Deye` a nenulovým součtem `nominal_power_wp` controllable polí na invertoru exportér mapuje `pv_a_forecast_solver_w` / `pv_a_curtailed_w` na zápis **holding registru 340** (max solar power, W) — viz [`control.md`](control.md) sekce *PV A curtailment* a [`modbus-registers.md`](modbus-registers.md) reg 340. +**Fyzická realizace na hybridu (bez změny solveru):** při `ems.fn_site_has_active_green_bonus_pv(site_id)` a nenulovém součtu `nominal_power_wp` controllable polí na invertoru exportér mapuje `pv_a_forecast_solver_w` / `pv_a_curtailed_w` na zápis **holding registru 340** (max solar power, W) — viz [`control.md`](control.md) sekce *PV A curtailment* a [`modbus-registers.md`](modbus-registers.md) reg 340. ---