foxesscloud 2.5.7__py3-none-any.whl → 2.5.8__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: 28 September 2024
4
+ Updated: 01 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.6.9"
13
+ version = "1.7.0"
14
14
  print(f"FoxESS-Cloud version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -83,16 +83,17 @@ def query_date(d, offset = None):
83
83
  return {'year': t.year, 'month': t.month, 'day': t.day, 'hour': t.hour, 'minute': t.minute, 'second': t.second}
84
84
 
85
85
  # interpolate a result from a list of values
86
- def interpolate(f, v):
86
+ def interpolate(f, v, wrap=0):
87
87
  if len(v) == 0:
88
88
  return None
89
89
  if f < 0.0:
90
90
  return v[0]
91
- elif f >= len(v) - 1:
91
+ elif wrap == 0 and f >= len(v) - 1:
92
92
  return v[-1]
93
- i = int(f)
94
- x = f - i
95
- return v[i] * (1-x) + v[i+1] * x
93
+ i = int(f) % len(v)
94
+ x = f % 1.0
95
+ j = (i + 1) % len(v)
96
+ return v[i] * (1-x) + v[j] * x
96
97
 
97
98
  # build request header with signing
98
99
  http_timeout = 55 # http request timeout in seconds
@@ -648,12 +649,12 @@ def get_charge():
648
649
 
649
650
  # helper to format time period structure
650
651
  def time_period(t):
651
- result = f"{t['startTime']['hour']:02d}:{t['startTime']['minute']:02d} - {t['endTime']['hour']:02d}:{t['endTime']['minute']:02d}"
652
+ result = f"{t['startTime']['hour']:02d}:{t['startTime']['minute']:02d}-{t['endTime']['hour']:02d}:{t['endTime']['minute']:02d}"
652
653
  if t['startTime']['hour'] != t['endTime']['hour'] or t['startTime']['minute'] != t['endTime']['minute']:
653
654
  result += f" Charge from grid" if t['enableGrid'] else f" Force Charge"
654
655
  return result
655
656
 
656
- def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force=0):
657
+ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force=0, enable=1):
657
658
  global token, device_sn, battery_settings, debug_setting, messages, schedule
658
659
  if get_device() is None:
659
660
  return None
@@ -703,6 +704,8 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
703
704
  output(f"\nSetting time periods:", 1)
704
705
  output(f" Time Period 1 = {time_period(battery_settings['times'][0])}", 1)
705
706
  output(f" Time Period 2 = {time_period(battery_settings['times'][1])}", 1)
707
+ if enable == 0:
708
+ return battery_settings
706
709
  # set charge times
707
710
  data = {'sn': device_sn, 'times': battery_settings.get('times')}
708
711
  setting_delay()
@@ -721,35 +724,6 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
721
724
  output(f"success", 2)
722
725
  return battery_settings
723
726
 
724
- def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, end_soc=100, start_soc=10):
725
- output(f"\nConfiguring schedule",1)
726
- charge = []
727
- st1 = time_hours(st1)
728
- en1 = time_hours(en1)
729
- st2 = time_hours(st2)
730
- en2 = time_hours(en2)
731
- span = None
732
- if st2 is not None and en2 is not None and st2 != en2:
733
- charge.append({'start': st2, 'end': en2, 'mode': 'ForceCharge', 'min_soc': min_soc, 'max_soc': end_soc})
734
- span = {'start': st2, 'end': en2}
735
- if st1 is not None and en1 is not None and st1 != en1:
736
- charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': start_soc})
737
- span = {'start': st1, 'end': en2}
738
- elif st1 is not None and en1 is not None and st1 != en1:
739
- span = {'start': st1, 'end': en1}
740
- if round_time(en1 - st1) > 0.25:
741
- st3 = round_time(en1 - 5 / 60)
742
- charge.append({'start': st1, 'end': st3, 'mode': 'SelfUse', 'min_soc': start_soc})
743
- st1 = st3
744
- charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': min_soc})
745
- strategy = get_strategy(remove=span, limit=24)[:(8 - len(charge))]
746
- for c in charge:
747
- strategy.append(c)
748
- periods = []
749
- for s in sorted(strategy, key=lambda s: s['start']):
750
- periods.append(set_period(segment = s, quiet=0))
751
- return periods
752
-
753
727
  ##################################################################################################
754
728
  # get min soc settings and save in battery_settings
755
729
  ##################################################################################################
@@ -1270,7 +1244,7 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1270
1244
  return None
1271
1245
  if quiet == 0:
1272
1246
  s = f" {hours_time(start)}-{hours_time(end)} {mode}, minsoc {min_soc}%"
1273
- s += f", maxsoc {max_soc}%" if max_soc is not None else ""
1247
+ s += f", maxsoc {max_soc}%" if max_soc is not None and mode == 'ForceCharge' else ""
1274
1248
  s += f", fdPwr {fdpwr}W, fdSoC {fdsoc}%" if mode == 'ForceDischarge' else ""
1275
1249
  s += f", {price:.2f}p/kWh" if price is not None else ""
1276
1250
  output(s, 1)
@@ -2122,7 +2096,7 @@ def hours_difference(t1, t2):
2122
2096
  # time periods for Octopus Flux
2123
2097
  octopus_flux = {
2124
2098
  'name': 'Octopus Flux',
2125
- 'off_peak1': {'start': 2.0, 'end': 5.0, 'force': 1}, # off-peak period 1 / am charging period
2099
+ 'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1}, # off-peak period 1 / am charging period
2126
2100
  'peak1': {'start': 16.0, 'end': 19.0 }, # peak period 1
2127
2101
  'forecast_times': [21, 22], # hours in a day to get a forecast
2128
2102
  'strategy': [
@@ -2133,16 +2107,16 @@ octopus_flux = {
2133
2107
  # time periods for Intelligent Octopus
2134
2108
  intelligent_octopus = {
2135
2109
  'name': 'Intelligent Octopus',
2136
- 'off_peak1': {'start': 23.5, 'end': 5.5, 'force': 1},
2110
+ 'off_peak1': {'start': 23.5, 'end': 5.5, 'hold': 1},
2137
2111
  'forecast_times': [21, 22]
2138
2112
  }
2139
2113
 
2140
2114
  # time periods for Octopus Cosy
2141
2115
  octopus_cosy = {
2142
2116
  'name': 'Octopus Cosy',
2143
- 'off_peak1': {'start': 4.0, 'end': 7.0, 'force': 1},
2144
- 'off_peak2': {'start': 13.0, 'end': 16.0, 'force': 0},
2145
- 'off_peak3': {'start': 22.0, 'end': 24.0, 'force': 0},
2117
+ 'off_peak1': {'start': 4.0, 'end': 7.0, 'hold': 1},
2118
+ 'off_peak2': {'start': 13.0, 'end': 16.0, 'hold': 0},
2119
+ 'off_peak3': {'start': 22.0, 'end': 24.0, 'hold': 0},
2146
2120
  'peak1': {'start': 16.0, 'end': 19.0 },
2147
2121
  'forecast_times': [10, 11, 21, 22]
2148
2122
  }
@@ -2150,15 +2124,15 @@ octopus_cosy = {
2150
2124
  # time periods for Octopus Go
2151
2125
  octopus_go = {
2152
2126
  'name': 'Octopus Go',
2153
- 'off_peak1': {'start': 0.5, 'end': 4.5, 'force': 1},
2127
+ 'off_peak1': {'start': 0.5, 'end': 4.5, 'hold': 1},
2154
2128
  'forecast_times': [21, 22]
2155
2129
  }
2156
2130
 
2157
2131
  # time periods for Agile Octopus
2158
2132
  agile_octopus = {
2159
2133
  'name': 'Agile Octopus',
2160
- 'off_peak1': {'start': 0.0, 'end': 6.0, 'force': 1},
2161
- 'off_peak2': {'start': 12.0, 'end': 16.0, 'force': 0},
2134
+ 'off_peak1': {'start': 0.0, 'end': 6.0, 'hold': 1},
2135
+ 'off_peak2': {'start': 12.0, 'end': 16.0, 'hold': 0},
2162
2136
  'peak1': {'start': 16.0, 'end': 19.0 },
2163
2137
  'forecast_times': [9, 10, 21, 22],
2164
2138
  'strategy': [],
@@ -2168,27 +2142,27 @@ agile_octopus = {
2168
2142
  # time periods for British Gas Electric Driver
2169
2143
  bg_driver = {
2170
2144
  'name': 'British Gas Electric Driver',
2171
- 'off_peak1': {'start': 0.0, 'end': 5.0, 'force': 1},
2145
+ 'off_peak1': {'start': 0.0, 'end': 5.0, 'hold': 1},
2172
2146
  'forecast_times': [21, 22]
2173
2147
  }
2174
2148
 
2175
2149
  # time periods for EON Next Drive
2176
2150
  eon_drive = {
2177
2151
  'name': 'EON NextDrive',
2178
- 'off_peak1': {'start': 0.0, 'end': 7.0, 'force': 1},
2152
+ 'off_peak1': {'start': 0.0, 'end': 7.0, 'hold': 1},
2179
2153
  'forecast_times': [21, 22]
2180
2154
  }
2181
2155
 
2182
2156
  # time periods for Economy 7
2183
2157
  economy_7 = {
2184
2158
  'name': 'Eco 7',
2185
- 'off_peak1': {'start': 0.5, 'end': 7.5, 'force': 1, 'gmt': 1},
2159
+ 'off_peak1': {'start': 0.5, 'end': 7.5, 'hold': 1, 'gmt': 1},
2186
2160
  'forecast_times': [21, 22]
2187
2161
  }
2188
2162
 
2189
2163
  # custom time periods / template
2190
2164
  custom_periods = {'name': 'Custom',
2191
- 'off_peak1': {'start': 2.0, 'end': 5.0, 'force': 1},
2165
+ 'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1},
2192
2166
  'peak1': {'start': 16.0, 'end': 19.0 },
2193
2167
  'forecast_times': [21, 22]
2194
2168
  }
@@ -2208,8 +2182,10 @@ test_strategy = [
2208
2182
  {'start': 21, 'end': 22, 'mode': 'ForceCharge'}]
2209
2183
 
2210
2184
  # return a strategy that has been sorted and filtered for charge times:
2211
- def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=None):
2185
+ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=24, timed_mode=1):
2212
2186
  global tariff, base_time
2187
+ if timed_mode == 0:
2188
+ return []
2213
2189
  if use is None:
2214
2190
  use = tariff
2215
2191
  base_time_adjust = 0
@@ -2218,11 +2194,12 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit
2218
2194
  if tariff.get('strategy') is not None:
2219
2195
  for s in tariff['strategy']:
2220
2196
  strategy.append(s)
2221
- if use.get('agile') is not None and use['agile'].get('strategy') is not None:
2197
+ if timed_mode > 1 and use.get('agile') is not None and use['agile'].get('strategy') is not None:
2222
2198
  base_time_adjust = hours_difference(base_time, use['agile'].get('base_time') )
2223
2199
  for s in use['agile']['strategy']:
2224
- if limit is None or s.get('hour') is None or s['hour'] - base_time_adjust < 24:
2225
- s['valid_for'] = [int((s['hour'] - base_time_adjust) * steps_per_hour + i) for i in range(0, steps_per_hour // 2)] if s.get('hour') is not None else None
2200
+ hour = (s['hour'] - base_time_adjust) if limit is not None and s.get('hour') is not None else None
2201
+ if hour is None or (hour >= 0 and hour < limit):
2202
+ s['valid_for'] = [hour * steps_per_hour + i for i in range(0, steps_per_hour // 2)] if hour is not None else None
2226
2203
  strategy.append(s)
2227
2204
  if strategy is None or len(strategy) == 0:
2228
2205
  return []
@@ -2271,8 +2248,8 @@ tariff_config = {
2271
2248
  'region': "H", # region code to use for Octopus API
2272
2249
  'update_time': 16.5, # time in hours when tomrow's data can be fetched
2273
2250
  'weighting': None, # weights for weighted average
2274
- 'plunge_price': [3, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2275
- 'plunge_slots': 8, # number of 30 minute slots to use
2251
+ 'plunge_price': [3, 3], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2252
+ 'plunge_slots': 6, # number of 30 minute slots to use
2276
2253
  'data_wrap': 6, # prices to show per line
2277
2254
  'show_data': 1, # show pricing data
2278
2255
  'show_plot': 1 # plot pricing data
@@ -2363,7 +2340,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
2363
2340
  col = (now.hour * 2) % data_wrap
2364
2341
  s = f"\nPrice p/kWh inc VAT on {today}:"
2365
2342
  for i in range(0, len(prices)):
2366
- s += (f"\n {prices[i]['time']} " + " " * col * 6) if i == 0 or col == 0 else ""
2343
+ s += f"\n {prices[i]['time']}" if i == 0 or col == 0 else ""
2367
2344
  s += f" {prices[i]['price']:4.1f}"
2368
2345
  col = (col + 1) % data_wrap
2369
2346
  output(s)
@@ -2481,7 +2458,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2481
2458
  use[key]['start'] = time_hours(t[1])
2482
2459
  use[key]['end'] = time_hours(t[2])
2483
2460
  if len(t) > 3:
2484
- use[key]['force'] = t[3]
2461
+ use[key]['hold'] = t[3]
2485
2462
  gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
2486
2463
  output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
2487
2464
  # update dynamic charge times
@@ -2556,10 +2533,11 @@ def timed_list(data, base_hour, run_time):
2556
2533
  result = []
2557
2534
  h = base_hour
2558
2535
  for t in range(0, run_time):
2559
- result.append(data[int(h)])
2536
+ result.append(interpolate(h, data, wrap=1))
2560
2537
  h = round_time(h + 1 / steps_per_hour)
2561
2538
  return result
2562
2539
 
2540
+ # align forecast with base_hour and expand to cover run_time
2563
2541
  def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
2564
2542
  global steps_per_hour
2565
2543
  profile = []
@@ -2588,10 +2566,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2588
2566
  min_soc_now = min_soc
2589
2567
  max_soc_now = max_soc
2590
2568
  current_mode = 'SelfUse' if current_mode is None else current_mode
2591
- strategy = get_strategy() if timed_mode > 0 else None
2569
+ strategy = get_strategy(timed_mode=timed_mode)
2592
2570
  h = base_hour
2593
2571
  for i in range(0, run_time):
2594
- 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, 'pv': 0.0, 'discharge': 0.0}
2572
+ 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,
2573
+ 'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2595
2574
  if strategy is not None:
2596
2575
  period['mode'] = 'SelfUse'
2597
2576
  for d in strategy:
@@ -2624,6 +2603,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2624
2603
  for i in range(0, len(work_mode_timed)):
2625
2604
  bat_timed.append(kwh_current)
2626
2605
  w = work_mode_timed[i]
2606
+ w['kwh'] = kwh_current
2627
2607
  max_now = w['max_soc'] * capacity / 100
2628
2608
  if kwh_current < max_now and w['charge'] > 0.0:
2629
2609
  kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
@@ -2651,6 +2631,40 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2651
2631
  kwh_min = kwh_current
2652
2632
  return (bat_timed, kwh_min)
2653
2633
 
2634
+ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2635
+ global steps_per_hour
2636
+ output(f"\nConfiguring schedule:",1)
2637
+ strategy = []
2638
+ start = base_hour
2639
+ periods = []
2640
+ for t in range(0, min([24 * steps_per_hour, len(work_mode_timed)])):
2641
+ period = periods[0] if len(periods) > 0 else work_mode_timed[0]
2642
+ next_period = work_mode_timed[t]
2643
+ h = base_hour + t / steps_per_hour
2644
+ if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
2645
+ s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
2646
+ if period['mode'] == 'ForceDischarge':
2647
+ s['fdsoc'] = period.get('fdsoc')
2648
+ s['fdpwr'] = period.get('fdpwr')
2649
+ elif period['mode'] == 'ForceCharge':
2650
+ s['max_soc'] = period.get('max_soc')
2651
+ elif period['mode'] == 'SelfUse' and period['hold'] == 1:
2652
+ s['min_soc'] = min([int(period['kwh'] / capacity * 100 + 0.5), 100])
2653
+ for p in periods:
2654
+ p['min_soc'] = s['min_soc']
2655
+ if s['mode'] != 'SelfUse' or s['min_soc'] != min_soc:
2656
+ strategy.append(s)
2657
+ start = h
2658
+ periods = []
2659
+ periods.append(work_mode_timed[t])
2660
+ if len(strategy) > 0 and strategy[-1]['min_soc'] != min_soc:
2661
+ strategy.append({'start': start %24, 'end': (start + 1 / steps_per_hour) % 24, 'mode': 'SelfUse', 'min_soc': min_soc})
2662
+ periods = []
2663
+ for s in strategy:
2664
+ periods.append(set_period(segment = s, quiet=0))
2665
+ return periods
2666
+
2667
+
2654
2668
  # Battery open circuit voltage (OCV) from 0% to 100% SoC
2655
2669
  # 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
2656
2670
  lifepo4_curve = [51.00, 51.50, 52.00, 52.30, 52.60, 52.80, 52.90, 53.00, 53.10, 53.30, 54.00]
@@ -2713,7 +2727,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2713
2727
  # show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
2714
2728
  # run_after: 0 over-rides 'forecast_times'. The default is 1.
2715
2729
  # forecast_times: list of hours when forecast can be fetched (UTC)
2716
- # force_charge: 1 = set force charge, 2 = charge for whole period
2730
+ # force_charge: 1 = hold battery, 2 = charge for whole period
2717
2731
 
2718
2732
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2719
2733
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
@@ -2771,10 +2785,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2771
2785
  if tariff is not None and tariff.get(k) is not None:
2772
2786
  start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2773
2787
  end = round_time(time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2774
- force = 0 if tariff[k].get('force') is not None and tariff[k]['force'] == 0 else force_charge
2775
- times.append({'key': k, 'start': start, 'end': end, 'force': force})
2788
+ hold = 0 if tariff[k].get('hold') is not None and tariff[k]['hold'] == 0 else force_charge
2789
+ times.append({'key': k, 'start': start, 'end': end, 'hold': hold})
2776
2790
  if len(times) == 0:
2777
- times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'force': force_charge})
2791
+ times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'hold': force_charge})
2778
2792
  output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
2779
2793
  time_to_end1 = None
2780
2794
  for t in times:
@@ -2804,7 +2818,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2804
2818
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
2805
2819
  run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
2806
2820
  time_line = [round_time(base_hour + x / steps_per_hour - (hour_adjustment if x >= time_change else 0)) for x in range(0, run_time)]
2807
- force_charge = times[0]['force']
2821
+ bat_hold = times[0]['hold']
2808
2822
  # if we need to do a full charge, full_charge is the date, otherwise None
2809
2823
  full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
2810
2824
  if type(full_charge) is int: # value = day of month
@@ -2814,7 +2828,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2814
2828
  if debug_setting > 2:
2815
2829
  output(f"\ntoday = {today}, tomorrow = {tomorrow}, time_shift = {time_shift}")
2816
2830
  output(f"times = {times}")
2817
- output(f"start_at = {start_at}, end_by = {end_by}, force_charge = {force_charge}")
2831
+ output(f"start_at = {start_at}, end_by = {end_by}, bat_hold = {bat_hold}")
2818
2832
  output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
2819
2833
  output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
2820
2834
  output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
@@ -2842,7 +2856,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2842
2856
  model = device.get('deviceType')
2843
2857
  else:
2844
2858
  current_soc = test_soc
2845
- capacity = 14.6
2859
+ capacity = 14.54
2846
2860
  residual = test_soc * capacity / 100
2847
2861
  bat_volt = 315.4
2848
2862
  bat_power = 0.0
@@ -2887,7 +2901,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2887
2901
  output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2888
2902
  charge_current = derated_current
2889
2903
  else:
2890
- force_charge = 2
2904
+ bat_hold = 2
2891
2905
  output(f" Full charge set")
2892
2906
  # inverter losses
2893
2907
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
@@ -2960,16 +2974,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2960
2974
  solcast_value = None
2961
2975
  solcast_profile = None
2962
2976
  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):
2963
- fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'))
2977
+ fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'), d=base_time)
2964
2978
  if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
2965
2979
  solcast_value = fsolcast.daily[forecast_day]['kwh']
2966
2980
  solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
2967
2981
  solcast_from = time_hours(fsolcast.daily[today]['from']) if fsolcast.daily[today].get('from') is not None else 0
2968
- output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh") # get forecast.solar data and produce time line
2982
+ output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh")
2983
+ # get forecast.solar data and produce time line
2969
2984
  solar_value = None
2970
2985
  solar_profile = None
2971
2986
  if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
2972
- fsolar = Solar(quiet=True, shading=charge_config.get('shading'))
2987
+ fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
2973
2988
  if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
2974
2989
  solar_value = fsolar.daily[forecast_day]['kwh']
2975
2990
  solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
@@ -3007,6 +3022,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3007
3022
  if forecast is not None:
3008
3023
  expected = forecast
3009
3024
  generation_timed = [expected * x / sun_sum for x in sun_timed]
3025
+ output(f"\nForecast: {forecast:.1f}kWh")
3010
3026
  elif solcast_value is not None:
3011
3027
  expected = solcast_value
3012
3028
  generation_timed = solcast_timed
@@ -3039,8 +3055,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3039
3055
  fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
3040
3056
  fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
3041
3057
  discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
3042
- elif force_charge > 0 and i >= int(time_to_start) and i < int(time_to_end):
3058
+ elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
3043
3059
  discharge_timed[i] = bms_loss
3060
+ if timed_mode > 1:
3061
+ work_mode_timed[i]['hold'] = 1
3044
3062
  elif timed_mode > 0 and work_mode == 'Backup':
3045
3063
  discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
3046
3064
  elif timed_mode > 0 and work_mode == 'Feedin':
@@ -3063,7 +3081,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3063
3081
  start_soc = int(start_residual / capacity * 100 + 0.5)
3064
3082
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
3065
3083
  target_soc = charge_config.get('target_soc')
3066
- target_kwh = capacity if full_charge is not None or force_charge == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
3084
+ 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
3067
3085
  if target_kwh > (end_residual + kwh_needed):
3068
3086
  kwh_needed = target_kwh - end_residual
3069
3087
  elif test_charge is not None:
@@ -3080,13 +3098,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3080
3098
  start_timed = time_to_end
3081
3099
  end_timed = time_to_end
3082
3100
  end_soc = int(end_residual / capacity * 100 + 0.5)
3083
- # update min_soc for battery hold
3084
- if force_charge > 0 and timed_mode > 1:
3085
- for t in range(int(time_to_start), int(time_to_end)):
3086
- work_mode_timed[t]['min_soc'] = start_soc
3087
3101
  else:
3088
3102
  if test_charge is None:
3089
- output(f"\nCharge needed {kwh_needed:.2f}kWh:")
3103
+ output(f"\nCharge needed: {kwh_needed:.2f}kWh")
3090
3104
  charge_message = "with charge added"
3091
3105
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
3092
3106
  # work out time to add kwh_needed to battery
@@ -3100,20 +3114,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3100
3114
  kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
3101
3115
  required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # time to recover energy not added
3102
3116
  hours = required if required > hours and required < charge_time else charge_time
3103
- # round charge time
3117
+ # round charge time and work out what will actually be added
3104
3118
  min_hours = charge_config['min_hours']
3105
3119
  hours = int(hours / min_hours + 0.99) * min_hours
3120
+ kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
3106
3121
  # rework charge and discharge
3107
3122
  charge_period = get_best_charge_period(start_at, hours)
3108
3123
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
3109
3124
  price = charge_period.get('price') if charge_period is not None else None
3110
3125
  start_timed = time_to_start + charge_offset * steps_per_hour
3111
- end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
3126
+ end_timed = start_timed + hours * steps_per_hour
3112
3127
  start_residual = interpolate(start_timed, bat_timed)
3113
- end_soc = min([int((start_residual + kwh_needed) / capacity * 100 + 0.5), 100])
3128
+ end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
3114
3129
  output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
3115
- output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}" + (f" at {price:5.2f}p/kWh" if price is not None else ""))
3116
- for i in range(int(time_to_start), int(end_timed) + 1):
3130
+ output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
3131
+ + (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh)")
3132
+ for i in range(int(time_to_start), int(time_to_end)):
3117
3133
  j = i + 1
3118
3134
  # work out time (fraction of hour) when charging in hour from i to j
3119
3135
  if start_timed >= i and end_timed < j:
@@ -3127,12 +3143,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3127
3143
  else:
3128
3144
  t = 0.0 # complete hour before start or after end
3129
3145
  output(f"i = {i}, j = {j}, t = {t}", 3)
3130
- if i >= start_timed:
3146
+ if i >= start_timed and i < end_timed:
3147
+ work_mode_timed[i]['mode'] = 'ForceCharge'
3131
3148
  work_mode_timed[i]['charge'] = charge_power * t
3132
- work_mode_timed[i]['max_soc'] = end_soc if timed_mode > 1 else target_soc if target_soc is not None else 100
3149
+ work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else 100
3133
3150
  work_mode_timed[i]['discharge'] *= (1-t)
3134
- elif force_charge > 0 and timed_mode > 1:
3135
- work_mode_timed[i]['min_soc'] = start_soc
3136
3151
  # rebuild the battery residual with the charge added and min_soc
3137
3152
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3138
3153
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
@@ -3141,6 +3156,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3141
3156
  output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
3142
3157
  if not charge_today:
3143
3158
  output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
3159
+ # setup charging
3160
+ if timed_mode > 1:
3161
+ periods = charge_periods(work_mode_timed, base_hour, min_soc, capacity)
3162
+ if update_settings > 0:
3163
+ set_schedule(periods = periods)
3164
+ else:
3165
+ # work out the charge times and set. First period is battery hold, second period is battery charge / hold
3166
+ start1 = round_time(base_hour + time_to_start / steps_per_hour)
3167
+ start2 = round_time(base_hour + start_timed / steps_per_hour)
3168
+ end1 = start1 if bat_hold == 0 else start2
3169
+ end2 = round_time(base_hour + (end_timed if bat_hold == 0 else time_to_end) / steps_per_hour)
3170
+ set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
3171
+ if update_settings == 0:
3172
+ output(f"\nNo changes made to charge settings")
3144
3173
  if show_data > 0:
3145
3174
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3146
3175
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
@@ -3148,7 +3177,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3148
3177
  t = 0
3149
3178
  while t < len(time_line) and bat_timed[t] is not None:
3150
3179
  col = h % data_wrap
3151
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3180
+ s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3152
3181
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3153
3182
  h += 1
3154
3183
  t += steps_per_hour
@@ -3196,20 +3225,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3196
3225
  file = open(storage + file_name, 'w')
3197
3226
  json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
3198
3227
  file.close()
3199
- # setup charging
3200
- if update_settings == 1:
3201
- # work out the charge times and set. First period is battery hold, second period is battery charge / hold
3202
- start1 = round_time(base_hour + time_to_start / steps_per_hour)
3203
- start2 = round_time(base_hour + start_timed / steps_per_hour)
3204
- end1 = start1 if force_charge == 0 else start2
3205
- end2 = round_time(base_hour + (end_timed if force_charge == 0 else time_to_end) / steps_per_hour)
3206
- if timed_mode > 1:
3207
- periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, end_soc=end_soc, start_soc=start_soc)
3208
- set_schedule(periods = periods)
3209
- else:
3210
- set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1)
3211
- else:
3212
- output(f"\nNo changes made to charge settings")
3213
3228
  output_close(plot=show_plot)
3214
3229
  return None
3215
3230
 
@@ -3285,7 +3300,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3285
3300
  t = 0
3286
3301
  while t < len(time_line) and bat_timed[t] is not None:
3287
3302
  col = h % data_wrap
3288
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3303
+ s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3289
3304
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3290
3305
  h += 1
3291
3306
  t += steps_per_hour
@@ -3833,18 +3848,19 @@ class Solcast :
3833
3848
  Load Solcast Estimate / Actuals / Forecast daily yield
3834
3849
  """
3835
3850
 
3836
- def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None) :
3851
+ def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None, d=None) :
3837
3852
  # days sets the number of days to get for forecasts (and estimated if enabled)
3838
3853
  # reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
3839
3854
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3840
3855
  # 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
3841
3856
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3842
3857
  self.data = {}
3858
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3843
3859
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3844
- self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
3860
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
3845
3861
  self.quarter = int(self.today[5:7]) // 3 % 4
3846
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
3847
- self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
3862
+ self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
3863
+ self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3848
3864
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
3849
3865
  if reload == 1 and os.path.exists(storage + self.save):
3850
3866
  os.remove(storage + self.save)
@@ -4180,13 +4196,14 @@ class Solar :
4180
4196
  """
4181
4197
 
4182
4198
  # get solar forecast and return total expected yield
4183
- def __init__(self, reload=0, quiet=False, shading=None):
4199
+ def __init__(self, reload=0, quiet=False, shading=None, d=None):
4184
4200
  global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
4185
4201
  self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
4186
- self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
4202
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
4203
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
4187
4204
  self.quarter = int(self.today[5:7]) // 3 % 4
4188
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
4189
- self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
4205
+ self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
4206
+ self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
4190
4207
  self.arrays = None
4191
4208
  self.results = None
4192
4209
  self.save = solar_save #.replace('.', '_%.'.replace('%',self.today.replace('-','')))
foxesscloud/openapi.py CHANGED
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 28 September 2024
4
+ Updated: 01 October 2024
5
5
  By: Tony Matthews
6
6
  """
7
7
  ##################################################################################################
@@ -10,7 +10,7 @@ By: Tony Matthews
10
10
  # ALL RIGHTS ARE RESERVED © Tony Matthews 2024
11
11
  ##################################################################################################
12
12
 
13
- version = "2.5.7"
13
+ version = "2.5.8"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -106,16 +106,17 @@ def query_time(d, time_span):
106
106
  return (t_begin * 1000, t_end * 1000)
107
107
 
108
108
  # interpolate a result from a list of values
109
- def interpolate(f, v):
109
+ def interpolate(f, v, wrap=0):
110
110
  if len(v) == 0:
111
111
  return None
112
112
  if f < 0.0:
113
113
  return v[0]
114
- elif f >= len(v) - 1:
114
+ elif wrap == 0 and f >= len(v) - 1:
115
115
  return v[-1]
116
- i = int(f)
117
- x = f - i
118
- return v[i] * (1-x) + v[i+1] * x
116
+ i = int(f) % len(v)
117
+ x = f % 1.0
118
+ j = (i + 1) % len(v)
119
+ return v[i] * (1-x) + v[j] * x
119
120
 
120
121
  # return the average of a list
121
122
  def avg(x):
@@ -594,12 +595,12 @@ def get_charge():
594
595
  # helper to format time period structure
595
596
  def time_period(t, n):
596
597
  (enable, start, end) = (t['enable1'], t['startTime1'], t['endTime1']) if n == 1 else (t['enable2'], t['startTime2'], t['endTime2'])
597
- result = f"{start['hour']:02d}:{start['minute']:02d} - {end['hour']:02d}:{end['minute']:02d}"
598
+ result = f"{start['hour']:02d}:{start['minute']:02d}-{end['hour']:02d}:{end['minute']:02d}"
598
599
  if start['hour'] != end['hour'] or start['minute'] != end['minute']:
599
600
  result += f" Charge from grid" if enable else f" Force Charge"
600
601
  return result
601
602
 
602
- def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force = 0):
603
+ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force = 0, enable=1):
603
604
  global token, device_sn, battery_settings, debug_setting, time_period_vars
604
605
  if get_device() is None:
605
606
  return None
@@ -649,6 +650,8 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
649
650
  output(f"\nSetting time periods:", 1)
650
651
  output(f" Time Period 1 = {time_period(battery_settings['times'], 1)}", 1)
651
652
  output(f" Time Period 2 = {time_period(battery_settings['times'], 2)}", 1)
653
+ if enable == 0:
654
+ return battery_settings
652
655
  # set charge times
653
656
  body = {'sn': device_sn}
654
657
  for k in ['enable1', 'startTime1', 'endTime1', 'enable2', 'startTime2', 'endTime2']:
@@ -669,36 +672,6 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
669
672
  output(f"success", 2)
670
673
  return battery_settings
671
674
 
672
- def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, end_soc=100, start_soc=10):
673
- output(f"\nConfiguring schedule",1)
674
- charge = []
675
- st1 = time_hours(st1)
676
- en1 = time_hours(en1)
677
- st2 = time_hours(st2)
678
- en2 = time_hours(en2)
679
- span = None
680
- if st2 is not None and en2 is not None and st2 != en2:
681
- charge.append({'start': st2, 'end': en2, 'mode': 'ForceCharge', 'min_soc': min_soc, 'max_soc': end_soc})
682
- span = {'start': st2, 'end': en2}
683
- if st1 is not None and en1 is not None and st1 != en1:
684
- charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': start_soc})
685
- span = {'start': st1, 'end': en2}
686
- elif st1 is not None and en1 is not None and st1 != en1:
687
- span = {'start': st1, 'end': en1}
688
- if round_time(en1 - st1) > 0.25:
689
- st3 = round_time(en1 - 5 / 60)
690
- charge.append({'start': st1, 'end': st3, 'mode': 'SelfUse', 'min_soc': start_soc})
691
- st1 = st3
692
- charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': min_soc})
693
- strategy = get_strategy(remove=span, limit=24)[:(8 - len(charge))]
694
- for c in charge:
695
- strategy.append(c)
696
- periods = []
697
- for s in sorted(strategy, key=lambda s: s['start']):
698
- periods.append(set_period(segment = s, quiet=0))
699
- return periods
700
-
701
-
702
675
  ##################################################################################################
703
676
  # get min soc settings and save in battery_settings
704
677
  ##################################################################################################
@@ -1147,7 +1120,7 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1147
1120
  return None
1148
1121
  if quiet == 0:
1149
1122
  s = f" {hours_time(start)}-{hours_time(end)} {mode}, minsoc {min_soc}%"
1150
- s += f", maxsoc {max_soc}%" if max_soc is not None else ""
1123
+ s += f", maxsoc {max_soc}%" if max_soc is not None and mode == 'ForceCharge' else ""
1151
1124
  s += f", fdPwr {fdpwr}W, fdSoC {fdsoc}%" if mode == 'ForceDischarge' else ""
1152
1125
  s += f", {price:.2f}p/kWh" if price is not None else ""
1153
1126
  output(s, 1)
@@ -1986,7 +1959,7 @@ def hours_difference(t1, t2):
1986
1959
  # time periods for Octopus Flux
1987
1960
  octopus_flux = {
1988
1961
  'name': 'Octopus Flux',
1989
- 'off_peak1': {'start': 2.0, 'end': 5.0, 'force': 1}, # off-peak period 1 / am charging period
1962
+ 'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1}, # off-peak period 1 / am charging period
1990
1963
  'peak1': {'start': 16.0, 'end': 19.0 }, # peak period 1
1991
1964
  'forecast_times': [21, 22], # hours in a day to get a forecast
1992
1965
  'strategy': [
@@ -1997,16 +1970,16 @@ octopus_flux = {
1997
1970
  # time periods for Intelligent Octopus
1998
1971
  intelligent_octopus = {
1999
1972
  'name': 'Intelligent Octopus',
2000
- 'off_peak1': {'start': 23.5, 'end': 5.5, 'force': 1},
1973
+ 'off_peak1': {'start': 23.5, 'end': 5.5, 'hold': 1},
2001
1974
  'forecast_times': [21, 22]
2002
1975
  }
2003
1976
 
2004
1977
  # time periods for Octopus Cosy
2005
1978
  octopus_cosy = {
2006
1979
  'name': 'Octopus Cosy',
2007
- 'off_peak1': {'start': 4.0, 'end': 7.0, 'force': 1},
2008
- 'off_peak2': {'start': 13.0, 'end': 16.0, 'force': 0},
2009
- 'off_peak3': {'start': 22.0, 'end': 24.0, 'force': 0},
1980
+ 'off_peak1': {'start': 4.0, 'end': 7.0, 'hold': 1},
1981
+ 'off_peak2': {'start': 13.0, 'end': 16.0, 'hold': 0},
1982
+ 'off_peak3': {'start': 22.0, 'end': 24.0, 'hold': 0},
2010
1983
  'peak1': {'start': 16.0, 'end': 19.0 },
2011
1984
  'forecast_times': [10, 11, 21, 22]
2012
1985
  }
@@ -2014,15 +1987,15 @@ octopus_cosy = {
2014
1987
  # time periods for Octopus Go
2015
1988
  octopus_go = {
2016
1989
  'name': 'Octopus Go',
2017
- 'off_peak1': {'start': 0.5, 'end': 4.5, 'force': 1},
1990
+ 'off_peak1': {'start': 0.5, 'end': 4.5, 'hold': 1},
2018
1991
  'forecast_times': [21, 22]
2019
1992
  }
2020
1993
 
2021
1994
  # time periods for Agile Octopus
2022
1995
  agile_octopus = {
2023
1996
  'name': 'Agile Octopus',
2024
- 'off_peak1': {'start': 0.0, 'end': 6.0, 'force': 1},
2025
- 'off_peak2': {'start': 12.0, 'end': 16.0, 'force': 0},
1997
+ 'off_peak1': {'start': 0.0, 'end': 6.0, 'hold': 1},
1998
+ 'off_peak2': {'start': 12.0, 'end': 16.0, 'hold': 0},
2026
1999
  'peak1': {'start': 16.0, 'end': 19.0 },
2027
2000
  'forecast_times': [9, 10, 21, 22],
2028
2001
  'strategy': [],
@@ -2032,27 +2005,27 @@ agile_octopus = {
2032
2005
  # time periods for British Gas Electric Driver
2033
2006
  bg_driver = {
2034
2007
  'name': 'British Gas Electric Driver',
2035
- 'off_peak1': {'start': 0.0, 'end': 5.0, 'force': 1},
2008
+ 'off_peak1': {'start': 0.0, 'end': 5.0, 'hold': 1},
2036
2009
  'forecast_times': [21, 22]
2037
2010
  }
2038
2011
 
2039
2012
  # time periods for EON Next Drive
2040
2013
  eon_drive = {
2041
2014
  'name': 'EON NextDrive',
2042
- 'off_peak1': {'start': 0.0, 'end': 7.0, 'force': 1},
2015
+ 'off_peak1': {'start': 0.0, 'end': 7.0, 'hold': 1},
2043
2016
  'forecast_times': [21, 22]
2044
2017
  }
2045
2018
 
2046
2019
  # time periods for Economy 7
2047
2020
  economy_7 = {
2048
2021
  'name': 'Eco 7',
2049
- 'off_peak1': {'start': 0.5, 'end': 7.5, 'force': 1, 'gmt': 1},
2022
+ 'off_peak1': {'start': 0.5, 'end': 7.5, 'hold': 1, 'gmt': 1},
2050
2023
  'forecast_times': [21, 22]
2051
2024
  }
2052
2025
 
2053
2026
  # custom time periods / template
2054
2027
  custom_periods = {'name': 'Custom',
2055
- 'off_peak1': {'start': 2.0, 'end': 5.0, 'force': 1},
2028
+ 'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1},
2056
2029
  'peak1': {'start': 16.0, 'end': 19.0 },
2057
2030
  'forecast_times': [21, 22]
2058
2031
  }
@@ -2072,8 +2045,10 @@ test_strategy = [
2072
2045
  {'start': 21, 'end': 22, 'mode': 'ForceCharge'}]
2073
2046
 
2074
2047
  # return a strategy that has been sorted and filtered for charge times:
2075
- def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=None):
2048
+ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=24, timed_mode=1):
2076
2049
  global tariff, base_time
2050
+ if timed_mode == 0:
2051
+ return []
2077
2052
  if use is None:
2078
2053
  use = tariff
2079
2054
  base_time_adjust = 0
@@ -2082,11 +2057,12 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit
2082
2057
  if tariff.get('strategy') is not None:
2083
2058
  for s in tariff['strategy']:
2084
2059
  strategy.append(s)
2085
- if use.get('agile') is not None and use['agile'].get('strategy') is not None:
2060
+ if timed_mode > 1 and use.get('agile') is not None and use['agile'].get('strategy') is not None:
2086
2061
  base_time_adjust = hours_difference(base_time, use['agile'].get('base_time') )
2087
2062
  for s in use['agile']['strategy']:
2088
- if limit is None or s.get('hour') is None or s['hour'] - base_time_adjust < 24:
2089
- s['valid_for'] = [int((s['hour'] - base_time_adjust) * steps_per_hour + i) for i in range(0, steps_per_hour // 2)] if s.get('hour') is not None else None
2063
+ hour = (s['hour'] - base_time_adjust) if limit is not None and s.get('hour') is not None else None
2064
+ if hour is None or (hour >= 0 and hour < limit):
2065
+ s['valid_for'] = [hour * steps_per_hour + i for i in range(0, steps_per_hour // 2)] if hour is not None else None
2090
2066
  strategy.append(s)
2091
2067
  if strategy is None or len(strategy) == 0:
2092
2068
  return []
@@ -2135,8 +2111,8 @@ tariff_config = {
2135
2111
  'region': "H", # region code to use for Octopus API
2136
2112
  'update_time': 16.5, # time in hours when tomrow's data can be fetched
2137
2113
  'weighting': None, # weights for weighted average
2138
- 'plunge_price': [3, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2139
- 'plunge_slots': 8, # number of 30 minute slots to use
2114
+ 'plunge_price': [3, 3], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2115
+ 'plunge_slots': 6, # number of 30 minute slots to use
2140
2116
  'data_wrap': 6, # prices to show per line
2141
2117
  'show_data': 1, # show pricing data
2142
2118
  'show_plot': 1 # plot pricing data
@@ -2227,7 +2203,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
2227
2203
  col = (now.hour * 2) % data_wrap
2228
2204
  s = f"\nPrice p/kWh inc VAT on {today}:"
2229
2205
  for i in range(0, len(prices)):
2230
- s += (f"\n {prices[i]['time']} " + " " * col * 6) if i == 0 or col == 0 else ""
2206
+ s += f"\n {prices[i]['time']}" if i == 0 or col == 0 else ""
2231
2207
  s += f" {prices[i]['price']:4.1f}"
2232
2208
  col = (col + 1) % data_wrap
2233
2209
  output(s)
@@ -2345,7 +2321,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2345
2321
  use[key]['start'] = time_hours(t[1])
2346
2322
  use[key]['end'] = time_hours(t[2])
2347
2323
  if len(t) > 3:
2348
- use[key]['force'] = t[3]
2324
+ use[key]['hold'] = t[3]
2349
2325
  gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
2350
2326
  output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
2351
2327
  # update dynamic charge times
@@ -2420,10 +2396,11 @@ def timed_list(data, base_hour, run_time):
2420
2396
  result = []
2421
2397
  h = base_hour
2422
2398
  for t in range(0, run_time):
2423
- result.append(data[int(h)])
2399
+ result.append(interpolate(h, data, wrap=1))
2424
2400
  h = round_time(h + 1 / steps_per_hour)
2425
2401
  return result
2426
2402
 
2403
+ # align forecast with base_hour and expand to cover run_time
2427
2404
  def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
2428
2405
  global steps_per_hour
2429
2406
  profile = []
@@ -2452,10 +2429,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2452
2429
  min_soc_now = min_soc
2453
2430
  max_soc_now = max_soc
2454
2431
  current_mode = 'SelfUse' if current_mode is None else current_mode
2455
- strategy = get_strategy() if timed_mode > 0 else None
2432
+ strategy = get_strategy(timed_mode=timed_mode)
2456
2433
  h = base_hour
2457
2434
  for i in range(0, run_time):
2458
- 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, 'pv': 0.0, 'discharge': 0.0}
2435
+ 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,
2436
+ 'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2459
2437
  if strategy is not None:
2460
2438
  period['mode'] = 'SelfUse'
2461
2439
  for d in strategy:
@@ -2488,6 +2466,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2488
2466
  for i in range(0, len(work_mode_timed)):
2489
2467
  bat_timed.append(kwh_current)
2490
2468
  w = work_mode_timed[i]
2469
+ w['kwh'] = kwh_current
2491
2470
  max_now = w['max_soc'] * capacity / 100
2492
2471
  if kwh_current < max_now and w['charge'] > 0.0:
2493
2472
  kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
@@ -2515,6 +2494,40 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2515
2494
  kwh_min = kwh_current
2516
2495
  return (bat_timed, kwh_min)
2517
2496
 
2497
+ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2498
+ global steps_per_hour
2499
+ output(f"\nConfiguring schedule:",1)
2500
+ strategy = []
2501
+ start = base_hour
2502
+ periods = []
2503
+ for t in range(0, min([24 * steps_per_hour, len(work_mode_timed)])):
2504
+ period = periods[0] if len(periods) > 0 else work_mode_timed[0]
2505
+ next_period = work_mode_timed[t]
2506
+ h = base_hour + t / steps_per_hour
2507
+ if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
2508
+ s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
2509
+ if period['mode'] == 'ForceDischarge':
2510
+ s['fdsoc'] = period.get('fdsoc')
2511
+ s['fdpwr'] = period.get('fdpwr')
2512
+ elif period['mode'] == 'ForceCharge':
2513
+ s['max_soc'] = period.get('max_soc')
2514
+ elif period['mode'] == 'SelfUse' and period['hold'] == 1:
2515
+ s['min_soc'] = min([int(period['kwh'] / capacity * 100 + 0.5), 100])
2516
+ for p in periods:
2517
+ p['min_soc'] = s['min_soc']
2518
+ if s['mode'] != 'SelfUse' or s['min_soc'] != min_soc:
2519
+ strategy.append(s)
2520
+ start = h
2521
+ periods = []
2522
+ periods.append(work_mode_timed[t])
2523
+ if len(strategy) > 0 and strategy[-1]['min_soc'] != min_soc:
2524
+ strategy.append({'start': start %24, 'end': (start + 1 / steps_per_hour) % 24, 'mode': 'SelfUse', 'min_soc': min_soc})
2525
+ periods = []
2526
+ for s in strategy:
2527
+ periods.append(set_period(segment = s, quiet=0))
2528
+ return periods
2529
+
2530
+
2518
2531
  # Battery open circuit voltage (OCV) from 0% to 100% SoC
2519
2532
  # 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
2520
2533
  lifepo4_curve = [51.00, 51.50, 52.00, 52.30, 52.60, 52.80, 52.90, 53.00, 53.10, 53.30, 54.00]
@@ -2577,7 +2590,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2577
2590
  # show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
2578
2591
  # run_after: 0 over-rides 'forecast_times'. The default is 1.
2579
2592
  # forecast_times: list of hours when forecast can be fetched (UTC)
2580
- # force_charge: 1 = set force charge, 2 = charge for whole period
2593
+ # force_charge: 1 = hold battery, 2 = charge for whole period
2581
2594
 
2582
2595
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2583
2596
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
@@ -2635,10 +2648,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2635
2648
  if tariff is not None and tariff.get(k) is not None:
2636
2649
  start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2637
2650
  end = round_time(time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2638
- force = 0 if tariff[k].get('force') is not None and tariff[k]['force'] == 0 else force_charge
2639
- times.append({'key': k, 'start': start, 'end': end, 'force': force})
2651
+ hold = 0 if tariff[k].get('hold') is not None and tariff[k]['hold'] == 0 else force_charge
2652
+ times.append({'key': k, 'start': start, 'end': end, 'hold': hold})
2640
2653
  if len(times) == 0:
2641
- times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'force': force_charge})
2654
+ times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'hold': force_charge})
2642
2655
  output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
2643
2656
  time_to_end1 = None
2644
2657
  for t in times:
@@ -2668,7 +2681,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2668
2681
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
2669
2682
  run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
2670
2683
  time_line = [round_time(base_hour + x / steps_per_hour - (hour_adjustment if x >= time_change else 0)) for x in range(0, run_time)]
2671
- force_charge = times[0]['force']
2684
+ bat_hold = times[0]['hold']
2672
2685
  # if we need to do a full charge, full_charge is the date, otherwise None
2673
2686
  full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
2674
2687
  if type(full_charge) is int: # value = day of month
@@ -2706,7 +2719,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2706
2719
  model = device.get('deviceType')
2707
2720
  else:
2708
2721
  current_soc = test_soc
2709
- capacity = 14.6
2722
+ capacity = 14.54
2710
2723
  residual = test_soc * capacity / 100
2711
2724
  bat_volt = 315.4
2712
2725
  bat_power = 0.0
@@ -2751,7 +2764,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2751
2764
  output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2752
2765
  charge_current = derated_current
2753
2766
  else:
2754
- force_charge = 2
2767
+ bat_hold = 2
2755
2768
  output(f" Full charge set")
2756
2769
  # inverter losses
2757
2770
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
@@ -2824,7 +2837,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2824
2837
  solcast_value = None
2825
2838
  solcast_profile = None
2826
2839
  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):
2827
- fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'))
2840
+ fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'), d=base_time)
2828
2841
  if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
2829
2842
  solcast_value = fsolcast.daily[forecast_day]['kwh']
2830
2843
  solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
@@ -2833,7 +2846,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2833
2846
  solar_value = None
2834
2847
  solar_profile = None
2835
2848
  if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
2836
- fsolar = Solar(quiet=True, shading=charge_config.get('shading'))
2849
+ fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
2837
2850
  if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
2838
2851
  solar_value = fsolar.daily[forecast_day]['kwh']
2839
2852
  solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
@@ -2871,6 +2884,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2871
2884
  if forecast is not None:
2872
2885
  expected = forecast
2873
2886
  generation_timed = [expected * x / sun_sum for x in sun_timed]
2887
+ output(f"\nForecast: {forecast:.1f}kWh")
2874
2888
  elif solcast_value is not None:
2875
2889
  expected = solcast_value
2876
2890
  generation_timed = solcast_timed
@@ -2903,8 +2917,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2903
2917
  fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
2904
2918
  fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
2905
2919
  discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
2906
- elif force_charge > 0 and i >= int(time_to_start) and i < int(time_to_end):
2920
+ elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
2907
2921
  discharge_timed[i] = bms_loss
2922
+ if timed_mode > 1:
2923
+ work_mode_timed[i]['hold'] = 1
2908
2924
  elif timed_mode > 0 and work_mode == 'Backup':
2909
2925
  discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
2910
2926
  elif timed_mode > 0 and work_mode == 'Feedin':
@@ -2927,7 +2943,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2927
2943
  start_soc = int(start_residual / capacity * 100 + 0.5)
2928
2944
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
2929
2945
  target_soc = charge_config.get('target_soc')
2930
- target_kwh = capacity if full_charge is not None or force_charge == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
2946
+ 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
2931
2947
  if target_kwh > (end_residual + kwh_needed):
2932
2948
  kwh_needed = target_kwh - end_residual
2933
2949
  elif test_charge is not None:
@@ -2944,13 +2960,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2944
2960
  start_timed = time_to_end
2945
2961
  end_timed = time_to_end
2946
2962
  end_soc = int(end_residual / capacity * 100 + 0.5)
2947
- # update min_soc for battery hold
2948
- if force_charge > 0 and timed_mode > 1:
2949
- for t in range(int(time_to_start), int(time_to_end)):
2950
- work_mode_timed[t]['min_soc'] = start_soc
2951
2963
  else:
2952
2964
  if test_charge is None:
2953
- output(f"\nCharge needed {kwh_needed:.2f}kWh:")
2965
+ output(f"\nCharge needed: {kwh_needed:.2f}kWh:")
2954
2966
  charge_message = "with charge added"
2955
2967
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
2956
2968
  # work out time to add kwh_needed to battery
@@ -2964,20 +2976,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2964
2976
  kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
2965
2977
  required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # hold time to recover energy not added
2966
2978
  hours = required if required > hours and required < charge_time else charge_time
2967
- # round charge time
2979
+ # round charge time and work out what will actually be added
2968
2980
  min_hours = charge_config['min_hours']
2969
2981
  hours = int(hours / min_hours + 0.99) * min_hours
2982
+ kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
2970
2983
  # rework charge and discharge
2971
2984
  charge_period = get_best_charge_period(start_at, hours)
2972
2985
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
2973
2986
  price = charge_period.get('price') if charge_period is not None else None
2974
2987
  start_timed = time_to_start + charge_offset * steps_per_hour
2975
- end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
2988
+ end_timed = start_timed + hours * steps_per_hour
2976
2989
  start_residual = interpolate(start_timed, bat_timed)
2977
- end_soc = min([int((start_residual + kwh_needed) / capacity * 100 + 0.5), 100])
2990
+ end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
2978
2991
  output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
2979
- output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}" + (f" at {price:5.2f}p/kWh" if price is not None else ""))
2980
- for i in range(int(time_to_start), int(end_timed) + 1):
2992
+ output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
2993
+ + (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh)")
2994
+ for i in range(int(time_to_start), int(time_to_end)):
2981
2995
  j = i + 1
2982
2996
  # work out time (fraction of hour) when charging in hour from i to j
2983
2997
  if start_timed >= i and end_timed < j:
@@ -2991,12 +3005,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2991
3005
  else:
2992
3006
  t = 0.0 # complete hour before start or after end
2993
3007
  output(f"i = {i}, j = {j}, t = {t}", 3)
2994
- if i >= start_timed:
3008
+ if i >= start_timed and i < end_timed:
3009
+ work_mode_timed[i]['mode'] = 'ForceCharge'
2995
3010
  work_mode_timed[i]['charge'] = charge_power * t
2996
- work_mode_timed[i]['max_soc'] = end_soc if timed_mode > 1 else target_soc if target_soc is not None else 100
3011
+ work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else 100
2997
3012
  work_mode_timed[i]['discharge'] *= (1-t)
2998
- elif force_charge > 0 and timed_mode > 1:
2999
- work_mode_timed[i]['min_soc'] = start_soc
3000
3013
  # rebuild the battery residual with any charge added and min_soc
3001
3014
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3002
3015
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
@@ -3005,6 +3018,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3005
3018
  output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
3006
3019
  if not charge_today:
3007
3020
  output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
3021
+ # setup charging
3022
+ if timed_mode > 1:
3023
+ periods = charge_periods(work_mode_timed, base_hour, min_soc, capacity)
3024
+ if update_settings > 0:
3025
+ set_schedule(periods = periods)
3026
+ else:
3027
+ # work out the charge times and set. First period is battery hold, second period is battery charge / hold
3028
+ start1 = round_time(base_hour + time_to_start / steps_per_hour)
3029
+ start2 = round_time(base_hour + start_timed / steps_per_hour)
3030
+ end1 = start1 if bat_hold == 0 else start2
3031
+ end2 = round_time(base_hour + (end_timed if bat_hold == 0 else time_to_end) / steps_per_hour)
3032
+ set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
3033
+ if update_settings == 0:
3034
+ output(f"\nNo changes made to charge settings")
3008
3035
  if show_data > 0:
3009
3036
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3010
3037
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
@@ -3012,7 +3039,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3012
3039
  t = 0
3013
3040
  while t < len(time_line) and bat_timed[t] is not None:
3014
3041
  col = h % data_wrap
3015
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3042
+ s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3016
3043
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3017
3044
  h += 1
3018
3045
  t += steps_per_hour
@@ -3060,20 +3087,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3060
3087
  file = open(storage + file_name, 'w')
3061
3088
  json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
3062
3089
  file.close()
3063
- # setup charging
3064
- if update_settings == 1:
3065
- # work out the charge times and set. First period is battery hold, second period is battery charge / hold
3066
- start1 = round_time(base_hour + time_to_start / steps_per_hour)
3067
- start2 = round_time(base_hour + start_timed / steps_per_hour)
3068
- end1 = start1 if force_charge == 0 else start2
3069
- end2 = round_time(base_hour + (end_timed if force_charge == 0 else time_to_end) / steps_per_hour)
3070
- if timed_mode > 1:
3071
- periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, end_soc=end_soc, start_soc=start_soc)
3072
- set_schedule(periods = periods)
3073
- else:
3074
- set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1)
3075
- else:
3076
- output(f"\nNo changes made to charge settings")
3077
3090
  output_close(plot=show_plot)
3078
3091
  return None
3079
3092
 
@@ -3148,7 +3161,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3148
3161
  t = 0
3149
3162
  while t < len(time_line) and bat_timed[t] is not None:
3150
3163
  col = h % data_wrap
3151
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3164
+ s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3152
3165
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3153
3166
  h += 1
3154
3167
  t += steps_per_hour
@@ -3695,18 +3708,19 @@ class Solcast :
3695
3708
  Load Solcast Estimate / Actuals / Forecast daily yield
3696
3709
  """
3697
3710
 
3698
- def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None) :
3711
+ def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None, d=None) :
3699
3712
  # days sets the number of days to get for forecasts (and estimated if enabled)
3700
3713
  # reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
3701
3714
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3702
3715
  # 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
3703
3716
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3704
3717
  self.data = {}
3718
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3705
3719
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3706
- self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
3720
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
3707
3721
  self.quarter = int(self.today[5:7]) // 3 % 4
3708
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
3709
- self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
3722
+ self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
3723
+ self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3710
3724
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
3711
3725
  if reload == 1 and os.path.exists(storage + self.save):
3712
3726
  os.remove(storage + self.save)
@@ -4042,13 +4056,14 @@ class Solar :
4042
4056
  """
4043
4057
 
4044
4058
  # get solar forecast and return total expected yield
4045
- def __init__(self, reload=0, quiet=False, shading=None):
4059
+ def __init__(self, reload=0, quiet=False, shading=None, d=None):
4046
4060
  global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
4047
4061
  self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
4048
- self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
4062
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
4063
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
4049
4064
  self.quarter = int(self.today[5:7]) // 3 % 4
4050
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
4051
- self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
4065
+ self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
4066
+ self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
4052
4067
  self.arrays = None
4053
4068
  self.results = None
4054
4069
  self.save = solar_save #.replace('.', '_%.'.replace('%',self.today.replace('-','')))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.5.7
3
+ Version: 2.5.8
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
@@ -130,7 +130,7 @@ You can change inverter settings using:
130
130
 
131
131
  ```
132
132
  f.set_min(minSocOnGrid, minSoc)
133
- f.set_charge(ch1, st1, en1, ch2, st2, en2)
133
+ f.set_charge(ch1, st1, en1, ch2, st2, en2, enable)
134
134
  f.set_period(start, end, mode, min_soc, max_soc, fdsoc, fdpwr, price, segment)
135
135
  f.charge_periods(st0, en0, st1, en1, st2, en2, min_soc, target_soc, start_soc)
136
136
  f.set_schedule(periods, enable)
@@ -147,6 +147,7 @@ set_charge() takes the charge times from the battery_settings and applies these
147
147
  + ch2: enable charge from grid for period 2 (True or False)
148
148
  + st2: the start time for period 2
149
149
  + en2: the end time for period 2
150
+ + enable: set to 0 to show settings but stop inverter settings being updated. Default is 1.
150
151
 
151
152
  set_period() returns a period structure that can be used to build a list for set_schedule()
152
153
  + start, end, mode: required parameters. end time is exclusive e.g. end at '07:00' will set a period end time of '06:59'
@@ -783,6 +784,14 @@ This setting can be:
783
784
 
784
785
  # Version Info
785
786
 
787
+ 2.5.8<br>
788
+ Fix incorrect charging setup when force_charge=1.
789
+ Rework charge_periods() to consolidate charge periods to reduce number of time segments when timed_mode=2.
790
+ Add 'enable' parameter to set_charge().
791
+ Change 'force' to 'hold' in preset tariffs.
792
+ Stop plunge slots being used when timed_mode is 0 or 1.
793
+ Change default plunge_price to [3,3] and plunge_slots to 6.
794
+
786
795
  2.5.7<br>
787
796
  Fix problem with schedules being set for plunge periods that are more than 24 hours in the future.
788
797
  Add date to plunge period display.
@@ -0,0 +1,7 @@
1
+ foxesscloud/foxesscloud.py,sha256=9WivKa8ysTZ52aTgPmTxJYDAnAz5TobibgvpJkrK_KM,211855
2
+ foxesscloud/openapi.py,sha256=SCr8VOz1aP0Nk5vlUi3-kJ-AMNpW0STlVWQRbbABjBE,205502
3
+ foxesscloud-2.5.8.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
+ foxesscloud-2.5.8.dist-info/METADATA,sha256=Aon8oxksMZP4Z9NgWKOwsz7Dpo8raj8A2cIjakNL_xM,56304
5
+ foxesscloud-2.5.8.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
+ foxesscloud-2.5.8.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
+ foxesscloud-2.5.8.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- foxesscloud/foxesscloud.py,sha256=KgIasVkQ0jtUemF4jRczfHg3AMTGX3Z3seNN6YOKQTE,211201
2
- foxesscloud/openapi.py,sha256=OXJ0_YFX60yes_0L7GCnyIdGfZ7eNptwnsi5NUkMEr4,204841
3
- foxesscloud-2.5.7.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
- foxesscloud-2.5.7.dist-info/METADATA,sha256=9gTkXkNX3iKhASjDewdprOANQO2Ac9diiE7TUhpnbuw,55827
5
- foxesscloud-2.5.7.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
- foxesscloud-2.5.7.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
- foxesscloud-2.5.7.dist-info/RECORD,,