foxesscloud 2.5.9__py3-none-any.whl → 2.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud
4
- Updated: 02 October 2024
4
+ Updated: 09 October 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.1"
13
+ version = "1.7.3"
14
14
  print(f"FoxESS-Cloud version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -477,6 +477,7 @@ def get_device(sn=None):
477
477
  device_id = device.get('deviceID')
478
478
  device_sn = device.get('deviceSN')
479
479
  battery = None
480
+ batteries = None
480
481
  battery_settings = None
481
482
  schedule = None
482
483
  templates = None
@@ -575,13 +576,30 @@ def get_firmware():
575
576
  ##################################################################################################
576
577
 
577
578
  battery = None
579
+ batteries = None
578
580
  battery_settings = None
579
581
 
580
- # 1 = returns Residual Energy. 2 = resturns Residual Capacity
582
+ # 1 = Residual Energy, 2 = Residual Capacity
581
583
  residual_handling = 1
582
584
 
583
- def get_battery(info=0):
584
- global token, device_id, battery, debug_setting, messages
585
+ # charge rates based on residual_handling
586
+ battery_params = {
587
+ # cell temp -5 0 5 10 15 20 25 30 35 40 45 50 55
588
+ # bms temp 5 10 15 20 25 30 35 40 45 50 55 60 65
589
+ 1: {'table': [ 0, 2, 10, 15, 25, 50, 50, 50, 50, 50, 30, 20, 0],
590
+ 'step': 5,
591
+ 'offset': 5,
592
+ 'charge_loss': 0.975,
593
+ 'discharge_loss': 0.975},
594
+ 2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
595
+ 'step': 5,
596
+ 'offset': 5,
597
+ 'charge_loss': 1.080,
598
+ 'discharge_loss': 0.975},
599
+ }
600
+
601
+ def get_battery(info=1):
602
+ global token, device_id, battery, debug_setting, messages, residual_handling, battery_params
585
603
  if get_device() is None:
586
604
  return None
587
605
  output(f"getting battery", 2)
@@ -595,13 +613,59 @@ def get_battery(info=0):
595
613
  errno = response.json().get('errno')
596
614
  output(f"** get_battery(), no result data, {errno_message(errno)}")
597
615
  return None
616
+ saved_info = battery['info'] if battery is not None and battery.get('info') is not None else None
598
617
  battery = result
618
+ if saved_info is not None:
619
+ battery['info'] = saved_info
620
+ elif info == 1:
621
+ response = signed_get(path="/generic/v0/device/battery/list", params=params)
622
+ if response.status_code != 200:
623
+ output(f"** get_battery().info got response code {response.status_code}: {response.reason}")
624
+ else:
625
+ result = response.json().get('result')
626
+ if result is None:
627
+ errno = response.json().get('errno')
628
+ output(f"** get_battery().info, no result data, {errno_message(errno)}")
629
+ else:
630
+ battery['info'] = result['batteries'][0]
631
+ if battery['info']['masterVersion'] >= '1.014':
632
+ residual_handling = 2
599
633
  if battery.get('residual') is not None:
600
- battery['residual'] /=1000
634
+ battery['residual'] /= 1000
601
635
  if residual_handling == 2:
602
636
  capacity = battery.get('residual')
603
637
  soc = battery.get('soc')
604
- battery['residual'] = capacity * soc / 100 if capacity is not None and soc is not None else capacity
638
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
639
+ else:
640
+ residual = battery.get('residual')
641
+ soc = battery.get('soc')
642
+ capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
643
+ battery['capacity'] = round(capacity, 3)
644
+ battery['residual'] = round(residual, 3)
645
+ battery['charge_rate'] = 50
646
+ params = battery_params[residual_handling]
647
+ battery['charge_loss'] = params['charge_loss']
648
+ battery['discharge_loss'] = params['discharge_loss']
649
+ if battery.get('temperature') is not None:
650
+ battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
651
+ return battery
652
+
653
+ def get_batteries(info=1):
654
+ global token, device_id, battery, debug_setting, messages, batteries, battery_params, residual_handling
655
+ if get_device() is None:
656
+ return None
657
+ output(f"getting batteries", 2)
658
+ params = {'id': device_id}
659
+ response = signed_get(path="/generic/v0/device/battery/info", params=params)
660
+ if response.status_code != 200:
661
+ output(f"** get_batteries() got response code {response.status_code}: {response.reason}")
662
+ return None
663
+ result = response.json().get('result')
664
+ if result is None:
665
+ errno = response.json().get('errno')
666
+ output(f"** get_batteries(), no result data, {errno_message(errno)}")
667
+ return None
668
+ batteries = result['batterys']
605
669
  if info == 1:
606
670
  response = signed_get(path="/generic/v0/device/battery/list", params=params)
607
671
  if response.status_code != 200:
@@ -612,8 +676,24 @@ def get_battery(info=0):
612
676
  errno = response.json().get('errno')
613
677
  output(f"** get_battery().info, no result data, {errno_message(errno)}")
614
678
  else:
615
- battery['info'] = result['batteries']
616
- return battery
679
+ for i in range(0, len(batteries)):
680
+ batteries[i]['info'] = result['batteries'][i]
681
+ for b in batteries:
682
+ if b.get('info') is not None and b['info']['masterVersion'] >= '1.014':
683
+ residual_handling = 2
684
+ capacity = b['ratedCapacity'] / 1000 * int(b['soh']) / 100
685
+ soc = b.get('soc')
686
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
687
+ b['capacity'] = round(capacity, 3)
688
+ b['residual'] = round(residual, 3)
689
+ b['charge_rate'] = 50
690
+ params = battery_params[residual_handling]
691
+ b['charge_loss'] = params['charge_loss']
692
+ b['discharge_loss'] = params['discharge_loss']
693
+ if b.get('temperature') is not None:
694
+ b['charge_rate'] = params['table'][int((b['temperature'] - params['offset']) / params['step'])]
695
+ battery = batteries[0]
696
+ return batteries
617
697
 
618
698
  ##################################################################################################
619
699
  # get charge times and save to battery_settings
@@ -2545,7 +2625,7 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2545
2625
  profile = []
2546
2626
  h = base_hour - time_offset
2547
2627
  while h < 0:
2548
- profile.append(0.0)
2628
+ profile.append(None)
2549
2629
  h += 1 / steps_per_hour
2550
2630
  while h < 48:
2551
2631
  day = today if h < 24 else tomorrow
@@ -2555,10 +2635,10 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2555
2635
  value = forecast.daily[day]['hourly'].get(int(h % 24))
2556
2636
  else:
2557
2637
  value = forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2))
2558
- profile.append(c_float(value))
2638
+ profile.append(value)
2559
2639
  h += 1 / steps_per_hour
2560
2640
  while len(profile) < run_time:
2561
- profile.append(0.0)
2641
+ profile.append(None)
2562
2642
  return profile[:run_time]
2563
2643
 
2564
2644
  # build the timed work mode profile from the tariff strategy:
@@ -2596,11 +2676,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2596
2676
  # build the timed battery residual from the charge / discharge, work mode and min_soc
2597
2677
  # all power values are as measured at the inverter battery connection
2598
2678
  def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=None, reserve_drain=None):
2599
- global charge_config, steps_per_hour, residual_handling
2679
+ global charge_config, steps_per_hour
2600
2680
  allowed_drain = charge_config['allowed_drain'] if charge_config.get('allowed_drain') is not None else 4
2601
2681
  bms_loss = (charge_config['bms_power'] / 1000 if charge_config.get('bms_power') is not None else 0.05)
2602
- charge_loss = charge_config['charge_loss'][residual_handling - 1]
2603
- discharge_loss = charge_config['discharge_loss'][residual_handling - 1]
2682
+ charge_loss = charge_config['charge_loss']
2683
+ discharge_loss = charge_config['discharge_loss']
2604
2684
  charge_limit = charge_config['charge_limit']
2605
2685
  float_charge = charge_config['float_charge']
2606
2686
  for i in range(0, len(work_mode_timed)):
@@ -2690,9 +2770,7 @@ charge_config = {
2690
2770
  'export_limit': None, # maximum export power in kW
2691
2771
  'dc_ac_loss': 0.970, # loss converting battery DC power to AC grid power
2692
2772
  'pv_loss': 0.950, # loss converting PV power to DC battery charge power
2693
- 'ac_dc_loss': 0.960, # loss converting AC grid power to DC battery charge power
2694
- 'charge_loss': [0.975, 1.040], # loss in battery energy for each kWh added (based on residual_handling)
2695
- 'discharge_loss': [0.975, 0.975], # loss in battery energy for each kWh removed (based on residual_handling)
2773
+ 'ac_dc_loss': 0.963, # loss converting AC grid power to DC battery charge power
2696
2774
  'inverter_power': 101, # Inverter power consumption in W
2697
2775
  'bms_power': 50, # BMS power consumption in W
2698
2776
  'force_charge_power': 5.00, # charge power in kW when using force charge
@@ -2713,9 +2791,6 @@ charge_config = {
2713
2791
  'special_contingency': 33, # contingency for special days when consumption might be higher
2714
2792
  'special_days': ['12-25', '12-26', '01-01'],
2715
2793
  'full_charge': None, # day of month (1-28) to do full charge, or 'daily' or 'Mon', 'Tue' etc
2716
- 'derate_temp': 28, # BMS temperature when cold derating starts to be applied
2717
- 'derate_step': 5, # scale for derating factors in C
2718
- 'derating': [24, 15, 10, 2], # max charge current de-rating
2719
2794
  'data_wrap': 6, # data items to show per line
2720
2795
  'target_soc': None, # the target SoC for charging (over-rides calculated value)
2721
2796
  'shading': { # effect of shading on Solcast / forecast.solar
@@ -2740,7 +2815,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2740
2815
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2741
2816
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2742
2817
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2743
- global timed_strategy, steps_per_hour, base_time, storage, residual_handling
2818
+ global timed_strategy, steps_per_hour, base_time, storage, battery
2744
2819
  print(f"\n---------------- charge_needed ----------------")
2745
2820
  # validate parameters
2746
2821
  args = locals()
@@ -2820,7 +2895,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2820
2895
  time_to_end = times[0]['time_to_end']
2821
2896
  charge_time = times[0]['charge_time']
2822
2897
  # work out time window and times with clock changes
2823
- time_to_next = int(time_to_start)
2824
2898
  charge_today = (base_hour + time_to_start / steps_per_hour) < 24
2825
2899
  forecast_day = today if charge_today else tomorrow
2826
2900
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
@@ -2839,9 +2913,23 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2839
2913
  output(f"start_at = {start_at}, end_by = {end_by}, bat_hold = {bat_hold}")
2840
2914
  output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
2841
2915
  output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
2842
- output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
2916
+ output(f"full_charge = {full_charge}")
2917
+ if test_soc is not None:
2918
+ current_soc = test_soc
2919
+ capacity = 14.54
2920
+ residual = test_soc * capacity / 100
2921
+ bat_volt = 317.4
2922
+ bat_power = 0.0
2923
+ temperature = 30
2924
+ bms_charge_current = 15
2925
+ charge_loss = 1.080
2926
+ discharge_loss = 0.975
2927
+ bat_current = 0.0
2928
+ device_power = 6.0
2929
+ device_current = 35
2930
+ model = 'H1-6.0-E'
2931
+ else:
2843
2932
  # get device and battery info from inverter
2844
- if test_soc is None:
2845
2933
  get_battery()
2846
2934
  if battery is None or battery['status'] != 1:
2847
2935
  output(f"\nBattery status is not available")
@@ -2852,27 +2940,16 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2852
2940
  bat_current = battery['current']
2853
2941
  temperature = battery['temperature']
2854
2942
  residual = battery['residual']
2855
- if charge_config.get('capacity') is not None:
2856
- capacity = charge_config['capacity']
2857
- elif residual is not None and residual > 0.2 and current_soc is not None and current_soc > 1:
2858
- capacity = residual * 100 / current_soc
2859
- else:
2943
+ capacity = charge_config['capacity'] if charge_config.get('capacity') is not None else battery.get('capacity')
2944
+ if capacity is None:
2860
2945
  output(f"Battery capacity could not be estimated. Please add the parameter 'capacity=xx' in kWh")
2861
2946
  return None
2947
+ bms_charge_current = battery.get('charge_rate')
2948
+ charge_loss = battery['charge_loss'] if battery.get('charge_loss') is not None else 1.0
2949
+ discharge_loss = battery['discharge_loss'] if battery.get('discharge_loss') is not None else 1.0
2862
2950
  device_power = device.get('power')
2863
2951
  device_current = device.get('max_charge_current')
2864
2952
  model = device.get('deviceType')
2865
- else:
2866
- current_soc = test_soc
2867
- capacity = 14.54
2868
- residual = test_soc * capacity / 100
2869
- bat_volt = 317.4
2870
- bat_power = 0.0
2871
- temperature = 30
2872
- bat_current = 0.0
2873
- device_power = 6.0
2874
- device_current = 25
2875
- model = 'H1-6.0-E'
2876
2953
  min_soc = charge_config['min_soc'] if charge_config['min_soc'] is not None else 10
2877
2954
  max_soc = charge_config['max_soc'] if charge_config['max_soc'] is not None else 100
2878
2955
  volt_curve = charge_config['volt_curve']
@@ -2890,27 +2967,14 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2890
2967
  output(f" Min SoC: {min_soc}% ({reserve:.2f}kWh)")
2891
2968
  output(f" Current SoC: {current_soc}%")
2892
2969
  output(f" Max SoC: {max_soc}% ({capacity * max_soc / 100:.2f}kWh)")
2970
+ output(f" Max Charge: {bms_charge_current:.1f}A")
2893
2971
  output(f" Temperature: {temperature:.1f}°C")
2894
2972
  output(f" Resistance: {bat_resistance:.2f} ohms")
2895
2973
  output(f" Nominal OCV: {bat_ocv:.1f}V at {nominal_soc}% SoC")
2896
- # charge times are derated based on temperature
2974
+ # charge current may be derated based on temperature
2897
2975
  charge_current = device_current if charge_config['charge_current'] is None else charge_config['charge_current']
2898
- derate_temp = charge_config['derate_temp']
2899
- if temperature > 36:
2900
- output(f"\nHigh battery temperature may affect the charge rate")
2901
- elif round(temperature, 0) <= derate_temp:
2902
- output(f"\nLow battery temperature may affect the charge rate")
2903
- derating = charge_config['derating']
2904
- derate_step = charge_config['derate_step']
2905
- i = int((derate_temp - temperature) / (derate_step if derate_step is not None and derate_step > 0 else 1))
2906
- if derating is not None and type(derating) is list and i < len(derating):
2907
- derated_current = derating[i]
2908
- if derated_current < charge_current:
2909
- output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2910
- charge_current = derated_current
2911
- else:
2912
- bat_hold = 2
2913
- output(f" Full charge set")
2976
+ if charge_current > bms_charge_current:
2977
+ charge_current = bms_charge_current
2914
2978
  # inverter losses
2915
2979
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
2916
2980
  operating_loss = inverter_power / 1000
@@ -2924,21 +2988,24 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2924
2988
  force_charge_power = charge_config['force_charge_power'] if timed_mode > 1 and charge_config.get('force_charge_power') is not None else 100
2925
2989
  charge_power = min([(device_power - operating_loss) * ac_dc_loss, force_charge_power * ac_dc_loss, charge_limit])
2926
2990
  float_charge = (charge_config['float_current'] if charge_config.get('float_current') is not None else 4) * bat_ocv / 1000
2927
- charge_config['charge_limit'] = charge_limit
2928
- charge_config['charge_power'] = charge_power
2929
- charge_config['float_charge'] = float_charge
2930
- charge_loss = charge_config['charge_loss'][residual_handling - 1]
2991
+ pv_loss = charge_config['pv_loss']
2931
2992
  # work out discharge limit = max power coming from the battery before ac conversion losses
2932
2993
  dc_ac_loss = charge_config['dc_ac_loss']
2933
2994
  discharge_limit = device_power / dc_ac_loss
2934
2995
  discharge_current = device_current if charge_config['discharge_current'] is None else charge_config['discharge_current']
2935
2996
  discharge_power = discharge_current * bat_ocv / 1000
2936
2997
  discharge_limit = discharge_power if discharge_power < discharge_limit else discharge_limit
2937
- discharge_loss = charge_config['discharge_loss'][residual_handling - 1]
2938
2998
  # charging happens if generation exceeds export limit in feedin work mode
2939
2999
  export_power = device_power if charge_config['export_limit'] is None else charge_config['export_limit']
2940
3000
  export_limit = export_power / dc_ac_loss
2941
3001
  current_mode = get_work_mode()
3002
+ # set parameters for battery_timed()
3003
+ charge_config['charge_limit'] = charge_limit
3004
+ charge_config['charge_power'] = charge_power
3005
+ charge_config['float_charge'] = float_charge
3006
+ charge_config['charge_loss'] = charge_loss
3007
+ charge_config['discharge_loss'] = discharge_loss
3008
+ # display what we have
2942
3009
  output(f"\ncharge_config = {json.dumps(charge_config, indent=2)}", 3)
2943
3010
  output(f"\nDevice Info:")
2944
3011
  output(f" Model: {model}")
@@ -3048,8 +3115,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3048
3115
  output(f"\nSettings will not be updated when forecast is not available")
3049
3116
  update_settings = 0
3050
3117
  # produce time lines for charge, discharge and work mode
3051
- charge_timed = [min([charge_limit, x * charge_config['pv_loss']]) for x in generation_timed]
3052
- discharge_timed = [min([discharge_limit, x / dc_ac_loss]) + bms_loss for x in consumption_timed]
3118
+ charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
3119
+ discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
3053
3120
  work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
3054
3121
  for i in range(0, len(work_mode_timed)):
3055
3122
  # get work mode
@@ -3079,7 +3146,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3079
3146
  work_mode_timed[i]['discharge'] = discharge_timed[i]
3080
3147
  # build the battery residual if we don't add any charge and don't limit discharge at min_soc
3081
3148
  kwh_current = residual - (charge_timed[0] - discharge_timed[0]) * (hour_now % 1)
3082
- (bat_timed, kwh_min) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=capacity)
3149
+ (bat_timed, kwh_min) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=time_to_end, kwh_min=capacity)
3083
3150
  # work out what we need to add to stay above reserve and provide contingency or to hit target_soc
3084
3151
  contingency = charge_config['special_contingency'] if tomorrow[-5:] in charge_config['special_days'] else charge_config['contingency']
3085
3152
  contingency = contingency[quarter] if type(contingency) is list else contingency
@@ -3157,7 +3224,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3157
3224
  work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else max_soc
3158
3225
  work_mode_timed[i]['discharge'] *= (1-t)
3159
3226
  # rebuild the battery residual with the charge added and min_soc
3160
- (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3227
+ (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=start_timed)
3161
3228
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
3162
3229
  # show the results
3163
3230
  output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
@@ -3240,10 +3307,14 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3240
3307
  # CHARGE_COMPARE - load saved data and compare with actual
3241
3308
  ##################################################################################################
3242
3309
 
3243
- def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3310
+ def charge_compare(save=None, v=None, show_data=1, show_plot=3, d=None):
3244
3311
  global charge_config, storage
3312
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3313
+ yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3245
3314
  if save is None and charge_config.get('save') is not None:
3246
- save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
3315
+ save = charge_config.get('save').replace('###', yesterday)
3316
+ if not os.path.exists(storage + save):
3317
+ save = None
3247
3318
  if save is None:
3248
3319
  print(f"** charge_compare(): please provide a saved file to load")
3249
3320
  return
@@ -3386,28 +3457,35 @@ def bat_count(cell_count):
3386
3457
  battery_info_app_key = "aug938dqt5cbqhvq69ixc4v39q6wtw"
3387
3458
 
3388
3459
  # show information about the current state of the batteries
3389
- def battery_info(log=0, plot=1, count=None, info=1):
3460
+ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3390
3461
  global debug_setting, battery_info_app_key
3391
- output_spool(battery_info_app_key)
3392
- bat = get_battery(info=info)
3393
3462
  if bat is None:
3394
- output_close()
3463
+ bats = get_batteries(info=info)
3464
+ if bats is None:
3465
+ return None
3466
+ for i in range(0, len(bats)):
3467
+ output(f"\n----------------------- BMS {i+1} -----------------------")
3468
+ battery_info(log=log, plot=plot, count=count, info=info, bat=bats[i])
3395
3469
  return None
3470
+ output_spool(battery_info_app_key)
3396
3471
  nbat = None
3397
- if bat.get('info') is not None:
3398
- for b in bat['info']:
3399
- output(f"\nSN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3400
- nbat = 0
3401
- for s in b['slaveBatteries']:
3402
- nbat += 1
3403
- output(f"SN {s['sn']}, {s['batType']}, Version {s['version']} (Battery {nbat})")
3472
+ if info == 1 and bat.get('info') is not None:
3473
+ b = bat['info']
3474
+ output(f"SN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3475
+ nbat = 0
3476
+ for s in b['slaveBatteries']:
3477
+ nbat += 1
3478
+ output(f"SN {s['sn']}, {s['batType']}, Version {s['version']} (Battery {nbat})")
3479
+ output()
3480
+ rated_capacity = bat.get('ratedCapacity')
3481
+ bat_soh = bat.get('soh')
3404
3482
  bat_volt = bat['volt']
3405
3483
  current_soc = bat['soc']
3406
3484
  residual = bat['residual']
3407
3485
  bat_current = bat['current']
3408
3486
  bat_power = bat['power']
3409
3487
  bms_temperature = bat['temperature']
3410
- capacity = residual / current_soc * 100
3488
+ capacity = bat['capacity']
3411
3489
  cell_volts = get_cell_volts()
3412
3490
  if cell_volts is None:
3413
3491
  output_close()
@@ -3451,7 +3529,10 @@ def battery_info(log=0, plot=1, count=None, info=1):
3451
3529
  for v in cell_temps:
3452
3530
  s +=f",{v:.0f}"
3453
3531
  return s
3454
- output(f"\nCurrent SoC: {current_soc}%")
3532
+ if rated_capacity is not None:
3533
+ output(f"Rated Capacity: {rated_capacity / 1000:.2f}kWh")
3534
+ output(f"SoH: {bat_soh}%")
3535
+ output(f"Current SoC: {current_soc}%")
3455
3536
  output(f"Capacity: {capacity:.2f}kWh")
3456
3537
  output(f"Residual: {residual:.2f}kWh")
3457
3538
  output(f"InvBatVolt: {bat_volt:.1f}V")
@@ -3462,6 +3543,7 @@ def battery_info(log=0, plot=1, count=None, info=1):
3462
3543
  output(f"Cell Volts: {avg(cell_volts):.3f}V average, {max(cell_volts):.3f}V maximum, {min(cell_volts):.3f}V minimum")
3463
3544
  output(f"Cell Imbalance: {imbalance(cell_volts):.2f}%:")
3464
3545
  output(f"BMS Temperature: {bms_temperature:.1f}°C")
3546
+ output(f"BMS Charge Rate: {bat.get('charge_rate'):.1f}A (estimated)")
3465
3547
  output(f"Battery Temperature: {avg(cell_temps):.1f}°C average, {max(cell_temps):.1f}°C maximum, {min(cell_temps):.1f}°C minimum")
3466
3548
  output(f"\nInfo by battery:")
3467
3549
  for i in range(0, nbat):
foxesscloud/openapi.py CHANGED
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 02 October 2024
4
+ Updated: 09 October 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.9"
13
+ version = "2.6.1"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -469,6 +469,7 @@ def get_device(sn=None):
469
469
  return None
470
470
  device = result
471
471
  battery = None
472
+ batteries = None
472
473
  battery_settings = None
473
474
  schedule = None
474
475
  get_flag()
@@ -538,6 +539,7 @@ def get_generation(update=1):
538
539
  ##################################################################################################
539
540
 
540
541
  battery = None
542
+ batteries = None
541
543
  battery_settings = None
542
544
  battery_vars = ['SoC', 'invBatVolt', 'invBatCurrent', 'invBatPower', 'batTemperature', 'ResidualEnergy' ]
543
545
  battery_data = ['soc', 'volt', 'current', 'power', 'temperature', 'residual']
@@ -545,8 +547,24 @@ battery_data = ['soc', 'volt', 'current', 'power', 'temperature', 'residual']
545
547
  # 1 = returns Residual Energy. 2 = resturns Residual Capacity
546
548
  residual_handling = 1
547
549
 
548
- def get_battery(v = None, info=0):
549
- global device_sn, battery, debug_setting, residual_handling
550
+ # charge rates based on residual_handling
551
+ battery_params = {
552
+ # cell temp -5 0 5 10 15 20 25 30 35 40 45 50 55
553
+ # bms temp 5 10 15 20 25 30 35 40 45 50 55 60 65
554
+ 1: {'table': [ 0, 2, 10, 15, 25, 50, 50, 50, 50, 50, 30, 20, 0],
555
+ 'step': 5,
556
+ 'offset': 5,
557
+ 'charge_loss': 0.975,
558
+ 'discharge_loss': 0.975},
559
+ 2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
560
+ 'step': 5,
561
+ 'offset': 5,
562
+ 'charge_loss': 1.080,
563
+ 'discharge_loss': 0.975},
564
+ }
565
+
566
+ def get_battery(info=0, v=None):
567
+ global device_sn, battery, debug_setting, residual_handling, battery_params
550
568
  if get_device() is None:
551
569
  return None
552
570
  output(f"getting battery", 2)
@@ -560,12 +578,30 @@ def get_battery(v = None, info=0):
560
578
  if residual_handling == 2:
561
579
  capacity = battery.get('residual')
562
580
  soc = battery.get('soc')
563
- battery['residual'] = capacity * soc / 100 if capacity is not None and soc is not None else capacity
564
- if info == 1:
565
- output(f"** get_battery(): info is not available via Open API")
581
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
582
+ else:
583
+ residual = battery.get('residual')
584
+ soc = battery.get('soc')
585
+ capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
586
+ battery['capacity'] = round(capacity, 3)
587
+ battery['residual'] = round(residual, 3)
566
588
  battery['status'] = 1
589
+ battery['charge_rate'] = 50
590
+ params = battery_params[residual_handling]
591
+ battery['charge_loss'] = params['charge_loss']
592
+ battery['discharge_loss'] = params['discharge_loss']
593
+ if battery.get('temperature') is not None:
594
+ battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
567
595
  return battery
568
596
 
597
+ def get_batteries(info=0):
598
+ global battery, batteries
599
+ get_battery(info=info)
600
+ battery['ratedCapacity'] = None
601
+ battery['soh'] = None
602
+ batteries = [battery]
603
+ return batteries
604
+
569
605
  ##################################################################################################
570
606
  # get charge times and save to battery_settings
571
607
  ##################################################################################################
@@ -2408,7 +2444,7 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2408
2444
  profile = []
2409
2445
  h = base_hour - time_offset
2410
2446
  while h < 0:
2411
- profile.append(0.0)
2447
+ profile.append(None)
2412
2448
  h += 1 / steps_per_hour
2413
2449
  while h < 48:
2414
2450
  day = today if h < 24 else tomorrow
@@ -2418,10 +2454,10 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2418
2454
  value = forecast.daily[day]['hourly'].get(int(h % 24))
2419
2455
  else:
2420
2456
  value = forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2))
2421
- profile.append(c_float(value))
2457
+ profile.append(value)
2422
2458
  h += 1 / steps_per_hour
2423
2459
  while len(profile) < run_time:
2424
- profile.append(0.0)
2460
+ profile.append(None)
2425
2461
  return profile[:run_time]
2426
2462
 
2427
2463
  # build the timed work mode profile from the tariff strategy:
@@ -2459,11 +2495,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2459
2495
  # build the timed battery residual from the charge / discharge, work mode and min_soc
2460
2496
  # note: all power values are as measured at the inverter battery connection
2461
2497
  def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=None, reserve_drain=None):
2462
- global charge_config, steps_per_hour, residual_handling
2498
+ global charge_config, steps_per_hour
2463
2499
  allowed_drain = charge_config['allowed_drain'] if charge_config.get('allowed_drain') is not None else 4
2464
2500
  bms_loss = (charge_config['bms_power'] / 1000 if charge_config.get('bms_power') is not None else 0.05)
2465
- charge_loss = charge_config['charge_loss'][residual_handling - 1]
2466
- discharge_loss = charge_config['discharge_loss'][residual_handling - 1]
2501
+ charge_loss = charge_config['charge_loss']
2502
+ discharge_loss = charge_config['discharge_loss']
2467
2503
  charge_limit = charge_config['charge_limit']
2468
2504
  float_charge = charge_config['float_charge']
2469
2505
  for i in range(0, len(work_mode_timed)):
@@ -2553,9 +2589,7 @@ charge_config = {
2553
2589
  'export_limit': None, # maximum export power in kW
2554
2590
  'dc_ac_loss': 0.97, # loss converting battery DC power to AC grid power
2555
2591
  'pv_loss': 0.95, # loss converting PV power to DC battery charge power
2556
- 'ac_dc_loss': 0.962, # loss converting AC grid power to DC battery charge power
2557
- 'charge_loss': [0.975, 1.040], # loss in battery energy for each kWh added based on residual_handling
2558
- 'discharge_loss': [0.975, 0.975], # loss in battery energy for each kWh removed based on residual_handling
2592
+ 'ac_dc_loss': 0.963, # loss converting AC grid power to DC battery charge power
2559
2593
  'inverter_power': 101, # Inverter power consumption in W
2560
2594
  'bms_power': 50, # BMS power consumption in W
2561
2595
  'force_charge_power': 5.00, # charge power in kW when using force charge
@@ -2576,9 +2610,6 @@ charge_config = {
2576
2610
  'special_contingency': 33, # contingency for special days when consumption might be higher
2577
2611
  'special_days': ['12-25', '12-26', '01-01'],
2578
2612
  'full_charge': None, # day of month (1-28) to do full charge, or 'daily' or 'Mon', 'Tue' etc
2579
- 'derate_temp': 25, # BMS temperature when cold derating starts to be applied
2580
- 'derate_step': 5, # scale for derating factors in C
2581
- 'derating': [24, 15, 10, 2], # max charge current e.g. 5C step = 25C, 20C, 15C, 10C
2582
2613
  'data_wrap': 6, # data items to show per line
2583
2614
  'target_soc': None, # the target SoC for charging (over-rides calculated value)
2584
2615
  'shading': { # effect of shading on Solcast / forecast.solar
@@ -2603,7 +2634,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2603
2634
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2604
2635
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2605
2636
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2606
- global timed_strategy, steps_per_hour, base_time, storage, residual_handling
2637
+ global timed_strategy, steps_per_hour, base_time, storage, battery
2607
2638
  print(f"\n---------------- charge_needed ----------------")
2608
2639
  # validate parameters
2609
2640
  args = locals()
@@ -2683,7 +2714,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2683
2714
  time_to_end = times[0]['time_to_end']
2684
2715
  charge_time = times[0]['charge_time']
2685
2716
  # work out time window and times with clock changes
2686
- time_to_next = int(time_to_start)
2687
2717
  charge_today = (base_hour + time_to_start / steps_per_hour) < 24
2688
2718
  forecast_day = today if charge_today else tomorrow
2689
2719
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
@@ -2702,9 +2732,23 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2702
2732
  output(f"start_at = {start_at}, end_by = {end_by}, force_charge = {force_charge}")
2703
2733
  output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
2704
2734
  output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
2705
- output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
2735
+ output(f"full_charge = {full_charge}")
2736
+ if test_soc is not None:
2737
+ current_soc = test_soc
2738
+ capacity = 14.54
2739
+ residual = test_soc * capacity / 100
2740
+ bat_volt = 317.4
2741
+ bat_power = 0.0
2742
+ temperature = 30
2743
+ bms_charge_current = 15
2744
+ charge_loss = 1.080
2745
+ discharge_loss = 0.975
2746
+ bat_current = 0.0
2747
+ device_power = 6.0
2748
+ device_current = 35
2749
+ model = 'H1-6.0-E'
2750
+ else:
2706
2751
  # get device and battery info from inverter
2707
- if test_soc is None:
2708
2752
  get_battery()
2709
2753
  if battery is None or battery['status'] != 1:
2710
2754
  output(f"\nBattery status is not available")
@@ -2715,27 +2759,16 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2715
2759
  bat_current = battery['current']
2716
2760
  temperature = battery['temperature']
2717
2761
  residual = battery['residual']
2718
- if charge_config.get('capacity') is not None:
2719
- capacity = charge_config['capacity']
2720
- elif residual is not None and residual > 0.2 and current_soc is not None and current_soc > 1:
2721
- capacity = residual * 100 / current_soc
2722
- else:
2762
+ capacity = charge_config['capacity'] if charge_config.get('capacity') is not None else battery.get('capacity')
2763
+ if capacity is None:
2723
2764
  output(f"Battery capacity could not be estimated. Please add the parameter 'capacity=xx' in kWh")
2724
2765
  return None
2766
+ bms_charge_current = battery.get('charge_rate')
2767
+ charge_loss = battery['charge_loss'] if battery.get('charge_loss') is not None else 1.0
2768
+ discharge_loss = battery['discharge_loss'] if battery.get('discharge_loss') is not None else 1.0
2725
2769
  device_power = device.get('power')
2726
2770
  device_current = device.get('max_charge_current')
2727
2771
  model = device.get('deviceType')
2728
- else:
2729
- current_soc = test_soc
2730
- capacity = 14.54
2731
- residual = test_soc * capacity / 100
2732
- bat_volt = 317.4
2733
- bat_power = 0.0
2734
- temperature = 30
2735
- bat_current = 0.0
2736
- device_power = 6.0
2737
- device_current = 25
2738
- model = 'H1-6.0-E'
2739
2772
  min_soc = charge_config['min_soc'] if charge_config['min_soc'] is not None else 10
2740
2773
  max_soc = charge_config['max_soc'] if charge_config['max_soc'] is not None else 100
2741
2774
  volt_curve = charge_config['volt_curve']
@@ -2754,26 +2787,13 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2754
2787
  output(f" Current SoC: {current_soc}%")
2755
2788
  output(f" Max SoC: {max_soc}% ({capacity * max_soc / 100:.2f}kWh)")
2756
2789
  output(f" Temperature: {temperature:.1f}°C")
2790
+ output(f" Max Charge: {bms_charge_current:.1f}A")
2757
2791
  output(f" Resistance: {bat_resistance:.2f} ohms")
2758
2792
  output(f" Nominal OCV: {bat_ocv:.1f}V at {nominal_soc}% SoC")
2759
- # charge times are derated based on temperature
2793
+ # charge current may be derated based on temperature
2760
2794
  charge_current = device_current if charge_config['charge_current'] is None else charge_config['charge_current']
2761
- derate_temp = charge_config['derate_temp']
2762
- if temperature > 36:
2763
- output(f"\nHigh battery temperature may affect the charge rate")
2764
- elif round(temperature, 0) <= derate_temp:
2765
- output(f"\nLow battery temperature may affect the charge rate")
2766
- derating = charge_config['derating']
2767
- derate_step = charge_config['derate_step']
2768
- i = int((derate_temp - temperature) / (derate_step if derate_step is not None and derate_step > 0 else 1))
2769
- if derating is not None and type(derating) is list and i < len(derating):
2770
- derated_current = derating[i]
2771
- if derated_current < charge_current:
2772
- output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2773
- charge_current = derated_current
2774
- else:
2775
- bat_hold = 2
2776
- output(f" Full charge set")
2795
+ if charge_current > bms_charge_current:
2796
+ charge_current = bms_charge_current
2777
2797
  # inverter losses
2778
2798
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
2779
2799
  operating_loss = inverter_power / 1000
@@ -2787,21 +2807,24 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2787
2807
  force_charge_power = charge_config['force_charge_power'] if timed_mode > 1 and charge_config.get('force_charge_power') is not None else 100
2788
2808
  charge_power = min([(device_power - operating_loss) * ac_dc_loss, force_charge_power * ac_dc_loss, charge_limit])
2789
2809
  float_charge = (charge_config['float_current'] if charge_config.get('float_current') is not None else 4) * bat_ocv / 1000
2790
- charge_config['charge_limit'] = charge_limit
2791
- charge_config['charge_power'] = charge_power
2792
- charge_config['float_charge'] = float_charge
2793
- charge_loss = charge_config['charge_loss'][residual_handling - 1]
2810
+ pv_loss = charge_config['pv_loss']
2794
2811
  # work out discharge limit = max power coming from the battery before ac conversion losses
2795
2812
  dc_ac_loss = charge_config['dc_ac_loss']
2796
2813
  discharge_limit = device_power / dc_ac_loss
2797
2814
  discharge_current = device_current if charge_config['discharge_current'] is None else charge_config['discharge_current']
2798
2815
  discharge_power = discharge_current * bat_ocv / 1000
2799
2816
  discharge_limit = discharge_power if discharge_power < discharge_limit else discharge_limit
2800
- discharge_loss = charge_config['discharge_loss'][residual_handling - 1]
2801
2817
  # charging happens if generation exceeds export limit in feedin work mode
2802
2818
  export_power = device_power if charge_config['export_limit'] is None else charge_config['export_limit']
2803
2819
  export_limit = export_power / dc_ac_loss
2804
2820
  current_mode = get_work_mode()
2821
+ # set parameters for battery_timed()
2822
+ charge_config['charge_limit'] = charge_limit
2823
+ charge_config['charge_power'] = charge_power
2824
+ charge_config['float_charge'] = float_charge
2825
+ charge_config['charge_loss'] = charge_loss
2826
+ charge_config['discharge_loss'] = discharge_loss
2827
+ # display what we have
2805
2828
  output(f"\ncharge_config = {json.dumps(charge_config, indent=2)}", 3)
2806
2829
  output(f"\nDevice Info:")
2807
2830
  output(f" Model: {model}")
@@ -2910,8 +2933,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2910
2933
  output(f"\nSettings will not be updated when forecast is not available")
2911
2934
  update_settings = 0
2912
2935
  # produce time lines for charge, discharge and work mode
2913
- charge_timed = [min([charge_limit, x * charge_config['pv_loss']]) for x in generation_timed]
2914
- discharge_timed = [min([discharge_limit, x / dc_ac_loss]) + bms_loss for x in consumption_timed]
2936
+ charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
2937
+ discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
2915
2938
  work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
2916
2939
  for i in range(0, len(work_mode_timed)):
2917
2940
  # get work mode
@@ -2941,7 +2964,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2941
2964
  work_mode_timed[i]['discharge'] = discharge_timed[i]
2942
2965
  # build the battery residual if we don't add any charge and don't limit discharge at min_soc
2943
2966
  kwh_current = residual - (charge_timed[0] - discharge_timed[0]) * (hour_now % 1)
2944
- (bat_timed, kwh_min) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=capacity)
2967
+ (bat_timed, kwh_min) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=time_to_end, kwh_min=capacity)
2945
2968
  # work out what we need to add to stay above reserve and provide contingency or to hit target_soc
2946
2969
  contingency = charge_config['special_contingency'] if tomorrow[-5:] in charge_config['special_days'] else charge_config['contingency']
2947
2970
  contingency = contingency[quarter] if type(contingency) is list else contingency
@@ -3019,7 +3042,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3019
3042
  work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else max_soc
3020
3043
  work_mode_timed[i]['discharge'] *= (1-t)
3021
3044
  # rebuild the battery residual with any charge added and min_soc
3022
- (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3045
+ (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=start_timed)
3023
3046
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
3024
3047
  # show the results
3025
3048
  output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
@@ -3103,8 +3126,12 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3103
3126
 
3104
3127
  def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3105
3128
  global charge_config, storage
3129
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3130
+ yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3106
3131
  if save is None and charge_config.get('save') is not None:
3107
- save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
3132
+ save = charge_config.get('save').replace('###', yesterday)
3133
+ if not os.path.exists(storage + save):
3134
+ save = None
3108
3135
  if save is None:
3109
3136
  print(f"** charge_compare(): please provide a saved file to load")
3110
3137
  return
@@ -3246,28 +3273,35 @@ def bat_count(cell_count):
3246
3273
  battery_info_app_key = "aug938dqt5cbqhvq69ixc4v39q6wtw"
3247
3274
 
3248
3275
  # show information about the current state of the batteries
3249
- def battery_info(log=0, plot=1, count=None, info=1):
3276
+ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3250
3277
  global debug_setting, battery_info_app_key
3251
- output_spool(battery_info_app_key)
3252
- bat = get_battery(info=info)
3253
3278
  if bat is None:
3254
- output_close()
3279
+ bats = get_batteries(info=info)
3280
+ if bats is None:
3281
+ return None
3282
+ for i in range(0, len(bats)):
3283
+ output(f"\n----------------------- BMS {i+1} -----------------------")
3284
+ battery_info(log=log, plot=plot, count=count, info=info, bat=bats[i])
3255
3285
  return None
3286
+ output_spool(battery_info_app_key)
3256
3287
  nbat = None
3257
- if bat.get('info') is not None:
3258
- for b in bat['info']:
3259
- output(f"\nSN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3260
- nbat = 0
3261
- for s in b['slaveBatteries']:
3262
- nbat += 1
3263
- output(f"SN {s['sn']}, {s['batType']}, Version {s['version']} (Battery {nbat})")
3288
+ if info == 1 and bat.get('info') is not None:
3289
+ b = bat['info']
3290
+ output(f"SN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3291
+ nbat = 0
3292
+ for s in b['slaveBatteries']:
3293
+ nbat += 1
3294
+ output(f"SN {s['sn']}, {s['batType']}, Version {s['version']} (Battery {nbat})")
3295
+ output()
3296
+ rated_capacity = bat.get('ratedCapacity')
3297
+ bat_soh = bat.get('soh')
3264
3298
  bat_volt = bat['volt']
3265
3299
  current_soc = bat['soc']
3266
3300
  residual = bat['residual']
3267
3301
  bat_current = bat['current']
3268
3302
  bat_power = bat['power']
3269
3303
  bms_temperature = bat['temperature']
3270
- capacity = residual / current_soc * 100
3304
+ capacity = bat['capacity']
3271
3305
  cell_volts = get_cell_volts()
3272
3306
  if cell_volts is None:
3273
3307
  output_close()
@@ -3311,7 +3345,10 @@ def battery_info(log=0, plot=1, count=None, info=1):
3311
3345
  for v in cell_temps:
3312
3346
  s +=f",{v:.0f}"
3313
3347
  return s
3314
- output(f"\nCurrent SoC: {current_soc}%")
3348
+ if rated_capacity is not None:
3349
+ output(f"Rated Capacity: {rated_capacity / 1000:.2f}kWh")
3350
+ output(f"SoH: {bat_soh}%")
3351
+ output(f"Current SoC: {current_soc}%")
3315
3352
  output(f"Capacity: {capacity:.2f}kWh")
3316
3353
  output(f"Residual: {residual:.2f}kWh")
3317
3354
  output(f"InvBatVolt: {bat_volt:.1f}V")
@@ -3322,6 +3359,7 @@ def battery_info(log=0, plot=1, count=None, info=1):
3322
3359
  output(f"Cell Volts: {avg(cell_volts):.3f}V average, {max(cell_volts):.3f}V maximum, {min(cell_volts):.3f}V minimum")
3323
3360
  output(f"Cell Imbalance: {imbalance(cell_volts):.2f}%:")
3324
3361
  output(f"BMS Temperature: {bms_temperature:.1f}°C")
3362
+ output(f"BMS Charge Rate: {bat.get('charge_rate'):.1f}A (estimated)")
3325
3363
  output(f"Battery Temperature: {avg(cell_temps):.1f}°C average, {max(cell_temps):.1f}°C maximum, {min(cell_temps):.1f}°C minimum")
3326
3364
  output(f"\nInfo by battery:")
3327
3365
  for i in range(0, nbat):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.5.9
3
+ Version: 2.6.1
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
@@ -105,6 +105,7 @@ Once an inverter is selected, you can make other calls to get information:
105
105
  ```
106
106
  f.get_generation()
107
107
  f.get_battery()
108
+ f.get_batteries()
108
109
  f.get_settings()
109
110
  f.get_charge()
110
111
  f.get_min()
@@ -116,7 +117,12 @@ Each of these calls will return a dictionary or list containing the relevant inf
116
117
 
117
118
  get_generation() will return the latest generation information for the device. The results are also stored in f.device as 'generationToday', 'generationMonth' and 'generationTotal'.
118
119
 
119
- get_battery() returns the current battery status, including 'soc', 'volt', 'current', 'power', 'temperature' and 'residual'. The result also updates f.battery.
120
+ get_battery() / get_batteries() returns the current battery status, including 'soc', 'volt', 'current', 'power', 'temperature' and 'residual'. The result also updates f.battery / f.batteries.
121
+ get_batteries() returns multiple batteries (if available) as a list. get_battery() returns the first battery. Additional battery attributes include:
122
+ + 'capacity': the estimated battery capacity, derrived from 'residual' and 'soc'
123
+ + 'charge_rate': the estimated BMS charge rate available, based on the current 'temperature' of the BMS
124
+ + 'charge_loss': the ratio of the kWh added to the battery for each kWh applied during charging
125
+ + 'discharge_loss': the ratio of the kWh available for each kWh removed from the battery during during discharging
120
126
 
121
127
  get_settings() will return the battery settings and is equivalent to get_charge() and get_min(). The results are stored in f.battery_settings. The settings include minSoc, minSocOnGrid, enable charge from grid and the charge times.
122
128
 
@@ -371,8 +377,6 @@ export_limit: None # maximum export power in kW. None uses the inver
371
377
  dc_ac_loss: 0.970 # loss converting battery DC power to AC grid power
372
378
  pv_loss: 0.950 # loss converting PV power to DC battery charge power
373
379
  ac_dc_loss: 0.960 # loss converting AC grid power to DC battery charge power
374
- charge_loss: [0.975, 1.040] # loss in battery energy for each kWh added (based on residual_handling)
375
- discharge_loss: [0.975, 0.975] # loss in battery energy for each kWh removed (based on residual_handling)
376
380
  inverter_power: None # inverter power consumption in W (dynamically set)
377
381
  bms_power: 50 # BMS power consumption in W
378
382
  force_charge_power: 5.00 # power used when Force Charge is scheduled
@@ -395,9 +399,6 @@ timed_mode: 0 # 0 = None, 1 = use timed work mode, 2 = strategy
395
399
  special_contingency: 30 # contingency for special days when consumption might be higher
396
400
  special_days: ['12-25', '12-26', '01-01']
397
401
  full_charge: None # day of month (1-28) to do full charge or 'daily' or day of week: 'Mon', 'Tue' etc
398
- derate_temp: 28 # battery temperature in C when derating charge current is applied
399
- derate_step: 5 # step size for derating e.g. 21, 16, 11
400
- derating: [24, 15, 10, 2] # derated charge current for each temperature step e.g. 28C, 23C, 18C, 13C
401
402
  force: 1 # 1 = disable strategy periods when setting charge. 0 = fail if strategy period has been set.
402
403
  data_wrap: 6 # data items to show per line
403
404
  target_soc: None # target soc for charging (over-rides calculated value)
@@ -786,6 +787,16 @@ This setting can be:
786
787
 
787
788
  # Version Info
788
789
 
790
+ 2.6.1<br>
791
+ Fix problem where battery discharges below min_soc while waiting for charging to start.
792
+ Update calibration for Force Charge with BMS 1.014 and later.
793
+ Add get_batteries() to return a list of BMS and batteries where inverters support more than 1 BMS.
794
+ Update battery_info() to support multiple BMS.
795
+ Add rated capacity and SoH to battery info if available.
796
+
797
+ 2.6.0<br>
798
+ Rework charge de-rating with temperature, losses and other info provided by get_battery() to take new BMS behaviour into account.
799
+
789
800
  2.5.9<br>
790
801
  Change loss parameters to separate AC/DC, DC/AC conversion losses and battery charge / discharge losses.
791
802
  Update charge calibration for new BMS firmware.
@@ -0,0 +1,7 @@
1
+ foxesscloud/foxesscloud.py,sha256=DHP0NqeFXj6rFIdwyTp7U935YBhJcFG2YMwaHGRBaF4,215964
2
+ foxesscloud/openapi.py,sha256=Rp1HdtBLpMt1iK5psUkfnCSzw7vmmABs_SkOZ4G2V0M,207271
3
+ foxesscloud-2.6.1.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
+ foxesscloud-2.6.1.dist-info/METADATA,sha256=aYpWZtJnpnzmEjkB5ip7fCW4Qx9Ra5WOL-5uFCYHemQ,57452
5
+ foxesscloud-2.6.1.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
+ foxesscloud-2.6.1.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
+ foxesscloud-2.6.1.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- foxesscloud/foxesscloud.py,sha256=AkyOh5uXAj-J318K6Tgr6J8uLnm-ZkL-lQcAc5L0ehg,212563
2
- foxesscloud/openapi.py,sha256=E_JTEiFcbuRBQn9zwAwpY9IWJdglS6xylE8PH_qWLHg,206235
3
- foxesscloud-2.5.9.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
- foxesscloud-2.5.9.dist-info/METADATA,sha256=dwM5NwG8HIyJ_ruXv0zZh6UGgATnRHcMWP3cUJDiXD8,56827
5
- foxesscloud-2.5.9.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
- foxesscloud-2.5.9.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
- foxesscloud-2.5.9.dist-info/RECORD,,