Skip to content

Commit

Permalink
Squashed commit of the following:
Browse files Browse the repository at this point in the history
commit 81ccd62
Author: Stefan_E <se_misc@hotmail.com>
Date:   Sun Jan 16 16:24:55 2022 +0100

    Update pvcontrol.py

    fixed spurios writes to wall-box after charge completed

commit 175842c
Author: Stefan_E <se_misc@hotmail.com>
Date:   Sun Jan 9 16:13:43 2022 +0100

    Documentation updates

commit 2e814b5
Author: Stefan_E <se_misc@hotmail.com>
Date:   Sat Jan 8 01:35:43 2022 +0100

    Update pvcontrol.py

commit 36908a6
Author: Stefan_E <se_misc@hotmail.com>
Date:   Thu Jan 6 21:18:23 2022 +0100

    added pvcontrol to pvstatus.json

commit 3a26db3
Author: Stefan_E <se_misc@hotmail.com>
Date:   Wed Jan 5 22:54:15 2022 +0100

    Update pvcontrol.py

    bug fix chargeStart

commit a82fe44
Author: Stefan_E <se_misc@hotmail.com>
Date:   Tue Jan 4 20:47:45 2022 +0100

    RC 1 for v2.0.0
  • Loading branch information
StefaE committed Feb 6, 2022
1 parent a223a66 commit 24eb5e4
Show file tree
Hide file tree
Showing 12 changed files with 459 additions and 232 deletions.
2 changes: 1 addition & 1 deletion PVControl/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__author__ = """Stefan E"""
__email__ = 'se_misc ... hotmail.com'
__version__ = 1.00
__version__ = 2.00
302 changes: 190 additions & 112 deletions PVControl/pvcontrol.py

Large diffs are not rendered by default.

23 changes: 14 additions & 9 deletions PVControl/pvmonitor/kostal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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):
Expand All @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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']:
Expand All @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions PVControl/pvserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions PVControl/wallbox/hardybarth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion PVControl/wallbox/wbtemplate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions PVOptimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import sys
import argparse
import time
import json
from datetime import datetime, timezone

from PVControl.pvcontrol import PVControl
Expand All @@ -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)
Expand All @@ -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
4 changes: 3 additions & 1 deletion PVSim.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 14 additions & 5 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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()` |
Expand All @@ -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)
Expand Down
Loading

0 comments on commit 24eb5e4

Please sign in to comment.