foxesscloud 2.5.6__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: 27 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.8"
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)[:(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):
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,13 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
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
- 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
2225
- strategy.append(s)
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
2203
+ strategy.append(s)
2226
2204
  if strategy is None or len(strategy) == 0:
2227
2205
  return []
2228
2206
  updated = []
@@ -2270,8 +2248,8 @@ tariff_config = {
2270
2248
  'region': "H", # region code to use for Octopus API
2271
2249
  'update_time': 16.5, # time in hours when tomrow's data can be fetched
2272
2250
  'weighting': None, # weights for weighted average
2273
- 'plunge_price': [3, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2274
- '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
2275
2253
  'data_wrap': 6, # prices to show per line
2276
2254
  'show_data': 1, # show pricing data
2277
2255
  'show_plot': 1 # plot pricing data
@@ -2344,7 +2322,8 @@ def get_agile_times(tariff=agile_octopus, d=None):
2344
2322
  output(f"\nPlunge slots:", 1)
2345
2323
  for t in plunge:
2346
2324
  strategy.append(prices[t])
2347
- output(f" {format_period(prices[t])} at {prices[t]['price']:.1f}p", 1)
2325
+ date = (now + timedelta(hours = prices[t]['hour'])).strftime("%Y-%m-%d")
2326
+ output(f" {format_period(prices[t])} at {prices[t]['price']:.1f}p on {date}", 1)
2348
2327
  tariff['agile']['strategy'] = strategy
2349
2328
  for key in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
2350
2329
  if tariff.get(key) is None:
@@ -2361,7 +2340,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
2361
2340
  col = (now.hour * 2) % data_wrap
2362
2341
  s = f"\nPrice p/kWh inc VAT on {today}:"
2363
2342
  for i in range(0, len(prices)):
2364
- 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 ""
2365
2344
  s += f" {prices[i]['price']:4.1f}"
2366
2345
  col = (col + 1) % data_wrap
2367
2346
  output(s)
@@ -2479,7 +2458,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2479
2458
  use[key]['start'] = time_hours(t[1])
2480
2459
  use[key]['end'] = time_hours(t[2])
2481
2460
  if len(t) > 3:
2482
- use[key]['force'] = t[3]
2461
+ use[key]['hold'] = t[3]
2483
2462
  gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
2484
2463
  output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
2485
2464
  # update dynamic charge times
@@ -2554,10 +2533,11 @@ def timed_list(data, base_hour, run_time):
2554
2533
  result = []
2555
2534
  h = base_hour
2556
2535
  for t in range(0, run_time):
2557
- result.append(data[int(h)])
2536
+ result.append(interpolate(h, data, wrap=1))
2558
2537
  h = round_time(h + 1 / steps_per_hour)
2559
2538
  return result
2560
2539
 
2540
+ # align forecast with base_hour and expand to cover run_time
2561
2541
  def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
2562
2542
  global steps_per_hour
2563
2543
  profile = []
@@ -2586,10 +2566,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2586
2566
  min_soc_now = min_soc
2587
2567
  max_soc_now = max_soc
2588
2568
  current_mode = 'SelfUse' if current_mode is None else current_mode
2589
- strategy = get_strategy() if timed_mode > 0 else None
2569
+ strategy = get_strategy(timed_mode=timed_mode)
2590
2570
  h = base_hour
2591
2571
  for i in range(0, run_time):
2592
- 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}
2593
2574
  if strategy is not None:
2594
2575
  period['mode'] = 'SelfUse'
2595
2576
  for d in strategy:
@@ -2622,6 +2603,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2622
2603
  for i in range(0, len(work_mode_timed)):
2623
2604
  bat_timed.append(kwh_current)
2624
2605
  w = work_mode_timed[i]
2606
+ w['kwh'] = kwh_current
2625
2607
  max_now = w['max_soc'] * capacity / 100
2626
2608
  if kwh_current < max_now and w['charge'] > 0.0:
2627
2609
  kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
@@ -2649,6 +2631,40 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2649
2631
  kwh_min = kwh_current
2650
2632
  return (bat_timed, kwh_min)
2651
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
+
2652
2668
  # Battery open circuit voltage (OCV) from 0% to 100% SoC
2653
2669
  # 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
2654
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]
@@ -2711,7 +2727,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2711
2727
  # show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
2712
2728
  # run_after: 0 over-rides 'forecast_times'. The default is 1.
2713
2729
  # forecast_times: list of hours when forecast can be fetched (UTC)
2714
- # force_charge: 1 = set force charge, 2 = charge for whole period
2730
+ # force_charge: 1 = hold battery, 2 = charge for whole period
2715
2731
 
2716
2732
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2717
2733
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
@@ -2769,10 +2785,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2769
2785
  if tariff is not None and tariff.get(k) is not None:
2770
2786
  start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2771
2787
  end = round_time(time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2772
- force = 0 if tariff[k].get('force') is not None and tariff[k]['force'] == 0 else force_charge
2773
- 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})
2774
2790
  if len(times) == 0:
2775
- 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})
2776
2792
  output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
2777
2793
  time_to_end1 = None
2778
2794
  for t in times:
@@ -2802,7 +2818,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2802
2818
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
2803
2819
  run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
2804
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)]
2805
- force_charge = times[0]['force']
2821
+ bat_hold = times[0]['hold']
2806
2822
  # if we need to do a full charge, full_charge is the date, otherwise None
2807
2823
  full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
2808
2824
  if type(full_charge) is int: # value = day of month
@@ -2812,7 +2828,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2812
2828
  if debug_setting > 2:
2813
2829
  output(f"\ntoday = {today}, tomorrow = {tomorrow}, time_shift = {time_shift}")
2814
2830
  output(f"times = {times}")
2815
- 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}")
2816
2832
  output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
2817
2833
  output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
2818
2834
  output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
@@ -2840,7 +2856,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2840
2856
  model = device.get('deviceType')
2841
2857
  else:
2842
2858
  current_soc = test_soc
2843
- capacity = 14.6
2859
+ capacity = 14.54
2844
2860
  residual = test_soc * capacity / 100
2845
2861
  bat_volt = 315.4
2846
2862
  bat_power = 0.0
@@ -2885,7 +2901,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2885
2901
  output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2886
2902
  charge_current = derated_current
2887
2903
  else:
2888
- force_charge = 2
2904
+ bat_hold = 2
2889
2905
  output(f" Full charge set")
2890
2906
  # inverter losses
2891
2907
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
@@ -2958,16 +2974,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2958
2974
  solcast_value = None
2959
2975
  solcast_profile = None
2960
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):
2961
- 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)
2962
2978
  if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
2963
2979
  solcast_value = fsolcast.daily[forecast_day]['kwh']
2964
2980
  solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
2965
2981
  solcast_from = time_hours(fsolcast.daily[today]['from']) if fsolcast.daily[today].get('from') is not None else 0
2966
- 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
2967
2984
  solar_value = None
2968
2985
  solar_profile = None
2969
2986
  if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
2970
- fsolar = Solar(quiet=True, shading=charge_config.get('shading'))
2987
+ fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
2971
2988
  if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
2972
2989
  solar_value = fsolar.daily[forecast_day]['kwh']
2973
2990
  solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
@@ -3005,6 +3022,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3005
3022
  if forecast is not None:
3006
3023
  expected = forecast
3007
3024
  generation_timed = [expected * x / sun_sum for x in sun_timed]
3025
+ output(f"\nForecast: {forecast:.1f}kWh")
3008
3026
  elif solcast_value is not None:
3009
3027
  expected = solcast_value
3010
3028
  generation_timed = solcast_timed
@@ -3037,8 +3055,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3037
3055
  fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
3038
3056
  fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
3039
3057
  discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
3040
- 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):
3041
3059
  discharge_timed[i] = bms_loss
3060
+ if timed_mode > 1:
3061
+ work_mode_timed[i]['hold'] = 1
3042
3062
  elif timed_mode > 0 and work_mode == 'Backup':
3043
3063
  discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
3044
3064
  elif timed_mode > 0 and work_mode == 'Feedin':
@@ -3061,7 +3081,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3061
3081
  start_soc = int(start_residual / capacity * 100 + 0.5)
3062
3082
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
3063
3083
  target_soc = charge_config.get('target_soc')
3064
- 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
3065
3085
  if target_kwh > (end_residual + kwh_needed):
3066
3086
  kwh_needed = target_kwh - end_residual
3067
3087
  elif test_charge is not None:
@@ -3078,13 +3098,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3078
3098
  start_timed = time_to_end
3079
3099
  end_timed = time_to_end
3080
3100
  end_soc = int(end_residual / capacity * 100 + 0.5)
3081
- # update min_soc for battery hold
3082
- if force_charge > 0 and timed_mode > 1:
3083
- for t in range(int(time_to_start), int(time_to_end)):
3084
- work_mode_timed[t]['min_soc'] = start_soc
3085
3101
  else:
3086
3102
  if test_charge is None:
3087
- output(f"\nCharge needed {kwh_needed:.2f}kWh:")
3103
+ output(f"\nCharge needed: {kwh_needed:.2f}kWh")
3088
3104
  charge_message = "with charge added"
3089
3105
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
3090
3106
  # work out time to add kwh_needed to battery
@@ -3098,20 +3114,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3098
3114
  kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
3099
3115
  required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # time to recover energy not added
3100
3116
  hours = required if required > hours and required < charge_time else charge_time
3101
- # round charge time
3117
+ # round charge time and work out what will actually be added
3102
3118
  min_hours = charge_config['min_hours']
3103
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)
3104
3121
  # rework charge and discharge
3105
3122
  charge_period = get_best_charge_period(start_at, hours)
3106
3123
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
3107
3124
  price = charge_period.get('price') if charge_period is not None else None
3108
3125
  start_timed = time_to_start + charge_offset * steps_per_hour
3109
- 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
3110
3127
  start_residual = interpolate(start_timed, bat_timed)
3111
- 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])
3112
3129
  output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
3113
- 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 ""))
3114
- 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)):
3115
3133
  j = i + 1
3116
3134
  # work out time (fraction of hour) when charging in hour from i to j
3117
3135
  if start_timed >= i and end_timed < j:
@@ -3125,12 +3143,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3125
3143
  else:
3126
3144
  t = 0.0 # complete hour before start or after end
3127
3145
  output(f"i = {i}, j = {j}, t = {t}", 3)
3128
- if i >= start_timed:
3146
+ if i >= start_timed and i < end_timed:
3147
+ work_mode_timed[i]['mode'] = 'ForceCharge'
3129
3148
  work_mode_timed[i]['charge'] = charge_power * t
3130
- 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
3131
3150
  work_mode_timed[i]['discharge'] *= (1-t)
3132
- elif force_charge > 0 and timed_mode > 1:
3133
- work_mode_timed[i]['min_soc'] = start_soc
3134
3151
  # rebuild the battery residual with the charge added and min_soc
3135
3152
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3136
3153
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
@@ -3139,6 +3156,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3139
3156
  output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
3140
3157
  if not charge_today:
3141
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")
3142
3173
  if show_data > 0:
3143
3174
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3144
3175
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
@@ -3146,7 +3177,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3146
3177
  t = 0
3147
3178
  while t < len(time_line) and bat_timed[t] is not None:
3148
3179
  col = h % data_wrap
3149
- 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 ""
3150
3181
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3151
3182
  h += 1
3152
3183
  t += steps_per_hour
@@ -3194,20 +3225,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3194
3225
  file = open(storage + file_name, 'w')
3195
3226
  json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
3196
3227
  file.close()
3197
- # setup charging
3198
- if update_settings == 1:
3199
- # work out the charge times and set. First period is battery hold, second period is battery charge / hold
3200
- start1 = round_time(base_hour + time_to_start / steps_per_hour)
3201
- start2 = round_time(base_hour + start_timed / steps_per_hour)
3202
- end1 = start1 if force_charge == 0 else start2
3203
- end2 = round_time(base_hour + (end_timed if force_charge == 0 else time_to_end) / steps_per_hour)
3204
- if timed_mode > 1:
3205
- periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, end_soc=end_soc, start_soc=start_soc)
3206
- set_schedule(periods = periods)
3207
- else:
3208
- set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1)
3209
- else:
3210
- output(f"\nNo changes made to charge settings")
3211
3228
  output_close(plot=show_plot)
3212
3229
  return None
3213
3230
 
@@ -3283,7 +3300,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3283
3300
  t = 0
3284
3301
  while t < len(time_line) and bat_timed[t] is not None:
3285
3302
  col = h % data_wrap
3286
- 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 ""
3287
3304
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3288
3305
  h += 1
3289
3306
  t += steps_per_hour
@@ -3831,18 +3848,19 @@ class Solcast :
3831
3848
  Load Solcast Estimate / Actuals / Forecast daily yield
3832
3849
  """
3833
3850
 
3834
- 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) :
3835
3852
  # days sets the number of days to get for forecasts (and estimated if enabled)
3836
3853
  # reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
3837
3854
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3838
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
3839
3856
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3840
3857
  self.data = {}
3858
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3841
3859
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3842
- self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
3860
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
3843
3861
  self.quarter = int(self.today[5:7]) // 3 % 4
3844
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
3845
- 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')
3846
3864
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
3847
3865
  if reload == 1 and os.path.exists(storage + self.save):
3848
3866
  os.remove(storage + self.save)
@@ -4178,13 +4196,14 @@ class Solar :
4178
4196
  """
4179
4197
 
4180
4198
  # get solar forecast and return total expected yield
4181
- def __init__(self, reload=0, quiet=False, shading=None):
4199
+ def __init__(self, reload=0, quiet=False, shading=None, d=None):
4182
4200
  global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
4183
4201
  self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
4184
- 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')
4185
4204
  self.quarter = int(self.today[5:7]) // 3 % 4
4186
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
4187
- 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')
4188
4207
  self.arrays = None
4189
4208
  self.results = None
4190
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: 27 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.6"
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)[:(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):
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,13 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
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
- 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
2089
- strategy.append(s)
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
2066
+ strategy.append(s)
2090
2067
  if strategy is None or len(strategy) == 0:
2091
2068
  return []
2092
2069
  updated = []
@@ -2134,8 +2111,8 @@ tariff_config = {
2134
2111
  'region': "H", # region code to use for Octopus API
2135
2112
  'update_time': 16.5, # time in hours when tomrow's data can be fetched
2136
2113
  'weighting': None, # weights for weighted average
2137
- 'plunge_price': [3, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2138
- '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
2139
2116
  'data_wrap': 6, # prices to show per line
2140
2117
  'show_data': 1, # show pricing data
2141
2118
  'show_plot': 1 # plot pricing data
@@ -2208,7 +2185,8 @@ def get_agile_times(tariff=agile_octopus, d=None):
2208
2185
  output(f"\nPlunge slots:", 1)
2209
2186
  for t in plunge:
2210
2187
  strategy.append(prices[t])
2211
- output(f" {format_period(prices[t])} at {prices[t]['price']:.1f}p", 1)
2188
+ date = (now + timedelta(hours = prices[t]['hour'])).strftime("%Y-%m-%d")
2189
+ output(f" {format_period(prices[t])} at {prices[t]['price']:.1f}p on {date}", 1)
2212
2190
  tariff['agile']['strategy'] = strategy
2213
2191
  for key in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
2214
2192
  if tariff.get(key) is None:
@@ -2225,7 +2203,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
2225
2203
  col = (now.hour * 2) % data_wrap
2226
2204
  s = f"\nPrice p/kWh inc VAT on {today}:"
2227
2205
  for i in range(0, len(prices)):
2228
- 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 ""
2229
2207
  s += f" {prices[i]['price']:4.1f}"
2230
2208
  col = (col + 1) % data_wrap
2231
2209
  output(s)
@@ -2343,7 +2321,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2343
2321
  use[key]['start'] = time_hours(t[1])
2344
2322
  use[key]['end'] = time_hours(t[2])
2345
2323
  if len(t) > 3:
2346
- use[key]['force'] = t[3]
2324
+ use[key]['hold'] = t[3]
2347
2325
  gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
2348
2326
  output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
2349
2327
  # update dynamic charge times
@@ -2418,10 +2396,11 @@ def timed_list(data, base_hour, run_time):
2418
2396
  result = []
2419
2397
  h = base_hour
2420
2398
  for t in range(0, run_time):
2421
- result.append(data[int(h)])
2399
+ result.append(interpolate(h, data, wrap=1))
2422
2400
  h = round_time(h + 1 / steps_per_hour)
2423
2401
  return result
2424
2402
 
2403
+ # align forecast with base_hour and expand to cover run_time
2425
2404
  def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
2426
2405
  global steps_per_hour
2427
2406
  profile = []
@@ -2450,10 +2429,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2450
2429
  min_soc_now = min_soc
2451
2430
  max_soc_now = max_soc
2452
2431
  current_mode = 'SelfUse' if current_mode is None else current_mode
2453
- strategy = get_strategy() if timed_mode > 0 else None
2432
+ strategy = get_strategy(timed_mode=timed_mode)
2454
2433
  h = base_hour
2455
2434
  for i in range(0, run_time):
2456
- 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}
2457
2437
  if strategy is not None:
2458
2438
  period['mode'] = 'SelfUse'
2459
2439
  for d in strategy:
@@ -2486,6 +2466,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2486
2466
  for i in range(0, len(work_mode_timed)):
2487
2467
  bat_timed.append(kwh_current)
2488
2468
  w = work_mode_timed[i]
2469
+ w['kwh'] = kwh_current
2489
2470
  max_now = w['max_soc'] * capacity / 100
2490
2471
  if kwh_current < max_now and w['charge'] > 0.0:
2491
2472
  kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
@@ -2513,6 +2494,40 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2513
2494
  kwh_min = kwh_current
2514
2495
  return (bat_timed, kwh_min)
2515
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
+
2516
2531
  # Battery open circuit voltage (OCV) from 0% to 100% SoC
2517
2532
  # 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
2518
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]
@@ -2575,7 +2590,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2575
2590
  # show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
2576
2591
  # run_after: 0 over-rides 'forecast_times'. The default is 1.
2577
2592
  # forecast_times: list of hours when forecast can be fetched (UTC)
2578
- # force_charge: 1 = set force charge, 2 = charge for whole period
2593
+ # force_charge: 1 = hold battery, 2 = charge for whole period
2579
2594
 
2580
2595
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2581
2596
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
@@ -2633,10 +2648,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2633
2648
  if tariff is not None and tariff.get(k) is not None:
2634
2649
  start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2635
2650
  end = round_time(time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2636
- force = 0 if tariff[k].get('force') is not None and tariff[k]['force'] == 0 else force_charge
2637
- 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})
2638
2653
  if len(times) == 0:
2639
- 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})
2640
2655
  output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
2641
2656
  time_to_end1 = None
2642
2657
  for t in times:
@@ -2666,7 +2681,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2666
2681
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
2667
2682
  run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
2668
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)]
2669
- force_charge = times[0]['force']
2684
+ bat_hold = times[0]['hold']
2670
2685
  # if we need to do a full charge, full_charge is the date, otherwise None
2671
2686
  full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
2672
2687
  if type(full_charge) is int: # value = day of month
@@ -2704,7 +2719,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2704
2719
  model = device.get('deviceType')
2705
2720
  else:
2706
2721
  current_soc = test_soc
2707
- capacity = 14.6
2722
+ capacity = 14.54
2708
2723
  residual = test_soc * capacity / 100
2709
2724
  bat_volt = 315.4
2710
2725
  bat_power = 0.0
@@ -2749,7 +2764,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2749
2764
  output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2750
2765
  charge_current = derated_current
2751
2766
  else:
2752
- force_charge = 2
2767
+ bat_hold = 2
2753
2768
  output(f" Full charge set")
2754
2769
  # inverter losses
2755
2770
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
@@ -2822,7 +2837,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2822
2837
  solcast_value = None
2823
2838
  solcast_profile = None
2824
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):
2825
- 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)
2826
2841
  if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
2827
2842
  solcast_value = fsolcast.daily[forecast_day]['kwh']
2828
2843
  solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
@@ -2831,7 +2846,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2831
2846
  solar_value = None
2832
2847
  solar_profile = None
2833
2848
  if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
2834
- fsolar = Solar(quiet=True, shading=charge_config.get('shading'))
2849
+ fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
2835
2850
  if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
2836
2851
  solar_value = fsolar.daily[forecast_day]['kwh']
2837
2852
  solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
@@ -2869,6 +2884,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2869
2884
  if forecast is not None:
2870
2885
  expected = forecast
2871
2886
  generation_timed = [expected * x / sun_sum for x in sun_timed]
2887
+ output(f"\nForecast: {forecast:.1f}kWh")
2872
2888
  elif solcast_value is not None:
2873
2889
  expected = solcast_value
2874
2890
  generation_timed = solcast_timed
@@ -2901,8 +2917,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2901
2917
  fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
2902
2918
  fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
2903
2919
  discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
2904
- 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):
2905
2921
  discharge_timed[i] = bms_loss
2922
+ if timed_mode > 1:
2923
+ work_mode_timed[i]['hold'] = 1
2906
2924
  elif timed_mode > 0 and work_mode == 'Backup':
2907
2925
  discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
2908
2926
  elif timed_mode > 0 and work_mode == 'Feedin':
@@ -2925,7 +2943,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2925
2943
  start_soc = int(start_residual / capacity * 100 + 0.5)
2926
2944
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
2927
2945
  target_soc = charge_config.get('target_soc')
2928
- 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
2929
2947
  if target_kwh > (end_residual + kwh_needed):
2930
2948
  kwh_needed = target_kwh - end_residual
2931
2949
  elif test_charge is not None:
@@ -2942,13 +2960,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2942
2960
  start_timed = time_to_end
2943
2961
  end_timed = time_to_end
2944
2962
  end_soc = int(end_residual / capacity * 100 + 0.5)
2945
- # update min_soc for battery hold
2946
- if force_charge > 0 and timed_mode > 1:
2947
- for t in range(int(time_to_start), int(time_to_end)):
2948
- work_mode_timed[t]['min_soc'] = start_soc
2949
2963
  else:
2950
2964
  if test_charge is None:
2951
- output(f"\nCharge needed {kwh_needed:.2f}kWh:")
2965
+ output(f"\nCharge needed: {kwh_needed:.2f}kWh:")
2952
2966
  charge_message = "with charge added"
2953
2967
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
2954
2968
  # work out time to add kwh_needed to battery
@@ -2962,20 +2976,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2962
2976
  kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
2963
2977
  required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # hold time to recover energy not added
2964
2978
  hours = required if required > hours and required < charge_time else charge_time
2965
- # round charge time
2979
+ # round charge time and work out what will actually be added
2966
2980
  min_hours = charge_config['min_hours']
2967
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)
2968
2983
  # rework charge and discharge
2969
2984
  charge_period = get_best_charge_period(start_at, hours)
2970
2985
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
2971
2986
  price = charge_period.get('price') if charge_period is not None else None
2972
2987
  start_timed = time_to_start + charge_offset * steps_per_hour
2973
- 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
2974
2989
  start_residual = interpolate(start_timed, bat_timed)
2975
- 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])
2976
2991
  output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
2977
- 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 ""))
2978
- 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)):
2979
2995
  j = i + 1
2980
2996
  # work out time (fraction of hour) when charging in hour from i to j
2981
2997
  if start_timed >= i and end_timed < j:
@@ -2989,12 +3005,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2989
3005
  else:
2990
3006
  t = 0.0 # complete hour before start or after end
2991
3007
  output(f"i = {i}, j = {j}, t = {t}", 3)
2992
- if i >= start_timed:
3008
+ if i >= start_timed and i < end_timed:
3009
+ work_mode_timed[i]['mode'] = 'ForceCharge'
2993
3010
  work_mode_timed[i]['charge'] = charge_power * t
2994
- 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
2995
3012
  work_mode_timed[i]['discharge'] *= (1-t)
2996
- elif force_charge > 0 and timed_mode > 1:
2997
- work_mode_timed[i]['min_soc'] = start_soc
2998
3013
  # rebuild the battery residual with any charge added and min_soc
2999
3014
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3000
3015
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
@@ -3003,6 +3018,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3003
3018
  output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
3004
3019
  if not charge_today:
3005
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")
3006
3035
  if show_data > 0:
3007
3036
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3008
3037
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
@@ -3010,7 +3039,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3010
3039
  t = 0
3011
3040
  while t < len(time_line) and bat_timed[t] is not None:
3012
3041
  col = h % data_wrap
3013
- 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 ""
3014
3043
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3015
3044
  h += 1
3016
3045
  t += steps_per_hour
@@ -3058,20 +3087,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3058
3087
  file = open(storage + file_name, 'w')
3059
3088
  json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
3060
3089
  file.close()
3061
- # setup charging
3062
- if update_settings == 1:
3063
- # work out the charge times and set. First period is battery hold, second period is battery charge / hold
3064
- start1 = round_time(base_hour + time_to_start / steps_per_hour)
3065
- start2 = round_time(base_hour + start_timed / steps_per_hour)
3066
- end1 = start1 if force_charge == 0 else start2
3067
- end2 = round_time(base_hour + (end_timed if force_charge == 0 else time_to_end) / steps_per_hour)
3068
- if timed_mode > 1:
3069
- periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, end_soc=end_soc, start_soc=start_soc)
3070
- set_schedule(periods = periods)
3071
- else:
3072
- set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1)
3073
- else:
3074
- output(f"\nNo changes made to charge settings")
3075
3090
  output_close(plot=show_plot)
3076
3091
  return None
3077
3092
 
@@ -3146,7 +3161,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3146
3161
  t = 0
3147
3162
  while t < len(time_line) and bat_timed[t] is not None:
3148
3163
  col = h % data_wrap
3149
- 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 ""
3150
3165
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3151
3166
  h += 1
3152
3167
  t += steps_per_hour
@@ -3693,18 +3708,19 @@ class Solcast :
3693
3708
  Load Solcast Estimate / Actuals / Forecast daily yield
3694
3709
  """
3695
3710
 
3696
- 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) :
3697
3712
  # days sets the number of days to get for forecasts (and estimated if enabled)
3698
3713
  # reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
3699
3714
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3700
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
3701
3716
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3702
3717
  self.data = {}
3718
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3703
3719
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3704
- self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
3720
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
3705
3721
  self.quarter = int(self.today[5:7]) // 3 % 4
3706
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
3707
- 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')
3708
3724
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
3709
3725
  if reload == 1 and os.path.exists(storage + self.save):
3710
3726
  os.remove(storage + self.save)
@@ -4040,13 +4056,14 @@ class Solar :
4040
4056
  """
4041
4057
 
4042
4058
  # get solar forecast and return total expected yield
4043
- def __init__(self, reload=0, quiet=False, shading=None):
4059
+ def __init__(self, reload=0, quiet=False, shading=None, d=None):
4044
4060
  global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
4045
4061
  self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
4046
- 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')
4047
4064
  self.quarter = int(self.today[5:7]) // 3 % 4
4048
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
4049
- 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')
4050
4067
  self.arrays = None
4051
4068
  self.results = None
4052
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.6
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,18 @@ 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
+
795
+ 2.5.7<br>
796
+ Fix problem with schedules being set for plunge periods that are more than 24 hours in the future.
797
+ Add date to plunge period display.
798
+
786
799
  2.5.6<br>
787
800
  Change plunge slots to 8 and plungs pricing to [3,10].
788
801
  Change min_hours setting in charge_needed to 0.5 (30 minutes) and round up charge times to increments of this.
@@ -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=pvRR5Wjbb0EIfXT59wtG5kGgGANxV-USjOZoPDzwaL4,210980
2
- foxesscloud/openapi.py,sha256=vj0hP-8IsD30IK5ylCqp9BahvhtgYOIbkgrccbjcaLE,204620
3
- foxesscloud-2.5.6.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
- foxesscloud-2.5.6.dist-info/METADATA,sha256=o3Vxj9OGsJayuifK48DkVlv2W2l4Oht8-KIuzrIB6DU,55678
5
- foxesscloud-2.5.6.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
- foxesscloud-2.5.6.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
- foxesscloud-2.5.6.dist-info/RECORD,,