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.
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud
4
- Updated: 22 September 2024
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.4"
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': 0, # show pricing 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
- t = (now.hour * 2) % data_wrap
2367
- s = f"\nPricing on {today} p/kWh inc VAT:\n" + " " * t * 13
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 > 0 and t % data_wrap == 0 else ""
2370
- s += f" {prices[i]['time']} {prices[i]['price']:4.1f},"
2371
- t += 1
2372
- output(s[:-1])
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 = 0.0
2569
+ value = None
2575
2570
  elif steps_per_hour == 1:
2576
- value = c_float(forecast.daily[day]['hourly'].get(int(h % 24)))
2571
+ value = forecast.daily[day]['hourly'].get(int(h % 24))
2577
2572
  else:
2578
- value = c_float(forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2)))
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': [20,10,5,15], # % of consumption. Single value or [winter, spring, summer, autumn]
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(start_now, t):
2784
- t['start'] = start_now
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
- output(f"\nSolcast forecast:\n {today}: {fsolcast.daily[today]['kwh']:.1f} (remaining)\n {tomorrow}: {fsolcast.daily[tomorrow]['kwh']:.1f}")
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 forecast:\n {today}: {fsolar.daily[today]['kwh']:.1f}\n {tomorrow}: {fsolar.daily[tomorrow]['kwh']:.1f}")
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 ({today} {hours_time(hour_now)} {current_soc:3.0f}%)")
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
- output(f" End SoC: {end_soc}% at {hours_time(adjusted_hour(time_to_end, time_line))}")
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 ({today} {hours_time(hour_now)} {current_soc:3.0f}%)")
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: {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 ""))
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
- # rebuild the battery residual with the charge added and min_soc
3133
- (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3134
- end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
3135
- # show the state
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)")
3138
- # show what we have worked out
3139
- if show_data == 3:
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:\n" if show_data == 2 else f"\nBattery SoC:\n"
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
- s += "\n" if t > steps_per_hour and h % data_wrap == 0 else ""
3152
- s += f" {hours_time(time_line[t])}"
3153
- s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%,"
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[:-1])
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:3.0f}%")
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:\n" if show_data == 2 else f"\nBattery SoC:\n"
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
- 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}%,"
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[:-1])
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
- shaded = time_hours(self.daily[date]['sun'][0]) + self.shading['am_delay']
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
- shaded = time_hours(self.daily[date]['sun'][1]) - self.shading['pm_delay']
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
- shaded = time_hours(self.daily[date]['sun'][0]) + self.shading['am_delay']
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
- shaded = time_hours(self.daily[date]['sun'][1]) - self.shading['pm_delay']
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: 22 September 2024
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.2"
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': 0, # show pricing 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
- t = (now.hour * 2) % data_wrap
2227
- s = f"\nPricing on {today} p/kWh inc VAT:\n" + " " * t * 13
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 > 0 and t % data_wrap == 0 else ""
2230
- s += f" {prices[i]['time']} {prices[i]['price']:4.1f},"
2231
- t += 1
2232
- output(s[:-1])
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 = 0.0
2433
+ value = None
2435
2434
  elif steps_per_hour == 1:
2436
- value = c_float(forecast.daily[day]['hourly'].get(int(h % 24)))
2435
+ value = forecast.daily[day]['hourly'].get(int(h % 24))
2437
2436
  else:
2438
- value = c_float(forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2)))
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': [20,10,5,15], # % of consumption. Single value or [winter, spring, summer, autumn]
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(start_now, t):
2645
- t['start'] = start_now
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 forecast:\n {today}: {fsolcast.daily[today]['kwh']:.1f} (remaining)\n {tomorrow}: {fsolcast.daily[tomorrow]['kwh']:.1f}")
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 forecast:\n {today}: {fsolcast.daily[today]['kwh']:.1f}\n {tomorrow}: {fsolcast.daily[tomorrow]['kwh']:.1f}")
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 ({today} {hours_time(hour_now)} {current_soc:3.0f}%)")
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
- output(f" End SoC: {end_soc}% at {hours_time(adjusted_hour(time_to_end, time_line))}")
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 ({today} {hours_time(hour_now)} {current_soc:3.0f}%)")
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: {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 ""))
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
- # rebuild the battery residual with the charge added and min_soc
2994
- (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
2995
- end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
2996
- # show the state
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)")
2999
- # show what we have worked out
3000
- if show_data == 3:
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:\n" if show_data == 2 else f"\nBattery SoC:\n"
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
- s += "\n" if t > steps_per_hour and h % data_wrap == 0 else ""
3013
- s += f" {hours_time(time_line[t])}"
3014
- s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%,"
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[:-1])
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:3.0f}%")
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:\n" if show_data == 2 else f"\nBattery SoC:\n"
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
- 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}%,"
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[:-1])
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 self.data.get('estimated_actuals') is None else ['forecasts', 'estimated_actuals']:
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())[1:-1]
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 > 2 * days :
3781
- self.keys = self.keys[1:-1]
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 and self.shading.get('solcast') 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['solcast'].get('adjust') is not None:
3793
- loss = self.shading['solcast']['adjust']
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['solcast'].get('am_delay') is not None:
3797
- shaded = time_hours(self.daily[date]['sun'][0]) + self.shading['solcast']['am_delay']
3798
- loss = self.shading['solcast']['am_loss']
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['solcast'].get('pm_delay') is not None:
3802
- shaded = time_hours(self.daily[date]['sun'][1]) - self.shading['solcast']['pm_delay']
3803
- loss = self.shading['solcast']['pm_loss']
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['solar'].get('adjust') is not None:
4109
- loss = self.shading['solar']['adjust']
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['solar'].get('am_delay') is not None:
4113
- shaded = time_hours(self.daily[date]['sun'][0]) + self.shading['solar']['am_delay']
4114
- loss = self.shading['solar']['am_loss']
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['solar'].get('pm_delay') is not None:
4118
- shaded = time_hours(self.daily[date]['sun'][1]) - self.shading['solar']['pm_delay']
4119
- loss = self.shading['solar']['pm_loss']
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.2
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 0.
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 change the weighting by providing 'weighting'. The following preset weightings are provided:
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,,