diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 450187d..3fda220 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -2,11 +2,6 @@ from __future__ import annotations -import logging -from typing import Any - -import asyncpg - from services.control.deye_helpers import ( BATT_VOLTAGE_V, DEYE_CLOCK_DRIFT_OK_SEC, @@ -55,6 +50,7 @@ from services.control.outputs import ( write_ev_setpoints, write_heat_pump_setpoint, ) +from services.control.orchestrator import export_setpoints from services.control.repository import ( _fetch_max_charge_power_w, _fetch_operating_mode, @@ -84,138 +80,3 @@ from services.control.verify import ( _verify_deye_clock_written_bundle, verify_modbus_commands, ) -from services.signal_service import enqueue_site_signals - -logger = logging.getLogger(__name__) - - -async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: - mode = await _fetch_operating_mode(site_id, db) - if mode is None: - logger.warning("control export site=%s: no operating mode row", site_id) - return - - if mode.mode_code == "MANUAL": - logger.info("control export site=%s: MANUAL, skip writes", site_id) - return - - 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 - 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, - reg340_pv_a_control_enabled=reg340_en, - ) - sp_next = _build_setpoints( - 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: - if pi_now is None: - logger.warning( - "control export site=%s: AUTO but no planning_interval for current slot, skip", - site_id, - ) - return - - if sp_now is None: - logger.warning( - "control export site=%s: no setpoints for mode %s, skip", - site_id, - mode.mode_code, - ) - return - - if mode.mode_code == "CHARGE_CHEAP": - max_ch = await _fetch_max_charge_power_w(site_id, db) - # Oba setpointy kladné → get_deye_mode CHARGE; min. 1 W, aby režim nebyl PASSIVE při nulové DB. - pw = max(1, int(max_ch)) - sp_now = ControlSetpoints( - battery_w=pw, - grid_export_limit=0, - ev1_current_a=0, - ev2_current_a=0, - heat_pump_enable=False, - grid_setpoint_w=pw, - ev1_power_w=0, - ev2_power_w=0, - target_soc_pct=None, - effective_sell_price_czk_kwh=None, - ) - sp_next = sp_now - else: - sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now) - if sp_next is not None: - sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next) - - planning_run_id = await db.fetchval( - """ - SELECT id FROM ems.planning_run - WHERE site_id = $1 AND status = 'active' - ORDER BY created_at DESC - LIMIT 1 - """, - site_id, - ) - if planning_run_id is not None: - planning_run_id = int(planning_run_id) - - try: - inv_res = await write_inverter_setpoints( - site_id, sp_now, sp_next, db, planning_run_id=planning_run_id - ) - except Exception as e: - logger.error("inverter write failed: %s", e) - inv_res = f"FAIL inverter: {e}" - - try: - ev_res = await write_ev_setpoints(site_id, sp_now, db) - except Exception as e: - logger.error("ev write failed: %s", e) - ev_res = f"FAIL ev: {e}" - - try: - hp_res = await write_heat_pump_setpoint(site_id, sp_now, db) - except Exception as e: - logger.error("hp write failed: %s", e) - hp_res = f"FAIL heat pump: {e}" - - try: - lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db) - except Exception as e: - logger.error("loxone write failed: %s", e) - lox_res = f"FAIL Loxone: {e}" - - results = list( - zip( - ("inverter", "ev", "heat_pump", "loxone"), - (inv_res, ev_res, hp_res, lox_res), - ) - ) - - for name, res in results: - if isinstance(res, Exception): - logger.error("control export site=%s %s: FAIL %s", site_id, name, res) - elif isinstance(res, str) and res.startswith("FAIL"): - logger.error("control export site=%s %s: %s", site_id, name, res) - else: - logger.info("control export site=%s %s: %s", site_id, name, res) - finally: - try: - await enqueue_site_signals(site_id, db) - except Exception as e: - logger.warning( - "control export site=%s: signal enqueue failed: %s", site_id, e - ) diff --git a/backend/services/control/orchestrator.py b/backend/services/control/orchestrator.py new file mode 100644 index 0000000..1ddbd7f --- /dev/null +++ b/backend/services/control/orchestrator.py @@ -0,0 +1,156 @@ +"""Top-level control export orchestration.""" + +from __future__ import annotations + +import logging + +import asyncpg + +from services.control.inverter import write_inverter_setpoints +from services.control.models import ControlSetpoints +from services.control.outputs import ( + send_loxone_setpoints, + write_ev_setpoints, + write_heat_pump_setpoint, +) +from services.control.repository import ( + _fetch_max_charge_power_w, + _fetch_operating_mode, + _fetch_plan_row_for_slot_offset, + _load_inverter_config, +) +from services.control.setpoints import _apply_price_failsafe_guard, _build_setpoints +from services.signal_service import enqueue_site_signals + +logger = logging.getLogger(__name__) + + +async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: + mode = await _fetch_operating_mode(site_id, db) + if mode is None: + logger.warning("control export site=%s: no operating mode row", site_id) + return + + if mode.mode_code == "MANUAL": + logger.info("control export site=%s: MANUAL, skip writes", site_id) + return + + 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 + 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, + reg340_pv_a_control_enabled=reg340_en, + ) + sp_next = _build_setpoints( + 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: + if pi_now is None: + logger.warning( + "control export site=%s: AUTO but no planning_interval for current slot, skip", + site_id, + ) + return + + if sp_now is None: + logger.warning( + "control export site=%s: no setpoints for mode %s, skip", + site_id, + mode.mode_code, + ) + return + + if mode.mode_code == "CHARGE_CHEAP": + max_ch = await _fetch_max_charge_power_w(site_id, db) + pw = max(1, int(max_ch)) + sp_now = ControlSetpoints( + battery_w=pw, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=pw, + ev1_power_w=0, + ev2_power_w=0, + target_soc_pct=None, + effective_sell_price_czk_kwh=None, + ) + sp_next = sp_now + else: + sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now) + if sp_next is not None: + sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next) + + planning_run_id = await db.fetchval( + """ + SELECT id FROM ems.planning_run + WHERE site_id = $1 AND status = 'active' + ORDER BY created_at DESC + LIMIT 1 + """, + site_id, + ) + if planning_run_id is not None: + planning_run_id = int(planning_run_id) + + try: + inv_res = await write_inverter_setpoints( + site_id, sp_now, sp_next, db, planning_run_id=planning_run_id + ) + except Exception as e: + logger.error("inverter write failed: %s", e) + inv_res = f"FAIL inverter: {e}" + + try: + ev_res = await write_ev_setpoints(site_id, sp_now, db) + except Exception as e: + logger.error("ev write failed: %s", e) + ev_res = f"FAIL ev: {e}" + + try: + hp_res = await write_heat_pump_setpoint(site_id, sp_now, db) + except Exception as e: + logger.error("hp write failed: %s", e) + hp_res = f"FAIL heat pump: {e}" + + try: + lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db) + except Exception as e: + logger.error("loxone write failed: %s", e) + lox_res = f"FAIL Loxone: {e}" + + results = list( + zip( + ("inverter", "ev", "heat_pump", "loxone"), + (inv_res, ev_res, hp_res, lox_res), + ) + ) + + for name, res in results: + if isinstance(res, Exception): + logger.error("control export site=%s %s: FAIL %s", site_id, name, res) + elif isinstance(res, str) and res.startswith("FAIL"): + logger.error("control export site=%s %s: %s", site_id, name, res) + else: + logger.info("control export site=%s %s: %s", site_id, name, res) + finally: + try: + await enqueue_site_signals(site_id, db) + except Exception as e: + logger.warning( + "control export site=%s: signal enqueue failed: %s", site_id, e + )