foxesscloud 2.6.5__tar.gz → 2.6.6__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.6.5
3
+ Version: 2.6.6
4
4
  Summary: library for accessing Fox ESS cloud data using Open API
5
5
  Author-email: Tony Matthews <tony@quasair.co.uk>
6
6
  Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
@@ -797,6 +797,15 @@ This setting can be:
797
797
 
798
798
  # Version Info
799
799
 
800
+ 2.6.6<br>
801
+ Add residual_handling=3 for Mira BMS with firmware 1.014 or later that returns residual capacity per battery.
802
+ Calculate 'ratedCapacity' in get_battery() and 'soh' for HV2600 and Mira.
803
+ Allow unlimited periods in strategy, including overlap with charge periods but warn and limit if the periods sent to inverter would be more than 8.
804
+ Improve behaviour prediction for schedules when clocks change due to day light saving.
805
+ Improve schedule generation and prediction when Min Soc changes.
806
+ Cache Solcast RIDS to reduce API usage (run with reload=1 if arrays are edited and cached RIDs need to be updated).
807
+ Remove spurious error message when (failing) to get inverter work mode.
808
+
800
809
  2.6.5<br>
801
810
  Add get_named_settings() and set_named_settings().
802
811
  Update get_work_mode() and set_work_mode() to use named settings (still doesn't work though as blocked by Fox)
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: foxesscloud
3
- Version: 2.6.5
4
- Summary: library for accessing Fox ESS cloud data using Open API
5
- Author-email: Tony Matthews <tony@quasair.co.uk>
6
- Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
7
- Project-URL: Bug Tracker, https://github.com/TonyM1958/FoxESS-Cloud/issues
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.7
12
- Description-Content-Type: text/markdown
13
- License-File: LICENCE
14
-
15
1
  # FoxESS-Cloud
16
2
 
17
3
  <a href="https://www.buymeacoffee.com/tonym1958" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174" align="right"></a>
@@ -797,6 +783,15 @@ This setting can be:
797
783
 
798
784
  # Version Info
799
785
 
786
+ 2.6.6<br>
787
+ Add residual_handling=3 for Mira BMS with firmware 1.014 or later that returns residual capacity per battery.
788
+ Calculate 'ratedCapacity' in get_battery() and 'soh' for HV2600 and Mira.
789
+ Allow unlimited periods in strategy, including overlap with charge periods but warn and limit if the periods sent to inverter would be more than 8.
790
+ Improve behaviour prediction for schedules when clocks change due to day light saving.
791
+ Improve schedule generation and prediction when Min Soc changes.
792
+ Cache Solcast RIDS to reduce API usage (run with reload=1 if arrays are edited and cached RIDs need to be updated).
793
+ Remove spurious error message when (failing) to get inverter work mode.
794
+
800
795
  2.6.5<br>
801
796
  Add get_named_settings() and set_named_settings().
802
797
  Update get_work_mode() and set_work_mode() to use named settings (still doesn't work though as blocked by Fox)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "foxesscloud"
7
- version = "2.6.5"
7
+ version = "2.6.6"
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: 43 October 2024
4
+ Updated: 01 November 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.7.7"
13
+ version = "1.7.8"
14
14
  print(f"FoxESS-Cloud version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -583,7 +583,7 @@ battery = None
583
583
  batteries = None
584
584
  battery_settings = None
585
585
 
586
- # 1 = Residual Energy, 2 = Residual Capacity
586
+ # 1 = Residual Energy, 2 = Residual Capacity (HV), 3 = Residual Capacity per battery (Mira)
587
587
  residual_handling = 1
588
588
 
589
589
  # charge rates based on residual_handling
@@ -595,11 +595,18 @@ battery_params = {
595
595
  'offset': 5,
596
596
  'charge_loss': 0.974,
597
597
  'discharge_loss': 0.974},
598
+ # HV BMS v2 with firmware 1.014 or later
598
599
  2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
599
600
  'step': 5,
600
601
  'offset': 5,
601
602
  'charge_loss': 1.08,
602
603
  'discharge_loss': 0.95},
604
+ # Mira BMS with firmware 1.014 or later
605
+ 3: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
606
+ 'step': 5,
607
+ 'offset': 5,
608
+ 'charge_loss': 0.974,
609
+ 'discharge_loss': 0.974},
603
610
  }
604
611
 
605
612
  def get_battery(info=1):
@@ -632,10 +639,13 @@ def get_battery(info=1):
632
639
  output(f"** get_battery().info, no result data, {errno_message(errno)}")
633
640
  else:
634
641
  battery['info'] = result['batteries'][0]
635
- if battery['info']['masterVersion'] >= '1.014' and battery['info']['masterSN'][:7] == '60BBHV2':
642
+ if battery['info'].get('slaveBatteries') is not None:
643
+ battery['count'] = len(battery['info']['slaveBatteries'])
644
+ if battery['info']['masterSN'][:7] == '60BBHV2' and battery['info']['masterVersion'] >= '1.014':
636
645
  residual_handling = 2
646
+ elif battery['info']['masterSN'][:7] == '60MBB01' and battery['info']['masterVersion'] >= '1.014':
647
+ residual_handling = 3
637
648
  battery['residual_handling'] = residual_handling
638
- battery['rated_capacity'] = None
639
649
  battery['soh'] = None
640
650
  battery['soh_supported'] = False
641
651
  if battery.get('residual') is not None:
@@ -644,6 +654,18 @@ def get_battery(info=1):
644
654
  capacity = battery.get('residual')
645
655
  soc = battery.get('soc')
646
656
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
657
+ if battery.get('count') is None:
658
+ battery['count'] = int(battery['volt'] / 49)
659
+ if battery.get('ratedCapacity') is None:
660
+ battery['ratedCapacity'] = 2560 * battery['count']
661
+ elif battery['residual_handling'] == 3:
662
+ if battery.get('count') is None:
663
+ battery['count'] = int(battery['volt'] / 49)
664
+ capacity = (battery['residual'] * battery['count']) if battery.get('residual') is not None else None
665
+ soc = battery.get('soc')
666
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
667
+ if battery.get('ratedCapacity') is None:
668
+ battery['ratedCapacity'] = 2450 * battery['count']
647
669
  else:
648
670
  residual = battery.get('residual')
649
671
  soc = battery.get('soc')
@@ -654,6 +676,8 @@ def get_battery(info=1):
654
676
  params = battery_params[battery['residual_handling']]
655
677
  battery['charge_loss'] = params['charge_loss']
656
678
  battery['discharge_loss'] = params['discharge_loss']
679
+ if battery.get('ratedCapacity') is not None and battery.get('capacity') is not None:
680
+ battery['soh'] = round(battery['capacity'] * 1000 / battery['ratedCapacity'] * 100, 1)
657
681
  if battery.get('temperature') is not None:
658
682
  battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
659
683
  return battery
@@ -688,8 +712,13 @@ def get_batteries(info=1):
688
712
  batteries[i]['info'] = result['batteries'][i]
689
713
  for b in batteries:
690
714
  b['residual_handling'] = residual_handling
691
- if b.get('info') is not None and b['info']['masterVersion'] >= '1.014' and b['info']['masterSN'][:7] == '60BBHV2':
692
- b['residual_handling'] = 2
715
+ if b.get('info') is not None:
716
+ if b['info'].get('slaveBatteries') is not None:
717
+ b['count'] = len(b['info']['slaveBatteries'])
718
+ if b['info']['masterVersion'] >= '1.014' and b['info']['masterSN'][:7] == '60BBHV2':
719
+ b['residual_handling'] = 2
720
+ elif battery['info']['masterSN'][:7] == '60MBB01' and battery['info']['masterVersion'] >= '1.014':
721
+ residual_handling = 3
693
722
  rated_capacity = b.get('ratedCapacity')
694
723
  b['ratedCapacity'] = rated_capacity if rated_capacity is not None and rated_capacity > 100 else None
695
724
  soh = b.get('soh')
@@ -708,7 +737,7 @@ def get_batteries(info=1):
708
737
  soc = b.get('soc')
709
738
  residual = b['capacity'] * b['soc'] / 100
710
739
  b['residual'] = round(residual, 3)
711
- if b.get('capacity') is not None and b.get('ratedCapacity') is not None:
740
+ if b.get('ratedCapacity') is not None and b.get('capacity') is not None:
712
741
  b['soh'] = round(b['capacity'] * 1000 / b['ratedCapacity'] * 100, 1)
713
742
  b['charge_rate'] = 50
714
743
  params = battery_params[b['residual_handling']]
@@ -1323,10 +1352,11 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1323
1352
  price = segment.get('price')
1324
1353
  start = time_hours(start)
1325
1354
  # adjust exclusive time to inclusive
1326
- end = round_time(time_hours(end) - 1/60)
1355
+ end = time_hours(end)
1327
1356
  if start is None or end is None or start >= end:
1328
1357
  output(f"set_period(): ** invalid period times: {hours_time(start)}-{hours_time(end)}")
1329
1358
  return None
1359
+ end = round_time(end - 1/60)
1330
1360
  mode = 'SelfUse' if mode is None else mode
1331
1361
  if mode not in work_modes:
1332
1362
  output(f"** mode must be one of {work_modes}")
@@ -1399,7 +1429,7 @@ def set_schedule(periods=None, template=None, enable=True):
1399
1429
  if len(periods) > 8:
1400
1430
  output(f"** set_schedule(): maximum of 8 periods allowed, {len(periods)} provided")
1401
1431
  return None
1402
- data = {'pollcy': periods, 'deviceSN': device_sn}
1432
+ data = {'pollcy': periods[-8:], 'deviceSN': device_sn}
1403
1433
  schedule['pollcy'] = periods
1404
1434
  schedule['template_id'] = None
1405
1435
  elif template is not None:
@@ -2588,7 +2618,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2588
2618
  elif type(strategy) is not list:
2589
2619
  strategy = [strategy]
2590
2620
  output(f"\nStrategy")
2591
- 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')])
2621
+ 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')])
2592
2622
  output_close(plot=tariff_config['show_plot'])
2593
2623
  if update == 1:
2594
2624
  tariff = use
@@ -2665,15 +2695,15 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2665
2695
  return profile[:run_time]
2666
2696
 
2667
2697
  # build the timed work mode profile from the tariff strategy:
2668
- def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, current_mode=None):
2698
+ def strategy_timed(timed_mode, time_line, run_time, min_soc=10, max_soc=100, current_mode=None):
2669
2699
  global tariff, steps_per_hour
2670
2700
  work_mode_timed = []
2671
2701
  min_soc_now = min_soc
2672
2702
  max_soc_now = max_soc
2673
2703
  current_mode = 'SelfUse' if current_mode is None else current_mode
2674
2704
  strategy = get_strategy(timed_mode=timed_mode)
2675
- h = base_hour
2676
2705
  for i in range(0, run_time):
2706
+ h = time_line[i]
2677
2707
  period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0, 'charge': 0.0,
2678
2708
  'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2679
2709
  if strategy is not None:
@@ -2692,8 +2722,8 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2692
2722
  if d.get('fdpwr') is not None:
2693
2723
  period['fdpwr'] = d['fdpwr']
2694
2724
  period['duration'] = duration_in(h, d, steps_per_hour) * steps_per_hour
2725
+ break
2695
2726
  work_mode_timed.append(period)
2696
- h = round_time(h + 1 / steps_per_hour)
2697
2727
  return work_mode_timed
2698
2728
 
2699
2729
  # build the timed battery residual from the charge / discharge, work mode and min_soc
@@ -2706,7 +2736,8 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2706
2736
  discharge_loss = charge_config['discharge_loss']
2707
2737
  charge_limit = charge_config['charge_limit']
2708
2738
  float_charge = charge_config['float_charge']
2709
- for i in range(0, len(work_mode_timed)):
2739
+ run_time = len(work_mode_timed)
2740
+ for i in range(0, run_time):
2710
2741
  w = work_mode_timed[i]
2711
2742
  w['kwh'] = kwh_current
2712
2743
  max_now = w['max_soc'] * capacity / 100
@@ -2717,6 +2748,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2717
2748
  if kwh_current > capacity:
2718
2749
  # battery is full
2719
2750
  kwh_current = capacity
2751
+ w = work_mode_timed[i+1] if (i + 1) < run_time else w
2720
2752
  min_soc_now = w['fdsoc'] if w['mode'] =='ForceDischarge' else w['min_soc']
2721
2753
  reserve_now = capacity * min_soc_now / 100
2722
2754
  if kwh_current < reserve_now and (i < time_to_next or kwh_min is None):
@@ -2746,7 +2778,7 @@ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2746
2778
  period = times[0] if len(times) > 0 else work_mode_timed[0]
2747
2779
  next_period = work_mode_timed[t]
2748
2780
  h = base_hour + t / steps_per_hour
2749
- if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
2781
+ if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold'] or period['min_soc'] != next_period['min_soc']:
2750
2782
  s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
2751
2783
  if period['mode'] == 'ForceDischarge':
2752
2784
  s['fdsoc'] = period.get('fdsoc')
@@ -3141,7 +3173,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3141
3173
  # produce time lines for charge, discharge and work mode
3142
3174
  charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
3143
3175
  discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
3144
- work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
3176
+ work_mode_timed = strategy_timed(timed_mode, time_line, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
3145
3177
  for i in range(0, len(work_mode_timed)):
3146
3178
  # get work mode
3147
3179
  work_mode = work_mode_timed[i]['mode']
@@ -3269,14 +3301,15 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3269
3301
  set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
3270
3302
  if update_settings == 0:
3271
3303
  output(f"\nNo changes made to charge settings")
3304
+ start_t = 0 # int(hour_now % 1 + 0.5) * steps_per_hour
3272
3305
  if show_data > 0:
3273
3306
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3274
3307
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3275
3308
  h = base_hour
3276
- t = 0
3309
+ t = start_t
3277
3310
  while t < len(time_line) and bat_timed[t] is not None:
3278
3311
  col = h % data_wrap
3279
- s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3312
+ s += f"\n {hours_time(time_line[t])}" if t == start_t or col == 0 else ""
3280
3313
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3281
3314
  h += 1
3282
3315
  t += steps_per_hour
@@ -3284,8 +3317,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3284
3317
  if show_plot > 0:
3285
3318
  print()
3286
3319
  plt.figure(figsize=(figure_width, figure_width/2))
3287
- x_timed = [i for i in range(0, run_time)]
3288
- x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3320
+ x_timed = [i for i in range(start_t, run_time)]
3321
+ x_ticks = [i for i in range(start_t, run_time, steps_per_hour)]
3289
3322
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3290
3323
  if show_plot == 1:
3291
3324
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3395,14 +3428,15 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3, d=None):
3395
3428
  for v in plots.keys():
3396
3429
  for i in range(0, run_time):
3397
3430
  plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
3431
+ start_t = 0 #int(hour_now % 1 + 0.5) * steps_per_hour
3398
3432
  if show_data > 0 and plots.get('SoC') is not None:
3399
3433
  data_wrap = 1 #charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 1
3400
3434
  s = f"\nBattery Energy kWh (predicted / actual):" if show_data == 2 else f"\nBattery SoC (predicted / actual):"
3401
3435
  h = base_hour
3402
- t = 0
3436
+ t = start_t
3403
3437
  while t < len(time_line) and bat_timed[t] is not None and plots['SoC'][t] is not None:
3404
3438
  col = h % data_wrap
3405
- s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3439
+ s += f"\n {hours_time(time_line[t])}" if t == start_t or col == 0 else ""
3406
3440
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3407
3441
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3408
3442
  h += 1
@@ -3411,8 +3445,8 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3, d=None):
3411
3445
  if show_plot > 0:
3412
3446
  print()
3413
3447
  plt.figure(figsize=(figure_width, figure_width/2))
3414
- x_timed = [i for i in range(0, run_time)]
3415
- x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3448
+ x_timed = [i for i in range(start_t, run_time)]
3449
+ x_ticks = [i for i in range(start_t, run_time, steps_per_hour)]
3416
3450
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3417
3451
  if show_plot == 1:
3418
3452
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3555,11 +3589,11 @@ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3555
3589
  s +=f",{v:.0f}"
3556
3590
  return s
3557
3591
  output(f"Current SoC: {current_soc}%")
3558
- output(f"Capacity: {capacity:.2f}kWh" + (" (Residual / SoC x 100)" if bat['residual_handling'] == 1 else ""))
3559
- output(f"Residual: {residual:.2f}kWh" + (" (SoC x Capacity / 100)" if bat['residual_handling'] == 2 else ""))
3592
+ output(f"Capacity: {capacity:.2f}kWh" + (" (calculated)" if bat['residual_handling'] in [1,3] else ""))
3593
+ output(f"Residual: {residual:.2f}kWh" + (" (calculated)" if bat['residual_handling'] in [2,3] else ""))
3560
3594
  if rated_capacity is not None and bat_soh is not None:
3561
3595
  output(f"Rated Capacity: {rated_capacity / 1000:.2f}kWh")
3562
- output(f"SoH: {bat_soh:.1f}%" + (" (Capacity / Rated Capacity x 100)" if not bat['soh_supported'] else ""))
3596
+ output(f"SoH: {bat_soh:.1f}%" + (" (calculated)" if not bat['soh_supported'] else ""))
3563
3597
  output(f"InvBatVolt: {bat_volt:.1f}V")
3564
3598
  output(f"InvBatCurrent: {bat_current:.1f}A")
3565
3599
  output(f"State: {'Charging' if bat_power < 0 else 'Discharging'} ({abs(bat_power):.3f}kW)")
@@ -3968,7 +4002,6 @@ class Solcast :
3968
4002
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3969
4003
  # The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
3970
4004
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3971
- self.data = {}
3972
4005
  now = convert_date(d)
3973
4006
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3974
4007
  self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
@@ -3976,6 +4009,8 @@ class Solcast :
3976
4009
  self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
3977
4010
  self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3978
4011
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
4012
+ self.data = {}
4013
+ self.rids = []
3979
4014
  if reload == 1 and os.path.exists(storage + self.save):
3980
4015
  os.remove(storage + self.save)
3981
4016
  if self.save is not None and os.path.exists(storage + self.save):
@@ -3984,33 +4019,37 @@ class Solcast :
3984
4019
  file.close()
3985
4020
  if len(self.data) == 0:
3986
4021
  print(f"No data in {self.save}")
3987
- elif reload == 2 and 'date' in self.data and self.data['date'] != self.today:
3988
- self.data = {}
3989
- elif debug_setting > 0 and not quiet:
3990
- print(f"Using data for {self.data['date']} from {self.save}")
3991
- if len(self.data) == 0 :
4022
+ else:
4023
+ self.rids = self.data['forecasts'].keys() if self.data.get('forecasts') is not None else []
4024
+ if reload == 2 and self.data.get('date') is not None and self.data['date'] != self.today:
4025
+ self.data = {}
4026
+ elif debug_setting > 0 and not quiet:
4027
+ print(f"Using data for {self.data['date']} from {self.save}")
4028
+ if len(self.data) == 0:
3992
4029
  if solcast_api_key is None or solcast_api_key == 'my.solcast_api_key>':
3993
4030
  print(f"\nSolcast: solcast_api_key not set, exiting")
3994
4031
  return
3995
4032
  self.credentials = HTTPBasicAuth(solcast_api_key, '')
3996
- if debug_setting > 1 and not quiet:
3997
- print(f"Getting rids from solcast.com")
3998
- params = {'format' : 'json'}
3999
- response = requests.get(solcast_url + 'rooftop_sites', auth = self.credentials, params = params)
4000
- if response.status_code != 200:
4001
- if response.status_code == 429:
4002
- print(f"\nSolcast API call limit reached for today")
4003
- else:
4004
- print(f"Solcast: response code getting rooftop_sites was {response.status_code}: {response.reason}")
4005
- return
4006
- sites = response.json().get('sites')
4033
+ if len(self.rids) == 0:
4034
+ if debug_setting > 1 and not quiet:
4035
+ print(f"Getting rids from solcast.com")
4036
+ params = {'format' : 'json'}
4037
+ response = requests.get(solcast_url + 'rooftop_sites', auth = self.credentials, params = params)
4038
+ if response.status_code != 200:
4039
+ if response.status_code == 429:
4040
+ print(f"\nSolcast API call limit reached for today")
4041
+ else:
4042
+ print(f"Solcast: response code getting rooftop_sites was {response.status_code}: {response.reason}")
4043
+ return
4044
+ sites = response.json().get('sites')
4045
+ self.rids = [s['resource_id'] for s in sites]
4007
4046
  if debug_setting > 0 and not quiet:
4008
4047
  print(f"Getting forecast for {self.today} from solcast.com")
4009
4048
  self.data['date'] = self.today
4010
4049
  params = {'format' : 'json', 'hours' : 168, 'period' : 'PT30M'} # always get 168 x 30 min values
4011
4050
  for t in ['forecasts'] if estimated == 0 else ['forecasts', 'estimated_actuals']:
4012
4051
  self.data[t] = {}
4013
- for rid in [s['resource_id'] for s in sites] :
4052
+ for rid in self.rids:
4014
4053
  response = requests.get(solcast_url + 'rooftop_sites/' + rid + '/' + t, auth = self.credentials, params = params)
4015
4054
  if response.status_code != 200 :
4016
4055
  if response.status_code == 429:
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 14 October 2024
4
+ Updated: 01 November 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.6.5"
13
+ version = "2.6.6"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -548,7 +548,7 @@ battery_settings = None
548
548
  battery_vars = ['SoC', 'invBatVolt', 'invBatCurrent', 'invBatPower', 'batTemperature', 'ResidualEnergy' ]
549
549
  battery_data = ['soc', 'volt', 'current', 'power', 'temperature', 'residual']
550
550
 
551
- # 1 = returns Residual Energy. 2 = resturns Residual Capacity
551
+ # 1 = Residual Energy, 2 = Residual Capacity (HV), 3 = Residual Capacity per battery (Mira)
552
552
  residual_handling = 1
553
553
 
554
554
  # charge rates based on residual_handling
@@ -560,11 +560,18 @@ battery_params = {
560
560
  'offset': 5,
561
561
  'charge_loss': 0.974,
562
562
  'discharge_loss': 0.974},
563
+ # HV BMS v2 with firmware 1.014 or later
563
564
  2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
564
565
  'step': 5,
565
566
  'offset': 5,
566
- 'charge_loss': 1.080,
567
+ 'charge_loss': 1.08,
567
568
  'discharge_loss': 0.95},
569
+ # Mira BMS with firmware 1.014 or later
570
+ 3: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
571
+ 'step': 5,
572
+ 'offset': 5,
573
+ 'charge_loss': 0.974,
574
+ 'discharge_loss': 0.974},
568
575
  }
569
576
 
570
577
  def get_battery(info=0, v=None):
@@ -580,17 +587,28 @@ def get_battery(info=0, v=None):
580
587
  for i in range(0, len(battery_vars)):
581
588
  battery[battery_data[i]] = result[i].get('value')
582
589
  battery['residual_handling'] = residual_handling
583
- battery['rated_capacity'] = None
584
590
  battery['soh'] = None
585
591
  battery['soh_supported'] = False
586
592
  if battery['residual_handling'] == 2:
587
593
  capacity = battery.get('residual')
588
594
  soc = battery.get('soc')
589
595
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
596
+ if battery.get('count') is None:
597
+ battery['count'] = int(battery['volt'] / 49)
598
+ if battery.get('ratedCapacity') is None:
599
+ battery['ratedCapacity'] = 2560 * battery['count']
600
+ elif battery['residual_handling'] == 3:
601
+ if battery.get('count') is None:
602
+ battery['count'] = int(battery['volt'] / 49)
603
+ capacity = (battery['residual'] * battery['count']) if battery.get('residual') is not None else None
604
+ soc = battery.get('soc')
605
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
606
+ if battery.get('ratedCapacity') is None:
607
+ battery['ratedCapacity'] = 2450 * battery['count']
590
608
  else:
591
609
  residual = battery.get('residual')
592
610
  soc = battery.get('soc')
593
- capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
611
+ capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
594
612
  battery['capacity'] = round(capacity, 3)
595
613
  battery['residual'] = round(residual, 3)
596
614
  battery['status'] = 1
@@ -598,6 +616,8 @@ def get_battery(info=0, v=None):
598
616
  params = battery_params[battery['residual_handling']]
599
617
  battery['charge_loss'] = params['charge_loss']
600
618
  battery['discharge_loss'] = params['discharge_loss']
619
+ if battery.get('ratedCapacity') is not None and battery.get('capacity') is not None:
620
+ battery['soh'] = round(battery['capacity'] * 1000 / battery['ratedCapacity'] * 100, 1)
601
621
  if battery.get('temperature') is not None:
602
622
  battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
603
623
  return battery
@@ -877,6 +897,8 @@ def get_work_mode():
877
897
  global work_mode
878
898
  if get_device() is None:
879
899
  return None
900
+ # not implemented by Open API, skip to avoid error
901
+ return None
880
902
  work_mode = get_named_settings('WorkMode')
881
903
  return work_mode
882
904
 
@@ -1049,10 +1071,11 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1049
1071
  price = segment.get('price')
1050
1072
  start = time_hours(start)
1051
1073
  # adjust exclusive time to inclusive
1052
- end = round_time(time_hours(end) - 1/60)
1074
+ end = time_hours(end)
1053
1075
  if start is None or end is None or start >= end:
1054
1076
  output(f"set_period(): ** invalid period times: {hours_time(start)}-{hours_time(end)}")
1055
1077
  return None
1078
+ end = round_time(end - 1/60)
1056
1079
  mode = 'SelfUse' if mode is None else mode
1057
1080
  if mode not in work_modes:
1058
1081
  output(f"** mode must be one of {work_modes}")
@@ -1109,8 +1132,7 @@ def set_schedule(periods=None, enable=True):
1109
1132
  periods = [periods]
1110
1133
  if len(periods) > 8:
1111
1134
  output(f"** set_schedule(): maximum of 8 periods allowed, {len(periods)} provided")
1112
- return None
1113
- body = {'deviceSN': device_sn, 'groups': periods}
1135
+ body = {'deviceSN': device_sn, 'groups': periods[-8:]}
1114
1136
  setting_delay()
1115
1137
  response = signed_post(path="/op/v0/device/scheduler/enable", body=body)
1116
1138
  if response.status_code != 200:
@@ -2301,7 +2323,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2301
2323
  elif type(strategy) is not list:
2302
2324
  strategy = [strategy]
2303
2325
  output(f"\nStrategy")
2304
- 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')])
2326
+ 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')])
2305
2327
  output_close(plot=tariff_config['show_plot'])
2306
2328
  if update == 1:
2307
2329
  tariff = use
@@ -2378,15 +2400,15 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2378
2400
  return profile[:run_time]
2379
2401
 
2380
2402
  # build the timed work mode profile from the tariff strategy:
2381
- def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, current_mode=None):
2403
+ def strategy_timed(timed_mode, time_line, run_time, min_soc=10, max_soc=100, current_mode=None):
2382
2404
  global tariff, steps_per_hour
2383
2405
  work_mode_timed = []
2384
2406
  min_soc_now = min_soc
2385
2407
  max_soc_now = max_soc
2386
2408
  current_mode = 'SelfUse' if current_mode is None else current_mode
2387
2409
  strategy = get_strategy(timed_mode=timed_mode)
2388
- h = base_hour
2389
2410
  for i in range(0, run_time):
2411
+ h = time_line[i]
2390
2412
  period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0, 'charge': 0.0,
2391
2413
  'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2392
2414
  if strategy is not None:
@@ -2406,11 +2428,10 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2406
2428
  period['fdpwr'] = d['fdpwr']
2407
2429
  period['duration'] = duration_in(h, d) * steps_per_hour
2408
2430
  work_mode_timed.append(period)
2409
- h = round_time(h + 1 / steps_per_hour)
2410
2431
  return work_mode_timed
2411
2432
 
2412
2433
  # build the timed battery residual from the charge / discharge, work mode and min_soc
2413
- # note: all power values are as measured at the inverter battery connection
2434
+ # all power values are as measured at the inverter battery connection
2414
2435
  def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=None, reserve_drain=None):
2415
2436
  global charge_config, steps_per_hour
2416
2437
  allowed_drain = charge_config['allowed_drain'] if charge_config.get('allowed_drain') is not None else 4
@@ -2419,7 +2440,8 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2419
2440
  discharge_loss = charge_config['discharge_loss']
2420
2441
  charge_limit = charge_config['charge_limit']
2421
2442
  float_charge = charge_config['float_charge']
2422
- for i in range(0, len(work_mode_timed)):
2443
+ run_time = len(work_mode_timed)
2444
+ for i in range(0, run_time):
2423
2445
  w = work_mode_timed[i]
2424
2446
  w['kwh'] = kwh_current
2425
2447
  max_now = w['max_soc'] * capacity / 100
@@ -2430,6 +2452,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2430
2452
  if kwh_current > capacity:
2431
2453
  # battery is full
2432
2454
  kwh_current = capacity
2455
+ w = work_mode_timed[i+1] if (i + 1) < run_time else w
2433
2456
  min_soc_now = w['fdsoc'] if w['mode'] =='ForceDischarge' else w['min_soc']
2434
2457
  reserve_now = capacity * min_soc_now / 100
2435
2458
  if kwh_current < reserve_now and (i < time_to_next or kwh_min is None):
@@ -2459,7 +2482,7 @@ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2459
2482
  period = times[0] if len(times) > 0 else work_mode_timed[0]
2460
2483
  next_period = work_mode_timed[t]
2461
2484
  h = base_hour + t / steps_per_hour
2462
- if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
2485
+ if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold'] or period['min_soc'] != next_period['min_soc']:
2463
2486
  s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
2464
2487
  if period['mode'] == 'ForceDischarge':
2465
2488
  s['fdsoc'] = period.get('fdsoc')
@@ -2852,7 +2875,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2852
2875
  # produce time lines for charge, discharge and work mode
2853
2876
  charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
2854
2877
  discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
2855
- work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
2878
+ work_mode_timed = strategy_timed(timed_mode, time_line, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
2856
2879
  for i in range(0, len(work_mode_timed)):
2857
2880
  # get work mode
2858
2881
  work_mode = work_mode_timed[i]['mode']
@@ -2980,14 +3003,15 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2980
3003
  set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
2981
3004
  if update_settings == 0:
2982
3005
  output(f"\nNo changes made to charge settings")
3006
+ start_t = 0 #int(hour_now % 1 + 0.5) * steps_per_hour
2983
3007
  if show_data > 0:
2984
3008
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
2985
3009
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
2986
3010
  h = base_hour
2987
- t = 0
3011
+ t = start_t
2988
3012
  while t < len(time_line) and bat_timed[t] is not None:
2989
3013
  col = h % data_wrap
2990
- s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3014
+ s += f"\n {hours_time(time_line[t])}" if t == start_t or col == 0 else ""
2991
3015
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
2992
3016
  h += 1
2993
3017
  t += steps_per_hour
@@ -2995,8 +3019,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2995
3019
  if show_plot > 0:
2996
3020
  print()
2997
3021
  plt.figure(figsize=(figure_width, figure_width/2))
2998
- x_timed = [i for i in range(0, run_time)]
2999
- x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3022
+ x_timed = [i for i in range(start_t, run_time)]
3023
+ x_ticks = [i for i in range(start_t, run_time, steps_per_hour)]
3000
3024
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3001
3025
  if show_plot == 1:
3002
3026
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3105,14 +3129,15 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3105
3129
  for v in plots.keys():
3106
3130
  for i in range(0, run_time):
3107
3131
  plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
3132
+ start_t = 0 #int(hour_now % 1 + 0.5) * steps_per_hour
3108
3133
  if show_data > 0 and plots.get('SoC') is not None:
3109
3134
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3110
3135
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3111
3136
  h = base_hour
3112
- t = 0
3137
+ t = start_t
3113
3138
  while t < len(time_line) and bat_timed[t] is not None and plots['SoC'][t] is not None:
3114
3139
  col = h % data_wrap
3115
- s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3140
+ s += f"\n {hours_time(time_line[t])}" if t == start_t or col == 0 else ""
3116
3141
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3117
3142
  h += 1
3118
3143
  t += steps_per_hour
@@ -3120,8 +3145,8 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3120
3145
  if show_plot > 0:
3121
3146
  print()
3122
3147
  plt.figure(figsize=(figure_width, figure_width/2))
3123
- x_timed = [i for i in range(0, run_time)]
3124
- x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3148
+ x_timed = [i for i in range(start_t, run_time)]
3149
+ x_ticks = [i for i in range(start_t, run_time, steps_per_hour)]
3125
3150
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3126
3151
  if show_plot == 1:
3127
3152
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3263,8 +3288,8 @@ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3263
3288
  s +=f",{v:.0f}"
3264
3289
  return s
3265
3290
  output(f"Current SoC: {current_soc}%")
3266
- output(f"Capacity: {capacity:.2f}kWh" + (" (Residual / SoC x 100)" if bat['residual_handling'] == 1 else ""))
3267
- output(f"Residual: {residual:.2f}kWh" + (" (SoC x Capacity / 100)" if bat['residual_handling'] == 2 else ""))
3291
+ output(f"Capacity: {capacity:.2f}kWh" + (" (calculated)" if bat['residual_handling'] in [1,3] else ""))
3292
+ output(f"Residual: {residual:.2f}kWh" + (" (calculated)" if bat['residual_handling'] in [2,3] else ""))
3268
3293
  if rated_capacity is not None and bat_soh is not None:
3269
3294
  output(f"Rated Capacity: {rated_capacity / 1000:.2f}kWh")
3270
3295
  output(f"SoH: {bat_soh:.1f}%" + (" (Capacity / Rated Capacity x 100)" if not bat['soh_supported'] else ""))
@@ -3676,7 +3701,6 @@ class Solcast :
3676
3701
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3677
3702
  # The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
3678
3703
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3679
- self.data = {}
3680
3704
  now = convert_date(d)
3681
3705
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3682
3706
  self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
@@ -3684,6 +3708,8 @@ class Solcast :
3684
3708
  self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
3685
3709
  self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3686
3710
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
3711
+ self.data = {}
3712
+ self.rids = []
3687
3713
  if reload == 1 and os.path.exists(storage + self.save):
3688
3714
  os.remove(storage + self.save)
3689
3715
  if self.save is not None and os.path.exists(storage + self.save):
@@ -3692,33 +3718,37 @@ class Solcast :
3692
3718
  file.close()
3693
3719
  if len(self.data) == 0:
3694
3720
  print(f"No data in {self.save}")
3695
- elif reload == 2 and 'date' in self.data and self.data['date'] != self.today:
3696
- self.data = {}
3697
- elif debug_setting > 0 and not quiet:
3698
- print(f"Using data for {self.data['date']} from {self.save}")
3721
+ else:
3722
+ self.rids = self.data['forecasts'].keys() if self.data.get('forecasts') is not None else []
3723
+ if reload == 2 and self.data.get('date') is not None and self.data['date'] != self.today:
3724
+ self.data = {}
3725
+ elif debug_setting > 0 and not quiet:
3726
+ print(f"Using data for {self.data['date']} from {self.save}")
3699
3727
  if len(self.data) == 0 :
3700
3728
  if solcast_api_key is None or solcast_api_key == 'my.solcast_api_key>':
3701
3729
  print(f"\nSolcast: solcast_api_key not set, exiting")
3702
3730
  return
3703
3731
  self.credentials = HTTPBasicAuth(solcast_api_key, '')
3704
- if debug_setting > 1 and not quiet:
3705
- print(f"Getting rids from solcast.com")
3706
- params = {'format' : 'json'}
3707
- response = requests.get(solcast_url + 'rooftop_sites', auth = self.credentials, params = params)
3708
- if response.status_code != 200:
3709
- if response.status_code == 429:
3710
- print(f"\nSolcast API call limit reached for today")
3711
- else:
3712
- print(f"Solcast: response code getting rooftop_sites was {response.status_code}: {response.reason}")
3713
- return
3714
- sites = response.json().get('sites')
3732
+ if len(self.rids) == 0:
3733
+ if debug_setting > 1 and not quiet:
3734
+ print(f"Getting rids from solcast.com")
3735
+ params = {'format' : 'json'}
3736
+ response = requests.get(solcast_url + 'rooftop_sites', auth = self.credentials, params = params)
3737
+ if response.status_code != 200:
3738
+ if response.status_code == 429:
3739
+ print(f"\nSolcast API call limit reached for today")
3740
+ else:
3741
+ print(f"Solcast: response code getting rooftop_sites was {response.status_code}: {response.reason}")
3742
+ return
3743
+ sites = response.json().get('sites')
3744
+ self.rids = [s['resource_id'] for s in sites]
3715
3745
  if debug_setting > 0 and not quiet:
3716
3746
  print(f"Getting forecast for {self.today} from solcast.com")
3717
3747
  self.data['date'] = self.today
3718
3748
  params = {'format' : 'json', 'hours' : 168, 'period' : 'PT30M'} # always get 168 x 30 min values
3719
3749
  for t in ['forecasts'] if estimated == 0 else ['forecasts', 'estimated_actuals']:
3720
3750
  self.data[t] = {}
3721
- for rid in [s['resource_id'] for s in sites] :
3751
+ for rid in self.rids:
3722
3752
  response = requests.get(solcast_url + 'rooftop_sites/' + rid + '/' + t, auth = self.credentials, params = params)
3723
3753
  if response.status_code != 200 :
3724
3754
  if response.status_code == 429:
@@ -3727,7 +3757,7 @@ class Solcast :
3727
3757
  print(f"Solcast: response code getting {t} was {response.status_code}: {response.reason}")
3728
3758
  return
3729
3759
  self.data[t][rid] = response.json().get(t)
3730
- if self.save is not None :
3760
+ if self.save is not None:
3731
3761
  file = open(storage + self.save, 'w')
3732
3762
  json.dump(self.data, file, sort_keys = True, indent=4, ensure_ascii= False)
3733
3763
  file.close()
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.1
2
+ Name: foxesscloud
3
+ Version: 2.6.6
4
+ Summary: library for accessing Fox ESS cloud data using Open API
5
+ Author-email: Tony Matthews <tony@quasair.co.uk>
6
+ Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
7
+ Project-URL: Bug Tracker, https://github.com/TonyM1958/FoxESS-Cloud/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.7
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENCE
14
+
1
15
  # FoxESS-Cloud
2
16
 
3
17
  <a href="https://www.buymeacoffee.com/tonym1958" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174" align="right"></a>
@@ -783,6 +797,15 @@ This setting can be:
783
797
 
784
798
  # Version Info
785
799
 
800
+ 2.6.6<br>
801
+ Add residual_handling=3 for Mira BMS with firmware 1.014 or later that returns residual capacity per battery.
802
+ Calculate 'ratedCapacity' in get_battery() and 'soh' for HV2600 and Mira.
803
+ Allow unlimited periods in strategy, including overlap with charge periods but warn and limit if the periods sent to inverter would be more than 8.
804
+ Improve behaviour prediction for schedules when clocks change due to day light saving.
805
+ Improve schedule generation and prediction when Min Soc changes.
806
+ Cache Solcast RIDS to reduce API usage (run with reload=1 if arrays are edited and cached RIDs need to be updated).
807
+ Remove spurious error message when (failing) to get inverter work mode.
808
+
786
809
  2.6.5<br>
787
810
  Add get_named_settings() and set_named_settings().
788
811
  Update get_work_mode() and set_work_mode() to use named settings (still doesn't work though as blocked by Fox)
File without changes
File without changes