foxesscloud 2.5.8__py3-none-any.whl → 2.6.0__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: 01 October 2024
4
+ Updated: 05 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.0"
13
+ version = "1.7.2"
14
14
  print(f"FoxESS-Cloud version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -576,10 +576,28 @@ def get_firmware():
576
576
 
577
577
  battery = None
578
578
  battery_settings = None
579
- residual_handling = 1 # set to 2 if Residual returns current capacity
580
579
 
581
- def get_battery(info=0):
582
- global token, device_id, battery, debug_setting, messages
580
+ # 1 = Residual Energy, 2 = Residual Capacity
581
+ residual_handling = 1
582
+
583
+ # charge rates based on residual_handling
584
+ battery_params = {
585
+ # cell temp -5 0 5 10 15 20 25 30 35 40 45 50 55
586
+ # bms temp 5 10 15 20 25 30 35 40 45 50 55 60 65
587
+ 1: {'table': [ 0, 2, 10, 15, 25, 50, 50, 50, 50, 50, 30, 20, 0],
588
+ 'step': 5,
589
+ 'offset': 5,
590
+ 'charge_loss': 0.975,
591
+ 'discharge_loss': 0.975},
592
+ 2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
593
+ 'step': 5,
594
+ 'offset': 5,
595
+ 'charge_loss': 1.040,
596
+ 'discharge_loss': 0.975},
597
+ }
598
+
599
+ def get_battery(info=1):
600
+ global token, device_id, battery, debug_setting, messages, residual_handling, battery_params
583
601
  if get_device() is None:
584
602
  return None
585
603
  output(f"getting battery", 2)
@@ -593,14 +611,11 @@ def get_battery(info=0):
593
611
  errno = response.json().get('errno')
594
612
  output(f"** get_battery(), no result data, {errno_message(errno)}")
595
613
  return None
614
+ saved_info = battery['info'] if battery is not None and battery.get('info') is not None else None
596
615
  battery = result
597
- if battery.get('residual') is not None:
598
- battery['residual'] /=1000
599
- if residual_handling == 2:
600
- capacity = battery.get('residual')
601
- soc = battery.get('soc')
602
- battery['residual'] = capacity * soc / 100 if capacity is not None and soc is not None else capacity
603
- if info == 1:
616
+ if saved_info is not None:
617
+ battery['info'] = saved_info
618
+ elif info == 1:
604
619
  response = signed_get(path="/generic/v0/device/battery/list", params=params)
605
620
  if response.status_code != 200:
606
621
  output(f"** get_battery().info got response code {response.status_code}: {response.reason}")
@@ -611,6 +626,26 @@ def get_battery(info=0):
611
626
  output(f"** get_battery().info, no result data, {errno_message(errno)}")
612
627
  else:
613
628
  battery['info'] = result['batteries']
629
+ if battery['info'][0]['masterVersion'] >= '1.014':
630
+ residual_handling = 2
631
+ if battery.get('residual') is not None:
632
+ battery['residual'] /= 1000
633
+ if residual_handling == 2:
634
+ capacity = battery.get('residual')
635
+ soc = battery.get('soc')
636
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
637
+ else:
638
+ residual = battery.get('residual')
639
+ soc = battery.get('soc')
640
+ capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
641
+ battery['capacity'] = round(capacity, 3)
642
+ battery['residual'] = round(residual, 3)
643
+ battery['charge_rate'] = 50
644
+ params = battery_params[residual_handling]
645
+ battery['charge_loss'] = params['charge_loss']
646
+ battery['discharge_loss'] = params['discharge_loss']
647
+ if battery.get('temperature') is not None:
648
+ battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
614
649
  return battery
615
650
 
616
651
  ##################################################################################################
@@ -2543,7 +2578,7 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2543
2578
  profile = []
2544
2579
  h = base_hour - time_offset
2545
2580
  while h < 0:
2546
- profile.append(0.0)
2581
+ profile.append(None)
2547
2582
  h += 1 / steps_per_hour
2548
2583
  while h < 48:
2549
2584
  day = today if h < 24 else tomorrow
@@ -2553,10 +2588,10 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2553
2588
  value = forecast.daily[day]['hourly'].get(int(h % 24))
2554
2589
  else:
2555
2590
  value = forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2))
2556
- profile.append(c_float(value))
2591
+ profile.append(value)
2557
2592
  h += 1 / steps_per_hour
2558
2593
  while len(profile) < run_time:
2559
- profile.append(0.0)
2594
+ profile.append(None)
2560
2595
  return profile[:run_time]
2561
2596
 
2562
2597
  # build the timed work mode profile from the tariff strategy:
@@ -2592,23 +2627,23 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2592
2627
  return work_mode_timed
2593
2628
 
2594
2629
  # build the timed battery residual from the charge / discharge, work mode and min_soc
2630
+ # all power values are as measured at the inverter battery connection
2595
2631
  def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=None, reserve_drain=None):
2596
2632
  global charge_config, steps_per_hour
2597
- bat_timed = []
2598
2633
  allowed_drain = charge_config['allowed_drain'] if charge_config.get('allowed_drain') is not None else 4
2599
2634
  bms_loss = (charge_config['bms_power'] / 1000 if charge_config.get('bms_power') is not None else 0.05)
2600
2635
  charge_loss = charge_config['charge_loss']
2636
+ discharge_loss = charge_config['discharge_loss']
2601
2637
  charge_limit = charge_config['charge_limit']
2602
2638
  float_charge = charge_config['float_charge']
2603
2639
  for i in range(0, len(work_mode_timed)):
2604
- bat_timed.append(kwh_current)
2605
2640
  w = work_mode_timed[i]
2606
2641
  w['kwh'] = kwh_current
2607
2642
  max_now = w['max_soc'] * capacity / 100
2608
2643
  if kwh_current < max_now and w['charge'] > 0.0:
2609
2644
  kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
2610
2645
  kwh_current = max_now if kwh_current > max_now else kwh_current
2611
- kwh_current += (w['pv'] - w['discharge']) / charge_loss / steps_per_hour
2646
+ kwh_current += (w['pv'] * charge_loss - w['discharge'] / discharge_loss) / steps_per_hour
2612
2647
  if kwh_current > capacity:
2613
2648
  # battery is full
2614
2649
  kwh_current = capacity
@@ -2629,16 +2664,16 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2629
2664
  reserve_drain = reserve_now
2630
2665
  if kwh_min is not None and kwh_current < kwh_min and i >= time_to_next: # track minimum without charge
2631
2666
  kwh_min = kwh_current
2632
- return (bat_timed, kwh_min)
2667
+ return ([work_mode_timed[i]['kwh'] for i in range(0, len(work_mode_timed))], kwh_min)
2633
2668
 
2669
+ # use work_mode_timed to generate time periods for the inverter schedule
2634
2670
  def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2635
2671
  global steps_per_hour
2636
- output(f"\nConfiguring schedule:",1)
2637
2672
  strategy = []
2638
2673
  start = base_hour
2639
- periods = []
2674
+ times = []
2640
2675
  for t in range(0, min([24 * steps_per_hour, len(work_mode_timed)])):
2641
- period = periods[0] if len(periods) > 0 else work_mode_timed[0]
2676
+ period = times[0] if len(times) > 0 else work_mode_timed[0]
2642
2677
  next_period = work_mode_timed[t]
2643
2678
  h = base_hour + t / steps_per_hour
2644
2679
  if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
@@ -2650,15 +2685,19 @@ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2650
2685
  s['max_soc'] = period.get('max_soc')
2651
2686
  elif period['mode'] == 'SelfUse' and period['hold'] == 1:
2652
2687
  s['min_soc'] = min([int(period['kwh'] / capacity * 100 + 0.5), 100])
2653
- for p in periods:
2688
+ s['end'] = (start + 1 / steps_per_hour) % 24
2689
+ for p in times:
2654
2690
  p['min_soc'] = s['min_soc']
2655
2691
  if s['mode'] != 'SelfUse' or s['min_soc'] != min_soc:
2656
2692
  strategy.append(s)
2657
2693
  start = h
2658
- periods = []
2659
- periods.append(work_mode_timed[t])
2660
- if len(strategy) > 0 and strategy[-1]['min_soc'] != min_soc:
2694
+ times = []
2695
+ times.append(work_mode_timed[t])
2696
+ if len(strategy) == 0:
2697
+ return []
2698
+ if strategy[-1]['min_soc'] != min_soc:
2661
2699
  strategy.append({'start': start %24, 'end': (start + 1 / steps_per_hour) % 24, 'mode': 'SelfUse', 'min_soc': min_soc})
2700
+ output(f"\nConfiguring schedule:",1)
2662
2701
  periods = []
2663
2702
  for s in strategy:
2664
2703
  periods.append(set_period(segment = s, quiet=0))
@@ -2682,9 +2721,9 @@ charge_config = {
2682
2721
  'charge_current': None, # max battery charge current setting in A
2683
2722
  'discharge_current': None, # max battery discharge current setting in A
2684
2723
  'export_limit': None, # maximum export power in kW
2685
- 'discharge_loss': 0.97, # loss converting battery discharge power to grid power
2686
- 'pv_loss': 0.95, # loss converting PV power to battery charge power
2687
- 'grid_loss': 0.975, # loss converting grid power to battery charge power
2724
+ 'dc_ac_loss': 0.970, # loss converting battery DC power to AC grid power
2725
+ 'pv_loss': 0.950, # loss converting PV power to DC battery charge power
2726
+ 'ac_dc_loss': 0.960, # loss converting AC grid power to DC battery charge power
2688
2727
  'inverter_power': 101, # Inverter power consumption in W
2689
2728
  'bms_power': 50, # BMS power consumption in W
2690
2729
  'force_charge_power': 5.00, # charge power in kW when using force charge
@@ -2705,11 +2744,8 @@ charge_config = {
2705
2744
  'special_contingency': 33, # contingency for special days when consumption might be higher
2706
2745
  'special_days': ['12-25', '12-26', '01-01'],
2707
2746
  'full_charge': None, # day of month (1-28) to do full charge, or 'daily' or 'Mon', 'Tue' etc
2708
- 'derate_temp': 22, # battery temperature where cold derating starts to be applied
2709
- 'derate_step': 5, # scale for derating factors in C
2710
- 'derating': [24, 15, 10, 2], # max charge current e.g. 5C step = 22C, 17C, 12C, 7C
2711
2747
  'data_wrap': 6, # data items to show per line
2712
- 'target_soc': None, # set the target SoC for charging
2748
+ 'target_soc': None, # the target SoC for charging (over-rides calculated value)
2713
2749
  'shading': { # effect of shading on Solcast / forecast.solar
2714
2750
  'solcast': {'adjust': 0.95, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2},
2715
2751
  'solar': {'adjust': 1.20, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2}
@@ -2732,7 +2768,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2732
2768
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2733
2769
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2734
2770
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2735
- global timed_strategy, steps_per_hour, base_time, storage
2771
+ global timed_strategy, steps_per_hour, base_time, storage, battery
2736
2772
  print(f"\n---------------- charge_needed ----------------")
2737
2773
  # validate parameters
2738
2774
  args = locals()
@@ -2832,8 +2868,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2832
2868
  output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
2833
2869
  output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
2834
2870
  output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
2871
+ if test_soc is not None:
2872
+ current_soc = test_soc
2873
+ capacity = 14.54
2874
+ residual = test_soc * capacity / 100
2875
+ bat_volt = 317.4
2876
+ bat_power = 0.0
2877
+ temperature = 30
2878
+ bms_charge_current = 25
2879
+ charge_loss = 1.040
2880
+ discharge_loss = 0.974
2881
+ bat_current = 0.0
2882
+ device_power = 6.0
2883
+ device_current = 35
2884
+ model = 'H1-6.0-E'
2885
+ else:
2835
2886
  # get device and battery info from inverter
2836
- if test_soc is None:
2837
2887
  get_battery()
2838
2888
  if battery is None or battery['status'] != 1:
2839
2889
  output(f"\nBattery status is not available")
@@ -2844,27 +2894,16 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2844
2894
  bat_current = battery['current']
2845
2895
  temperature = battery['temperature']
2846
2896
  residual = battery['residual']
2847
- if charge_config.get('capacity') is not None:
2848
- capacity = charge_config['capacity']
2849
- elif residual is not None and residual > 0.2 and current_soc is not None and current_soc > 1:
2850
- capacity = residual * 100 / current_soc
2851
- else:
2897
+ capacity = charge_config['capacity'] if charge_config.get('capacity') is not None else battery.get('capacity')
2898
+ if capacity is None:
2852
2899
  output(f"Battery capacity could not be estimated. Please add the parameter 'capacity=xx' in kWh")
2853
2900
  return None
2901
+ bms_charge_current = battery.get('charge_rate')
2902
+ charge_loss = battery['charge_loss'] if battery.get('charge_loss') is not None else 1.0
2903
+ discharge_loss = battery['discharge_loss'] if battery.get('discharge_loss') is not None else 1.0
2854
2904
  device_power = device.get('power')
2855
2905
  device_current = device.get('max_charge_current')
2856
2906
  model = device.get('deviceType')
2857
- else:
2858
- current_soc = test_soc
2859
- capacity = 14.54
2860
- residual = test_soc * capacity / 100
2861
- bat_volt = 315.4
2862
- bat_power = 0.0
2863
- temperature = 30
2864
- bat_current = 0.0
2865
- device_power = 6.0
2866
- device_current = 25
2867
- model = 'H1-6.0-E'
2868
2907
  min_soc = charge_config['min_soc'] if charge_config['min_soc'] is not None else 10
2869
2908
  max_soc = charge_config['max_soc'] if charge_config['max_soc'] is not None else 100
2870
2909
  volt_curve = charge_config['volt_curve']
@@ -2882,62 +2921,52 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2882
2921
  output(f" Min SoC: {min_soc}% ({reserve:.2f}kWh)")
2883
2922
  output(f" Current SoC: {current_soc}%")
2884
2923
  output(f" Max SoC: {max_soc}% ({capacity * max_soc / 100:.2f}kWh)")
2924
+ output(f" Charge Rate: {bms_charge_current:.1f}A")
2885
2925
  output(f" Temperature: {temperature:.1f}°C")
2886
2926
  output(f" Resistance: {bat_resistance:.2f} ohms")
2887
2927
  output(f" Nominal OCV: {bat_ocv:.1f}V at {nominal_soc}% SoC")
2888
- # charge times are derated based on temperature
2928
+ # charge current may be derated based on temperature
2889
2929
  charge_current = device_current if charge_config['charge_current'] is None else charge_config['charge_current']
2890
- derate_temp = charge_config['derate_temp']
2891
- if temperature > 36:
2892
- output(f"\nHigh battery temperature may affect the charge rate")
2893
- elif round(temperature, 0) <= derate_temp:
2894
- output(f"\nLow battery temperature may affect the charge rate")
2895
- derating = charge_config['derating']
2896
- derate_step = charge_config['derate_step']
2897
- i = int((derate_temp - temperature) / (derate_step if derate_step is not None and derate_step > 0 else 1))
2898
- if derating is not None and type(derating) is list and i < len(derating):
2899
- derated_current = derating[i]
2900
- if derated_current < charge_current:
2901
- output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2902
- charge_current = derated_current
2903
- else:
2904
- bat_hold = 2
2905
- output(f" Full charge set")
2930
+ if charge_current > bms_charge_current:
2931
+ charge_current = bms_charge_current
2906
2932
  # inverter losses
2907
2933
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
2908
2934
  operating_loss = inverter_power / 1000
2909
2935
  bms_power = charge_config['bms_power']
2910
2936
  bms_loss = bms_power / 1000
2911
2937
  # work out charge limit, power and losses. Max power going to the battery after ac conversion losses
2938
+ ac_dc_loss = charge_config['ac_dc_loss']
2912
2939
  charge_limit = min([charge_current * (bat_ocv + charge_current * bat_resistance) / 1000, max([6, device_power])])
2913
2940
  if charge_limit < 0.1:
2914
2941
  output(f"** charge_current is too low ({charge_current:.1f}A)")
2915
- charge_loss = 1.0 - charge_limit * 1000 * bat_resistance / bat_ocv ** 2
2916
2942
  force_charge_power = charge_config['force_charge_power'] if timed_mode > 1 and charge_config.get('force_charge_power') is not None else 100
2917
- grid_loss = charge_config['grid_loss']
2918
- charge_power = min([(device_power - operating_loss) * grid_loss, force_charge_power * grid_loss, charge_limit])
2943
+ charge_power = min([(device_power - operating_loss) * ac_dc_loss, force_charge_power * ac_dc_loss, charge_limit])
2919
2944
  float_charge = (charge_config['float_current'] if charge_config.get('float_current') is not None else 4) * bat_ocv / 1000
2920
- charge_config['charge_loss'] = charge_loss
2921
- charge_config['charge_limit'] = charge_limit
2922
- charge_config['charge_power'] = charge_power
2923
- charge_config['float_charge'] = float_charge
2945
+ pv_loss = charge_config['pv_loss']
2924
2946
  # work out discharge limit = max power coming from the battery before ac conversion losses
2925
- discharge_loss = charge_config['discharge_loss']
2926
- discharge_limit = device_power / discharge_loss
2947
+ dc_ac_loss = charge_config['dc_ac_loss']
2948
+ discharge_limit = device_power / dc_ac_loss
2927
2949
  discharge_current = device_current if charge_config['discharge_current'] is None else charge_config['discharge_current']
2928
2950
  discharge_power = discharge_current * bat_ocv / 1000
2929
2951
  discharge_limit = discharge_power if discharge_power < discharge_limit else discharge_limit
2930
2952
  # charging happens if generation exceeds export limit in feedin work mode
2931
2953
  export_power = device_power if charge_config['export_limit'] is None else charge_config['export_limit']
2932
- export_limit = export_power / discharge_loss
2954
+ export_limit = export_power / dc_ac_loss
2933
2955
  current_mode = get_work_mode()
2956
+ # set parameters for battery_timed()
2957
+ charge_config['charge_limit'] = charge_limit
2958
+ charge_config['charge_power'] = charge_power
2959
+ charge_config['float_charge'] = float_charge
2960
+ charge_config['charge_loss'] = charge_loss
2961
+ charge_config['discharge_loss'] = discharge_loss
2962
+ # display what we have
2934
2963
  output(f"\ncharge_config = {json.dumps(charge_config, indent=2)}", 3)
2935
2964
  output(f"\nDevice Info:")
2936
2965
  output(f" Model: {model}")
2937
2966
  output(f" Rating: {device_power:.2f}kW")
2938
2967
  output(f" Export: {export_power:.2f}kW")
2939
- output(f" Charge: {charge_current:.1f}A, {charge_power:.2f}kW, {charge_loss * 100:.1f}% efficient")
2940
- output(f" Discharge: {discharge_current:.1f}A, {discharge_limit:.2f}kW, {discharge_loss * 100:.1f}% efficient")
2968
+ output(f" Charge: {charge_current:.1f}A, {charge_power:.2f}kW, {ac_dc_loss * 100:.1f}% efficient")
2969
+ output(f" Discharge: {discharge_current:.1f}A, {discharge_limit:.2f}kW, {dc_ac_loss * 100:.1f}% efficient")
2941
2970
  output(f" Inverter: {inverter_power:.0f}W power consumption")
2942
2971
  output(f" BMS: {bms_power:.0f}W power consumption")
2943
2972
  if current_mode is not None:
@@ -3040,8 +3069,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3040
3069
  output(f"\nSettings will not be updated when forecast is not available")
3041
3070
  update_settings = 0
3042
3071
  # produce time lines for charge, discharge and work mode
3043
- charge_timed = [min([charge_limit, x * charge_config['pv_loss']]) for x in generation_timed]
3044
- discharge_timed = [min([discharge_limit, x / discharge_loss]) + bms_loss for x in consumption_timed]
3072
+ charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
3073
+ discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
3045
3074
  work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
3046
3075
  for i in range(0, len(work_mode_timed)):
3047
3076
  # get work mode
@@ -3052,9 +3081,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3052
3081
  discharge_timed[i] = discharge_timed[i] * (1.0 - duration)
3053
3082
  work_mode_timed[i]['charge'] = charge_power * duration
3054
3083
  elif timed_mode > 0 and work_mode == 'ForceDischarge':
3055
- fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
3056
- fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
3057
- discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
3084
+ fdpwr = work_mode_timed[i]['fdpwr'] / dc_ac_loss / 1000
3085
+ fdpwr = min([discharge_limit, export_limit + discharge_timed[i], fdpwr])
3086
+ discharge_timed[i] = fdpwr * duration + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
3058
3087
  elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
3059
3088
  discharge_timed[i] = bms_loss
3060
3089
  if timed_mode > 1:
@@ -3099,13 +3128,13 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3099
3128
  end_timed = time_to_end
3100
3129
  end_soc = int(end_residual / capacity * 100 + 0.5)
3101
3130
  else:
3102
- if test_charge is None:
3103
- output(f"\nCharge needed: {kwh_needed:.2f}kWh")
3104
- charge_message = "with charge added"
3105
- output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
3106
3131
  # work out time to add kwh_needed to battery
3107
3132
  charge_rate = charge_power * charge_loss
3108
3133
  hours = kwh_needed / charge_rate
3134
+ if test_charge is None:
3135
+ output(f"\nCharge needed: {kwh_needed:.2f}kWh ({hours_time(hours)})")
3136
+ charge_message = "with charge added"
3137
+ output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
3109
3138
  # check if charge time exceeded or charge needed exceeds capacity
3110
3139
  hours_to_full = (capacity - start_residual) / charge_rate
3111
3140
  if hours > charge_time:
@@ -3146,7 +3175,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3146
3175
  if i >= start_timed and i < end_timed:
3147
3176
  work_mode_timed[i]['mode'] = 'ForceCharge'
3148
3177
  work_mode_timed[i]['charge'] = charge_power * t
3149
- work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else 100
3178
+ work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else max_soc
3150
3179
  work_mode_timed[i]['discharge'] *= (1-t)
3151
3180
  # rebuild the battery residual with the charge added and min_soc
3152
3181
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
@@ -3218,7 +3247,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3218
3247
  data['capacity'] = capacity
3219
3248
  data['config'] = charge_config
3220
3249
  data['time'] = time_line
3221
- data['bat'] = bat_timed
3222
3250
  data['work_mode'] = work_mode_timed
3223
3251
  data['generation'] = generation_timed
3224
3252
  data['consumption'] = consumption_timed
@@ -3233,10 +3261,14 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3233
3261
  # CHARGE_COMPARE - load saved data and compare with actual
3234
3262
  ##################################################################################################
3235
3263
 
3236
- def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3264
+ def charge_compare(save=None, v=None, show_data=1, show_plot=3, d=None):
3237
3265
  global charge_config, storage
3266
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3267
+ yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3238
3268
  if save is None and charge_config.get('save') is not None:
3239
- save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
3269
+ save = charge_config.get('save').replace('###', yesterday)
3270
+ if not os.path.exists(storage + save):
3271
+ save = None
3240
3272
  if save is None:
3241
3273
  print(f"** charge_compare(): please provide a saved file to load")
3242
3274
  return
@@ -3253,10 +3285,10 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3253
3285
  steps_per_hour = data.get('steps')
3254
3286
  capacity = data.get('capacity')
3255
3287
  time_line = data.get('time')
3256
- bat_timed = data.get('bat')
3257
3288
  generation_timed = data.get('generation')
3258
3289
  consumption_timed = data.get('consumption')
3259
3290
  work_mode_timed = data.get('work_mode')
3291
+ bat_timed = data['bat'] if data.get('bat') is not None else [work_mode_timed[t]['kwh'] for t in range(0, len(work_mode_timed))]
3260
3292
  run_time = len(time_line)
3261
3293
  base_hour = int(time_hours(base_time[11:16]))
3262
3294
  start_day = base_time[:10]
@@ -3298,7 +3330,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3298
3330
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3299
3331
  h = base_hour
3300
3332
  t = 0
3301
- while t < len(time_line) and bat_timed[t] is not None:
3333
+ while t < len(time_line) and bat_timed[t] is not None and plots['SoC'][t] is not None:
3302
3334
  col = h % data_wrap
3303
3335
  s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3304
3336
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
@@ -3380,14 +3412,14 @@ battery_info_app_key = "aug938dqt5cbqhvq69ixc4v39q6wtw"
3380
3412
 
3381
3413
  # show information about the current state of the batteries
3382
3414
  def battery_info(log=0, plot=1, count=None, info=1):
3383
- global debug_setting, battery_info_app_key
3415
+ global debug_setting, battery_info_app_key, residual_handling
3384
3416
  output_spool(battery_info_app_key)
3385
3417
  bat = get_battery(info=info)
3386
3418
  if bat is None:
3387
3419
  output_close()
3388
3420
  return None
3389
3421
  nbat = None
3390
- if bat.get('info') is not None:
3422
+ if info == 1 and bat.get('info') is not None:
3391
3423
  for b in bat['info']:
3392
3424
  output(f"\nSN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3393
3425
  nbat = 0
@@ -3400,7 +3432,7 @@ def battery_info(log=0, plot=1, count=None, info=1):
3400
3432
  bat_current = bat['current']
3401
3433
  bat_power = bat['power']
3402
3434
  bms_temperature = bat['temperature']
3403
- capacity = residual / current_soc * 100
3435
+ capacity = bat['capacity']
3404
3436
  cell_volts = get_cell_volts()
3405
3437
  if cell_volts is None:
3406
3438
  output_close()
@@ -3455,6 +3487,7 @@ def battery_info(log=0, plot=1, count=None, info=1):
3455
3487
  output(f"Cell Volts: {avg(cell_volts):.3f}V average, {max(cell_volts):.3f}V maximum, {min(cell_volts):.3f}V minimum")
3456
3488
  output(f"Cell Imbalance: {imbalance(cell_volts):.2f}%:")
3457
3489
  output(f"BMS Temperature: {bms_temperature:.1f}°C")
3490
+ output(f"BMS Charge Rate: {bat.get('charge_rate'):.1f}A (estimated)")
3458
3491
  output(f"Battery Temperature: {avg(cell_temps):.1f}°C average, {max(cell_temps):.1f}°C maximum, {min(cell_temps):.1f}°C minimum")
3459
3492
  output(f"\nInfo by battery:")
3460
3493
  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: 01 October 2024
4
+ Updated: 05 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.8"
13
+ version = "2.6.0"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -541,10 +541,28 @@ battery = None
541
541
  battery_settings = None
542
542
  battery_vars = ['SoC', 'invBatVolt', 'invBatCurrent', 'invBatPower', 'batTemperature', 'ResidualEnergy' ]
543
543
  battery_data = ['soc', 'volt', 'current', 'power', 'temperature', 'residual']
544
- residual_handling = 1 # set to 2 if Residual returns current capacity
544
+
545
+ # 1 = returns Residual Energy. 2 = resturns Residual Capacity
546
+ residual_handling = 1
547
+
548
+ # charge rates based on residual_handling
549
+ battery_params = {
550
+ # cell temp -5 0 5 10 15 20 25 30 35 40 45 50 55
551
+ # bms temp 5 10 15 20 25 30 35 40 45 50 55 60 65
552
+ 1: {'table': [ 0, 2, 10, 15, 25, 50, 50, 50, 50, 50, 30, 20, 0],
553
+ 'step': 5,
554
+ 'offset': 5,
555
+ 'charge_loss': 0.975,
556
+ 'discharge_loss': 0.975},
557
+ 2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
558
+ 'step': 5,
559
+ 'offset': 5,
560
+ 'charge_loss': 1.040,
561
+ 'discharge_loss': 0.975},
562
+ }
545
563
 
546
564
  def get_battery(v = None, info=0):
547
- global device_sn, battery, debug_setting, residual_handling
565
+ global device_sn, battery, debug_setting, residual_handling, battery_params
548
566
  if get_device() is None:
549
567
  return None
550
568
  output(f"getting battery", 2)
@@ -558,10 +576,20 @@ def get_battery(v = None, info=0):
558
576
  if residual_handling == 2:
559
577
  capacity = battery.get('residual')
560
578
  soc = battery.get('soc')
561
- battery['residual'] = capacity * soc / 100 if capacity is not None and soc is not None else capacity
562
- if info == 1:
563
- output(f"** get_battery(): info is not available via Open API")
579
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
580
+ else:
581
+ residual = battery.get('residual')
582
+ soc = battery.get('soc')
583
+ capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
584
+ battery['capacity'] = round(capacity, 3)
585
+ battery['residual'] = round(residual, 3)
564
586
  battery['status'] = 1
587
+ battery['charge_rate'] = 50
588
+ params = battery_params[residual_handling]
589
+ battery['charge_loss'] = params['charge_loss']
590
+ battery['discharge_loss'] = params['discharge_loss']
591
+ if battery.get('temperature') is not None:
592
+ battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
565
593
  return battery
566
594
 
567
595
  ##################################################################################################
@@ -2406,7 +2434,7 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2406
2434
  profile = []
2407
2435
  h = base_hour - time_offset
2408
2436
  while h < 0:
2409
- profile.append(0.0)
2437
+ profile.append(None)
2410
2438
  h += 1 / steps_per_hour
2411
2439
  while h < 48:
2412
2440
  day = today if h < 24 else tomorrow
@@ -2416,10 +2444,10 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2416
2444
  value = forecast.daily[day]['hourly'].get(int(h % 24))
2417
2445
  else:
2418
2446
  value = forecast.daily[day]['pt30'].get(hours_time(int(h * 2) / 2))
2419
- profile.append(c_float(value))
2447
+ profile.append(value)
2420
2448
  h += 1 / steps_per_hour
2421
2449
  while len(profile) < run_time:
2422
- profile.append(0.0)
2450
+ profile.append(None)
2423
2451
  return profile[:run_time]
2424
2452
 
2425
2453
  # build the timed work mode profile from the tariff strategy:
@@ -2455,23 +2483,23 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2455
2483
  return work_mode_timed
2456
2484
 
2457
2485
  # build the timed battery residual from the charge / discharge, work mode and min_soc
2486
+ # note: all power values are as measured at the inverter battery connection
2458
2487
  def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=None, reserve_drain=None):
2459
2488
  global charge_config, steps_per_hour
2460
- bat_timed = []
2461
2489
  allowed_drain = charge_config['allowed_drain'] if charge_config.get('allowed_drain') is not None else 4
2462
2490
  bms_loss = (charge_config['bms_power'] / 1000 if charge_config.get('bms_power') is not None else 0.05)
2463
2491
  charge_loss = charge_config['charge_loss']
2492
+ discharge_loss = charge_config['discharge_loss']
2464
2493
  charge_limit = charge_config['charge_limit']
2465
2494
  float_charge = charge_config['float_charge']
2466
2495
  for i in range(0, len(work_mode_timed)):
2467
- bat_timed.append(kwh_current)
2468
2496
  w = work_mode_timed[i]
2469
2497
  w['kwh'] = kwh_current
2470
2498
  max_now = w['max_soc'] * capacity / 100
2471
2499
  if kwh_current < max_now and w['charge'] > 0.0:
2472
2500
  kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
2473
2501
  kwh_current = max_now if kwh_current > max_now else kwh_current
2474
- kwh_current += (w['pv'] - w['discharge']) / charge_loss / steps_per_hour
2502
+ kwh_current += (w['pv'] * charge_loss - w['discharge'] / discharge_loss) / steps_per_hour
2475
2503
  if kwh_current > capacity:
2476
2504
  # battery is full
2477
2505
  kwh_current = capacity
@@ -2492,16 +2520,16 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2492
2520
  reserve_drain = reserve_now
2493
2521
  if kwh_min is not None and kwh_current < kwh_min and i >= time_to_next: # track minimum without charge
2494
2522
  kwh_min = kwh_current
2495
- return (bat_timed, kwh_min)
2523
+ return ([work_mode_timed[i]['kwh'] for i in range(0, len(work_mode_timed))], kwh_min)
2496
2524
 
2525
+ # use work_mode_timed to generate time periods for the inverter schedule
2497
2526
  def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2498
2527
  global steps_per_hour
2499
- output(f"\nConfiguring schedule:",1)
2500
2528
  strategy = []
2501
2529
  start = base_hour
2502
- periods = []
2530
+ times = []
2503
2531
  for t in range(0, min([24 * steps_per_hour, len(work_mode_timed)])):
2504
- period = periods[0] if len(periods) > 0 else work_mode_timed[0]
2532
+ period = times[0] if len(times) > 0 else work_mode_timed[0]
2505
2533
  next_period = work_mode_timed[t]
2506
2534
  h = base_hour + t / steps_per_hour
2507
2535
  if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
@@ -2513,15 +2541,19 @@ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2513
2541
  s['max_soc'] = period.get('max_soc')
2514
2542
  elif period['mode'] == 'SelfUse' and period['hold'] == 1:
2515
2543
  s['min_soc'] = min([int(period['kwh'] / capacity * 100 + 0.5), 100])
2516
- for p in periods:
2544
+ s['end'] = (start + 1 / steps_per_hour) % 24
2545
+ for p in times:
2517
2546
  p['min_soc'] = s['min_soc']
2518
2547
  if s['mode'] != 'SelfUse' or s['min_soc'] != min_soc:
2519
2548
  strategy.append(s)
2520
2549
  start = h
2521
- periods = []
2522
- periods.append(work_mode_timed[t])
2523
- if len(strategy) > 0 and strategy[-1]['min_soc'] != min_soc:
2550
+ times = []
2551
+ times.append(work_mode_timed[t])
2552
+ if len(strategy) == 0:
2553
+ return []
2554
+ if strategy[-1]['min_soc'] != min_soc:
2524
2555
  strategy.append({'start': start %24, 'end': (start + 1 / steps_per_hour) % 24, 'mode': 'SelfUse', 'min_soc': min_soc})
2556
+ output(f"\nConfiguring schedule:",1)
2525
2557
  periods = []
2526
2558
  for s in strategy:
2527
2559
  periods.append(set_period(segment = s, quiet=0))
@@ -2545,9 +2577,9 @@ charge_config = {
2545
2577
  'charge_current': None, # max battery charge current setting in A
2546
2578
  'discharge_current': None, # max battery discharge current setting in A
2547
2579
  'export_limit': None, # maximum export power in kW
2548
- 'discharge_loss': 0.97, # loss converting battery discharge power to grid power
2549
- 'pv_loss': 0.95, # loss converting PV power to battery charge power
2550
- 'grid_loss': 0.975, # loss converting grid power to battery charge power
2580
+ 'dc_ac_loss': 0.97, # loss converting battery DC power to AC grid power
2581
+ 'pv_loss': 0.95, # loss converting PV power to DC battery charge power
2582
+ 'ac_dc_loss': 0.962, # loss converting AC grid power to DC battery charge power
2551
2583
  'inverter_power': 101, # Inverter power consumption in W
2552
2584
  'bms_power': 50, # BMS power consumption in W
2553
2585
  'force_charge_power': 5.00, # charge power in kW when using force charge
@@ -2568,11 +2600,8 @@ charge_config = {
2568
2600
  'special_contingency': 33, # contingency for special days when consumption might be higher
2569
2601
  'special_days': ['12-25', '12-26', '01-01'],
2570
2602
  'full_charge': None, # day of month (1-28) to do full charge, or 'daily' or 'Mon', 'Tue' etc
2571
- 'derate_temp': 22, # battery temperature where cold derating starts to be applied
2572
- 'derate_step': 5, # scale for derating factors in C
2573
- 'derating': [24, 15, 10, 2], # max charge current e.g. 5C step = 22C, 17C, 12C, 7C
2574
2603
  'data_wrap': 6, # data items to show per line
2575
- 'target_soc': None, # set the target SoC for charging
2604
+ 'target_soc': None, # the target SoC for charging (over-rides calculated value)
2576
2605
  'shading': { # effect of shading on Solcast / forecast.solar
2577
2606
  'solcast': {'adjust': 0.95, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2},
2578
2607
  'solar': {'adjust': 1.20, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2}
@@ -2595,7 +2624,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2595
2624
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2596
2625
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2597
2626
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2598
- global timed_strategy, steps_per_hour, base_time, storage
2627
+ global timed_strategy, steps_per_hour, base_time, storage, battery
2599
2628
  print(f"\n---------------- charge_needed ----------------")
2600
2629
  # validate parameters
2601
2630
  args = locals()
@@ -2695,8 +2724,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2695
2724
  output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
2696
2725
  output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
2697
2726
  output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
2727
+ if test_soc is not None:
2728
+ current_soc = test_soc
2729
+ capacity = 14.54
2730
+ residual = test_soc * capacity / 100
2731
+ bat_volt = 317.4
2732
+ bat_power = 0.0
2733
+ temperature = 30
2734
+ bms_charge_current = 25
2735
+ charge_loss = 1.040
2736
+ discharge_loss = 0.974
2737
+ bat_current = 0.0
2738
+ device_power = 6.0
2739
+ device_current = 35
2740
+ model = 'H1-6.0-E'
2741
+ else:
2698
2742
  # get device and battery info from inverter
2699
- if test_soc is None:
2700
2743
  get_battery()
2701
2744
  if battery is None or battery['status'] != 1:
2702
2745
  output(f"\nBattery status is not available")
@@ -2707,27 +2750,16 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2707
2750
  bat_current = battery['current']
2708
2751
  temperature = battery['temperature']
2709
2752
  residual = battery['residual']
2710
- if charge_config.get('capacity') is not None:
2711
- capacity = charge_config['capacity']
2712
- elif residual is not None and residual > 0.2 and current_soc is not None and current_soc > 1:
2713
- capacity = residual * 100 / current_soc
2714
- else:
2753
+ capacity = charge_config['capacity'] if charge_config.get('capacity') is not None else battery.get('capacity')
2754
+ if capacity is None:
2715
2755
  output(f"Battery capacity could not be estimated. Please add the parameter 'capacity=xx' in kWh")
2716
2756
  return None
2757
+ bms_charge_current = battery.get('charge_rate')
2758
+ charge_loss = battery['charge_loss'] if battery.get('charge_loss') is not None else 1.0
2759
+ discharge_loss = battery['discharge_loss'] if battery.get('discharge_loss') is not None else 1.0
2717
2760
  device_power = device.get('power')
2718
2761
  device_current = device.get('max_charge_current')
2719
2762
  model = device.get('deviceType')
2720
- else:
2721
- current_soc = test_soc
2722
- capacity = 14.54
2723
- residual = test_soc * capacity / 100
2724
- bat_volt = 315.4
2725
- bat_power = 0.0
2726
- temperature = 30
2727
- bat_current = 0.0
2728
- device_power = 6.0
2729
- device_current = 25
2730
- model = 'H1-6.0-E'
2731
2763
  min_soc = charge_config['min_soc'] if charge_config['min_soc'] is not None else 10
2732
2764
  max_soc = charge_config['max_soc'] if charge_config['max_soc'] is not None else 100
2733
2765
  volt_curve = charge_config['volt_curve']
@@ -2746,61 +2778,51 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2746
2778
  output(f" Current SoC: {current_soc}%")
2747
2779
  output(f" Max SoC: {max_soc}% ({capacity * max_soc / 100:.2f}kWh)")
2748
2780
  output(f" Temperature: {temperature:.1f}°C")
2781
+ output(f" Charge Rate: {bms_charge_current:.1f}A")
2749
2782
  output(f" Resistance: {bat_resistance:.2f} ohms")
2750
2783
  output(f" Nominal OCV: {bat_ocv:.1f}V at {nominal_soc}% SoC")
2751
- # charge times are derated based on temperature
2784
+ # charge current may be derated based on temperature
2752
2785
  charge_current = device_current if charge_config['charge_current'] is None else charge_config['charge_current']
2753
- derate_temp = charge_config['derate_temp']
2754
- if temperature > 36:
2755
- output(f"\nHigh battery temperature may affect the charge rate")
2756
- elif round(temperature, 0) <= derate_temp:
2757
- output(f"\nLow battery temperature may affect the charge rate")
2758
- derating = charge_config['derating']
2759
- derate_step = charge_config['derate_step']
2760
- i = int((derate_temp - temperature) / (derate_step if derate_step is not None and derate_step > 0 else 1))
2761
- if derating is not None and type(derating) is list and i < len(derating):
2762
- derated_current = derating[i]
2763
- if derated_current < charge_current:
2764
- output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2765
- charge_current = derated_current
2766
- else:
2767
- bat_hold = 2
2768
- output(f" Full charge set")
2786
+ if charge_current > bms_charge_current:
2787
+ charge_current = bms_charge_current
2769
2788
  # inverter losses
2770
2789
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
2771
2790
  operating_loss = inverter_power / 1000
2772
2791
  bms_power = charge_config['bms_power']
2773
2792
  bms_loss = bms_power / 1000
2774
2793
  # work out charge limit, power and losses. Max power going to the battery after ac conversion losses
2794
+ ac_dc_loss = charge_config['ac_dc_loss']
2775
2795
  charge_limit = min([charge_current * (bat_ocv + charge_current * bat_resistance) / 1000, max([6, device_power])])
2776
2796
  if charge_limit < 0.1:
2777
2797
  output(f"** charge_current is too low ({charge_current:.1f}A)")
2778
- charge_loss = 1.0 - charge_limit * 1000 * bat_resistance / bat_ocv ** 2
2779
2798
  force_charge_power = charge_config['force_charge_power'] if timed_mode > 1 and charge_config.get('force_charge_power') is not None else 100
2780
- grid_loss = charge_config['grid_loss']
2781
- charge_power = min([(device_power - operating_loss) * grid_loss, force_charge_power * grid_loss, charge_limit])
2799
+ charge_power = min([(device_power - operating_loss) * ac_dc_loss, force_charge_power * ac_dc_loss, charge_limit])
2782
2800
  float_charge = (charge_config['float_current'] if charge_config.get('float_current') is not None else 4) * bat_ocv / 1000
2783
- charge_config['charge_loss'] = charge_loss
2784
- charge_config['charge_limit'] = charge_limit
2785
- charge_config['charge_power'] = charge_power
2786
- charge_config['float_charge'] = float_charge
2801
+ pv_loss = charge_config['pv_loss']
2787
2802
  # work out discharge limit = max power coming from the battery before ac conversion losses
2788
- discharge_loss = charge_config['discharge_loss']
2789
- discharge_limit = device_power / discharge_loss
2803
+ dc_ac_loss = charge_config['dc_ac_loss']
2804
+ discharge_limit = device_power / dc_ac_loss
2790
2805
  discharge_current = device_current if charge_config['discharge_current'] is None else charge_config['discharge_current']
2791
2806
  discharge_power = discharge_current * bat_ocv / 1000
2792
2807
  discharge_limit = discharge_power if discharge_power < discharge_limit else discharge_limit
2793
2808
  # charging happens if generation exceeds export limit in feedin work mode
2794
2809
  export_power = device_power if charge_config['export_limit'] is None else charge_config['export_limit']
2795
- export_limit = export_power / discharge_loss
2810
+ export_limit = export_power / dc_ac_loss
2796
2811
  current_mode = get_work_mode()
2812
+ # set parameters for battery_timed()
2813
+ charge_config['charge_limit'] = charge_limit
2814
+ charge_config['charge_power'] = charge_power
2815
+ charge_config['float_charge'] = float_charge
2816
+ charge_config['charge_loss'] = charge_loss
2817
+ charge_config['discharge_loss'] = discharge_loss
2818
+ # display what we have
2797
2819
  output(f"\ncharge_config = {json.dumps(charge_config, indent=2)}", 3)
2798
2820
  output(f"\nDevice Info:")
2799
2821
  output(f" Model: {model}")
2800
2822
  output(f" Rating: {device_power:.2f}kW")
2801
2823
  output(f" Export: {export_power:.2f}kW")
2802
- output(f" Charge: {charge_current:.1f}A, {charge_power:.2f}kW, {charge_loss * 100:.1f}% efficient")
2803
- output(f" Discharge: {discharge_current:.1f}A, {discharge_limit:.2f}kW, {discharge_loss * 100:.1f}% efficient")
2824
+ output(f" Charge: {charge_current:.1f}A, {charge_power:.2f}kW, {ac_dc_loss * 100:.1f}% efficient")
2825
+ output(f" Discharge: {discharge_current:.1f}A, {discharge_limit:.2f}kW, {dc_ac_loss * 100:.1f}% efficient")
2804
2826
  output(f" Inverter: {inverter_power:.0f}W power consumption")
2805
2827
  output(f" BMS: {bms_power:.0f}W power consumption")
2806
2828
  if current_mode is not None:
@@ -2902,8 +2924,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2902
2924
  output(f"\nSettings will not be updated when forecast is not available")
2903
2925
  update_settings = 0
2904
2926
  # produce time lines for charge, discharge and work mode
2905
- charge_timed = [min([charge_limit, x * charge_config['pv_loss']]) for x in generation_timed]
2906
- discharge_timed = [min([discharge_limit, x / discharge_loss]) + bms_loss for x in consumption_timed]
2927
+ charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
2928
+ discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
2907
2929
  work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
2908
2930
  for i in range(0, len(work_mode_timed)):
2909
2931
  # get work mode
@@ -2914,9 +2936,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2914
2936
  discharge_timed[i] = discharge_timed[i] * (1.0 - duration)
2915
2937
  work_mode_timed[i]['charge'] = charge_power * duration
2916
2938
  elif timed_mode > 0 and work_mode == 'ForceDischarge':
2917
- fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
2918
- fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
2919
- discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
2939
+ fdpwr = work_mode_timed[i]['fdpwr'] / dc_ac_loss / 1000
2940
+ fdpwr = min([discharge_limit, export_limit + discharge_timed[i], fdpwr])
2941
+ discharge_timed[i] = fdpwr * duration + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
2920
2942
  elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
2921
2943
  discharge_timed[i] = bms_loss
2922
2944
  if timed_mode > 1:
@@ -2961,13 +2983,13 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2961
2983
  end_timed = time_to_end
2962
2984
  end_soc = int(end_residual / capacity * 100 + 0.5)
2963
2985
  else:
2964
- if test_charge is None:
2965
- output(f"\nCharge needed: {kwh_needed:.2f}kWh:")
2966
- charge_message = "with charge added"
2967
- output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
2968
2986
  # work out time to add kwh_needed to battery
2969
2987
  charge_rate = charge_power * charge_loss
2970
2988
  hours = kwh_needed / charge_rate
2989
+ if test_charge is None:
2990
+ output(f"\nCharge needed: {kwh_needed:.2f}kWh ({hours_time(hours)})")
2991
+ charge_message = "with charge added"
2992
+ output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
2971
2993
  # check if charge time exceeded or charge needed exceeds capacity
2972
2994
  hours_to_full = (capacity - start_residual) / charge_rate
2973
2995
  if hours > charge_time:
@@ -3008,7 +3030,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3008
3030
  if i >= start_timed and i < end_timed:
3009
3031
  work_mode_timed[i]['mode'] = 'ForceCharge'
3010
3032
  work_mode_timed[i]['charge'] = charge_power * t
3011
- work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else 100
3033
+ work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else max_soc
3012
3034
  work_mode_timed[i]['discharge'] *= (1-t)
3013
3035
  # rebuild the battery residual with any charge added and min_soc
3014
3036
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
@@ -3080,7 +3102,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3080
3102
  data['capacity'] = capacity
3081
3103
  data['config'] = charge_config
3082
3104
  data['time'] = time_line
3083
- data['bat'] = bat_timed
3084
3105
  data['work_mode'] = work_mode_timed
3085
3106
  data['generation'] = generation_timed
3086
3107
  data['consumption'] = consumption_timed
@@ -3096,8 +3117,12 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3096
3117
 
3097
3118
  def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3098
3119
  global charge_config, storage
3120
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3121
+ yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3099
3122
  if save is None and charge_config.get('save') is not None:
3100
- save = charge_config.get('save').replace('###', base_time.replace(' ', 'T'))
3123
+ save = charge_config.get('save').replace('###', yesterday)
3124
+ if not os.path.exists(storage + save):
3125
+ save = None
3101
3126
  if save is None:
3102
3127
  print(f"** charge_compare(): please provide a saved file to load")
3103
3128
  return
@@ -3114,10 +3139,10 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3114
3139
  steps_per_hour = data.get('steps')
3115
3140
  capacity = data.get('capacity')
3116
3141
  time_line = data.get('time')
3117
- bat_timed = data.get('bat')
3118
3142
  generation_timed = data.get('generation')
3119
3143
  consumption_timed = data.get('consumption')
3120
3144
  work_mode_timed = data.get('work_mode')
3145
+ bat_timed = data['bat'] if data.get('bat') is not None else [work_mode_timed[t]['kwh'] for t in range(0, len(work_mode_timed))]
3121
3146
  run_time = len(time_line)
3122
3147
  base_hour = int(time_hours(base_time[11:16]))
3123
3148
  start_day = base_time[:10]
@@ -3159,7 +3184,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3159
3184
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3160
3185
  h = base_hour
3161
3186
  t = 0
3162
- while t < len(time_line) and bat_timed[t] is not None:
3187
+ while t < len(time_line) and bat_timed[t] is not None and plots['SoC'][t] is not None:
3163
3188
  col = h % data_wrap
3164
3189
  s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3165
3190
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
@@ -3240,14 +3265,14 @@ battery_info_app_key = "aug938dqt5cbqhvq69ixc4v39q6wtw"
3240
3265
 
3241
3266
  # show information about the current state of the batteries
3242
3267
  def battery_info(log=0, plot=1, count=None, info=1):
3243
- global debug_setting, battery_info_app_key
3268
+ global debug_setting, battery_info_app_key, residual_handling
3244
3269
  output_spool(battery_info_app_key)
3245
3270
  bat = get_battery(info=info)
3246
3271
  if bat is None:
3247
3272
  output_close()
3248
3273
  return None
3249
3274
  nbat = None
3250
- if bat.get('info') is not None:
3275
+ if info == 1 and bat.get('info') is not None:
3251
3276
  for b in bat['info']:
3252
3277
  output(f"\nSN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3253
3278
  nbat = 0
@@ -3260,7 +3285,7 @@ def battery_info(log=0, plot=1, count=None, info=1):
3260
3285
  bat_current = bat['current']
3261
3286
  bat_power = bat['power']
3262
3287
  bms_temperature = bat['temperature']
3263
- capacity = residual / current_soc * 100
3288
+ capacity = bat['capacity']
3264
3289
  cell_volts = get_cell_volts()
3265
3290
  if cell_volts is None:
3266
3291
  output_close()
@@ -3315,6 +3340,7 @@ def battery_info(log=0, plot=1, count=None, info=1):
3315
3340
  output(f"Cell Volts: {avg(cell_volts):.3f}V average, {max(cell_volts):.3f}V maximum, {min(cell_volts):.3f}V minimum")
3316
3341
  output(f"Cell Imbalance: {imbalance(cell_volts):.2f}%:")
3317
3342
  output(f"BMS Temperature: {bms_temperature:.1f}°C")
3343
+ output(f"BMS Charge Rate: {bat.get('charge_rate'):.1f}A (estimated)")
3318
3344
  output(f"Battery Temperature: {avg(cell_temps):.1f}°C average, {max(cell_temps):.1f}°C maximum, {min(cell_temps):.1f}°C minimum")
3319
3345
  output(f"\nInfo by battery:")
3320
3346
  for i in range(0, nbat):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.5.8
3
+ Version: 2.6.0
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
@@ -116,7 +116,12 @@ Each of these calls will return a dictionary or list containing the relevant inf
116
116
 
117
117
  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
118
 
119
- get_battery() returns the current battery status, including 'soc', 'volt', 'current', 'power', 'temperature' and 'residual'. The result also updates f.battery.
119
+ get_battery() returns the current battery status, including 'soc', 'volt', 'current', 'power', 'temperature' and 'residual'. The result also updates f.battery. Additional battery attributes include:
120
+ + 'info': a list of BMS and battery serial numbers and firmware versions
121
+ + 'capacity': the estimated battery capacity, derrived from 'residual' and 'soc'
122
+ + 'charge_rate': the estimated BMS charge rate available, based on the current 'temperature' of the BMS
123
+ + 'charge_loss': the ratio of the kWh added to the battery for each kWh applied during charging
124
+ + 'discharge_loss': the ratio of the kWh available for each kWh removed from the battery during during discharging
120
125
 
121
126
  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
127
 
@@ -363,44 +368,41 @@ Given the data available, the modelling works as follows:
363
368
 
364
369
  The following parameters and default values are used to configure charge_needed and may be updated if required using name=value:
365
370
  ```
366
- contingency: [20,10,5,10] # % of consumption. Single or [winter, spring, summer, autumn] values
367
- capacity: None # Battery capacity in kWh (over-rides generated value if set)
368
- charge_current: None # max battery charge current setting in A. None uses a value derrived from the inverter model
369
- discharge_current: None # max battery discharge current setting in A. None uses a value derrived from the inverter model
370
- export_limit: None # maximum export power in kW. None uses the inverter power rating
371
- discharge_loss: 0.98 # loss converting battery discharge power to grid power
372
- pv_loss: 0.95 # loss converting PV power to battery charge power
373
- grid_loss: 0.975 # loss converting grid power to battery charge power
374
- inverter_power: None # inverter power consumption in W (dynamically set)
375
- bms_power: 50 # BMS power consumption in W
376
- force_charge_power: 5.00 # power used when Force Charge is scheduled
377
- allowed_drain: 4, # % tolerance below min_soc before float charge starts
378
- float_current: 4, # BMS float charge current in A
379
- bat_resistance: 0.070 # internal resistance of a battery in ohms
380
- volt_curve: lifepo4_curve # battery OCV from 0% to 100% SoC
381
- nominal_soc: 55 # SoC for nominal open circuit voltage
382
- generation_days: 3 # number of days to use for average generation (1-7)
383
- consumption_days: 3 # number of days to use for average consumption (1-7)
384
- consumption_span: 'week' # 'week' = last 7 days or 'weekday' = last 7 weekdays e.g. Saturdays
385
- use_today: 21.0 # hour when today's generation and consumption data will be used
386
- min_hours: 0.25 # minimum charge time to set (in decimal hours)
387
- min_kwh: 0.5 # minimum charge to add in kwh
388
- solcast_adjust: 100 # % adjustment to make to Solcast forecast
389
- solar_adjust: 100 # % adjustment to make to Solar forecast
390
- forecast_selection: 1 # 1 = only update charge times if forecast is available, 0 = use best available data. Default is 1.
391
- annual_consumption: None # optional annual consumption in kWh. If set, this replaces consumption history
392
- timed_mode: 0 # 0 = None, 1 = use timed work mode, 2 = strategy mode
393
- special_contingency: 30 # contingency for special days when consumption might be higher
371
+ contingency: [20,10,5,10] # % of consumption. Single or [winter, spring, summer, autumn] values
372
+ capacity: None # Battery capacity in kWh (over-rides generated value if set)
373
+ charge_current: None # max battery charge current setting in A. None uses a value derrived from the inverter model
374
+ discharge_current: None # max battery discharge current setting in A. None uses a value derrived from the inverter model
375
+ export_limit: None # maximum export power in kW. None uses the inverter power rating
376
+ dc_ac_loss: 0.970 # loss converting battery DC power to AC grid power
377
+ pv_loss: 0.950 # loss converting PV power to DC battery charge power
378
+ ac_dc_loss: 0.960 # loss converting AC grid power to DC battery charge power
379
+ inverter_power: None # inverter power consumption in W (dynamically set)
380
+ bms_power: 50 # BMS power consumption in W
381
+ force_charge_power: 5.00 # power used when Force Charge is scheduled
382
+ allowed_drain: 4, # % tolerance below min_soc before float charge starts
383
+ float_current: 4, # BMS float charge current in A
384
+ bat_resistance: 0.070 # internal resistance of a battery in ohms
385
+ volt_curve: lifepo4_curve # battery OCV from 0% to 100% SoC
386
+ nominal_soc: 55 # SoC for nominal open circuit voltage
387
+ generation_days: 3 # number of days to use for average generation (1-7)
388
+ consumption_days: 3 # number of days to use for average consumption (1-7)
389
+ consumption_span: 'week' # 'week' = last 7 days or 'weekday' = last 7 weekdays e.g. Saturdays
390
+ use_today: 21.0 # hour when today's generation and consumption data will be used
391
+ min_hours: 0.25 # minimum charge time to set (in decimal hours)
392
+ min_kwh: 0.5 # minimum charge to add in kwh
393
+ solcast_adjust: 100 # % adjustment to make to Solcast forecast
394
+ solar_adjust: 100 # % adjustment to make to Solar forecast
395
+ forecast_selection: 1 # 1 = only update charge times if forecast is available, 0 = use best available data. Default is 1.
396
+ annual_consumption: None # optional annual consumption in kWh. If set, this replaces consumption history
397
+ timed_mode: 0 # 0 = None, 1 = use timed work mode, 2 = strategy mode
398
+ special_contingency: 30 # contingency for special days when consumption might be higher
394
399
  special_days: ['12-25', '12-26', '01-01']
395
- full_charge: None # day of month (1-28) to do full charge or 'daily' or day of week: 'Mon', 'Tue' etc
396
- derate_temp: 21 # battery temperature in C when derating charge current is applied
397
- derate_step: 5 # step size for derating e.g. 21, 16, 11
398
- derating: [24, 15, 10, 2] # derated charge current for each temperature step e.g. 21C, 16C, 11C, 6C
399
- force: 1 # 1 = disable strategy periods when setting charge. 0 = fail if strategy period has been set.
400
- data_wrap: 6 # data items to show per line
401
- target_soc: None # target soc for charging
402
- shading: {} # effect of shading on Solcast / Solar (see below)
403
- save: 'charge_needed.txt' # where to save calculation data for charge_compare(). '###' gets replaced with todays date.
400
+ full_charge: None # day of month (1-28) to do full charge or 'daily' or day of week: 'Mon', 'Tue' etc
401
+ force: 1 # 1 = disable strategy periods when setting charge. 0 = fail if strategy period has been set.
402
+ data_wrap: 6 # data items to show per line
403
+ target_soc: None # target soc for charging (over-rides calculated value)
404
+ shading: {} # effect of shading on Solcast / Solar (see below)
405
+ save: 'charge_needed.txt' # where to save calculation data for charge_compare(). '###' gets replaced with todays date.
404
406
  ```
405
407
 
406
408
  These values are stored / available in f.charge_config.
@@ -784,6 +786,14 @@ This setting can be:
784
786
 
785
787
  # Version Info
786
788
 
789
+ 2.6.0<br>
790
+ Rework charge de-rating with temperature, losses and other info provided by get_battery() to take new BMS behaviour into account.
791
+
792
+ 2.5.9<br>
793
+ Change loss parameters to separate AC/DC, DC/AC conversion losses and battery charge / discharge losses.
794
+ Update charge calibration for new BMS firmware.
795
+ Increase de-rating temperature from 21C to 28C for new BMS firmware.
796
+
787
797
  2.5.8<br>
788
798
  Fix incorrect charging setup when force_charge=1.
789
799
  Rework charge_periods() to consolidate charge periods to reduce number of time segments when timed_mode=2.
@@ -0,0 +1,7 @@
1
+ foxesscloud/foxesscloud.py,sha256=2Nsr3hmtq6jcFLslcOrkBEvFKgjPiZdpsQO0O85S928,213371
2
+ foxesscloud/openapi.py,sha256=sJopUqWbk8OrO9MXFAnEQvs14G6t4GWtX05lL-tXymI,206640
3
+ foxesscloud-2.6.0.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
+ foxesscloud-2.6.0.dist-info/METADATA,sha256=GUbw1ZyKzfPaxtmBxGnuM__RKDZqAJpeejiGb4KZfSM,56993
5
+ foxesscloud-2.6.0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
+ foxesscloud-2.6.0.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
+ foxesscloud-2.6.0.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- foxesscloud/foxesscloud.py,sha256=9WivKa8ysTZ52aTgPmTxJYDAnAz5TobibgvpJkrK_KM,211855
2
- foxesscloud/openapi.py,sha256=SCr8VOz1aP0Nk5vlUi3-kJ-AMNpW0STlVWQRbbABjBE,205502
3
- foxesscloud-2.5.8.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
- foxesscloud-2.5.8.dist-info/METADATA,sha256=Aon8oxksMZP4Z9NgWKOwsz7Dpo8raj8A2cIjakNL_xM,56304
5
- foxesscloud-2.5.8.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
- foxesscloud-2.5.8.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
- foxesscloud-2.5.8.dist-info/RECORD,,