foxesscloud 2.7.0__tar.gz → 2.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.7.0
3
+ Version: 2.7.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
@@ -158,12 +158,12 @@ set_min() applies new SoC settings to the inverter. The parameters update batter
158
158
  + minSoc: min Soc setting e.g. 10 = 10%
159
159
 
160
160
  set_charge() takes the charge times from the battery_settings and applies these to the inverter. The parameters are optional and will update battery_settings. You should specify all 3 parameter for a time period:
161
- + ch1: enable charge from grid for period 1 (True or False)
162
- + st1: the start time for period 1
163
- + en1: the end time for period 1
164
- + ch2: enable charge from grid for period 2 (True or False)
165
- + st2: the start time for period 2
166
- + en2: the end time for period 2
161
+ + ch1: enable charge from grid for period 1 (default True)
162
+ + st1: the start time for period 1 (default 0)
163
+ + en1: the end time for period 1 (default 0)
164
+ + ch2: enable charge from grid for period 2 (default True)
165
+ + st2: the start time for period 2 (default 0)
166
+ + en2: the end time for period 2 (default 0)
167
167
  + enable: set to 0 to show settings but stop inverter settings being updated. Default is 1.
168
168
 
169
169
  set_period() returns a period structure that can be used to build a list for set_schedule()
@@ -807,6 +807,22 @@ This setting can be:
807
807
 
808
808
  # Version Info
809
809
 
810
+ 2.7.2<br>
811
+ Fix to get_battery() to return error and flag status=0 in f.battery when the cloud is not returning valid data.
812
+ Fix exception calculating SoH if ratedCapacity is returned as 0 when cloud is not returning valid data.
813
+ Update charge_rate in charge_needed() to use a blended charge rate based on battery warming up during charging.
814
+ Fix exception in set_charge() caused by incorrect default parameter values.
815
+ Update charge_needed() to only show forecast that is in use.
816
+
817
+ 2.7.1<br>
818
+ Update charge_needed() so it only gets generation history if there is no forecast to reduce API calls and save time.
819
+ Update default parameter values for set_charge() so the other time period is cleared if you only set 1 time.
820
+ Fix problem where a full charge was being set when charge_needed() is called with force_charge=1.
821
+ Move charging to the end of the charge time when force_charge=1 so the charge time completes with the required charge.
822
+ Update battery predictions to more accurately reflect what happens when SoC gets to min_soc or fd_soc.
823
+ Correct model to use inverter operating losses instead of BMS losses when the battery is above min_soc.
824
+ Correct exception in Solcast and Solar when a forecast is not available.
825
+
810
826
  2.7.0<br>
811
827
  Allow charge_loss / discharge_loss to be configured for charge_needed().
812
828
  Change 'Force Charge' to 'Battery Hold' in charge times to avoid confusion with Force Charge work mode.
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: foxesscloud
3
- Version: 2.7.0
4
- Summary: library for accessing Fox ESS cloud data using Open API
5
- Author-email: Tony Matthews <tony@quasair.co.uk>
6
- Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
7
- Project-URL: Bug Tracker, https://github.com/TonyM1958/FoxESS-Cloud/issues
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.7
12
- Description-Content-Type: text/markdown
13
- License-File: LICENCE
14
-
15
1
  # FoxESS-Cloud
16
2
 
17
3
  <a href="https://www.buymeacoffee.com/tonym1958" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174" align="right"></a>
@@ -158,12 +144,12 @@ set_min() applies new SoC settings to the inverter. The parameters update batter
158
144
  + minSoc: min Soc setting e.g. 10 = 10%
159
145
 
160
146
  set_charge() takes the charge times from the battery_settings and applies these to the inverter. The parameters are optional and will update battery_settings. You should specify all 3 parameter for a time period:
161
- + ch1: enable charge from grid for period 1 (True or False)
162
- + st1: the start time for period 1
163
- + en1: the end time for period 1
164
- + ch2: enable charge from grid for period 2 (True or False)
165
- + st2: the start time for period 2
166
- + en2: the end time for period 2
147
+ + ch1: enable charge from grid for period 1 (default True)
148
+ + st1: the start time for period 1 (default 0)
149
+ + en1: the end time for period 1 (default 0)
150
+ + ch2: enable charge from grid for period 2 (default True)
151
+ + st2: the start time for period 2 (default 0)
152
+ + en2: the end time for period 2 (default 0)
167
153
  + enable: set to 0 to show settings but stop inverter settings being updated. Default is 1.
168
154
 
169
155
  set_period() returns a period structure that can be used to build a list for set_schedule()
@@ -807,6 +793,22 @@ This setting can be:
807
793
 
808
794
  # Version Info
809
795
 
796
+ 2.7.2<br>
797
+ Fix to get_battery() to return error and flag status=0 in f.battery when the cloud is not returning valid data.
798
+ Fix exception calculating SoH if ratedCapacity is returned as 0 when cloud is not returning valid data.
799
+ Update charge_rate in charge_needed() to use a blended charge rate based on battery warming up during charging.
800
+ Fix exception in set_charge() caused by incorrect default parameter values.
801
+ Update charge_needed() to only show forecast that is in use.
802
+
803
+ 2.7.1<br>
804
+ Update charge_needed() so it only gets generation history if there is no forecast to reduce API calls and save time.
805
+ Update default parameter values for set_charge() so the other time period is cleared if you only set 1 time.
806
+ Fix problem where a full charge was being set when charge_needed() is called with force_charge=1.
807
+ Move charging to the end of the charge time when force_charge=1 so the charge time completes with the required charge.
808
+ Update battery predictions to more accurately reflect what happens when SoC gets to min_soc or fd_soc.
809
+ Correct model to use inverter operating losses instead of BMS losses when the battery is above min_soc.
810
+ Correct exception in Solcast and Solar when a forecast is not available.
811
+
810
812
  2.7.0<br>
811
813
  Allow charge_loss / discharge_loss to be configured for charge_needed().
812
814
  Change 'Force Charge' to 'Battery Hold' in charge times to avoid confusion with Force Charge work mode.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "foxesscloud"
7
- version = "2.7.0"
7
+ version = "2.7.2"
8
8
  authors = [
9
9
  {name="Tony Matthews", email="tony@quasair.co.uk"},
10
10
  ]
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud
4
- Updated: 06 November 2024
4
+ Updated: 23 November 2024
5
5
  By: Tony Matthews
6
6
  """
7
7
  ##################################################################################################
@@ -10,7 +10,7 @@ By: Tony Matthews
10
10
  # ALL RIGHTS ARE RESERVED © Tony Matthews 2023
11
11
  ##################################################################################################
12
12
 
13
- version = "1.8.1"
13
+ version = "1.8.3"
14
14
  print(f"FoxESS-Cloud version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -600,7 +600,7 @@ battery_params = {
600
600
  2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
601
601
  'step': 5,
602
602
  'offset': 5,
603
- 'charge_loss': 1.07,
603
+ 'charge_loss': 1.08,
604
604
  'discharge_loss': 0.95},
605
605
  # Mira BMS with firmware 1.014 or later
606
606
  3: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
@@ -615,6 +615,7 @@ def get_battery(info=1, rated=None, count=None):
615
615
  if get_device() is None:
616
616
  return None
617
617
  output(f"getting battery", 2)
618
+ battery = None
618
619
  params = {'id': device_id}
619
620
  response = signed_get(path="/c/v0/device/battery/info", params=params)
620
621
  if response.status_code != 200:
@@ -625,11 +626,8 @@ def get_battery(info=1, rated=None, count=None):
625
626
  errno = response.json().get('errno')
626
627
  output(f"** get_battery(), no result data, {errno_message(errno)}")
627
628
  return None
628
- saved_info = battery['info'] if battery is not None and battery.get('info') is not None else None
629
629
  battery = result
630
- if saved_info is not None:
631
- battery['info'] = saved_info
632
- elif info == 1:
630
+ if info == 1:
633
631
  response = signed_get(path="/generic/v0/device/battery/list", params=params)
634
632
  if response.status_code != 200:
635
633
  output(f"** get_battery().info got response code {response.status_code}: {response.reason}")
@@ -649,6 +647,9 @@ def get_battery(info=1, rated=None, count=None):
649
647
  battery['residual_handling'] = residual_handling
650
648
  battery['soh'] = None
651
649
  battery['soh_supported'] = False
650
+ if battery.get('status') is None or battery['status'] != 1:
651
+ output(f"** get_battery(): battery status not available")
652
+ return None
652
653
  if battery.get('residual') is not None:
653
654
  battery['residual'] /= 1000
654
655
  if battery['residual_handling'] == 2:
@@ -680,9 +681,9 @@ def get_battery(info=1, rated=None, count=None):
680
681
  battery['charge_loss'] = params['charge_loss']
681
682
  battery['discharge_loss'] = params['discharge_loss']
682
683
  if battery.get('ratedCapacity') is not None and battery.get('capacity') is not None:
683
- battery['soh'] = round(battery['capacity'] * 1000 / battery['ratedCapacity'] * 100, 1)
684
+ battery['soh'] = round(battery['capacity'] * 1000 / battery['ratedCapacity'] * 100, 1) if battery['ratedCapacity'] > 0.0 else None
684
685
  if battery.get('temperature') is not None:
685
- battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
686
+ battery['charge_rate'] = interpolate((battery['temperature'] - params['offset']) / params['step'], params['table'])
686
687
  return battery
687
688
 
688
689
  def get_batteries(info=1, rated=None, count=None):
@@ -690,6 +691,7 @@ def get_batteries(info=1, rated=None, count=None):
690
691
  if get_device() is None:
691
692
  return None
692
693
  output(f"getting batteries", 2)
694
+ batteries = None
693
695
  params = {'id': device_id}
694
696
  response = signed_get(path="/generic/v0/device/battery/info", params=params)
695
697
  if response.status_code != 200:
@@ -722,6 +724,9 @@ def get_batteries(info=1, rated=None, count=None):
722
724
  while len(count) < len(batteries):
723
725
  count.append(None)
724
726
  for i,b in enumerate(batteries):
727
+ if b.get('status') is None or b['status'] != 1:
728
+ output(f"** get_batteries(): battery {i+1} status not available")
729
+ continue
725
730
  b['residual_handling'] = residual_handling
726
731
  if b.get('info') is not None:
727
732
  if b['info'].get('slaveBatteries') is not None:
@@ -738,6 +743,8 @@ def get_batteries(info=1, rated=None, count=None):
738
743
  b['soh'] = int(soh) if soh.isnumeric() and int(soh) > 10 else None
739
744
  b['soh_supported'] = b['soh'] is not None
740
745
  for i, b in enumerate(batteries):
746
+ if b.get('status') is None or b['status'] != 1:
747
+ continue
741
748
  if i == 0:
742
749
  residual_handling = b['residual_handling']
743
750
  get_battery(info=0)
@@ -751,13 +758,13 @@ def get_batteries(info=1, rated=None, count=None):
751
758
  residual = b['capacity'] * b['soc'] / 100
752
759
  b['residual'] = round(residual, 3)
753
760
  if b.get('ratedCapacity') is not None and b.get('capacity') is not None:
754
- b['soh'] = round(b['capacity'] * 1000 / b['ratedCapacity'] * 100, 1)
761
+ b['soh'] = round(b['capacity'] * 1000 / b['ratedCapacity'] * 100, 1) if b['ratedCapacity'] > 0.0 else None
755
762
  b['charge_rate'] = None
756
763
  params = battery_params[b['residual_handling']]
757
764
  b['charge_loss'] = params['charge_loss']
758
765
  b['discharge_loss'] = params['discharge_loss']
759
766
  if b.get('temperature') is not None:
760
- b['charge_rate'] = params['table'][int((b['temperature'] - params['offset']) / params['step'])]
767
+ b['charge_rate'] = interpolate((b['temperature'] - params['offset']) / params['step'], params['table'])
761
768
  return batteries
762
769
 
763
770
  ##################################################################################################
@@ -801,7 +808,7 @@ def time_period(t):
801
808
  result += f" Charge from grid" if t['enableGrid'] else f" Battery Hold"
802
809
  return result
803
810
 
804
- def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force=0, enable=1):
811
+ def set_charge(ch1=True, st1=0, en1=0, ch2=True, st2=0, en2=0, force=0, enable=1):
805
812
  global device_sn, battery_settings, debug_setting, messages, schedule
806
813
  if get_device() is None:
807
814
  return None
@@ -1081,6 +1088,8 @@ def get_remote_settings(key):
1081
1088
 
1082
1089
  def get_named_settings(name):
1083
1090
  global named_settings
1091
+ if get_device() is None:
1092
+ return None
1084
1093
  if type(name) is list:
1085
1094
  result = []
1086
1095
  for n in name:
@@ -2774,8 +2783,8 @@ def strategy_timed(timed_mode, time_line, run_time, min_soc=10, max_soc=100, cur
2774
2783
  strategy = get_strategy(timed_mode=timed_mode)
2775
2784
  for i in range(0, run_time):
2776
2785
  h = time_line[i]
2777
- period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0, 'charge': 0.0,
2778
- 'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2786
+ period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0,
2787
+ 'pv': 0.0, 'charge': 0.0, 'discharge': 0.0, 'fd_kwh': 0.0, 'hold': 0, 'kwh': None}
2779
2788
  if strategy is not None:
2780
2789
  period['mode'] = 'SelfUse'
2781
2790
  for d in strategy:
@@ -2810,33 +2819,47 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2810
2819
  for i in range(0, run_time):
2811
2820
  w = work_mode_timed[i]
2812
2821
  w['kwh'] = kwh_current
2822
+ kwh_next = kwh_current
2813
2823
  max_now = w['max_soc'] * capacity / 100
2814
- if kwh_current < max_now and w['charge'] > 0.0:
2815
- kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
2816
- kwh_current = max_now if kwh_current > max_now else kwh_current
2817
- kwh_current += (w['pv'] * charge_loss - w['discharge'] / discharge_loss) / steps_per_hour
2818
- if kwh_current > capacity:
2819
- # battery is full
2820
- kwh_current = capacity
2821
- w = work_mode_timed[i+1] if (i + 1) < run_time else w
2822
- min_soc_now = w['fdsoc'] if w['mode'] =='ForceDischarge' else w['min_soc']
2824
+ min_soc_now = w['min_soc']
2823
2825
  reserve_now = capacity * min_soc_now / 100
2824
- if kwh_current < reserve_now and (i < time_to_next or kwh_min is None):
2826
+ reserve_limit = capacity * (min_soc_now - allowed_drain) / 100
2827
+ fdsoc_limit = (capacity * w['fdsoc'] / 100) if w['mode'] =='ForceDischarge' else capacity
2828
+ if kwh_next < max_now and w['charge'] > 0.0:
2829
+ # charge from grid or force charge
2830
+ kwh_next += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
2831
+ kwh_next = max_now if kwh_next > max_now else kwh_next
2832
+ if kwh_next > fdsoc_limit and w['fd_kwh'] > 0.0:
2833
+ # force discharge
2834
+ kwh_next += (w['pv' * charge_loss - w['fd_kwh'] / discharge_loss]) / steps_per_hour
2835
+ if kwh_current > fdsoc_limit and kwh_next < fdsoc_limit:
2836
+ kwh_next = fdsoc_limit - w['discharge'] * (1.0 - w['duration']) / discharge_loss / steps_per_hour
2837
+ else:
2838
+ # normal discharge
2839
+ kwh_next += (w['pv'] * charge_loss - w['discharge'] / discharge_loss) / steps_per_hour
2840
+ if kwh_next > capacity:
2841
+ # battery is full
2842
+ kwh_next = capacity
2843
+ if kwh_next < reserve_now and (i < time_to_next or kwh_min is None):
2825
2844
  # battery is empty, check if charge is needed
2826
- reserve_limit = capacity * (min_soc_now - allowed_drain) / 100
2827
- reserve_drain = kwh_current if reserve_drain is None or kwh_current > reserve_drain else reserve_drain
2828
- kwh_current = reserve_drain
2845
+ if kwh_current > reserve_now and kwh_next < reserve_now:
2846
+ kwh_next = reserve_now
2847
+ reserve_drain = kwh_next if reserve_drain is None or kwh_next > reserve_drain else reserve_drain
2829
2848
  if reserve_drain <= reserve_limit:
2849
+ # float charge
2830
2850
  reserve_drain = min([reserve_now, reserve_drain + float_charge * charge_loss / steps_per_hour])
2851
+ kwh_next = reserve_drain
2831
2852
  else:
2832
2853
  # BMS power drain
2854
+ kwh_next = reserve_drain
2833
2855
  reserve_drain -= bms_loss / steps_per_hour
2834
2856
  else:
2835
2857
  # reset drain level
2836
2858
  reserve_drain = reserve_now
2837
- if kwh_min is not None and kwh_current < kwh_min and i >= time_to_next: # track minimum without charge
2838
- kwh_min = kwh_current
2839
- return ([work_mode_timed[i]['kwh'] for i in range(0, len(work_mode_timed))], kwh_min)
2859
+ if kwh_min is not None and kwh_next < kwh_min and i >= time_to_next: # track minimum without charge
2860
+ kwh_min = kwh_next
2861
+ kwh_current = kwh_next
2862
+ return ([work_mode_timed[i]['kwh'] for i in range(0, run_time)], kwh_min)
2840
2863
 
2841
2864
  # use work_mode_timed to generate time periods for the inverter schedule
2842
2865
  def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
@@ -3044,7 +3067,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3044
3067
  output(f"full_charge = {full_charge}")
3045
3068
  if test_soc is not None:
3046
3069
  current_soc = test_soc
3047
- capacity = 14.53
3070
+ capacity = 14.43
3048
3071
  residual = test_soc * capacity / 100
3049
3072
  bat_volt = 317.4
3050
3073
  bat_power = 0.0
@@ -3060,7 +3083,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3060
3083
  # get device and battery info from inverter
3061
3084
  get_battery()
3062
3085
  if battery is None or battery['status'] != 1:
3063
- output(f"\nBattery status is not available")
3064
3086
  return None
3065
3087
  current_soc = battery['soc']
3066
3088
  bat_volt = battery['volt']
@@ -3176,47 +3198,19 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3176
3198
  consumption_timed = timed_list([consumption * x / daily_sum for x in consumption_by_hour], base_hour, run_time)
3177
3199
  # get Solcast data and produce time line
3178
3200
  solcast_value = None
3179
- solcast_profile = None
3180
3201
  if forecast is None and solcast_api_key is not None and solcast_api_key != 'my.solcast_api_key' and (system_time.hour in forecast_times or run_after == 0):
3181
3202
  fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'), d=base_time)
3182
3203
  if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
3183
3204
  solcast_value = fsolcast.daily[forecast_day]['kwh']
3184
3205
  solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
3185
- solcast_from = time_hours(fsolcast.daily[today]['from']) if fsolcast.daily[today].get('from') is not None else 0
3186
- output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh")
3187
3206
  # get forecast.solar data and produce time line
3188
3207
  solar_value = None
3189
- solar_profile = None
3190
3208
  if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
3191
3209
  fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
3192
3210
  if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
3193
3211
  solar_value = fsolar.daily[forecast_day]['kwh']
3194
3212
  solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
3195
- output(f"\nSolar: {tomorrow} {fsolar.daily[tomorrow]['kwh']:.1f}kWh")
3196
- if solcast_value is None and solar_value is None and debug_setting > 1:
3197
- output(f"\nNo forecasts available at this time")
3198
- # get generation data
3199
- generation = None
3200
- last_date = today if hour_now >= charge_config['use_today'] else yesterday
3201
- gen_days = charge_config['generation_days']
3202
- history = get_raw('week', d=last_date, v=['pvPower','meterPower2'], summary=2)
3203
- pv_history = {}
3204
- if history is not None and len(history) > 0:
3205
- for day in history:
3206
- date = day['date']
3207
- if pv_history.get(date) is None:
3208
- pv_history[date] = 0.0
3209
- if day.get('kwh') is not None and day.get('kwh_neg') is not None:
3210
- pv_history[date] += day['kwh_neg'] / 0.92 if day['variable'] == 'meterPower2' else day['kwh']
3211
- pv_sum = sum([pv_history[d] for d in sorted(pv_history.keys())[-gen_days:]])
3212
- output(f"\nGeneration (kWh):")
3213
- s = ""
3214
- for d in sorted(pv_history.keys())[-gen_days:]:
3215
- s += f" {d} {pv_history[d]:4.1f},"
3216
- output(' ' + s[:-1])
3217
- generation = pv_sum / gen_days
3218
- output(f" Average of last {gen_days} days: {generation:.1f}kWh")
3219
- # choose expected value and produce generation time line
3213
+ # choose expected value
3220
3214
  quarter = int(today[5:7] if charge_today else tomorrow[5:7]) // 3 % 4
3221
3215
  sun_name = seasonal_sun[quarter]['name']
3222
3216
  sun_profile = seasonal_sun[quarter]['sun']
@@ -3230,14 +3224,37 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3230
3224
  elif solcast_value is not None:
3231
3225
  expected = solcast_value
3232
3226
  generation_timed = solcast_timed
3227
+ output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh")
3233
3228
  elif solar_value is not None:
3234
3229
  expected = solar_value
3235
3230
  generation_timed = solar_timed
3236
- elif generation is None or generation == 0.0:
3237
- output(f"\nNo generation data available")
3238
- output_close()
3239
- return None
3231
+ output(f"\nSolar: {tomorrow} {fsolar.daily[tomorrow]['kwh']:.1f}kWh")
3240
3232
  else:
3233
+ # no forecast, use generation history
3234
+ generation = None
3235
+ last_date = today if hour_now >= charge_config['use_today'] else yesterday
3236
+ gen_days = charge_config['generation_days']
3237
+ history = get_raw('week', d=last_date, v=['pvPower','meterPower2'], summary=2)
3238
+ pv_history = {}
3239
+ if history is not None and len(history) > 0:
3240
+ for day in history:
3241
+ date = day['date']
3242
+ if pv_history.get(date) is None:
3243
+ pv_history[date] = 0.0
3244
+ if day.get('kwh') is not None and day.get('kwh_neg') is not None:
3245
+ pv_history[date] += day['kwh_neg'] / 0.92 if day['variable'] == 'meterPower2' else day['kwh']
3246
+ pv_sum = sum([pv_history[d] for d in sorted(pv_history.keys())[-gen_days:]])
3247
+ output(f"\nGeneration (kWh):")
3248
+ s = ""
3249
+ for d in sorted(pv_history.keys())[-gen_days:]:
3250
+ s += f" {d} {pv_history[d]:4.1f},"
3251
+ output(' ' + s[:-1])
3252
+ generation = pv_sum / gen_days
3253
+ output(f" Average of last {gen_days} days: {generation:.1f}kWh")
3254
+ if generation is None or generation == 0.0:
3255
+ output(f"\nNo generation data available")
3256
+ output_close()
3257
+ return None
3241
3258
  expected = generation
3242
3259
  generation_timed = [expected * x / sun_sum for x in sun_timed]
3243
3260
  if charge_config['forecast_selection'] == 1 and update_settings > 0:
@@ -3245,7 +3262,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3245
3262
  update_settings = 0
3246
3263
  # produce time lines for charge, discharge and work mode
3247
3264
  charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
3248
- discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
3265
+ discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + operating_loss for x in consumption_timed]
3249
3266
  work_mode_timed = strategy_timed(timed_mode, time_line, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
3250
3267
  for i in range(0, len(work_mode_timed)):
3251
3268
  # get work mode
@@ -3257,19 +3274,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3257
3274
  work_mode_timed[i]['charge'] = charge_power * duration
3258
3275
  elif timed_mode > 0 and work_mode == 'ForceDischarge':
3259
3276
  fdpwr = work_mode_timed[i]['fdpwr'] / dc_ac_loss / 1000
3260
- fdpwr = min([discharge_limit, export_limit + discharge_timed[i], fdpwr])
3261
- discharge_timed[i] = fdpwr * duration + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
3277
+ work_mode_timed[i]['fd_kwh'] = min([discharge_limit, export_limit + discharge_timed[i], fdpwr]) * duration
3262
3278
  elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
3263
- discharge_timed[i] = bms_loss
3264
- if timed_mode > 1:
3265
- work_mode_timed[i]['hold'] = 1
3279
+ discharge_timed[i] = operating_loss
3280
+ work_mode_timed[i]['hold'] = 1
3266
3281
  elif timed_mode > 0 and work_mode == 'Backup':
3267
- discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
3282
+ discharge_timed[i] = operating_loss if charge_timed[i] == 0.0 else 0.0
3268
3283
  elif timed_mode > 0 and work_mode == 'Feedin':
3269
- (discharge_timed[i], charge_timed[i]) = (bms_loss if (charge_timed[i] >= discharge_timed[i]) else (discharge_timed[i] - charge_timed[i]),
3284
+ (discharge_timed[i], charge_timed[i]) = (0.0 if (charge_timed[i] >= discharge_timed[i]) else (discharge_timed[i] - charge_timed[i]),
3270
3285
  0.0 if (charge_timed[i] <= export_limit + discharge_timed[i]) else (charge_timed[i] - export_limit - discharge_timed[i]))
3271
3286
  else: # work_mode == 'SelfUse'
3272
- (discharge_timed[i], charge_timed[i]) = (bms_loss if (charge_timed[i] >= discharge_timed[i]) else (discharge_timed[i] - charge_timed[i]),
3287
+ (discharge_timed[i], charge_timed[i]) = (0.0 if (charge_timed[i] >= discharge_timed[i]) else (discharge_timed[i] - charge_timed[i]),
3273
3288
  0.0 if (charge_timed[i] <= discharge_timed[i]) else (charge_timed[i] - discharge_timed[i]))
3274
3289
  work_mode_timed[i]['pv'] = charge_timed[i]
3275
3290
  work_mode_timed[i]['discharge'] = discharge_timed[i]
@@ -3282,8 +3297,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3282
3297
  kwh_contingency = consumption * contingency / 100
3283
3298
  kwh_needed = reserve + kwh_contingency - kwh_min
3284
3299
  start_residual = interpolate(time_to_start, bat_timed) # residual when charge time starts
3285
- start_soc = int(start_residual / capacity * 100 + 0.5)
3286
- end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
3300
+ end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends (without charging)
3287
3301
  target_soc = charge_config.get('target_soc')
3288
3302
  target_kwh = capacity if full_charge is not None or bat_hold == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
3289
3303
  if target_kwh > (end_residual + kwh_needed):
@@ -3302,7 +3316,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3302
3316
  hours = 0.0
3303
3317
  start_timed = time_to_end
3304
3318
  end_timed = time_to_end
3305
- end_soc = int(end_residual / capacity * 100 + 0.5)
3306
3319
  else:
3307
3320
  # work out time to add kwh_needed to battery
3308
3321
  charge_rate = charge_power * charge_loss
@@ -3313,30 +3326,30 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3313
3326
  charge_message = "with charge added"
3314
3327
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
3315
3328
  # check if charge time exceeded or charge needed exceeds capacity
3316
- hours_to_full = (capacity - start_residual) / charge_rate
3317
- if hours > charge_time:
3329
+ hours_to_full = (capacity - end_residual) / charge_rate
3330
+ if hours > charge_time or bat_hold == 2:
3318
3331
  hours = charge_time
3319
3332
  elif hours > hours_to_full:
3320
- kwh_shortfall = kwh_needed - (capacity - start_residual) # amount of energy that won't be added
3333
+ kwh_shortfall = kwh_needed - (capacity - end_residual) # amount of energy that won't be added
3321
3334
  required = (hours_to_full + kwh_shortfall / discharge_rate) if discharge_rate > 0.0 else charge_time
3322
3335
  hours = required if required > hours and required < charge_time else charge_time
3323
- # round charge time and work out what will actually be added
3336
+ # round charge time
3324
3337
  min_hours = charge_config['min_hours']
3325
3338
  hours = int(hours / min_hours + 0.99) * min_hours
3326
- kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
3327
- kwh_added += discharge_rate * hours # discharge saved by charging
3328
- kwh_spare = kwh_min - reserve + kwh_added
3329
3339
  # rework charge and discharge
3330
3340
  charge_period = get_best_charge_period(start_at, hours)
3331
- charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
3341
+ charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else charge_time - hours
3332
3342
  price = charge_period.get('price') if charge_period is not None else None
3333
3343
  start_timed = time_to_start + charge_offset * steps_per_hour
3334
3344
  end_timed = start_timed + hours * steps_per_hour
3335
3345
  start_residual = interpolate(start_timed, bat_timed)
3336
- end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
3337
- output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
3338
- output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
3339
- + (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh)")
3346
+ start_soc = start_residual / capacity * 100
3347
+ kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
3348
+ kwh_added += discharge_rate * hours # discharge saved by charging
3349
+ kwh_spare = kwh_min - reserve + kwh_added
3350
+ output(f" Start SoC: {start_soc:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
3351
+ output(f" Charge: {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
3352
+ + (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh added)")
3340
3353
  for i in range(int(time_to_start), int(time_to_end)):
3341
3354
  j = i + 1
3342
3355
  # work out time (fraction of hour) when charging in hour from i to j
@@ -3359,8 +3372,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3359
3372
  # rebuild the battery residual with the charge added and min_soc
3360
3373
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=start_timed)
3361
3374
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
3375
+ end_soc = end_residual / capacity * 100
3362
3376
  # show the results
3363
- output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
3377
+ output(f" End SoC: {end_soc:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
3364
3378
  output(f" Contingency: {kwh_spare / capacity * 100:.0f}% SoC ({kwh_spare:.2f}kWh)")
3365
3379
  if not charge_today:
3366
3380
  output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
@@ -4356,7 +4370,7 @@ class Solcast :
4356
4370
  estimate_values = [self.estimate[r][hours_time(t)] for t in times]
4357
4371
  plots[r] = estimate_values
4358
4372
  total_forecast = 0.0
4359
- if self.daily.get(day) is not None:
4373
+ if hasattr(self, 'daily') and self.daily.get(day) is not None:
4360
4374
  sun_times = get_suntimes(day)
4361
4375
  print(f"\n{day}:\n Sunrise {sun_times[0]}, Sunset {sun_times[1]}")
4362
4376
  forecast_values = [self.daily[day]['pt30'][hours_time(t - time_offset)] for t in times]
@@ -4679,7 +4693,7 @@ class Solar :
4679
4693
  estimate_values = [c_float(self.estimate[r].get(hours_time(t))) for t in times]
4680
4694
  plots[r] = estimate_values
4681
4695
  total_forecast = 0.0
4682
- if self.daily.get(day) is not None:
4696
+ if hasattr(self, 'daily') and self.daily.get(day) is not None:
4683
4697
  sun_times = get_suntimes(day)
4684
4698
  print(f"\n{day}:\n Sunrise {sun_times[0]}, Sunset {sun_times[1]}")
4685
4699
  forecast_values = [self.daily[day]['pt30'][hours_time(t)] for t in times]
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 06 November 2024
4
+ Updated: 23 November 2024
5
5
  By: Tony Matthews
6
6
  """
7
7
  ##################################################################################################
@@ -10,7 +10,7 @@ By: Tony Matthews
10
10
  # ALL RIGHTS ARE RESERVED © Tony Matthews 2024
11
11
  ##################################################################################################
12
12
 
13
- version = "2.7.0"
13
+ version = "2.7.2"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -564,7 +564,7 @@ battery_params = {
564
564
  2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
565
565
  'step': 5,
566
566
  'offset': 5,
567
- 'charge_loss': 1.07,
567
+ 'charge_loss': 1.08,
568
568
  'discharge_loss': 0.95},
569
569
  # Mira BMS with firmware 1.014 or later
570
570
  3: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
@@ -582,13 +582,18 @@ def get_battery(info=0, v=None, rated=None, count=None):
582
582
  if v is None:
583
583
  v = battery_vars
584
584
  result = get_real(v)
585
- if battery is None:
586
- battery = {}
585
+ battery = {}
587
586
  for i in range(0, len(battery_vars)):
588
587
  battery[battery_data[i]] = result[i].get('value')
589
588
  battery['residual_handling'] = residual_handling
590
589
  battery['soh'] = None
591
590
  battery['soh_supported'] = False
591
+ if battery.get('status') is None:
592
+ battery['status'] = 0 if battery.get('volt') is None or battery['volt'] <= 0 else 1
593
+ if battery['status'] != 1:
594
+ output(f"** get_battery(): battery status not available")
595
+ return None
596
+ battery['status'] = 1
592
597
  if battery['residual_handling'] == 2:
593
598
  capacity = battery.get('residual')
594
599
  soc = battery.get('soc')
@@ -621,9 +626,9 @@ def get_battery(info=0, v=None, rated=None, count=None):
621
626
  battery['charge_loss'] = params['charge_loss']
622
627
  battery['discharge_loss'] = params['discharge_loss']
623
628
  if battery.get('ratedCapacity') is not None and battery.get('capacity') is not None:
624
- battery['soh'] = round(battery['capacity'] * 1000 / battery['ratedCapacity'] * 100, 1)
629
+ battery['soh'] = round(battery['capacity'] * 1000 / battery['ratedCapacity'] * 100, 1) if battery['ratedCapacity'] > 0.0 else None
625
630
  if battery.get('temperature') is not None:
626
- battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
631
+ battery['charge_rate'] = interpolate((battery['temperature'] - params['offset']) / params['step'], params['table'])
627
632
  return battery
628
633
 
629
634
  def get_batteries(info=0, rated=None, count=None):
@@ -674,7 +679,7 @@ def time_period(t, n):
674
679
  result += f" Charge from grid" if enable else f" Battery Hold"
675
680
  return result
676
681
 
677
- def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force = 0, enable=1):
682
+ def set_charge(ch1=True, st1=0, en1=0, ch2=True, st2=0, en2=0, force = 0, enable=1):
678
683
  global device_sn, battery_settings, debug_setting, time_period_vars
679
684
  if get_device() is None:
680
685
  return None
@@ -2439,8 +2444,8 @@ def strategy_timed(timed_mode, time_line, run_time, min_soc=10, max_soc=100, cur
2439
2444
  strategy = get_strategy(timed_mode=timed_mode)
2440
2445
  for i in range(0, run_time):
2441
2446
  h = time_line[i]
2442
- period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0, 'charge': 0.0,
2443
- 'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2447
+ period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0,
2448
+ 'pv': 0.0, 'charge': 0.0, 'discharge': 0.0, 'fd_kwh': 0.0, 'hold': 0, 'kwh': None}
2444
2449
  if strategy is not None:
2445
2450
  period['mode'] = 'SelfUse'
2446
2451
  for d in strategy:
@@ -2474,33 +2479,47 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2474
2479
  for i in range(0, run_time):
2475
2480
  w = work_mode_timed[i]
2476
2481
  w['kwh'] = kwh_current
2482
+ kwh_next = kwh_current
2477
2483
  max_now = w['max_soc'] * capacity / 100
2478
- if kwh_current < max_now and w['charge'] > 0.0:
2479
- kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
2480
- kwh_current = max_now if kwh_current > max_now else kwh_current
2481
- kwh_current += (w['pv'] * charge_loss - w['discharge'] / discharge_loss) / steps_per_hour
2482
- if kwh_current > capacity:
2483
- # battery is full
2484
- kwh_current = capacity
2485
- w = work_mode_timed[i+1] if (i + 1) < run_time else w
2486
- min_soc_now = w['fdsoc'] if w['mode'] =='ForceDischarge' else w['min_soc']
2484
+ min_soc_now = w['min_soc']
2487
2485
  reserve_now = capacity * min_soc_now / 100
2488
- if kwh_current < reserve_now and (i < time_to_next or kwh_min is None):
2486
+ reserve_limit = capacity * (min_soc_now - allowed_drain) / 100
2487
+ fdsoc_limit = (capacity * w['fdsoc'] / 100) if w['mode'] =='ForceDischarge' else capacity
2488
+ if kwh_next < max_now and w['charge'] > 0.0:
2489
+ # charge from grid or force charge
2490
+ kwh_next += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
2491
+ kwh_next = max_now if kwh_next > max_now else kwh_next
2492
+ if kwh_next > fdsoc_limit and w['fd_kwh'] > 0.0:
2493
+ # force discharge
2494
+ kwh_next += (w['pv' * charge_loss - w['fd_kwh'] / discharge_loss]) / steps_per_hour
2495
+ if kwh_current > fdsoc_limit and kwh_next < fdsoc_limit:
2496
+ kwh_next = fdsoc_limit - w['discharge'] * (1.0 - w['duration']) / discharge_loss / steps_per_hour
2497
+ else:
2498
+ # normal discharge
2499
+ kwh_next += (w['pv'] * charge_loss - w['discharge'] / discharge_loss) / steps_per_hour
2500
+ if kwh_next > capacity:
2501
+ # battery is full
2502
+ kwh_next = capacity
2503
+ if kwh_next < reserve_now and (i < time_to_next or kwh_min is None):
2489
2504
  # battery is empty, check if charge is needed
2490
- reserve_limit = capacity * (min_soc_now - allowed_drain) / 100
2491
- reserve_drain = kwh_current if reserve_drain is None or kwh_current > reserve_drain else reserve_drain
2492
- kwh_current = reserve_drain
2505
+ if kwh_current > reserve_now and kwh_next < reserve_now:
2506
+ kwh_next = reserve_now
2507
+ reserve_drain = kwh_next if reserve_drain is None or kwh_next > reserve_drain else reserve_drain
2493
2508
  if reserve_drain <= reserve_limit:
2509
+ # float charge
2494
2510
  reserve_drain = min([reserve_now, reserve_drain + float_charge * charge_loss / steps_per_hour])
2511
+ kwh_next = reserve_drain
2495
2512
  else:
2496
2513
  # BMS power drain
2514
+ kwh_next = reserve_drain
2497
2515
  reserve_drain -= bms_loss / steps_per_hour
2498
2516
  else:
2499
2517
  # reset drain level
2500
2518
  reserve_drain = reserve_now
2501
- if kwh_min is not None and kwh_current < kwh_min and i >= time_to_next: # track minimum without charge
2502
- kwh_min = kwh_current
2503
- return ([work_mode_timed[i]['kwh'] for i in range(0, len(work_mode_timed))], kwh_min)
2519
+ if kwh_min is not None and kwh_next < kwh_min and i >= time_to_next: # track minimum without charge
2520
+ kwh_min = kwh_next
2521
+ kwh_current = kwh_next
2522
+ return ([work_mode_timed[i]['kwh'] for i in range(0, run_time)], kwh_min)
2504
2523
 
2505
2524
  # use work_mode_timed to generate time periods for the inverter schedule
2506
2525
  def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
@@ -2708,7 +2727,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2708
2727
  output(f"full_charge = {full_charge}")
2709
2728
  if test_soc is not None:
2710
2729
  current_soc = test_soc
2711
- capacity = 14.54
2730
+ capacity = 14.43
2712
2731
  residual = test_soc * capacity / 100
2713
2732
  bat_volt = 317.4
2714
2733
  bat_power = 0.0
@@ -2724,7 +2743,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2724
2743
  # get device and battery info from inverter
2725
2744
  get_battery()
2726
2745
  if battery is None or battery['status'] != 1:
2727
- output(f"\nBattery status is not available")
2728
2746
  return None
2729
2747
  current_soc = battery['soc']
2730
2748
  bat_volt = battery['volt']
@@ -2844,46 +2862,19 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2844
2862
  consumption_timed = timed_list([consumption * x / daily_sum for x in consumption_by_hour], base_hour, run_time)
2845
2863
  # get Solcast data and produce time line
2846
2864
  solcast_value = None
2847
- solcast_profile = None
2848
2865
  if forecast is None and solcast_api_key is not None and solcast_api_key != 'my.solcast_api_key' and (system_time.hour in forecast_times or run_after == 0):
2849
2866
  fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'), d=base_time)
2850
2867
  if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
2851
2868
  solcast_value = fsolcast.daily[forecast_day]['kwh']
2852
2869
  solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
2853
- output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh")
2854
2870
  # get forecast.solar data and produce time line
2855
2871
  solar_value = None
2856
- solar_profile = None
2857
2872
  if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
2858
2873
  fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
2859
2874
  if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
2860
2875
  solar_value = fsolar.daily[forecast_day]['kwh']
2861
2876
  solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
2862
- output(f"\nSolar: {tomorrow} {fsolar.daily[tomorrow]['kwh']:.1f}kWh")
2863
- if solcast_value is None and solar_value is None and debug_setting > 1:
2864
- output(f"\nNo forecasts available at this time")
2865
- # get generation data
2866
- generation = None
2867
- last_date = today if hour_now >= charge_config['use_today'] else yesterday
2868
- gen_days = charge_config['generation_days']
2869
- history = get_raw('week', d=last_date, v=['pvPower','meterPower2'], summary=2)
2870
- pv_history = {}
2871
- if history is not None and len(history) > 0:
2872
- for day in history:
2873
- date = day['date']
2874
- if pv_history.get(date) is None:
2875
- pv_history[date] = 0.0
2876
- if day.get('kwh') is not None and day.get('kwh_neg') is not None:
2877
- pv_history[date] += day['kwh_neg'] / 0.92 if day['variable'] == 'meterPower2' else day['kwh']
2878
- pv_sum = sum([pv_history[d] for d in sorted(pv_history.keys())[-gen_days:]])
2879
- output(f"\nGeneration (kWh):")
2880
- s = ""
2881
- for d in sorted(pv_history.keys())[-gen_days:]:
2882
- s += f" {d}: {pv_history[d]:4.1f},"
2883
- output(' ' + s[:-1])
2884
- generation = pv_sum / gen_days
2885
- output(f" Average of last {gen_days} days: {generation:.1f}kWh")
2886
- # choose expected value and produce generation time line
2877
+ # choose expected value
2887
2878
  quarter = int(today[5:7] if charge_today else tomorrow[5:7]) // 3 % 4
2888
2879
  sun_name = seasonal_sun[quarter]['name']
2889
2880
  sun_profile = seasonal_sun[quarter]['sun']
@@ -2897,14 +2888,37 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2897
2888
  elif solcast_value is not None:
2898
2889
  expected = solcast_value
2899
2890
  generation_timed = solcast_timed
2891
+ output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh")
2900
2892
  elif solar_value is not None:
2901
2893
  expected = solar_value
2902
2894
  generation_timed = solar_timed
2903
- elif generation is None or generation == 0.0:
2904
- output(f"\nNo generation data available")
2905
- output_close()
2906
- return None
2895
+ output(f"\nSolar: {tomorrow} {fsolar.daily[tomorrow]['kwh']:.1f}kWh")
2907
2896
  else:
2897
+ # no forecast, use generation data
2898
+ generation = None
2899
+ last_date = today if hour_now >= charge_config['use_today'] else yesterday
2900
+ gen_days = charge_config['generation_days']
2901
+ history = get_raw('week', d=last_date, v=['pvPower','meterPower2'], summary=2)
2902
+ pv_history = {}
2903
+ if history is not None and len(history) > 0:
2904
+ for day in history:
2905
+ date = day['date']
2906
+ if pv_history.get(date) is None:
2907
+ pv_history[date] = 0.0
2908
+ if day.get('kwh') is not None and day.get('kwh_neg') is not None:
2909
+ pv_history[date] += day['kwh_neg'] / 0.92 if day['variable'] == 'meterPower2' else day['kwh']
2910
+ pv_sum = sum([pv_history[d] for d in sorted(pv_history.keys())[-gen_days:]])
2911
+ output(f"\nGeneration (kWh):")
2912
+ s = ""
2913
+ for d in sorted(pv_history.keys())[-gen_days:]:
2914
+ s += f" {d} {pv_history[d]:4.1f},"
2915
+ output(' ' + s[:-1])
2916
+ generation = pv_sum / gen_days
2917
+ output(f" Average of last {gen_days} days: {generation:.1f}kWh")
2918
+ if generation is None or generation == 0.0:
2919
+ output(f"\nNo generation data available")
2920
+ output_close()
2921
+ return None
2908
2922
  expected = generation
2909
2923
  generation_timed = [expected * x / sun_sum for x in sun_timed]
2910
2924
  if charge_config['forecast_selection'] == 1 and update_settings > 0:
@@ -2912,7 +2926,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2912
2926
  update_settings = 0
2913
2927
  # produce time lines for charge, discharge and work mode
2914
2928
  charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
2915
- discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
2929
+ discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + operating_loss for x in consumption_timed]
2916
2930
  work_mode_timed = strategy_timed(timed_mode, time_line, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
2917
2931
  for i in range(0, len(work_mode_timed)):
2918
2932
  # get work mode
@@ -2924,19 +2938,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2924
2938
  work_mode_timed[i]['charge'] = charge_power * duration
2925
2939
  elif timed_mode > 0 and work_mode == 'ForceDischarge':
2926
2940
  fdpwr = work_mode_timed[i]['fdpwr'] / dc_ac_loss / 1000
2927
- fdpwr = min([discharge_limit, export_limit + discharge_timed[i], fdpwr])
2928
- discharge_timed[i] = fdpwr * duration + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
2941
+ work_mode_timed[i]['fd_kwh'] = min([discharge_limit, export_limit + discharge_timed[i], fdpwr]) * duration
2929
2942
  elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
2930
- discharge_timed[i] = bms_loss
2931
- if timed_mode > 1:
2932
- work_mode_timed[i]['hold'] = 1
2943
+ discharge_timed[i] = operating_loss
2944
+ work_mode_timed[i]['hold'] = 1
2933
2945
  elif timed_mode > 0 and work_mode == 'Backup':
2934
- discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
2946
+ discharge_timed[i] = operating_loss if charge_timed[i] == 0.0 else 0.0
2935
2947
  elif timed_mode > 0 and work_mode == 'Feedin':
2936
- (discharge_timed[i], charge_timed[i]) = (bms_loss if (charge_timed[i] >= discharge_timed[i]) else (discharge_timed[i] - charge_timed[i]),
2948
+ (discharge_timed[i], charge_timed[i]) = (0.0 if (charge_timed[i] >= discharge_timed[i]) else (discharge_timed[i] - charge_timed[i]),
2937
2949
  0.0 if (charge_timed[i] <= export_limit + discharge_timed[i]) else (charge_timed[i] - export_limit - discharge_timed[i]))
2938
2950
  else: # work_mode == 'SelfUse'
2939
- (discharge_timed[i], charge_timed[i]) = (bms_loss if (charge_timed[i] >= discharge_timed[i]) else (discharge_timed[i] - charge_timed[i]),
2951
+ (discharge_timed[i], charge_timed[i]) = (0.0 if (charge_timed[i] >= discharge_timed[i]) else (discharge_timed[i] - charge_timed[i]),
2940
2952
  0.0 if (charge_timed[i] <= discharge_timed[i]) else (charge_timed[i] - discharge_timed[i]))
2941
2953
  work_mode_timed[i]['pv'] = charge_timed[i]
2942
2954
  work_mode_timed[i]['discharge'] = discharge_timed[i]
@@ -2949,7 +2961,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2949
2961
  kwh_contingency = consumption * contingency / 100
2950
2962
  kwh_needed = reserve + kwh_contingency - kwh_min
2951
2963
  start_residual = interpolate(time_to_start, bat_timed) # residual when charge time starts
2952
- start_soc = int(start_residual / capacity * 100 + 0.5)
2953
2964
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
2954
2965
  target_soc = charge_config.get('target_soc')
2955
2966
  target_kwh = capacity if full_charge is not None or bat_hold == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
@@ -2969,7 +2980,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2969
2980
  hours = 0.0
2970
2981
  start_timed = time_to_end
2971
2982
  end_timed = time_to_end
2972
- end_soc = int(end_residual / capacity * 100 + 0.5)
2973
2983
  else:
2974
2984
  # work out time to add kwh_needed to battery
2975
2985
  charge_rate = charge_power * charge_loss
@@ -2980,30 +2990,30 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2980
2990
  charge_message = "with charge added"
2981
2991
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
2982
2992
  # check if charge time exceeded or charge needed exceeds capacity
2983
- hours_to_full = (capacity - start_residual) / charge_rate
2984
- if hours > charge_time:
2993
+ hours_to_full = (capacity - end_residual) / charge_rate
2994
+ if hours > charge_time or bat_hold == 2:
2985
2995
  hours = charge_time
2986
2996
  elif hours > hours_to_full:
2987
- kwh_shortfall = kwh_needed - (capacity - start_residual) # amount of energy that won't be added
2997
+ kwh_shortfall = kwh_needed - (capacity - end_residual) # amount of energy that won't be added
2988
2998
  required = (hours_to_full + kwh_shortfall / discharge_rate) if discharge_rate > 0.0 else charge_time
2989
2999
  hours = required if required > hours and required < charge_time else charge_time
2990
- # round charge time and work out what will actually be added
3000
+ # round charge time
2991
3001
  min_hours = charge_config['min_hours']
2992
3002
  hours = int(hours / min_hours + 0.99) * min_hours
2993
- kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
2994
- kwh_added += discharge_rate * hours # discharge saved during charging
2995
- kwh_spare = kwh_min - reserve + kwh_added
2996
3003
  # rework charge and discharge
2997
3004
  charge_period = get_best_charge_period(start_at, hours)
2998
- charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
3005
+ charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else charge_time - hours
2999
3006
  price = charge_period.get('price') if charge_period is not None else None
3000
3007
  start_timed = time_to_start + charge_offset * steps_per_hour
3001
3008
  end_timed = start_timed + hours * steps_per_hour
3002
3009
  start_residual = interpolate(start_timed, bat_timed)
3003
- end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
3004
- output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
3005
- output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
3006
- + (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh)")
3010
+ start_soc = start_residual / capacity * 100
3011
+ kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
3012
+ kwh_added += discharge_rate * hours # discharge saved by charging
3013
+ kwh_spare = kwh_min - reserve + kwh_added
3014
+ output(f" Start SoC: {start_soc:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
3015
+ output(f" Charge: {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
3016
+ + (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh added)")
3007
3017
  for i in range(int(time_to_start), int(time_to_end)):
3008
3018
  j = i + 1
3009
3019
  # work out time (fraction of hour) when charging in hour from i to j
@@ -3026,8 +3036,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3026
3036
  # rebuild the battery residual with any charge added and min_soc
3027
3037
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next=start_timed)
3028
3038
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
3039
+ end_soc = end_residual / capacity * 100
3029
3040
  # show the results
3030
- output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
3041
+ output(f" End SoC: {end_soc:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
3031
3042
  output(f" Contingency: {kwh_spare / capacity * 100:.0f}% SoC ({kwh_spare:.2f}kWh)")
3032
3043
  if not charge_today:
3033
3044
  output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
@@ -4020,7 +4031,7 @@ class Solcast :
4020
4031
  estimate_values = [self.estimate[r][hours_time(t)] for t in times]
4021
4032
  plots[r] = estimate_values
4022
4033
  total_forecast = 0.0
4023
- if self.daily.get(day) is not None:
4034
+ if hasattr(self, 'daily') and self.daily.get(day) is not None:
4024
4035
  sun_times = get_suntimes(day)
4025
4036
  print(f"\n{day}:\n Sunrise {sun_times[0]}, Sunset {sun_times[1]}")
4026
4037
  forecast_values = [self.daily[day]['pt30'][hours_time(t - time_offset)] for t in times]
@@ -4343,7 +4354,7 @@ class Solar :
4343
4354
  estimate_values = [c_float(self.estimate[r].get(hours_time(t))) for t in times]
4344
4355
  plots[r] = estimate_values
4345
4356
  total_forecast = 0.0
4346
- if self.daily.get(day) is not None:
4357
+ if hasattr(self, 'daily') and self.daily.get(day) is not None:
4347
4358
  sun_times = get_suntimes(day)
4348
4359
  print(f"\n{day}:\n Sunrise {sun_times[0]}, Sunset {sun_times[1]}")
4349
4360
  forecast_values = [self.daily[day]['pt30'][hours_time(t)] for t in times]
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.1
2
+ Name: foxesscloud
3
+ Version: 2.7.2
4
+ Summary: library for accessing Fox ESS cloud data using Open API
5
+ Author-email: Tony Matthews <tony@quasair.co.uk>
6
+ Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
7
+ Project-URL: Bug Tracker, https://github.com/TonyM1958/FoxESS-Cloud/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.7
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENCE
14
+
1
15
  # FoxESS-Cloud
2
16
 
3
17
  <a href="https://www.buymeacoffee.com/tonym1958" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174" align="right"></a>
@@ -144,12 +158,12 @@ set_min() applies new SoC settings to the inverter. The parameters update batter
144
158
  + minSoc: min Soc setting e.g. 10 = 10%
145
159
 
146
160
  set_charge() takes the charge times from the battery_settings and applies these to the inverter. The parameters are optional and will update battery_settings. You should specify all 3 parameter for a time period:
147
- + ch1: enable charge from grid for period 1 (True or False)
148
- + st1: the start time for period 1
149
- + en1: the end time for period 1
150
- + ch2: enable charge from grid for period 2 (True or False)
151
- + st2: the start time for period 2
152
- + en2: the end time for period 2
161
+ + ch1: enable charge from grid for period 1 (default True)
162
+ + st1: the start time for period 1 (default 0)
163
+ + en1: the end time for period 1 (default 0)
164
+ + ch2: enable charge from grid for period 2 (default True)
165
+ + st2: the start time for period 2 (default 0)
166
+ + en2: the end time for period 2 (default 0)
153
167
  + enable: set to 0 to show settings but stop inverter settings being updated. Default is 1.
154
168
 
155
169
  set_period() returns a period structure that can be used to build a list for set_schedule()
@@ -793,6 +807,22 @@ This setting can be:
793
807
 
794
808
  # Version Info
795
809
 
810
+ 2.7.2<br>
811
+ Fix to get_battery() to return error and flag status=0 in f.battery when the cloud is not returning valid data.
812
+ Fix exception calculating SoH if ratedCapacity is returned as 0 when cloud is not returning valid data.
813
+ Update charge_rate in charge_needed() to use a blended charge rate based on battery warming up during charging.
814
+ Fix exception in set_charge() caused by incorrect default parameter values.
815
+ Update charge_needed() to only show forecast that is in use.
816
+
817
+ 2.7.1<br>
818
+ Update charge_needed() so it only gets generation history if there is no forecast to reduce API calls and save time.
819
+ Update default parameter values for set_charge() so the other time period is cleared if you only set 1 time.
820
+ Fix problem where a full charge was being set when charge_needed() is called with force_charge=1.
821
+ Move charging to the end of the charge time when force_charge=1 so the charge time completes with the required charge.
822
+ Update battery predictions to more accurately reflect what happens when SoC gets to min_soc or fd_soc.
823
+ Correct model to use inverter operating losses instead of BMS losses when the battery is above min_soc.
824
+ Correct exception in Solcast and Solar when a forecast is not available.
825
+
796
826
  2.7.0<br>
797
827
  Allow charge_loss / discharge_loss to be configured for charge_needed().
798
828
  Change 'Force Charge' to 'Battery Hold' in charge times to avoid confusion with Force Charge work mode.
File without changes
File without changes