foxesscloud 2.5.1__tar.gz → 2.5.2__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.5.1
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
 
@@ -565,7 +565,7 @@ The best charging period is determined based on the weighted average of the 30 m
565
565
 
566
566
  set_tariff() can configure multiple off-peak and peak periods for any tariff using the 'times' parameter. Times is a list of tuples:
567
567
  + containing values for key, 'start', 'end' and optional 'force'.
568
- + 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'
569
569
  + a tuple containing a key with no values will remove the time period from the tariff.
570
570
 
571
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':
@@ -782,6 +782,12 @@ This setting can be:
782
782
 
783
783
  # Version Info
784
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
+
785
791
  2.5.1<br>
786
792
  Fix anomaly in scheduler support when get_device and get_flag return different results.
787
793
  Add 'show_data' to charge_compare() and display run time and starting SoC.
@@ -225,7 +225,7 @@ The summary includes the following attributes:
225
225
 
226
226
  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:
227
227
  + kwh: the total energy generated or consumed
228
- + kwh_off: the total energy consumed or generated during the off-peak time of use (off_peak1, off_peak2, off_peak3)
228
+ + kwh_off: the total energy consumed or generated during the off-peak time of use (off_peak1, off_peak2, off_peak3, off_peak4)
229
229
  + kwh_peak: the total energy consumed or generated during the peak time of use (peak1, peak2)
230
230
  + kwh_neg: the total energy from -ve power flow (all other totals are based on +ve power flow)
231
231
 
@@ -551,7 +551,7 @@ The best charging period is determined based on the weighted average of the 30 m
551
551
 
552
552
  set_tariff() can configure multiple off-peak and peak periods for any tariff using the 'times' parameter. Times is a list of tuples:
553
553
  + containing values for key, 'start', 'end' and optional 'force'.
554
- + recongnised keys are: 'off_peak1', 'off_peak2', 'off_peak3', 'peak1', 'peak2'
554
+ + recongnised keys are: 'off_peak1', 'off_peak2', 'off_peak3', 'off_peak4', 'peak1', 'peak2'
555
555
  + a tuple containing a key with no values will remove the time period from the tariff.
556
556
 
557
557
  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':
@@ -768,6 +768,12 @@ This setting can be:
768
768
 
769
769
  # Version Info
770
770
 
771
+ 2.5.2<br>
772
+ Updates to allow charge_needed() to run during a charge period.
773
+ Add suport for 'off_peak4' charge period.
774
+ Change Solcast forecast in charge_needed() so it does not get todays estimate to save API calls.
775
+ Include contingency and reserve when checking minimum battery level.
776
+
771
777
  2.5.1<br>
772
778
  Fix anomaly in scheduler support when get_device and get_flag return different results.
773
779
  Add 'show_data' to charge_compare() and display run time and starting SoC.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "foxesscloud"
7
- version = "2.5.1"
7
+ version = "2.5.2"
8
8
  authors = [
9
9
  {name="Tony Matthews", email="tony@quasair.co.uk"},
10
10
  ]
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud
4
- Updated: 17 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.3"
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',
@@ -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 ({today} {hours_time(hour_now)} {current_soc}%)\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 ({today} {hours_time(hour_now)} {current_soc}%)\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)
3149
+ s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
3146
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))
@@ -3242,7 +3246,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3242
3246
  run_time = len(time_line)
3243
3247
  base_hour = int(time_hours(base_time[11:16]))
3244
3248
  start_day = base_time[:10]
3245
- print(f"Run at {start_day} {hours_time(hour_now)} with SoC = {current_soc}%")
3249
+ print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:3.0f}%")
3246
3250
  now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
3247
3251
  end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
3248
3252
  if v is None:
@@ -3277,17 +3281,17 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3277
3281
  plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
3278
3282
  if show_data > 0 and plots.get('SoC') is not None:
3279
3283
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3280
- s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC %:\n"
3284
+ s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC:\n"
3281
3285
  h = base_hour + 1
3282
3286
  t = steps_per_hour
3283
- s += " " * (13 if show_data == 2 else 12) * (h % data_wrap)
3287
+ s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
3284
3288
  while t < len(time_line) and plots['SoC'][t] is not None:
3285
3289
  s += "\n" if t > steps_per_hour and h % data_wrap == 0 else ""
3286
3290
  s += f" {hours_time(time_line[t])}"
3287
- s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3291
+ s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%,"
3288
3292
  h += 1
3289
3293
  t += steps_per_hour
3290
- print(s)
3294
+ print(s[:-1])
3291
3295
  if show_plot > 0:
3292
3296
  print()
3293
3297
  plt.figure(figsize=(figure_width, figure_width/2))
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 17 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.1"
13
+ version = "2.5.2"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -786,13 +786,15 @@ merge_settings = { # keys to add
786
786
  'WorkMode': {'keys': {
787
787
  'h115__': 'operation_mode__work_mode',
788
788
  'h116__': 'operation_mode__work_mode',
789
- 'h117__': 'operation_mode__work_mode'
789
+ 'h117__': 'operation_mode__work_mode',
790
+ # 'k106__': 'operation_mode__work_mode',
790
791
  },
791
792
  'values': ['SelfUse', 'Feedin', 'Backup']},
792
793
  'BatteryVolt': {'keys': {
793
794
  'h115__': ['h115__14', 'h115__15', 'h115__16'],
794
795
  'h116__': ['h116__15', 'h116__16', 'h116__17'],
795
- 'h117__': ['h117__15', 'h117__16', 'h117__17']
796
+ 'h117__': ['h117__15', 'h117__16', 'h117__17'],
797
+ # 'k106__': ['k106__xx', 'k106__xx', 'k106__xx'],
796
798
  },
797
799
  'type': 'list',
798
800
  'valueType': 'float',
@@ -801,11 +803,11 @@ merge_settings = { # keys to add
801
803
  'h115__': 'h115__17',
802
804
  'h116__': 'h116__18',
803
805
  'h117__': 'h117__18',
806
+ # 'k106__': 'k106__xx',
804
807
  },
805
808
  'type': 'list',
806
809
  'valueType': 'int',
807
810
  'unit': '℃'},
808
-
809
811
  }
810
812
 
811
813
  def get_ui():
@@ -1391,7 +1393,7 @@ def get_history(time_span='hour', d=None, v=None, summary=1, save=None, load=Non
1391
1393
  if e > 0.0:
1392
1394
  kwh += e
1393
1395
  if tariff is not None:
1394
- 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')]):
1395
1397
  kwh_off += e
1396
1398
  elif hour_in(h, [tariff.get('peak1'), tariff.get('peak2')]):
1397
1399
  kwh_peak += e
@@ -2209,56 +2211,36 @@ def get_agile_times(tariff=agile_octopus, d=None):
2209
2211
  strategy.append(prices[t])
2210
2212
  output(f" {format_period(prices[t])} at {prices[t]['price']:.1f}p", 1)
2211
2213
  tariff['agile']['strategy'] = strategy
2212
- for key in ['off_peak1', 'off_peak2', 'off_peak3']:
2214
+ for key in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
2213
2215
  if tariff.get(key) is None:
2214
2216
  continue
2215
2217
  if tariff['agile'].get(key) is None:
2216
2218
  tariff['agile'][key] = {}
2217
2219
  # get price index for AM/PM charge times
2218
- slots = []
2219
- for i in range(0, len(prices)):
2220
- if hour_in(time_hours(prices[i]['start']), tariff[key]):
2221
- slots.append(i)
2220
+ slots = [i for i in range(0, len(prices)) if hour_in(time_hours(prices[i]['start']), tariff[key])]
2222
2221
  tariff['agile'][key]['slots'] = slots
2223
- # best charge time for [0.5, 1, 1.5, 2 etc] hours
2224
- weighting = tariff_config.get('weighting')
2225
- tariff['agile'][key]['times'] = []
2226
- for j in range (0, len(slots)):
2227
- span = j + 1
2228
- weights = (([1.0] * (span-1) if weighting is None else weighting) + [0.5] * span)[:span]
2229
- best = None
2230
- price = None
2231
- for i in range(0, len(slots) - j):
2232
- t = slots[i: i + span]
2233
- p_span = [prices[x]['price'] for x in t]
2234
- wavg = round(sum(p * w for p,w in zip(p_span, weights)) / sum(weights), 2)
2235
- if price is None or wavg < price:
2236
- price = wavg
2237
- best = t
2238
- # save best time slot for charge duration
2239
- start = prices[best[0]]['start']
2240
- 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])
2241
2223
  # show the results
2242
2224
  if tariff_config['show_data'] > 0:
2243
2225
  data_wrap = tariff_config['data_wrap'] if tariff_config.get('data_wrap') is not None else 6
2244
2226
  t = (now.hour * 2) % data_wrap
2245
- 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
2246
2228
  for i in range(0, len(prices)):
2247
2229
  s += "\n" if i > 0 and t % data_wrap == 0 else ""
2248
- s += f" {prices[i]['time']} {prices[i]['price']:4.1f}"
2230
+ s += f" {prices[i]['time']} {prices[i]['price']:4.1f},"
2249
2231
  t += 1
2250
- output(s)
2232
+ output(s[:-1])
2251
2233
  if tariff_config['show_plot'] > 0:
2252
2234
  plt.figure(figsize=(figure_width, figure_width/2))
2253
2235
  x_timed = [i for i in range(0, len(prices))]
2254
2236
  plt.xticks(ticks=x_timed, labels=[prices[x]['time'] for x in x_timed], rotation=90, fontsize=8, ha='center')
2255
2237
  plt.plot(x_timed, [prices[x]['price'] for x in x_timed], label='30 minute price', color='blue')
2256
2238
  s = ""
2257
- for key in ['off_peak1', 'off_peak2']:
2258
- if tariff['agile'].get(key) is not None and len(tariff['agile'][key]['times']) > 0:
2259
- p = tariff['agile'][key]['times'][-1]
2260
- 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")
2261
- 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"
2262
2244
  output(f"\nCharge times{s}" if s != "" else "", 1)
2263
2245
  plt.title(f"Pricing on {today} p/kWh inc VAT", fontsize=10)
2264
2246
  plt.legend(fontsize=8)
@@ -2269,13 +2251,40 @@ def get_agile_times(tariff=agile_octopus, d=None):
2269
2251
  # return the best charge time:
2270
2252
  def get_best_charge_period(start, duration):
2271
2253
  global tariff
2272
- if tariff is None:
2254
+ if tariff is None or tariff.get('agile') is None or tariff['agile'].get('prices') is None:
2255
+ return None
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:
2273
2265
  return None
2274
- key = [k for k in ['off_peak1', 'off_peak2', 'off_peak3'] if hour_in(start, tariff.get(k))][0]
2275
- if tariff.get('agile') is None or tariff['agile'].get(key) is None:
2276
- return tariff.get(key)
2277
- i = min([int(duration * 2), len(tariff['agile'][key]['times']) - 1])
2278
- return tariff['agile'][key]['times'][i]
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']
2279
2288
 
2280
2289
  # pushover app key for set_tariff()
2281
2290
  set_tariff_app_key = "apx24dswzinhrbeb62sdensvt42aqe"
@@ -2319,7 +2328,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2319
2328
  times = [times]
2320
2329
  output(f"\n{use['name']}:")
2321
2330
  for t in times:
2322
- 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']:
2323
2332
  output(f"** set_tariff(): invalid time period {t}")
2324
2333
  continue
2325
2334
  key = t[0]
@@ -2358,7 +2367,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2358
2367
  elif type(strategy) is not list:
2359
2368
  strategy = [strategy]
2360
2369
  output(f"\nStrategy")
2361
- 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')])
2362
2371
  output_close(plot=tariff_config['show_plot'])
2363
2372
  if update == 1:
2364
2373
  tariff = use
@@ -2553,7 +2562,7 @@ charge_config = {
2553
2562
  'solcast': {'adjust': 0.95, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2},
2554
2563
  'solar': {'adjust': 1.20, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2}
2555
2564
  },
2556
- 'save': 'charge_needed.txt' # save calculation data for analysis
2565
+ 'save': 'charge_needed ###.txt' # save calculation data for analysis
2557
2566
  }
2558
2567
 
2559
2568
  # app key for charge_needed (used to send output via pushover)
@@ -2568,7 +2577,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2568
2577
  # forecast_times: list of hours when forecast can be fetched (UTC)
2569
2578
  # force_charge: 1 = set force charge, 2 = charge for whole period
2570
2579
 
2571
- 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,
2572
2581
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2573
2582
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2574
2583
  global timed_strategy, steps_per_hour, base_time
@@ -2604,6 +2613,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2604
2613
  now = system_time + timedelta(hours=time_offset)
2605
2614
  today = datetime.strftime(now, '%Y-%m-%d')
2606
2615
  base_hour = now.hour
2616
+ base_time = today + f" {hours_time(base_hour)}"
2607
2617
  hour_now = now.hour + now.minute / 60
2608
2618
  output(f" datetime = {today} {hours_time(hour_now)}", 2)
2609
2619
  yesterday = datetime.strftime(now - timedelta(days=1), '%Y-%m-%d')
@@ -2619,17 +2629,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2619
2629
  time_change = (change_hour - base_hour) * steps_per_hour
2620
2630
  # get charge times
2621
2631
  times = []
2622
- for k in ['off_peak1', 'off_peak2', 'off_peak3']:
2632
+ for k in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
2623
2633
  if tariff is not None and tariff.get(k) is not None:
2624
- start = time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0)
2625
- 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))
2626
2636
  force = 0 if tariff[k].get('force') is not None and tariff[k]['force'] == 0 else force_charge
2627
2637
  times.append({'key': k, 'start': start, 'end': end, 'force': force})
2628
2638
  if len(times) == 0:
2629
- times.append({'key': 'off_peak1', 'start': round_time(base_hour + 2), 'end': round_time(base_hour + 5), 'force': force_charge})
2630
- 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)}")
2631
2641
  time_to_end1 = None
2642
+ start_now = (int(hour_now * 2 + 1) / 2) % 24
2632
2643
  for t in times:
2644
+ if hour_in(start_now, t):
2645
+ t['start'] = start_now
2633
2646
  time_to_start = round_time(t['start'] - base_hour) * steps_per_hour
2634
2647
  time_to_start += hour_adjustment * steps_per_hour if time_to_start > time_change else 0
2635
2648
  charge_time = round_time(t['end'] - t['start'])
@@ -2646,9 +2659,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2646
2659
  time_to_start = times[0]['time_to_start']
2647
2660
  time_to_end = times[0]['time_to_end']
2648
2661
  charge_time = times[0]['charge_time']
2649
- if hour_in(hour_now, {'start': round_time(start_at - 0.25), 'end': round_time(end_by + 0.25)}) and update_settings > 0:
2650
- print(f"\nInverter settings will not be changed less than 15 minutes before or after the next charging period")
2651
- update_settings = 0
2652
2662
  # work out time window and times with clock changes
2653
2663
  time_to_next = int(time_to_start)
2654
2664
  charge_today = (base_hour + time_to_start / steps_per_hour) < 24
@@ -2802,8 +2812,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2802
2812
  output(f"\nConsumption (kWh):")
2803
2813
  s = ""
2804
2814
  for h in history:
2805
- s += f" {h['date']}: {h['total']:4.1f}"
2806
- output(s)
2815
+ s += f" {h['date']}: {h['total']:4.1f},"
2816
+ output(' ' + s[:-1])
2807
2817
  output(f" Average of last {consumption_days} {day_tomorrow if consumption_span=='weekday' else 'day'}s: {consumption:.1f}kWh")
2808
2818
  # time line buckets of consumption
2809
2819
  daily_sum = sum(consumption_by_hour)
@@ -2812,14 +2822,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2812
2822
  solcast_value = None
2813
2823
  solcast_profile = None
2814
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):
2815
- 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'))
2816
2826
  if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
2817
2827
  solcast_value = fsolcast.daily[forecast_day]['kwh']
2818
2828
  solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
2819
- if charge_today:
2820
- output(f"\nSolcast forecast for {today} = {fsolcast.daily[today]['kwh']:.1f}, {tomorrow} = {fsolcast.daily[tomorrow]['kwh']:.1f}")
2821
- else:
2822
- 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}")
2823
2830
  # get forecast.solar data and produce time line
2824
2831
  solar_value = None
2825
2832
  solar_profile = None
@@ -2828,10 +2835,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2828
2835
  if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
2829
2836
  solar_value = fsolar.daily[forecast_day]['kwh']
2830
2837
  solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
2831
- if charge_today:
2832
- output(f"\nSolar forecast for {today} = {fsolar.daily[today]['kwh']:.1f}, {tomorrow} = {fsolar.daily[tomorrow]['kwh']:.1f}")
2833
- else:
2834
- 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}")
2835
2839
  if solcast_value is None and solar_value is None and debug_setting > 1:
2836
2840
  output(f"\nNo forecasts available at this time")
2837
2841
  # get generation data
@@ -2851,8 +2855,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2851
2855
  output(f"\nGeneration (kWh):")
2852
2856
  s = ""
2853
2857
  for d in sorted(pv_history.keys())[-gen_days:]:
2854
- s += f" {d}: {pv_history[d]:4.1f}"
2855
- output(s)
2858
+ s += f" {d}: {pv_history[d]:4.1f},"
2859
+ output(' ' + s[:-1])
2856
2860
  generation = pv_sum / gen_days
2857
2861
  output(f" Average of last {gen_days} days: {generation:.1f}kWh")
2858
2862
  # choose expected value and produce generation time line
@@ -2931,15 +2935,15 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2931
2935
  kwh_needed = test_charge
2932
2936
  charge_message = "** test charge **"
2933
2937
  # work out charge needed
2934
- 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:
2935
- output(f"\nNo charging needed ({today} {hours_time(hour_now)} {current_soc}%)\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}%)")
2936
2940
  charge_message = "no charge needed"
2937
2941
  kwh_needed = 0.0
2938
2942
  hours = 0.0
2939
2943
  start_timed = time_to_end
2940
2944
  end_timed = time_to_end
2941
2945
  end_soc = int(end_residual / capacity * 100 + 0.5)
2942
- 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))}")
2943
2947
  # rebuild the battery residual with min_soc for battery hold
2944
2948
  if force_charge > 0 and timed_mode > 1:
2945
2949
  for t in range(int(time_to_start), int(time_to_end)):
@@ -2947,7 +2951,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2947
2951
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
2948
2952
  else:
2949
2953
  if test_charge is None:
2950
- output(f"\nCharge needed {kwh_needed:.2f}kWh ({today} {hours_time(hour_now)} {current_soc}%)\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}%)")
2951
2955
  charge_message = "with charge added"
2952
2956
  # work out time to add kwh_needed to battery
2953
2957
  taper_time = 10/60 if (start_residual + kwh_needed) >= (capacity * 0.95) else 0
@@ -2965,7 +2969,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2965
2969
  price = charge_period.get('price') if charge_period is not None else None
2966
2970
  start_timed = time_to_start + charge_offset * steps_per_hour
2967
2971
  end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
2968
- 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 ""))
2969
2973
  for i in range(int(time_to_start), int(end_timed) + 1):
2970
2974
  j = i + 1
2971
2975
  # work out time (fraction of hour) when charging in hour from i to j
@@ -2990,8 +2994,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2990
2994
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
2991
2995
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
2992
2996
  # show the state
2993
- output(f" Start SoC {start_residual / capacity * 100:3.0f}% at {hours_time(adjusted_hour(time_to_start, time_line))} ({start_residual:.2f}kWh)")
2994
- 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)")
2995
2999
  # show what we have worked out
2996
3000
  if show_data == 3:
2997
3001
  output(f"\nTime, Generation, Charge, Consumption, Discharge, Residual, kWh")
@@ -3000,17 +3004,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3000
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}")
3001
3005
  if show_data > 0:
3002
3006
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3003
- 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"
3004
3008
  h = base_hour + 1
3005
3009
  t = steps_per_hour
3006
- s += " " * (13 if show_data == 2 else 12) * (h % data_wrap)
3010
+ s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
3007
3011
  while t < len(time_line) and bat_timed[t] is not None:
3008
3012
  s += "\n" if t > steps_per_hour and h % data_wrap == 0 else ""
3009
3013
  s += f" {hours_time(time_line[t])}"
3010
- 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}%,"
3011
3015
  h += 1
3012
3016
  t += steps_per_hour
3013
- output(s)
3017
+ output(s[:-1])
3014
3018
  if show_plot > 0:
3015
3019
  print()
3016
3020
  plt.figure(figsize=(figure_width, figure_width/2))
@@ -3102,7 +3106,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3102
3106
  run_time = len(time_line)
3103
3107
  base_hour = int(time_hours(base_time[11:16]))
3104
3108
  start_day = base_time[:10]
3105
- print(f"Run at {start_day} {hours_time(hour_now)} with SoC = {current_soc}%")
3109
+ print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:3.0f}%")
3106
3110
  now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
3107
3111
  end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
3108
3112
  if v is None:
@@ -3137,17 +3141,17 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3137
3141
  plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
3138
3142
  if show_data > 0 and plots.get('SoC') is not None:
3139
3143
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3140
- s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC %:\n"
3144
+ s = f"\nBattery Energy kWh:\n" if show_data == 2 else f"\nBattery SoC:\n"
3141
3145
  h = base_hour + 1
3142
3146
  t = steps_per_hour
3143
- s += " " * (13 if show_data == 2 else 12) * (h % data_wrap)
3147
+ s += " " * (14 if show_data == 2 else 13) * (h % data_wrap)
3144
3148
  while t < len(time_line) and plots['SoC'][t] is not None:
3145
3149
  s += "\n" if t > steps_per_hour and h % data_wrap == 0 else ""
3146
3150
  s += f" {hours_time(time_line[t])}"
3147
- s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3151
+ s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%,"
3148
3152
  h += 1
3149
3153
  t += steps_per_hour
3150
- print(s)
3154
+ print(s[:-1])
3151
3155
  if show_plot > 0:
3152
3156
  print()
3153
3157
  plt.figure(figsize=(figure_width, figure_width/2))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.5.1
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
 
@@ -565,7 +565,7 @@ The best charging period is determined based on the weighted average of the 30 m
565
565
 
566
566
  set_tariff() can configure multiple off-peak and peak periods for any tariff using the 'times' parameter. Times is a list of tuples:
567
567
  + containing values for key, 'start', 'end' and optional 'force'.
568
- + 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'
569
569
  + a tuple containing a key with no values will remove the time period from the tariff.
570
570
 
571
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':
@@ -782,6 +782,12 @@ This setting can be:
782
782
 
783
783
  # Version Info
784
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
+
785
791
  2.5.1<br>
786
792
  Fix anomaly in scheduler support when get_device and get_flag return different results.
787
793
  Add 'show_data' to charge_compare() and display run time and starting SoC.
File without changes
File without changes