foxesscloud 2.5.2__py3-none-any.whl → 2.5.3__py3-none-any.whl

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