foxesscloud 2.5.7__py3-none-any.whl → 2.5.9__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: 02 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.1"
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
@@ -575,7 +576,9 @@ def get_firmware():
575
576
 
576
577
  battery = None
577
578
  battery_settings = None
578
- residual_handling = 1 # set to 2 if Residual returns current capacity
579
+
580
+ # 1 = returns Residual Energy. 2 = resturns Residual Capacity
581
+ residual_handling = 1
579
582
 
580
583
  def get_battery(info=0):
581
584
  global token, device_id, battery, debug_setting, messages
@@ -648,12 +651,12 @@ def get_charge():
648
651
 
649
652
  # helper to format time period structure
650
653
  def time_period(t):
651
- result = f"{t['startTime']['hour']:02d}:{t['startTime']['minute']:02d} - {t['endTime']['hour']:02d}:{t['endTime']['minute']:02d}"
654
+ result = f"{t['startTime']['hour']:02d}:{t['startTime']['minute']:02d}-{t['endTime']['hour']:02d}:{t['endTime']['minute']:02d}"
652
655
  if t['startTime']['hour'] != t['endTime']['hour'] or t['startTime']['minute'] != t['endTime']['minute']:
653
656
  result += f" Charge from grid" if t['enableGrid'] else f" Force Charge"
654
657
  return result
655
658
 
656
- def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force=0):
659
+ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force=0, enable=1):
657
660
  global token, device_sn, battery_settings, debug_setting, messages, schedule
658
661
  if get_device() is None:
659
662
  return None
@@ -703,6 +706,8 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
703
706
  output(f"\nSetting time periods:", 1)
704
707
  output(f" Time Period 1 = {time_period(battery_settings['times'][0])}", 1)
705
708
  output(f" Time Period 2 = {time_period(battery_settings['times'][1])}", 1)
709
+ if enable == 0:
710
+ return battery_settings
706
711
  # set charge times
707
712
  data = {'sn': device_sn, 'times': battery_settings.get('times')}
708
713
  setting_delay()
@@ -721,35 +726,6 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
721
726
  output(f"success", 2)
722
727
  return battery_settings
723
728
 
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
729
  ##################################################################################################
754
730
  # get min soc settings and save in battery_settings
755
731
  ##################################################################################################
@@ -1270,7 +1246,7 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1270
1246
  return None
1271
1247
  if quiet == 0:
1272
1248
  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 ""
1249
+ s += f", maxsoc {max_soc}%" if max_soc is not None and mode == 'ForceCharge' else ""
1274
1250
  s += f", fdPwr {fdpwr}W, fdSoC {fdsoc}%" if mode == 'ForceDischarge' else ""
1275
1251
  s += f", {price:.2f}p/kWh" if price is not None else ""
1276
1252
  output(s, 1)
@@ -2122,7 +2098,7 @@ def hours_difference(t1, t2):
2122
2098
  # time periods for Octopus Flux
2123
2099
  octopus_flux = {
2124
2100
  'name': 'Octopus Flux',
2125
- 'off_peak1': {'start': 2.0, 'end': 5.0, 'force': 1}, # off-peak period 1 / am charging period
2101
+ 'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1}, # off-peak period 1 / am charging period
2126
2102
  'peak1': {'start': 16.0, 'end': 19.0 }, # peak period 1
2127
2103
  'forecast_times': [21, 22], # hours in a day to get a forecast
2128
2104
  'strategy': [
@@ -2133,16 +2109,16 @@ octopus_flux = {
2133
2109
  # time periods for Intelligent Octopus
2134
2110
  intelligent_octopus = {
2135
2111
  'name': 'Intelligent Octopus',
2136
- 'off_peak1': {'start': 23.5, 'end': 5.5, 'force': 1},
2112
+ 'off_peak1': {'start': 23.5, 'end': 5.5, 'hold': 1},
2137
2113
  'forecast_times': [21, 22]
2138
2114
  }
2139
2115
 
2140
2116
  # time periods for Octopus Cosy
2141
2117
  octopus_cosy = {
2142
2118
  '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},
2119
+ 'off_peak1': {'start': 4.0, 'end': 7.0, 'hold': 1},
2120
+ 'off_peak2': {'start': 13.0, 'end': 16.0, 'hold': 0},
2121
+ 'off_peak3': {'start': 22.0, 'end': 24.0, 'hold': 0},
2146
2122
  'peak1': {'start': 16.0, 'end': 19.0 },
2147
2123
  'forecast_times': [10, 11, 21, 22]
2148
2124
  }
@@ -2150,15 +2126,15 @@ octopus_cosy = {
2150
2126
  # time periods for Octopus Go
2151
2127
  octopus_go = {
2152
2128
  'name': 'Octopus Go',
2153
- 'off_peak1': {'start': 0.5, 'end': 4.5, 'force': 1},
2129
+ 'off_peak1': {'start': 0.5, 'end': 4.5, 'hold': 1},
2154
2130
  'forecast_times': [21, 22]
2155
2131
  }
2156
2132
 
2157
2133
  # time periods for Agile Octopus
2158
2134
  agile_octopus = {
2159
2135
  '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},
2136
+ 'off_peak1': {'start': 0.0, 'end': 6.0, 'hold': 1},
2137
+ 'off_peak2': {'start': 12.0, 'end': 16.0, 'hold': 0},
2162
2138
  'peak1': {'start': 16.0, 'end': 19.0 },
2163
2139
  'forecast_times': [9, 10, 21, 22],
2164
2140
  'strategy': [],
@@ -2168,27 +2144,27 @@ agile_octopus = {
2168
2144
  # time periods for British Gas Electric Driver
2169
2145
  bg_driver = {
2170
2146
  'name': 'British Gas Electric Driver',
2171
- 'off_peak1': {'start': 0.0, 'end': 5.0, 'force': 1},
2147
+ 'off_peak1': {'start': 0.0, 'end': 5.0, 'hold': 1},
2172
2148
  'forecast_times': [21, 22]
2173
2149
  }
2174
2150
 
2175
2151
  # time periods for EON Next Drive
2176
2152
  eon_drive = {
2177
2153
  'name': 'EON NextDrive',
2178
- 'off_peak1': {'start': 0.0, 'end': 7.0, 'force': 1},
2154
+ 'off_peak1': {'start': 0.0, 'end': 7.0, 'hold': 1},
2179
2155
  'forecast_times': [21, 22]
2180
2156
  }
2181
2157
 
2182
2158
  # time periods for Economy 7
2183
2159
  economy_7 = {
2184
2160
  'name': 'Eco 7',
2185
- 'off_peak1': {'start': 0.5, 'end': 7.5, 'force': 1, 'gmt': 1},
2161
+ 'off_peak1': {'start': 0.5, 'end': 7.5, 'hold': 1, 'gmt': 1},
2186
2162
  'forecast_times': [21, 22]
2187
2163
  }
2188
2164
 
2189
2165
  # custom time periods / template
2190
2166
  custom_periods = {'name': 'Custom',
2191
- 'off_peak1': {'start': 2.0, 'end': 5.0, 'force': 1},
2167
+ 'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1},
2192
2168
  'peak1': {'start': 16.0, 'end': 19.0 },
2193
2169
  'forecast_times': [21, 22]
2194
2170
  }
@@ -2208,8 +2184,10 @@ test_strategy = [
2208
2184
  {'start': 21, 'end': 22, 'mode': 'ForceCharge'}]
2209
2185
 
2210
2186
  # 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):
2187
+ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=24, timed_mode=1):
2212
2188
  global tariff, base_time
2189
+ if timed_mode == 0:
2190
+ return []
2213
2191
  if use is None:
2214
2192
  use = tariff
2215
2193
  base_time_adjust = 0
@@ -2218,11 +2196,12 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit
2218
2196
  if tariff.get('strategy') is not None:
2219
2197
  for s in tariff['strategy']:
2220
2198
  strategy.append(s)
2221
- if use.get('agile') is not None and use['agile'].get('strategy') is not None:
2199
+ if timed_mode > 1 and use.get('agile') is not None and use['agile'].get('strategy') is not None:
2222
2200
  base_time_adjust = hours_difference(base_time, use['agile'].get('base_time') )
2223
2201
  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
2202
+ hour = (s['hour'] - base_time_adjust) if limit is not None and s.get('hour') is not None else None
2203
+ if hour is None or (hour >= 0 and hour < limit):
2204
+ 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
2205
  strategy.append(s)
2227
2206
  if strategy is None or len(strategy) == 0:
2228
2207
  return []
@@ -2271,8 +2250,8 @@ tariff_config = {
2271
2250
  'region': "H", # region code to use for Octopus API
2272
2251
  'update_time': 16.5, # time in hours when tomrow's data can be fetched
2273
2252
  '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
2253
+ 'plunge_price': [3, 3], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2254
+ 'plunge_slots': 6, # number of 30 minute slots to use
2276
2255
  'data_wrap': 6, # prices to show per line
2277
2256
  'show_data': 1, # show pricing data
2278
2257
  'show_plot': 1 # plot pricing data
@@ -2363,7 +2342,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
2363
2342
  col = (now.hour * 2) % data_wrap
2364
2343
  s = f"\nPrice p/kWh inc VAT on {today}:"
2365
2344
  for i in range(0, len(prices)):
2366
- s += (f"\n {prices[i]['time']} " + " " * col * 6) if i == 0 or col == 0 else ""
2345
+ s += f"\n {prices[i]['time']}" if i == 0 or col == 0 else ""
2367
2346
  s += f" {prices[i]['price']:4.1f}"
2368
2347
  col = (col + 1) % data_wrap
2369
2348
  output(s)
@@ -2481,7 +2460,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2481
2460
  use[key]['start'] = time_hours(t[1])
2482
2461
  use[key]['end'] = time_hours(t[2])
2483
2462
  if len(t) > 3:
2484
- use[key]['force'] = t[3]
2463
+ use[key]['hold'] = t[3]
2485
2464
  gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
2486
2465
  output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
2487
2466
  # update dynamic charge times
@@ -2556,10 +2535,11 @@ def timed_list(data, base_hour, run_time):
2556
2535
  result = []
2557
2536
  h = base_hour
2558
2537
  for t in range(0, run_time):
2559
- result.append(data[int(h)])
2538
+ result.append(interpolate(h, data, wrap=1))
2560
2539
  h = round_time(h + 1 / steps_per_hour)
2561
2540
  return result
2562
2541
 
2542
+ # align forecast with base_hour and expand to cover run_time
2563
2543
  def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
2564
2544
  global steps_per_hour
2565
2545
  profile = []
@@ -2588,10 +2568,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2588
2568
  min_soc_now = min_soc
2589
2569
  max_soc_now = max_soc
2590
2570
  current_mode = 'SelfUse' if current_mode is None else current_mode
2591
- strategy = get_strategy() if timed_mode > 0 else None
2571
+ strategy = get_strategy(timed_mode=timed_mode)
2592
2572
  h = base_hour
2593
2573
  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}
2574
+ 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,
2575
+ 'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2595
2576
  if strategy is not None:
2596
2577
  period['mode'] = 'SelfUse'
2597
2578
  for d in strategy:
@@ -2613,22 +2594,23 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2613
2594
  return work_mode_timed
2614
2595
 
2615
2596
  # build the timed battery residual from the charge / discharge, work mode and min_soc
2597
+ # all power values are as measured at the inverter battery connection
2616
2598
  def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=None, reserve_drain=None):
2617
- global charge_config, steps_per_hour
2618
- bat_timed = []
2599
+ global charge_config, steps_per_hour, residual_handling
2619
2600
  allowed_drain = charge_config['allowed_drain'] if charge_config.get('allowed_drain') is not None else 4
2620
2601
  bms_loss = (charge_config['bms_power'] / 1000 if charge_config.get('bms_power') is not None else 0.05)
2621
- charge_loss = charge_config['charge_loss']
2602
+ charge_loss = charge_config['charge_loss'][residual_handling - 1]
2603
+ discharge_loss = charge_config['discharge_loss'][residual_handling - 1]
2622
2604
  charge_limit = charge_config['charge_limit']
2623
2605
  float_charge = charge_config['float_charge']
2624
2606
  for i in range(0, len(work_mode_timed)):
2625
- bat_timed.append(kwh_current)
2626
2607
  w = work_mode_timed[i]
2608
+ w['kwh'] = kwh_current
2627
2609
  max_now = w['max_soc'] * capacity / 100
2628
2610
  if kwh_current < max_now and w['charge'] > 0.0:
2629
2611
  kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
2630
2612
  kwh_current = max_now if kwh_current > max_now else kwh_current
2631
- kwh_current += (w['pv'] - w['discharge']) / charge_loss / steps_per_hour
2613
+ kwh_current += (w['pv'] * charge_loss - w['discharge'] / discharge_loss) / steps_per_hour
2632
2614
  if kwh_current > capacity:
2633
2615
  # battery is full
2634
2616
  kwh_current = capacity
@@ -2649,7 +2631,45 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2649
2631
  reserve_drain = reserve_now
2650
2632
  if kwh_min is not None and kwh_current < kwh_min and i >= time_to_next: # track minimum without charge
2651
2633
  kwh_min = kwh_current
2652
- return (bat_timed, kwh_min)
2634
+ return ([work_mode_timed[i]['kwh'] for i in range(0, len(work_mode_timed))], kwh_min)
2635
+
2636
+ # use work_mode_timed to generate time periods for the inverter schedule
2637
+ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2638
+ global steps_per_hour
2639
+ strategy = []
2640
+ start = base_hour
2641
+ times = []
2642
+ for t in range(0, min([24 * steps_per_hour, len(work_mode_timed)])):
2643
+ period = times[0] if len(times) > 0 else work_mode_timed[0]
2644
+ next_period = work_mode_timed[t]
2645
+ h = base_hour + t / steps_per_hour
2646
+ if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
2647
+ s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
2648
+ if period['mode'] == 'ForceDischarge':
2649
+ s['fdsoc'] = period.get('fdsoc')
2650
+ s['fdpwr'] = period.get('fdpwr')
2651
+ elif period['mode'] == 'ForceCharge':
2652
+ s['max_soc'] = period.get('max_soc')
2653
+ elif period['mode'] == 'SelfUse' and period['hold'] == 1:
2654
+ s['min_soc'] = min([int(period['kwh'] / capacity * 100 + 0.5), 100])
2655
+ s['end'] = (start + 1 / steps_per_hour) % 24
2656
+ for p in times:
2657
+ p['min_soc'] = s['min_soc']
2658
+ if s['mode'] != 'SelfUse' or s['min_soc'] != min_soc:
2659
+ strategy.append(s)
2660
+ start = h
2661
+ times = []
2662
+ times.append(work_mode_timed[t])
2663
+ if len(strategy) == 0:
2664
+ return []
2665
+ if strategy[-1]['min_soc'] != min_soc:
2666
+ strategy.append({'start': start %24, 'end': (start + 1 / steps_per_hour) % 24, 'mode': 'SelfUse', 'min_soc': min_soc})
2667
+ output(f"\nConfiguring schedule:",1)
2668
+ periods = []
2669
+ for s in strategy:
2670
+ periods.append(set_period(segment = s, quiet=0))
2671
+ return periods
2672
+
2653
2673
 
2654
2674
  # Battery open circuit voltage (OCV) from 0% to 100% SoC
2655
2675
  # 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
@@ -2668,9 +2688,11 @@ charge_config = {
2668
2688
  'charge_current': None, # max battery charge current setting in A
2669
2689
  'discharge_current': None, # max battery discharge current setting in A
2670
2690
  'export_limit': None, # maximum export power in kW
2671
- 'discharge_loss': 0.97, # loss converting battery discharge power to grid power
2672
- 'pv_loss': 0.95, # loss converting PV power to battery charge power
2673
- 'grid_loss': 0.975, # loss converting grid power to battery charge power
2691
+ 'dc_ac_loss': 0.970, # loss converting battery DC power to AC grid power
2692
+ 'pv_loss': 0.950, # loss converting PV power to DC battery charge power
2693
+ 'ac_dc_loss': 0.960, # loss converting AC grid power to DC battery charge power
2694
+ 'charge_loss': [0.975, 1.040], # loss in battery energy for each kWh added (based on residual_handling)
2695
+ 'discharge_loss': [0.975, 0.975], # loss in battery energy for each kWh removed (based on residual_handling)
2674
2696
  'inverter_power': 101, # Inverter power consumption in W
2675
2697
  'bms_power': 50, # BMS power consumption in W
2676
2698
  'force_charge_power': 5.00, # charge power in kW when using force charge
@@ -2691,11 +2713,11 @@ charge_config = {
2691
2713
  'special_contingency': 33, # contingency for special days when consumption might be higher
2692
2714
  'special_days': ['12-25', '12-26', '01-01'],
2693
2715
  'full_charge': None, # day of month (1-28) to do full charge, or 'daily' or 'Mon', 'Tue' etc
2694
- 'derate_temp': 22, # battery temperature where cold derating starts to be applied
2716
+ 'derate_temp': 28, # BMS temperature when cold derating starts to be applied
2695
2717
  'derate_step': 5, # scale for derating factors in C
2696
- 'derating': [24, 15, 10, 2], # max charge current e.g. 5C step = 22C, 17C, 12C, 7C
2718
+ 'derating': [24, 15, 10, 2], # max charge current de-rating
2697
2719
  'data_wrap': 6, # data items to show per line
2698
- 'target_soc': None, # set the target SoC for charging
2720
+ 'target_soc': None, # the target SoC for charging (over-rides calculated value)
2699
2721
  'shading': { # effect of shading on Solcast / forecast.solar
2700
2722
  'solcast': {'adjust': 0.95, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2},
2701
2723
  'solar': {'adjust': 1.20, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2}
@@ -2713,12 +2735,12 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2713
2735
  # show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
2714
2736
  # run_after: 0 over-rides 'forecast_times'. The default is 1.
2715
2737
  # forecast_times: list of hours when forecast can be fetched (UTC)
2716
- # force_charge: 1 = set force charge, 2 = charge for whole period
2738
+ # force_charge: 1 = hold battery, 2 = charge for whole period
2717
2739
 
2718
2740
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2719
2741
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2720
2742
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2721
- global timed_strategy, steps_per_hour, base_time, storage
2743
+ global timed_strategy, steps_per_hour, base_time, storage, residual_handling
2722
2744
  print(f"\n---------------- charge_needed ----------------")
2723
2745
  # validate parameters
2724
2746
  args = locals()
@@ -2771,10 +2793,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2771
2793
  if tariff is not None and tariff.get(k) is not None:
2772
2794
  start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2773
2795
  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})
2796
+ hold = 0 if tariff[k].get('hold') is not None and tariff[k]['hold'] == 0 else force_charge
2797
+ times.append({'key': k, 'start': start, 'end': end, 'hold': hold})
2776
2798
  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})
2799
+ times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'hold': force_charge})
2778
2800
  output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
2779
2801
  time_to_end1 = None
2780
2802
  for t in times:
@@ -2804,7 +2826,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2804
2826
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
2805
2827
  run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
2806
2828
  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']
2829
+ bat_hold = times[0]['hold']
2808
2830
  # if we need to do a full charge, full_charge is the date, otherwise None
2809
2831
  full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
2810
2832
  if type(full_charge) is int: # value = day of month
@@ -2814,7 +2836,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2814
2836
  if debug_setting > 2:
2815
2837
  output(f"\ntoday = {today}, tomorrow = {tomorrow}, time_shift = {time_shift}")
2816
2838
  output(f"times = {times}")
2817
- output(f"start_at = {start_at}, end_by = {end_by}, force_charge = {force_charge}")
2839
+ output(f"start_at = {start_at}, end_by = {end_by}, bat_hold = {bat_hold}")
2818
2840
  output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
2819
2841
  output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
2820
2842
  output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
@@ -2842,9 +2864,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2842
2864
  model = device.get('deviceType')
2843
2865
  else:
2844
2866
  current_soc = test_soc
2845
- capacity = 14.6
2867
+ capacity = 14.54
2846
2868
  residual = test_soc * capacity / 100
2847
- bat_volt = 315.4
2869
+ bat_volt = 317.4
2848
2870
  bat_power = 0.0
2849
2871
  temperature = 30
2850
2872
  bat_current = 0.0
@@ -2887,7 +2909,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2887
2909
  output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2888
2910
  charge_current = derated_current
2889
2911
  else:
2890
- force_charge = 2
2912
+ bat_hold = 2
2891
2913
  output(f" Full charge set")
2892
2914
  # inverter losses
2893
2915
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
@@ -2895,35 +2917,35 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2895
2917
  bms_power = charge_config['bms_power']
2896
2918
  bms_loss = bms_power / 1000
2897
2919
  # work out charge limit, power and losses. Max power going to the battery after ac conversion losses
2920
+ ac_dc_loss = charge_config['ac_dc_loss']
2898
2921
  charge_limit = min([charge_current * (bat_ocv + charge_current * bat_resistance) / 1000, max([6, device_power])])
2899
2922
  if charge_limit < 0.1:
2900
2923
  output(f"** charge_current is too low ({charge_current:.1f}A)")
2901
- charge_loss = 1.0 - charge_limit * 1000 * bat_resistance / bat_ocv ** 2
2902
2924
  force_charge_power = charge_config['force_charge_power'] if timed_mode > 1 and charge_config.get('force_charge_power') is not None else 100
2903
- grid_loss = charge_config['grid_loss']
2904
- charge_power = min([(device_power - operating_loss) * grid_loss, force_charge_power * grid_loss, charge_limit])
2925
+ charge_power = min([(device_power - operating_loss) * ac_dc_loss, force_charge_power * ac_dc_loss, charge_limit])
2905
2926
  float_charge = (charge_config['float_current'] if charge_config.get('float_current') is not None else 4) * bat_ocv / 1000
2906
- charge_config['charge_loss'] = charge_loss
2907
2927
  charge_config['charge_limit'] = charge_limit
2908
2928
  charge_config['charge_power'] = charge_power
2909
2929
  charge_config['float_charge'] = float_charge
2930
+ charge_loss = charge_config['charge_loss'][residual_handling - 1]
2910
2931
  # work out discharge limit = max power coming from the battery before ac conversion losses
2911
- discharge_loss = charge_config['discharge_loss']
2912
- discharge_limit = device_power / discharge_loss
2932
+ dc_ac_loss = charge_config['dc_ac_loss']
2933
+ discharge_limit = device_power / dc_ac_loss
2913
2934
  discharge_current = device_current if charge_config['discharge_current'] is None else charge_config['discharge_current']
2914
2935
  discharge_power = discharge_current * bat_ocv / 1000
2915
2936
  discharge_limit = discharge_power if discharge_power < discharge_limit else discharge_limit
2937
+ discharge_loss = charge_config['discharge_loss'][residual_handling - 1]
2916
2938
  # charging happens if generation exceeds export limit in feedin work mode
2917
2939
  export_power = device_power if charge_config['export_limit'] is None else charge_config['export_limit']
2918
- export_limit = export_power / discharge_loss
2940
+ export_limit = export_power / dc_ac_loss
2919
2941
  current_mode = get_work_mode()
2920
2942
  output(f"\ncharge_config = {json.dumps(charge_config, indent=2)}", 3)
2921
2943
  output(f"\nDevice Info:")
2922
2944
  output(f" Model: {model}")
2923
2945
  output(f" Rating: {device_power:.2f}kW")
2924
2946
  output(f" Export: {export_power:.2f}kW")
2925
- output(f" Charge: {charge_current:.1f}A, {charge_power:.2f}kW, {charge_loss * 100:.1f}% efficient")
2926
- output(f" Discharge: {discharge_current:.1f}A, {discharge_limit:.2f}kW, {discharge_loss * 100:.1f}% efficient")
2947
+ output(f" Charge: {charge_current:.1f}A, {charge_power:.2f}kW, {ac_dc_loss * 100:.1f}% efficient")
2948
+ output(f" Discharge: {discharge_current:.1f}A, {discharge_limit:.2f}kW, {dc_ac_loss * 100:.1f}% efficient")
2927
2949
  output(f" Inverter: {inverter_power:.0f}W power consumption")
2928
2950
  output(f" BMS: {bms_power:.0f}W power consumption")
2929
2951
  if current_mode is not None:
@@ -2960,16 +2982,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2960
2982
  solcast_value = None
2961
2983
  solcast_profile = None
2962
2984
  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'))
2985
+ fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'), d=base_time)
2964
2986
  if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
2965
2987
  solcast_value = fsolcast.daily[forecast_day]['kwh']
2966
2988
  solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
2967
2989
  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
2990
+ output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh")
2991
+ # get forecast.solar data and produce time line
2969
2992
  solar_value = None
2970
2993
  solar_profile = None
2971
2994
  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'))
2995
+ fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
2973
2996
  if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
2974
2997
  solar_value = fsolar.daily[forecast_day]['kwh']
2975
2998
  solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
@@ -3007,6 +3030,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3007
3030
  if forecast is not None:
3008
3031
  expected = forecast
3009
3032
  generation_timed = [expected * x / sun_sum for x in sun_timed]
3033
+ output(f"\nForecast: {forecast:.1f}kWh")
3010
3034
  elif solcast_value is not None:
3011
3035
  expected = solcast_value
3012
3036
  generation_timed = solcast_timed
@@ -3025,7 +3049,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3025
3049
  update_settings = 0
3026
3050
  # produce time lines for charge, discharge and work mode
3027
3051
  charge_timed = [min([charge_limit, x * charge_config['pv_loss']]) for x in generation_timed]
3028
- discharge_timed = [min([discharge_limit, x / discharge_loss]) + bms_loss for x in consumption_timed]
3052
+ discharge_timed = [min([discharge_limit, x / dc_ac_loss]) + bms_loss for x in consumption_timed]
3029
3053
  work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
3030
3054
  for i in range(0, len(work_mode_timed)):
3031
3055
  # get work mode
@@ -3036,11 +3060,13 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3036
3060
  discharge_timed[i] = discharge_timed[i] * (1.0 - duration)
3037
3061
  work_mode_timed[i]['charge'] = charge_power * duration
3038
3062
  elif timed_mode > 0 and work_mode == 'ForceDischarge':
3039
- fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
3040
- fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
3041
- 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):
3063
+ fdpwr = work_mode_timed[i]['fdpwr'] / dc_ac_loss / 1000
3064
+ fdpwr = min([discharge_limit, export_limit + discharge_timed[i], fdpwr])
3065
+ discharge_timed[i] = fdpwr * duration + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
3066
+ elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
3043
3067
  discharge_timed[i] = bms_loss
3068
+ if timed_mode > 1:
3069
+ work_mode_timed[i]['hold'] = 1
3044
3070
  elif timed_mode > 0 and work_mode == 'Backup':
3045
3071
  discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
3046
3072
  elif timed_mode > 0 and work_mode == 'Feedin':
@@ -3063,7 +3089,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3063
3089
  start_soc = int(start_residual / capacity * 100 + 0.5)
3064
3090
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
3065
3091
  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
3092
+ 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
3093
  if target_kwh > (end_residual + kwh_needed):
3068
3094
  kwh_needed = target_kwh - end_residual
3069
3095
  elif test_charge is not None:
@@ -3080,18 +3106,14 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3080
3106
  start_timed = time_to_end
3081
3107
  end_timed = time_to_end
3082
3108
  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
3109
  else:
3088
- if test_charge is None:
3089
- output(f"\nCharge needed {kwh_needed:.2f}kWh:")
3090
- charge_message = "with charge added"
3091
- output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
3092
3110
  # work out time to add kwh_needed to battery
3093
3111
  charge_rate = charge_power * charge_loss
3094
3112
  hours = kwh_needed / charge_rate
3113
+ if test_charge is None:
3114
+ output(f"\nCharge needed: {kwh_needed:.2f}kWh ({hours_time(hours)})")
3115
+ charge_message = "with charge added"
3116
+ output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
3095
3117
  # check if charge time exceeded or charge needed exceeds capacity
3096
3118
  hours_to_full = (capacity - start_residual) / charge_rate
3097
3119
  if hours > charge_time:
@@ -3100,20 +3122,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3100
3122
  kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
3101
3123
  required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # time to recover energy not added
3102
3124
  hours = required if required > hours and required < charge_time else charge_time
3103
- # round charge time
3125
+ # round charge time and work out what will actually be added
3104
3126
  min_hours = charge_config['min_hours']
3105
3127
  hours = int(hours / min_hours + 0.99) * min_hours
3128
+ kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
3106
3129
  # rework charge and discharge
3107
3130
  charge_period = get_best_charge_period(start_at, hours)
3108
3131
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
3109
3132
  price = charge_period.get('price') if charge_period is not None else None
3110
3133
  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
3134
+ end_timed = start_timed + hours * steps_per_hour
3112
3135
  start_residual = interpolate(start_timed, bat_timed)
3113
- end_soc = min([int((start_residual + kwh_needed) / capacity * 100 + 0.5), 100])
3136
+ end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
3114
3137
  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):
3138
+ output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
3139
+ + (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh)")
3140
+ for i in range(int(time_to_start), int(time_to_end)):
3117
3141
  j = i + 1
3118
3142
  # work out time (fraction of hour) when charging in hour from i to j
3119
3143
  if start_timed >= i and end_timed < j:
@@ -3127,12 +3151,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3127
3151
  else:
3128
3152
  t = 0.0 # complete hour before start or after end
3129
3153
  output(f"i = {i}, j = {j}, t = {t}", 3)
3130
- if i >= start_timed:
3154
+ if i >= start_timed and i < end_timed:
3155
+ work_mode_timed[i]['mode'] = 'ForceCharge'
3131
3156
  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
3157
+ work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else max_soc
3133
3158
  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
3159
  # rebuild the battery residual with the charge added and min_soc
3137
3160
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3138
3161
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
@@ -3141,6 +3164,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3141
3164
  output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
3142
3165
  if not charge_today:
3143
3166
  output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
3167
+ # setup charging
3168
+ if timed_mode > 1:
3169
+ periods = charge_periods(work_mode_timed, base_hour, min_soc, capacity)
3170
+ if update_settings > 0:
3171
+ set_schedule(periods = periods)
3172
+ else:
3173
+ # work out the charge times and set. First period is battery hold, second period is battery charge / hold
3174
+ start1 = round_time(base_hour + time_to_start / steps_per_hour)
3175
+ start2 = round_time(base_hour + start_timed / steps_per_hour)
3176
+ end1 = start1 if bat_hold == 0 else start2
3177
+ end2 = round_time(base_hour + (end_timed if bat_hold == 0 else time_to_end) / steps_per_hour)
3178
+ set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
3179
+ if update_settings == 0:
3180
+ output(f"\nNo changes made to charge settings")
3144
3181
  if show_data > 0:
3145
3182
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3146
3183
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
@@ -3148,7 +3185,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3148
3185
  t = 0
3149
3186
  while t < len(time_line) and bat_timed[t] is not None:
3150
3187
  col = h % data_wrap
3151
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3188
+ s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3152
3189
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3153
3190
  h += 1
3154
3191
  t += steps_per_hour
@@ -3189,27 +3226,12 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3189
3226
  data['capacity'] = capacity
3190
3227
  data['config'] = charge_config
3191
3228
  data['time'] = time_line
3192
- data['bat'] = bat_timed
3193
3229
  data['work_mode'] = work_mode_timed
3194
3230
  data['generation'] = generation_timed
3195
3231
  data['consumption'] = consumption_timed
3196
3232
  file = open(storage + file_name, 'w')
3197
3233
  json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
3198
3234
  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
3235
  output_close(plot=show_plot)
3214
3236
  return None
3215
3237
 
@@ -3238,10 +3260,10 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3238
3260
  steps_per_hour = data.get('steps')
3239
3261
  capacity = data.get('capacity')
3240
3262
  time_line = data.get('time')
3241
- bat_timed = data.get('bat')
3242
3263
  generation_timed = data.get('generation')
3243
3264
  consumption_timed = data.get('consumption')
3244
3265
  work_mode_timed = data.get('work_mode')
3266
+ bat_timed = data['bat'] if data.get('bat') is not None else [work_mode_timed[t]['kwh'] for t in range(0, len(work_mode_timed))]
3245
3267
  run_time = len(time_line)
3246
3268
  base_hour = int(time_hours(base_time[11:16]))
3247
3269
  start_day = base_time[:10]
@@ -3283,9 +3305,9 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3283
3305
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3284
3306
  h = base_hour
3285
3307
  t = 0
3286
- while t < len(time_line) and bat_timed[t] is not None:
3308
+ while t < len(time_line) and bat_timed[t] is not None and plots['SoC'][t] is not None:
3287
3309
  col = h % data_wrap
3288
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3310
+ s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3289
3311
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3290
3312
  h += 1
3291
3313
  t += steps_per_hour
@@ -3833,18 +3855,19 @@ class Solcast :
3833
3855
  Load Solcast Estimate / Actuals / Forecast daily yield
3834
3856
  """
3835
3857
 
3836
- def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None) :
3858
+ def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None, d=None) :
3837
3859
  # days sets the number of days to get for forecasts (and estimated if enabled)
3838
3860
  # reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
3839
3861
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3840
3862
  # 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
3863
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3842
3864
  self.data = {}
3865
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3843
3866
  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')
3867
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
3845
3868
  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')
3869
+ self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
3870
+ self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3848
3871
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
3849
3872
  if reload == 1 and os.path.exists(storage + self.save):
3850
3873
  os.remove(storage + self.save)
@@ -4180,13 +4203,14 @@ class Solar :
4180
4203
  """
4181
4204
 
4182
4205
  # get solar forecast and return total expected yield
4183
- def __init__(self, reload=0, quiet=False, shading=None):
4206
+ def __init__(self, reload=0, quiet=False, shading=None, d=None):
4184
4207
  global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
4185
4208
  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')
4209
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
4210
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
4187
4211
  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')
4212
+ self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
4213
+ self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
4190
4214
  self.arrays = None
4191
4215
  self.results = None
4192
4216
  self.save = solar_save #.replace('.', '_%.'.replace('%',self.today.replace('-','')))