diff --git a/PVControl/__init__.py b/PVControl/__init__.py index d328e92..126c745 100644 --- a/PVControl/__init__.py +++ b/PVControl/__init__.py @@ -1,3 +1,3 @@ __author__ = """Stefan E""" __email__ = 'se_misc ... hotmail.com' -__version__ = 1.00 \ No newline at end of file +__version__ = 2.00 \ No newline at end of file diff --git a/PVControl/pvcontrol.py b/PVControl/pvcontrol.py index 12d44aa..9af437a 100644 --- a/PVControl/pvcontrol.py +++ b/PVControl/pvcontrol.py @@ -47,37 +47,45 @@ class PVControl(): collects the required data directly. """ def __init__(self, config): - self.config = config - self.Name = "PV Controller" + self.config = config + self.Name = "PV Controller" - self.phases = self.config['PVControl'].getint('phases', 3) - self.I_min = self.config['PVControl'].getfloat('I_min', None) # minimum charge current, will be read from wallbox - self.I_max = self.config['PVControl'].getfloat('I_max', None) # maximum charge current supported by wallbox, will be read from wallbox - self.I_gridMax = self.config['PVControl'].getfloat('I_gridMax', 0) # max current we are allowed to get from grid - self.feedInLimit = self.config['PVControl'].getint('feedInLimit', 99999) # power limit (70% rule) - self.maxSOC = self.config['PVControl'].getfloat('maxSOC', 1) - self.maxSOCCharge = self.config['PVControl'].getfloat('maxSOCCharge', self.maxSOC) - - self.InverterEff = 0.97 - self.batCapacity = self.config['PVStorage'].getint('batCapacity') # battery capacity [Wh] - self.maxBatDischarge = self.config['PVStorage'].getint('maxBatDischarge') # maximum battery (dis-)charge power [W] - self.maxBatCharge = self.config['PVStorage'].getint('maxBatCharge', self.maxBatDischarge/self.InverterEff) - self.minSOC = self.config['PVStorage'].getfloat('minSOC', 0.05) - - self.coeff_A = [0.5, 1.0] # coefficients for battery power allowence model - self.coeff_B = [0.2, 0.7] - self.coeff_C = [1.2, 2] # coefficients for battery charge model - - self.pvstatus = None # current PV status - self.pvforecast = None - self.wallbox = None # hardware abstraction - self.inverter = None - - self.monitorProvider = self.config['PVControl'].get('pvmonitor', 'Kostal') # which class provides PVMonitor? - self.wallboxProvider = self.config['PVControl'].get('wallbox', 'HardyBarth') # which class provides wallbox? - - self.I_charge = None - self.ctrlstatus = {} + self.phases = self.config['PVControl'].getint('phases', 3) + self.I_min = self.config['PVControl'].getfloat('I_min', None) # minimum charge current, will be read from wallbox + self.I_max = self.config['PVControl'].getfloat('I_max', None) # maximum charge current supported by wallbox, will be read from wallbox + self.I_gridMax = self.config['PVControl'].getfloat('I_gridMax', 0) # max current we are allowed to get from grid + self.feedInLimit = self.config['PVControl'].getint('feedInLimit', 99999) # power limit (70% rule) + self.minSOC = self.config['PVControl'].getfloat('minSOC', 0.05) # minimum SOC we want to tolerate + self.batMinSOC = self.config['PVStorage'].getfloat('minSOC', 0.05) # minimum SOC supported by battery + if self.minSOC < self.batMinSOC: self.minSOC = self.batMinSOC + self.maxSOC = self.config['PVControl'].getfloat('maxSOC', 1) # maximum SOC we want to tolerate + self.minSOCCharge = self.config['PVControl'].getfloat('minSOCCharge', self.minSOC) # minmum SOC before PV charge starts + self.maxSOCCharge = self.config['PVControl'].getfloat('maxSOCCharge', self.maxSOC) # maximum SOC during PV charing + + self.chargeNow = self.config['PVControl'].getboolean('chargeNow', True) # start charging 'now' if possible + self.chargeStart = self.config['PVControl'].getint('chargeStart', 0) # Epoch (UTC) after which to start charging if possible (nowSwitch = False) + + self.InverterEff = 0.97 + self.batCapacity = self.config['PVStorage'].getint('batCapacity') # battery capacity [Wh] + self.maxBatDischarge = self.config['PVStorage'].getint('maxBatDischarge') # maximum battery (dis-)charge power [W] + self.maxBatCharge = self.config['PVStorage'].getint('maxBatCharge', self.maxBatDischarge/self.InverterEff) + + self.coeff_A = [0.5, 1.0] # coefficients for battery power allowence model + self.coeff_B = [0.2, 0.7] + self.coeff_C = [1.2, 2] # coefficients for battery charge model + + self.pvstatus = None # current PV status + self.pvforecast = None + self.wallbox = None # hardware abstraction + self.inverter = None + + self.monitorProvider = self.config['PVControl'].get('pvmonitor', 'Kostal') # which class provides PVMonitor? + self.wallboxProvider = self.config['PVControl'].get('wallbox', 'HardyBarth') # which class provides wallbox? + + self.I_charge = None + self.inhibitDischarge = False # don't allow battery discharge + self.ctrlstatus = {} + self.sysstatus = {} try: file = open('./pvcontrol.pickle', 'rb') @@ -106,49 +114,62 @@ def runControl(self, _pvstatus = None, _pvforecast = None, _carstatus = None): Returns ------- - self.ctrlstatus : Dictionary - Caller can expect to find at least the following keys: - fastcharge : boolean - fast-charge home battery, or use smart-charge capability of inverter - ctrl_power : float - total power provided by the controller to wallbox, [W] - max_soc : float, range 0 .. 1 - battery SOC should not exceed max_soc - bat_forecast : float - have/need, where 'have' is expected remaining PV power which can be used for battery charging - and 'need' is power needed to charge battery to 'max_soc' [W] + self.sysstatus : Dictionary with keys: + ctrlstatus : Caller can expect to find at least the following keys: + fastcharge : boolean + fast-charge home battery, or use smart-charge capability of inverter + ctrl_power : float + total power provided by the controller to wallbox, [W] + max_soc : float, range 0 .. 1 + battery SOC should not exceed max_soc + bat_forecast : float + have/need, where 'have' is expected remaining PV power which can be used for battery charging + and 'need' is power needed to charge battery to 'max_soc' [W] + I_charge : float + EV charge current requested from wallbox + pvstatus : status as returned from self.monitorProvider + wbstatus : status as returned from self.wallboxProvider - used by GUI to display status (wallbox specific) + GUI_control : configuration information for GUI + i_charge_min : integer + minimum for current sliders + i_charge_max : integer + maximum for current sliders + minsoc : float, range 0 .. 1 + lowest value for SOC sliders """ - self.pvforecast = _pvforecast - wallbox = WallBoxFactory() - if _pvstatus is None: # ----------------------------------------------------- we need get life PV status data - pvmonitor = PVMonitorFactory() - self.inverter = pvmonitor.getPVMonitor(self.monitorProvider, self.config) - self.pvstatus = self.inverter.getStatus() - self.wallbox = wallbox.getWallBox(self.wallboxProvider, self.config) + self.pvforecast = _pvforecast + wallbox = WallBoxFactory() + if _pvstatus is None: # --------------------------------------------------- we need get life PV status data + pvmonitor = PVMonitorFactory() + self.inverter = pvmonitor.getPVMonitor(self.monitorProvider, self.config) + self.pvstatus = self.inverter.getStatus() + self.wallbox = wallbox.getWallBox(self.wallboxProvider, self.config) self.wallbox.readWB(self.persist['charge_completed']) if self.wallbox.status is not None: - ctrl_power = self._I_to_P(self.wallbox.status['ctrl_current']) + ctrl_power = self._I_to_P(self.wallbox.status['ctrl_current']) if self.I_min is None or self.I_min < self.wallbox.status['I_min']: self.I_min = self.wallbox.status['I_min'] if self.I_max is None or self.I_max > self.wallbox.status['I_max']: self.I_max = self.wallbox.status['I_max'] else: - ctrl_power = self.persist['ctrl_power'] # fall-back - self.currTime = self.pvstatus.name + ctrl_power = self.persist['ctrl_power'] # fall-back + self.currTime = self.pvstatus.name if self.pvforecast is None: - forecastObj = PVForecast(self.config) - self.pvforecast = forecastObj.getForecast(self.pvstatus.name) - active = True # we are in active mode, actually controlling wallbox + forecastObj = PVForecast(self.config) + self.pvforecast = forecastObj.getForecast(self.pvstatus.name) + active = True # we are in active mode, actually controlling wallbox else: # ----------------------------------------------------- running in simulation mode - self.pvstatus = _pvstatus - self.wallbox = wallbox.getWallBox('dummy', self.config) - self.wallbox.status = _carstatus - self.currTime = self.pvstatus.name # time of last PV status - ctrl_power = self.persist['ctrl_power'] - if self.I_min is None: self.I_min = 6 # fall-back values - if self.I_max is None: self.I_max = 16 - active = False - - self.I_min = self.wallbox.round_current(self.I_min) + self.pvstatus = _pvstatus + self.wallbox = wallbox.getWallBox('dummy', self.config) + self.wallbox.status.update(_carstatus) + self.currTime = self.pvstatus.name # time of last PV status + ctrl_power = self.persist['ctrl_power'] + if self.I_min is None: self.I_min = self.wallbox.status['I_min'] # fall-back values + if self.I_max is None: self.I_max = self.wallbox.status['I_max'] + active = False + req_ctrl_power_prev = self.persist['ctrl_power'] # requested control power in previous step + + self.I_min = self.wallbox.round_current(self.I_min) + self.sysstatus['pvcontrol'] = self._getPVControl() if self.persist['saved'].year > 1970: delta_t = (self.currTime - self.persist['saved']).total_seconds()/60 if delta_t > 10: self._initPersist() # file is older than 10 minutes, re-inialize @@ -161,7 +182,9 @@ def runControl(self, _pvstatus = None, _pvforecast = None, _carstatus = None): self.ctrlstatus['calcSOC'] = self.persist['calcSOC'] self._getClearsky() # determine clearsky parameters - self._getI_charge(ctrl_power) # calculate WB charge current + if self.chargeStart < datetime.now().timestamp()*1000: + self.chargeNow = True + self._getI_charge(ctrl_power, req_ctrl_power_prev) # calculate WB charge current fastcharge = self._manageBatCharge(ctrl_power) # calculate max. charge battery power self.ctrlstatus['I_charge'] = self.I_charge @@ -172,13 +195,22 @@ def runControl(self, _pvstatus = None, _pvforecast = None, _carstatus = None): if active: # actively controll wallbox self._logInflux() - self.wallbox.controlWB(self.I_charge) + if self.I_max > 0: # don't control wallbox if I_max == 0 + self.wallbox.controlWB(self.I_charge) if self.inverter is not None: - self.inverter.setBatCharge(fastcharge, self.feedInLimit, self.maxBatCharge, self.maxSOC) + self.inverter.setBatCharge(fastcharge, self.inhibitDischarge, self.feedInLimit, self.maxBatCharge, self.maxSOC, self.minSOC) del self.inverter - self.ctrlstatus['ctrl_power'] = self._I_to_P(self.I_charge) - self.ctrlstatus['max_soc'] = self.maxSOC - return(self.ctrlstatus) + self.ctrlstatus['ctrl_power'] = self._I_to_P(self.I_charge) + self.ctrlstatus['max_soc'] = self.maxSOC + self.ctrlstatus['inhibitDischarge'] = self.inhibitDischarge + self.ctrlstatus['batMinSoc'] = self.batMinSOC + if self.chargeNow: self.ctrlstatus['chargeNow'] = 1 # GUI wants an integer + else: self.ctrlstatus['chargeNow'] = 0 + + self.sysstatus['ctrlstatus'] = self.ctrlstatus + self.sysstatus['pvstatus'] = self.pvstatus.to_dict() + self.sysstatus['wbstatus'] = self.wallbox.status + return(self.sysstatus) def __del__ (self): """ @@ -193,8 +225,6 @@ def _initPersist(self): re-creates self.pickle from scratch, in case pickle serialization file was not found or older than 10min. """ print("pvcontrol: file pvcontrol.pickle recreated") - if self.pvstatus is None: startSOC = 0 - else: startSOC = self.pvstatus.soc t = datetime(1970, 1, 1, 0, 0, tzinfo=pytz.utc) self.persist = { 'saved' : t, # time stamp of persistent data 'ctrl_power' : 0, # power delivered to controller in prior step (for sim, as fall-back) @@ -229,7 +259,7 @@ def _getClearsky(self): print('power_limit_ends for ' + str(self.currTime.date()) + ': ' + str(overflow_end)) return() - def _getI_charge(self, ctrl_power): + def _getI_charge(self, ctrl_power, req_ctrl_power_prev = None): """ Determine current for EV excess charging. This method is the core of the smart PV excess charging algorithm @@ -237,19 +267,27 @@ def _getI_charge(self, ctrl_power): ---------- ctrl_power : float power currently delivered by wallbox (based on calculations from previous time stamp) + req_ctrl_power_prev : float + requested control power in previous step; normally that should be ctrl_power, but if EV + is unable to consume all requested power, this maybe smaller. """ - if self.wallbox.status['connected']: + if req_ctrl_power_prev is None: + req_ctrl_power_prev = ctrl_power + if self.chargeNow and self.wallbox.status['connected']: I_prev = self._P_to_I(ctrl_power) # what we have been charging so far + I_prev_req = self._P_to_I(req_ctrl_power_prev) if abs(self.I_min - I_prev) < 0.1: # we suffer from rounding errors I_prev = self.I_min + if abs(I_prev_req - I_prev) < 0.1: + I_prev_req = I_prev avail_P = self.pvstatus.dc_power*self.InverterEff - self.pvstatus.home_consumption + ctrl_power if avail_P < 0: avail_P = 0 # negative: no PV power available at all I_maxPV = self._P_to_I(avail_P) I_missing = 0 if ctrl_power > 0 and I_maxPV < self.I_min: # if we can supply that much power, we are mid-way between previous and min I_missing = (I_prev + self.I_min)/2 - I_maxPV - if ctrl_power == 0 and I_maxPV + self.I_gridMax > self.I_min: # try to harvest battery and grid - I_missing = self.I_min - I_maxPV + if ctrl_power == 0 and I_maxPV + self.I_gridMax >= self.I_min: # try to harvest battery and grid + I_missing = self.I_min - I_maxPV # ... at least this much we need find if I_missing > 0: I_bat = self._maxFromBat(self.coeff_A) # current we can supply, using Coeff_A if I_missing > I_bat: # we don't want provide so much from the battery @@ -257,17 +295,26 @@ def _getI_charge(self, ctrl_power): I_bat = self._maxFromBat(self.coeff_B) # max. avail current based on coeff. b1, b2 to sustain I_min if I_missing > I_bat + self.I_gridMax: # we can't supply from battery alone, or battery plus grid allowence I_missing = 0 + elif I_missing <= self.I_gridMax: # if grid allowence itself is sufficient + self.inhibitDischarge = True # don't use battery + elif I_missing <= self.I_gridMax: + self.inhibitDischarge = True + if self.inhibitDischarge and self.I_gridMax - I_maxPV > I_missing: + I_missing = self.I_gridMax # ok., let's use all grid power we can (but limit below to self.I_max) I = math.floor(self.I_min - self.I_gridMax) # will not be able to charge anymore without battery if I in self.persist['endcharge']: t = self.persist['endcharge'][I] if self.currTime.time() > t: I_missing = 0 I_charge = I_maxPV + I_missing # how much we want supply - this may include some grid power - if I_prev > 0 and I_charge > I_prev: I_charge = I_prev # .. this should only be due to rounding errors + if I_prev > 0 and I_charge > I_prev and not self.inhibitDischarge: # we have something missing (not feeding from grid only), still increase I_charge? + I_charge = I_prev # .. this should only be due to rounding errors else: I_charge = I_maxPV I_charge = self.wallbox.round_current(I_charge) # HardyBarth rounds down to full amps - if I_charge < self.I_min: I_charge = 0 # we are below the limit which WB can deliver for charging + if I_charge < self.I_min: + if I_prev < I_prev_req: I_charge = self.I_min # we requested more than was consumed ... + else: I_charge = 0 # we are below the limit which WB can deliver for charging if I_charge > self.I_max: I_charge = self.I_max # we can't charge with more current than this self.I_charge = I_charge else: @@ -310,20 +357,28 @@ def _manageBatCharge(self, ctrl_power): need = 0 have = 0 - if need > have/self.coeff_C[0]: # oops - we should start focusing on battery now - fastcharge = True - self.I_charge = self.I_charge - self._P_to_I(self.maxBatCharge) # stop charging car - if self.I_charge < self.I_min: self.I_charge = 0 - elif self.wallbox.status['connected'] and not self.wallbox.status['charge_completed']: # planning to / ongoing car charge - all surplus goes to battery - fastcharge = True - if need < have/self.coeff_C[1] and self.currTime.time() < self.persist['overflow_end']: # don't charge full yet, whilst charging car - self.maxSOC = self.maxSOCCharge - elif need > have/self.coeff_C[1] and self.currTime.time() < self.persist['overflow_end']: # still early, but not that much more energy left ... - fastcharge = True - elif self.currTime.time() > self.persist['overflow_end']: # afternoon - charge battery now without further condition - fastcharge = True - self.ctrlstatus['need'] = need - self.ctrlstatus['have'] = have + if self.wallbox.status['connected'] and self.wallbox.status['charge_completed']: + self.inhibitDischarge = False + + if self.pvstatus.dc_power > self.feedInLimit/50: # we have some PV power ... + if need > have/self.coeff_C[0] and self.inhibitDischarge == False: # oops - we should start focusing on battery now (unless grid charging is on) + fastcharge = True + self.I_charge = self.I_charge - self._P_to_I(self.maxBatCharge) # stop charging car + if self.I_charge < self.I_min: self.I_charge = 0 + elif self.minSOCCharge > self.pvstatus.soc: # enforce fastcharge to minSOCCharge + fastcharge = True + self.I_charge = 0 # focus on bringing battery to minSOCCharge + elif self.wallbox.status['connected'] and not self.wallbox.status['charge_completed']: # planning to / ongoing car charge - all surplus goes to battery + fastcharge = True + if need < have/self.coeff_C[1] and self.currTime.time() < self.persist['overflow_end']: # don't charge full yet, whilst charging car + self.maxSOC = self.maxSOCCharge + elif need > have/self.coeff_C[1] and self.currTime.time() < self.persist['overflow_end']: # still early, but not that much more energy left ... + fastcharge = True + elif self.currTime.time() > self.persist['overflow_end']: # afternoon - charge battery now without further condition + fastcharge = True + else: fastcharge = True # ... if we are here, we probably won't load battery anyway, but we may at least try ... + self.ctrlstatus['need'] = need + self.ctrlstatus['have'] = have if need > 0: self.ctrlstatus['bat_forecast'] = have/need else: self.ctrlstatus['bat_forecast'] = 1 return(fastcharge) @@ -350,15 +405,17 @@ def _maxFromBat(self, coeff): I_batMax = self._P_to_I(self.maxBatDischarge) a = I_batMax/(coeff[1]-coeff[0]) b = -a*coeff[0] - if self.pvstatus.soc > self.minSOC: + if self.pvstatus.soc > self.minSOCCharge: I_bat = self.pvstatus.soc*a+b # max. avail current based on coeff. a1, a2 to slowly reduce charing + if I_bat < 0: I_bat = 0 else: I_bat = 0 return(I_bat) def _logInflux(self): """ - Log controller information to Influx. Three measurements are created: + Log controller information to Influx. Three measurements are created (below). + It also sets a corresponding JSON structure in self.sysstatus Measurements ------------ @@ -383,23 +440,44 @@ def _logInflux(self): host = self.config['PVControl'].get('host', None) if host is not None: try: - port = self.config['PVControl'].getint('port', 8086) - database = self.config['PVControl'].get('database') - client = DataFrameClient(host=host, port=port, database=database) - df = pd.DataFrame(self.wallbox.status, index = [self.currTime]) - df.drop(['I_min', 'I_max'], axis=1, inplace=True) - for field in df: - df.loc[:,field] = df[field].astype(float) - client.write_points(df, 'wbstatus') - - df = pd.DataFrame(self.pvstatus.to_frame().transpose()) - for field in df: - df.loc[:,field] = df[field].astype(float) - client.write_points(df, 'pvstatus') - - df = pd.DataFrame(self.ctrlstatus, index = [self.currTime]) - for field in df: - df.loc[:,field] = df[field].astype(float) - client.write_points(df, 'ctrlstatus') + inhibit = self.config['PVControl'].getboolean('inhibitInflux', False) # inhibit writing to Influx DB + if not inhibit: + port = self.config['PVControl'].getint('port', 8086) + database = self.config['PVControl'].get('database') + client = DataFrameClient(host=host, port=port, database=database) + + df = pd.DataFrame(self.wallbox.status, index = [self.currTime]) + df.drop(['I_min', 'I_max'], axis=1, inplace=True) + for field in df: + df.loc[:,field] = df[field].astype(float) + client.write_points(df, 'wbstatus') + + df = pd.DataFrame(self.pvstatus.to_frame().transpose()) + df.drop(['minSoc'], axis=1, inplace=True) + for field in df: + df.loc[:,field] = df[field].astype(float) + client.write_points(df, 'pvstatus') + + df = pd.DataFrame(self.ctrlstatus, index = [self.currTime]) + for field in df: + df.loc[:,field] = df[field].astype(float) + client.write_points(df, 'ctrlstatus') + pass except Exception as e: print('pvcontrol._logInflux: ' + str(e)) + + def _getPVControl(self): + """ + get PVControl settings for sysstatus (as later used by GUI) + """ + + pvcontrol = { "I_min" : self.I_min, + "I_max" : self.I_max, + "I_gridMax" : self.I_gridMax, + + "minSOC" : self.minSOC, + "minSOCCharge" : self.minSOCCharge, + "maxSOCCharge" : self.maxSOCCharge, + "maxSOC" : self.maxSOC + } + return pvcontrol \ No newline at end of file diff --git a/PVControl/pvmonitor/kostal.py b/PVControl/pvmonitor/kostal.py index 62c8fbb..54b8e56 100644 --- a/PVControl/pvmonitor/kostal.py +++ b/PVControl/pvmonitor/kostal.py @@ -105,14 +105,14 @@ def randomString(stringLength): e2, authtag = e2.encrypt_and_digest(token.encode('utf-8')) step3 = { "transactionId" : transID, - "iv" : base64.b64encode(t).decode('utf-8'), - "tag" : base64.b64encode(authtag).decode("utf-8"), - "payload" : base64.b64encode(e2).decode('utf-8') } + "iv" : base64.b64encode(t).decode('utf-8'), + "tag" : base64.b64encode(authtag).decode("utf-8"), + "payload" : base64.b64encode(e2).decode('utf-8') } response = self._postData("/auth/create_session", step3) self.headers['authorization'] = "Session " + response['sessionId'] except Exception as e: - print ("Kostal._LogMeIn: ERROR --- unable to login" + str(e)) + print ("Kostal._LogMeIn: ERROR --- unable to login: " + str(e)) sys.exit(1) return() @@ -121,7 +121,7 @@ def _LogMeOut(self): self._postData("/auth/logout") self.headers.pop('authorization', None) except Exception as e: - print ("Kostal._LogMeOut: ERROR --- unable to logout" + str(e)) + print ("Kostal._LogMeOut: ERROR --- unable to logout: " + str(e)) sys.exit(1) def _getData(self, endpoint): @@ -130,7 +130,7 @@ def _getData(self, endpoint): e = e.replace(',', '%2C') r = requests.get(url = self._base_url + e, headers = self.headers) if r.reason != 'OK': - raise Exception("ERROR --- request to endpoint=" + endpoint + " --- Reason: " + r.reason) + raise Exception("request to endpoint=" + endpoint + " --- Reason: " + r.reason) return(r.json()) except Exception as e: print("Kostal._getData: " + str(e)) @@ -142,7 +142,7 @@ def _postData(self, endpoint, data = None, isPut = False): if isPut: r = requests.put (url = self._base_url + e, json = data, headers = self.headers) else: r = requests.post(url = self._base_url + e, json = data, headers = self.headers) if r.reason != 'OK': - raise Exception("ERROR --- request to endpoint=" + endpoint + " --- Reason: " + r.reason) + raise Exception("request to endpoint=" + endpoint + "; data=" + str(data) + " --- Reason: " + r.reason) return(r.json()) except Exception as e: print("ERROR -- Kostal._postData: " + str(e)) @@ -183,19 +183,22 @@ def getStatus(self): data = self._getData('/processdata/devices:local:battery/P,SoC,LimitEvuAbs')[0]['processdata'] status['bat_power'] = [elem['value'] for elem in data if elem['id'] == 'P'][0] status['soc'] = [elem['value'] for elem in data if elem['id'] == 'SoC'][0]/100 - data = self._getData('/settings/devices:local/Battery:ExternControl:MaxChargePowerAbs,Battery:ExternControl:MaxSocRel,Battery:SmartBatteryControl:Enable') + data = self._getData('/settings/devices:local/Battery:ExternControl:MaxChargePowerAbs,Battery:ExternControl:MaxSocRel,Battery:SmartBatteryControl:Enable,Battery:MinSoc') status['max_bat_charge'] = float([elem['value'] for elem in data if elem['id'] == 'Battery:ExternControl:MaxChargePowerAbs'][0]) # strangely, returns string status['max_soc'] = float([elem['value'] for elem in data if elem['id'] == 'Battery:ExternControl:MaxSocRel'][0])/100 # strangely, returns string status['smart_bat_ctrl'] = int([elem['value'] for elem in data if elem['id'] == 'Battery:SmartBatteryControl:Enable'][0]) # strangely, returns string + status['minSoc'] = int([elem['value'] for elem in data if elem['id'] == 'Battery:MinSoc'][0])/100 # strangely, returns string status = pd.Series(status, name = pd.Timestamp.utcnow()) self.status = status return(status) - def setBatCharge(self, fastcharge, feedinLimit, maxChargeLim, maxSoc = 1): + def setBatCharge(self, fastcharge, inhibitDischarge, feedinLimit, maxChargeLim, maxSoc = 1, minSoc = 0.05): try: max_charge = None if self.status is not None: + if self.status['minSoc'] != minSoc: + self._setSetting("Battery:MinSoc", int(minSoc*100)) # set minSOC (only if changed) - must be int if self.status['dc_power'] > 50: # only set battery charge strategy if we have any dc_power if fastcharge: if self.status['smart_bat_ctrl']: @@ -217,6 +220,8 @@ def setBatCharge(self, fastcharge, feedinLimit, maxChargeLim, maxSoc = 1): self._setSetting("Battery:ExternControl:MaxSocRel", maxSoc*100) if max_charge is None and self.status['max_bat_charge'] < maxChargeLim * 0.9: self._setSetting("Battery:ExternControl:MaxChargePowerAbs", maxChargeLim*1.05) + if inhibitDischarge: + self._setSetting("Battery:ExternControl:MaxDischargePowerAbs", 0) else: raise Exception ("ERROR - Kostal status not initialized") except Exception as e: diff --git a/PVControl/pvserver.py b/PVControl/pvserver.py index b6c7c4c..35cbb14 100644 --- a/PVControl/pvserver.py +++ b/PVControl/pvserver.py @@ -90,7 +90,7 @@ def __init__(self, config): h, m = connectTime.split(':') self._connectTime = time(int(h), int(m)) else: self._connectTime = None - self.chargePower = self.config['PVServer'].getint('chargePower', 16000) # how much we want to charge + self.chargePower = self.config['PVServer'].getint('chargePower', 16000) # how much we want to charge (Wh) self.storePNG = self.config['PVServer'].getboolean('storePNG', False) @@ -177,10 +177,11 @@ def runController(self): pvforecast = forecastObj.getForecast(currTime) ctrl = controlObj.runControl(pvstatus, pvforecast, carstatus) # ---------- run controller + ctrl = ctrl['ctrlstatus'] # we don't need 'pvstatus', 'wbstatus' in simulation if 'ctrl_power' not in ctrl: # controller needs some power: ctrl_power raise Exception("ERROR --- controller " + controlObj.Name + " does not appear to control any power") if ctrl['ctrl_power'] < 0: - raise Exception("ERROR --- controller " + controlObj.Name + " can only consume, not produce power; " + self.currTime) + raise Exception("ERROR --- controller " + controlObj.Name + " can only consume, not produce power; " + str(self.currTime)) if 'dc_power' not in ctrl: ctrl['dc_power'] = pvstatus.dc_power # a controller might potentially re-calculate these elements if 'home_consumption' not in ctrl: ctrl['home_consumption'] = pvstatus.home_consumption - prevCtrlPower @@ -217,7 +218,7 @@ def runController(self): ctrl['bat_power'] = bat_power else: # PV provides insufficient power - if ctrl['soc'] > ctrl['min_soc']: # battery can serve excess power needed ---- add: only if battery discharging enabled + if ctrl['soc'] > ctrl['min_soc'] and not ctrl['inhibitDischarge']: # battery can serve excess power needed ---- add: only if battery discharging enabled ctrl['bat_power'] = -(ctrl['ctrl_power'] + ctrl['home_consumption'] - ctrl['dc_power']) # (discharge: <0) if (abs(ctrl['bat_power']) > self.maxBatDischarge): # consumption exceeding maximum battery discharge power ctrl['grid_power'] = abs(ctrl['bat_power']) - self.maxBatDischarge # (consume: >0) @@ -262,7 +263,7 @@ def plot(self, hasCtrl): create a simulation result with one row per simulated day. """ - maxY = self.config['PVServer'].get('maxY', 10000) * 1.05 + maxY = self.config['PVServer'].getfloat('maxY', 10000) * 1.05 if (hasCtrl): fig, axes = plt.subplots(2, 2, sharex=True, sharey=False, figsize=(20, 11)) else: @@ -308,7 +309,7 @@ def plot(self, hasCtrl): axes[1][1].legend(loc='best') summary = self.ctrlData.sum(axis=0)/60 - summary = summary.drop(labels = ['soc', 'max_soc', 'min_soc', 'bat_forecast', 'fastcharge']) + summary = summary.drop(labels = ['calcSOC', 'soc', 'max_soc', 'min_soc', 'need', 'have', 'bat_forecast', 'I_charge', 'batMinSoc', 'chargeNow', 'fastcharge', 'inhibitDischarge']) summary.name = self.day else: summary = None diff --git a/PVControl/wallbox/hardybarth.py b/PVControl/wallbox/hardybarth.py index 0e0f2ef..313cee4 100644 --- a/PVControl/wallbox/hardybarth.py +++ b/PVControl/wallbox/hardybarth.py @@ -36,8 +36,8 @@ def __init__(self, config): self.id = self.config['HardyBarth'].get('id', 1) self.verbose = self.config['HardyBarth'].getboolean('verbose', False) self.inhibitWrite = self.config['HardyBarth'].getboolean('inhibitWrite', False) - host = self.config['HardyBarth'].get('host') # wallbox address - self.url = 'http://' + host + '/api/v1/' + host = self.config['HardyBarth'].get('host') # wallbox address + self.url = 'http://' + host + '/api/v1/' def readWB(self, charge_completed = False): """ @@ -98,14 +98,14 @@ def controlWB(self, I_new): else: if self.status['modeid'] != 3: # manual self._request(True, f'pvmode', { 'pvmode': 'manual' }) + if self.status['stateid'] != 5 and self.status['stateid'] != 4: # 5: charging / 4: enabled, waiting + self._request(True, f'chargecontrols/{id}/start') if self.status['manualmodeamp'] != I_new: self._request(True, f'pvmode/manual/ampere', { 'manualmodeamp': I_new }) - if self.status['stateid'] != 5 and self.status['stateid'] != 4: # charging / enabled, waiting - self._request(True, f'chargecontrols/{id}/start') else: if self.status['manualmodeamp'] > self.status['I_min']: self._request(True, f'pvmode/manual/ampere', { 'manualmodeamp': self.status['I_min'] }) - if self.status['stateid'] != 17 and self.status['stateid'] != 4: # disabled / enabled, waiting + if self.status['stateid'] != 17 and self.status['stateid'] != 4: # 17: disabled / 4: enabled, waiting self._request(True, f'chargecontrols/{id}/stop') return() diff --git a/PVControl/wallbox/wbtemplate.py b/PVControl/wallbox/wbtemplate.py index 523dcdf..bea9a08 100644 --- a/PVControl/wallbox/wbtemplate.py +++ b/PVControl/wallbox/wbtemplate.py @@ -45,7 +45,7 @@ def controlWB(self): class DummyWB(WBTemplate): def __init__(self, config = None): - self.status = [] + self.status = { 'I_min': 6, 'I_max': 16 } def readWB(self): pass diff --git a/PVOptimize.py b/PVOptimize.py index 72f5ca6..08c3cb5 100644 --- a/PVOptimize.py +++ b/PVOptimize.py @@ -25,6 +25,7 @@ import sys import argparse import time +import json from datetime import datetime, timezone from PVControl.pvcontrol import PVControl @@ -38,10 +39,22 @@ def get_script_path(): args = cfgParser.parse_args() if args.cfg: cfgFile = args.cfg else: cfgFile = 'config.ini' + cfgFile = get_script_path() + '/' + cfgFile try: myConfig = configparser.ConfigParser(inline_comment_prefixes='#', empty_lines_in_values=False) - myConfig.read(get_script_path() + '/' + cfgFile) + if not os.path.isfile(cfgFile): raise Exception (cfgFile + ' does not exist') + myConfig.read(cfgFile) + path = None + if myConfig['PVControl'].getboolean('enableGUI', False): + I_max = myConfig['PVControl'].getfloat('I_max', None) + path = myConfig['PVControl'].get('guiPath', '~/.node-red/projects/PVControl') + path = os.path.expanduser(path) + cfgFile = path + '/gui_config.ini' + if not os.path.isfile(cfgFile): raise Exception (cfgFile + ' does not exist') + myConfig.read(cfgFile) + if I_max == 0: # preserve I_max if it was zero. This disables WB controll + myConfig.set('PVControl', 'I_max', '0') except Exception as e: print('Error reading config file ' + cfgFile + ': ' + str(e)) sys.exit(1) @@ -50,5 +63,9 @@ def get_script_path(): print("-- " + str(datetime.now(timezone.utc))) myPVControl = PVControl(myConfig) time.sleep(runDelay) - myPVControl.runControl() + sysstatus = myPVControl.runControl() + if path is not None: # write out GUI control file + json_file = open(path + '/pvstatus.json', 'w') + json.dump(sysstatus, json_file, indent=4) + json_file.close() del myPVControl \ No newline at end of file diff --git a/PVSim.py b/PVSim.py index c423393..fd37aa6 100644 --- a/PVSim.py +++ b/PVSim.py @@ -37,10 +37,12 @@ def get_script_path(): args = cfgParser.parse_args() if args.cfg: cfgFile = args.cfg else: cfgFile = 'config.ini' + cfgFile = get_script_path() + '/' + cfgFile try: myConfig = configparser.ConfigParser(inline_comment_prefixes='#', empty_lines_in_values=False) - myConfig.read(get_script_path() + '/' + cfgFile) + if not os.path.isfile(cfgFile): raise Exception (cfgFile + ' does not exist') + myConfig.read(cfgFile) except Exception as e: print('Error reading config file ' + cfgFile + ': ' + str(e)) sys.exit(1) diff --git a/Readme.md b/Readme.md index 674ddca..d965f36 100644 --- a/Readme.md +++ b/Readme.md @@ -9,6 +9,8 @@ Current implemented functionality includes: * smart home battery charging with excess energy during PV peak production, avoiding grid feed-in limits of network provider. This is implemented for [Kostal Plenticore](https://www.kostal-solar-electric.com/en-gb/products/hybrid-inverter/plenticore-plus) inverters. * control the above based on PV output forecasts, as generated with the sister project [PVForecast](https://stefae.github.io/PVForecast/) +The behavior of the controller is configured in a `config.ini` file, or can be controled from a GUI dashboard of the sister project [PVControl](https://github.com/StefaE/PVControl). + The controller algorithm can be simulated based on historic PV data, as stored by data loggers such as eg. [Solaranzeige](https://solaranzeige.de/phpBB3/solaranzeige.php). This allows to understand, debug and optimize control algorithms. Once one is happy with the algorithm, it can obviously be applied to the supported hardware. In that usage scenario, the controller is typically called from a crontab entry on a Raspberry Pi. It is very likely that a user of this project wants to do adaptions and modifications according to his needs, either to the algorithms or supported hardware. Hence, the documentation focuses on the software structure more than trying to be a simple users guide. The software is structured such that different hardware components can easily be added. At this moment, the above mentioned wallbox and (single) inverter are the only ones supported. Python knowledge will be required for adaptions. @@ -18,6 +20,7 @@ This file mainly focuses on the software structure, whereas a functional overvie **Note: all time stamps used in this project are UTC** Improvements are welcome - please use *Issues* and *Discussions* in Git. + ------------- ## Table of Content * [Introduction](#introduction) @@ -34,6 +37,7 @@ Improvements are welcome - please use *Issues* and *Discussions* in Git. * [Influx database](#influx-database) * [Installation](#installation) * [To Do](#to-do) + * [Version History](#version-history) * [Acknowlegements](#acknowlegements) * [Disclaimer](#disclaimer) * [License](#license) @@ -165,6 +169,7 @@ They are called from the factories `PVMonitorFactory` and `WallBoxFactory` respe ## Influx database In active mode, an Influx database is used to log performance of the controller. Three `measurements` are created: + | Measurement | Purpose | Documentation | |-------------|---------|---------------| | `ctrlstatus` | Status of controller - all fields are calculated in class `PVControl` | `PVControl._logInflux()` | @@ -185,12 +190,16 @@ Note that the project assumes that UTC midnight is in the dark hours of the day means that the project might not work immediatly as expected in the Americas or Asia. ## To Do -* currently, only excess car charging is supported - this is obviously insufficient when overnight charging is required, or in winter. Adequate control needs be provided: - + if I_gridMax > 0, discharging battery must be limited to I_bat (currently, with I_gridMax > 0, battery is discharged until minSOC) - + charging is currently terminated if remaining PV power no longer can charge battery to maxSOC, which doesn't make sense in winter - + ... +* it maybe desireable to have the option to charge EV from battery (example: evening charging in summer, when it is known that battery will not be fully used for night consumption) +* day-ahead planning of `minSOC` and `maxSOC`, based on [PVForecast](https://stefae.github.io/PVForecast/#forcast-horizon) data * `ZOE mode`: At low charge currents, _Renault Zoe_ has large reactive power (cos-phi < 0.5). `I_min` (and possibly `I_max`) should then automatically be increased -* GUI for easy control + +## Version History + +| Version |Date | Comment | +|---------|-----|---------| +| 1.0 | June 2021 | Initial release | +| 2.0 | January 2022 | Support for winter charging (`PVControl.I_gridMax`), communication with GUI (see project [PVControl](https://github.com/StefaE/PVControl)) | ## Acknowlegements Login procedure Kostal._LogMeIn() is derieved from [kilianknoll](https://github.com/kilianknoll/kostal-RESTAPI) diff --git a/config.ini b/config.ini index 3d7f58d..7b47a43 100644 --- a/config.ini +++ b/config.ini @@ -1,90 +1,103 @@ # Outcommented values show defaults -[PVControl] # -------------------------- Controller settings (for both simulation and active mode) - run = 1 # simulation mode: run wallbox charging simulation (else, only actual data are dumped to output) - # active mode: delay [s] from call, until controller is run, to avoid congestion eg. at inverter - - I_min = 6 # minimal charge current we want to start charging - I_gridMax = 0 # maximum grid contribution to wallbox charging (not fully implemented yet) - phases = 3 # number of phases available to Wallbox (default 3, could be or 1) - feedInLimit = 6825 # feed-in limit (due to network provider limitations, default 99999) [W] - maxSOC = 0.9 # max. SOC to which we want charge battery (0 .. 1, default 1) - maxSOCCharge = 0.7 # max. SOC when car is connected, but not fully charged yet - - host = raspi4 # controller writes to this Influx host ... - # port = 8086 # ... Influx port - database = hardy # ... and database - # wallbox = HardyBarth # wallbox provider (currently only HardyBarth) - # pvmonitor = Kostal # PVMontior provider (could be: SolarAnzeige) - -[HardyBarth] # -------------------------- Hardy Barth Wallbox Provider configuration - inhibitWrite = 1 # inhibit writes to wallbox - for debugging, default False - verbose = 1 # create verbose output - for debugging, default False - host = # IP of wallbox - -[Kostal] # -------------------------- Kostal PVMonitor provider configuration +[PVControl] # -------------------------- Controller settings (for both simulation and active mode) + run = 20 # simulation mode: run wallbox charging simulation (else, only actual data are dumped to output) + # active mode: delay [s] from call, until controller is run, to avoid congestion eg. at inverter + # when running eg. solaranzeige at start of each minute (through crontab, etc.) + + I_min = 6 # minimal charge current we want to start charging + I_max = 16 # 0 will disable wallbox control + I_gridMax = 0 # maximum grid contribution to wallbox charging + # phases = 3 # number of phases available to Wallbox (default 3, could be or 1) + feedInLimit = 6825 # feed-in limit (due to network provider limitations, default 99999) [W] + minSOC = 0.1 # fast-charge battery from PV to this level if below (0 .. 1, default 0.05) + maxSOC = 0.9 # max. SOC to which we want charge battery (0 .. 1, default 1) + maxSOCCharge = 0.7 # max. SOC when car is connected, but not fully charged yet (0 .. 1, default maxSOC) + minSOCCharge = 0.1 # fast-charge battery from PV to this level if below (0 .. 1, default minSOC) + + host = # controller writes to this Influx host ... + # port = 8086 # ... Influx port + database = hardy # ... and database + # inhibitInflux = 0 # inhibit writing to Influx DB, default False + # wallbox = HardyBarth # wallbox provider (currently: only HardyBarth (or 'dummy' if only homebattery charge control needed)) + # pvmonitor = Kostal # PVMontior provider (could be: SolarAnzeige) + + # enableGUI = 0 # enable communication with GUI (project PVControl), default False + # guiPath = ~/.node-red/projects/PVControl # path to GUI home directory + +[HardyBarth] # -------------------------- Hardy Barth Wallbox Provider configuration + # inhibitWrite = 0 # inhibit writes to wallbox - for debugging, default False + # verbose = 1 # create verbose output - for debugging, default False + I_chargeMin = 6 # minimum possible charge current + I_chargeMax = 16 # maximum possible charge current + host = # IP of wallbox + +[Kostal] # -------------------------- Kostal PVMonitor provider configuration host = passwd = - inhibitWrite = 1 # inhibit writes to inverter - for debugging, default False - verbose = 1 # create verbose output - for debugging, default False - -[SolarAnzeige] # -------------------------- SolarAnzeige PVMonitor provider configuration / used for simulation - host = # Solaranzeige host ... - # port = 8086 # ... Influx port - database = solaranzeige # ... and database - -[PVStorage] # -------------------------- home battery storage system configuration - batCapacity = 7680 # battery capacity [Wh] - minSOC = 0.05 # don't discharge battery below this value(0 .. 1, default 0.05) - maxBatDischarge = 3990 # max battery discharge power [W] - # maxBatCharge = 3990 # max battery charge power [W], default: maxBatDischarge/0.97 (efficiency), - # default assumes DC coupled battery - -[PVForecast] # -------------------------- PV forecast provider + # inhibitWrite = 0 # inhibit writes to inverter - for debugging, default False + # verbose = 0 # create verbose output - for debugging, default False + +[SolarAnzeige] # -------------------------- SolarAnzeige PVMonitor provider configuration / used for simulation + host = # Solaranzeige host ... + # port = 8086 # ... Influx port + database = solaranzeige # ... and database + +[PVStorage] # -------------------------- home battery storage system configuration + batCapacity = 7680 # battery capacity [Wh] + # minSOC = 0.05 # don't discharge battery below this value(0 .. 1, default 0.05) + maxBatDischarge = 3990 # max battery discharge power [W] + # maxBatCharge = 3990 # max battery charge power [W], default: maxBatDischarge/0.97 (efficiency), + # default assumes DC coupled battery + +[PVForecast] # -------------------------- PV forecast provider host = - # port = 8086 # ... Influx port - database = forecast # ... and database - forecastField = solcast.pv_estimate # . containing forecast estimation - # useForecast = 1 # use forecast yes/no + # port = 8086 # ... Influx port + database = forecast # ... and database + forecastField = solcast.pv_estimate # . containing forecast estimation + # useForecast = 1 # use forecast yes/no -[PVSystem] # -------------------------- simulates PV system (panels, inverters), using pvlib; only used for clearsky performance +[PVSystem] # -------------------------- simulates PV system (panels, inverters), using pvlib; only used for clearsky performance Latitude = Longitude = - # Altitude = 0 # altidude of system (above sea level) + # Altitude = 0 # altidude of system (above sea level) - Tilt = 30 # tilt and orientation of solar panels - Azimuth = 127 # 270=West, 180=South, 90=East + # ---------------------------------------------------- following configurations are only used for clearsky limit calculation through PVServer + # some more control options are defined in pvmodel.py, but they are not relevant for + # the application here + Tilt = 30 # tilt and orientation of solar panels + Azimuth = 127 # 270=West, 180=South, 90=East - # Model = CEC # modeling strategy for PV: 'CEC' or 'PVWatts' - # TemperatureModel = open_rack_glass_glass # https://pvlib-python.readthedocs.io/en/stable/generated/pvlib.temperature.sapm_cell.html - # clearsky_model = simplified_solis # ineichen or simplified_solis (haurwitz not supported) + # Model = CEC # modeling strategy for PV: 'CEC' or 'PVWatts' + # TemperatureModel = open_rack_glass_glass # https://pvlib-python.readthedocs.io/en/stable/generated/pvlib.temperature.sapm_cell.html + # clearsky_model = simplified_solis # ineichen or simplified_solis (haurwitz not supported) - # next four lines only needed if Model = CEC (default), see .csv files at - # ~/.local/lib/python3.8/site-packages/pvlib/data for allowed names, replace special characters with '_' + # next four lines only needed if Model = CEC (default), see .csv files at + # ~/.local/lib/python3.8/site-packages/pvlib/data for allowed names, replace special characters with '_' ModuleName = LG_Electronics_Inc__LG325N1W_V5 InverterName = SMA_America__SB10000TL_US__240V_ - NumStrings = 2 # number of strings - NumPanels = 15 # number of panels per string - - # next four lines only needed if Model = PVWatts - InverterPower = 10000 # name-plate inverter max. power - NominalEfficiency = 0.965 # nominal European inverter efficiency - SystemPower = 9750 # system power [Wp] - TemperatureCoeff = -0.0036 # temperature coefficient (efficiency loss per 1C) - -[PVServer] # -------------------------- # Simulator configuration - startDate = 2021-01-08 # start date of simulation - endDate = 2021-01-08 # end date of simulation (default: startDate - simulate one day only) - connectTime = 10:00 # time when EV is connected to wallbox - chargePower = 13000 # total power we want to charge - # breakTime = 14:02 # allows to set a debugging break point in pvserver.py - - #startSOC = 0.05 # SOC at sunries - default: SOC from PVMonitor provider (SolarAnzeige) - maxConsumption = 4500 # limit home consumption to this (default: no limit = 99999) - baseConsumption = 350 # ... if limit is reached, replace with this - sigmaConsumption = 0 # ... +- this (sigma); zero = fixed value, which makes simulator output deterministic - feedInLimit = 6825 # limit grid feedin [W] (due to regulatory rules; default: no limit = 99999) - - storePath = ./temp/ # storage path for files generated; plot files will be .png - storePNG = 0 # store PNG files (instead of interactive display) - # maxY = 10000 # defines y-axis of plots + NumStrings = 2 # number of strings + NumPanels = 15 # number of panels per string + + # next four lines only needed if Model = PVWatts + InverterPower = 10000 # name-plate inverter max. power + NominalEfficiency = 0.965 # nominal European inverter efficiency + SystemPower = 9750 # system power [Wp] + TemperatureCoeff = -0.0036 # temperature coefficient (efficiency loss per 1C) + +[PVServer] # -------------------------- # Simulator configuration + startDate = 2021-01-08 # start date of simulation + endDate = 2021-01-08 # end date of simulation (default: startDate - simulate one day only) + connectTime = 10:00 # time when EV is connected to wallbox + chargePower = 13000 # total power we want to charge + # breakTime = 14:02 # allows to set a debugging break point in pvserver.py + + #startSOC = 0.05 # SOC at sunries - default: SOC from PVMonitor provider (SolarAnzeige) + maxConsumption = 4500 # limit home consumption to this (default: no limit = 99999) + baseConsumption = 350 # ... if limit is reached, replace with this + sigmaConsumption = 0 # ... +- this (sigma); zero = fixed value, which makes simulator output deterministic + feedInLimit = 6825 # limit grid feedin [W] (due to regulatory rules; default: no limit = 99999) + + storePath = ./temp/ # storage path for files generated; plot files will be .png + storePNG = 0 # store PNG files (instead of interactive display) + # maxY = 10000 # defines y-axis of plots diff --git a/docs/I_gridMax.png b/docs/I_gridMax.png new file mode 100644 index 0000000..a95e7b0 Binary files /dev/null and b/docs/I_gridMax.png differ diff --git a/docs/index.md b/docs/index.md index affecdc..dab3d6f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,6 +9,8 @@ Current implemented functionality includes: * smart home battery charging with excess energy during PV peak production, avoiding grid feed-in limits of network provider. This is implemented for [Kostal Plenticore](https://www.kostal-solar-electric.com/en-gb/products/hybrid-inverter/plenticore-plus) inverters. * control the above based on PV output forecasts, as generated with the sister project [PVForecast](https://stefae.github.io/PVForecast/) +The behavior of the controller is configured in a `config.ini` file, or can be controled from a GUI dashboard of the sister project [PVControl](https://github.com/StefaE/PVControl). + The controller algorithm can be simulated based on historic PV data, as stored by data loggers such as eg. [Solaranzeige](https://solaranzeige.de/phpBB3/solaranzeige.php). This allows to understand, debug and optimize control algorithms. Once one is happy with the algorithm, it can obviously be applied to the supported hardware. In that usage scenario, the controller is typically called from a crontab entry on a Raspberry Pi. It is very likely that a user of this project wants to do adaptions and modifications according to his needs, either to the algorithms or supported hardware. Hence, the documentation focuses on the software structure more than trying to be a simple users guide. The software is structured such that different hardware components can easily be added. At this moment, the above mentioned wallbox and (single) inverter are the only ones supported. Python knowledge will be required for adaptions. @@ -21,26 +23,104 @@ Improvements are welcome - please use *Issues* and *Discussions* in Git. ------------ ## Table of Content -- [Introduction](#introduction) -- [Table of Content](#table-of-content) -- [Examples of what the controller can do](#examples-of-what-the-controller-can-do) - - [EV charging example](#ev-charging-example) - - [Home battery charging example](#home-battery-charging-example) -- [What does the simulator do?](#what-does-the-simulator-do) - - [Overview](#overview) - - [A closer look at simulator output](#a-closer-look-at-simulator-output) - - [Battery charging forecast on a winter day](#battery-charging-forecast-on-a-winter-day) - - [Some more notes](#some-more-notes) -- [Disclaimer and License](#disclaimer-and-license) -- [License](#license) +- [PVOptimize](#pvoptimize) + - [Introduction](#introduction) + - [Table of Content](#table-of-content) + - [Concepts](#concepts) + - [Home battery management](#home-battery-management) + - [`minSOC` and `maxSOC`](#minsoc-and-maxsoc) + - [Grid feedin limitations](#grid-feedin-limitations) + - [Home battery and EV charging](#home-battery-and-ev-charging) + - [Forecast based home battery charging](#forecast-based-home-battery-charging) + - [Summary of battery charging control parameters](#summary-of-battery-charging-control-parameters) + - [EV charging management](#ev-charging-management) + - [Examples of what the controller can do](#examples-of-what-the-controller-can-do) + - [EV excess charging example](#ev-excess-charging-example) + - [Interplay of `I_gridMax`, `I_min` and `I_max`](#interplay-of-i_gridmax-i_min-and-i_max) + - [Home battery charging example](#home-battery-charging-example) + - [What does the simulator do?](#what-does-the-simulator-do) + - [Overview](#overview) + - [A closer look at simulator output](#a-closer-look-at-simulator-output) + - [Battery charging forecast on a winter day](#battery-charging-forecast-on-a-winter-day) + - [Some more notes](#some-more-notes) + - [Disclaimer and License](#disclaimer-and-license) + - [License](#license) Table of contents generated with markdown-toc ------------ +## Concepts + +### Home battery management +#### `minSOC` and `maxSOC` +During an annual cycle of observing the performance of a home battery, one observes the following issues: +* in summer, nights are short and battery typically fully charged at end of the day. As a result, discharging at night time with the background home consumption results in a morning SOC of maybe 60%. That is, the battery never gets fully discharged. +* in winter, nights are long and home consumption is larger (eg. due to circulation pumps of central heating, etc.). Sunlight is rare and fully charging the battery is often not possible. However, in the morning, it will regularly be fully discharged. + +Batteries neither like to be fully charged or discharged for an extended period of time. Hence, it is advantageous to define a `maxSOC` during summer which is <100% and a `minSOC` > 5% in winter (where 5% is the battery provider default for 'fully discharged' state). + +If, for example, in summer we define `maxSOC` = 80%, discharging will still only reach 40% in the morning - more than enough. Conversely, in winter, setting `minSOC` to eg. 20% still allows adding 80% charge, which is unlikely to be possible in cloudy winter days. + +This general rule is of course not valid during long rainy summer periods or long sunny winter periods. In a future verison of this project, longterm [PVForecast](https://stefae.github.io/PVForecast/) may implement an automatic management of `minSOC` and `maxSOC`, but this is currently not available yet. + +#### Grid feedin limitations +Regulatory requirements may limit grid feedin. In Germany for example, a rooftop PV installation basically may never feed in more than 70% of its nominal power. This leads to clamping solar production during peak hours in summer and hence energy waste. It is advantageous to charge a home battery with this excess power. + +This clamping of course can only happen around noon: in the morning and evening, <70% of nominal power will be generated even in full sun. `PVOptimize` knows from a clear-sky model (using [pvlib](https://pvlib-python.readthedocs.io/en/stable/)), during which periods of the day, in principle clamping is possible. + +Hence, it will try to delay charging until this period ends. If excess power is produced prior to that moment, the battery will be charged, but charge current will be limited to the excess power. + +#### Home battery and EV charging +We want to use PV power to charge an EV. Unfortunatly, PV power can fluctuate heavily and rapidly, which would result in large oscillations of EV charge current. `PVOptimize` tries to dampen these fluctuations by allowing limited use of the home battery to charge the EV: +* if PV power suddenly drops, charging may continue for several minutes out of the home battery +* this home battery support is stronger if the battery SOC is higher, and less if its lower + +To enable this, `PVOptimize` steers any excess PV power to home battery charging even early in the morning, if an EV is connected to the wallbox, but PV power is yet insufficient to charge the EV. Also, every excess PV power during EV charging (resulting eg. from rounding errors - some wallboxes can control charge currents only in 1A steps) is moved to the battery. Hence, it tries to keep the home battery well charged, if an EV is connected (hence, awaiting charging). However, this `fastcharge` mode is used only until the home battery SOC has reached `maxSOCCharge`. + +In addition, `minSOCCharge` makes sure that EV charging is only started once the home battery SOC has reached a desired minimal level. This can be used eg. in spring or autumn to first partially or fully charge the home battery and then steer any excess power to an EV. + +#### Forecast based home battery charging +If PV excess charging (`I_gridMax = 0`) is in motion, PV Forecast data from [PVForecast](https://stefae.github.io/PVForecast/#forcast-horizon) is used to abort EV charging early enough, so that home battery can still be fully charged to `maxSOC` prior to sunset. + +#### Summary of battery charging control parameters +The following parameters can be set in the `[PVControl]` section of the `config.ini` file: + +| Setting | Effect | +|---------|--------| +| minSOC | controls minimal SOC level of home battery. There is an absolute minimum defined in `PVStorage.minSOC`, but in winter times, where anyway insufficient PV power is available to fully charge battery, one may set a higher level. | +| maxSOC | controls maximum SOC level of home battery. In summer, were not all battery capacity is needed to support home consumption at night time, one might lower this value from 100% to eg. 80% | +| minSOCCharge | Home battery is charged to at least this level prior to EV charging to start. This has two reasons: (a) one may want to give priority to home battery charging with `minSOCCarge` = 100%. Then, EV charging starts once this SOC level is reached. (b) during EV excess charging, home battery can support short shadow periods. However, support is programmed to be dependent on SOC level to avoid too heavy battery cycling. Hence, one may choose to have battery at least 50% full before EV charging starts. (For the same reason, the home battery is charged at maximum rate if an EV is plugged in, but power is insufficient to start charging) | +| maxSOCCharge | Prior to EV charging being completed, home battery will not be charged above this level. Purpose to keep some battery capacity available in case EV charging finishes early and PV power would otherwise run into grid feed-in limits. | + +### EV charging management +Fundamentally, we can distinguish between three desired modes of operations: +* charge only from PV excess power - EV may or may not get fully charged. +* charge from grid as needed, if PV does not provide sufficient power - this is typically done if EV must be fully charged, regardless of PV state. +* charge as much as possible from PV, but we know that PV is insufficient to charge EV and we need some support from grid + +These modes are controlled with the following settings: + +| Setting | Effect | +|---------|--------| +| I_max | Maximum current provided for EV charging. This level can only be reached if sufficient PV power is available to support the difference `I_max - I_gridMax`.

Reason to set this lower than maximum current supported by wallbox are days where some PV power is available and we want extent the duration of EV charging, to make use of 'all the sun we get' | +| I_min | Minimum current required to start EV charging.

If `I_min` can be supplied from PV alone, grid power support will be denied. Hence, if fastest charging is needed, this shall be set high and `I_gridMax` shall allow for sufficient grid power support. | +| I_gridMax | Maximum grid power to be used for EV charging. If set to 0A, only PV excess charging is possible. If set to `I_max`, potentially all energy could come from grid. If `0A < I_gridMax < I_max` it defines the maximum current which can be drawn from grid for EV charging (after having accounted for home consumption).

If PV power is insufficient for `I_min`, grid power will be used and charge current maximized (respecting `I_max`). Use home battery for EV charging will be inhibited and no attempt will be made to charge home battery before end of the day. See next section for more details and an example. | + +The special value `I_max = 0` disables control of the wallbox, so that native wallbox control can be used again, without interference from `PVOptimize`. + ## Examples of what the controller can do -The following examples are based on the current implementation status, which is still fairly restricted (for example, night time EV charging is not yet supported, which is a very obvious need). +The following examples show different aspects of the capabilities of the controller. + +### EV excess charging example + +The following example assumes that sufficient PV energy is available to charge EV. During intermittent shadow periods, the home battery is used to support charging to 'some extent'. Grid power is not allowed to be used. The relevant `config.ini` settings are in section `PVControl`: + +| Setting | Value | Comment | +|---------|-------|---------| +| I_min | 8A | start charging once PV supports a charge current of at least 8A | +| I_max | 16A | allow charging with a current as high as 16A (11kW) | +| I_gridMax | 0A | don't allow grid power to support charging | -### EV charging example ![ActualChargeCase](ActualChargeCase.png) @@ -52,6 +132,28 @@ The following examples are based on the current implementation status, which is | d | ... there is PV excess power (here: >6.8kW) which cannot be fed to the grid due to network provider limitations | | e | after a PV clear-sky model predicts that it is no longer possible to have PV excess power for the day (here ~15:13), home battery charging is released. | +### Interplay of `I_gridMax`, `I_min` and `I_max` + +The following simulation was run with + +| Setting | Value | +|---------|-------| +| I_gridMax | 8A | +| I_min | 6A | +| I_max | 12A | + +In period (a), PV power (blue) is insufficient to supply `I_min`. Hence, max. `I_gridMax = 8A` are drawn from grid (green), ensuring that total charge current does not exceed `I_max = 12A`. + +In period (b), PV power exceeds `I_min = 6A` and the grid is no longer used. Charging now follows the capabilities of the PV system until the EV is charged. + +![I_gridMax](I_gridMax.png) + +This example as shown is probably not a very practical use-case, but it illustrates the working of the controller. Practically, the user might configure as follows: + +* If in a hurry, or if we expect a cloudy day, one would set `I_min = I_max = I_gridMax` to ensure immediate and fastest possible charging, independent of the EV system. +* If we have a little more freedom when the EV must be fully charged, but we still don't expect the day to be sunny enough to do all from excess power, a scenario as in (a) may be configured +* On a sunny day as shown and if full EV charging is not mandatory, one would probably either set `I_gridMax = 0A` to do only excess charging, as shown in period (b). Or, if in a hurry, + ### Home battery charging example ![ForecastBasedBattery](ForecastBasedBattery.png)