foxesscloud 2.5.0__py3-none-any.whl → 2.5.2__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 +147 -119
- foxesscloud/openapi.py +160 -121
- {foxesscloud-2.5.0.dist-info → foxesscloud-2.5.2.dist-info}/METADATA +16 -4
- foxesscloud-2.5.2.dist-info/RECORD +7 -0
- foxesscloud-2.5.0.dist-info/RECORD +0 -7
- {foxesscloud-2.5.0.dist-info → foxesscloud-2.5.2.dist-info}/LICENCE +0 -0
- {foxesscloud-2.5.0.dist-info → foxesscloud-2.5.2.dist-info}/WHEEL +0 -0
- {foxesscloud-2.5.0.dist-info → foxesscloud-2.5.2.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: 22 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.4"
|
14
14
|
print(f"FoxESS-Cloud version {version}")
|
15
15
|
|
16
16
|
debug_setting = 1
|
@@ -834,13 +834,15 @@ merge_settings = { # keys to add
|
|
834
834
|
'WorkMode': {'keys': {
|
835
835
|
'h115__': 'operation_mode__work_mode',
|
836
836
|
'h116__': 'operation_mode__work_mode',
|
837
|
-
'h117__': 'operation_mode__work_mode'
|
837
|
+
'h117__': 'operation_mode__work_mode',
|
838
|
+
# 'k106__': 'operation_mode__work_mode',
|
838
839
|
},
|
839
840
|
'values': ['SelfUse', 'Feedin', 'Backup']},
|
840
841
|
'BatteryVolt': {'keys': {
|
841
842
|
'h115__': ['h115__14', 'h115__15', 'h115__16'],
|
842
843
|
'h116__': ['h116__15', 'h116__16', 'h116__17'],
|
843
|
-
'h117__': ['h117__15', 'h117__16', 'h117__17']
|
844
|
+
'h117__': ['h117__15', 'h117__16', 'h117__17'],
|
845
|
+
# 'k106__': ['k106__xx', 'k106__xx', 'k106__xx'],
|
844
846
|
},
|
845
847
|
'type': 'list',
|
846
848
|
'valueType': 'float',
|
@@ -849,6 +851,7 @@ merge_settings = { # keys to add
|
|
849
851
|
'h115__': 'h115__17',
|
850
852
|
'h116__': 'h116__18',
|
851
853
|
'h117__': 'h117__18',
|
854
|
+
# 'k106__': 'k106__xx',
|
852
855
|
},
|
853
856
|
'type': 'list',
|
854
857
|
'valueType': 'int',
|
@@ -1073,8 +1076,6 @@ def get_flag():
|
|
1073
1076
|
global token, device_id, device_sn, schedule, debug_setting, messages
|
1074
1077
|
if get_device() is None:
|
1075
1078
|
return None
|
1076
|
-
if schedule is not None and schedule.get('support') is not None:
|
1077
|
-
return schedule
|
1078
1079
|
output(f"getting flag", 2)
|
1079
1080
|
params = {'deviceSN': device_sn}
|
1080
1081
|
response = signed_get(path="/generic/v0/device/scheduler/get/flag", params=params)
|
@@ -1284,22 +1285,24 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
|
|
1284
1285
|
return period
|
1285
1286
|
|
1286
1287
|
# set a schedule from a period or list of periods
|
1287
|
-
def set_schedule(periods=None, template=None, enable=
|
1288
|
+
def set_schedule(periods=None, template=None, enable=True):
|
1288
1289
|
global token, device_sn, debug_setting, messages, schedule, templates
|
1289
1290
|
if get_flag() is None:
|
1290
1291
|
return None
|
1291
1292
|
if schedule.get('support') == False:
|
1292
1293
|
output(f"** set_schedule(), not supported on this device")
|
1293
1294
|
return None
|
1295
|
+
if type(enable) is int:
|
1296
|
+
enable = True if enable == 1 else False
|
1294
1297
|
if schedule is None:
|
1295
1298
|
schedule = get_schedule()
|
1296
1299
|
output(f"set_schedule(): enable = {enable}, periods = {periods}, template={template}", 2)
|
1297
1300
|
if periods is not None and type(periods) is not list:
|
1298
1301
|
periods = [periods]
|
1299
1302
|
if (periods is None or len(periods) == 0) and template is None:
|
1300
|
-
enable =
|
1303
|
+
enable = False
|
1301
1304
|
params = {'deviceSN': device_sn}
|
1302
|
-
if enable ==
|
1305
|
+
if enable == False:
|
1303
1306
|
output(f"\nDisabling schedule", 1)
|
1304
1307
|
setting_delay()
|
1305
1308
|
response = signed_get(path="/generic/v0/device/scheduler/disable", params=params)
|
@@ -1310,7 +1313,7 @@ def set_schedule(periods=None, template=None, enable=1):
|
|
1310
1313
|
if errno != 0:
|
1311
1314
|
output(f"** set_schedule(), disable, {errno_message(errno)}")
|
1312
1315
|
return None
|
1313
|
-
schedule['enable'] =
|
1316
|
+
schedule['enable'] = enable
|
1314
1317
|
else:
|
1315
1318
|
template_id = None
|
1316
1319
|
if periods is not None:
|
@@ -1344,7 +1347,7 @@ def set_schedule(periods=None, template=None, enable=1):
|
|
1344
1347
|
if errno != 0:
|
1345
1348
|
output(f"** set_schedule(), enable, {errno_message(errno)}")
|
1346
1349
|
return None
|
1347
|
-
schedule['enable'] =
|
1350
|
+
schedule['enable'] = enable
|
1348
1351
|
return schedule
|
1349
1352
|
|
1350
1353
|
|
@@ -1497,7 +1500,7 @@ def get_raw(time_span='hour', d=None, v=None, summary=1, save=None, load=None, p
|
|
1497
1500
|
if e > 0.0:
|
1498
1501
|
kwh += e
|
1499
1502
|
if tariff is not None:
|
1500
|
-
if hour_in (h, [tariff.get('off_peak1'), tariff.get('off_peak2'), tariff.get('off_peak3')]):
|
1503
|
+
if hour_in (h, [tariff.get('off_peak1'), tariff.get('off_peak2'), tariff.get('off_peak3'), tariff.get('off_peak4')]):
|
1501
1504
|
kwh_off += e
|
1502
1505
|
elif hour_in(h, [tariff.get('peak1'), tariff.get('peak2')]):
|
1503
1506
|
kwh_peak += e
|
@@ -2348,56 +2351,36 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2348
2351
|
strategy.append(prices[t])
|
2349
2352
|
output(f" {format_period(prices[t])} at {prices[t]['price']:.1f}p", 1)
|
2350
2353
|
tariff['agile']['strategy'] = strategy
|
2351
|
-
for key in ['off_peak1', 'off_peak2', 'off_peak3']:
|
2354
|
+
for key in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
|
2352
2355
|
if tariff.get(key) is None:
|
2353
2356
|
continue
|
2354
2357
|
if tariff['agile'].get(key) is None:
|
2355
2358
|
tariff['agile'][key] = {}
|
2356
2359
|
# get price index for AM/PM charge times
|
2357
|
-
slots = []
|
2358
|
-
for i in range(0, len(prices)):
|
2359
|
-
if hour_in(time_hours(prices[i]['start']), tariff[key]):
|
2360
|
-
slots.append(i)
|
2360
|
+
slots = [i for i in range(0, len(prices)) if hour_in(time_hours(prices[i]['start']), tariff[key])]
|
2361
2361
|
tariff['agile'][key]['slots'] = slots
|
2362
|
-
|
2363
|
-
weighting = tariff_config.get('weighting')
|
2364
|
-
tariff['agile'][key]['times'] = []
|
2365
|
-
for j in range (0, len(slots)):
|
2366
|
-
span = j + 1
|
2367
|
-
weights = (([1.0] * (span-1) if weighting is None else weighting) + [0.5] * span)[:span]
|
2368
|
-
best = None
|
2369
|
-
price = None
|
2370
|
-
for i in range(0, len(slots) - j):
|
2371
|
-
t = slots[i: i + span]
|
2372
|
-
p_span = [prices[x]['price'] for x in t]
|
2373
|
-
wavg = round(sum(p * w for p,w in zip(p_span, weights)) / sum(weights), 2)
|
2374
|
-
if price is None or wavg < price:
|
2375
|
-
price = wavg
|
2376
|
-
best = t
|
2377
|
-
# save best time slot for charge duration
|
2378
|
-
start = prices[best[0]]['start']
|
2379
|
-
tariff['agile'][key]['times'].append({'start': start, 'end': round_time(start + span / 2), 'price': price, 'best': best, 'key': key})
|
2362
|
+
tariff['agile'][key]['avg'] = avg([prices[t]['price'] for t in slots])
|
2380
2363
|
# show the results
|
2381
2364
|
if tariff_config['show_data'] > 0:
|
2382
2365
|
data_wrap = tariff_config['data_wrap'] if tariff_config.get('data_wrap') is not None else 6
|
2383
2366
|
t = (now.hour * 2) % data_wrap
|
2384
|
-
s = f"\nPricing on {today} p/kWh inc VAT:\n" + " " * t *
|
2367
|
+
s = f"\nPricing on {today} p/kWh inc VAT:\n" + " " * t * 13
|
2385
2368
|
for i in range(0, len(prices)):
|
2386
2369
|
s += "\n" if i > 0 and t % data_wrap == 0 else ""
|
2387
|
-
s += f" {prices[i]['time']} {prices[i]['price']:4.1f}"
|
2370
|
+
s += f" {prices[i]['time']} {prices[i]['price']:4.1f},"
|
2388
2371
|
t += 1
|
2389
|
-
output(s)
|
2372
|
+
output(s[:-1])
|
2390
2373
|
if tariff_config['show_plot'] > 0:
|
2391
2374
|
plt.figure(figsize=(figure_width, figure_width/2))
|
2392
2375
|
x_timed = [i for i in range(0, len(prices))]
|
2393
2376
|
plt.xticks(ticks=x_timed, labels=[prices[x]['time'] for x in x_timed], rotation=90, fontsize=8, ha='center')
|
2394
2377
|
plt.plot(x_timed, [prices[x]['price'] for x in x_timed], label='30 minute price', color='blue')
|
2395
2378
|
s = ""
|
2396
|
-
for key in ['off_peak1', 'off_peak2']:
|
2397
|
-
if tariff['agile'].get(key) is not None and len(tariff['agile'][key]['
|
2398
|
-
p = tariff['agile'][key]
|
2399
|
-
plt.plot(x_timed, [p['
|
2400
|
-
s += f"\n {
|
2379
|
+
for key in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
|
2380
|
+
if tariff['agile'].get(key) is not None and len(tariff['agile'][key]['slots']) > 0:
|
2381
|
+
p = tariff['agile'][key]
|
2382
|
+
plt.plot(x_timed, [p['avg'] if x in p['slots'] else None for x in x_timed], label=f"{key} {p['avg']:.1f}p")
|
2383
|
+
s += f"\n {hours_time(prices[p['slots'][0]]['start'])}-{hours_time(prices[p['slots'][-1]]['end'])} at {p['avg']:.1f}p"
|
2401
2384
|
output(f"\nCharge times{s}" if s != "" else "", 1)
|
2402
2385
|
plt.title(f"Pricing on {today} p/kWh inc VAT", fontsize=10)
|
2403
2386
|
plt.legend(fontsize=8)
|
@@ -2408,13 +2391,40 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2408
2391
|
# return the best charge time:
|
2409
2392
|
def get_best_charge_period(start, duration):
|
2410
2393
|
global tariff
|
2411
|
-
if tariff is None:
|
2412
|
-
return None
|
2413
|
-
key = [k for k in ['off_peak1', 'off_peak2', 'off_peak3'] if hour_in(start, tariff.get(k))]
|
2414
|
-
|
2415
|
-
|
2416
|
-
|
2417
|
-
|
2394
|
+
if tariff is None or tariff.get('agile') is None or tariff['agile'].get('prices') is None:
|
2395
|
+
return None
|
2396
|
+
key = [k for k in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4'] if hour_in(start, tariff.get(k))]
|
2397
|
+
key = key[0] if len(key) > 0 else None
|
2398
|
+
end = tariff[key]['end'] if key is not None else round_time(start + duration)
|
2399
|
+
span = int(duration * 2 + 0.99)
|
2400
|
+
coverage = max([round_time(end - start), duration])
|
2401
|
+
period = {'start': start, 'end': round_time(start + coverage)}
|
2402
|
+
prices = tariff['agile']['prices']
|
2403
|
+
slots = [i for i in range(0, len(prices)) if hour_in(time_hours(prices[i]['start']), period)]
|
2404
|
+
if len(slots) == 0:
|
2405
|
+
return None
|
2406
|
+
elif len(slots) == 1:
|
2407
|
+
best = slots
|
2408
|
+
price = prices[slots[0]]['price']
|
2409
|
+
best_start = start
|
2410
|
+
else:
|
2411
|
+
# best charge time for duration
|
2412
|
+
weighting = tariff_config.get('weighting')
|
2413
|
+
times = []
|
2414
|
+
weights = ([1.0] * span) if weighting is None else (weighting + [1.0] * span)[:span]
|
2415
|
+
best = None
|
2416
|
+
price = None
|
2417
|
+
for i in range(0, len(slots) - span + 1):
|
2418
|
+
t = slots[i: i + span]
|
2419
|
+
p_span = [prices[x]['price'] for x in t]
|
2420
|
+
wavg = round(sum(p * w for p,w in zip(p_span, weights)) / sum(weights), 2)
|
2421
|
+
if price is None or wavg < price:
|
2422
|
+
price = wavg
|
2423
|
+
best = t
|
2424
|
+
best_start = prices[best[0]]['start']
|
2425
|
+
# save best time slot for charge duration
|
2426
|
+
tariff['agile']['best'] = {'start': best_start, 'end': round_time(best_start + span / 2), 'price': price, 'slots': best, 'key': key}
|
2427
|
+
return tariff['agile']['best']
|
2418
2428
|
|
2419
2429
|
# pushover app key for set_tariff()
|
2420
2430
|
set_tariff_app_key = "apx24dswzinhrbeb62sdensvt42aqe"
|
@@ -2458,7 +2468,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
|
|
2458
2468
|
times = [times]
|
2459
2469
|
output(f"\n{use['name']}:")
|
2460
2470
|
for t in times:
|
2461
|
-
if len(t) not in (1,3,4) or t[0] not in ['off_peak1', 'off_peak2', 'off_peak3', 'peak1', 'peak2']:
|
2471
|
+
if len(t) not in (1,3,4) or t[0] not in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4', 'peak1', 'peak2']:
|
2462
2472
|
output(f"** set_tariff(): invalid time period {t}")
|
2463
2473
|
continue
|
2464
2474
|
key = t[0]
|
@@ -2497,7 +2507,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
|
|
2497
2507
|
elif type(strategy) is not list:
|
2498
2508
|
strategy = [strategy]
|
2499
2509
|
output(f"\nStrategy")
|
2500
|
-
use['strategy'] = get_strategy(use=use, strategy=strategy, quiet=0, remove=[use.get('off_peak1'), use.get('off_peak2'), use.get('off_peak3')])
|
2510
|
+
use['strategy'] = get_strategy(use=use, strategy=strategy, quiet=0, remove=[use.get('off_peak1'), use.get('off_peak2'), use.get('off_peak3'), use.get('off_peak4')])
|
2501
2511
|
output_close(plot=tariff_config['show_plot'])
|
2502
2512
|
if update == 1:
|
2503
2513
|
tariff = use
|
@@ -2691,7 +2701,7 @@ charge_config = {
|
|
2691
2701
|
'solcast': {'adjust': 0.95, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2},
|
2692
2702
|
'solar': {'adjust': 1.20, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2}
|
2693
2703
|
},
|
2694
|
-
'save': 'charge_needed
|
2704
|
+
'save': 'charge_needed ###.txt' # save calculation data for analysis
|
2695
2705
|
}
|
2696
2706
|
|
2697
2707
|
# app key for charge_needed (used to send output via pushover)
|
@@ -2706,7 +2716,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2706
2716
|
# forecast_times: list of hours when forecast can be fetched (UTC)
|
2707
2717
|
# force_charge: 1 = set force charge, 2 = charge for whole period
|
2708
2718
|
|
2709
|
-
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None,
|
2719
|
+
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2710
2720
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
2711
2721
|
global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
|
2712
2722
|
global timed_strategy, steps_per_hour, base_time
|
@@ -2758,17 +2768,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2758
2768
|
time_change = (change_hour - base_hour) * steps_per_hour
|
2759
2769
|
# get charge times
|
2760
2770
|
times = []
|
2761
|
-
for k in ['off_peak1', 'off_peak2', 'off_peak3']:
|
2771
|
+
for k in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
|
2762
2772
|
if tariff is not None and tariff.get(k) is not None:
|
2763
|
-
start = time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0)
|
2764
|
-
end = time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0)
|
2773
|
+
start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2774
|
+
end = round_time(time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2765
2775
|
force = 0 if tariff[k].get('force') is not None and tariff[k]['force'] == 0 else force_charge
|
2766
2776
|
times.append({'key': k, 'start': start, 'end': end, 'force': force})
|
2767
2777
|
if len(times) == 0:
|
2768
|
-
times.append({'key': 'off_peak1', 'start': round_time(base_hour +
|
2769
|
-
output(f"Charge time: {hours_time(base_hour +
|
2778
|
+
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'force': force_charge})
|
2779
|
+
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2770
2780
|
time_to_end1 = None
|
2781
|
+
start_now = (int(hour_now * 2 + 1) / 2) % 24
|
2771
2782
|
for t in times:
|
2783
|
+
if hour_in(start_now, t):
|
2784
|
+
t['start'] = start_now
|
2772
2785
|
time_to_start = round_time(t['start'] - base_hour) * steps_per_hour
|
2773
2786
|
time_to_start += hour_adjustment * steps_per_hour if time_to_start > time_change else 0
|
2774
2787
|
charge_time = round_time(t['end'] - t['start'])
|
@@ -2785,9 +2798,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2785
2798
|
time_to_start = times[0]['time_to_start']
|
2786
2799
|
time_to_end = times[0]['time_to_end']
|
2787
2800
|
charge_time = times[0]['charge_time']
|
2788
|
-
if hour_in(hour_now, {'start': round_time(start_at - 0.25), 'end': round_time(end_by + 0.25)}) and update_settings > 0:
|
2789
|
-
print(f"\nInverter settings will not be changed less than 15 minutes before or after the next charging period")
|
2790
|
-
update_settings = 0
|
2791
2801
|
# work out time window and times with clock changes
|
2792
2802
|
time_to_next = int(time_to_start)
|
2793
2803
|
charge_today = (base_hour + time_to_start / steps_per_hour) < 24
|
@@ -2941,8 +2951,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2941
2951
|
output(f"\nConsumption (kWh):")
|
2942
2952
|
s = ""
|
2943
2953
|
for h in history:
|
2944
|
-
s += f"
|
2945
|
-
output(s)
|
2954
|
+
s += f" {h['date']} {h['total']:4.1f},"
|
2955
|
+
output(' ' + s[:-1])
|
2946
2956
|
output(f" Average of last {consumption_days} {day_tomorrow if consumption_span=='weekday' else 'day'}s: {consumption:.1f}kWh")
|
2947
2957
|
# time line buckets of consumption
|
2948
2958
|
daily_sum = sum(consumption_by_hour)
|
@@ -2951,14 +2961,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2951
2961
|
solcast_value = None
|
2952
2962
|
solcast_profile = None
|
2953
2963
|
if forecast is None and solcast_api_key is not None and solcast_api_key != 'my.solcast_api_key' and (system_time.hour in forecast_times or run_after == 0):
|
2954
|
-
fsolcast = Solcast(quiet=True,
|
2964
|
+
fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'))
|
2955
2965
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2956
2966
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2957
2967
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
2958
|
-
|
2959
|
-
output(f"\nSolcast forecast for {today} = {fsolcast.daily[today]['kwh']:.1f}, {tomorrow} = {fsolcast.daily[tomorrow]['kwh']:.1f}")
|
2960
|
-
else:
|
2961
|
-
output(f"\nSolcast forecast for {forecast_day} = {solcast_value:.1f}kWh")
|
2968
|
+
output(f"\nSolcast forecast:\n {today}: {fsolcast.daily[today]['kwh']:.1f} (remaining)\n {tomorrow}: {fsolcast.daily[tomorrow]['kwh']:.1f}")
|
2962
2969
|
# get forecast.solar data and produce time line
|
2963
2970
|
solar_value = None
|
2964
2971
|
solar_profile = None
|
@@ -2967,10 +2974,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2967
2974
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2968
2975
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2969
2976
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
2970
|
-
|
2971
|
-
output(f"\nSolar forecast for {today} = {fsolar.daily[today]['kwh']:.1f}, {tomorrow} = {fsolar.daily[tomorrow]['kwh']:.1f}")
|
2972
|
-
else:
|
2973
|
-
output(f"\nSolar forecast for {forecast_day} = {solar_value:.1f}kWh")
|
2977
|
+
output(f"\nSolar forecast:\n {today}: {fsolar.daily[today]['kwh']:.1f}\n {tomorrow}: {fsolar.daily[tomorrow]['kwh']:.1f}")
|
2974
2978
|
if solcast_value is None and solar_value is None and debug_setting > 1:
|
2975
2979
|
output(f"\nNo forecasts available at this time")
|
2976
2980
|
# get generation data
|
@@ -2990,8 +2994,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2990
2994
|
output(f"\nGeneration (kWh):")
|
2991
2995
|
s = ""
|
2992
2996
|
for d in sorted(pv_history.keys())[-gen_days:]:
|
2993
|
-
s += f"
|
2994
|
-
output(s)
|
2997
|
+
s += f" {d} {pv_history[d]:4.1f},"
|
2998
|
+
output(' ' + s[:-1])
|
2995
2999
|
generation = pv_sum / gen_days
|
2996
3000
|
output(f" Average of last {gen_days} days: {generation:.1f}kWh")
|
2997
3001
|
# choose expected value and produce generation time line
|
@@ -3070,15 +3074,15 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3070
3074
|
kwh_needed = test_charge
|
3071
3075
|
charge_message = "** test charge **"
|
3072
3076
|
# work out charge needed
|
3073
|
-
if kwh_min > reserve and kwh_needed < charge_config['min_kwh']:
|
3074
|
-
output(f"\nNo charging needed ({
|
3077
|
+
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:3.0f}%)")
|
3075
3079
|
charge_message = "no charge needed"
|
3076
3080
|
kwh_needed = 0.0
|
3077
3081
|
hours = 0.0
|
3078
3082
|
start_timed = time_to_end
|
3079
3083
|
end_timed = time_to_end
|
3080
3084
|
end_soc = int(end_residual / capacity * 100 + 0.5)
|
3081
|
-
output(f"
|
3085
|
+
output(f" End SoC: {end_soc}% at {hours_time(adjusted_hour(time_to_end, time_line))}")
|
3082
3086
|
# rebuild the battery residual with min_soc for battery hold
|
3083
3087
|
if force_charge > 0 and timed_mode > 1:
|
3084
3088
|
for t in range(int(time_to_start), int(time_to_end)):
|
@@ -3086,7 +3090,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3086
3090
|
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3087
3091
|
else:
|
3088
3092
|
if test_charge is None:
|
3089
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh ({
|
3093
|
+
output(f"\nCharge needed {kwh_needed:.2f}kWh ({today} {hours_time(hour_now)} {current_soc:3.0f}%)")
|
3090
3094
|
charge_message = "with charge added"
|
3091
3095
|
# work out time to add kwh_needed to battery
|
3092
3096
|
taper_time = 10/60 if (start_residual + kwh_needed) >= (capacity * 0.95) else 0
|
@@ -3104,7 +3108,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3104
3108
|
price = charge_period.get('price') if charge_period is not None else None
|
3105
3109
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
3106
3110
|
end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
|
3107
|
-
output(f" Charge
|
3111
|
+
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 ""))
|
3108
3112
|
for i in range(int(time_to_start), int(end_timed) + 1):
|
3109
3113
|
j = i + 1
|
3110
3114
|
# work out time (fraction of hour) when charging in hour from i to j
|
@@ -3129,8 +3133,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3129
3133
|
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3130
3134
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
3131
3135
|
# show the state
|
3132
|
-
output(f" Start SoC {start_residual / capacity * 100:3.0f}% at {hours_time(adjusted_hour(time_to_start, time_line))} ({start_residual:.2f}kWh)")
|
3133
|
-
output(f" End SoC {end_residual / capacity * 100:3.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
|
3136
|
+
output(f" Start SoC: {start_residual / capacity * 100:3.0f}% at {hours_time(adjusted_hour(time_to_start, time_line))} ({start_residual:.2f}kWh)")
|
3137
|
+
output(f" End SoC: {end_residual / capacity * 100:3.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
|
3134
3138
|
# show what we have worked out
|
3135
3139
|
if show_data == 3:
|
3136
3140
|
output(f"\nTime, Generation, Charge, Consumption, Discharge, Residual, kWh")
|
@@ -3139,17 +3143,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3139
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}")
|
3140
3144
|
if show_data > 0:
|
3141
3145
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3142
|
-
s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC
|
3146
|
+
s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC:\n"
|
3143
3147
|
h = base_hour + 1
|
3144
3148
|
t = steps_per_hour
|
3145
|
-
s += " " * (
|
3146
|
-
while t < len(time_line):
|
3149
|
+
s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
|
3150
|
+
while t < len(time_line) and bat_timed[t] is not None:
|
3147
3151
|
s += "\n" if t > steps_per_hour and h % data_wrap == 0 else ""
|
3148
3152
|
s += f" {hours_time(time_line[t])}"
|
3149
|
-
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}
|
3153
|
+
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%,"
|
3150
3154
|
h += 1
|
3151
3155
|
t += steps_per_hour
|
3152
|
-
output(s)
|
3156
|
+
output(s[:-1])
|
3153
3157
|
if show_plot > 0:
|
3154
3158
|
print()
|
3155
3159
|
plt.figure(figsize=(figure_width, figure_width/2))
|
@@ -3180,6 +3184,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3180
3184
|
file_name = charge_config['save'].replace('###', today)
|
3181
3185
|
data = {}
|
3182
3186
|
data['base_time'] = base_time
|
3187
|
+
data['hour_now'] = hour_now
|
3188
|
+
data['current_soc'] = current_soc
|
3183
3189
|
data['steps'] = steps_per_hour
|
3184
3190
|
data['capacity'] = capacity
|
3185
3191
|
data['config'] = charge_config
|
@@ -3213,7 +3219,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3213
3219
|
# CHARGE_COMPARE - load saved data and compare with actual
|
3214
3220
|
##################################################################################################
|
3215
3221
|
|
3216
|
-
def charge_compare(save=None, v=None, show_plot=3):
|
3222
|
+
def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
3217
3223
|
global charge_config
|
3218
3224
|
if save is None and charge_config.get('save') is not None:
|
3219
3225
|
save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
|
@@ -3228,6 +3234,8 @@ def charge_compare(save=None, v=None, show_plot=3):
|
|
3228
3234
|
return
|
3229
3235
|
charge_message = f"using '{save}'"
|
3230
3236
|
base_time = data.get('base_time')
|
3237
|
+
hour_now = data.get('hour_now')
|
3238
|
+
current_soc = data.get('current_soc')
|
3231
3239
|
steps_per_hour = data.get('steps')
|
3232
3240
|
capacity = data.get('capacity')
|
3233
3241
|
time_line = data.get('time')
|
@@ -3236,8 +3244,9 @@ def charge_compare(save=None, v=None, show_plot=3):
|
|
3236
3244
|
consumption_timed = data.get('consumption')
|
3237
3245
|
work_mode_timed = data.get('work_mode')
|
3238
3246
|
run_time = len(time_line)
|
3239
|
-
base_hour = time_hours(base_time[11:16])
|
3247
|
+
base_hour = int(time_hours(base_time[11:16]))
|
3240
3248
|
start_day = base_time[:10]
|
3249
|
+
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:3.0f}%")
|
3241
3250
|
now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
|
3242
3251
|
end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
|
3243
3252
|
if v is None:
|
@@ -3256,43 +3265,62 @@ def charge_compare(save=None, v=None, show_plot=3):
|
|
3256
3265
|
names[var] = name
|
3257
3266
|
for i in range(0, len(d.get('data'))):
|
3258
3267
|
value = d['data'][i]['value']
|
3268
|
+
if value is not None and var == 'SoC':
|
3269
|
+
value *= capacity / 100 # convert % to kWh
|
3259
3270
|
time = d['data'][i]['time'][:16]
|
3260
3271
|
t = int(hours_difference(time, base_time) * steps_per_hour)
|
3261
3272
|
if t >= 0 and t < run_time:
|
3262
3273
|
if plots[var][t] is None:
|
3263
|
-
plots[var][t] =
|
3264
|
-
|
3265
|
-
|
3274
|
+
plots[var][t] = value
|
3275
|
+
count[var][t] = 1
|
3276
|
+
elif var != 'SoC':
|
3277
|
+
plots[var][t] += value
|
3278
|
+
count[var][t] += 1
|
3266
3279
|
for v in plots.keys():
|
3267
3280
|
for i in range(0, run_time):
|
3268
3281
|
plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
|
3269
|
-
|
3270
|
-
|
3271
|
-
|
3272
|
-
|
3273
|
-
|
3274
|
-
|
3275
|
-
|
3276
|
-
|
3277
|
-
|
3278
|
-
|
3279
|
-
|
3280
|
-
|
3281
|
-
|
3282
|
-
|
3283
|
-
|
3284
|
-
plt.
|
3285
|
-
|
3286
|
-
|
3287
|
-
|
3288
|
-
|
3289
|
-
|
3290
|
-
|
3291
|
-
plt.plot(x_timed, [
|
3292
|
-
|
3293
|
-
|
3294
|
-
|
3295
|
-
|
3282
|
+
if show_data > 0 and plots.get('SoC') is not None:
|
3283
|
+
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3284
|
+
s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC:\n"
|
3285
|
+
h = base_hour + 1
|
3286
|
+
t = steps_per_hour
|
3287
|
+
s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
|
3288
|
+
while t < len(time_line) and plots['SoC'][t] is not None:
|
3289
|
+
s += "\n" if t > steps_per_hour and h % data_wrap == 0 else ""
|
3290
|
+
s += f" {hours_time(time_line[t])}"
|
3291
|
+
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%,"
|
3292
|
+
h += 1
|
3293
|
+
t += steps_per_hour
|
3294
|
+
print(s[:-1])
|
3295
|
+
if show_plot > 0:
|
3296
|
+
print()
|
3297
|
+
plt.figure(figsize=(figure_width, figure_width/2))
|
3298
|
+
x_timed = [i for i in range(steps_per_hour, run_time)]
|
3299
|
+
x_ticks = [i for i in range(steps_per_hour, run_time, steps_per_hour)]
|
3300
|
+
plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
|
3301
|
+
if show_plot == 1:
|
3302
|
+
title = f"Predicted Battery SoC % at {base_time}({charge_message})"
|
3303
|
+
plt.plot(x_timed, [bat_timed[x] * 100 / capacity for x in x_timed], label='Battery', color='blue')
|
3304
|
+
plt.plot(x_timed, [work_mode_timed[x]['min_soc'] for x in x_timed], label='Min SoC', color='grey', linestyle='dotted')
|
3305
|
+
plt.plot(x_timed, [work_mode_timed[x]['max_soc'] for x in x_timed], label='Max SoC', color='coral', linestyle='dotted')
|
3306
|
+
plt.plot(x_timed, [(plots['SoC'][x] * 100 / capacity) if plots['SoC'][x] is not None else None for x in x_timed], label='SoC')
|
3307
|
+
else:
|
3308
|
+
title = f"Predicted Energy Flow kWh at {base_time} ({charge_message})"
|
3309
|
+
plt.plot(x_timed, [bat_timed[x] for x in x_timed], label='Battery', color='blue')
|
3310
|
+
plt.plot(x_timed, [generation_timed[x] for x in x_timed], label='Generation', color='green')
|
3311
|
+
plt.plot(x_timed, [consumption_timed[x] for x in x_timed], label='Consumption', color='red')
|
3312
|
+
plt.plot(x_timed, [round(capacity * work_mode_timed[x]['min_soc'] / 100, 1) for x in x_timed], label='Min SoC', color='grey', linestyle='dotted')
|
3313
|
+
plt.plot(x_timed, [round(capacity * work_mode_timed[x]['max_soc'] / 100, 1) for x in x_timed], label='Max SoC', color='coral', linestyle='dotted')
|
3314
|
+
if show_plot == 3:
|
3315
|
+
plt.plot(x_timed, [work_mode_timed[x]['pv'] for x in x_timed], label='PV Charge', color='orange', linestyle='dotted')
|
3316
|
+
plt.plot(x_timed, [work_mode_timed[x]['discharge'] for x in x_timed], label='Discharge', color='brown', linestyle='dotted')
|
3317
|
+
plt.plot(x_timed, [work_mode_timed[x]['charge'] for x in x_timed], label='Grid Charge', color='pink', linestyle='dotted')
|
3318
|
+
for var in plots.keys():
|
3319
|
+
plt.plot(x_timed, [plots[var][x] for x in x_timed], label=names[var])
|
3320
|
+
plt.title(title, fontsize=10)
|
3321
|
+
plt.grid()
|
3322
|
+
plt.legend(fontsize=8, loc='upper right')
|
3323
|
+
plot_show()
|
3296
3324
|
return
|
3297
3325
|
|
3298
3326
|
|
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: 22 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.2"
|
14
14
|
print(f"FoxESS-Cloud Open API version {version}")
|
15
15
|
|
16
16
|
debug_setting = 1
|
@@ -468,6 +468,7 @@ def get_device(sn=None):
|
|
468
468
|
battery = None
|
469
469
|
battery_settings = None
|
470
470
|
schedule = None
|
471
|
+
get_flag()
|
471
472
|
get_generation()
|
472
473
|
# remote_settings = get_ui()
|
473
474
|
# parse the model code to work out attributes
|
@@ -725,10 +726,10 @@ def get_min():
|
|
725
726
|
##################################################################################################
|
726
727
|
|
727
728
|
def set_min(minSocOnGrid = None, minSoc = None, force = 0):
|
728
|
-
global token, device_sn, battery_settings, debug_setting
|
729
|
+
global token, device_sn, schedule, battery_settings, debug_setting
|
729
730
|
if get_device() is None:
|
730
731
|
return None
|
731
|
-
if
|
732
|
+
if schedule['enable'] == True:
|
732
733
|
if force == 0:
|
733
734
|
output(f"** set_min(): cannot set min SoC mode when a schedule is enabled")
|
734
735
|
return None
|
@@ -785,13 +786,15 @@ merge_settings = { # keys to add
|
|
785
786
|
'WorkMode': {'keys': {
|
786
787
|
'h115__': 'operation_mode__work_mode',
|
787
788
|
'h116__': 'operation_mode__work_mode',
|
788
|
-
'h117__': 'operation_mode__work_mode'
|
789
|
+
'h117__': 'operation_mode__work_mode',
|
790
|
+
# 'k106__': 'operation_mode__work_mode',
|
789
791
|
},
|
790
792
|
'values': ['SelfUse', 'Feedin', 'Backup']},
|
791
793
|
'BatteryVolt': {'keys': {
|
792
794
|
'h115__': ['h115__14', 'h115__15', 'h115__16'],
|
793
795
|
'h116__': ['h116__15', 'h116__16', 'h116__17'],
|
794
|
-
'h117__': ['h117__15', 'h117__16', 'h117__17']
|
796
|
+
'h117__': ['h117__15', 'h117__16', 'h117__17'],
|
797
|
+
# 'k106__': ['k106__xx', 'k106__xx', 'k106__xx'],
|
795
798
|
},
|
796
799
|
'type': 'list',
|
797
800
|
'valueType': 'float',
|
@@ -800,11 +803,11 @@ merge_settings = { # keys to add
|
|
800
803
|
'h115__': 'h115__17',
|
801
804
|
'h116__': 'h116__18',
|
802
805
|
'h117__': 'h117__18',
|
806
|
+
# 'k106__': 'k106__xx',
|
803
807
|
},
|
804
808
|
'type': 'list',
|
805
809
|
'valueType': 'int',
|
806
810
|
'unit': '℃'},
|
807
|
-
|
808
811
|
}
|
809
812
|
|
810
813
|
def get_ui():
|
@@ -1026,9 +1029,6 @@ def get_flag():
|
|
1026
1029
|
global device_sn, schedule, debug_setting
|
1027
1030
|
if get_device() is None:
|
1028
1031
|
return None
|
1029
|
-
if device.get('function') is None or device['function'].get('scheduler') is None or device['function']['scheduler'] == False:
|
1030
|
-
output(f"** get_schedule() schedules are not supported")
|
1031
|
-
return None
|
1032
1032
|
output(f"getting flag", 2)
|
1033
1033
|
body = {'deviceSN': device_sn}
|
1034
1034
|
response = signed_post(path="/op/v0/device/scheduler/get/flag", body=body)
|
@@ -1044,6 +1044,8 @@ def get_flag():
|
|
1044
1044
|
schedule['enable'] = result.get('enable')
|
1045
1045
|
schedule['support'] = result.get('support')
|
1046
1046
|
schedule['maxsoc'] = False
|
1047
|
+
if device.get('function') is not None and device['function'].get('scheduler') is not None:
|
1048
|
+
device['function']['scheduler'] = schedule['support']
|
1047
1049
|
return schedule
|
1048
1050
|
|
1049
1051
|
##################################################################################################
|
@@ -1055,6 +1057,9 @@ def get_schedule():
|
|
1055
1057
|
global device_sn, schedule, debug_setting, work_modes
|
1056
1058
|
if get_flag() is None:
|
1057
1059
|
return None
|
1060
|
+
if schedule.get('support') == False:
|
1061
|
+
output(f"** get_schedule(), not supported on this device")
|
1062
|
+
return None
|
1058
1063
|
output(f"getting schedule", 2)
|
1059
1064
|
body = {'deviceSN': device_sn}
|
1060
1065
|
response = signed_post(path="/op/v0/device/scheduler/get", body=body)
|
@@ -1065,7 +1070,10 @@ def get_schedule():
|
|
1065
1070
|
if result is None:
|
1066
1071
|
output(f"** get_schedule(), no result data, {errno_message(response)}")
|
1067
1072
|
return None
|
1068
|
-
|
1073
|
+
enable = result['enable']
|
1074
|
+
if type(enable) is int:
|
1075
|
+
enable = True if enable == 1 else False
|
1076
|
+
schedule['enable'] = enable
|
1069
1077
|
schedule['periods'] = []
|
1070
1078
|
# remove invalid work mode from periods
|
1071
1079
|
for g in result['groups']:
|
@@ -1150,14 +1158,19 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
|
|
1150
1158
|
return period
|
1151
1159
|
|
1152
1160
|
# set a schedule from a period or list of time segment periods
|
1153
|
-
def set_schedule(periods=None, enable=
|
1161
|
+
def set_schedule(periods=None, enable=True):
|
1154
1162
|
global token, device_sn, debug_setting, schedule
|
1155
1163
|
if get_flag() is None:
|
1156
1164
|
return None
|
1165
|
+
if schedule.get('support') == False:
|
1166
|
+
output(f"** set_schedule(), not supported on this device")
|
1167
|
+
return None
|
1157
1168
|
output(f"set_schedule(): enable = {enable}, periods = {periods}", 2)
|
1158
1169
|
if debug_setting > 2:
|
1159
1170
|
return None
|
1160
|
-
if enable
|
1171
|
+
if type(enable) is int:
|
1172
|
+
enable = True if enable == 1 else False
|
1173
|
+
if enable == False:
|
1161
1174
|
output(f"\nDisabling schedule", 1)
|
1162
1175
|
else:
|
1163
1176
|
output(f"\nEnabling schedule", 1)
|
@@ -1178,7 +1191,7 @@ def set_schedule(periods=None, enable=1):
|
|
1178
1191
|
output(f"** set_schedule(), enable, {errno_message(response)}")
|
1179
1192
|
return None
|
1180
1193
|
schedule['periods'] = periods
|
1181
|
-
body = {'deviceSN': device_sn, 'enable': enable}
|
1194
|
+
body = {'deviceSN': device_sn, 'enable': 1 if enable else 0}
|
1182
1195
|
setting_delay()
|
1183
1196
|
response = signed_post(path="/op/v0/device/scheduler/set/flag", body=body)
|
1184
1197
|
if response.status_code != 200:
|
@@ -1380,7 +1393,7 @@ def get_history(time_span='hour', d=None, v=None, summary=1, save=None, load=Non
|
|
1380
1393
|
if e > 0.0:
|
1381
1394
|
kwh += e
|
1382
1395
|
if tariff is not None:
|
1383
|
-
if hour_in (h, [tariff.get('off_peak1'), tariff.get('off_peak2'), tariff.get('off_peak3')]):
|
1396
|
+
if hour_in (h, [tariff.get('off_peak1'), tariff.get('off_peak2'), tariff.get('off_peak3'), tariff.get('off_peak4')]):
|
1384
1397
|
kwh_off += e
|
1385
1398
|
elif hour_in(h, [tariff.get('peak1'), tariff.get('peak2')]):
|
1386
1399
|
kwh_peak += e
|
@@ -2198,56 +2211,36 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2198
2211
|
strategy.append(prices[t])
|
2199
2212
|
output(f" {format_period(prices[t])} at {prices[t]['price']:.1f}p", 1)
|
2200
2213
|
tariff['agile']['strategy'] = strategy
|
2201
|
-
for key in ['off_peak1', 'off_peak2', 'off_peak3']:
|
2214
|
+
for key in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
|
2202
2215
|
if tariff.get(key) is None:
|
2203
2216
|
continue
|
2204
2217
|
if tariff['agile'].get(key) is None:
|
2205
2218
|
tariff['agile'][key] = {}
|
2206
2219
|
# get price index for AM/PM charge times
|
2207
|
-
slots = []
|
2208
|
-
for i in range(0, len(prices)):
|
2209
|
-
if hour_in(time_hours(prices[i]['start']), tariff[key]):
|
2210
|
-
slots.append(i)
|
2220
|
+
slots = [i for i in range(0, len(prices)) if hour_in(time_hours(prices[i]['start']), tariff[key])]
|
2211
2221
|
tariff['agile'][key]['slots'] = slots
|
2212
|
-
|
2213
|
-
weighting = tariff_config.get('weighting')
|
2214
|
-
tariff['agile'][key]['times'] = []
|
2215
|
-
for j in range (0, len(slots)):
|
2216
|
-
span = j + 1
|
2217
|
-
weights = (([1.0] * (span-1) if weighting is None else weighting) + [0.5] * span)[:span]
|
2218
|
-
best = None
|
2219
|
-
price = None
|
2220
|
-
for i in range(0, len(slots) - j):
|
2221
|
-
t = slots[i: i + span]
|
2222
|
-
p_span = [prices[x]['price'] for x in t]
|
2223
|
-
wavg = round(sum(p * w for p,w in zip(p_span, weights)) / sum(weights), 2)
|
2224
|
-
if price is None or wavg < price:
|
2225
|
-
price = wavg
|
2226
|
-
best = t
|
2227
|
-
# save best time slot for charge duration
|
2228
|
-
start = prices[best[0]]['start']
|
2229
|
-
tariff['agile'][key]['times'].append({'start': start, 'end': round_time(start + span / 2), 'price': price, 'best': best, 'key': key})
|
2222
|
+
tariff['agile'][key]['avg'] = avg([prices[t]['price'] for t in slots])
|
2230
2223
|
# show the results
|
2231
2224
|
if tariff_config['show_data'] > 0:
|
2232
2225
|
data_wrap = tariff_config['data_wrap'] if tariff_config.get('data_wrap') is not None else 6
|
2233
2226
|
t = (now.hour * 2) % data_wrap
|
2234
|
-
s = f"\nPricing on {today} p/kWh inc VAT:\n" + " " * t *
|
2227
|
+
s = f"\nPricing on {today} p/kWh inc VAT:\n" + " " * t * 13
|
2235
2228
|
for i in range(0, len(prices)):
|
2236
2229
|
s += "\n" if i > 0 and t % data_wrap == 0 else ""
|
2237
|
-
s += f" {prices[i]['time']} {prices[i]['price']:4.1f}"
|
2230
|
+
s += f" {prices[i]['time']} {prices[i]['price']:4.1f},"
|
2238
2231
|
t += 1
|
2239
|
-
output(s)
|
2232
|
+
output(s[:-1])
|
2240
2233
|
if tariff_config['show_plot'] > 0:
|
2241
2234
|
plt.figure(figsize=(figure_width, figure_width/2))
|
2242
2235
|
x_timed = [i for i in range(0, len(prices))]
|
2243
2236
|
plt.xticks(ticks=x_timed, labels=[prices[x]['time'] for x in x_timed], rotation=90, fontsize=8, ha='center')
|
2244
2237
|
plt.plot(x_timed, [prices[x]['price'] for x in x_timed], label='30 minute price', color='blue')
|
2245
2238
|
s = ""
|
2246
|
-
for key in ['off_peak1', 'off_peak2']:
|
2247
|
-
if tariff['agile'].get(key) is not None and len(tariff['agile'][key]['
|
2248
|
-
p = tariff['agile'][key]
|
2249
|
-
plt.plot(x_timed, [p['
|
2250
|
-
s += f"\n {
|
2239
|
+
for key in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
|
2240
|
+
if tariff['agile'].get(key) is not None and len(tariff['agile'][key]['slots']) > 0:
|
2241
|
+
p = tariff['agile'][key]
|
2242
|
+
plt.plot(x_timed, [p['avg'] if x in p['slots'] else None for x in x_timed], label=f"{key} {p['avg']:.1f}p")
|
2243
|
+
s += f"\n {hours_time(prices[p['slots'][0]]['start'])}-{hours_time(prices[p['slots'][-1]]['end'])} at {p['avg']:.1f}p"
|
2251
2244
|
output(f"\nCharge times{s}" if s != "" else "", 1)
|
2252
2245
|
plt.title(f"Pricing on {today} p/kWh inc VAT", fontsize=10)
|
2253
2246
|
plt.legend(fontsize=8)
|
@@ -2258,13 +2251,40 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2258
2251
|
# return the best charge time:
|
2259
2252
|
def get_best_charge_period(start, duration):
|
2260
2253
|
global tariff
|
2261
|
-
if tariff is None:
|
2254
|
+
if tariff is None or tariff.get('agile') is None or tariff['agile'].get('prices') is None:
|
2262
2255
|
return None
|
2263
|
-
key = [k for k in ['off_peak1', 'off_peak2', 'off_peak3'] if hour_in(start, tariff.get(k))]
|
2264
|
-
|
2265
|
-
|
2266
|
-
|
2267
|
-
|
2256
|
+
key = [k for k in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4'] if hour_in(start, tariff.get(k))]
|
2257
|
+
key = key[0] if len(key) > 0 else None
|
2258
|
+
end = tariff[key]['end'] if key is not None else round_time(start + duration)
|
2259
|
+
span = int(duration * 2 + 0.99)
|
2260
|
+
coverage = max([round_time(end - start), duration])
|
2261
|
+
period = {'start': start, 'end': round_time(start + coverage)}
|
2262
|
+
prices = tariff['agile']['prices']
|
2263
|
+
slots = [i for i in range(0, len(prices)) if hour_in(time_hours(prices[i]['start']), period)]
|
2264
|
+
if len(slots) == 0:
|
2265
|
+
return None
|
2266
|
+
elif len(slots) == 1:
|
2267
|
+
best = slots
|
2268
|
+
price = prices[slots[0]]['price']
|
2269
|
+
best_start = start
|
2270
|
+
else:
|
2271
|
+
# best charge time for duration
|
2272
|
+
weighting = tariff_config.get('weighting')
|
2273
|
+
times = []
|
2274
|
+
weights = ([1.0] * span) if weighting is None else (weighting + [1.0] * span)[:span]
|
2275
|
+
best = None
|
2276
|
+
price = None
|
2277
|
+
for i in range(0, len(slots) - span + 1):
|
2278
|
+
t = slots[i: i + span]
|
2279
|
+
p_span = [prices[x]['price'] for x in t]
|
2280
|
+
wavg = round(sum(p * w for p,w in zip(p_span, weights)) / sum(weights), 2)
|
2281
|
+
if price is None or wavg < price:
|
2282
|
+
price = wavg
|
2283
|
+
best = t
|
2284
|
+
best_start = prices[best[0]]['start']
|
2285
|
+
# save best time slot for charge duration
|
2286
|
+
tariff['agile']['best'] = {'start': best_start, 'end': round_time(best_start + span / 2), 'price': price, 'slots': best, 'key': key}
|
2287
|
+
return tariff['agile']['best']
|
2268
2288
|
|
2269
2289
|
# pushover app key for set_tariff()
|
2270
2290
|
set_tariff_app_key = "apx24dswzinhrbeb62sdensvt42aqe"
|
@@ -2308,7 +2328,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
|
|
2308
2328
|
times = [times]
|
2309
2329
|
output(f"\n{use['name']}:")
|
2310
2330
|
for t in times:
|
2311
|
-
if len(t) not in (1,3,4) or t[0] not in ['off_peak1', 'off_peak2', 'off_peak3', 'peak1', 'peak2']:
|
2331
|
+
if len(t) not in (1,3,4) or t[0] not in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4', 'peak1', 'peak2']:
|
2312
2332
|
output(f"** set_tariff(): invalid time period {t}")
|
2313
2333
|
continue
|
2314
2334
|
key = t[0]
|
@@ -2347,7 +2367,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
|
|
2347
2367
|
elif type(strategy) is not list:
|
2348
2368
|
strategy = [strategy]
|
2349
2369
|
output(f"\nStrategy")
|
2350
|
-
use['strategy'] = get_strategy(use=use, strategy=strategy, quiet=0, remove=[use.get('off_peak1'), use.get('off_peak2'), use.get('off_peak3')])
|
2370
|
+
use['strategy'] = get_strategy(use=use, strategy=strategy, quiet=0, remove=[use.get('off_peak1'), use.get('off_peak2'), use.get('off_peak3'), use.get('off_peak4')])
|
2351
2371
|
output_close(plot=tariff_config['show_plot'])
|
2352
2372
|
if update == 1:
|
2353
2373
|
tariff = use
|
@@ -2542,7 +2562,7 @@ charge_config = {
|
|
2542
2562
|
'solcast': {'adjust': 0.95, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2},
|
2543
2563
|
'solar': {'adjust': 1.20, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2}
|
2544
2564
|
},
|
2545
|
-
'save': 'charge_needed
|
2565
|
+
'save': 'charge_needed ###.txt' # save calculation data for analysis
|
2546
2566
|
}
|
2547
2567
|
|
2548
2568
|
# app key for charge_needed (used to send output via pushover)
|
@@ -2557,7 +2577,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2557
2577
|
# forecast_times: list of hours when forecast can be fetched (UTC)
|
2558
2578
|
# force_charge: 1 = set force charge, 2 = charge for whole period
|
2559
2579
|
|
2560
|
-
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None,
|
2580
|
+
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2561
2581
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
2562
2582
|
global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
|
2563
2583
|
global timed_strategy, steps_per_hour, base_time
|
@@ -2593,6 +2613,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2593
2613
|
now = system_time + timedelta(hours=time_offset)
|
2594
2614
|
today = datetime.strftime(now, '%Y-%m-%d')
|
2595
2615
|
base_hour = now.hour
|
2616
|
+
base_time = today + f" {hours_time(base_hour)}"
|
2596
2617
|
hour_now = now.hour + now.minute / 60
|
2597
2618
|
output(f" datetime = {today} {hours_time(hour_now)}", 2)
|
2598
2619
|
yesterday = datetime.strftime(now - timedelta(days=1), '%Y-%m-%d')
|
@@ -2608,17 +2629,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2608
2629
|
time_change = (change_hour - base_hour) * steps_per_hour
|
2609
2630
|
# get charge times
|
2610
2631
|
times = []
|
2611
|
-
for k in ['off_peak1', 'off_peak2', 'off_peak3']:
|
2632
|
+
for k in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
|
2612
2633
|
if tariff is not None and tariff.get(k) is not None:
|
2613
|
-
start = time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0)
|
2614
|
-
end = time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0)
|
2634
|
+
start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2635
|
+
end = round_time(time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2615
2636
|
force = 0 if tariff[k].get('force') is not None and tariff[k]['force'] == 0 else force_charge
|
2616
2637
|
times.append({'key': k, 'start': start, 'end': end, 'force': force})
|
2617
2638
|
if len(times) == 0:
|
2618
|
-
times.append({'key': 'off_peak1', 'start': round_time(base_hour +
|
2619
|
-
output(f"Charge time: {hours_time(base_hour +
|
2639
|
+
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'force': force_charge})
|
2640
|
+
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2620
2641
|
time_to_end1 = None
|
2642
|
+
start_now = (int(hour_now * 2 + 1) / 2) % 24
|
2621
2643
|
for t in times:
|
2644
|
+
if hour_in(start_now, t):
|
2645
|
+
t['start'] = start_now
|
2622
2646
|
time_to_start = round_time(t['start'] - base_hour) * steps_per_hour
|
2623
2647
|
time_to_start += hour_adjustment * steps_per_hour if time_to_start > time_change else 0
|
2624
2648
|
charge_time = round_time(t['end'] - t['start'])
|
@@ -2635,9 +2659,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2635
2659
|
time_to_start = times[0]['time_to_start']
|
2636
2660
|
time_to_end = times[0]['time_to_end']
|
2637
2661
|
charge_time = times[0]['charge_time']
|
2638
|
-
if hour_in(hour_now, {'start': round_time(start_at - 0.25), 'end': round_time(end_by + 0.25)}) and update_settings > 0:
|
2639
|
-
print(f"\nInverter settings will not be changed less than 15 minutes before or after the next charging period")
|
2640
|
-
update_settings = 0
|
2641
2662
|
# work out time window and times with clock changes
|
2642
2663
|
time_to_next = int(time_to_start)
|
2643
2664
|
charge_today = (base_hour + time_to_start / steps_per_hour) < 24
|
@@ -2791,8 +2812,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2791
2812
|
output(f"\nConsumption (kWh):")
|
2792
2813
|
s = ""
|
2793
2814
|
for h in history:
|
2794
|
-
s += f"
|
2795
|
-
output(s)
|
2815
|
+
s += f" {h['date']}: {h['total']:4.1f},"
|
2816
|
+
output(' ' + s[:-1])
|
2796
2817
|
output(f" Average of last {consumption_days} {day_tomorrow if consumption_span=='weekday' else 'day'}s: {consumption:.1f}kWh")
|
2797
2818
|
# time line buckets of consumption
|
2798
2819
|
daily_sum = sum(consumption_by_hour)
|
@@ -2801,14 +2822,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2801
2822
|
solcast_value = None
|
2802
2823
|
solcast_profile = None
|
2803
2824
|
if forecast is None and solcast_api_key is not None and solcast_api_key != 'my.solcast_api_key' and (system_time.hour in forecast_times or run_after == 0):
|
2804
|
-
fsolcast = Solcast(quiet=True,
|
2825
|
+
fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'))
|
2805
2826
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2806
2827
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2807
2828
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
2808
|
-
|
2809
|
-
output(f"\nSolcast forecast for {today} = {fsolcast.daily[today]['kwh']:.1f}, {tomorrow} = {fsolcast.daily[tomorrow]['kwh']:.1f}")
|
2810
|
-
else:
|
2811
|
-
output(f"\nSolcast forecast for {forecast_day} = {solcast_value:.1f}kWh")
|
2829
|
+
output(f"\nSolcast forecast:\n {today}: {fsolcast.daily[today]['kwh']:.1f} (remaining)\n {tomorrow}: {fsolcast.daily[tomorrow]['kwh']:.1f}")
|
2812
2830
|
# get forecast.solar data and produce time line
|
2813
2831
|
solar_value = None
|
2814
2832
|
solar_profile = None
|
@@ -2817,10 +2835,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2817
2835
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2818
2836
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2819
2837
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
2820
|
-
|
2821
|
-
output(f"\nSolar forecast for {today} = {fsolar.daily[today]['kwh']:.1f}, {tomorrow} = {fsolar.daily[tomorrow]['kwh']:.1f}")
|
2822
|
-
else:
|
2823
|
-
output(f"\nSolar forecast for {forecast_day} = {solar_value:.1f}kWh")
|
2838
|
+
output(f"\nSolar forecast:\n {today}: {fsolcast.daily[today]['kwh']:.1f}\n {tomorrow}: {fsolcast.daily[tomorrow]['kwh']:.1f}")
|
2824
2839
|
if solcast_value is None and solar_value is None and debug_setting > 1:
|
2825
2840
|
output(f"\nNo forecasts available at this time")
|
2826
2841
|
# get generation data
|
@@ -2840,8 +2855,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2840
2855
|
output(f"\nGeneration (kWh):")
|
2841
2856
|
s = ""
|
2842
2857
|
for d in sorted(pv_history.keys())[-gen_days:]:
|
2843
|
-
s += f"
|
2844
|
-
output(s)
|
2858
|
+
s += f" {d}: {pv_history[d]:4.1f},"
|
2859
|
+
output(' ' + s[:-1])
|
2845
2860
|
generation = pv_sum / gen_days
|
2846
2861
|
output(f" Average of last {gen_days} days: {generation:.1f}kWh")
|
2847
2862
|
# choose expected value and produce generation time line
|
@@ -2920,15 +2935,15 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2920
2935
|
kwh_needed = test_charge
|
2921
2936
|
charge_message = "** test charge **"
|
2922
2937
|
# work out charge needed
|
2923
|
-
if kwh_min > reserve and kwh_needed < charge_config['min_kwh'] and full_charge is None and test_charge is None and force_charge != 2:
|
2924
|
-
output(f"\nNo charging needed
|
2938
|
+
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:3.0f}%)")
|
2925
2940
|
charge_message = "no charge needed"
|
2926
2941
|
kwh_needed = 0.0
|
2927
2942
|
hours = 0.0
|
2928
2943
|
start_timed = time_to_end
|
2929
2944
|
end_timed = time_to_end
|
2930
2945
|
end_soc = int(end_residual / capacity * 100 + 0.5)
|
2931
|
-
output(f"
|
2946
|
+
output(f" End SoC: {end_soc}% at {hours_time(adjusted_hour(time_to_end, time_line))}")
|
2932
2947
|
# rebuild the battery residual with min_soc for battery hold
|
2933
2948
|
if force_charge > 0 and timed_mode > 1:
|
2934
2949
|
for t in range(int(time_to_start), int(time_to_end)):
|
@@ -2936,7 +2951,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2936
2951
|
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
2937
2952
|
else:
|
2938
2953
|
if test_charge is None:
|
2939
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh
|
2954
|
+
output(f"\nCharge needed {kwh_needed:.2f}kWh ({today} {hours_time(hour_now)} {current_soc:3.0f}%)")
|
2940
2955
|
charge_message = "with charge added"
|
2941
2956
|
# work out time to add kwh_needed to battery
|
2942
2957
|
taper_time = 10/60 if (start_residual + kwh_needed) >= (capacity * 0.95) else 0
|
@@ -2954,7 +2969,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2954
2969
|
price = charge_period.get('price') if charge_period is not None else None
|
2955
2970
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
2956
2971
|
end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
|
2957
|
-
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 ""))
|
2958
2973
|
for i in range(int(time_to_start), int(end_timed) + 1):
|
2959
2974
|
j = i + 1
|
2960
2975
|
# work out time (fraction of hour) when charging in hour from i to j
|
@@ -2979,8 +2994,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2979
2994
|
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
2980
2995
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
2981
2996
|
# show the state
|
2982
|
-
output(f" Start SoC {start_residual / capacity * 100:3.0f}% at {hours_time(adjusted_hour(time_to_start, time_line))} ({start_residual:.2f}kWh)")
|
2983
|
-
output(f" End SoC {end_residual / capacity * 100:3.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
|
2997
|
+
output(f" Start SoC: {start_residual / capacity * 100:3.0f}% at {hours_time(adjusted_hour(time_to_start, time_line))} ({start_residual:.2f}kWh)")
|
2998
|
+
output(f" End SoC: {end_residual / capacity * 100:3.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
|
2984
2999
|
# show what we have worked out
|
2985
3000
|
if show_data == 3:
|
2986
3001
|
output(f"\nTime, Generation, Charge, Consumption, Discharge, Residual, kWh")
|
@@ -2989,17 +3004,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2989
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}")
|
2990
3005
|
if show_data > 0:
|
2991
3006
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
2992
|
-
s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC
|
3007
|
+
s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC:\n"
|
2993
3008
|
h = base_hour + 1
|
2994
3009
|
t = steps_per_hour
|
2995
|
-
s += " " * (
|
2996
|
-
while t < len(time_line):
|
3010
|
+
s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
|
3011
|
+
while t < len(time_line) and bat_timed[t] is not None:
|
2997
3012
|
s += "\n" if t > steps_per_hour and h % data_wrap == 0 else ""
|
2998
3013
|
s += f" {hours_time(time_line[t])}"
|
2999
|
-
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}
|
3014
|
+
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%,"
|
3000
3015
|
h += 1
|
3001
3016
|
t += steps_per_hour
|
3002
|
-
output(s)
|
3017
|
+
output(s[:-1])
|
3003
3018
|
if show_plot > 0:
|
3004
3019
|
print()
|
3005
3020
|
plt.figure(figsize=(figure_width, figure_width/2))
|
@@ -3030,6 +3045,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3030
3045
|
file_name = charge_config['save'].replace('###', today)
|
3031
3046
|
data = {}
|
3032
3047
|
data['base_time'] = base_time
|
3048
|
+
data['hour_now'] = hour_now
|
3049
|
+
data['current_soc'] = current_soc
|
3033
3050
|
data['steps'] = steps_per_hour
|
3034
3051
|
data['capacity'] = capacity
|
3035
3052
|
data['config'] = charge_config
|
@@ -3062,7 +3079,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3062
3079
|
# CHARGE_COMPARE - load saved data and compare with actual
|
3063
3080
|
##################################################################################################
|
3064
3081
|
|
3065
|
-
def charge_compare(save=None, v=None, show_plot=3):
|
3082
|
+
def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
3066
3083
|
global charge_config
|
3067
3084
|
if save is None and charge_config.get('save') is not None:
|
3068
3085
|
save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
|
@@ -3077,6 +3094,8 @@ def charge_compare(save=None, v=None, show_plot=3):
|
|
3077
3094
|
return
|
3078
3095
|
charge_message = f"using '{save}'"
|
3079
3096
|
base_time = data.get('base_time')
|
3097
|
+
hour_now = data.get('hour_now')
|
3098
|
+
current_soc = data.get('current_soc')
|
3080
3099
|
steps_per_hour = data.get('steps')
|
3081
3100
|
capacity = data.get('capacity')
|
3082
3101
|
time_line = data.get('time')
|
@@ -3085,8 +3104,9 @@ def charge_compare(save=None, v=None, show_plot=3):
|
|
3085
3104
|
consumption_timed = data.get('consumption')
|
3086
3105
|
work_mode_timed = data.get('work_mode')
|
3087
3106
|
run_time = len(time_line)
|
3088
|
-
base_hour = time_hours(base_time[11:16])
|
3107
|
+
base_hour = int(time_hours(base_time[11:16]))
|
3089
3108
|
start_day = base_time[:10]
|
3109
|
+
print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:3.0f}%")
|
3090
3110
|
now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
|
3091
3111
|
end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
|
3092
3112
|
if v is None:
|
@@ -3105,43 +3125,62 @@ def charge_compare(save=None, v=None, show_plot=3):
|
|
3105
3125
|
names[var] = name
|
3106
3126
|
for i in range(0, len(d.get('data'))):
|
3107
3127
|
value = d['data'][i]['value']
|
3128
|
+
if value is not None and var == 'SoC':
|
3129
|
+
value *= capacity / 100 # convert % to kWh
|
3108
3130
|
time = d['data'][i]['time'][:16]
|
3109
3131
|
t = int(hours_difference(time, base_time) * steps_per_hour)
|
3110
3132
|
if t >= 0 and t < run_time:
|
3111
3133
|
if plots[var][t] is None:
|
3112
|
-
plots[var][t] =
|
3113
|
-
|
3114
|
-
|
3134
|
+
plots[var][t] = value
|
3135
|
+
count[var][t] = 1
|
3136
|
+
elif var != 'SoC':
|
3137
|
+
plots[var][t] += value
|
3138
|
+
count[var][t] += 1
|
3115
3139
|
for v in plots.keys():
|
3116
3140
|
for i in range(0, run_time):
|
3117
3141
|
plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
|
3118
|
-
|
3119
|
-
|
3120
|
-
|
3121
|
-
|
3122
|
-
|
3123
|
-
|
3124
|
-
|
3125
|
-
|
3126
|
-
|
3127
|
-
|
3128
|
-
|
3129
|
-
|
3130
|
-
|
3131
|
-
|
3132
|
-
|
3133
|
-
plt.
|
3134
|
-
|
3135
|
-
|
3136
|
-
|
3137
|
-
|
3138
|
-
|
3139
|
-
|
3140
|
-
plt.plot(x_timed, [
|
3141
|
-
|
3142
|
-
|
3143
|
-
|
3144
|
-
|
3142
|
+
if show_data > 0 and plots.get('SoC') is not None:
|
3143
|
+
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3144
|
+
s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC:\n"
|
3145
|
+
h = base_hour + 1
|
3146
|
+
t = steps_per_hour
|
3147
|
+
s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
|
3148
|
+
while t < len(time_line) and plots['SoC'][t] is not None:
|
3149
|
+
s += "\n" if t > steps_per_hour and h % data_wrap == 0 else ""
|
3150
|
+
s += f" {hours_time(time_line[t])}"
|
3151
|
+
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%,"
|
3152
|
+
h += 1
|
3153
|
+
t += steps_per_hour
|
3154
|
+
print(s[:-1])
|
3155
|
+
if show_plot > 0:
|
3156
|
+
print()
|
3157
|
+
plt.figure(figsize=(figure_width, figure_width/2))
|
3158
|
+
x_timed = [i for i in range(steps_per_hour, run_time)]
|
3159
|
+
x_ticks = [i for i in range(steps_per_hour, run_time, steps_per_hour)]
|
3160
|
+
plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
|
3161
|
+
if show_plot == 1:
|
3162
|
+
title = f"Predicted Battery SoC % at {base_time}({charge_message})"
|
3163
|
+
plt.plot(x_timed, [bat_timed[x] * 100 / capacity for x in x_timed], label='Battery', color='blue')
|
3164
|
+
plt.plot(x_timed, [work_mode_timed[x]['min_soc'] for x in x_timed], label='Min SoC', color='grey', linestyle='dotted')
|
3165
|
+
plt.plot(x_timed, [work_mode_timed[x]['max_soc'] for x in x_timed], label='Max SoC', color='coral', linestyle='dotted')
|
3166
|
+
plt.plot(x_timed, [(plots['SoC'][x] * 100 / capacity) if plots['SoC'][x] is not None else None for x in x_timed], label='SoC')
|
3167
|
+
else:
|
3168
|
+
title = f"Predicted Energy Flow kWh at {base_time} ({charge_message})"
|
3169
|
+
plt.plot(x_timed, [bat_timed[x] for x in x_timed], label='Battery', color='blue')
|
3170
|
+
plt.plot(x_timed, [generation_timed[x] for x in x_timed], label='Generation', color='green')
|
3171
|
+
plt.plot(x_timed, [consumption_timed[x] for x in x_timed], label='Consumption', color='red')
|
3172
|
+
plt.plot(x_timed, [round(capacity * work_mode_timed[x]['min_soc'] / 100, 1) for x in x_timed], label='Min SoC', color='grey', linestyle='dotted')
|
3173
|
+
plt.plot(x_timed, [round(capacity * work_mode_timed[x]['max_soc'] / 100, 1) for x in x_timed], label='Max SoC', color='coral', linestyle='dotted')
|
3174
|
+
if show_plot == 3:
|
3175
|
+
plt.plot(x_timed, [work_mode_timed[x]['pv'] for x in x_timed], label='PV Charge', color='orange', linestyle='dotted')
|
3176
|
+
plt.plot(x_timed, [work_mode_timed[x]['discharge'] for x in x_timed], label='Discharge', color='brown', linestyle='dotted')
|
3177
|
+
plt.plot(x_timed, [work_mode_timed[x]['charge'] for x in x_timed], label='Grid Charge', color='pink', linestyle='dotted')
|
3178
|
+
for var in plots.keys():
|
3179
|
+
plt.plot(x_timed, [plots[var][x] for x in x_timed], label=names[var])
|
3180
|
+
plt.title(title, fontsize=10)
|
3181
|
+
plt.grid()
|
3182
|
+
plt.legend(fontsize=8, loc='upper right')
|
3183
|
+
plot_show()
|
3145
3184
|
return
|
3146
3185
|
|
3147
3186
|
##################################################################################################
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: foxesscloud
|
3
|
-
Version: 2.5.
|
3
|
+
Version: 2.5.2
|
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
|
@@ -239,7 +239,7 @@ The summary includes the following attributes:
|
|
239
239
|
|
240
240
|
For power values (unit = kW), the summary performs a Riemann sum of the data, integrating kW over the day to estimate energy in kWh. In this case, the following attributes are also added:
|
241
241
|
+ kwh: the total energy generated or consumed
|
242
|
-
+ kwh_off: the total energy consumed or generated during the off-peak time of use (off_peak1, off_peak2, off_peak3)
|
242
|
+
+ kwh_off: the total energy consumed or generated during the off-peak time of use (off_peak1, off_peak2, off_peak3, off_peak4)
|
243
243
|
+ kwh_peak: the total energy consumed or generated during the peak time of use (peak1, peak2)
|
244
244
|
+ kwh_neg: the total energy from -ve power flow (all other totals are based on +ve power flow)
|
245
245
|
|
@@ -418,12 +418,13 @@ This example shows the results reported by charge needed:
|
|
418
418
|
Provides a comparison of a prediction, saved by charge_needed(), with the actuals
|
419
419
|
|
420
420
|
```
|
421
|
-
f.charge_compare(save, v, show_plot)
|
421
|
+
f.charge_compare(save, v, show_data, show_plot)
|
422
422
|
```
|
423
423
|
|
424
424
|
Produces a plot of the saved data from charge_needed() overlaid with data from get_history():
|
425
425
|
+ 'save': the name of the file to load
|
426
426
|
+ 'v': the variables to plot. The default is 'pvPower', 'loadsPower' and 'SoC'
|
427
|
+
+ show_data: 1 show battery SoC data by hour (default)
|
427
428
|
+ show_plot: 1 plot battery SoC data. 2 plot battery Residual, Generation and Consumption. 3 plot 2 + Charge and Discharge The default is 3
|
428
429
|
|
429
430
|
|
@@ -564,7 +565,7 @@ The best charging period is determined based on the weighted average of the 30 m
|
|
564
565
|
|
565
566
|
set_tariff() can configure multiple off-peak and peak periods for any tariff using the 'times' parameter. Times is a list of tuples:
|
566
567
|
+ containing values for key, 'start', 'end' and optional 'force'.
|
567
|
-
+ recongnised keys are: 'off_peak1', 'off_peak2', 'off_peak3', 'peak1', 'peak2'
|
568
|
+
+ recongnised keys are: 'off_peak1', 'off_peak2', 'off_peak3', 'off_peak4', 'peak1', 'peak2'
|
568
569
|
+ a tuple containing a key with no values will remove the time period from the tariff.
|
569
570
|
|
570
571
|
For example, this parameter configures an AM charging period between 11pm and 8am and a PM charging period between 12 noon and 4pm and removes the time period 'peak2':
|
@@ -781,6 +782,17 @@ This setting can be:
|
|
781
782
|
|
782
783
|
# Version Info
|
783
784
|
|
785
|
+
2.5.2<br>
|
786
|
+
Updates to allow charge_needed() to run during a charge period.
|
787
|
+
Add suport for 'off_peak4' charge period.
|
788
|
+
Change Solcast forecast in charge_needed() so it does not get todays estimate to save API calls.
|
789
|
+
Include contingency and reserve when checking minimum battery level.
|
790
|
+
|
791
|
+
2.5.1<br>
|
792
|
+
Fix anomaly in scheduler support when get_device and get_flag return different results.
|
793
|
+
Add 'show_data' to charge_compare() and display run time and starting SoC.
|
794
|
+
Fix incorrect SoC actual data in charge_compare().
|
795
|
+
|
784
796
|
2.5.0<br>
|
785
797
|
Fix duration_in() to work with more steps per hour.
|
786
798
|
Improve charge calibrationn when using Force Charge.
|
@@ -0,0 +1,7 @@
|
|
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,,
|
@@ -1,7 +0,0 @@
|
|
1
|
-
foxesscloud/foxesscloud.py,sha256=Wxk6p5q5_AGEK62W9q41V8qMFMw-AYeexb30f593qA0,208768
|
2
|
-
foxesscloud/openapi.py,sha256=QW_KgGFbG8jQeQzcXWjFC4Zq7ImnYrDrCT7S8ZfopJk,201645
|
3
|
-
foxesscloud-2.5.0.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
|
4
|
-
foxesscloud-2.5.0.dist-info/METADATA,sha256=nfI8LrxU_4l6GGwhiFcqBLbgw9tgPc1bvlmtcKSKYVs,53682
|
5
|
-
foxesscloud-2.5.0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
6
|
-
foxesscloud-2.5.0.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
|
7
|
-
foxesscloud-2.5.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|