foxesscloud 2.5.2__py3-none-any.whl → 2.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foxesscloud/foxesscloud.py +93 -91
- foxesscloud/openapi.py +89 -83
- {foxesscloud-2.5.2.dist-info → foxesscloud-2.5.3.dist-info}/METADATA +12 -2
- foxesscloud-2.5.3.dist-info/RECORD +7 -0
- foxesscloud-2.5.2.dist-info/RECORD +0 -7
- {foxesscloud-2.5.2.dist-info → foxesscloud-2.5.3.dist-info}/LICENCE +0 -0
- {foxesscloud-2.5.2.dist-info → foxesscloud-2.5.3.dist-info}/WHEEL +0 -0
- {foxesscloud-2.5.2.dist-info → foxesscloud-2.5.3.dist-info}/top_level.txt +0 -0
foxesscloud/foxesscloud.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
##################################################################################################
|
2
2
|
"""
|
3
3
|
Module: Fox ESS Cloud
|
4
|
-
Updated:
|
4
|
+
Updated: 23 September 2024
|
5
5
|
By: Tony Matthews
|
6
6
|
"""
|
7
7
|
##################################################################################################
|
@@ -10,7 +10,7 @@ By: Tony Matthews
|
|
10
10
|
# ALL RIGHTS ARE RESERVED © Tony Matthews 2023
|
11
11
|
##################################################################################################
|
12
12
|
|
13
|
-
version = "1.6.
|
13
|
+
version = "1.6.5"
|
14
14
|
print(f"FoxESS-Cloud version {version}")
|
15
15
|
|
16
16
|
debug_setting = 1
|
@@ -19,6 +19,25 @@ debug_setting = 1
|
|
19
19
|
month_names = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
20
20
|
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
21
21
|
|
22
|
+
import os.path
|
23
|
+
import json
|
24
|
+
import time
|
25
|
+
from datetime import datetime, timedelta, timezone
|
26
|
+
from copy import deepcopy
|
27
|
+
import requests
|
28
|
+
from requests.auth import HTTPBasicAuth
|
29
|
+
import hashlib
|
30
|
+
import math
|
31
|
+
import matplotlib.pyplot as plt
|
32
|
+
|
33
|
+
fox_domain = "https://www.foxesscloud.com"
|
34
|
+
fox_client_id = "5245784"
|
35
|
+
fox_user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
36
|
+
time_zone = 'Europe/London'
|
37
|
+
|
38
|
+
# optional path to use for file storage
|
39
|
+
storage = ''
|
40
|
+
|
22
41
|
# global plot parameters
|
23
42
|
figure_width = 9 # width of plots
|
24
43
|
legend_location = "upper right"
|
@@ -39,27 +58,6 @@ def plot_show():
|
|
39
58
|
plt.show()
|
40
59
|
return
|
41
60
|
|
42
|
-
import os.path
|
43
|
-
import json
|
44
|
-
import time
|
45
|
-
from datetime import datetime, timedelta, timezone
|
46
|
-
from copy import deepcopy
|
47
|
-
import requests
|
48
|
-
from requests.auth import HTTPBasicAuth
|
49
|
-
import hashlib
|
50
|
-
#from random_user_agent.user_agent import UserAgent
|
51
|
-
#from random_user_agent.params import SoftwareName, OperatingSystem
|
52
|
-
import math
|
53
|
-
import matplotlib.pyplot as plt
|
54
|
-
|
55
|
-
#software_names = [SoftwareName.CHROME.value]
|
56
|
-
#operating_systems = [OperatingSystem.WINDOWS.value, OperatingSystem.LINUX.value]
|
57
|
-
#user_agent_rotator = UserAgent(software_names=software_names, operating_systems=operating_systems, limit=100)
|
58
|
-
|
59
|
-
fox_domain = "https://www.foxesscloud.com"
|
60
|
-
fox_client_id = "5245784"
|
61
|
-
fox_user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
62
|
-
time_zone = 'Europe/London'
|
63
61
|
|
64
62
|
##################################################################################################
|
65
63
|
##################################################################################################
|
@@ -221,11 +219,11 @@ token_renewal = timedelta(hours=2).seconds # interval before token needs t
|
|
221
219
|
|
222
220
|
# login and get token if required. Check if token has expired and renew if required.
|
223
221
|
def get_token():
|
224
|
-
global username, password, fox_user_agent, fox_client_id, time_zone, token_store, device_list, device, device_id, debug_setting, token_save, token_renewal, messages
|
222
|
+
global username, password, fox_user_agent, fox_client_id, time_zone, token_store, device_list, device, device_id, debug_setting, token_save, token_renewal, messages, storage
|
225
223
|
if token_store is None:
|
226
224
|
token_store = {'token': None, 'valid_from': None, 'valid_for': token_renewal, 'user_agent': fox_user_agent, 'lang': 'en', 'time_zone': time_zone, 'client_id': fox_client_id}
|
227
|
-
if token_store['token'] is None and os.path.exists(token_save):
|
228
|
-
file = open(token_save)
|
225
|
+
if token_store['token'] is None and os.path.exists(storage + token_save):
|
226
|
+
file = open(storage + token_save)
|
229
227
|
token_store = json.load(file)
|
230
228
|
file.close()
|
231
229
|
if token_store.get('time_zone') is None:
|
@@ -260,7 +258,7 @@ def get_token():
|
|
260
258
|
output(f"** no token in result data")
|
261
259
|
token_store['valid_from'] = time_now.isoformat()
|
262
260
|
if token_save is not None :
|
263
|
-
file = open(token_save, 'w')
|
261
|
+
file = open(storage + token_save, 'w')
|
264
262
|
json.dump(token_store, file, indent=4, ensure_ascii=False)
|
265
263
|
file.close()
|
266
264
|
return token_store['token']
|
@@ -1375,7 +1373,7 @@ sample_time = 5.0 # 5 minutes default
|
|
1375
1373
|
sample_rounding = 2 # round to 30 seconds
|
1376
1374
|
|
1377
1375
|
def get_raw(time_span='hour', d=None, v=None, summary=1, save=None, load=None, plot=0, station=0):
|
1378
|
-
global token, device_id, debug_setting, var_list, invert_ct2, tariff, max_power_kw, messages, sample_rounding, sample_time
|
1376
|
+
global token, device_id, debug_setting, var_list, invert_ct2, tariff, max_power_kw, messages, sample_rounding, sample_time, storage
|
1379
1377
|
if station == 0 and get_device() is None:
|
1380
1378
|
return None
|
1381
1379
|
elif station == 1 and get_site() is None:
|
@@ -1423,12 +1421,12 @@ def get_raw(time_span='hour', d=None, v=None, summary=1, save=None, load=None, p
|
|
1423
1421
|
output(f"** get_raw(), no raw data, {errno_message(errno)}")
|
1424
1422
|
return None
|
1425
1423
|
else:
|
1426
|
-
file = open(load)
|
1424
|
+
file = open(storage + load)
|
1427
1425
|
result = json.load(file)
|
1428
1426
|
file.close()
|
1429
1427
|
if save is not None:
|
1430
1428
|
file_name = save + "_raw_" + time_span + "_" + d[0:10].replace('-','') + ".txt"
|
1431
|
-
file = open(file_name, 'w', encoding='utf-8')
|
1429
|
+
file = open(storage + file_name, 'w', encoding='utf-8')
|
1432
1430
|
json.dump(result, file, indent=4, ensure_ascii= False)
|
1433
1431
|
file.close()
|
1434
1432
|
for var in result:
|
@@ -1758,12 +1756,12 @@ def get_report(report_type='day', d=None, v=None, summary=1, save=None, load=Non
|
|
1758
1756
|
for x in v:
|
1759
1757
|
result.append({'variable': x, 'data': [], 'date': d})
|
1760
1758
|
if load is not None:
|
1761
|
-
file = open(load)
|
1759
|
+
file = open(storage + load)
|
1762
1760
|
result = json.load(file)
|
1763
1761
|
file.close()
|
1764
1762
|
elif save is not None:
|
1765
1763
|
file_name = save + "_rep_" + report_type + "_" + d.replace('-','') + ".txt"
|
1766
|
-
file = open(file_name, 'w', encoding='utf-8')
|
1764
|
+
file = open(storage + file_name, 'w', encoding='utf-8')
|
1767
1765
|
json.dump(result, file, indent=4, ensure_ascii= False)
|
1768
1766
|
file.close()
|
1769
1767
|
if summary == 0:
|
@@ -2571,12 +2569,12 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
|
|
2571
2569
|
while h < 48:
|
2572
2570
|
day = today if h < 24 else tomorrow
|
2573
2571
|
if forecast.daily.get(day) is None:
|
2574
|
-
value =
|
2572
|
+
value = None
|
2575
2573
|
elif steps_per_hour == 1:
|
2576
|
-
value =
|
2574
|
+
value = forecast.daily[day]['hourly'].get(int(h % 24))
|
2577
2575
|
else:
|
2578
|
-
value =
|
2579
|
-
profile.append(value)
|
2576
|
+
value = forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2))
|
2577
|
+
profile.append(c_float(value))
|
2580
2578
|
h += 1 / steps_per_hour
|
2581
2579
|
while len(profile) < run_time:
|
2582
2580
|
profile.append(0.0)
|
@@ -2662,7 +2660,7 @@ base_time = None
|
|
2662
2660
|
|
2663
2661
|
# charge_needed settings
|
2664
2662
|
charge_config = {
|
2665
|
-
'contingency': [
|
2663
|
+
'contingency': [15,10,5,10], # % of consumption. Single value or [winter, spring, summer, autumn]
|
2666
2664
|
'capacity': None, # Battery capacity (over-ride)
|
2667
2665
|
'min_soc': None, # Minimum Soc. Default 10%
|
2668
2666
|
'max_soc': None, # Maximum Soc. Default 100%
|
@@ -2719,7 +2717,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2719
2717
|
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2720
2718
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
2721
2719
|
global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
|
2722
|
-
global timed_strategy, steps_per_hour, base_time
|
2720
|
+
global timed_strategy, steps_per_hour, base_time, storage
|
2723
2721
|
print(f"\n---------------- charge_needed ----------------")
|
2724
2722
|
# validate parameters
|
2725
2723
|
args = locals()
|
@@ -2778,10 +2776,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2778
2776
|
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'force': force_charge})
|
2779
2777
|
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2780
2778
|
time_to_end1 = None
|
2781
|
-
start_now = (int(hour_now * 2 + 1) / 2) % 24
|
2782
2779
|
for t in times:
|
2783
|
-
if hour_in(
|
2784
|
-
|
2780
|
+
if hour_in(hour_now, t) and update_settings > 0:
|
2781
|
+
update_settings = 0
|
2782
|
+
output(f"\nSettings will not be updated during a charge period")
|
2785
2783
|
time_to_start = round_time(t['start'] - base_hour) * steps_per_hour
|
2786
2784
|
time_to_start += hour_adjustment * steps_per_hour if time_to_start > time_change else 0
|
2787
2785
|
charge_time = round_time(t['end'] - t['start'])
|
@@ -2965,8 +2963,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2965
2963
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2966
2964
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2967
2965
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
2968
|
-
|
2969
|
-
# get forecast.solar data and produce time line
|
2966
|
+
solcast_from = time_hours(fsolcast.daily[today]['from']) if fsolcast.daily[today].get('from') is not None else 0
|
2967
|
+
output(f"\nSolcast forecast for {tomorrow}: {fsolcast.daily[tomorrow]['kwh']:.1f}") # get forecast.solar data and produce time line
|
2970
2968
|
solar_value = None
|
2971
2969
|
solar_profile = None
|
2972
2970
|
if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
|
@@ -2974,7 +2972,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2974
2972
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2975
2973
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2976
2974
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
2977
|
-
output(f"\nSolar forecast
|
2975
|
+
output(f"\nSolar forecast for {tomorrow}: {fsolar.daily[tomorrow]['kwh']:.1f}")
|
2978
2976
|
if solcast_value is None and solar_value is None and debug_setting > 1:
|
2979
2977
|
output(f"\nNo forecasts available at this time")
|
2980
2978
|
# get generation data
|
@@ -3075,23 +3073,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3075
3073
|
charge_message = "** test charge **"
|
3076
3074
|
# work out charge needed
|
3077
3075
|
if kwh_min > (reserve + kwh_contingency) and kwh_needed < charge_config['min_kwh']:
|
3078
|
-
output(f"\nNo charging needed ({today} {hours_time(hour_now)} {current_soc
|
3076
|
+
output(f"\nNo charging needed ({today} {hours_time(hour_now)} {current_soc:.0f}%)")
|
3079
3077
|
charge_message = "no charge needed"
|
3080
3078
|
kwh_needed = 0.0
|
3081
3079
|
hours = 0.0
|
3082
3080
|
start_timed = time_to_end
|
3083
3081
|
end_timed = time_to_end
|
3084
3082
|
end_soc = int(end_residual / capacity * 100 + 0.5)
|
3085
|
-
|
3086
|
-
# rebuild the battery residual with min_soc for battery hold
|
3083
|
+
# update min_soc for battery hold
|
3087
3084
|
if force_charge > 0 and timed_mode > 1:
|
3088
3085
|
for t in range(int(time_to_start), int(time_to_end)):
|
3089
3086
|
work_mode_timed[t]['min_soc'] = start_soc
|
3090
|
-
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3091
3087
|
else:
|
3092
3088
|
if test_charge is None:
|
3093
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh ({today} {hours_time(hour_now)} {current_soc
|
3089
|
+
output(f"\nCharge needed {kwh_needed:.2f}kWh ({today} {hours_time(hour_now)} {current_soc:.0f}%)")
|
3094
3090
|
charge_message = "with charge added"
|
3091
|
+
output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_start, time_line))} ({start_residual:.2f}kWh)")
|
3095
3092
|
# work out time to add kwh_needed to battery
|
3096
3093
|
taper_time = 10/60 if (start_residual + kwh_needed) >= (capacity * 0.95) else 0
|
3097
3094
|
hours = round_time(kwh_needed / (charge_power * charge_loss) + taper_time)
|
@@ -3108,7 +3105,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3108
3105
|
price = charge_period.get('price') if charge_period is not None else None
|
3109
3106
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
3110
3107
|
end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
|
3111
|
-
output(f" Charge:
|
3108
|
+
output(f" Charge: {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}" + (f" at {price:5.2f}p/kWh" if price is not None else ""))
|
3112
3109
|
for i in range(int(time_to_start), int(end_timed) + 1):
|
3113
3110
|
j = i + 1
|
3114
3111
|
# work out time (fraction of hour) when charging in hour from i to j
|
@@ -3129,18 +3126,14 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3129
3126
|
work_mode_timed[i]['discharge'] *= (1-t)
|
3130
3127
|
elif force_charge > 0 and timed_mode > 1:
|
3131
3128
|
work_mode_timed[i]['min_soc'] = start_soc
|
3132
|
-
|
3133
|
-
|
3134
|
-
|
3135
|
-
|
3136
|
-
|
3137
|
-
|
3138
|
-
|
3139
|
-
|
3140
|
-
output(f"\nTime, Generation, Charge, Consumption, Discharge, Residual, kWh")
|
3141
|
-
for i in range(0, run_time):
|
3142
|
-
h = base_hour + i / steps_per_hour
|
3143
|
-
output(f" {hours_time(h)}, {generation_timed[i]:6.3f}, {charge_timed[i]:6.3f}, {consumption_timed[i]:6.3f}, {discharge_timed[i]:6.3f}, {bat_timed[i]:6.3f}")
|
3129
|
+
# rebuild the battery residual with the charge added and min_soc
|
3130
|
+
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3131
|
+
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
3132
|
+
# show the results
|
3133
|
+
output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
|
3134
|
+
output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
|
3135
|
+
if not charge_today:
|
3136
|
+
output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
|
3144
3137
|
if show_data > 0:
|
3145
3138
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3146
3139
|
s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC:\n"
|
@@ -3194,7 +3187,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3194
3187
|
data['work_mode'] = work_mode_timed
|
3195
3188
|
data['generation'] = generation_timed
|
3196
3189
|
data['consumption'] = consumption_timed
|
3197
|
-
file = open(file_name, 'w')
|
3190
|
+
file = open(storage + file_name, 'w')
|
3198
3191
|
json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3199
3192
|
file.close()
|
3200
3193
|
# setup charging
|
@@ -3220,13 +3213,13 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3220
3213
|
##################################################################################################
|
3221
3214
|
|
3222
3215
|
def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
3223
|
-
global charge_config
|
3216
|
+
global charge_config, storage
|
3224
3217
|
if save is None and charge_config.get('save') is not None:
|
3225
3218
|
save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
|
3226
3219
|
if save is None:
|
3227
3220
|
print(f"** charge_compare(): please provide a saved file to load")
|
3228
3221
|
return
|
3229
|
-
file = open(save)
|
3222
|
+
file = open(storage + save)
|
3230
3223
|
data = json.load(file)
|
3231
3224
|
file.close()
|
3232
3225
|
if data is None or data.get('base_time') is None:
|
@@ -3246,7 +3239,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3246
3239
|
run_time = len(time_line)
|
3247
3240
|
base_hour = int(time_hours(base_time[11:16]))
|
3248
3241
|
start_day = base_time[:10]
|
3249
|
-
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc
|
3242
|
+
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:.0f}%")
|
3250
3243
|
now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
|
3251
3244
|
end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
|
3252
3245
|
if v is None:
|
@@ -3490,15 +3483,16 @@ def write(f, s, m='a'):
|
|
3490
3483
|
# log battery information in CSV format at 'interval' minutes apart for 'run' times
|
3491
3484
|
# log 1: battery info, 2: add cell volts, 3: add cell temps
|
3492
3485
|
def battery_monitor(interval=30, run=48, log=1, count=None, save=None, overwrite=0):
|
3486
|
+
global storage
|
3493
3487
|
run_time = interval * run / 60
|
3494
3488
|
print(f"\n---------------- battery_monitor ------------------")
|
3495
3489
|
print(f"Expected runtime = {hours_time(run_time, day=True)} (hh:mm/days)")
|
3496
3490
|
if save is not None:
|
3497
|
-
print(f"Saving data to {save} ")
|
3491
|
+
print(f"Saving data to {storage + save} ")
|
3498
3492
|
print()
|
3499
3493
|
s = f"time,soc,residual,bat_volt,bat_current,bat_temp,nbat,ncell,ntemp,volts*,imbalance*,temps*"
|
3500
3494
|
s += ",cell_volts*" if log == 2 else ",cell_volts*,cell_temps*" if log ==3 else ""
|
3501
|
-
write(save, s, 'w' if overwrite == 1 else 'a')
|
3495
|
+
write(storage + save, s, 'w' if overwrite == 1 else 'a')
|
3502
3496
|
i = run
|
3503
3497
|
while i > 0:
|
3504
3498
|
t1 = time.time()
|
@@ -3839,17 +3833,18 @@ class Solcast :
|
|
3839
3833
|
# reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
|
3840
3834
|
# The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
|
3841
3835
|
# The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
|
3842
|
-
global debug_setting, solcast_url, solcast_api_key, solcast_save
|
3836
|
+
global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
|
3843
3837
|
self.data = {}
|
3844
3838
|
self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
|
3845
3839
|
self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
|
3840
|
+
self.quarter = int(self.today[5:7]) // 3 % 4
|
3846
3841
|
self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
|
3847
3842
|
self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
|
3848
3843
|
self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
|
3849
|
-
if reload == 1 and os.path.exists(self.save):
|
3850
|
-
os.remove(self.save)
|
3851
|
-
if self.save is not None and os.path.exists(self.save):
|
3852
|
-
file = open(self.save)
|
3844
|
+
if reload == 1 and os.path.exists(storage + self.save):
|
3845
|
+
os.remove(storage + self.save)
|
3846
|
+
if self.save is not None and os.path.exists(storage + self.save):
|
3847
|
+
file = open(storage + self.save)
|
3853
3848
|
self.data = json.load(file)
|
3854
3849
|
file.close()
|
3855
3850
|
if len(self.data) == 0:
|
@@ -3890,7 +3885,7 @@ class Solcast :
|
|
3890
3885
|
return
|
3891
3886
|
self.data[t][rid] = response.json().get(t)
|
3892
3887
|
if self.save is not None :
|
3893
|
-
file = open(self.save, 'w')
|
3888
|
+
file = open(storage + self.save, 'w')
|
3894
3889
|
json.dump(self.data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3895
3890
|
file.close()
|
3896
3891
|
self.daily = {}
|
@@ -3922,26 +3917,30 @@ class Solcast :
|
|
3922
3917
|
while self.days > days * (1 + estimated) :
|
3923
3918
|
self.keys = self.keys[estimated:-1]
|
3924
3919
|
self.days = len(self.keys)
|
3925
|
-
# fill out forecast to cover 24 hours
|
3920
|
+
# fill out forecast to cover 24 hours and set forecast start time
|
3926
3921
|
for date in self.keys:
|
3927
3922
|
for t in [hours_time(t / 2) for t in range(0,48)]:
|
3928
3923
|
if self.daily[date]['pt30'].get(t) is None:
|
3929
3924
|
self.daily[date]['pt30'][t] = 0.0
|
3925
|
+
elif self.daily[date].get('from') is None:
|
3926
|
+
self.daily[date]['from'] = t
|
3930
3927
|
# apply shading
|
3931
3928
|
if self.shading is not None:
|
3932
3929
|
for date in self.keys:
|
3933
3930
|
times = sorted(time_hours(t) for t in self.daily[date]['pt30'].keys())
|
3934
3931
|
if self.shading.get('adjust') is not None:
|
3935
|
-
loss = self.shading['adjust']
|
3932
|
+
loss = self.shading['adjust'] if type(self.shading['adjust']) is not list else self.shading['adjust'][self.quarter]
|
3936
3933
|
for t in times:
|
3937
3934
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3938
3935
|
if self.shading.get('am_delay') is not None:
|
3939
|
-
|
3936
|
+
delay = self.shading['am_delay'] if type(self.shading['am_delay']) is not list else self.shading['am_delay'][self.quarter]
|
3937
|
+
shaded = time_hours(self.daily[date]['sun'][0]) + delay
|
3940
3938
|
loss = self.shading['am_loss']
|
3941
3939
|
for t in [t for t in times if t < shaded]:
|
3942
3940
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3943
3941
|
if self.shading.get('pm_delay') is not None:
|
3944
|
-
|
3942
|
+
delay = self.shading['pm_delay'] if type(self.shading['pm_delay']) is not list else self.shading['pm_delay'][self.quarter]
|
3943
|
+
shaded = time_hours(self.daily[date]['sun'][1]) - delay
|
3945
3944
|
loss = self.shading['pm_loss']
|
3946
3945
|
for t in [t for t in times if t > shaded]:
|
3947
3946
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
@@ -4177,19 +4176,19 @@ class Solar :
|
|
4177
4176
|
|
4178
4177
|
# get solar forecast and return total expected yield
|
4179
4178
|
def __init__(self, reload=0, quiet=False, shading=None):
|
4180
|
-
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key
|
4181
|
-
self.shading = shading
|
4179
|
+
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
|
4180
|
+
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4182
4181
|
self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
|
4182
|
+
self.quarter = int(self.today[5:7]) // 3 % 4
|
4183
4183
|
self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
|
4184
4184
|
self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
|
4185
4185
|
self.arrays = None
|
4186
4186
|
self.results = None
|
4187
|
-
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4188
4187
|
self.save = solar_save #.replace('.', '_%.'.replace('%',self.today.replace('-','')))
|
4189
|
-
if reload == 1 and os.path.exists(self.save):
|
4190
|
-
os.remove(self.save)
|
4191
|
-
if self.save is not None and os.path.exists(self.save):
|
4192
|
-
file = open(self.save)
|
4188
|
+
if reload == 1 and os.path.exists(storage + self.save):
|
4189
|
+
os.remove(storage + self.save)
|
4190
|
+
if self.save is not None and os.path.exists(storage + self.save):
|
4191
|
+
file = open(storage + self.save)
|
4193
4192
|
data = json.load(file)
|
4194
4193
|
file.close()
|
4195
4194
|
if data.get('date') is not None and (data['date'] == self.today and reload != 1):
|
@@ -4220,7 +4219,7 @@ class Solar :
|
|
4220
4219
|
if self.save is not None :
|
4221
4220
|
if debug_setting > 0 and not quiet:
|
4222
4221
|
print(f"Saving data to {self.save}")
|
4223
|
-
file = open(self.save, 'w')
|
4222
|
+
file = open(storage + self.save, 'w')
|
4224
4223
|
json.dump({'date': self.today, 'arrays': self.arrays, 'results': self.results}, file, indent=4, ensure_ascii= False)
|
4225
4224
|
file.close()
|
4226
4225
|
self.daily = {}
|
@@ -4249,16 +4248,18 @@ class Solar :
|
|
4249
4248
|
for date in self.keys:
|
4250
4249
|
times = sorted(time_hours(t) for t in self.daily[date]['pt30'].keys())
|
4251
4250
|
if self.shading.get('adjust') is not None:
|
4252
|
-
loss = self.shading['adjust']
|
4251
|
+
loss = self.shading['adjust'] if type(self.shading['adjust']) is not list else self.shading['adjust'][self.quarter]
|
4253
4252
|
for t in times:
|
4254
4253
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4255
4254
|
if self.shading.get('am_delay') is not None:
|
4256
|
-
|
4255
|
+
delay = self.shading['am_delay'] if type(self.shading['am_delay']) is not list else self.shading['am_delay'][self.quarter]
|
4256
|
+
shaded = time_hours(self.daily[date]['sun'][0]) + delay
|
4257
4257
|
loss = self.shading['am_loss']
|
4258
4258
|
for t in [t for t in times if t < shaded]:
|
4259
4259
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4260
4260
|
if self.shading.get('pm_delay') is not None:
|
4261
|
-
|
4261
|
+
delay = self.shading['pm_delay'] if type(self.shading['pm_delay']) is not list else self.shading['pm_delay'][self.quarter]
|
4262
|
+
shaded = time_hours(self.daily[date]['sun'][1]) - delay
|
4262
4263
|
loss = self.shading['pm_loss']
|
4263
4264
|
for t in [t for t in times if t > shaded]:
|
4264
4265
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
@@ -4464,6 +4465,7 @@ class Solar :
|
|
4464
4465
|
plot_show()
|
4465
4466
|
return
|
4466
4467
|
|
4468
|
+
|
4467
4469
|
##################################################################################################
|
4468
4470
|
##################################################################################################
|
4469
4471
|
# Pushover API
|
@@ -4477,7 +4479,7 @@ pushover_url = "https://api.pushover.net/1/messages.json"
|
|
4477
4479
|
foxesscloud_app_key = "aqj8up6jeg9hu4zr1pgir3368vda4q"
|
4478
4480
|
|
4479
4481
|
def pushover_post(message, file=None, app_key=None):
|
4480
|
-
global pushover_user_key, pushover_url, foxesscloud_app_key, debug_setting
|
4482
|
+
global pushover_user_key, pushover_url, foxesscloud_app_key, debug_setting, storage
|
4481
4483
|
if pushover_user_key is None or message is None:
|
4482
4484
|
return None
|
4483
4485
|
if app_key is None:
|
@@ -4485,7 +4487,7 @@ def pushover_post(message, file=None, app_key=None):
|
|
4485
4487
|
if len(message) > 1024:
|
4486
4488
|
message = message[-1024:]
|
4487
4489
|
body = {'token': app_key, 'user': pushover_user_key, 'message': message}
|
4488
|
-
files = {'attachment': open(file, 'rb')} if file is not None else None
|
4490
|
+
files = {'attachment': open(storage + file, 'rb')} if file is not None else None
|
4489
4491
|
response = requests.post(pushover_url, data=body, files=files)
|
4490
4492
|
if response.status_code != 200:
|
4491
4493
|
print(f"** pushover_post() got response code {response.status_code}: {response.reason}")
|
foxesscloud/openapi.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
##################################################################################################
|
2
2
|
"""
|
3
3
|
Module: Fox ESS Cloud using Open API
|
4
|
-
Updated:
|
4
|
+
Updated: 23 September 2024
|
5
5
|
By: Tony Matthews
|
6
6
|
"""
|
7
7
|
##################################################################################################
|
@@ -10,7 +10,7 @@ By: Tony Matthews
|
|
10
10
|
# ALL RIGHTS ARE RESERVED © Tony Matthews 2024
|
11
11
|
##################################################################################################
|
12
12
|
|
13
|
-
version = "2.5.
|
13
|
+
version = "2.5.3"
|
14
14
|
print(f"FoxESS-Cloud Open API version {version}")
|
15
15
|
|
16
16
|
debug_setting = 1
|
@@ -19,7 +19,6 @@ debug_setting = 1
|
|
19
19
|
month_names = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
20
20
|
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
21
21
|
|
22
|
-
|
23
22
|
import os.path
|
24
23
|
import json
|
25
24
|
import time
|
@@ -37,6 +36,9 @@ user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTM
|
|
37
36
|
time_zone = 'Europe/London'
|
38
37
|
lang = 'en'
|
39
38
|
|
39
|
+
# optional path to use for file storage
|
40
|
+
storage = ''
|
41
|
+
|
40
42
|
# global plot parameters
|
41
43
|
figure_width = 9 # width of plots
|
42
44
|
legend_location = "upper right"
|
@@ -1273,7 +1275,7 @@ sample_time = 5.0 # 5 minutes default
|
|
1273
1275
|
sample_rounding = 2 # round to 30 seconds
|
1274
1276
|
|
1275
1277
|
def get_history(time_span='hour', d=None, v=None, summary=1, save=None, load=None, plot=0):
|
1276
|
-
global token, device_sn, debug_setting, var_list, invert_ct2, tariff, max_power_kw, sample_rounding, sample_time, residual_scale
|
1278
|
+
global token, device_sn, debug_setting, var_list, invert_ct2, tariff, max_power_kw, sample_rounding, sample_time, residual_scale, storage
|
1277
1279
|
if get_device() is None:
|
1278
1280
|
return None
|
1279
1281
|
time_span = time_span.lower()
|
@@ -1318,12 +1320,12 @@ def get_history(time_span='hour', d=None, v=None, summary=1, save=None, load=Non
|
|
1318
1320
|
return None
|
1319
1321
|
result = result[0].get('datas')
|
1320
1322
|
else:
|
1321
|
-
file = open(load)
|
1323
|
+
file = open(storage + load)
|
1322
1324
|
result = json.load(file)
|
1323
1325
|
file.close()
|
1324
1326
|
if save is not None:
|
1325
1327
|
file_name = save + "_history_" + time_span + "_" + d[0:10].replace('-','') + ".txt"
|
1326
|
-
file = open(file_name, 'w', encoding='utf-8')
|
1328
|
+
file = open(storage + file_name, 'w', encoding='utf-8')
|
1327
1329
|
json.dump(result, file, indent=4, ensure_ascii= False)
|
1328
1330
|
file.close()
|
1329
1331
|
for var in result:
|
@@ -1539,7 +1541,7 @@ fix_value_threshold = 200000000.0
|
|
1539
1541
|
fix_value_mask = 0x0000FFFF
|
1540
1542
|
|
1541
1543
|
def get_report(dimension='day', d=None, v=None, summary=1, save=None, load=None, plot=0):
|
1542
|
-
global token, device_sn, var_list, debug_setting, report_vars
|
1544
|
+
global token, device_sn, var_list, debug_setting, report_vars, storage
|
1543
1545
|
if get_device() is None:
|
1544
1546
|
return None
|
1545
1547
|
# process list of days
|
@@ -1645,12 +1647,12 @@ def get_report(dimension='day', d=None, v=None, summary=1, save=None, load=None,
|
|
1645
1647
|
for x in v:
|
1646
1648
|
result.append({'variable': x, 'values': [], 'date': d})
|
1647
1649
|
if load is not None:
|
1648
|
-
file = open(load)
|
1650
|
+
file = open(storage + load)
|
1649
1651
|
result = json.load(file)
|
1650
1652
|
file.close()
|
1651
1653
|
elif save is not None:
|
1652
1654
|
file_name = save + "_report_" + dimension + "_" + d.replace('-','') + ".txt"
|
1653
|
-
file = open(file_name, 'w', encoding='utf-8')
|
1655
|
+
file = open(storage + file_name, 'w', encoding='utf-8')
|
1654
1656
|
json.dump(result, file, indent=4, ensure_ascii= False)
|
1655
1657
|
file.close()
|
1656
1658
|
if summary == 0:
|
@@ -2431,18 +2433,17 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
|
|
2431
2433
|
while h < 48:
|
2432
2434
|
day = today if h < 24 else tomorrow
|
2433
2435
|
if forecast.daily.get(day) is None:
|
2434
|
-
value =
|
2436
|
+
value = None
|
2435
2437
|
elif steps_per_hour == 1:
|
2436
|
-
value =
|
2438
|
+
value = forecast.daily[day]['hourly'].get(int(h % 24))
|
2437
2439
|
else:
|
2438
|
-
value =
|
2439
|
-
profile.append(value)
|
2440
|
+
value = forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2))
|
2441
|
+
profile.append(c_float(value))
|
2440
2442
|
h += 1 / steps_per_hour
|
2441
2443
|
while len(profile) < run_time:
|
2442
2444
|
profile.append(0.0)
|
2443
2445
|
return profile[:run_time]
|
2444
2446
|
|
2445
|
-
|
2446
2447
|
# build the timed work mode profile from the tariff strategy:
|
2447
2448
|
def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, current_mode=None):
|
2448
2449
|
global tariff, steps_per_hour
|
@@ -2523,7 +2524,7 @@ base_time = None
|
|
2523
2524
|
|
2524
2525
|
# charge_needed settings
|
2525
2526
|
charge_config = {
|
2526
|
-
'contingency': [
|
2527
|
+
'contingency': [15,10,5,10], # % of consumption. Single value or [winter, spring, summer, autumn]
|
2527
2528
|
'capacity': None, # Battery capacity (over-ride)
|
2528
2529
|
'min_soc': None, # Minimum Soc. Default 10%
|
2529
2530
|
'max_soc': None, # Maximum Soc. Default 100%
|
@@ -2580,7 +2581,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2580
2581
|
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2581
2582
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
2582
2583
|
global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
|
2583
|
-
global timed_strategy, steps_per_hour, base_time
|
2584
|
+
global timed_strategy, steps_per_hour, base_time, storage
|
2584
2585
|
print(f"\n---------------- charge_needed ----------------")
|
2585
2586
|
# validate parameters
|
2586
2587
|
args = locals()
|
@@ -2639,10 +2640,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2639
2640
|
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'force': force_charge})
|
2640
2641
|
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2641
2642
|
time_to_end1 = None
|
2642
|
-
start_now = (int(hour_now * 2 + 1) / 2) % 24
|
2643
2643
|
for t in times:
|
2644
|
-
if hour_in(
|
2645
|
-
|
2644
|
+
if hour_in(hour_now, t) and update_settings > 0:
|
2645
|
+
update_settings = 0
|
2646
|
+
output(f"\nSettings will not be updated during a charge period")
|
2646
2647
|
time_to_start = round_time(t['start'] - base_hour) * steps_per_hour
|
2647
2648
|
time_to_start += hour_adjustment * steps_per_hour if time_to_start > time_change else 0
|
2648
2649
|
charge_time = round_time(t['end'] - t['start'])
|
@@ -2826,7 +2827,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2826
2827
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2827
2828
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2828
2829
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
2829
|
-
output(f"\nSolcast forecast
|
2830
|
+
output(f"\nSolcast forecast for {tomorrow}: {fsolcast.daily[tomorrow]['kwh']:.1f}")
|
2830
2831
|
# get forecast.solar data and produce time line
|
2831
2832
|
solar_value = None
|
2832
2833
|
solar_profile = None
|
@@ -2835,7 +2836,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2835
2836
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2836
2837
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2837
2838
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
2838
|
-
output(f"\nSolar forecast
|
2839
|
+
output(f"\nSolar forecast for {tomorrow}: {fsolar.daily[tomorrow]['kwh']:.1f}")
|
2839
2840
|
if solcast_value is None and solar_value is None and debug_setting > 1:
|
2840
2841
|
output(f"\nNo forecasts available at this time")
|
2841
2842
|
# get generation data
|
@@ -2936,23 +2937,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2936
2937
|
charge_message = "** test charge **"
|
2937
2938
|
# work out charge needed
|
2938
2939
|
if kwh_min > (reserve + kwh_contingency) and kwh_needed < charge_config['min_kwh'] and full_charge is None and test_charge is None and force_charge != 2:
|
2939
|
-
output(f"\nNo charging needed ({today} {hours_time(hour_now)} {current_soc
|
2940
|
+
output(f"\nNo charging needed ({today} {hours_time(hour_now)} {current_soc:.0f}%)")
|
2940
2941
|
charge_message = "no charge needed"
|
2941
2942
|
kwh_needed = 0.0
|
2942
2943
|
hours = 0.0
|
2943
2944
|
start_timed = time_to_end
|
2944
2945
|
end_timed = time_to_end
|
2945
2946
|
end_soc = int(end_residual / capacity * 100 + 0.5)
|
2946
|
-
|
2947
|
-
# rebuild the battery residual with min_soc for battery hold
|
2947
|
+
# update min_soc for battery hold
|
2948
2948
|
if force_charge > 0 and timed_mode > 1:
|
2949
2949
|
for t in range(int(time_to_start), int(time_to_end)):
|
2950
2950
|
work_mode_timed[t]['min_soc'] = start_soc
|
2951
|
-
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
2952
2951
|
else:
|
2953
2952
|
if test_charge is None:
|
2954
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh ({today} {hours_time(hour_now)} {current_soc
|
2953
|
+
output(f"\nCharge needed {kwh_needed:.2f}kWh ({today} {hours_time(hour_now)} {current_soc:.0f}%)")
|
2955
2954
|
charge_message = "with charge added"
|
2955
|
+
output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_start, time_line))} ({start_residual:.2f}kWh)")
|
2956
2956
|
# work out time to add kwh_needed to battery
|
2957
2957
|
taper_time = 10/60 if (start_residual + kwh_needed) >= (capacity * 0.95) else 0
|
2958
2958
|
hours = round_time(kwh_needed / (charge_power * charge_loss) + taper_time)
|
@@ -2969,7 +2969,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2969
2969
|
price = charge_period.get('price') if charge_period is not None else None
|
2970
2970
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
2971
2971
|
end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
|
2972
|
-
output(f" Charge:
|
2972
|
+
output(f" Charge: {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}" + (f" at {price:5.2f}p/kWh" if price is not None else ""))
|
2973
2973
|
for i in range(int(time_to_start), int(end_timed) + 1):
|
2974
2974
|
j = i + 1
|
2975
2975
|
# work out time (fraction of hour) when charging in hour from i to j
|
@@ -2990,18 +2990,14 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2990
2990
|
work_mode_timed[i]['discharge'] *= (1-t)
|
2991
2991
|
elif force_charge > 0 and timed_mode > 1:
|
2992
2992
|
work_mode_timed[i]['min_soc'] = start_soc
|
2993
|
-
|
2994
|
-
|
2995
|
-
|
2996
|
-
|
2997
|
-
|
2998
|
-
|
2999
|
-
|
3000
|
-
|
3001
|
-
output(f"\nTime, Generation, Charge, Consumption, Discharge, Residual, kWh")
|
3002
|
-
for i in range(0, run_time):
|
3003
|
-
h = base_hour + i / steps_per_hour
|
3004
|
-
output(f" {hours_time(h)}, {generation_timed[i]:6.3f}, {charge_timed[i]:6.3f}, {consumption_timed[i]:6.3f}, {discharge_timed[i]:6.3f}, {bat_timed[i]:6.3f}")
|
2993
|
+
# rebuild the battery residual with any charge added and min_soc
|
2994
|
+
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
2995
|
+
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
2996
|
+
# show the results
|
2997
|
+
output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
|
2998
|
+
output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
|
2999
|
+
if not charge_today:
|
3000
|
+
output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
|
3005
3001
|
if show_data > 0:
|
3006
3002
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3007
3003
|
s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC:\n"
|
@@ -3055,7 +3051,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3055
3051
|
data['work_mode'] = work_mode_timed
|
3056
3052
|
data['generation'] = generation_timed
|
3057
3053
|
data['consumption'] = consumption_timed
|
3058
|
-
file = open(file_name, 'w')
|
3054
|
+
file = open(storage + file_name, 'w')
|
3059
3055
|
json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3060
3056
|
file.close()
|
3061
3057
|
# setup charging
|
@@ -3080,13 +3076,13 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3080
3076
|
##################################################################################################
|
3081
3077
|
|
3082
3078
|
def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
3083
|
-
global charge_config
|
3079
|
+
global charge_config, storage
|
3084
3080
|
if save is None and charge_config.get('save') is not None:
|
3085
3081
|
save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
|
3086
3082
|
if save is None:
|
3087
3083
|
print(f"** charge_compare(): please provide a saved file to load")
|
3088
3084
|
return
|
3089
|
-
file = open(save)
|
3085
|
+
file = open(storage + save)
|
3090
3086
|
data = json.load(file)
|
3091
3087
|
file.close()
|
3092
3088
|
if data is None or data.get('base_time') is None:
|
@@ -3106,7 +3102,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3106
3102
|
run_time = len(time_line)
|
3107
3103
|
base_hour = int(time_hours(base_time[11:16]))
|
3108
3104
|
start_day = base_time[:10]
|
3109
|
-
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc
|
3105
|
+
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:.0f}%")
|
3110
3106
|
now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
|
3111
3107
|
end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
|
3112
3108
|
if v is None:
|
@@ -3349,6 +3345,7 @@ def write(f, s, m='a'):
|
|
3349
3345
|
# log battery information in CSV format at 'interval' minutes apart for 'run' times
|
3350
3346
|
# log 1: battery info, 2: add cell volts, 3: add cell temps
|
3351
3347
|
def battery_monitor(interval=30, run=48, log=1, count=None, save=None, overwrite=0):
|
3348
|
+
global storage
|
3352
3349
|
run_time = interval * run / 60
|
3353
3350
|
print(f"\n---------------- battery_monitor ------------------")
|
3354
3351
|
print(f"Expected runtime = {hours_time(run_time, day=True)} (hh:mm/days)")
|
@@ -3357,7 +3354,7 @@ def battery_monitor(interval=30, run=48, log=1, count=None, save=None, overwrite
|
|
3357
3354
|
print()
|
3358
3355
|
s = f"time,soc,residual,bat_volt,bat_current,bat_temp,nbat,ncell,ntemp,volts*,imbalance*,temps*"
|
3359
3356
|
s += ",cell_volts*" if log == 2 else ",cell_volts*,cell_temps*" if log ==3 else ""
|
3360
|
-
write(save, s, 'w' if overwrite == 1 else 'a')
|
3357
|
+
write(storage + save, s, 'w' if overwrite == 1 else 'a')
|
3361
3358
|
i = run
|
3362
3359
|
while i > 0:
|
3363
3360
|
t1 = time.time()
|
@@ -3698,17 +3695,18 @@ class Solcast :
|
|
3698
3695
|
# reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
|
3699
3696
|
# The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
|
3700
3697
|
# The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
|
3701
|
-
global debug_setting, solcast_url, solcast_api_key, solcast_save
|
3698
|
+
global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
|
3702
3699
|
self.data = {}
|
3703
|
-
self.shading = shading
|
3700
|
+
self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
|
3704
3701
|
self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
|
3702
|
+
self.quarter = int(self.today[5:7]) // 3 % 4
|
3705
3703
|
self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
|
3706
3704
|
self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
|
3707
3705
|
self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
|
3708
|
-
if reload == 1 and os.path.exists(self.save):
|
3709
|
-
os.remove(self.save)
|
3710
|
-
if self.save is not None and os.path.exists(self.save):
|
3711
|
-
file = open(self.save)
|
3706
|
+
if reload == 1 and os.path.exists(storage + self.save):
|
3707
|
+
os.remove(storage + self.save)
|
3708
|
+
if self.save is not None and os.path.exists(storage + self.save):
|
3709
|
+
file = open(storage + self.save)
|
3712
3710
|
self.data = json.load(file)
|
3713
3711
|
file.close()
|
3714
3712
|
if len(self.data) == 0:
|
@@ -3749,12 +3747,13 @@ class Solcast :
|
|
3749
3747
|
return
|
3750
3748
|
self.data[t][rid] = response.json().get(t)
|
3751
3749
|
if self.save is not None :
|
3752
|
-
file = open(self.save, 'w')
|
3750
|
+
file = open(storage + self.save, 'w')
|
3753
3751
|
json.dump(self.data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3754
3752
|
file.close()
|
3755
3753
|
self.daily = {}
|
3754
|
+
estimated = 0 if self.data.get('estimated_actuals') is None else 1
|
3756
3755
|
loaded = {} # track what we have loaded so we don't duplicate between forecast and actuals
|
3757
|
-
for t in ['forecasts'] if
|
3756
|
+
for t in ['forecasts'] if estimated == 0 else ['forecasts', 'estimated_actuals']:
|
3758
3757
|
for rid in self.data[t].keys() : # aggregate sites
|
3759
3758
|
if loaded.get(rid) is None:
|
3760
3759
|
loaded[rid] = {}
|
@@ -3774,33 +3773,37 @@ class Solcast :
|
|
3774
3773
|
self.daily[date]['pt30'][key] = 0.0
|
3775
3774
|
self.daily [date]['pt30'][key] += value
|
3776
3775
|
# ignore first and last dates as these only cover part of the day, so are not accurate
|
3777
|
-
self.keys = sorted(self.daily.keys())[
|
3776
|
+
self.keys = sorted(self.daily.keys())[estimated:-1]
|
3778
3777
|
self.days = len(self.keys)
|
3779
3778
|
# trim the range if fewer days have been requested
|
3780
|
-
while self.days >
|
3781
|
-
self.keys = self.keys[
|
3779
|
+
while self.days > days * (1 + estimated) :
|
3780
|
+
self.keys = self.keys[estimated:-1]
|
3782
3781
|
self.days = len(self.keys)
|
3783
|
-
# fill out forecast to cover 24 hours
|
3782
|
+
# fill out forecast to cover 24 hours and set forecast start time
|
3784
3783
|
for date in self.keys:
|
3785
3784
|
for t in [hours_time(t / 2) for t in range(0,48)]:
|
3786
3785
|
if self.daily[date]['pt30'].get(t) is None:
|
3787
3786
|
self.daily[date]['pt30'][t] = 0.0
|
3787
|
+
elif self.daily[date].get('from') is None:
|
3788
|
+
self.daily[date]['from'] = t
|
3788
3789
|
# apply shading
|
3789
|
-
if self.shading is not None
|
3790
|
+
if self.shading is not None:
|
3790
3791
|
for date in self.keys:
|
3791
3792
|
times = sorted(time_hours(t) for t in self.daily[date]['pt30'].keys())
|
3792
|
-
if self.shading
|
3793
|
-
loss = self.shading['
|
3793
|
+
if self.shading.get('adjust') is not None:
|
3794
|
+
loss = self.shading['adjust'] if type(self.shading['adjust']) is not list else self.shading['adjust'][self.quarter]
|
3794
3795
|
for t in times:
|
3795
3796
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3796
|
-
if self.shading
|
3797
|
-
|
3798
|
-
|
3797
|
+
if self.shading.get('am_delay') is not None:
|
3798
|
+
delay = self.shading['am_delay'] if type(self.shading['am_delay']) is not list else self.shading['am_delay'][self.quarter]
|
3799
|
+
shaded = time_hours(self.daily[date]['sun'][0]) + delay
|
3800
|
+
loss = self.shading['am_loss']
|
3799
3801
|
for t in [t for t in times if t < shaded]:
|
3800
3802
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3801
|
-
if self.shading
|
3802
|
-
|
3803
|
-
|
3803
|
+
if self.shading.get('pm_delay') is not None:
|
3804
|
+
delay = self.shading['pm_delay'] if type(self.shading['pm_delay']) is not list else self.shading['pm_delay'][self.quarter]
|
3805
|
+
shaded = time_hours(self.daily[date]['sun'][1]) - delay
|
3806
|
+
loss = self.shading['pm_loss']
|
3804
3807
|
for t in [t for t in times if t > shaded]:
|
3805
3808
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3806
3809
|
# calculate hourly values and total
|
@@ -4035,18 +4038,19 @@ class Solar :
|
|
4035
4038
|
|
4036
4039
|
# get solar forecast and return total expected yield
|
4037
4040
|
def __init__(self, reload=0, quiet=False, shading=None):
|
4038
|
-
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key
|
4039
|
-
self.shading = shading
|
4041
|
+
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
|
4042
|
+
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4040
4043
|
self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
|
4044
|
+
self.quarter = int(self.today[5:7]) // 3 % 4
|
4041
4045
|
self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
|
4046
|
+
self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
|
4042
4047
|
self.arrays = None
|
4043
4048
|
self.results = None
|
4044
|
-
self.shading = shading
|
4045
4049
|
self.save = solar_save #.replace('.', '_%.'.replace('%',self.today.replace('-','')))
|
4046
|
-
if reload == 1 and os.path.exists(self.save):
|
4047
|
-
os.remove(self.save)
|
4048
|
-
if self.save is not None and os.path.exists(self.save):
|
4049
|
-
file = open(self.save)
|
4050
|
+
if reload == 1 and os.path.exists(storage + self.save):
|
4051
|
+
os.remove(storage + self.save)
|
4052
|
+
if self.save is not None and os.path.exists(storage + self.save):
|
4053
|
+
file = open(storage + self.save)
|
4050
4054
|
data = json.load(file)
|
4051
4055
|
file.close()
|
4052
4056
|
if data.get('date') is not None and (data['date'] == self.today and reload != 1):
|
@@ -4077,7 +4081,7 @@ class Solar :
|
|
4077
4081
|
if self.save is not None :
|
4078
4082
|
if debug_setting > 0 and not quiet:
|
4079
4083
|
print(f"Saving data to {self.save}")
|
4080
|
-
file = open(self.save, 'w')
|
4084
|
+
file = open(storage + self.save, 'w')
|
4081
4085
|
json.dump({'date': self.today, 'arrays': self.arrays, 'results': self.results}, file, indent=4, ensure_ascii= False)
|
4082
4086
|
file.close()
|
4083
4087
|
self.daily = {}
|
@@ -4105,18 +4109,20 @@ class Solar :
|
|
4105
4109
|
if self.shading is not None and self.shading.get('solar') is not None:
|
4106
4110
|
for date in self.keys:
|
4107
4111
|
times = sorted(time_hours(t) for t in self.daily[date]['pt30'].keys())
|
4108
|
-
if self.shading
|
4109
|
-
loss = self.shading['
|
4112
|
+
if self.shading.get('adjust') is not None:
|
4113
|
+
loss = self.shading['adjust'] if type(self.shading['adjust']) is not list else self.shading['adjust'][self.quarter]
|
4110
4114
|
for t in times:
|
4111
4115
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4112
|
-
if self.shading
|
4113
|
-
|
4114
|
-
|
4116
|
+
if self.shading.get('am_delay') is not None:
|
4117
|
+
delay = self.shading['am_delay'] if type(self.shading['am_delay']) is not list else self.shading['am_delay'][self.quarter]
|
4118
|
+
shaded = time_hours(self.daily[date]['sun'][0]) + delay
|
4119
|
+
loss = self.shading['am_loss']
|
4115
4120
|
for t in [t for t in times if t < shaded]:
|
4116
4121
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4117
|
-
if self.shading
|
4118
|
-
|
4119
|
-
|
4122
|
+
if self.shading.get('pm_delay') is not None:
|
4123
|
+
delay = self.shading['pm_delay'] if type(self.shading['pm_delay']) is not list else self.shading['pm_delay'][self.quarter]
|
4124
|
+
shaded = time_hours(self.daily[date]['sun'][1]) - delay
|
4125
|
+
loss = self.shading['pm_loss']
|
4120
4126
|
for t in [t for t in times if t > shaded]:
|
4121
4127
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4122
4128
|
# calculate hourly values and total
|
@@ -4335,7 +4341,7 @@ pushover_url = "https://api.pushover.net/1/messages.json"
|
|
4335
4341
|
foxesscloud_app_key = "aqj8up6jeg9hu4zr1pgir3368vda4q"
|
4336
4342
|
|
4337
4343
|
def pushover_post(message, file=None, app_key=None):
|
4338
|
-
global pushover_user_key, pushover_url, foxesscloud_app_key, debug_setting
|
4344
|
+
global pushover_user_key, pushover_url, foxesscloud_app_key, debug_setting, storage
|
4339
4345
|
if pushover_user_key is None or message is None:
|
4340
4346
|
return None
|
4341
4347
|
if app_key is None:
|
@@ -4343,7 +4349,7 @@ def pushover_post(message, file=None, app_key=None):
|
|
4343
4349
|
if len(message) > 1024:
|
4344
4350
|
message = message[-1024:]
|
4345
4351
|
body = {'token': app_key, 'user': pushover_user_key, 'message': message}
|
4346
|
-
files = {'attachment': open(file, 'rb')} if file is not None else None
|
4352
|
+
files = {'attachment': open(storage + file, 'rb')} if file is not None else None
|
4347
4353
|
response = requests.post(pushover_url, data=body, files=files)
|
4348
4354
|
if response.status_code != 200:
|
4349
4355
|
print(f"** pushover_post() got response code {response.status_code}: {response.reason}")
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: foxesscloud
|
3
|
-
Version: 2.5.
|
3
|
+
Version: 2.5.3
|
4
4
|
Summary: library for accessing Fox ESS cloud data using Open API
|
5
5
|
Author-email: Tony Matthews <tony@quasair.co.uk>
|
6
6
|
Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
|
@@ -71,6 +71,9 @@ If a value is set for f.plot_file, any charts created will also be saved to an i
|
|
71
71
|
|
72
72
|
If you set f.pushover_user_key to your user_key for pushover.net, a summary from set_tariff(), charge_needed(), set_pvoutput() and battery_info() will be sent to your pushover app.
|
73
73
|
|
74
|
+
You can set 'f.storage' to a path to save files to a different location such as cloud storage. The default is to use the current working directory.
|
75
|
+
|
76
|
+
|
74
77
|
## User info
|
75
78
|
Return information about the current user:
|
76
79
|
|
@@ -742,7 +745,7 @@ f.get_suntimes(date, utc)
|
|
742
745
|
+ date: 'YYYY-MM-DD'
|
743
746
|
+ utc: 1 = return time in UTC. 0 = return local time (default)
|
744
747
|
|
745
|
-
'shading' adjusts the forecast and sets delays and losses caused as the sun rise and set for Solcast and Solar. The forecast is multiplied by 'adjust'. The (AM/PM) delay is the time after sunrise or before sunset that is applied and 'loss' is the amount that the solar forecast is reduced. The default structure above is used by Solcast and Solar when called from charge_needed() or they can be passed directly as parameters when forecasts are being created using 'f.Solcast()' and 'f.Solar()'.
|
748
|
+
'shading' adjusts the forecast and sets delays and losses caused as the sun rise and set for Solcast and Solar. The forecast is multiplied by 'adjust'. The (AM/PM) delay is the time after sunrise or before sunset that is applied and 'loss' is the amount that the solar forecast is reduced. The default structure above is used by Solcast and Solar when called from charge_needed() or they can be passed directly as parameters when forecasts are being created using 'f.Solcast()' and 'f.Solar()'. If the delays are presented as a list, they are values for winter, spring, summer and autumn.
|
746
749
|
|
747
750
|
|
748
751
|
# Pushover
|
@@ -782,6 +785,13 @@ This setting can be:
|
|
782
785
|
|
783
786
|
# Version Info
|
784
787
|
|
788
|
+
2.5.3<br>
|
789
|
+
Reverted change to allow updates during a charge period to avoid removing charge in progress.
|
790
|
+
Update contingency and show how this relates to battery SoC.
|
791
|
+
Add PV cover, the ratio of PV generation to consumption.
|
792
|
+
Add f.storage path so files can be saved to different locations if needed.
|
793
|
+
Allow delays in 'shading' to be a seaonal list of 4 values (winter, spring, summer, autumn).
|
794
|
+
|
785
795
|
2.5.2<br>
|
786
796
|
Updates to allow charge_needed() to run during a charge period.
|
787
797
|
Add suport for 'off_peak4' charge period.
|
@@ -0,0 +1,7 @@
|
|
1
|
+
foxesscloud/foxesscloud.py,sha256=LkRglTcW4sAVPRgXxkhqyt7fcDcDk00ui_nnsdSU51Y,210817
|
2
|
+
foxesscloud/openapi.py,sha256=2hyHrGpYNUQybmKCLRZmujW8CfnyS0bjKdxZwPpkjCU,204531
|
3
|
+
foxesscloud-2.5.3.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
|
4
|
+
foxesscloud-2.5.3.dist-info/METADATA,sha256=iXU6pABWNwPSUXW4-NrJWCiy3ZecYsiJPIKNAamHR-g,54937
|
5
|
+
foxesscloud-2.5.3.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
6
|
+
foxesscloud-2.5.3.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
|
7
|
+
foxesscloud-2.5.3.dist-info/RECORD,,
|
@@ -1,7 +0,0 @@
|
|
1
|
-
foxesscloud/foxesscloud.py,sha256=sf5LJSJ2gP_Bs87rMVgfFJJLhU_tdOAM0aun2J0ZNlk,210279
|
2
|
-
foxesscloud/openapi.py,sha256=8mgfPW7DZg9m7rjTLaf1uofY6mco0Ad4xv0o03pZML8,203654
|
3
|
-
foxesscloud-2.5.2.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
|
4
|
-
foxesscloud-2.5.2.dist-info/METADATA,sha256=wQD_1dfXG1Ta5UOnSVyq6L2NtRR6PB2gHySnGV_XD9U,54292
|
5
|
-
foxesscloud-2.5.2.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
6
|
-
foxesscloud-2.5.2.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
|
7
|
-
foxesscloud-2.5.2.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|