├── LICENSE ├── README.md └── solaredge_predictive_charging.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 fredlcore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SolarEdge Predictive Charging 2 | Python script to implement predictive battery charging based on SolCast/DWD forecasts 3 | 4 | This tool uses the awesome [solaredge_modbus](https://github.com/nmakel/solaredge_modbus) library to access the SolarEdge inverter data (including the battery's data) together with the solar forecasts from either the Germen Weather Service/DWD (via the likewiese awesome [wetterdienst](https://github.com/earthobservations/wetterdienst) library) as well as optionally the forecasts from [SolCast](https://www.solcast.com) (registration required, free for private use). 5 | 6 | ## Installation 7 | * Download the repository 8 | * Install the libraries listed in the import/from section of the script via `pip` (e.g. `pip install isodate` or `pip install wetterdienst`) 9 | 10 | ## Configuration 11 | * Choose the capital city closest to you (or one that matches the timezone of your location best) and enter the city's name in the `nearest_city` variable at the beginning. If you leave it empty, you will be prompted with a list of cities to choose from during runtime. 12 | * Choose the DWD station ID closest to you from [this list](https://www.dwd.de/DE/leistungen/klimadatenweltweit/stationsverzeichnis.html) and enter it into the `dwd_station` variable. 13 | * Set `kWp` to your peak production capacity in kWp. 14 | * Set `panel_area` to the square meter area occupied by your modules. 15 | * Set `panel_efficiency` to the module's efficiency according to the datasheet. 16 | * Set `inverter_efficiency` to the inverter's efficiency according to the datasheet. 17 | * `adjustment_factor` acts as a bias regarding the forecast data. It can help adjust the forecast data to fit your individual setup. A value below one starts the charging process earlier, a value above one delays it further. 18 | * `min_battery_level` determines how much battery charge should be maintained even during peak production 19 | * Set `interval_seconds` to the interval you want the script to poll data from the inverter. 20 | 21 | ### SolCast configuration 22 | Using SolCast is optional, but it could result in higher quality data than the one provided by the DWD. To use it, you have to register your site on their website and then configure the following variables: 23 | * Set `use_solcast` to `True` in order to enable SolCast queries. 24 | * Set `solcast_reporting_interval` to a value greater than zero if you want to report your production data to SolCast to enable them to fine-tune their forecast data for you. 25 | * Set `solcast_api_key` and `solcast_resource_id` to the API key and resource ID respectively which you can get from your SolCast account page. 26 | 27 | ## Running SolarEdge Predictive Charging 28 | The script takes two arguments: the first one is the IP address of the inverter, the second one is the ModBus/TCP port of the inverter. This is 1502 by default, but might have been set to 502. The person who installed your inverter needs to enable the ModBus/TCP functionality, so make sure this has been done! 29 | 30 | You should run the script on a system that runs 24/7, like a NAS, for example. To keep it running even after you sign off from your console, run the program like this: 31 | `nohup ./solaredge_predictive_charging.py 192.168.1.132 502 &` (of course you have to modify the IP address and the port accordingly) 32 | Output will be logged to a file called `nohup.out` in the same directory. With `tail nohup.out` you can then see the output and statistical data coming up. 33 | If you want to terminate the script (which has to be done prior to restarting it!), have a look at the output of `ps aux` and then use `kill` or `killall` to terminate the script. 34 | If you know that everything works fine and you no longer need the output logged to `nohup.out`, you can terminate the program and restart it like this: `nohup ./solaredge_predictive_charging.py 192.168.1.132 502 >/dev/null 2>&1 &`. 35 | 36 | ## Notes 37 | Currently, the script is written in a way that it will start to export (empty) the battery to the grid in the morning as soon as production is doulbe the household consumption until the battery is down to `min_battery_level`. If the battery level is below that, it will use all PV production not consumed to charge the battery up to this level first. 38 | Exporting to the grid will continue until the predicted solar energy for the rest of the day is lower than what the battery needs. This is adjusted by `adjustment_factor` with which the predicted solar energy is multiplied with. Thus, if you set the value to `0.7`, the predicted solar energy is calculated with 30% less. You'll have to experiment with this a bit; for my setting 0.7 works fine as the battery charging kicks in well after the noon peak but still comfortably charges the battery fully. 39 | 40 | BTW: This is my first program written in Python, so bear with me if coding style is not up to standard (I'm coming from a Perl background ;) ). Any suggestions for improvements are welcome via PR or issues! 41 | -------------------------------------------------------------------------------- /solaredge_predictive_charging.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | nearest_city = "Berlin" # Enter capital city for timezone and sunrise/sunset detection or leave empty to select during runtime 4 | dwd_station = 10253 # DWD station ID, see https://www.dwd.de/DE/leistungen/klimadatenweltweit/stationsverzeichnis.html for a list 5 | kWp = 5920 # peak production capacity of solar panels 6 | panel_area = 16 * 1 * 1.6 # module area in square meter 7 | panel_efficiency = 0.203 # module efficiency according to data sheet (divide by 100) 8 | inverter_efficiency = 0.98 # inverter efficieny according to datasheet (divide by 100) 9 | adjustment_factor = 0.7 # Adjustment factor 10 | min_battery_level = 20 # minimum battery level to be maintained in percent 11 | interval_seconds = 10 # update interval 12 | use_solcast = True # Set to False in order to use DWD MOSMIX only, otherwise use DWD MOSMIX as fallback 13 | solcast_reporting_interval = 5 # report interval in minutes, set to 0 to disable reporting 14 | solcast_api_key = "" # Enter SolCast API key here 15 | solcast_resource_id = "" # Enter SolCast Resource ID here 16 | 17 | import argparse 18 | import astral.geocoder 19 | import astral.location 20 | import astral.sun 21 | from datetime import datetime 22 | import dateutil 23 | import isodate 24 | import pytz 25 | import time 26 | import requests 27 | import json 28 | import solaredge_modbus 29 | from wetterdienst.provider.dwd.forecast import ( 30 | DwdForecastDate, 31 | DwdMosmixRequest, 32 | DwdMosmixType, 33 | ) 34 | 35 | solcast_period = "PT{}M".format(solcast_reporting_interval) 36 | solcast_url = "https://api.solcast.com.au/rooftop_sites/" + solcast_resource_id + "/forecasts?hours=24&format=json&api_key=" + solcast_api_key 37 | solcast_report_url = "https://api.solcast.com.au/rooftop_sites/" + solcast_resource_id + "/measurements?api_key=" + solcast_api_key 38 | 39 | factor = 0.278 * panel_area * panel_efficiency * inverter_efficiency 40 | 41 | try: 42 | city = astral.geocoder.lookup(nearest_city, astral.geocoder.database()) 43 | except KeyError: 44 | try: 45 | print (f"'{nearest_city}' not found in database.") 46 | input("Hit 'Enter' to print a list and select by number\nor fill in (only!) the city's name in the 'nearest_city' variable\nat the beginning of this script or press CTRL+C to abort.\n") 47 | except KeyboardInterrupt as err: 48 | quit() 49 | entries = [] 50 | for location in astral.geocoder.all_locations(astral.geocoder.database()): 51 | c = astral.location.Location(location) 52 | entry = {'name': c.name, 'region': c.region, 'timezone': c.timezone} 53 | entries.append(entry) 54 | sorted_entries = sorted(entries, key=lambda item: (item.get("region"), item.get("name")), reverse=False) 55 | entry_index = 0 56 | for entry in sorted_entries: 57 | print (f"{entry_index}: {entry['region']} ({entry['timezone']}): {entry['name']}") 58 | entry_index = entry_index + 1 59 | entry_selected = input("Enter number of city: ") 60 | try: 61 | entry = sorted_entries[int(entry_selected)] 62 | except IndexError: 63 | print ("Invalid selection, quitting...") 64 | quit() 65 | nearest_city = entry['name'] 66 | print (nearest_city) 67 | city = astral.geocoder.lookup(nearest_city, astral.geocoder.database()) 68 | 69 | def get_sunshine(avg_consumption): 70 | 71 | solcast_sunshine = 0 72 | gross_solcast_sunshine = 0 73 | api_exceeded = False 74 | current_time = datetime.now().astimezone(pytz.timezone(city.timezone)) 75 | try: 76 | if (use_solcast == True): 77 | request = requests.get(solcast_url) 78 | x_rate_limit = request.headers['x-rate-limit'] 79 | x_rate_remaining = request.headers['x-rate-limit-remaining'] 80 | x_rate_limit_reset = request.headers['x-rate-limit-reset'] 81 | reset_date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(x_rate_limit_reset))) 82 | print (f"Retrieving SolCast data, status {request.status_code}, {x_rate_remaining} of {x_rate_limit} left, resetting on {reset_date}") 83 | if (request.status_code != 429): 84 | contents = json.loads(request.text) 85 | for item in contents['forecasts']: 86 | solcast_time = dateutil.parser.parse(item['period_end']).astimezone(pytz.timezone(city.timezone)) 87 | solcast_duration = isodate.parse_duration(item['period']) 88 | solcast_time = solcast_time - solcast_duration 89 | solcast_pv_estimate = item['pv_estimate'] * 1000 90 | if (current_time.date() == solcast_time.date() and solcast_pv_estimate > 0): 91 | solcast_sunshine = solcast_sunshine + (solcast_pv_estimate / (60 / (solcast_duration.total_seconds() / 60))) - avg_consumption / (60 / (solcast_duration.total_seconds( 92 | ) / 60)) 93 | gross_solcast_sunshine = gross_solcast_sunshine + (solcast_pv_estimate / (60 / (solcast_duration.total_seconds() / 60))) 94 | print (solcast_time, ":", item['pv_estimate']) 95 | print (avg_consumption, solcast_sunshine, gross_solcast_sunshine) 96 | else: 97 | print ("API calls exceeded for today, will use DWD values as fallback") 98 | api_exceeded = True 99 | 100 | request = DwdMosmixRequest( 101 | parameter=["Rad1h"], 102 | start_issue=DwdForecastDate.LATEST, # automatically set if left empty 103 | mosmix_type=DwdMosmixType.SMALL, 104 | humanize=False, 105 | tidy=False, 106 | ) 107 | stations = request.filter_by_station_id( 108 | station_id=[dwd_station], 109 | ) 110 | response = next(stations.values.query()) 111 | 112 | print ("Retrieving DWD data...") 113 | dwd_sunshine = 0 114 | gross_dwd_sunshine = 0 115 | for index, row in response.df.iterrows(): 116 | dwd_time = row['date'].astimezone(pytz.timezone(city.timezone)) 117 | current_time = datetime.now().astimezone(pytz.timezone(city.timezone)) 118 | if (current_time.date() == dwd_time.date() and dwd_time.hour >= current_time.hour and row['rad1h'] > 0): 119 | dwd_sunshine = dwd_sunshine + row['rad1h'] * factor - avg_consumption 120 | gross_dwd_sunshine = gross_dwd_sunshine + row['rad1h'] * factor 121 | print (dwd_time,row['rad1h'],row['rad1h'] * factor) 122 | print (avg_consumption, dwd_sunshine, gross_dwd_sunshine) 123 | 124 | if (use_solcast == True and api_exceeded == False): 125 | sunshine = solcast_sunshine 126 | else: 127 | sunshine = dwd_sunshine 128 | 129 | except Exception as err: 130 | print (f"Error occurred: {err.args}", flush=True) 131 | sunshine = 0 132 | 133 | return sunshine 134 | 135 | def get_values(inverter): 136 | values = {} 137 | values = inverter.read_all() 138 | meters = inverter.meters() 139 | batteries = inverter.batteries() 140 | values["meters"] = {} 141 | values["batteries"] = {} 142 | 143 | for meter, params in meters.items(): 144 | meter_values = params.read_all() 145 | values["meters"][meter] = meter_values 146 | 147 | for battery, params in batteries.items(): 148 | battery_values = params.read_all() 149 | values["batteries"][battery] = battery_values 150 | 151 | return values 152 | 153 | if __name__ == "__main__": 154 | 155 | argparser = argparse.ArgumentParser() 156 | argparser.add_argument("host", type=str, help="Modbus TCP address") 157 | argparser.add_argument("port", type=int, help="Modbus TCP port") 158 | argparser.add_argument("--timeout", type=int, default=1, help="Connection timeout") 159 | argparser.add_argument("--unit", type=int, default=1, help="Modbus device address") 160 | argparser.add_argument("--json", action="store_true", default=False, help="Output as JSON") 161 | args = argparser.parse_args() 162 | 163 | inverter = solaredge_modbus.Inverter( 164 | host=args.host, 165 | port=args.port, 166 | timeout=args.timeout, 167 | unit=args.unit 168 | ) 169 | 170 | values = get_values(inverter) 171 | batteryCapacity = (values['batteries']['Battery1']['rated_energy']) 172 | 173 | if args.json: 174 | print(json.dumps(values, indent=4)) 175 | else: 176 | # print("Power Inverter;Power Meter;Power Battery;Consumption") 177 | 178 | mode = ""; 179 | old_hour = -1 180 | old_day = 0 181 | avg_counter = 0 182 | avg_consumption = 0 183 | daily_consumption = 0 184 | avg_production_counter = 0 185 | avg_production = 0 186 | pvProductionInterval = 0 187 | remaining_sunshine = 0 188 | post_peak = False 189 | 190 | while True: 191 | start_time = time.time() 192 | 193 | sun_data = astral.sun.sun(city.observer,datetime.now(),tzinfo=city.timezone) 194 | sunrise_hour = sun_data['sunrise'].hour 195 | sunset_hour = sun_data['sunset'].hour 196 | 197 | try: 198 | pvProduction = (values['power_ac'] * (10 ** values['power_ac_scale'])) + (values['batteries']['Battery1']['instantaneous_power']) 199 | gridImportExport = (values['meters']['Meter1']['power'] * (10 ** values['meters']['Meter1']['power_scale'])) 200 | batteryImportExport = (values['batteries']['Battery1']['instantaneous_power']) 201 | householdConsumption = (values['power_ac'] * (10 ** values['power_ac_scale'])) - (values['meters']['Meter1']['power'] * (10 ** values['meters']['Meter1']['power_scale'])) 202 | batterySoe = (values['batteries']['Battery1']['soe']) 203 | batteryNeeded = batteryCapacity - (batteryCapacity * batterySoe / 100) 204 | 205 | if pvProduction < 0: 206 | householdConsumption = householdConsumption + abs(pvProduction) 207 | pvProduction = 0 208 | if householdConsumption > 0: 209 | daily_consumption = daily_consumption + householdConsumption 210 | avg_counter = avg_counter + 1 211 | avg_consumption = daily_consumption / avg_counter 212 | 213 | pvProductionInterval = pvProductionInterval + (pvProduction / 1000) 214 | avg_production_counter = avg_production_counter + 1 215 | avg_production = pvProductionInterval / avg_production_counter 216 | 217 | hour = datetime.now().time().hour 218 | day = datetime.now().today().day 219 | dt_string = datetime.now().strftime("%d.%m.%Y;%H:%M:%S") 220 | 221 | if (hour != old_hour): 222 | if (hour >= sunrise_hour and hour <= sunset_hour): 223 | remaining_sunshine = get_sunshine(avg_consumption) * adjustment_factor 224 | else: 225 | remaining_sunshine = 0 226 | old_hour = hour 227 | if (remaining_sunshine < batteryNeeded and hour > sunrise_hour): 228 | post_peak = True 229 | if (day != old_day): 230 | avg_consumption = 0 231 | daily_consumption = 0 232 | avg_counter = 0 233 | post_peak = False 234 | old_day = day 235 | 236 | print(f"{dt_string};{pvProduction};{avg_production:.4f};{gridImportExport:.1f};{batteryImportExport};{householdConsumption};{avg_consumption:.1f};{batterySoe};{batteryNeeded:.0f};{remaining_sunshine:.0f};{post_peak};{mode}", flush=True) 237 | 238 | if (avg_production_counter >= ((solcast_reporting_interval * 60) / interval_seconds) and solcast_reporting_interval > 0): 239 | print ("Sending average production to Solcast...") 240 | current_time_iso = datetime.now().astimezone(pytz.timezone(city.timezone)).replace(microsecond=0).isoformat() 241 | solcast_json = {"measurement": {"period_end": current_time_iso, "period": solcast_period, "total_power": avg_production}} 242 | try: 243 | solcast_response = requests.post(solcast_report_url, json = solcast_json) 244 | print (solcast_response.status_code, solcast_response.reason, solcast_response.text) 245 | except Exception as err: 246 | print (f"Error occurred: {err.args}", flush=True) 247 | avg_production = 0 248 | avg_production_counter = 0 249 | pvProductionInterval = 0 250 | 251 | inverter.write("storage_control_mode", 4) 252 | inverter.write("storage_default_mode", 7) 253 | if pvProduction > (kWp / 3) and batterySoe > min_battery_level and (post_peak == False or batterySoe > 90): 254 | mode = "Maximize export" 255 | inverter.write("rc_cmd_mode", 4) 256 | elif pvProduction > (householdConsumption * 2) and batterySoe >= min_battery_level and post_peak == False: 257 | mode = "Charge only with excess PV" 258 | inverter.write("rc_cmd_mode", 1) 259 | else: 260 | mode = "Maximize self-consumption" 261 | inverter.write("rc_cmd_mode", 7) 262 | 263 | except Exception as err: 264 | print (f"Error occurred: {err.args}", flush=True) 265 | 266 | values = get_values(inverter) 267 | end_time = time.time() 268 | time.sleep(abs(interval_seconds - (end_time - start_time))) 269 | --------------------------------------------------------------------------------