#!/usr/bin/python -u # coding=utf-8 import logging import os import time import states as State import target_type as TargetType from random import randint from python_libs.ie_dbus.dbus_service import DBusService from python_libs.ie_utils.main_loop import run_on_main_loop # noinspection PyUnreachableCode if False: from typing import NoReturn, Optional, Any, Iterable, List logging.basicConfig(level=logging.INFO) _log = logging.getLogger(__name__) VERSION = '1.0.0' PRODUCT = 'Controller' GRID_SERVICE_PREFIX = 'com.victronenergy.grid.' BATTERY_SERVICE_PREFIX = 'com.victronenergy.battery.' INVERTER_SERVICE_PREFIX = 'com.victronenergy.vebus.' SYSTEM_SERVICE_PREFIX = 'com.victronenergy.system' HUB4_SERVICE_PREFIX = 'com.victronenergy.hub4' SETTINGS_SERVICE_PREFIX = 'com.victronenergy.settings' UPDATE_PERIOD_MS = 2000 MAX_POWER_PER_BATTERY = 2500 MAX_DAYS_WITHOUT_EOC = 7 SECONDS_PER_DAY = 24 * 60 * 60 GRID_SET_POINT_SETTING = PRODUCT + '/GridSetPoint' LAST_EOC_SETTING = PRODUCT + '/LastEOC' CALIBRATION_CHARGE_START_TIME_OF_DAY_SETTING = PRODUCT + '/CalibrationChargeStartTime' HEAT_LOSS = 150 # W P_CONST = 0.5 # W/W Epoch = int Seconds = int def time_now(): return int(time.time()) class Controller(object): def __init__(self, measurement, target, target_type, state): # type: (float, float, int, int) -> NoReturn self.target_type = target_type self.target = target self.measurement = measurement self.state = state d_p = target - measurement self.delta = d_p * P_CONST @staticmethod def min(controllers): # type: (Iterable[Controller]) -> Controller return min(controllers, key=lambda c: c.delta) @staticmethod def max(controllers): # type: (Iterable[Controller]) -> Controller return max(controllers, key=lambda c: c.delta) def clamp(self, lower_limit_controllers, upper_limit_controllers): # type: (List[Controller],List[Controller]) -> Controller c_min = Controller.min(upper_limit_controllers + [self]) return Controller.max(lower_limit_controllers + [c_min]) # noinspection PyMethodMayBeStatic class InnovEnergyController(DBusService): def __init__(self): super(InnovEnergyController, self).__init__(PRODUCT.lower()) self.settings.add_setting(path=LAST_EOC_SETTING, default_value=0) # unix epoch timestamp self.settings.add_setting(path=GRID_SET_POINT_SETTING, default_value=0) # grid setpoint, Watts self.settings.add_setting(path=CALIBRATION_CHARGE_START_TIME_OF_DAY_SETTING, default_value=32400) # 09:00 self.own_properties.set('/ProductName', PRODUCT) self.own_properties.set('/Mgmt/ProcessName', __file__) self.own_properties.set('/Mgmt/ProcessVersion', VERSION) self.own_properties.set('/Mgmt/Connection', 'dbus') self.own_properties.set('/ProductId', PRODUCT) self.own_properties.set('/FirmwareVersion', VERSION) self.own_properties.set('/HardwareVersion', VERSION) self.own_properties.set('/Connected', 1) self.own_properties.set('/TimeToCalibrationCharge', 'unknown') self.own_properties.set('/State', 0) self.phases = [ p for p in ['/Hub4/L1/AcPowerSetpoint', '/Hub4/L2/AcPowerSetpoint', '/Hub4/L3/AcPowerSetpoint'] if self.remote_properties.exists(self.inverter_service + p) ] self.n_phases = len(self.phases) print ('The system has ' + str(self.n_phases) + ' phase' + ('s' if self.n_phases != 1 else '')) self.max_inverter_power = 32700 # ^ defined in https://github.com/victronenergy/dbus_modbustcp/blob/master/CCGX-Modbus-TCP-register-list.xlsx def clamp_power_command(self, value): # type: (float) -> int value = max(value, -self.max_inverter_power) value = min(value, self.max_inverter_power) return int(value) def get_service(self, prefix): # type: (str) -> Optional[unicode] service = next((s for s in self.available_services if s.startswith(prefix)), None) if service is None: raise Exception('no service matching ' + prefix + '* available') return service def is_service_available(self, prefix): # type: (str) -> bool return next((True for s in self.available_services if s.startswith(prefix)), False) @property def battery_service(self): # type: () -> Optional[unicode] return self.get_service(BATTERY_SERVICE_PREFIX) @property def battery_available(self): # type: () -> bool return self.is_service_available(BATTERY_SERVICE_PREFIX) @property def grid_service(self): # type: () -> Optional[unicode] return self.get_service(GRID_SERVICE_PREFIX) @property def grid_meter_available(self): # type: () -> bool return self.is_service_available(GRID_SERVICE_PREFIX) @property def inverter_service(self): # type: () -> Optional[unicode] return self.get_service(INVERTER_SERVICE_PREFIX) @property def inverter_available(self): # type: () -> bool return self.is_service_available(INVERTER_SERVICE_PREFIX) @property def system_service(self): # type: () -> Optional[unicode] return self.get_service(SYSTEM_SERVICE_PREFIX) @property def system_service_available(self): # type: () -> bool return self.is_service_available(SYSTEM_SERVICE_PREFIX) @property def hub4_service(self): # type: () -> Optional[unicode] return self.get_service(HUB4_SERVICE_PREFIX) @property def hub4_service_available(self): # type: () -> bool return self.is_service_available(HUB4_SERVICE_PREFIX) @property def inverter_power_setpoint(self): # type: () -> float return sum((self.get_inverter_prop(p) for p in self.phases)) def get_battery_prop(self, dbus_path): # type: (str) -> Any battery_service = self.battery_service return self.remote_properties.get(battery_service + dbus_path).value def get_grid_prop(self, dbus_path): # type: (str) -> Any return self.remote_properties.get(self.grid_service + dbus_path).value def get_inverter_prop(self, dbus_path): # type: (str) -> Any return self.remote_properties.get(self.inverter_service + dbus_path).value def get_system_prop(self, dbus_path): # type: (str) -> Any system_service = self.system_service return self.remote_properties.get(system_service + dbus_path).value def get_hub4_prop(self, dbus_path): # type: (str) -> Any hub4_service = self.hub4_service return self.remote_properties.get(hub4_service + dbus_path).value def set_settings_prop(self, dbus_path, value): # type: (str, Any) -> bool return self.remote_properties.set(SETTINGS_SERVICE_PREFIX + dbus_path, value) def set_inverter_prop(self, dbus_path, value): # type: (str, Any) -> bool inverter_service = self.inverter_service return self.remote_properties.set(inverter_service + dbus_path, value) @property def max_battery_charge_power(self): # type: () -> int return self.get_battery_prop('/Info/MaxChargePower') @property def max_battery_discharge_power(self): # type: () -> int return self.get_battery_prop('/Info/MaxDischargePower') @property def max_configured_charge_power(self): # type: () -> Optional[int] max_power = self.settings.get('/Settings/CGwacs/MaxChargePower') return max_power if max_power >= 0 else None @property def max_configured_discharge_power(self): # unsigned # type: () -> Optional[int] max_power = self.settings.get('/Settings/CGwacs/MaxDischargePower') return max_power if max_power >= 0 else None @property def max_charge_power(self): # type: () -> int if self.max_configured_charge_power is None: return self.max_battery_charge_power else: return min(self.max_battery_charge_power, self.max_configured_charge_power) @property def max_discharge_power(self): # unsigned # type: () -> int if self.max_configured_discharge_power is None: return self.max_battery_discharge_power else: return min(self.max_battery_discharge_power, self.max_configured_discharge_power) def set_inverter_power_setpoint(self, power,controller_state): # type: (float,int) -> NoReturn if self.settings.get('/Settings/CGwacs/BatteryLife/State') == 9: self.settings.set('/Settings/CGwacs/BatteryLife/State', 0) # enables scheduled charge self.settings.set('/Settings/CGwacs/Hub4Mode', 3) # disable hub4 # off-grid installation has no /Hub4 path, thus the setting here will fail and self.n_phases = 0 if controller_state != 6 and controller_state != 0: self.set_inverter_prop('/Hub4/DisableCharge', 0) self.set_inverter_prop('/Hub4/DisableFeedIn', 0) power = self.clamp_power_command(power / self.n_phases) for p in self.phases: self.set_inverter_prop(p, power + randint(-1, 1)) # use randint to force dbus re-send def set_controller_state(self, state): # type: (int) -> NoReturn self.own_properties.set('/State', state) @property def grid_power(self): # type: () -> Optional[float] try: return self.get_grid_prop('/Ac/Power') except: return None @property def battery_cold(self): # type: () -> bool return self.get_battery_prop('/IoStatus/BatteryCold') == 1 @property def eoc_reached(self): # type: () -> bool if not self.battery_available: return False return self.get_battery_prop('/IoStatus/EocReached') == 1 @property def battery_power(self): # type: () -> float return self.get_battery_prop('/Dc/0/Power') @property def inverter_ac_in_power(self): # type: () -> float return self.get_inverter_prop('/Ac/ActiveIn/P') @property def inverter_ac_out_power(self): # type: () -> float return self.get_inverter_prop('/Ac/Out/P') @property def soc(self): # type: () -> float return self.get_battery_prop('/Soc') @property def n_batteries(self): # type: () -> int return self.get_battery_prop('/NbOfBatteries') @property def min_soc(self): # type: () -> float return self.settings.get('/Settings/CGwacs/BatteryLife/MinimumSocLimit') @property def should_hold_min_soc(self): # type: () -> bool return self.min_soc <= self.soc <= self.min_soc + 5 @property def utc_offset(self): # type: () -> int # stackoverflow.com/a/1301528 # stackoverflow.com/a/3168394 os.environ['TZ'] = self.settings.get('/Settings/System/TimeZone') time.tzset() is_dst = time.daylight and time.localtime().tm_isdst > 0 return -(time.altzone if is_dst else time.timezone) @property def grid_set_point(self): # type: () -> float return self.settings.get('/Settings/CGwacs/AcPowerSetPoint') @property def time_to_calibration_charge_str(self): # type: () -> str return self.own_properties.get('/TimeToCalibrationCharge').text @property def calibration_charge_deadline(self): # type: () -> Epoch utc_offset = self.utc_offset ultimate_deadline = self.settings.get(LAST_EOC_SETTING) + MAX_DAYS_WITHOUT_EOC * SECONDS_PER_DAY midnight_before_udl = int((ultimate_deadline + utc_offset) / SECONDS_PER_DAY) * SECONDS_PER_DAY - utc_offset # round off to last midnight dead_line = midnight_before_udl + self.calibration_charge_start_time_of_day while dead_line > ultimate_deadline: # should fire at most once, but let's be defensive... dead_line -= SECONDS_PER_DAY # too late, advance one day return dead_line @property def time_to_calibration_charge(self): # type: () -> Seconds return self.calibration_charge_deadline - time_now() @property def grid_blackout(self): # type: () -> bool return self.get_inverter_prop('/Leds/Mains') < 1 @property def scheduled_charge(self): # type: () -> bool return self.get_hub4_prop('/Overrides/ForceCharge') != 0 @property def calibration_charge_start_time_of_day(self): # type: () -> Seconds return self.settings.get(CALIBRATION_CHARGE_START_TIME_OF_DAY_SETTING) # seconds since midnight @property def must_do_calibration_charge(self): # type: () -> bool return self.time_to_calibration_charge <= 0 def controller_charge_to_min_soc(self): # type: () -> Controller return Controller( measurement=self.battery_power, target=self.max_charge_power, target_type=TargetType.BATTERY_DC, state=State.CHARGE_TO_MIN_SOC ) def controller_hold_min_soc(self): # type: () -> Controller # TODO: explain a = -4 * HEAT_LOSS * self.n_batteries b = -a * (self.min_soc + .5) target_dc_power = a * self.soc + b return Controller( measurement = self.battery_power, target = target_dc_power, target_type = TargetType.BATTERY_DC, state = State.HOLD_MIN_SOC ) def controller_calibration_charge(self): # type: () -> Controller return Controller( measurement = self.battery_power, target = self.max_charge_power, target_type = TargetType.BATTERY_DC, state = State.CALIBRATION_CHARGE ) def controller_limit_discharge_power(self): # signed # type: () -> Controller return Controller( measurement = self.battery_power, target = -self.max_discharge_power, # add sign! target_type = TargetType.BATTERY_DC, state = State.LIMIT_DISCHARGE_POWER ) def controller_limit_charge_power(self): # type: () -> Controller return Controller( measurement = self.battery_power, target = self.max_charge_power, target_type = TargetType.BATTERY_DC, state = State.LIMIT_CHARGE_POWER ) def controller_optimize_self_consumption(self): # type: () -> Controller return Controller( measurement = self.grid_power, target = self.grid_set_point, target_type = TargetType.GRID_AC, state = State.OPTIMIZE_SELF_CONSUMPTION ) def controller_heating(self): # type: () -> Controller return Controller( measurement = self.battery_power, target = self.max_charge_power, target_type = TargetType.BATTERY_DC, state = State.HEATING ) def controller_scheduled_charge(self): # type: () -> Controller return Controller( measurement = self.battery_power, target = self.max_charge_power, target_type = TargetType.BATTERY_DC, state = State.SCHEDULED_CHARGE ) def controller_no_grid_meter(self): # type: () -> Controller return Controller( measurement = self.battery_power, target = self.max_charge_power, target_type = TargetType.BATTERY_DC, state = State.NO_GRID_METER_AVAILABLE ) def controller_no_battery(self): # type: () -> Controller return Controller( measurement = self.inverter_ac_in_power, target = 0, target_type = TargetType.INVERTER_AC_IN, state = State.NO_BATTERY_AVAILABLE ) def controller_bridge_grid_blackout(self): # type: () -> Controller return Controller( measurement = 0, target = 0, target_type = TargetType.GRID_AC, state = State.BRIDGE_GRID_BLACKOUT ) def update_eoc(self): if self.eoc_reached: print('battery has reached EOC') self.settings.set(LAST_EOC_SETTING, time_now()) self.publish_time_to_calibration_charge() def publish_time_to_calibration_charge(self): total_seconds = self.time_to_calibration_charge if total_seconds <= 0: time_to_eoc_str = 'now' else: total_minutes, seconds = divmod(total_seconds, 60) total_hours, minutes = divmod(total_minutes, 60) total_days, hours = divmod(total_hours, 24) days_str = (str(total_days) + 'd') if total_days > 0 else '' hours_str = (str(hours) + 'h') if total_hours > 0 else '' minutes_str = (str(minutes) + 'm') if total_days == 0 else '' time_to_eoc_str = "{0} {1} {2}".format(days_str, hours_str, minutes_str).strip() self.own_properties.set('/TimeToCalibrationCharge', time_to_eoc_str) def print_system_stats(self, controller): # type: (Controller) -> NoReturn def soc_setpoint(): if controller.state == State.CALIBRATION_CHARGE or controller.state == State.NO_GRID_METER_AVAILABLE: return ' => 100%' if controller.state == State.CHARGE_TO_MIN_SOC: return ' => ' + str(int(self.min_soc)) + '%' return '' def setpoint(target_type): if target_type != controller.target_type: return '' return ' => ' + str(int(controller.target)) + 'W' def p(power): # type: (Optional[float]) -> str if power is None: return ' --- W' else: return str(int(power)) + 'W' ac_loads = None if self.grid_power is None else self.grid_power - self.inverter_ac_in_power delta = p(controller.delta) if controller.delta < 0 else '+' + p(controller.delta) battery_power = self.battery_power if self.battery_available else None soc_ = str(self.soc) + '%' if self.battery_available else '---' print (State.name_of[controller.state]) print ('') print ('time to CC: ' + self.time_to_calibration_charge_str) print (' SOC: ' + soc_ + soc_setpoint()) print (' grid: ' + p(self.grid_power) + setpoint(TargetType.GRID_AC)) print (' battery: ' + p(battery_power) + setpoint(TargetType.BATTERY_DC)) print (' AC in: ' + p(self.inverter_ac_in_power) + ' ' + delta) print (' AC out: ' + p(self.inverter_ac_out_power)) print (' AC loads: ' + p(ac_loads)) def choose_controller(self): # type: () -> Controller if self.grid_blackout: return self.controller_bridge_grid_blackout() if not self.battery_available: return self.controller_no_battery() if self.battery_cold: return self.controller_heating() if self.scheduled_charge: return self.controller_scheduled_charge() if self.must_do_calibration_charge: return self.controller_calibration_charge() if self.soc < self.min_soc: return self.controller_charge_to_min_soc() if not self.grid_meter_available: return self.controller_no_grid_meter() hold_min_soc = self.controller_hold_min_soc() limit_discharge_power = self.controller_limit_discharge_power() # signed lower_limit = [limit_discharge_power, hold_min_soc] # No upper limit. We no longer actively limit charge power. DC/DC Charger inside the BMS will do that for us. upper_limit = [] optimize_self_consumption = self.controller_optimize_self_consumption() return optimize_self_consumption.clamp(lower_limit, upper_limit) def update(self): print('iteration started\n') self.update_eoc() if self.inverter_available: controller = self.choose_controller() power = self.inverter_ac_in_power + controller.delta self.print_system_stats(controller) # for debug self.set_controller_state(controller.state) self.set_inverter_power_setpoint(power,controller.state) else: self.set_controller_state(State.NO_INVERTER_AVAILABLE) print('inverter not available!') print('\niteration finished\n') def main(): print('starting ' + __file__) with InnovEnergyController() as service: run_on_main_loop(service.update, UPDATE_PERIOD_MS) print(__file__ + ' has shut down') if __name__ == '__main__': main()