foxesscloud 2.5.2__py3-none-any.whl → 2.5.4__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 +118 -119
- foxesscloud/openapi.py +114 -111
- {foxesscloud-2.5.2.dist-info → foxesscloud-2.5.4.dist-info}/METADATA +21 -7
- foxesscloud-2.5.4.dist-info/RECORD +7 -0
- foxesscloud-2.5.2.dist-info/RECORD +0 -7
- {foxesscloud-2.5.2.dist-info → foxesscloud-2.5.4.dist-info}/LICENCE +0 -0
- {foxesscloud-2.5.2.dist-info → foxesscloud-2.5.4.dist-info}/WHEEL +0 -0
- {foxesscloud-2.5.2.dist-info → foxesscloud-2.5.4.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: 25 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.6"
|
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:
|
@@ -2266,11 +2264,6 @@ regions = {'A':'Eastern England', 'B':'East Midlands', 'C':'London', 'D':'Mersey
|
|
2266
2264
|
'J':'South Eastern England', 'K':'Southern Wales', 'L':'South Western England', 'M':'Yorkshire', 'N':'Southern Scotland', 'P':'Northern Scotland'}
|
2267
2265
|
|
2268
2266
|
|
2269
|
-
# preset weightings for average 30 minute pricing over charging duration:
|
2270
|
-
front_loaded = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5] # 3 hour average, front loaded
|
2271
|
-
first_hour = [1.0, 1.0] # lowest average price for first hour
|
2272
|
-
|
2273
|
-
|
2274
2267
|
tariff_config = {
|
2275
2268
|
'product': "AGILE-24-04-03", # product code to use for Octopus API
|
2276
2269
|
'region': "H", # region code to use for Octopus API
|
@@ -2279,7 +2272,7 @@ tariff_config = {
|
|
2279
2272
|
'plunge_price': [1, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
|
2280
2273
|
'plunge_slots': 6, # number of 30 minute slots to use
|
2281
2274
|
'data_wrap': 6, # prices to show per line
|
2282
|
-
'show_data':
|
2275
|
+
'show_data': 1, # show pricing data
|
2283
2276
|
'show_plot': 1 # plot pricing data
|
2284
2277
|
}
|
2285
2278
|
|
@@ -2363,13 +2356,13 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2363
2356
|
# show the results
|
2364
2357
|
if tariff_config['show_data'] > 0:
|
2365
2358
|
data_wrap = tariff_config['data_wrap'] if tariff_config.get('data_wrap') is not None else 6
|
2366
|
-
|
2367
|
-
s = f"\
|
2359
|
+
col = (now.hour * 2) % data_wrap
|
2360
|
+
s = f"\nPrice p/kWh inc VAT on {today}:"
|
2368
2361
|
for i in range(0, len(prices)):
|
2369
|
-
s += "\n" if i
|
2370
|
-
s += f" {prices[i]['
|
2371
|
-
|
2372
|
-
output(s
|
2362
|
+
s += (f"\n {prices[i]['time']} " + " " * col * 6) if i == 0 or col == 0 else ""
|
2363
|
+
s += f" {prices[i]['price']:4.1f}"
|
2364
|
+
col = (col + 1) % data_wrap
|
2365
|
+
output(s)
|
2373
2366
|
if tariff_config['show_plot'] > 0:
|
2374
2367
|
plt.figure(figsize=(figure_width, figure_width/2))
|
2375
2368
|
x_timed = [i for i in range(0, len(prices))]
|
@@ -2396,7 +2389,8 @@ def get_best_charge_period(start, duration):
|
|
2396
2389
|
key = [k for k in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4'] if hour_in(start, tariff.get(k))]
|
2397
2390
|
key = key[0] if len(key) > 0 else None
|
2398
2391
|
end = tariff[key]['end'] if key is not None else round_time(start + duration)
|
2399
|
-
span = int(duration * 2 + 0.99)
|
2392
|
+
span = int(duration * 2 + 0.99) # number of slots needed for charging
|
2393
|
+
last = (duration * 2) % 1 # amount of last slot used for charging
|
2400
2394
|
coverage = max([round_time(end - start), duration])
|
2401
2395
|
period = {'start': start, 'end': round_time(start + coverage)}
|
2402
2396
|
prices = tariff['agile']['prices']
|
@@ -2405,13 +2399,14 @@ def get_best_charge_period(start, duration):
|
|
2405
2399
|
return None
|
2406
2400
|
elif len(slots) == 1:
|
2407
2401
|
best = slots
|
2408
|
-
price = prices[slots[0]]['price']
|
2409
2402
|
best_start = start
|
2403
|
+
price = prices[best[0]]['price']
|
2410
2404
|
else:
|
2411
2405
|
# best charge time for duration
|
2412
2406
|
weighting = tariff_config.get('weighting')
|
2413
2407
|
times = []
|
2414
|
-
weights = ([1.0] * span) if weighting is None else (weighting + [1.0] * span)[:span]
|
2408
|
+
weights = ([1.0] * (span)) if weighting is None else (weighting + [1.0] * span)[:span]
|
2409
|
+
weights[-1] *= last if last > 0.0 else 1.0
|
2415
2410
|
best = None
|
2416
2411
|
price = None
|
2417
2412
|
for i in range(0, len(slots) - span + 1):
|
@@ -2571,12 +2566,12 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
|
|
2571
2566
|
while h < 48:
|
2572
2567
|
day = today if h < 24 else tomorrow
|
2573
2568
|
if forecast.daily.get(day) is None:
|
2574
|
-
value =
|
2569
|
+
value = None
|
2575
2570
|
elif steps_per_hour == 1:
|
2576
|
-
value =
|
2571
|
+
value = forecast.daily[day]['hourly'].get(int(h % 24))
|
2577
2572
|
else:
|
2578
|
-
value =
|
2579
|
-
profile.append(value)
|
2573
|
+
value = forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2))
|
2574
|
+
profile.append(c_float(value))
|
2580
2575
|
h += 1 / steps_per_hour
|
2581
2576
|
while len(profile) < run_time:
|
2582
2577
|
profile.append(0.0)
|
@@ -2662,7 +2657,7 @@ base_time = None
|
|
2662
2657
|
|
2663
2658
|
# charge_needed settings
|
2664
2659
|
charge_config = {
|
2665
|
-
'contingency': [
|
2660
|
+
'contingency': [15,10,5,10], # % of consumption. Single value or [winter, spring, summer, autumn]
|
2666
2661
|
'capacity': None, # Battery capacity (over-ride)
|
2667
2662
|
'min_soc': None, # Minimum Soc. Default 10%
|
2668
2663
|
'max_soc': None, # Maximum Soc. Default 100%
|
@@ -2719,7 +2714,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2719
2714
|
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2720
2715
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
2721
2716
|
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
|
2717
|
+
global timed_strategy, steps_per_hour, base_time, storage
|
2723
2718
|
print(f"\n---------------- charge_needed ----------------")
|
2724
2719
|
# validate parameters
|
2725
2720
|
args = locals()
|
@@ -2778,10 +2773,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2778
2773
|
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'force': force_charge})
|
2779
2774
|
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2780
2775
|
time_to_end1 = None
|
2781
|
-
start_now = (int(hour_now * 2 + 1) / 2) % 24
|
2782
2776
|
for t in times:
|
2783
|
-
if hour_in(
|
2784
|
-
|
2777
|
+
if hour_in(hour_now, t) and update_settings > 0:
|
2778
|
+
update_settings = 0
|
2779
|
+
output(f"\nSettings will not be updated during a charge period")
|
2785
2780
|
time_to_start = round_time(t['start'] - base_hour) * steps_per_hour
|
2786
2781
|
time_to_start += hour_adjustment * steps_per_hour if time_to_start > time_change else 0
|
2787
2782
|
charge_time = round_time(t['end'] - t['start'])
|
@@ -2965,8 +2960,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2965
2960
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2966
2961
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2967
2962
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
2968
|
-
|
2969
|
-
# get forecast.solar data and produce time line
|
2963
|
+
solcast_from = time_hours(fsolcast.daily[today]['from']) if fsolcast.daily[today].get('from') is not None else 0
|
2964
|
+
output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh") # get forecast.solar data and produce time line
|
2970
2965
|
solar_value = None
|
2971
2966
|
solar_profile = None
|
2972
2967
|
if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
|
@@ -2974,7 +2969,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2974
2969
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2975
2970
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2976
2971
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
2977
|
-
output(f"\nSolar
|
2972
|
+
output(f"\nSolar: {tomorrow} {fsolar.daily[tomorrow]['kwh']:.1f}kWh")
|
2978
2973
|
if solcast_value is None and solar_value is None and debug_setting > 1:
|
2979
2974
|
output(f"\nNo forecasts available at this time")
|
2980
2975
|
# get generation data
|
@@ -3075,23 +3070,24 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3075
3070
|
charge_message = "** test charge **"
|
3076
3071
|
# work out charge needed
|
3077
3072
|
if kwh_min > (reserve + kwh_contingency) and kwh_needed < charge_config['min_kwh']:
|
3078
|
-
output(f"\nNo charging needed
|
3073
|
+
output(f"\nNo charging needed:")
|
3074
|
+
output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
|
3079
3075
|
charge_message = "no charge needed"
|
3080
3076
|
kwh_needed = 0.0
|
3081
3077
|
hours = 0.0
|
3082
3078
|
start_timed = time_to_end
|
3083
3079
|
end_timed = time_to_end
|
3084
3080
|
end_soc = int(end_residual / capacity * 100 + 0.5)
|
3085
|
-
|
3086
|
-
# rebuild the battery residual with min_soc for battery hold
|
3081
|
+
# update min_soc for battery hold
|
3087
3082
|
if force_charge > 0 and timed_mode > 1:
|
3088
3083
|
for t in range(int(time_to_start), int(time_to_end)):
|
3089
3084
|
work_mode_timed[t]['min_soc'] = start_soc
|
3090
|
-
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3091
3085
|
else:
|
3092
3086
|
if test_charge is None:
|
3093
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh
|
3087
|
+
output(f"\nCharge needed {kwh_needed:.2f}kWh:")
|
3094
3088
|
charge_message = "with charge added"
|
3089
|
+
output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
|
3090
|
+
output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_start, time_line))} ({start_residual:.2f}kWh)")
|
3095
3091
|
# work out time to add kwh_needed to battery
|
3096
3092
|
taper_time = 10/60 if (start_residual + kwh_needed) >= (capacity * 0.95) else 0
|
3097
3093
|
hours = round_time(kwh_needed / (charge_power * charge_loss) + taper_time)
|
@@ -3108,7 +3104,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3108
3104
|
price = charge_period.get('price') if charge_period is not None else None
|
3109
3105
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
3110
3106
|
end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
|
3111
|
-
output(f" Charge:
|
3107
|
+
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
3108
|
for i in range(int(time_to_start), int(end_timed) + 1):
|
3113
3109
|
j = i + 1
|
3114
3110
|
# work out time (fraction of hour) when charging in hour from i to j
|
@@ -3129,31 +3125,26 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3129
3125
|
work_mode_timed[i]['discharge'] *= (1-t)
|
3130
3126
|
elif force_charge > 0 and timed_mode > 1:
|
3131
3127
|
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}")
|
3128
|
+
# rebuild the battery residual with the charge added and min_soc
|
3129
|
+
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3130
|
+
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
3131
|
+
# show the results
|
3132
|
+
output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
|
3133
|
+
output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
|
3134
|
+
if not charge_today:
|
3135
|
+
output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
|
3144
3136
|
if show_data > 0:
|
3145
3137
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3146
|
-
s = f"\nBattery Energy kWh
|
3138
|
+
s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
|
3147
3139
|
h = base_hour + 1
|
3148
3140
|
t = steps_per_hour
|
3149
|
-
s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
|
3150
3141
|
while t < len(time_line) and bat_timed[t] is not None:
|
3151
|
-
|
3152
|
-
s += f" {hours_time(time_line[t])}"
|
3153
|
-
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f"
|
3142
|
+
col = h % data_wrap
|
3143
|
+
s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == steps_per_hour or col == 0 else ""
|
3144
|
+
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
|
3154
3145
|
h += 1
|
3155
3146
|
t += steps_per_hour
|
3156
|
-
output(s
|
3147
|
+
output(s)
|
3157
3148
|
if show_plot > 0:
|
3158
3149
|
print()
|
3159
3150
|
plt.figure(figsize=(figure_width, figure_width/2))
|
@@ -3194,7 +3185,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3194
3185
|
data['work_mode'] = work_mode_timed
|
3195
3186
|
data['generation'] = generation_timed
|
3196
3187
|
data['consumption'] = consumption_timed
|
3197
|
-
file = open(file_name, 'w')
|
3188
|
+
file = open(storage + file_name, 'w')
|
3198
3189
|
json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3199
3190
|
file.close()
|
3200
3191
|
# setup charging
|
@@ -3220,13 +3211,13 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3220
3211
|
##################################################################################################
|
3221
3212
|
|
3222
3213
|
def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
3223
|
-
global charge_config
|
3214
|
+
global charge_config, storage
|
3224
3215
|
if save is None and charge_config.get('save') is not None:
|
3225
3216
|
save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
|
3226
3217
|
if save is None:
|
3227
3218
|
print(f"** charge_compare(): please provide a saved file to load")
|
3228
3219
|
return
|
3229
|
-
file = open(save)
|
3220
|
+
file = open(storage + save)
|
3230
3221
|
data = json.load(file)
|
3231
3222
|
file.close()
|
3232
3223
|
if data is None or data.get('base_time') is None:
|
@@ -3246,7 +3237,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3246
3237
|
run_time = len(time_line)
|
3247
3238
|
base_hour = int(time_hours(base_time[11:16]))
|
3248
3239
|
start_day = base_time[:10]
|
3249
|
-
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc
|
3240
|
+
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:.0f}%")
|
3250
3241
|
now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
|
3251
3242
|
end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
|
3252
3243
|
if v is None:
|
@@ -3281,17 +3272,16 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3281
3272
|
plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
|
3282
3273
|
if show_data > 0 and plots.get('SoC') is not None:
|
3283
3274
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3284
|
-
s = f"\nBattery Energy kWh
|
3275
|
+
s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
|
3285
3276
|
h = base_hour + 1
|
3286
3277
|
t = steps_per_hour
|
3287
|
-
|
3288
|
-
|
3289
|
-
s += "\n" if t
|
3290
|
-
s += f" {
|
3291
|
-
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%,"
|
3278
|
+
while t < len(time_line) and bat_timed[t] is not None:
|
3279
|
+
col = h % data_wrap
|
3280
|
+
s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == steps_per_hour or col == 0 else ""
|
3281
|
+
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
|
3292
3282
|
h += 1
|
3293
3283
|
t += steps_per_hour
|
3294
|
-
print(s
|
3284
|
+
print(s)
|
3295
3285
|
if show_plot > 0:
|
3296
3286
|
print()
|
3297
3287
|
plt.figure(figsize=(figure_width, figure_width/2))
|
@@ -3490,15 +3480,16 @@ def write(f, s, m='a'):
|
|
3490
3480
|
# log battery information in CSV format at 'interval' minutes apart for 'run' times
|
3491
3481
|
# log 1: battery info, 2: add cell volts, 3: add cell temps
|
3492
3482
|
def battery_monitor(interval=30, run=48, log=1, count=None, save=None, overwrite=0):
|
3483
|
+
global storage
|
3493
3484
|
run_time = interval * run / 60
|
3494
3485
|
print(f"\n---------------- battery_monitor ------------------")
|
3495
3486
|
print(f"Expected runtime = {hours_time(run_time, day=True)} (hh:mm/days)")
|
3496
3487
|
if save is not None:
|
3497
|
-
print(f"Saving data to {save} ")
|
3488
|
+
print(f"Saving data to {storage + save} ")
|
3498
3489
|
print()
|
3499
3490
|
s = f"time,soc,residual,bat_volt,bat_current,bat_temp,nbat,ncell,ntemp,volts*,imbalance*,temps*"
|
3500
3491
|
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')
|
3492
|
+
write(storage + save, s, 'w' if overwrite == 1 else 'a')
|
3502
3493
|
i = run
|
3503
3494
|
while i > 0:
|
3504
3495
|
t1 = time.time()
|
@@ -3839,17 +3830,18 @@ class Solcast :
|
|
3839
3830
|
# reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
|
3840
3831
|
# The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
|
3841
3832
|
# 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
|
3833
|
+
global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
|
3843
3834
|
self.data = {}
|
3844
3835
|
self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
|
3845
3836
|
self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
|
3837
|
+
self.quarter = int(self.today[5:7]) // 3 % 4
|
3846
3838
|
self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
|
3847
3839
|
self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
|
3848
3840
|
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)
|
3841
|
+
if reload == 1 and os.path.exists(storage + self.save):
|
3842
|
+
os.remove(storage + self.save)
|
3843
|
+
if self.save is not None and os.path.exists(storage + self.save):
|
3844
|
+
file = open(storage + self.save)
|
3853
3845
|
self.data = json.load(file)
|
3854
3846
|
file.close()
|
3855
3847
|
if len(self.data) == 0:
|
@@ -3890,7 +3882,7 @@ class Solcast :
|
|
3890
3882
|
return
|
3891
3883
|
self.data[t][rid] = response.json().get(t)
|
3892
3884
|
if self.save is not None :
|
3893
|
-
file = open(self.save, 'w')
|
3885
|
+
file = open(storage + self.save, 'w')
|
3894
3886
|
json.dump(self.data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3895
3887
|
file.close()
|
3896
3888
|
self.daily = {}
|
@@ -3922,26 +3914,30 @@ class Solcast :
|
|
3922
3914
|
while self.days > days * (1 + estimated) :
|
3923
3915
|
self.keys = self.keys[estimated:-1]
|
3924
3916
|
self.days = len(self.keys)
|
3925
|
-
# fill out forecast to cover 24 hours
|
3917
|
+
# fill out forecast to cover 24 hours and set forecast start time
|
3926
3918
|
for date in self.keys:
|
3927
3919
|
for t in [hours_time(t / 2) for t in range(0,48)]:
|
3928
3920
|
if self.daily[date]['pt30'].get(t) is None:
|
3929
3921
|
self.daily[date]['pt30'][t] = 0.0
|
3922
|
+
elif self.daily[date].get('from') is None:
|
3923
|
+
self.daily[date]['from'] = t
|
3930
3924
|
# apply shading
|
3931
3925
|
if self.shading is not None:
|
3932
3926
|
for date in self.keys:
|
3933
3927
|
times = sorted(time_hours(t) for t in self.daily[date]['pt30'].keys())
|
3934
3928
|
if self.shading.get('adjust') is not None:
|
3935
|
-
loss = self.shading['adjust']
|
3929
|
+
loss = self.shading['adjust'] if type(self.shading['adjust']) is not list else self.shading['adjust'][self.quarter]
|
3936
3930
|
for t in times:
|
3937
3931
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3938
3932
|
if self.shading.get('am_delay') is not None:
|
3939
|
-
|
3933
|
+
delay = self.shading['am_delay'] if type(self.shading['am_delay']) is not list else self.shading['am_delay'][self.quarter]
|
3934
|
+
shaded = time_hours(self.daily[date]['sun'][0]) + delay
|
3940
3935
|
loss = self.shading['am_loss']
|
3941
3936
|
for t in [t for t in times if t < shaded]:
|
3942
3937
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3943
3938
|
if self.shading.get('pm_delay') is not None:
|
3944
|
-
|
3939
|
+
delay = self.shading['pm_delay'] if type(self.shading['pm_delay']) is not list else self.shading['pm_delay'][self.quarter]
|
3940
|
+
shaded = time_hours(self.daily[date]['sun'][1]) - delay
|
3945
3941
|
loss = self.shading['pm_loss']
|
3946
3942
|
for t in [t for t in times if t > shaded]:
|
3947
3943
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
@@ -4177,19 +4173,19 @@ class Solar :
|
|
4177
4173
|
|
4178
4174
|
# get solar forecast and return total expected yield
|
4179
4175
|
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
|
4176
|
+
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
|
4177
|
+
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4182
4178
|
self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
|
4179
|
+
self.quarter = int(self.today[5:7]) // 3 % 4
|
4183
4180
|
self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
|
4184
4181
|
self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
|
4185
4182
|
self.arrays = None
|
4186
4183
|
self.results = None
|
4187
|
-
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4188
4184
|
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)
|
4185
|
+
if reload == 1 and os.path.exists(storage + self.save):
|
4186
|
+
os.remove(storage + self.save)
|
4187
|
+
if self.save is not None and os.path.exists(storage + self.save):
|
4188
|
+
file = open(storage + self.save)
|
4193
4189
|
data = json.load(file)
|
4194
4190
|
file.close()
|
4195
4191
|
if data.get('date') is not None and (data['date'] == self.today and reload != 1):
|
@@ -4220,7 +4216,7 @@ class Solar :
|
|
4220
4216
|
if self.save is not None :
|
4221
4217
|
if debug_setting > 0 and not quiet:
|
4222
4218
|
print(f"Saving data to {self.save}")
|
4223
|
-
file = open(self.save, 'w')
|
4219
|
+
file = open(storage + self.save, 'w')
|
4224
4220
|
json.dump({'date': self.today, 'arrays': self.arrays, 'results': self.results}, file, indent=4, ensure_ascii= False)
|
4225
4221
|
file.close()
|
4226
4222
|
self.daily = {}
|
@@ -4249,16 +4245,18 @@ class Solar :
|
|
4249
4245
|
for date in self.keys:
|
4250
4246
|
times = sorted(time_hours(t) for t in self.daily[date]['pt30'].keys())
|
4251
4247
|
if self.shading.get('adjust') is not None:
|
4252
|
-
loss = self.shading['adjust']
|
4248
|
+
loss = self.shading['adjust'] if type(self.shading['adjust']) is not list else self.shading['adjust'][self.quarter]
|
4253
4249
|
for t in times:
|
4254
4250
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4255
4251
|
if self.shading.get('am_delay') is not None:
|
4256
|
-
|
4252
|
+
delay = self.shading['am_delay'] if type(self.shading['am_delay']) is not list else self.shading['am_delay'][self.quarter]
|
4253
|
+
shaded = time_hours(self.daily[date]['sun'][0]) + delay
|
4257
4254
|
loss = self.shading['am_loss']
|
4258
4255
|
for t in [t for t in times if t < shaded]:
|
4259
4256
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4260
4257
|
if self.shading.get('pm_delay') is not None:
|
4261
|
-
|
4258
|
+
delay = self.shading['pm_delay'] if type(self.shading['pm_delay']) is not list else self.shading['pm_delay'][self.quarter]
|
4259
|
+
shaded = time_hours(self.daily[date]['sun'][1]) - delay
|
4262
4260
|
loss = self.shading['pm_loss']
|
4263
4261
|
for t in [t for t in times if t > shaded]:
|
4264
4262
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
@@ -4464,6 +4462,7 @@ class Solar :
|
|
4464
4462
|
plot_show()
|
4465
4463
|
return
|
4466
4464
|
|
4465
|
+
|
4467
4466
|
##################################################################################################
|
4468
4467
|
##################################################################################################
|
4469
4468
|
# Pushover API
|
@@ -4477,7 +4476,7 @@ pushover_url = "https://api.pushover.net/1/messages.json"
|
|
4477
4476
|
foxesscloud_app_key = "aqj8up6jeg9hu4zr1pgir3368vda4q"
|
4478
4477
|
|
4479
4478
|
def pushover_post(message, file=None, app_key=None):
|
4480
|
-
global pushover_user_key, pushover_url, foxesscloud_app_key, debug_setting
|
4479
|
+
global pushover_user_key, pushover_url, foxesscloud_app_key, debug_setting, storage
|
4481
4480
|
if pushover_user_key is None or message is None:
|
4482
4481
|
return None
|
4483
4482
|
if app_key is None:
|
@@ -4485,7 +4484,7 @@ def pushover_post(message, file=None, app_key=None):
|
|
4485
4484
|
if len(message) > 1024:
|
4486
4485
|
message = message[-1024:]
|
4487
4486
|
body = {'token': app_key, 'user': pushover_user_key, 'message': message}
|
4488
|
-
files = {'attachment': open(file, 'rb')} if file is not None else None
|
4487
|
+
files = {'attachment': open(storage + file, 'rb')} if file is not None else None
|
4489
4488
|
response = requests.post(pushover_url, data=body, files=files)
|
4490
4489
|
if response.status_code != 200:
|
4491
4490
|
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: 25 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.4"
|
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:
|
@@ -2126,11 +2128,6 @@ regions = {'A':'Eastern England', 'B':'East Midlands', 'C':'London', 'D':'Mersey
|
|
2126
2128
|
'J':'South Eastern England', 'K':'Southern Wales', 'L':'South Western England', 'M':'Yorkshire', 'N':'Southern Scotland', 'P':'Northern Scotland'}
|
2127
2129
|
|
2128
2130
|
|
2129
|
-
# preset weightings for average 30 minute pricing over charging duration:
|
2130
|
-
front_loaded = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5] # 3 hour average, front loaded
|
2131
|
-
first_hour = [1.0, 1.0] # lowest average price for first hour
|
2132
|
-
|
2133
|
-
|
2134
2131
|
tariff_config = {
|
2135
2132
|
'product': "AGILE-24-04-03", # product code to use for Octopus API
|
2136
2133
|
'region': "H", # region code to use for Octopus API
|
@@ -2139,7 +2136,7 @@ tariff_config = {
|
|
2139
2136
|
'plunge_price': [1, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
|
2140
2137
|
'plunge_slots': 6, # number of 30 minute slots to use
|
2141
2138
|
'data_wrap': 6, # prices to show per line
|
2142
|
-
'show_data':
|
2139
|
+
'show_data': 1, # show pricing data
|
2143
2140
|
'show_plot': 1 # plot pricing data
|
2144
2141
|
}
|
2145
2142
|
|
@@ -2223,13 +2220,13 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2223
2220
|
# show the results
|
2224
2221
|
if tariff_config['show_data'] > 0:
|
2225
2222
|
data_wrap = tariff_config['data_wrap'] if tariff_config.get('data_wrap') is not None else 6
|
2226
|
-
|
2227
|
-
s = f"\
|
2223
|
+
col = (now.hour * 2) % data_wrap
|
2224
|
+
s = f"\nPrice p/kWh inc VAT on {today}:"
|
2228
2225
|
for i in range(0, len(prices)):
|
2229
|
-
s += "\n" if i
|
2230
|
-
s += f" {prices[i]['
|
2231
|
-
|
2232
|
-
output(s
|
2226
|
+
s += (f"\n {prices[i]['time']} " + " " * col * 6) if i == 0 or col == 0 else ""
|
2227
|
+
s += f" {prices[i]['price']:4.1f}"
|
2228
|
+
col = (col + 1) % data_wrap
|
2229
|
+
output(s)
|
2233
2230
|
if tariff_config['show_plot'] > 0:
|
2234
2231
|
plt.figure(figsize=(figure_width, figure_width/2))
|
2235
2232
|
x_timed = [i for i in range(0, len(prices))]
|
@@ -2256,7 +2253,8 @@ def get_best_charge_period(start, duration):
|
|
2256
2253
|
key = [k for k in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4'] if hour_in(start, tariff.get(k))]
|
2257
2254
|
key = key[0] if len(key) > 0 else None
|
2258
2255
|
end = tariff[key]['end'] if key is not None else round_time(start + duration)
|
2259
|
-
span = int(duration * 2 + 0.99)
|
2256
|
+
span = int(duration * 2 + 0.99) # number of slots needed for charging
|
2257
|
+
last = (duration * 2) % 1 # amount of last slot used for charging
|
2260
2258
|
coverage = max([round_time(end - start), duration])
|
2261
2259
|
period = {'start': start, 'end': round_time(start + coverage)}
|
2262
2260
|
prices = tariff['agile']['prices']
|
@@ -2265,13 +2263,14 @@ def get_best_charge_period(start, duration):
|
|
2265
2263
|
return None
|
2266
2264
|
elif len(slots) == 1:
|
2267
2265
|
best = slots
|
2268
|
-
price = prices[slots[0]]['price']
|
2269
2266
|
best_start = start
|
2267
|
+
price = prices[best[0]]['price']
|
2270
2268
|
else:
|
2271
2269
|
# best charge time for duration
|
2272
2270
|
weighting = tariff_config.get('weighting')
|
2273
2271
|
times = []
|
2274
|
-
weights = ([1.0] * span) if weighting is None else (weighting + [1.0] * span)[:span]
|
2272
|
+
weights = ([1.0] * (span)) if weighting is None else (weighting + [1.0] * span)[:span]
|
2273
|
+
weights[-1] *= last if last > 0.0 else 1.0
|
2275
2274
|
best = None
|
2276
2275
|
price = None
|
2277
2276
|
for i in range(0, len(slots) - span + 1):
|
@@ -2431,18 +2430,17 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
|
|
2431
2430
|
while h < 48:
|
2432
2431
|
day = today if h < 24 else tomorrow
|
2433
2432
|
if forecast.daily.get(day) is None:
|
2434
|
-
value =
|
2433
|
+
value = None
|
2435
2434
|
elif steps_per_hour == 1:
|
2436
|
-
value =
|
2435
|
+
value = forecast.daily[day]['hourly'].get(int(h % 24))
|
2437
2436
|
else:
|
2438
|
-
value =
|
2439
|
-
profile.append(value)
|
2437
|
+
value = forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2))
|
2438
|
+
profile.append(c_float(value))
|
2440
2439
|
h += 1 / steps_per_hour
|
2441
2440
|
while len(profile) < run_time:
|
2442
2441
|
profile.append(0.0)
|
2443
2442
|
return profile[:run_time]
|
2444
2443
|
|
2445
|
-
|
2446
2444
|
# build the timed work mode profile from the tariff strategy:
|
2447
2445
|
def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, current_mode=None):
|
2448
2446
|
global tariff, steps_per_hour
|
@@ -2523,7 +2521,7 @@ base_time = None
|
|
2523
2521
|
|
2524
2522
|
# charge_needed settings
|
2525
2523
|
charge_config = {
|
2526
|
-
'contingency': [
|
2524
|
+
'contingency': [15,10,5,10], # % of consumption. Single value or [winter, spring, summer, autumn]
|
2527
2525
|
'capacity': None, # Battery capacity (over-ride)
|
2528
2526
|
'min_soc': None, # Minimum Soc. Default 10%
|
2529
2527
|
'max_soc': None, # Maximum Soc. Default 100%
|
@@ -2580,7 +2578,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2580
2578
|
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2581
2579
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
2582
2580
|
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
|
2581
|
+
global timed_strategy, steps_per_hour, base_time, storage
|
2584
2582
|
print(f"\n---------------- charge_needed ----------------")
|
2585
2583
|
# validate parameters
|
2586
2584
|
args = locals()
|
@@ -2639,10 +2637,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2639
2637
|
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'force': force_charge})
|
2640
2638
|
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2641
2639
|
time_to_end1 = None
|
2642
|
-
start_now = (int(hour_now * 2 + 1) / 2) % 24
|
2643
2640
|
for t in times:
|
2644
|
-
if hour_in(
|
2645
|
-
|
2641
|
+
if hour_in(hour_now, t) and update_settings > 0:
|
2642
|
+
update_settings = 0
|
2643
|
+
output(f"\nSettings will not be updated during a charge period")
|
2646
2644
|
time_to_start = round_time(t['start'] - base_hour) * steps_per_hour
|
2647
2645
|
time_to_start += hour_adjustment * steps_per_hour if time_to_start > time_change else 0
|
2648
2646
|
charge_time = round_time(t['end'] - t['start'])
|
@@ -2826,7 +2824,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2826
2824
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2827
2825
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2828
2826
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
2829
|
-
output(f"\nSolcast
|
2827
|
+
output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh")
|
2830
2828
|
# get forecast.solar data and produce time line
|
2831
2829
|
solar_value = None
|
2832
2830
|
solar_profile = None
|
@@ -2835,7 +2833,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2835
2833
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2836
2834
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2837
2835
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
2838
|
-
output(f"\nSolar
|
2836
|
+
output(f"\nSolar: {tomorrow} {fsolar.daily[tomorrow]['kwh']:.1f}kWh")
|
2839
2837
|
if solcast_value is None and solar_value is None and debug_setting > 1:
|
2840
2838
|
output(f"\nNo forecasts available at this time")
|
2841
2839
|
# get generation data
|
@@ -2936,23 +2934,24 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2936
2934
|
charge_message = "** test charge **"
|
2937
2935
|
# work out charge needed
|
2938
2936
|
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
|
2937
|
+
output(f"\nNo charging needed:")
|
2938
|
+
output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
|
2940
2939
|
charge_message = "no charge needed"
|
2941
2940
|
kwh_needed = 0.0
|
2942
2941
|
hours = 0.0
|
2943
2942
|
start_timed = time_to_end
|
2944
2943
|
end_timed = time_to_end
|
2945
2944
|
end_soc = int(end_residual / capacity * 100 + 0.5)
|
2946
|
-
|
2947
|
-
# rebuild the battery residual with min_soc for battery hold
|
2945
|
+
# update min_soc for battery hold
|
2948
2946
|
if force_charge > 0 and timed_mode > 1:
|
2949
2947
|
for t in range(int(time_to_start), int(time_to_end)):
|
2950
2948
|
work_mode_timed[t]['min_soc'] = start_soc
|
2951
|
-
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
2952
2949
|
else:
|
2953
2950
|
if test_charge is None:
|
2954
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh
|
2951
|
+
output(f"\nCharge needed {kwh_needed:.2f}kWh:")
|
2955
2952
|
charge_message = "with charge added"
|
2953
|
+
output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
|
2954
|
+
output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_start, time_line))} ({start_residual:.2f}kWh)")
|
2956
2955
|
# work out time to add kwh_needed to battery
|
2957
2956
|
taper_time = 10/60 if (start_residual + kwh_needed) >= (capacity * 0.95) else 0
|
2958
2957
|
hours = round_time(kwh_needed / (charge_power * charge_loss) + taper_time)
|
@@ -2969,7 +2968,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2969
2968
|
price = charge_period.get('price') if charge_period is not None else None
|
2970
2969
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
2971
2970
|
end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
|
2972
|
-
output(f" Charge:
|
2971
|
+
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
2972
|
for i in range(int(time_to_start), int(end_timed) + 1):
|
2974
2973
|
j = i + 1
|
2975
2974
|
# work out time (fraction of hour) when charging in hour from i to j
|
@@ -2990,31 +2989,26 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2990
2989
|
work_mode_timed[i]['discharge'] *= (1-t)
|
2991
2990
|
elif force_charge > 0 and timed_mode > 1:
|
2992
2991
|
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}")
|
2992
|
+
# rebuild the battery residual with any charge added and min_soc
|
2993
|
+
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
2994
|
+
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
2995
|
+
# show the results
|
2996
|
+
output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
|
2997
|
+
output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
|
2998
|
+
if not charge_today:
|
2999
|
+
output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
|
3005
3000
|
if show_data > 0:
|
3006
3001
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3007
|
-
s = f"\nBattery Energy kWh
|
3002
|
+
s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
|
3008
3003
|
h = base_hour + 1
|
3009
3004
|
t = steps_per_hour
|
3010
|
-
s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
|
3011
3005
|
while t < len(time_line) and bat_timed[t] is not None:
|
3012
|
-
|
3013
|
-
s += f" {hours_time(time_line[t])}"
|
3014
|
-
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f"
|
3006
|
+
col = h % data_wrap
|
3007
|
+
s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == steps_per_hour or col == 0 else ""
|
3008
|
+
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
|
3015
3009
|
h += 1
|
3016
3010
|
t += steps_per_hour
|
3017
|
-
output(s
|
3011
|
+
output(s)
|
3018
3012
|
if show_plot > 0:
|
3019
3013
|
print()
|
3020
3014
|
plt.figure(figsize=(figure_width, figure_width/2))
|
@@ -3055,7 +3049,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3055
3049
|
data['work_mode'] = work_mode_timed
|
3056
3050
|
data['generation'] = generation_timed
|
3057
3051
|
data['consumption'] = consumption_timed
|
3058
|
-
file = open(file_name, 'w')
|
3052
|
+
file = open(storage + file_name, 'w')
|
3059
3053
|
json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3060
3054
|
file.close()
|
3061
3055
|
# setup charging
|
@@ -3080,13 +3074,13 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3080
3074
|
##################################################################################################
|
3081
3075
|
|
3082
3076
|
def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
3083
|
-
global charge_config
|
3077
|
+
global charge_config, storage
|
3084
3078
|
if save is None and charge_config.get('save') is not None:
|
3085
3079
|
save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
|
3086
3080
|
if save is None:
|
3087
3081
|
print(f"** charge_compare(): please provide a saved file to load")
|
3088
3082
|
return
|
3089
|
-
file = open(save)
|
3083
|
+
file = open(storage + save)
|
3090
3084
|
data = json.load(file)
|
3091
3085
|
file.close()
|
3092
3086
|
if data is None or data.get('base_time') is None:
|
@@ -3106,7 +3100,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3106
3100
|
run_time = len(time_line)
|
3107
3101
|
base_hour = int(time_hours(base_time[11:16]))
|
3108
3102
|
start_day = base_time[:10]
|
3109
|
-
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc
|
3103
|
+
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:.0f}%")
|
3110
3104
|
now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
|
3111
3105
|
end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
|
3112
3106
|
if v is None:
|
@@ -3141,17 +3135,16 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3141
3135
|
plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
|
3142
3136
|
if show_data > 0 and plots.get('SoC') is not None:
|
3143
3137
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3144
|
-
s = f"\nBattery Energy kWh
|
3138
|
+
s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
|
3145
3139
|
h = base_hour + 1
|
3146
3140
|
t = steps_per_hour
|
3147
|
-
|
3148
|
-
|
3149
|
-
s += "\n" if t
|
3150
|
-
s += f" {
|
3151
|
-
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%,"
|
3141
|
+
while t < len(time_line) and bat_timed[t] is not None:
|
3142
|
+
col = h % data_wrap
|
3143
|
+
s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == steps_per_hour or col == 0 else ""
|
3144
|
+
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
|
3152
3145
|
h += 1
|
3153
3146
|
t += steps_per_hour
|
3154
|
-
print(s
|
3147
|
+
print(s)
|
3155
3148
|
if show_plot > 0:
|
3156
3149
|
print()
|
3157
3150
|
plt.figure(figsize=(figure_width, figure_width/2))
|
@@ -3349,6 +3342,7 @@ def write(f, s, m='a'):
|
|
3349
3342
|
# log battery information in CSV format at 'interval' minutes apart for 'run' times
|
3350
3343
|
# log 1: battery info, 2: add cell volts, 3: add cell temps
|
3351
3344
|
def battery_monitor(interval=30, run=48, log=1, count=None, save=None, overwrite=0):
|
3345
|
+
global storage
|
3352
3346
|
run_time = interval * run / 60
|
3353
3347
|
print(f"\n---------------- battery_monitor ------------------")
|
3354
3348
|
print(f"Expected runtime = {hours_time(run_time, day=True)} (hh:mm/days)")
|
@@ -3357,7 +3351,7 @@ def battery_monitor(interval=30, run=48, log=1, count=None, save=None, overwrite
|
|
3357
3351
|
print()
|
3358
3352
|
s = f"time,soc,residual,bat_volt,bat_current,bat_temp,nbat,ncell,ntemp,volts*,imbalance*,temps*"
|
3359
3353
|
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')
|
3354
|
+
write(storage + save, s, 'w' if overwrite == 1 else 'a')
|
3361
3355
|
i = run
|
3362
3356
|
while i > 0:
|
3363
3357
|
t1 = time.time()
|
@@ -3698,17 +3692,18 @@ class Solcast :
|
|
3698
3692
|
# reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
|
3699
3693
|
# The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
|
3700
3694
|
# 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
|
3695
|
+
global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
|
3702
3696
|
self.data = {}
|
3703
|
-
self.shading = shading
|
3697
|
+
self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
|
3704
3698
|
self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
|
3699
|
+
self.quarter = int(self.today[5:7]) // 3 % 4
|
3705
3700
|
self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
|
3706
3701
|
self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
|
3707
3702
|
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)
|
3703
|
+
if reload == 1 and os.path.exists(storage + self.save):
|
3704
|
+
os.remove(storage + self.save)
|
3705
|
+
if self.save is not None and os.path.exists(storage + self.save):
|
3706
|
+
file = open(storage + self.save)
|
3712
3707
|
self.data = json.load(file)
|
3713
3708
|
file.close()
|
3714
3709
|
if len(self.data) == 0:
|
@@ -3749,12 +3744,13 @@ class Solcast :
|
|
3749
3744
|
return
|
3750
3745
|
self.data[t][rid] = response.json().get(t)
|
3751
3746
|
if self.save is not None :
|
3752
|
-
file = open(self.save, 'w')
|
3747
|
+
file = open(storage + self.save, 'w')
|
3753
3748
|
json.dump(self.data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3754
3749
|
file.close()
|
3755
3750
|
self.daily = {}
|
3751
|
+
estimated = 0 if self.data.get('estimated_actuals') is None else 1
|
3756
3752
|
loaded = {} # track what we have loaded so we don't duplicate between forecast and actuals
|
3757
|
-
for t in ['forecasts'] if
|
3753
|
+
for t in ['forecasts'] if estimated == 0 else ['forecasts', 'estimated_actuals']:
|
3758
3754
|
for rid in self.data[t].keys() : # aggregate sites
|
3759
3755
|
if loaded.get(rid) is None:
|
3760
3756
|
loaded[rid] = {}
|
@@ -3774,33 +3770,37 @@ class Solcast :
|
|
3774
3770
|
self.daily[date]['pt30'][key] = 0.0
|
3775
3771
|
self.daily [date]['pt30'][key] += value
|
3776
3772
|
# ignore first and last dates as these only cover part of the day, so are not accurate
|
3777
|
-
self.keys = sorted(self.daily.keys())[
|
3773
|
+
self.keys = sorted(self.daily.keys())[estimated:-1]
|
3778
3774
|
self.days = len(self.keys)
|
3779
3775
|
# trim the range if fewer days have been requested
|
3780
|
-
while self.days >
|
3781
|
-
self.keys = self.keys[
|
3776
|
+
while self.days > days * (1 + estimated) :
|
3777
|
+
self.keys = self.keys[estimated:-1]
|
3782
3778
|
self.days = len(self.keys)
|
3783
|
-
# fill out forecast to cover 24 hours
|
3779
|
+
# fill out forecast to cover 24 hours and set forecast start time
|
3784
3780
|
for date in self.keys:
|
3785
3781
|
for t in [hours_time(t / 2) for t in range(0,48)]:
|
3786
3782
|
if self.daily[date]['pt30'].get(t) is None:
|
3787
3783
|
self.daily[date]['pt30'][t] = 0.0
|
3784
|
+
elif self.daily[date].get('from') is None:
|
3785
|
+
self.daily[date]['from'] = t
|
3788
3786
|
# apply shading
|
3789
|
-
if self.shading is not None
|
3787
|
+
if self.shading is not None:
|
3790
3788
|
for date in self.keys:
|
3791
3789
|
times = sorted(time_hours(t) for t in self.daily[date]['pt30'].keys())
|
3792
|
-
if self.shading
|
3793
|
-
loss = self.shading['
|
3790
|
+
if self.shading.get('adjust') is not None:
|
3791
|
+
loss = self.shading['adjust'] if type(self.shading['adjust']) is not list else self.shading['adjust'][self.quarter]
|
3794
3792
|
for t in times:
|
3795
3793
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3796
|
-
if self.shading
|
3797
|
-
|
3798
|
-
|
3794
|
+
if self.shading.get('am_delay') is not None:
|
3795
|
+
delay = self.shading['am_delay'] if type(self.shading['am_delay']) is not list else self.shading['am_delay'][self.quarter]
|
3796
|
+
shaded = time_hours(self.daily[date]['sun'][0]) + delay
|
3797
|
+
loss = self.shading['am_loss']
|
3799
3798
|
for t in [t for t in times if t < shaded]:
|
3800
3799
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3801
|
-
if self.shading
|
3802
|
-
|
3803
|
-
|
3800
|
+
if self.shading.get('pm_delay') is not None:
|
3801
|
+
delay = self.shading['pm_delay'] if type(self.shading['pm_delay']) is not list else self.shading['pm_delay'][self.quarter]
|
3802
|
+
shaded = time_hours(self.daily[date]['sun'][1]) - delay
|
3803
|
+
loss = self.shading['pm_loss']
|
3804
3804
|
for t in [t for t in times if t > shaded]:
|
3805
3805
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
3806
3806
|
# calculate hourly values and total
|
@@ -4035,18 +4035,19 @@ class Solar :
|
|
4035
4035
|
|
4036
4036
|
# get solar forecast and return total expected yield
|
4037
4037
|
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
|
4038
|
+
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
|
4039
|
+
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4040
4040
|
self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
|
4041
|
+
self.quarter = int(self.today[5:7]) // 3 % 4
|
4041
4042
|
self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
|
4043
|
+
self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
|
4042
4044
|
self.arrays = None
|
4043
4045
|
self.results = None
|
4044
|
-
self.shading = shading
|
4045
4046
|
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)
|
4047
|
+
if reload == 1 and os.path.exists(storage + self.save):
|
4048
|
+
os.remove(storage + self.save)
|
4049
|
+
if self.save is not None and os.path.exists(storage + self.save):
|
4050
|
+
file = open(storage + self.save)
|
4050
4051
|
data = json.load(file)
|
4051
4052
|
file.close()
|
4052
4053
|
if data.get('date') is not None and (data['date'] == self.today and reload != 1):
|
@@ -4077,7 +4078,7 @@ class Solar :
|
|
4077
4078
|
if self.save is not None :
|
4078
4079
|
if debug_setting > 0 and not quiet:
|
4079
4080
|
print(f"Saving data to {self.save}")
|
4080
|
-
file = open(self.save, 'w')
|
4081
|
+
file = open(storage + self.save, 'w')
|
4081
4082
|
json.dump({'date': self.today, 'arrays': self.arrays, 'results': self.results}, file, indent=4, ensure_ascii= False)
|
4082
4083
|
file.close()
|
4083
4084
|
self.daily = {}
|
@@ -4105,18 +4106,20 @@ class Solar :
|
|
4105
4106
|
if self.shading is not None and self.shading.get('solar') is not None:
|
4106
4107
|
for date in self.keys:
|
4107
4108
|
times = sorted(time_hours(t) for t in self.daily[date]['pt30'].keys())
|
4108
|
-
if self.shading
|
4109
|
-
loss = self.shading['
|
4109
|
+
if self.shading.get('adjust') is not None:
|
4110
|
+
loss = self.shading['adjust'] if type(self.shading['adjust']) is not list else self.shading['adjust'][self.quarter]
|
4110
4111
|
for t in times:
|
4111
4112
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4112
|
-
if self.shading
|
4113
|
-
|
4114
|
-
|
4113
|
+
if self.shading.get('am_delay') is not None:
|
4114
|
+
delay = self.shading['am_delay'] if type(self.shading['am_delay']) is not list else self.shading['am_delay'][self.quarter]
|
4115
|
+
shaded = time_hours(self.daily[date]['sun'][0]) + delay
|
4116
|
+
loss = self.shading['am_loss']
|
4115
4117
|
for t in [t for t in times if t < shaded]:
|
4116
4118
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4117
|
-
if self.shading
|
4118
|
-
|
4119
|
-
|
4119
|
+
if self.shading.get('pm_delay') is not None:
|
4120
|
+
delay = self.shading['pm_delay'] if type(self.shading['pm_delay']) is not list else self.shading['pm_delay'][self.quarter]
|
4121
|
+
shaded = time_hours(self.daily[date]['sun'][1]) - delay
|
4122
|
+
loss = self.shading['pm_loss']
|
4120
4123
|
for t in [t for t in times if t > shaded]:
|
4121
4124
|
self.daily[date]['pt30'][hours_time(t)] *= loss
|
4122
4125
|
# calculate hourly values and total
|
@@ -4335,7 +4338,7 @@ pushover_url = "https://api.pushover.net/1/messages.json"
|
|
4335
4338
|
foxesscloud_app_key = "aqj8up6jeg9hu4zr1pgir3368vda4q"
|
4336
4339
|
|
4337
4340
|
def pushover_post(message, file=None, app_key=None):
|
4338
|
-
global pushover_user_key, pushover_url, foxesscloud_app_key, debug_setting
|
4341
|
+
global pushover_user_key, pushover_url, foxesscloud_app_key, debug_setting, storage
|
4339
4342
|
if pushover_user_key is None or message is None:
|
4340
4343
|
return None
|
4341
4344
|
if app_key is None:
|
@@ -4343,7 +4346,7 @@ def pushover_post(message, file=None, app_key=None):
|
|
4343
4346
|
if len(message) > 1024:
|
4344
4347
|
message = message[-1024:]
|
4345
4348
|
body = {'token': app_key, 'user': pushover_user_key, 'message': message}
|
4346
|
-
files = {'attachment': open(file, 'rb')} if file is not None else None
|
4349
|
+
files = {'attachment': open(storage + file, 'rb')} if file is not None else None
|
4347
4350
|
response = requests.post(pushover_url, data=body, files=files)
|
4348
4351
|
if response.status_code != 200:
|
4349
4352
|
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.4
|
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
|
|
@@ -525,11 +528,11 @@ This gets the latest 30 minute pricing and uses this to work out the best off pe
|
|
525
528
|
+ forecast_times: a list of times when a forecast can be obtained from Solcast / forecast.solar, aligned with the host system time
|
526
529
|
+ strategy: an optional list of times and work modes (see below)
|
527
530
|
+ update: optional, 1 (the default) sets the current tariff to Agile Octopus. Setting to 0 does not change the current tariff
|
528
|
-
+ weighting: optional, default is None (see below)
|
531
|
+
+ weighting: optional, default is None / flat (see below)
|
529
532
|
+ time_shift: optional system time shift in hours. The default is for system time to be UTC and to apply the current day light saving time (e.g. GMT/BST)
|
530
533
|
+ plunge_price: list of prices in p/kWh when plunge pricing is used (see below). The default is [0, 5].
|
531
534
|
+ plunge_slots: the number of 30 minute slots to use for plunge pricing. The default is 6, allowing up to 3 hours.
|
532
|
-
+ show_data: show 30 minute Agile pricing data. Default is
|
535
|
+
+ show_data: show 30 minute Agile pricing data. Default is 1.
|
533
536
|
+ show_plot: plot 30 minute Agile pricing data. Default is 1.
|
534
537
|
|
535
538
|
Product codes include:
|
@@ -559,9 +562,7 @@ Region codes include:
|
|
559
562
|
Pricing for tomorrow is updated around 5pm each day. If run before this time, prices from yesterday are used. By default, prices for tomorrow are fetched after 5pm. The setting for this is:
|
560
563
|
+ f.agile_update_time = 17
|
561
564
|
|
562
|
-
The best charging period is determined based on the weighted average of the 30 minute prices over the duration. The default is flat (all prices are weighted equally). You can
|
563
|
-
+ f.front_loaded: [1.0, 0.9, 0.8, 0.7, 0.6, 0.5]
|
564
|
-
+ f.first_hour: [1.0, 1.0]
|
565
|
+
The best charging period is determined based on the weighted average of the 30 minute prices over the duration. The default is flat (all prices are weighted equally, except the last slot, which is pro rata to the charge duration used). You can over-ride the default weighting by providing a list of 30 minute values to apply.
|
565
566
|
|
566
567
|
set_tariff() can configure multiple off-peak and peak periods for any tariff using the 'times' parameter. Times is a list of tuples:
|
567
568
|
+ containing values for key, 'start', 'end' and optional 'force'.
|
@@ -742,7 +743,7 @@ f.get_suntimes(date, utc)
|
|
742
743
|
+ date: 'YYYY-MM-DD'
|
743
744
|
+ utc: 1 = return time in UTC. 0 = return local time (default)
|
744
745
|
|
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()'.
|
746
|
+
'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
747
|
|
747
748
|
|
748
749
|
# Pushover
|
@@ -782,6 +783,19 @@ This setting can be:
|
|
782
783
|
|
783
784
|
# Version Info
|
784
785
|
|
786
|
+
2.5.4<br>
|
787
|
+
Remove preset 'weighting' that were not used.
|
788
|
+
Update weighting to apply the requested charge duration correctly.
|
789
|
+
Reformat price and SoC tables to reduce wrapping and make them easier to read on small screens.
|
790
|
+
Change default for set_tariff() to show Agile 30 minute prices.
|
791
|
+
|
792
|
+
2.5.3<br>
|
793
|
+
Reverted change to allow updates during a charge period to avoid removing charge in progress.
|
794
|
+
Update contingency and show how this relates to battery SoC.
|
795
|
+
Add PV cover, the ratio of PV generation to consumption.
|
796
|
+
Add f.storage path so files can be saved to different locations if needed.
|
797
|
+
Allow delays in 'shading' to be a seaonal list of 4 values (winter, spring, summer, autumn).
|
798
|
+
|
785
799
|
2.5.2<br>
|
786
800
|
Updates to allow charge_needed() to run during a charge period.
|
787
801
|
Add suport for 'off_peak4' charge period.
|
@@ -0,0 +1,7 @@
|
|
1
|
+
foxesscloud/foxesscloud.py,sha256=elLQc40Zt8QkjmiOhZ2gNe-Wqp5NE38LgnflHpUQMmc,210693
|
2
|
+
foxesscloud/openapi.py,sha256=aBVMx8eyh5WRbUQTiAFHh6SDNiKeksBplODIO1x3L-k,204407
|
3
|
+
foxesscloud-2.5.4.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
|
4
|
+
foxesscloud-2.5.4.dist-info/METADATA,sha256=KXfCjbfLt8t5jjQbLW_-7UJyoKBvskga5X0sxMkzxmk,55214
|
5
|
+
foxesscloud-2.5.4.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
6
|
+
foxesscloud-2.5.4.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
|
7
|
+
foxesscloud-2.5.4.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
|