foxesscloud 2.5.4__py3-none-any.whl → 2.5.6__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: 25 September 2024
4
+ Updated: 27 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.6"
13
+ version = "1.6.8"
14
14
  print(f"FoxESS-Cloud version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -721,7 +721,7 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
721
721
  output(f"success", 2)
722
722
  return battery_settings
723
723
 
724
- def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, target_soc=100, start_soc=10):
724
+ def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, end_soc=100, start_soc=10):
725
725
  output(f"\nConfiguring schedule",1)
726
726
  charge = []
727
727
  st1 = time_hours(st1)
@@ -730,7 +730,7 @@ def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, target_so
730
730
  en2 = time_hours(en2)
731
731
  span = None
732
732
  if st2 is not None and en2 is not None and st2 != en2:
733
- charge.append({'start': st2, 'end': en2, 'mode': 'ForceCharge', 'min_soc': min_soc, 'max_soc': target_soc})
733
+ charge.append({'start': st2, 'end': en2, 'mode': 'ForceCharge', 'min_soc': min_soc, 'max_soc': end_soc})
734
734
  span = {'start': st2, 'end': en2}
735
735
  if st1 is not None and en1 is not None and st1 != en1:
736
736
  charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': start_soc})
@@ -2221,6 +2221,7 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
2221
2221
  if use.get('agile') is not None and use['agile'].get('strategy') is not None:
2222
2222
  base_time_adjust = hours_difference(base_time, use['agile'].get('base_time') )
2223
2223
  for s in use['agile']['strategy']:
2224
+ s['valid_for'] = [int((s['hour'] - base_time_adjust) * steps_per_hour + i) for i in range(0, steps_per_hour // 2)] if s.get('hour') is not None else None
2224
2225
  strategy.append(s)
2225
2226
  if strategy is None or len(strategy) == 0:
2226
2227
  return []
@@ -2230,7 +2231,7 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
2230
2231
  start = s['start']
2231
2232
  end = s['end']
2232
2233
  if hour_overlap(s, remove):
2233
- output(f" {hours_time(start)}-{hours_time(end)} ** removed ** (overlaps charge period)", 2)
2234
+ output(f" {hours_time(start)}-{hours_time(end)} was removed from strategy", 2)
2234
2235
  continue
2235
2236
  # add segment
2236
2237
  min_soc_now = s['min_soc'] if s.get('min_soc') is not None and s['min_soc'] > 10 else 10
@@ -2239,9 +2240,9 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
2239
2240
  fdsoc = s.get('fdsoc')
2240
2241
  fdpwr = s.get('fdpwr')
2241
2242
  price = s.get('price')
2242
- expires = int((s['valid_to'] - base_time_adjust) * steps_per_hour) if s.get('valid_to') is not None else None
2243
+ valid_for = s.get('valid_for')
2243
2244
  segment = {'start': start, 'end': end, 'mode': mode, 'min_soc': min_soc_now, 'max_soc': max_soc,
2244
- 'fdsoc': fdsoc, 'fdpwr': fdpwr, 'price': price, 'expires': expires}
2245
+ 'fdsoc': fdsoc, 'fdpwr': fdpwr, 'price': price, 'valid_for': valid_for}
2245
2246
  if quiet == 0:
2246
2247
  s = f" {hours_time(start)}-{hours_time(end)} {mode}, min_soc {min_soc_now}%"
2247
2248
  s += f", max_soc {max_soc}%" if max_soc is not None else ""
@@ -2269,8 +2270,8 @@ tariff_config = {
2269
2270
  'region': "H", # region code to use for Octopus API
2270
2271
  'update_time': 16.5, # time in hours when tomrow's data can be fetched
2271
2272
  'weighting': None, # weights for weighted average
2272
- 'plunge_price': [1, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2273
- 'plunge_slots': 6, # number of 30 minute slots to use
2273
+ 'plunge_price': [3, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2274
+ 'plunge_slots': 8, # number of 30 minute slots to use
2274
2275
  'data_wrap': 6, # prices to show per line
2275
2276
  'show_data': 1, # show pricing data
2276
2277
  'show_plot': 1 # plot pricing data
@@ -2317,21 +2318,22 @@ def get_agile_times(tariff=agile_octopus, d=None):
2317
2318
  # extract times and prices. Times are Zulu (UTC)
2318
2319
  prices = [] # ordered list of 30 minute prices
2319
2320
  for i in range(0, len(results)):
2320
- start = (now.hour + i / 2) % 24
2321
+ hour = i / 2
2322
+ start = (now.hour + hour) % 24
2321
2323
  time_offset = daylight_saving(results[i]['valid_from'][:16]) if daylight_saving is not None else 0
2322
2324
  prices.append({
2323
2325
  'start': start,
2324
2326
  'end': round_time(start + 0.5),
2325
2327
  'time': hours_time(time_hours(results[i]['valid_from'][11:16]) + time_offset + time_shift),
2326
2328
  'price': results[i]['value_inc_vat'],
2327
- 'valid_to': i / 2 + 0.5})
2329
+ 'hour': hour})
2328
2330
  tariff['agile']['base_time'] = period_from.replace('T', ' ')
2329
2331
  tariff['agile']['prices'] = prices
2330
2332
  plunge = []
2331
2333
  plunge_price = tariff_config['plunge_price'] if tariff_config.get('plunge_price') is not None else 2
2332
2334
  plunge_price = [plunge_price] if type(plunge_price) is not list else plunge_price
2333
2335
  plunge_slots = tariff_config['plunge_slots'] if tariff_config.get('plunge_slots') is not None else 6
2334
- for i in range(0, min([48, len(prices)])):
2336
+ for i in range(0, len(prices)):
2335
2337
  # hour relative index into list of plunge prices, starting at 7am
2336
2338
  x = int(((now.hour - 7 + i / 2) % 24) * len(plunge_price) / 24)
2337
2339
  if prices[i] is not None and prices[i]['price'] < plunge_price[x]:
@@ -2591,7 +2593,7 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2591
2593
  if strategy is not None:
2592
2594
  period['mode'] = 'SelfUse'
2593
2595
  for d in strategy:
2594
- if hour_in(h, d) and (d.get('expires') is None or i < d['expires']):
2596
+ if hour_in(h, d) and (d.get('valid_for') is None or i in d['valid_for']):
2595
2597
  mode = d['mode']
2596
2598
  period['mode'] = mode
2597
2599
  min_soc_now = d['min_soc'] if d.get('min_soc') is not None else min_soc
@@ -2679,7 +2681,7 @@ charge_config = {
2679
2681
  'consumption_days': 3, # number of days to use for average consumption (1-7)
2680
2682
  'consumption_span': 'week', # 'week' = last n days or 'weekday' = last n weekdays
2681
2683
  'use_today': 21.0, # hour when todays consumption and generation can be used
2682
- 'min_hours': 0.25, # minimum charge time in decimal hours
2684
+ 'min_hours': 0.5, # minimum charge time in decimal hours
2683
2685
  'min_kwh': 0.5, # minimum to add in kwh
2684
2686
  'forecast_selection': 1, # 0 = use available forecast / generation, 1 only update settings with forecast
2685
2687
  'annual_consumption': None, # optional annual consumption in kWh
@@ -2776,7 +2778,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2776
2778
  for t in times:
2777
2779
  if hour_in(hour_now, t) and update_settings > 0:
2778
2780
  update_settings = 0
2779
- output(f"\nSettings will not be updated during a charge period")
2781
+ output(f"\nSettings will not be updated during a charge period {format_period(t)}")
2780
2782
  time_to_start = round_time(t['start'] - base_hour) * steps_per_hour
2781
2783
  time_to_start += hour_adjustment * steps_per_hour if time_to_start > time_change else 0
2782
2784
  charge_time = round_time(t['end'] - t['start'])
@@ -3058,18 +3060,16 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3058
3060
  start_residual = interpolate(time_to_start, bat_timed) # residual when charge time starts
3059
3061
  start_soc = int(start_residual / capacity * 100 + 0.5)
3060
3062
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
3061
- target_soc = charge_config['target_soc'] if charge_config.get('target_soc') is not None else None
3062
- target_kwh = target_soc / 100 * capacity if target_soc is not None else 0
3063
+ target_soc = charge_config.get('target_soc')
3064
+ target_kwh = capacity if full_charge is not None or force_charge == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
3063
3065
  if target_kwh > (end_residual + kwh_needed):
3064
3066
  kwh_needed = target_kwh - end_residual
3065
- elif full_charge is not None or force_charge == 2:
3066
- kwh_needed = capacity - start_residual
3067
3067
  elif test_charge is not None:
3068
3068
  output(f"\nTest charge of {test_charge}kWh")
3069
3069
  kwh_needed = test_charge
3070
3070
  charge_message = "** test charge **"
3071
3071
  # work out charge needed
3072
- if kwh_min > (reserve + kwh_contingency) and kwh_needed < charge_config['min_kwh']:
3072
+ if kwh_min > reserve and kwh_needed < charge_config['min_kwh'] and test_charge is None:
3073
3073
  output(f"\nNo charging needed:")
3074
3074
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
3075
3075
  charge_message = "no charge needed"
@@ -3087,24 +3087,30 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3087
3087
  output(f"\nCharge needed {kwh_needed:.2f}kWh:")
3088
3088
  charge_message = "with charge added"
3089
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)")
3091
3090
  # work out time to add kwh_needed to battery
3092
- taper_time = 10/60 if (start_residual + kwh_needed) >= (capacity * 0.95) else 0
3093
- hours = round_time(kwh_needed / (charge_power * charge_loss) + taper_time)
3094
- # charge time exceeded or charge needed exceeds capacity
3095
- if hours > charge_time or (start_residual + kwh_needed) > capacity:
3096
- kwh_needed = capacity - start_residual
3091
+ charge_rate = charge_power * charge_loss
3092
+ hours = kwh_needed / charge_rate
3093
+ # check if charge time exceeded or charge needed exceeds capacity
3094
+ hours_to_full = (capacity - start_residual) / charge_rate
3095
+ if hours > charge_time:
3097
3096
  hours = charge_time
3098
- elif hours < charge_config['min_hours']:
3099
- hours = charge_config['min_hours']
3100
- end_soc = min([int((start_residual + kwh_needed) / capacity * 100 + 0.5), 100])
3097
+ elif hours > hours_to_full:
3098
+ kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
3099
+ required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # time to recover energy not added
3100
+ hours = required if required > hours and required < charge_time else charge_time
3101
+ # round charge time
3102
+ min_hours = charge_config['min_hours']
3103
+ hours = int(hours / min_hours + 0.99) * min_hours
3101
3104
  # rework charge and discharge
3102
3105
  charge_period = get_best_charge_period(start_at, hours)
3103
3106
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
3104
3107
  price = charge_period.get('price') if charge_period is not None else None
3105
3108
  start_timed = time_to_start + charge_offset * steps_per_hour
3106
3109
  end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
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 ""))
3110
+ start_residual = interpolate(start_timed, bat_timed)
3111
+ end_soc = min([int((start_residual + kwh_needed) / capacity * 100 + 0.5), 100])
3112
+ output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
3113
+ output(f" Charge to: {end_soc:.0f}% {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
3114
  for i in range(int(time_to_start), int(end_timed) + 1):
3109
3115
  j = i + 1
3110
3116
  # work out time (fraction of hour) when charging in hour from i to j
@@ -3136,11 +3142,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3136
3142
  if show_data > 0:
3137
3143
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3138
3144
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3139
- h = base_hour + 1
3140
- t = steps_per_hour
3145
+ h = base_hour
3146
+ t = 0
3141
3147
  while t < len(time_line) and bat_timed[t] is not None:
3142
3148
  col = h % data_wrap
3143
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == steps_per_hour or col == 0 else ""
3149
+ s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3144
3150
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3145
3151
  h += 1
3146
3152
  t += steps_per_hour
@@ -3148,8 +3154,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3148
3154
  if show_plot > 0:
3149
3155
  print()
3150
3156
  plt.figure(figsize=(figure_width, figure_width/2))
3151
- x_timed = [i for i in range(steps_per_hour, run_time)]
3152
- x_ticks = [i for i in range(steps_per_hour, run_time, steps_per_hour)]
3157
+ x_timed = [i for i in range(0, run_time)]
3158
+ x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3153
3159
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3154
3160
  if show_plot == 1:
3155
3161
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3196,7 +3202,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3196
3202
  end1 = start1 if force_charge == 0 else start2
3197
3203
  end2 = round_time(base_hour + (end_timed if force_charge == 0 else time_to_end) / steps_per_hour)
3198
3204
  if timed_mode > 1:
3199
- periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, target_soc=end_soc, start_soc=start_soc)
3205
+ periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, end_soc=end_soc, start_soc=start_soc)
3200
3206
  set_schedule(periods = periods)
3201
3207
  else:
3202
3208
  set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1)
@@ -3273,11 +3279,11 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3273
3279
  if show_data > 0 and plots.get('SoC') is not None:
3274
3280
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3275
3281
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3276
- h = base_hour + 1
3277
- t = steps_per_hour
3282
+ h = base_hour
3283
+ t = 0
3278
3284
  while t < len(time_line) and bat_timed[t] is not None:
3279
3285
  col = h % data_wrap
3280
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == steps_per_hour or col == 0 else ""
3286
+ s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3281
3287
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3282
3288
  h += 1
3283
3289
  t += steps_per_hour
@@ -3285,8 +3291,8 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3285
3291
  if show_plot > 0:
3286
3292
  print()
3287
3293
  plt.figure(figsize=(figure_width, figure_width/2))
3288
- x_timed = [i for i in range(steps_per_hour, run_time)]
3289
- x_ticks = [i for i in range(steps_per_hour, run_time, steps_per_hour)]
3294
+ x_timed = [i for i in range(0, run_time)]
3295
+ x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3290
3296
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3291
3297
  if show_plot == 1:
3292
3298
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
foxesscloud/openapi.py CHANGED
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 25 September 2024
4
+ Updated: 27 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.4"
13
+ version = "2.5.6"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -669,7 +669,7 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
669
669
  output(f"success", 2)
670
670
  return battery_settings
671
671
 
672
- def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, target_soc=100, start_soc=10):
672
+ def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, end_soc=100, start_soc=10):
673
673
  output(f"\nConfiguring schedule",1)
674
674
  charge = []
675
675
  st1 = time_hours(st1)
@@ -678,7 +678,7 @@ def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, target_so
678
678
  en2 = time_hours(en2)
679
679
  span = None
680
680
  if st2 is not None and en2 is not None and st2 != en2:
681
- charge.append({'start': st2, 'end': en2, 'mode': 'ForceCharge', 'min_soc': min_soc, 'max_soc': target_soc})
681
+ charge.append({'start': st2, 'end': en2, 'mode': 'ForceCharge', 'min_soc': min_soc, 'max_soc': end_soc})
682
682
  span = {'start': st2, 'end': en2}
683
683
  if st1 is not None and en1 is not None and st1 != en1:
684
684
  charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': start_soc})
@@ -2085,6 +2085,7 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
2085
2085
  if use.get('agile') is not None and use['agile'].get('strategy') is not None:
2086
2086
  base_time_adjust = hours_difference(base_time, use['agile'].get('base_time') )
2087
2087
  for s in use['agile']['strategy']:
2088
+ s['valid_for'] = [int((s['hour'] - base_time_adjust) * steps_per_hour + i) for i in range(0, steps_per_hour // 2)] if s.get('hour') is not None else None
2088
2089
  strategy.append(s)
2089
2090
  if strategy is None or len(strategy) == 0:
2090
2091
  return []
@@ -2094,7 +2095,7 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
2094
2095
  start = s['start']
2095
2096
  end = s['end']
2096
2097
  if hour_overlap(s, remove):
2097
- output(f" {hours_time(start)}-{hours_time(end)} ** removed ** (overlaps charge period)", 2)
2098
+ output(f" {hours_time(start)}-{hours_time(end)} was removed from strategy", 2)
2098
2099
  continue
2099
2100
  # add segment
2100
2101
  min_soc_now = s['min_soc'] if s.get('min_soc') is not None and s['min_soc'] > 10 else 10
@@ -2103,9 +2104,9 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
2103
2104
  fdsoc = s.get('fdsoc')
2104
2105
  fdpwr = s.get('fdpwr')
2105
2106
  price = s.get('price')
2106
- expires = int((s['valid_to'] - base_time_adjust) * steps_per_hour) if s.get('valid_to') is not None else None
2107
+ valid_for = s.get('valid_for')
2107
2108
  segment = {'start': start, 'end': end, 'mode': mode, 'min_soc': min_soc_now, 'max_soc': max_soc,
2108
- 'fdsoc': fdsoc, 'fdpwr': fdpwr, 'price': price, 'expires': expires}
2109
+ 'fdsoc': fdsoc, 'fdpwr': fdpwr, 'price': price, 'valid_for': valid_for}
2109
2110
  if quiet == 0:
2110
2111
  s = f" {hours_time(start)}-{hours_time(end)} {mode}, min_soc {min_soc_now}%"
2111
2112
  s += f", max_soc {max_soc}%" if max_soc is not None else ""
@@ -2133,8 +2134,8 @@ tariff_config = {
2133
2134
  'region': "H", # region code to use for Octopus API
2134
2135
  'update_time': 16.5, # time in hours when tomrow's data can be fetched
2135
2136
  'weighting': None, # weights for weighted average
2136
- 'plunge_price': [1, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2137
- 'plunge_slots': 6, # number of 30 minute slots to use
2137
+ 'plunge_price': [3, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2138
+ 'plunge_slots': 8, # number of 30 minute slots to use
2138
2139
  'data_wrap': 6, # prices to show per line
2139
2140
  'show_data': 1, # show pricing data
2140
2141
  'show_plot': 1 # plot pricing data
@@ -2181,21 +2182,22 @@ def get_agile_times(tariff=agile_octopus, d=None):
2181
2182
  # extract times and prices. Times are Zulu (UTC)
2182
2183
  prices = [] # ordered list of 30 minute prices
2183
2184
  for i in range(0, len(results)):
2184
- start = (now.hour + i / 2) % 24
2185
+ hour = i / 2
2186
+ start = (now.hour + hour) % 24
2185
2187
  time_offset = daylight_saving(results[i]['valid_from'][:16]) if daylight_saving is not None else 0
2186
2188
  prices.append({
2187
2189
  'start': start,
2188
2190
  'end': round_time(start + 0.5),
2189
2191
  'time': hours_time(time_hours(results[i]['valid_from'][11:16]) + time_offset + time_shift),
2190
2192
  'price': results[i]['value_inc_vat'],
2191
- 'valid_to': i / 2 + 0.5})
2193
+ 'hour': hour})
2192
2194
  tariff['agile']['base_time'] = period_from.replace('T', ' ')
2193
2195
  tariff['agile']['prices'] = prices
2194
2196
  plunge = []
2195
2197
  plunge_price = tariff_config['plunge_price'] if tariff_config.get('plunge_price') is not None else 2
2196
2198
  plunge_price = [plunge_price] if type(plunge_price) is not list else plunge_price
2197
2199
  plunge_slots = tariff_config['plunge_slots'] if tariff_config.get('plunge_slots') is not None else 6
2198
- for i in range(0, min([48, len(prices)])):
2200
+ for i in range(0, len(prices)):
2199
2201
  # hour relative index into list of plunge prices, starting at 7am
2200
2202
  x = int(((now.hour - 7 + i / 2) % 24) * len(plunge_price) / 24)
2201
2203
  if prices[i] is not None and prices[i]['price'] < plunge_price[x]:
@@ -2455,7 +2457,7 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2455
2457
  if strategy is not None:
2456
2458
  period['mode'] = 'SelfUse'
2457
2459
  for d in strategy:
2458
- if hour_in(h, d) and (d.get('expires') is None or i < d['expires']):
2460
+ if hour_in(h, d) and (d.get('valid_for') is None or i in d['valid_for']):
2459
2461
  mode = d['mode']
2460
2462
  period['mode'] = mode
2461
2463
  min_soc_now = d['min_soc'] if d.get('min_soc') is not None else min_soc
@@ -2543,7 +2545,7 @@ charge_config = {
2543
2545
  'consumption_days': 3, # number of days to use for average consumption (1-7)
2544
2546
  'consumption_span': 'week', # 'week' = last n days or 'weekday' = last n weekdays
2545
2547
  'use_today': 21.0, # hour when todays consumption and generation can be used
2546
- 'min_hours': 0.25, # minimum charge time in decimal hours
2548
+ 'min_hours': 0.5, # minimum charge time in decimal hours
2547
2549
  'min_kwh': 0.5, # minimum to add in kwh
2548
2550
  'forecast_selection': 1, # 0 = use available forecast / generation, 1 only update settings with forecast
2549
2551
  'annual_consumption': None, # optional annual consumption in kWh
@@ -2640,7 +2642,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2640
2642
  for t in times:
2641
2643
  if hour_in(hour_now, t) and update_settings > 0:
2642
2644
  update_settings = 0
2643
- output(f"\nSettings will not be updated during a charge period")
2645
+ output(f"\nSettings will not be updated during a charge period {format_period(t)}")
2644
2646
  time_to_start = round_time(t['start'] - base_hour) * steps_per_hour
2645
2647
  time_to_start += hour_adjustment * steps_per_hour if time_to_start > time_change else 0
2646
2648
  charge_time = round_time(t['end'] - t['start'])
@@ -2922,18 +2924,16 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2922
2924
  start_residual = interpolate(time_to_start, bat_timed) # residual when charge time starts
2923
2925
  start_soc = int(start_residual / capacity * 100 + 0.5)
2924
2926
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
2925
- target_soc = charge_config['target_soc'] if charge_config.get('target_soc') is not None else None
2926
- target_kwh = target_soc / 100 * capacity if target_soc is not None else 0
2927
+ target_soc = charge_config.get('target_soc')
2928
+ target_kwh = capacity if full_charge is not None or force_charge == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
2927
2929
  if target_kwh > (end_residual + kwh_needed):
2928
2930
  kwh_needed = target_kwh - end_residual
2929
- elif full_charge is not None or force_charge == 2:
2930
- kwh_needed = capacity - start_residual
2931
2931
  elif test_charge is not None:
2932
2932
  output(f"\nTest charge of {test_charge}kWh")
2933
2933
  kwh_needed = test_charge
2934
2934
  charge_message = "** test charge **"
2935
2935
  # work out charge needed
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:
2936
+ if kwh_min > reserve and kwh_needed < charge_config['min_kwh'] and test_charge is None:
2937
2937
  output(f"\nNo charging needed:")
2938
2938
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
2939
2939
  charge_message = "no charge needed"
@@ -2951,24 +2951,30 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2951
2951
  output(f"\nCharge needed {kwh_needed:.2f}kWh:")
2952
2952
  charge_message = "with charge added"
2953
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)")
2955
2954
  # work out time to add kwh_needed to battery
2956
- taper_time = 10/60 if (start_residual + kwh_needed) >= (capacity * 0.95) else 0
2957
- hours = round_time(kwh_needed / (charge_power * charge_loss) + taper_time)
2958
- # charge time exceeded or charge needed exceeds capacity
2959
- if hours > charge_time or (start_residual + kwh_needed) > (capacity * 1.01):
2960
- kwh_needed = capacity - start_residual
2955
+ charge_rate = charge_power * charge_loss
2956
+ hours = kwh_needed / charge_rate
2957
+ # check if charge time exceeded or charge needed exceeds capacity
2958
+ hours_to_full = (capacity - start_residual) / charge_rate
2959
+ if hours > charge_time:
2961
2960
  hours = charge_time
2962
- elif hours < charge_config['min_hours']:
2963
- hours = charge_config['min_hours']
2964
- end_soc = min([int((start_residual + kwh_needed) / capacity * 100 + 0.5), 100])
2961
+ elif hours > hours_to_full:
2962
+ kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
2963
+ required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # hold time to recover energy not added
2964
+ hours = required if required > hours and required < charge_time else charge_time
2965
+ # round charge time
2966
+ min_hours = charge_config['min_hours']
2967
+ hours = int(hours / min_hours + 0.99) * min_hours
2965
2968
  # rework charge and discharge
2966
2969
  charge_period = get_best_charge_period(start_at, hours)
2967
2970
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
2968
2971
  price = charge_period.get('price') if charge_period is not None else None
2969
2972
  start_timed = time_to_start + charge_offset * steps_per_hour
2970
2973
  end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
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 ""))
2974
+ start_residual = interpolate(start_timed, bat_timed)
2975
+ end_soc = min([int((start_residual + kwh_needed) / capacity * 100 + 0.5), 100])
2976
+ output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
2977
+ output(f" Charge to: {end_soc:.0f}% {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
2978
  for i in range(int(time_to_start), int(end_timed) + 1):
2973
2979
  j = i + 1
2974
2980
  # work out time (fraction of hour) when charging in hour from i to j
@@ -3000,11 +3006,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3000
3006
  if show_data > 0:
3001
3007
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3002
3008
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3003
- h = base_hour + 1
3004
- t = steps_per_hour
3009
+ h = base_hour
3010
+ t = 0
3005
3011
  while t < len(time_line) and bat_timed[t] is not None:
3006
3012
  col = h % data_wrap
3007
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == steps_per_hour or col == 0 else ""
3013
+ s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3008
3014
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3009
3015
  h += 1
3010
3016
  t += steps_per_hour
@@ -3012,8 +3018,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3012
3018
  if show_plot > 0:
3013
3019
  print()
3014
3020
  plt.figure(figsize=(figure_width, figure_width/2))
3015
- x_timed = [i for i in range(steps_per_hour, run_time)]
3016
- x_ticks = [i for i in range(steps_per_hour, run_time, steps_per_hour)]
3021
+ x_timed = [i for i in range(0, run_time)]
3022
+ x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3017
3023
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3018
3024
  if show_plot == 1:
3019
3025
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3060,7 +3066,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3060
3066
  end1 = start1 if force_charge == 0 else start2
3061
3067
  end2 = round_time(base_hour + (end_timed if force_charge == 0 else time_to_end) / steps_per_hour)
3062
3068
  if timed_mode > 1:
3063
- periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, target_soc=end_soc, start_soc=start_soc)
3069
+ periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, end_soc=end_soc, start_soc=start_soc)
3064
3070
  set_schedule(periods = periods)
3065
3071
  else:
3066
3072
  set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1)
@@ -3136,11 +3142,11 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3136
3142
  if show_data > 0 and plots.get('SoC') is not None:
3137
3143
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3138
3144
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3139
- h = base_hour + 1
3140
- t = steps_per_hour
3145
+ h = base_hour
3146
+ t = 0
3141
3147
  while t < len(time_line) and bat_timed[t] is not None:
3142
3148
  col = h % data_wrap
3143
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == steps_per_hour or col == 0 else ""
3149
+ s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3144
3150
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3145
3151
  h += 1
3146
3152
  t += steps_per_hour
@@ -3148,8 +3154,8 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3148
3154
  if show_plot > 0:
3149
3155
  print()
3150
3156
  plt.figure(figsize=(figure_width, figure_width/2))
3151
- x_timed = [i for i in range(steps_per_hour, run_time)]
3152
- x_ticks = [i for i in range(steps_per_hour, run_time, steps_per_hour)]
3157
+ x_timed = [i for i in range(0, run_time)]
3158
+ x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3153
3159
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3154
3160
  if show_plot == 1:
3155
3161
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.5.4
3
+ Version: 2.5.6
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
@@ -783,6 +783,16 @@ This setting can be:
783
783
 
784
784
  # Version Info
785
785
 
786
+ 2.5.6<br>
787
+ Change plunge slots to 8 and plungs pricing to [3,10].
788
+ Change min_hours setting in charge_needed to 0.5 (30 minutes) and round up charge times to increments of this.
789
+ Show data and plot starting at t=0.
790
+
791
+ 2.5.5<br>
792
+ Improve validation of plunge price periods so they don't repeat across days.
793
+ Correct start and end soc times and values when charging using best Agile time periods.
794
+ Extend charge times when charge needed exceeds battery capacity.
795
+
786
796
  2.5.4<br>
787
797
  Remove preset 'weighting' that were not used.
788
798
  Update weighting to apply the requested charge duration correctly.
@@ -0,0 +1,7 @@
1
+ foxesscloud/foxesscloud.py,sha256=pvRR5Wjbb0EIfXT59wtG5kGgGANxV-USjOZoPDzwaL4,210980
2
+ foxesscloud/openapi.py,sha256=vj0hP-8IsD30IK5ylCqp9BahvhtgYOIbkgrccbjcaLE,204620
3
+ foxesscloud-2.5.6.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
+ foxesscloud-2.5.6.dist-info/METADATA,sha256=o3Vxj9OGsJayuifK48DkVlv2W2l4Oht8-KIuzrIB6DU,55678
5
+ foxesscloud-2.5.6.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
+ foxesscloud-2.5.6.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
+ foxesscloud-2.5.6.dist-info/RECORD,,
@@ -1,7 +0,0 @@
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,,