foxesscloud 2.6.0__py3-none-any.whl → 2.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud
4
- Updated: 05 October 2024
4
+ Updated: 12 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.2"
13
+ version = "1.7.4"
14
14
  print(f"FoxESS-Cloud version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -65,7 +65,7 @@ def plot_show():
65
65
  ##################################################################################################
66
66
  ##################################################################################################
67
67
 
68
- def query_date(d, offset = None):
68
+ def convert_date(d):
69
69
  if d is not None and len(d) < 18:
70
70
  if len(d) == 10:
71
71
  d += ' 00:00:00'
@@ -76,8 +76,12 @@ def query_date(d, offset = None):
76
76
  try:
77
77
  t = datetime.now() if d is None else datetime.strptime(d, "%Y-%m-%d %H:%M:%S")
78
78
  except Exception as e:
79
- output(f"** query_date(): {str(e)}")
79
+ output(f"** convert_date(): {str(e)}")
80
80
  return None
81
+ return t
82
+
83
+ def query_date(d, offset = None):
84
+ t = convert_date(d)
81
85
  if offset is not None:
82
86
  t += timedelta(days = offset)
83
87
  return {'year': t.year, 'month': t.month, 'day': t.day, 'hour': t.hour, 'minute': t.minute, 'second': t.second}
@@ -477,6 +481,7 @@ def get_device(sn=None):
477
481
  device_id = device.get('deviceID')
478
482
  device_sn = device.get('deviceSN')
479
483
  battery = None
484
+ batteries = None
480
485
  battery_settings = None
481
486
  schedule = None
482
487
  templates = None
@@ -575,6 +580,7 @@ def get_firmware():
575
580
  ##################################################################################################
576
581
 
577
582
  battery = None
583
+ batteries = None
578
584
  battery_settings = None
579
585
 
580
586
  # 1 = Residual Energy, 2 = Residual Capacity
@@ -592,8 +598,8 @@ battery_params = {
592
598
  2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
593
599
  'step': 5,
594
600
  'offset': 5,
595
- 'charge_loss': 1.040,
596
- 'discharge_loss': 0.975},
601
+ 'charge_loss': 1.08,
602
+ 'discharge_loss': 0.85},
597
603
  }
598
604
 
599
605
  def get_battery(info=1):
@@ -625,12 +631,16 @@ def get_battery(info=1):
625
631
  errno = response.json().get('errno')
626
632
  output(f"** get_battery().info, no result data, {errno_message(errno)}")
627
633
  else:
628
- battery['info'] = result['batteries']
629
- if battery['info'][0]['masterVersion'] >= '1.014':
634
+ battery['info'] = result['batteries'][0]
635
+ if battery['info']['masterVersion'] >= '1.014':
630
636
  residual_handling = 2
637
+ battery['residual_handling'] = residual_handling
638
+ battery['rated_capacity'] = None
639
+ battery['soh'] = None
640
+ battery['soh_supported'] = False
631
641
  if battery.get('residual') is not None:
632
642
  battery['residual'] /= 1000
633
- if residual_handling == 2:
643
+ if battery['residual_handling'] == 2:
634
644
  capacity = battery.get('residual')
635
645
  soc = battery.get('soc')
636
646
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
@@ -641,13 +651,70 @@ def get_battery(info=1):
641
651
  battery['capacity'] = round(capacity, 3)
642
652
  battery['residual'] = round(residual, 3)
643
653
  battery['charge_rate'] = 50
644
- params = battery_params[residual_handling]
654
+ params = battery_params[battery['residual_handling']]
645
655
  battery['charge_loss'] = params['charge_loss']
646
656
  battery['discharge_loss'] = params['discharge_loss']
647
657
  if battery.get('temperature') is not None:
648
658
  battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
649
659
  return battery
650
660
 
661
+ def get_batteries(info=1):
662
+ global token, device_id, battery, debug_setting, messages, batteries, battery_params, residual_handling
663
+ if get_device() is None:
664
+ return None
665
+ output(f"getting batteries", 2)
666
+ params = {'id': device_id}
667
+ response = signed_get(path="/generic/v0/device/battery/info", params=params)
668
+ if response.status_code != 200:
669
+ output(f"** get_batteries() got response code {response.status_code}: {response.reason}")
670
+ return None
671
+ result = response.json().get('result')
672
+ if result is None:
673
+ errno = response.json().get('errno')
674
+ output(f"** get_batteries(), no result data, {errno_message(errno)}")
675
+ return None
676
+ batteries = result['batterys']
677
+ if info == 1:
678
+ response = signed_get(path="/generic/v0/device/battery/list", params=params)
679
+ if response.status_code != 200:
680
+ output(f"** get_battery().info got response code {response.status_code}: {response.reason}")
681
+ else:
682
+ result = response.json().get('result')
683
+ if result is None:
684
+ errno = response.json().get('errno')
685
+ output(f"** get_battery().info, no result data, {errno_message(errno)}")
686
+ else:
687
+ for i in range(0, len(batteries)):
688
+ batteries[i]['info'] = result['batteries'][i]
689
+ for b in batteries:
690
+ if b.get('info') is not None and b['info']['masterVersion'] >= '1.014':
691
+ b['residual_handling'] = 2
692
+ if b.get('soh') is not None and b['soh'].isnumeric():
693
+ b['soh_supported'] = True
694
+ else:
695
+ b['rated_capacity'] = None
696
+ b['soh'] = None
697
+ b['soh_supported'] = False
698
+ for b in batteries:
699
+ if b.get('soh_supported') is not None and b['soh_supported']:
700
+ capacity = (b['ratedCapacity'] / 1000 * int(b['soh']) / 100)
701
+ soc = b.get('soc')
702
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
703
+ b['capacity'] = round(capacity, 3)
704
+ b['residual'] = round(residual, 3)
705
+ b['charge_rate'] = 50
706
+ params = battery_params[residual_handling]
707
+ b['charge_loss'] = params['charge_loss']
708
+ b['discharge_loss'] = params['discharge_loss']
709
+ if b.get('temperature') is not None:
710
+ b['charge_rate'] = params['table'][int((b['temperature'] - params['offset']) / params['step'])]
711
+ else:
712
+ get_battery(info=info)
713
+ batteries = [battery]
714
+ break
715
+ battery = batteries[0]
716
+ return batteries
717
+
651
718
  ##################################################################################################
652
719
  # get charge times and save to battery_settings
653
720
  ##################################################################################################
@@ -2118,9 +2185,9 @@ def hours_difference(t1, t2):
2118
2185
  if t1 == t2:
2119
2186
  return 0.0
2120
2187
  if type(t1) is str:
2121
- t1 = datetime.strptime(t1, '%Y-%m-%d %H:%M')
2188
+ t1 = convert_date(t1)
2122
2189
  if type(t2) is str:
2123
- t2 = datetime.strptime(t2, '%Y-%m-%d %H:%M')
2190
+ t2 = convert_date(t2)
2124
2191
  return round((t1 - t2).total_seconds() / 3600,1)
2125
2192
 
2126
2193
  ##################################################################################################
@@ -2297,7 +2364,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
2297
2364
  if d is not None and len(d) < 11:
2298
2365
  d += " 18:00"
2299
2366
  # get dates and times
2300
- system_time = (datetime.now(tz=timezone.utc) + timedelta(hours=time_shift)) if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
2367
+ system_time = (datetime.now(tz=timezone.utc) + timedelta(hours=time_shift)) if d is None else convert_date(d)
2301
2368
  time_offset = daylight_saving(system_time) if daylight_saving is not None else 0
2302
2369
  # adjust system to get local time now
2303
2370
  now = system_time + timedelta(hours=time_offset)
@@ -2723,7 +2790,7 @@ charge_config = {
2723
2790
  'export_limit': None, # maximum export power in kW
2724
2791
  'dc_ac_loss': 0.970, # loss converting battery DC power to AC grid power
2725
2792
  '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
2793
+ 'ac_dc_loss': 0.963, # loss converting AC grid power to DC battery charge power
2727
2794
  'inverter_power': 101, # Inverter power consumption in W
2728
2795
  'bms_power': 50, # BMS power consumption in W
2729
2796
  'force_charge_power': 5.00, # charge power in kW when using force charge
@@ -2768,7 +2835,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2768
2835
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2769
2836
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2770
2837
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2771
- global timed_strategy, steps_per_hour, base_time, storage, battery
2838
+ global timed_strategy, steps_per_hour, base_time, storage, battery, charge_rates
2772
2839
  print(f"\n---------------- charge_needed ----------------")
2773
2840
  # validate parameters
2774
2841
  args = locals()
@@ -2796,7 +2863,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2796
2863
  if type(forecast_times) is not list:
2797
2864
  forecast_times = [forecast_times]
2798
2865
  # get dates and times
2799
- system_time = (datetime.now(tz=timezone.utc) + timedelta(hours=time_shift)) if test_time is None else datetime.strptime(test_time, '%Y-%m-%d %H:%M')
2866
+ system_time = (datetime.now(tz=timezone.utc) + timedelta(hours=time_shift)) if test_time is None else convert_date(test_time)
2800
2867
  time_offset = daylight_saving(system_time) if daylight_saving is not None else 0
2801
2868
  now = system_time + timedelta(hours=time_offset)
2802
2869
  today = datetime.strftime(now, '%Y-%m-%d')
@@ -2848,7 +2915,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2848
2915
  time_to_end = times[0]['time_to_end']
2849
2916
  charge_time = times[0]['charge_time']
2850
2917
  # work out time window and times with clock changes
2851
- time_to_next = int(time_to_start)
2852
2918
  charge_today = (base_hour + time_to_start / steps_per_hour) < 24
2853
2919
  forecast_day = today if charge_today else tomorrow
2854
2920
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
@@ -2867,17 +2933,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2867
2933
  output(f"start_at = {start_at}, end_by = {end_by}, bat_hold = {bat_hold}")
2868
2934
  output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
2869
2935
  output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
2870
- output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
2936
+ output(f"full_charge = {full_charge}")
2871
2937
  if test_soc is not None:
2872
2938
  current_soc = test_soc
2873
- capacity = 14.54
2939
+ capacity = 14.53
2874
2940
  residual = test_soc * capacity / 100
2875
2941
  bat_volt = 317.4
2876
2942
  bat_power = 0.0
2877
2943
  temperature = 30
2878
- bms_charge_current = 25
2879
- charge_loss = 1.040
2880
- discharge_loss = 0.974
2944
+ bms_charge_current = 15
2945
+ charge_loss = charge_rates[2]['charge_loss']
2946
+ discharge_loss = charge_rates[2]['discharge_loss']
2881
2947
  bat_current = 0.0
2882
2948
  device_power = 6.0
2883
2949
  device_current = 35
@@ -2921,10 +2987,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2921
2987
  output(f" Min SoC: {min_soc}% ({reserve:.2f}kWh)")
2922
2988
  output(f" Current SoC: {current_soc}%")
2923
2989
  output(f" Max SoC: {max_soc}% ({capacity * max_soc / 100:.2f}kWh)")
2924
- output(f" Charge Rate: {bms_charge_current:.1f}A")
2990
+ output(f" Max Charge: {bms_charge_current:.1f}A")
2925
2991
  output(f" Temperature: {temperature:.1f}°C")
2926
2992
  output(f" Resistance: {bat_resistance:.2f} ohms")
2927
2993
  output(f" Nominal OCV: {bat_ocv:.1f}V at {nominal_soc}% SoC")
2994
+ output(f" Losses: {charge_loss * 100:.1f}% charge / {discharge_loss * 100:.1f}% discharge")
2928
2995
  # charge current may be derated based on temperature
2929
2996
  charge_current = device_current if charge_config['charge_current'] is None else charge_config['charge_current']
2930
2997
  if charge_current > bms_charge_current:
@@ -3100,7 +3167,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3100
3167
  work_mode_timed[i]['discharge'] = discharge_timed[i]
3101
3168
  # build the battery residual if we don't add any charge and don't limit discharge at min_soc
3102
3169
  kwh_current = residual - (charge_timed[0] - discharge_timed[0]) * (hour_now % 1)
3103
- (bat_timed, kwh_min) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=capacity)
3170
+ (bat_timed, kwh_min) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=time_to_end, kwh_min=capacity)
3104
3171
  # work out what we need to add to stay above reserve and provide contingency or to hit target_soc
3105
3172
  contingency = charge_config['special_contingency'] if tomorrow[-5:] in charge_config['special_days'] else charge_config['contingency']
3106
3173
  contingency = contingency[quarter] if type(contingency) is list else contingency
@@ -3178,7 +3245,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3178
3245
  work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else max_soc
3179
3246
  work_mode_timed[i]['discharge'] *= (1-t)
3180
3247
  # rebuild the battery residual with the charge added and min_soc
3181
- (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3248
+ (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=start_timed)
3182
3249
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
3183
3250
  # show the results
3184
3251
  output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
@@ -3263,10 +3330,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3263
3330
 
3264
3331
  def charge_compare(save=None, v=None, show_data=1, show_plot=3, d=None):
3265
3332
  global charge_config, storage
3266
- now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3333
+ now = convert_date(d)
3267
3334
  yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3268
3335
  if save is None and charge_config.get('save') is not None:
3269
- save = charge_config.get('save').replace('###', yesterday)
3336
+ save = charge_config.get('save').replace('###', yesterday if d is None else d[:10])
3270
3337
  if not os.path.exists(storage + save):
3271
3338
  save = None
3272
3339
  if save is None:
@@ -3293,7 +3360,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3, d=None):
3293
3360
  base_hour = int(time_hours(base_time[11:16]))
3294
3361
  start_day = base_time[:10]
3295
3362
  print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:.0f}%")
3296
- now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
3363
+ now = convert_date(base_time)
3297
3364
  end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
3298
3365
  if v is None:
3299
3366
  v = ['pvPower', 'loadsPower', 'SoC']
@@ -3326,13 +3393,14 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3, d=None):
3326
3393
  for i in range(0, run_time):
3327
3394
  plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
3328
3395
  if show_data > 0 and plots.get('SoC') is not None:
3329
- data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3330
- s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3396
+ data_wrap = 1 #charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 1
3397
+ s = f"\nBattery Energy kWh (predicted / actual):" if show_data == 2 else f"\nBattery SoC (predicted / actual):"
3331
3398
  h = base_hour
3332
3399
  t = 0
3333
3400
  while t < len(time_line) and bat_timed[t] is not None and plots['SoC'][t] is not None:
3334
3401
  col = h % data_wrap
3335
3402
  s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3403
+ s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3336
3404
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3337
3405
  h += 1
3338
3406
  t += steps_per_hour
@@ -3411,21 +3479,28 @@ def bat_count(cell_count):
3411
3479
  battery_info_app_key = "aug938dqt5cbqhvq69ixc4v39q6wtw"
3412
3480
 
3413
3481
  # show information about the current state of the batteries
3414
- def battery_info(log=0, plot=1, count=None, info=1):
3415
- global debug_setting, battery_info_app_key, residual_handling
3416
- output_spool(battery_info_app_key)
3417
- bat = get_battery(info=info)
3482
+ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3483
+ global debug_setting, battery_info_app_key
3418
3484
  if bat is None:
3419
- output_close()
3485
+ bats = get_batteries(info=info)
3486
+ if bats is None:
3487
+ return None
3488
+ for i in range(0, len(bats)):
3489
+ output(f"\n----------------------- BMS {i+1} -----------------------")
3490
+ battery_info(log=log, plot=plot, count=count, info=info, bat=bats[i])
3420
3491
  return None
3492
+ output_spool(battery_info_app_key)
3421
3493
  nbat = None
3422
3494
  if info == 1 and bat.get('info') is not None:
3423
- for b in bat['info']:
3424
- output(f"\nSN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3425
- nbat = 0
3426
- for s in b['slaveBatteries']:
3427
- nbat += 1
3428
- output(f"SN {s['sn']}, {s['batType']}, Version {s['version']} (Battery {nbat})")
3495
+ b = bat['info']
3496
+ output(f"SN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3497
+ nbat = 0
3498
+ for s in b['slaveBatteries']:
3499
+ nbat += 1
3500
+ output(f"SN {s['sn']}, {s['batType']}, Version {s['version']} (Battery {nbat})")
3501
+ output()
3502
+ rated_capacity = bat.get('ratedCapacity')
3503
+ bat_soh = bat.get('soh')
3429
3504
  bat_volt = bat['volt']
3430
3505
  current_soc = bat['soc']
3431
3506
  residual = bat['residual']
@@ -3476,9 +3551,12 @@ def battery_info(log=0, plot=1, count=None, info=1):
3476
3551
  for v in cell_temps:
3477
3552
  s +=f",{v:.0f}"
3478
3553
  return s
3479
- output(f"\nCurrent SoC: {current_soc}%")
3554
+ if rated_capacity is not None:
3555
+ output(f"Rated Capacity: {rated_capacity / 1000:.2f}kWh")
3556
+ output(f"SoH: {bat_soh}%")
3557
+ output(f"Current SoC: {current_soc}%")
3480
3558
  output(f"Capacity: {capacity:.2f}kWh")
3481
- output(f"Residual: {residual:.2f}kWh")
3559
+ output(f"Residual: {residual:.2f}kWh" + (" (SoC x Capacity)" if bat['residual_handling'] == 2 else ""))
3482
3560
  output(f"InvBatVolt: {bat_volt:.1f}V")
3483
3561
  output(f"InvBatCurrent: {bat_current:.1f}A")
3484
3562
  output(f"State: {'Charging' if bat_power < 0 else 'Discharging'} ({abs(bat_power):.3f}kW)")
@@ -3888,7 +3966,7 @@ class Solcast :
3888
3966
  # The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
3889
3967
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3890
3968
  self.data = {}
3891
- now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3969
+ now = convert_date(d)
3892
3970
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3893
3971
  self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
3894
3972
  self.quarter = int(self.today[5:7]) // 3 % 4
@@ -4232,7 +4310,7 @@ class Solar :
4232
4310
  def __init__(self, reload=0, quiet=False, shading=None, d=None):
4233
4311
  global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
4234
4312
  self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
4235
- now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
4313
+ now = convert_date(d)
4236
4314
  self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
4237
4315
  self.quarter = int(self.today[5:7]) // 3 % 4
4238
4316
  self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
foxesscloud/openapi.py CHANGED
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 05 October 2024
4
+ Updated: 12 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.6.0"
13
+ version = "2.6.2"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -66,8 +66,7 @@ def plot_show():
66
66
  ##################################################################################################
67
67
  ##################################################################################################
68
68
 
69
- # return query date as a dictionary with year, month, day, hour, minute, second
70
- def query_date(d, offset = None):
69
+ def convert_date(d):
71
70
  if d is not None and len(d) < 18:
72
71
  if len(d) == 10:
73
72
  d += ' 00:00:00'
@@ -78,8 +77,13 @@ def query_date(d, offset = None):
78
77
  try:
79
78
  t = datetime.now() if d is None else datetime.strptime(d, "%Y-%m-%d %H:%M:%S")
80
79
  except Exception as e:
81
- output(f"** query_date(): {str(e)}")
80
+ output(f"** convert_date(): {str(e)}")
82
81
  return None
82
+ return t
83
+
84
+ # return query date as a dictionary with year, month, day, hour, minute, second
85
+ def query_date(d, offset = None):
86
+ t = convert_date(d)
83
87
  if offset is not None:
84
88
  t += timedelta(days = offset)
85
89
  return {'year': t.year, 'month': t.month, 'day': t.day, 'hour': t.hour, 'minute': t.minute, 'second': t.second}
@@ -94,7 +98,7 @@ def query_time(d, time_span):
94
98
  else:
95
99
  d += ':00'
96
100
  try:
97
- t = datetime.now().replace(minute=0, second=0, microsecond=0) if d is None else datetime.strptime(d, "%Y-%m-%d %H:%M:%S")
101
+ t = datetime.now().replace(minute=0, second=0, microsecond=0) if d is None else convert_date(d)
98
102
  except Exception as e:
99
103
  output(f"** query_time(): {str(e)}")
100
104
  return (None, None)
@@ -469,6 +473,7 @@ def get_device(sn=None):
469
473
  return None
470
474
  device = result
471
475
  battery = None
476
+ batteries = None
472
477
  battery_settings = None
473
478
  schedule = None
474
479
  get_flag()
@@ -538,6 +543,7 @@ def get_generation(update=1):
538
543
  ##################################################################################################
539
544
 
540
545
  battery = None
546
+ batteries = None
541
547
  battery_settings = None
542
548
  battery_vars = ['SoC', 'invBatVolt', 'invBatCurrent', 'invBatPower', 'batTemperature', 'ResidualEnergy' ]
543
549
  battery_data = ['soc', 'volt', 'current', 'power', 'temperature', 'residual']
@@ -557,11 +563,11 @@ battery_params = {
557
563
  2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
558
564
  'step': 5,
559
565
  'offset': 5,
560
- 'charge_loss': 1.040,
561
- 'discharge_loss': 0.975},
566
+ 'charge_loss': 1.080,
567
+ 'discharge_loss': 0.85},
562
568
  }
563
569
 
564
- def get_battery(v = None, info=0):
570
+ def get_battery(info=0, v=None):
565
571
  global device_sn, battery, debug_setting, residual_handling, battery_params
566
572
  if get_device() is None:
567
573
  return None
@@ -573,7 +579,11 @@ def get_battery(v = None, info=0):
573
579
  battery = {}
574
580
  for i in range(0, len(battery_vars)):
575
581
  battery[battery_data[i]] = result[i].get('value')
576
- if residual_handling == 2:
582
+ battery['residual_handling'] = residual_handling
583
+ battery['rated_capacity'] = None
584
+ battery['soh'] = None
585
+ battery['soh_supported'] = False
586
+ if battery['residual_handling'] == 2:
577
587
  capacity = battery.get('residual')
578
588
  soc = battery.get('soc')
579
589
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
@@ -585,13 +595,19 @@ def get_battery(v = None, info=0):
585
595
  battery['residual'] = round(residual, 3)
586
596
  battery['status'] = 1
587
597
  battery['charge_rate'] = 50
588
- params = battery_params[residual_handling]
598
+ params = battery_params[battery['residual_handling']]
589
599
  battery['charge_loss'] = params['charge_loss']
590
600
  battery['discharge_loss'] = params['discharge_loss']
591
601
  if battery.get('temperature') is not None:
592
602
  battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
593
603
  return battery
594
604
 
605
+ def get_batteries(info=0):
606
+ global battery, batteries
607
+ get_battery(info=info)
608
+ batteries = [battery]
609
+ return batteries
610
+
595
611
  ##################################################################################################
596
612
  # get charge times and save to battery_settings
597
613
  ##################################################################################################
@@ -1974,9 +1990,9 @@ def hours_difference(t1, t2):
1974
1990
  if t1 == t2:
1975
1991
  return 0.0
1976
1992
  if type(t1) is str:
1977
- t1 = datetime.strptime(t1, '%Y-%m-%d %H:%M')
1993
+ t1 = convert_date(t1)
1978
1994
  if type(t2) is str:
1979
- t2 = datetime.strptime(t2, '%Y-%m-%d %H:%M')
1995
+ t2 = convert_date(t2)
1980
1996
  return round((t1 - t2).total_seconds() / 3600,1)
1981
1997
 
1982
1998
  ##################################################################################################
@@ -2153,7 +2169,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
2153
2169
  if d is not None and len(d) < 11:
2154
2170
  d += " 18:00"
2155
2171
  # get dates and times
2156
- system_time = (datetime.now(tz=timezone.utc) + timedelta(hours=time_shift)) if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
2172
+ system_time = (datetime.now(tz=timezone.utc) + timedelta(hours=time_shift)) if d is None else convert_date(d)
2157
2173
  time_offset = daylight_saving(system_time) if daylight_saving is not None else 0
2158
2174
  # adjust system to get local time now
2159
2175
  now = system_time + timedelta(hours=time_offset)
@@ -2579,7 +2595,7 @@ charge_config = {
2579
2595
  'export_limit': None, # maximum export power in kW
2580
2596
  'dc_ac_loss': 0.97, # loss converting battery DC power to AC grid power
2581
2597
  '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
2598
+ 'ac_dc_loss': 0.963, # loss converting AC grid power to DC battery charge power
2583
2599
  'inverter_power': 101, # Inverter power consumption in W
2584
2600
  'bms_power': 50, # BMS power consumption in W
2585
2601
  'force_charge_power': 5.00, # charge power in kW when using force charge
@@ -2624,7 +2640,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2624
2640
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2625
2641
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2626
2642
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2627
- global timed_strategy, steps_per_hour, base_time, storage, battery
2643
+ global timed_strategy, steps_per_hour, base_time, storage, battery, charge_rates
2628
2644
  print(f"\n---------------- charge_needed ----------------")
2629
2645
  # validate parameters
2630
2646
  args = locals()
@@ -2652,7 +2668,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2652
2668
  if type(forecast_times) is not list:
2653
2669
  forecast_times = [forecast_times]
2654
2670
  # get dates and times
2655
- system_time = (datetime.now(tz=timezone.utc) + timedelta(hours=time_shift)) if test_time is None else datetime.strptime(test_time, '%Y-%m-%d %H:%M')
2671
+ system_time = (datetime.now(tz=timezone.utc) + timedelta(hours=time_shift)) if test_time is None else convert_date(test_time)
2656
2672
  time_offset = daylight_saving(system_time) if daylight_saving is not None else 0
2657
2673
  now = system_time + timedelta(hours=time_offset)
2658
2674
  today = datetime.strftime(now, '%Y-%m-%d')
@@ -2704,7 +2720,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2704
2720
  time_to_end = times[0]['time_to_end']
2705
2721
  charge_time = times[0]['charge_time']
2706
2722
  # work out time window and times with clock changes
2707
- time_to_next = int(time_to_start)
2708
2723
  charge_today = (base_hour + time_to_start / steps_per_hour) < 24
2709
2724
  forecast_day = today if charge_today else tomorrow
2710
2725
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
@@ -2723,7 +2738,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2723
2738
  output(f"start_at = {start_at}, end_by = {end_by}, force_charge = {force_charge}")
2724
2739
  output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
2725
2740
  output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
2726
- output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
2741
+ output(f"full_charge = {full_charge}")
2727
2742
  if test_soc is not None:
2728
2743
  current_soc = test_soc
2729
2744
  capacity = 14.54
@@ -2731,9 +2746,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2731
2746
  bat_volt = 317.4
2732
2747
  bat_power = 0.0
2733
2748
  temperature = 30
2734
- bms_charge_current = 25
2735
- charge_loss = 1.040
2736
- discharge_loss = 0.974
2749
+ bms_charge_current = 15
2750
+ charge_loss = charge_rates[2]['charge_loss']
2751
+ discharge_loss = charge_rates[2]['discharge_loss']
2737
2752
  bat_current = 0.0
2738
2753
  device_power = 6.0
2739
2754
  device_current = 35
@@ -2778,7 +2793,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2778
2793
  output(f" Current SoC: {current_soc}%")
2779
2794
  output(f" Max SoC: {max_soc}% ({capacity * max_soc / 100:.2f}kWh)")
2780
2795
  output(f" Temperature: {temperature:.1f}°C")
2781
- output(f" Charge Rate: {bms_charge_current:.1f}A")
2796
+ output(f" Max Charge: {bms_charge_current:.1f}A")
2782
2797
  output(f" Resistance: {bat_resistance:.2f} ohms")
2783
2798
  output(f" Nominal OCV: {bat_ocv:.1f}V at {nominal_soc}% SoC")
2784
2799
  # charge current may be derated based on temperature
@@ -2955,7 +2970,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2955
2970
  work_mode_timed[i]['discharge'] = discharge_timed[i]
2956
2971
  # build the battery residual if we don't add any charge and don't limit discharge at min_soc
2957
2972
  kwh_current = residual - (charge_timed[0] - discharge_timed[0]) * (hour_now % 1)
2958
- (bat_timed, kwh_min) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=capacity)
2973
+ (bat_timed, kwh_min) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=time_to_end, kwh_min=capacity)
2959
2974
  # work out what we need to add to stay above reserve and provide contingency or to hit target_soc
2960
2975
  contingency = charge_config['special_contingency'] if tomorrow[-5:] in charge_config['special_days'] else charge_config['contingency']
2961
2976
  contingency = contingency[quarter] if type(contingency) is list else contingency
@@ -3033,7 +3048,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3033
3048
  work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else max_soc
3034
3049
  work_mode_timed[i]['discharge'] *= (1-t)
3035
3050
  # rebuild the battery residual with any charge added and min_soc
3036
- (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3051
+ (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=start_timed)
3037
3052
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
3038
3053
  # show the results
3039
3054
  output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
@@ -3117,10 +3132,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3117
3132
 
3118
3133
  def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3119
3134
  global charge_config, storage
3120
- now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3135
+ now = convert_date(d)
3121
3136
  yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3122
3137
  if save is None and charge_config.get('save') is not None:
3123
- save = charge_config.get('save').replace('###', yesterday)
3138
+ save = charge_config.get('save').replace('###', yesterday if d is None else d[:10])
3124
3139
  if not os.path.exists(storage + save):
3125
3140
  save = None
3126
3141
  if save is None:
@@ -3147,7 +3162,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3147
3162
  base_hour = int(time_hours(base_time[11:16]))
3148
3163
  start_day = base_time[:10]
3149
3164
  print(f"Run at {start_day} {hours_time(hour_now)} with SoC {current_soc:.0f}%")
3150
- now = datetime.strptime(base_time, '%Y-%m-%d %H:%M')
3165
+ now = convert_date(base_time)
3151
3166
  end_day = datetime.strftime(now + timedelta(hours=run_time / steps_per_hour), '%Y-%m-%d')
3152
3167
  if v is None:
3153
3168
  v = ['pvPower', 'loadsPower', 'SoC']
@@ -3264,21 +3279,28 @@ def bat_count(cell_count):
3264
3279
  battery_info_app_key = "aug938dqt5cbqhvq69ixc4v39q6wtw"
3265
3280
 
3266
3281
  # show information about the current state of the batteries
3267
- def battery_info(log=0, plot=1, count=None, info=1):
3268
- global debug_setting, battery_info_app_key, residual_handling
3269
- output_spool(battery_info_app_key)
3270
- bat = get_battery(info=info)
3282
+ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3283
+ global debug_setting, battery_info_app_key
3271
3284
  if bat is None:
3272
- output_close()
3285
+ bats = get_batteries(info=info)
3286
+ if bats is None:
3287
+ return None
3288
+ for i in range(0, len(bats)):
3289
+ output(f"\n----------------------- BMS {i+1} -----------------------")
3290
+ battery_info(log=log, plot=plot, count=count, info=info, bat=bats[i])
3273
3291
  return None
3292
+ output_spool(battery_info_app_key)
3274
3293
  nbat = None
3275
3294
  if info == 1 and bat.get('info') is not None:
3276
- for b in bat['info']:
3277
- output(f"\nSN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3278
- nbat = 0
3279
- for s in b['slaveBatteries']:
3280
- nbat += 1
3281
- output(f"SN {s['sn']}, {s['batType']}, Version {s['version']} (Battery {nbat})")
3295
+ b = bat['info']
3296
+ output(f"SN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3297
+ nbat = 0
3298
+ for s in b['slaveBatteries']:
3299
+ nbat += 1
3300
+ output(f"SN {s['sn']}, {s['batType']}, Version {s['version']} (Battery {nbat})")
3301
+ output()
3302
+ rated_capacity = bat.get('ratedCapacity')
3303
+ bat_soh = bat.get('soh')
3282
3304
  bat_volt = bat['volt']
3283
3305
  current_soc = bat['soc']
3284
3306
  residual = bat['residual']
@@ -3329,7 +3351,10 @@ def battery_info(log=0, plot=1, count=None, info=1):
3329
3351
  for v in cell_temps:
3330
3352
  s +=f",{v:.0f}"
3331
3353
  return s
3332
- output(f"\nCurrent SoC: {current_soc}%")
3354
+ if rated_capacity is not None:
3355
+ output(f"Rated Capacity: {rated_capacity / 1000:.2f}kWh")
3356
+ output(f"SoH: {bat_soh}%")
3357
+ output(f"Current SoC: {current_soc}%")
3333
3358
  output(f"Capacity: {capacity:.2f}kWh")
3334
3359
  output(f"Residual: {residual:.2f}kWh")
3335
3360
  output(f"InvBatVolt: {bat_volt:.1f}V")
@@ -3741,7 +3766,7 @@ class Solcast :
3741
3766
  # The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
3742
3767
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3743
3768
  self.data = {}
3744
- now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3769
+ now = convert_date(d)
3745
3770
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3746
3771
  self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
3747
3772
  self.quarter = int(self.today[5:7]) // 3 % 4
@@ -4085,7 +4110,7 @@ class Solar :
4085
4110
  def __init__(self, reload=0, quiet=False, shading=None, d=None):
4086
4111
  global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
4087
4112
  self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
4088
- now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
4113
+ now = convert_date(d)
4089
4114
  self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
4090
4115
  self.quarter = int(self.today[5:7]) // 3 % 4
4091
4116
  self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.6.0
3
+ Version: 2.6.2
4
4
  Summary: library for accessing Fox ESS cloud data using Open API
5
5
  Author-email: Tony Matthews <tony@quasair.co.uk>
6
6
  Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
@@ -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,8 +117,8 @@ 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. Additional battery attributes include:
120
- + 'info': a list of BMS and battery serial numbers and firmware versions
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:
121
122
  + 'capacity': the estimated battery capacity, derrived from 'residual' and 'soc'
122
123
  + 'charge_rate': the estimated BMS charge rate available, based on the current 'temperature' of the BMS
123
124
  + 'charge_loss': the ratio of the kWh added to the battery for each kWh applied during charging
@@ -786,6 +787,18 @@ This setting can be:
786
787
 
787
788
  # Version Info
788
789
 
790
+ 2.6.2<br>
791
+ Update battery calibration for charge_needed() when residual_handling is 2.
792
+ Update get_battery() and get_batteries() to include states for ratedCapacity, soh, residual_handling and soh_supported.
793
+ Update charge_compare(), Solcast() and Solar() so date (d) parameter is more flexible.
794
+
795
+ 2.6.1<br>
796
+ Fix problem where battery discharges below min_soc while waiting for charging to start.
797
+ Update calibration for Force Charge with BMS 1.014 and later.
798
+ Add get_batteries() to return a list of BMS and batteries where inverters support more than 1 BMS.
799
+ Update battery_info() to support multiple BMS.
800
+ Add rated capacity and SoH to battery info if available.
801
+
789
802
  2.6.0<br>
790
803
  Rework charge de-rating with temperature, losses and other info provided by get_battery() to take new BMS behaviour into account.
791
804
 
@@ -0,0 +1,7 @@
1
+ foxesscloud/foxesscloud.py,sha256=j1-EcJ3vudFCalHBbyBXUTl99s4-foWqq1831YLdHXg,216792
2
+ foxesscloud/openapi.py,sha256=USny4eYMLxG2rg3FsijGp7w_cOT8eMMvy0pVN0DbVSU,207227
3
+ foxesscloud-2.6.2.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
+ foxesscloud-2.6.2.dist-info/METADATA,sha256=Y96zL2xTMWPk1yjNnO_QQgulzpPy82Q6IXiEVYHyxxE,57751
5
+ foxesscloud-2.6.2.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
+ foxesscloud-2.6.2.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
+ foxesscloud-2.6.2.dist-info/RECORD,,
@@ -1,7 +0,0 @@
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,,