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

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