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.
foxesscloud/openapi.py CHANGED
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 28 September 2024
4
+ Updated: 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 2024
11
11
  ##################################################################################################
12
12
 
13
- version = "2.5.7"
13
+ version = "2.5.9"
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):
@@ -540,7 +541,9 @@ battery = None
540
541
  battery_settings = None
541
542
  battery_vars = ['SoC', 'invBatVolt', 'invBatCurrent', 'invBatPower', 'batTemperature', 'ResidualEnergy' ]
542
543
  battery_data = ['soc', 'volt', 'current', 'power', 'temperature', 'residual']
543
- residual_handling = 1 # set to 2 if Residual returns current capacity
544
+
545
+ # 1 = returns Residual Energy. 2 = resturns Residual Capacity
546
+ residual_handling = 1
544
547
 
545
548
  def get_battery(v = None, info=0):
546
549
  global device_sn, battery, debug_setting, residual_handling
@@ -594,12 +597,12 @@ def get_charge():
594
597
  # helper to format time period structure
595
598
  def time_period(t, n):
596
599
  (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}"
600
+ result = f"{start['hour']:02d}:{start['minute']:02d}-{end['hour']:02d}:{end['minute']:02d}"
598
601
  if start['hour'] != end['hour'] or start['minute'] != end['minute']:
599
602
  result += f" Charge from grid" if enable else f" Force Charge"
600
603
  return result
601
604
 
602
- def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force = 0):
605
+ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force = 0, enable=1):
603
606
  global token, device_sn, battery_settings, debug_setting, time_period_vars
604
607
  if get_device() is None:
605
608
  return None
@@ -649,6 +652,8 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
649
652
  output(f"\nSetting time periods:", 1)
650
653
  output(f" Time Period 1 = {time_period(battery_settings['times'], 1)}", 1)
651
654
  output(f" Time Period 2 = {time_period(battery_settings['times'], 2)}", 1)
655
+ if enable == 0:
656
+ return battery_settings
652
657
  # set charge times
653
658
  body = {'sn': device_sn}
654
659
  for k in ['enable1', 'startTime1', 'endTime1', 'enable2', 'startTime2', 'endTime2']:
@@ -669,36 +674,6 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
669
674
  output(f"success", 2)
670
675
  return battery_settings
671
676
 
672
- def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, end_soc=100, start_soc=10):
673
- output(f"\nConfiguring schedule",1)
674
- charge = []
675
- st1 = time_hours(st1)
676
- en1 = time_hours(en1)
677
- st2 = time_hours(st2)
678
- en2 = time_hours(en2)
679
- span = None
680
- if st2 is not None and en2 is not None and st2 != en2:
681
- charge.append({'start': st2, 'end': en2, 'mode': 'ForceCharge', 'min_soc': min_soc, 'max_soc': end_soc})
682
- span = {'start': st2, 'end': en2}
683
- if st1 is not None and en1 is not None and st1 != en1:
684
- charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': start_soc})
685
- span = {'start': st1, 'end': en2}
686
- elif st1 is not None and en1 is not None and st1 != en1:
687
- span = {'start': st1, 'end': en1}
688
- if round_time(en1 - st1) > 0.25:
689
- st3 = round_time(en1 - 5 / 60)
690
- charge.append({'start': st1, 'end': st3, 'mode': 'SelfUse', 'min_soc': start_soc})
691
- st1 = st3
692
- charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': min_soc})
693
- strategy = get_strategy(remove=span, limit=24)[:(8 - len(charge))]
694
- for c in charge:
695
- strategy.append(c)
696
- periods = []
697
- for s in sorted(strategy, key=lambda s: s['start']):
698
- periods.append(set_period(segment = s, quiet=0))
699
- return periods
700
-
701
-
702
677
  ##################################################################################################
703
678
  # get min soc settings and save in battery_settings
704
679
  ##################################################################################################
@@ -1147,7 +1122,7 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1147
1122
  return None
1148
1123
  if quiet == 0:
1149
1124
  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 ""
1125
+ s += f", maxsoc {max_soc}%" if max_soc is not None and mode == 'ForceCharge' else ""
1151
1126
  s += f", fdPwr {fdpwr}W, fdSoC {fdsoc}%" if mode == 'ForceDischarge' else ""
1152
1127
  s += f", {price:.2f}p/kWh" if price is not None else ""
1153
1128
  output(s, 1)
@@ -1986,7 +1961,7 @@ def hours_difference(t1, t2):
1986
1961
  # time periods for Octopus Flux
1987
1962
  octopus_flux = {
1988
1963
  'name': 'Octopus Flux',
1989
- 'off_peak1': {'start': 2.0, 'end': 5.0, 'force': 1}, # off-peak period 1 / am charging period
1964
+ 'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1}, # off-peak period 1 / am charging period
1990
1965
  'peak1': {'start': 16.0, 'end': 19.0 }, # peak period 1
1991
1966
  'forecast_times': [21, 22], # hours in a day to get a forecast
1992
1967
  'strategy': [
@@ -1997,16 +1972,16 @@ octopus_flux = {
1997
1972
  # time periods for Intelligent Octopus
1998
1973
  intelligent_octopus = {
1999
1974
  'name': 'Intelligent Octopus',
2000
- 'off_peak1': {'start': 23.5, 'end': 5.5, 'force': 1},
1975
+ 'off_peak1': {'start': 23.5, 'end': 5.5, 'hold': 1},
2001
1976
  'forecast_times': [21, 22]
2002
1977
  }
2003
1978
 
2004
1979
  # time periods for Octopus Cosy
2005
1980
  octopus_cosy = {
2006
1981
  '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},
1982
+ 'off_peak1': {'start': 4.0, 'end': 7.0, 'hold': 1},
1983
+ 'off_peak2': {'start': 13.0, 'end': 16.0, 'hold': 0},
1984
+ 'off_peak3': {'start': 22.0, 'end': 24.0, 'hold': 0},
2010
1985
  'peak1': {'start': 16.0, 'end': 19.0 },
2011
1986
  'forecast_times': [10, 11, 21, 22]
2012
1987
  }
@@ -2014,15 +1989,15 @@ octopus_cosy = {
2014
1989
  # time periods for Octopus Go
2015
1990
  octopus_go = {
2016
1991
  'name': 'Octopus Go',
2017
- 'off_peak1': {'start': 0.5, 'end': 4.5, 'force': 1},
1992
+ 'off_peak1': {'start': 0.5, 'end': 4.5, 'hold': 1},
2018
1993
  'forecast_times': [21, 22]
2019
1994
  }
2020
1995
 
2021
1996
  # time periods for Agile Octopus
2022
1997
  agile_octopus = {
2023
1998
  '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},
1999
+ 'off_peak1': {'start': 0.0, 'end': 6.0, 'hold': 1},
2000
+ 'off_peak2': {'start': 12.0, 'end': 16.0, 'hold': 0},
2026
2001
  'peak1': {'start': 16.0, 'end': 19.0 },
2027
2002
  'forecast_times': [9, 10, 21, 22],
2028
2003
  'strategy': [],
@@ -2032,27 +2007,27 @@ agile_octopus = {
2032
2007
  # time periods for British Gas Electric Driver
2033
2008
  bg_driver = {
2034
2009
  'name': 'British Gas Electric Driver',
2035
- 'off_peak1': {'start': 0.0, 'end': 5.0, 'force': 1},
2010
+ 'off_peak1': {'start': 0.0, 'end': 5.0, 'hold': 1},
2036
2011
  'forecast_times': [21, 22]
2037
2012
  }
2038
2013
 
2039
2014
  # time periods for EON Next Drive
2040
2015
  eon_drive = {
2041
2016
  'name': 'EON NextDrive',
2042
- 'off_peak1': {'start': 0.0, 'end': 7.0, 'force': 1},
2017
+ 'off_peak1': {'start': 0.0, 'end': 7.0, 'hold': 1},
2043
2018
  'forecast_times': [21, 22]
2044
2019
  }
2045
2020
 
2046
2021
  # time periods for Economy 7
2047
2022
  economy_7 = {
2048
2023
  'name': 'Eco 7',
2049
- 'off_peak1': {'start': 0.5, 'end': 7.5, 'force': 1, 'gmt': 1},
2024
+ 'off_peak1': {'start': 0.5, 'end': 7.5, 'hold': 1, 'gmt': 1},
2050
2025
  'forecast_times': [21, 22]
2051
2026
  }
2052
2027
 
2053
2028
  # custom time periods / template
2054
2029
  custom_periods = {'name': 'Custom',
2055
- 'off_peak1': {'start': 2.0, 'end': 5.0, 'force': 1},
2030
+ 'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1},
2056
2031
  'peak1': {'start': 16.0, 'end': 19.0 },
2057
2032
  'forecast_times': [21, 22]
2058
2033
  }
@@ -2072,8 +2047,10 @@ test_strategy = [
2072
2047
  {'start': 21, 'end': 22, 'mode': 'ForceCharge'}]
2073
2048
 
2074
2049
  # return a strategy that has been sorted and filtered for charge times:
2075
- def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=None):
2050
+ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=24, timed_mode=1):
2076
2051
  global tariff, base_time
2052
+ if timed_mode == 0:
2053
+ return []
2077
2054
  if use is None:
2078
2055
  use = tariff
2079
2056
  base_time_adjust = 0
@@ -2082,11 +2059,12 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit
2082
2059
  if tariff.get('strategy') is not None:
2083
2060
  for s in tariff['strategy']:
2084
2061
  strategy.append(s)
2085
- if use.get('agile') is not None and use['agile'].get('strategy') is not None:
2062
+ if timed_mode > 1 and use.get('agile') is not None and use['agile'].get('strategy') is not None:
2086
2063
  base_time_adjust = hours_difference(base_time, use['agile'].get('base_time') )
2087
2064
  for s in use['agile']['strategy']:
2088
- if limit is None or s.get('hour') is None or s['hour'] - base_time_adjust < 24:
2089
- s['valid_for'] = [int((s['hour'] - base_time_adjust) * steps_per_hour + i) for i in range(0, steps_per_hour // 2)] if s.get('hour') is not None else None
2065
+ hour = (s['hour'] - base_time_adjust) if limit is not None and s.get('hour') is not None else None
2066
+ if hour is None or (hour >= 0 and hour < limit):
2067
+ s['valid_for'] = [hour * steps_per_hour + i for i in range(0, steps_per_hour // 2)] if hour is not None else None
2090
2068
  strategy.append(s)
2091
2069
  if strategy is None or len(strategy) == 0:
2092
2070
  return []
@@ -2135,8 +2113,8 @@ tariff_config = {
2135
2113
  'region': "H", # region code to use for Octopus API
2136
2114
  'update_time': 16.5, # time in hours when tomrow's data can be fetched
2137
2115
  'weighting': None, # weights for weighted average
2138
- 'plunge_price': [3, 10], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2139
- 'plunge_slots': 8, # number of 30 minute slots to use
2116
+ 'plunge_price': [3, 3], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
2117
+ 'plunge_slots': 6, # number of 30 minute slots to use
2140
2118
  'data_wrap': 6, # prices to show per line
2141
2119
  'show_data': 1, # show pricing data
2142
2120
  'show_plot': 1 # plot pricing data
@@ -2227,7 +2205,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
2227
2205
  col = (now.hour * 2) % data_wrap
2228
2206
  s = f"\nPrice p/kWh inc VAT on {today}:"
2229
2207
  for i in range(0, len(prices)):
2230
- s += (f"\n {prices[i]['time']} " + " " * col * 6) if i == 0 or col == 0 else ""
2208
+ s += f"\n {prices[i]['time']}" if i == 0 or col == 0 else ""
2231
2209
  s += f" {prices[i]['price']:4.1f}"
2232
2210
  col = (col + 1) % data_wrap
2233
2211
  output(s)
@@ -2345,7 +2323,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2345
2323
  use[key]['start'] = time_hours(t[1])
2346
2324
  use[key]['end'] = time_hours(t[2])
2347
2325
  if len(t) > 3:
2348
- use[key]['force'] = t[3]
2326
+ use[key]['hold'] = t[3]
2349
2327
  gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
2350
2328
  output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
2351
2329
  # update dynamic charge times
@@ -2420,10 +2398,11 @@ def timed_list(data, base_hour, run_time):
2420
2398
  result = []
2421
2399
  h = base_hour
2422
2400
  for t in range(0, run_time):
2423
- result.append(data[int(h)])
2401
+ result.append(interpolate(h, data, wrap=1))
2424
2402
  h = round_time(h + 1 / steps_per_hour)
2425
2403
  return result
2426
2404
 
2405
+ # align forecast with base_hour and expand to cover run_time
2427
2406
  def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
2428
2407
  global steps_per_hour
2429
2408
  profile = []
@@ -2452,10 +2431,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2452
2431
  min_soc_now = min_soc
2453
2432
  max_soc_now = max_soc
2454
2433
  current_mode = 'SelfUse' if current_mode is None else current_mode
2455
- strategy = get_strategy() if timed_mode > 0 else None
2434
+ strategy = get_strategy(timed_mode=timed_mode)
2456
2435
  h = base_hour
2457
2436
  for i in range(0, run_time):
2458
- period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0, 'charge': 0.0, 'pv': 0.0, 'discharge': 0.0}
2437
+ 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,
2438
+ 'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2459
2439
  if strategy is not None:
2460
2440
  period['mode'] = 'SelfUse'
2461
2441
  for d in strategy:
@@ -2477,22 +2457,23 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2477
2457
  return work_mode_timed
2478
2458
 
2479
2459
  # build the timed battery residual from the charge / discharge, work mode and min_soc
2460
+ # note: all power values are as measured at the inverter battery connection
2480
2461
  def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=None, reserve_drain=None):
2481
- global charge_config, steps_per_hour
2482
- bat_timed = []
2462
+ global charge_config, steps_per_hour, residual_handling
2483
2463
  allowed_drain = charge_config['allowed_drain'] if charge_config.get('allowed_drain') is not None else 4
2484
2464
  bms_loss = (charge_config['bms_power'] / 1000 if charge_config.get('bms_power') is not None else 0.05)
2485
- charge_loss = charge_config['charge_loss']
2465
+ charge_loss = charge_config['charge_loss'][residual_handling - 1]
2466
+ discharge_loss = charge_config['discharge_loss'][residual_handling - 1]
2486
2467
  charge_limit = charge_config['charge_limit']
2487
2468
  float_charge = charge_config['float_charge']
2488
2469
  for i in range(0, len(work_mode_timed)):
2489
- bat_timed.append(kwh_current)
2490
2470
  w = work_mode_timed[i]
2471
+ w['kwh'] = kwh_current
2491
2472
  max_now = w['max_soc'] * capacity / 100
2492
2473
  if kwh_current < max_now and w['charge'] > 0.0:
2493
2474
  kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
2494
2475
  kwh_current = max_now if kwh_current > max_now else kwh_current
2495
- kwh_current += (w['pv'] - w['discharge']) / charge_loss / steps_per_hour
2476
+ kwh_current += (w['pv'] * charge_loss - w['discharge'] / discharge_loss) / steps_per_hour
2496
2477
  if kwh_current > capacity:
2497
2478
  # battery is full
2498
2479
  kwh_current = capacity
@@ -2513,7 +2494,45 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2513
2494
  reserve_drain = reserve_now
2514
2495
  if kwh_min is not None and kwh_current < kwh_min and i >= time_to_next: # track minimum without charge
2515
2496
  kwh_min = kwh_current
2516
- return (bat_timed, kwh_min)
2497
+ return ([work_mode_timed[i]['kwh'] for i in range(0, len(work_mode_timed))], kwh_min)
2498
+
2499
+ # use work_mode_timed to generate time periods for the inverter schedule
2500
+ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2501
+ global steps_per_hour
2502
+ strategy = []
2503
+ start = base_hour
2504
+ times = []
2505
+ for t in range(0, min([24 * steps_per_hour, len(work_mode_timed)])):
2506
+ period = times[0] if len(times) > 0 else work_mode_timed[0]
2507
+ next_period = work_mode_timed[t]
2508
+ h = base_hour + t / steps_per_hour
2509
+ if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
2510
+ s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
2511
+ if period['mode'] == 'ForceDischarge':
2512
+ s['fdsoc'] = period.get('fdsoc')
2513
+ s['fdpwr'] = period.get('fdpwr')
2514
+ elif period['mode'] == 'ForceCharge':
2515
+ s['max_soc'] = period.get('max_soc')
2516
+ elif period['mode'] == 'SelfUse' and period['hold'] == 1:
2517
+ s['min_soc'] = min([int(period['kwh'] / capacity * 100 + 0.5), 100])
2518
+ s['end'] = (start + 1 / steps_per_hour) % 24
2519
+ for p in times:
2520
+ p['min_soc'] = s['min_soc']
2521
+ if s['mode'] != 'SelfUse' or s['min_soc'] != min_soc:
2522
+ strategy.append(s)
2523
+ start = h
2524
+ times = []
2525
+ times.append(work_mode_timed[t])
2526
+ if len(strategy) == 0:
2527
+ return []
2528
+ if strategy[-1]['min_soc'] != min_soc:
2529
+ strategy.append({'start': start %24, 'end': (start + 1 / steps_per_hour) % 24, 'mode': 'SelfUse', 'min_soc': min_soc})
2530
+ output(f"\nConfiguring schedule:",1)
2531
+ periods = []
2532
+ for s in strategy:
2533
+ periods.append(set_period(segment = s, quiet=0))
2534
+ return periods
2535
+
2517
2536
 
2518
2537
  # Battery open circuit voltage (OCV) from 0% to 100% SoC
2519
2538
  # 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
@@ -2532,9 +2551,11 @@ charge_config = {
2532
2551
  'charge_current': None, # max battery charge current setting in A
2533
2552
  'discharge_current': None, # max battery discharge current setting in A
2534
2553
  'export_limit': None, # maximum export power in kW
2535
- 'discharge_loss': 0.97, # loss converting battery discharge power to grid power
2536
- 'pv_loss': 0.95, # loss converting PV power to battery charge power
2537
- 'grid_loss': 0.975, # loss converting grid power to battery charge power
2554
+ 'dc_ac_loss': 0.97, # loss converting battery DC power to AC grid power
2555
+ 'pv_loss': 0.95, # loss converting PV power to DC battery charge power
2556
+ 'ac_dc_loss': 0.962, # loss converting AC grid power to DC battery charge power
2557
+ 'charge_loss': [0.975, 1.040], # loss in battery energy for each kWh added based on residual_handling
2558
+ 'discharge_loss': [0.975, 0.975], # loss in battery energy for each kWh removed based on residual_handling
2538
2559
  'inverter_power': 101, # Inverter power consumption in W
2539
2560
  'bms_power': 50, # BMS power consumption in W
2540
2561
  'force_charge_power': 5.00, # charge power in kW when using force charge
@@ -2555,11 +2576,11 @@ charge_config = {
2555
2576
  'special_contingency': 33, # contingency for special days when consumption might be higher
2556
2577
  'special_days': ['12-25', '12-26', '01-01'],
2557
2578
  'full_charge': None, # day of month (1-28) to do full charge, or 'daily' or 'Mon', 'Tue' etc
2558
- 'derate_temp': 22, # battery temperature where cold derating starts to be applied
2579
+ 'derate_temp': 25, # BMS temperature when cold derating starts to be applied
2559
2580
  'derate_step': 5, # scale for derating factors in C
2560
- 'derating': [24, 15, 10, 2], # max charge current e.g. 5C step = 22C, 17C, 12C, 7C
2581
+ 'derating': [24, 15, 10, 2], # max charge current e.g. 5C step = 25C, 20C, 15C, 10C
2561
2582
  'data_wrap': 6, # data items to show per line
2562
- 'target_soc': None, # set the target SoC for charging
2583
+ 'target_soc': None, # the target SoC for charging (over-rides calculated value)
2563
2584
  'shading': { # effect of shading on Solcast / forecast.solar
2564
2585
  'solcast': {'adjust': 0.95, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2},
2565
2586
  'solar': {'adjust': 1.20, 'am_delay': 1.0, 'am_loss': 0.2, 'pm_delay': 1.0, 'pm_loss': 0.2}
@@ -2577,12 +2598,12 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2577
2598
  # show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
2578
2599
  # run_after: 0 over-rides 'forecast_times'. The default is 1.
2579
2600
  # forecast_times: list of hours when forecast can be fetched (UTC)
2580
- # force_charge: 1 = set force charge, 2 = charge for whole period
2601
+ # force_charge: 1 = hold battery, 2 = charge for whole period
2581
2602
 
2582
2603
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2583
2604
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2584
2605
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2585
- global timed_strategy, steps_per_hour, base_time, storage
2606
+ global timed_strategy, steps_per_hour, base_time, storage, residual_handling
2586
2607
  print(f"\n---------------- charge_needed ----------------")
2587
2608
  # validate parameters
2588
2609
  args = locals()
@@ -2635,10 +2656,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2635
2656
  if tariff is not None and tariff.get(k) is not None:
2636
2657
  start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2637
2658
  end = round_time(time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0))
2638
- force = 0 if tariff[k].get('force') is not None and tariff[k]['force'] == 0 else force_charge
2639
- times.append({'key': k, 'start': start, 'end': end, 'force': force})
2659
+ hold = 0 if tariff[k].get('hold') is not None and tariff[k]['hold'] == 0 else force_charge
2660
+ times.append({'key': k, 'start': start, 'end': end, 'hold': hold})
2640
2661
  if len(times) == 0:
2641
- times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'force': force_charge})
2662
+ times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'hold': force_charge})
2642
2663
  output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
2643
2664
  time_to_end1 = None
2644
2665
  for t in times:
@@ -2668,7 +2689,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2668
2689
  run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
2669
2690
  run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
2670
2691
  time_line = [round_time(base_hour + x / steps_per_hour - (hour_adjustment if x >= time_change else 0)) for x in range(0, run_time)]
2671
- force_charge = times[0]['force']
2692
+ bat_hold = times[0]['hold']
2672
2693
  # if we need to do a full charge, full_charge is the date, otherwise None
2673
2694
  full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
2674
2695
  if type(full_charge) is int: # value = day of month
@@ -2706,9 +2727,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2706
2727
  model = device.get('deviceType')
2707
2728
  else:
2708
2729
  current_soc = test_soc
2709
- capacity = 14.6
2730
+ capacity = 14.54
2710
2731
  residual = test_soc * capacity / 100
2711
- bat_volt = 315.4
2732
+ bat_volt = 317.4
2712
2733
  bat_power = 0.0
2713
2734
  temperature = 30
2714
2735
  bat_current = 0.0
@@ -2751,7 +2772,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2751
2772
  output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
2752
2773
  charge_current = derated_current
2753
2774
  else:
2754
- force_charge = 2
2775
+ bat_hold = 2
2755
2776
  output(f" Full charge set")
2756
2777
  # inverter losses
2757
2778
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
@@ -2759,35 +2780,35 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2759
2780
  bms_power = charge_config['bms_power']
2760
2781
  bms_loss = bms_power / 1000
2761
2782
  # work out charge limit, power and losses. Max power going to the battery after ac conversion losses
2783
+ ac_dc_loss = charge_config['ac_dc_loss']
2762
2784
  charge_limit = min([charge_current * (bat_ocv + charge_current * bat_resistance) / 1000, max([6, device_power])])
2763
2785
  if charge_limit < 0.1:
2764
2786
  output(f"** charge_current is too low ({charge_current:.1f}A)")
2765
- charge_loss = 1.0 - charge_limit * 1000 * bat_resistance / bat_ocv ** 2
2766
2787
  force_charge_power = charge_config['force_charge_power'] if timed_mode > 1 and charge_config.get('force_charge_power') is not None else 100
2767
- grid_loss = charge_config['grid_loss']
2768
- charge_power = min([(device_power - operating_loss) * grid_loss, force_charge_power * grid_loss, charge_limit])
2788
+ charge_power = min([(device_power - operating_loss) * ac_dc_loss, force_charge_power * ac_dc_loss, charge_limit])
2769
2789
  float_charge = (charge_config['float_current'] if charge_config.get('float_current') is not None else 4) * bat_ocv / 1000
2770
- charge_config['charge_loss'] = charge_loss
2771
2790
  charge_config['charge_limit'] = charge_limit
2772
2791
  charge_config['charge_power'] = charge_power
2773
2792
  charge_config['float_charge'] = float_charge
2793
+ charge_loss = charge_config['charge_loss'][residual_handling - 1]
2774
2794
  # work out discharge limit = max power coming from the battery before ac conversion losses
2775
- discharge_loss = charge_config['discharge_loss']
2776
- discharge_limit = device_power / discharge_loss
2795
+ dc_ac_loss = charge_config['dc_ac_loss']
2796
+ discharge_limit = device_power / dc_ac_loss
2777
2797
  discharge_current = device_current if charge_config['discharge_current'] is None else charge_config['discharge_current']
2778
2798
  discharge_power = discharge_current * bat_ocv / 1000
2779
2799
  discharge_limit = discharge_power if discharge_power < discharge_limit else discharge_limit
2800
+ discharge_loss = charge_config['discharge_loss'][residual_handling - 1]
2780
2801
  # charging happens if generation exceeds export limit in feedin work mode
2781
2802
  export_power = device_power if charge_config['export_limit'] is None else charge_config['export_limit']
2782
- export_limit = export_power / discharge_loss
2803
+ export_limit = export_power / dc_ac_loss
2783
2804
  current_mode = get_work_mode()
2784
2805
  output(f"\ncharge_config = {json.dumps(charge_config, indent=2)}", 3)
2785
2806
  output(f"\nDevice Info:")
2786
2807
  output(f" Model: {model}")
2787
2808
  output(f" Rating: {device_power:.2f}kW")
2788
2809
  output(f" Export: {export_power:.2f}kW")
2789
- output(f" Charge: {charge_current:.1f}A, {charge_power:.2f}kW, {charge_loss * 100:.1f}% efficient")
2790
- output(f" Discharge: {discharge_current:.1f}A, {discharge_limit:.2f}kW, {discharge_loss * 100:.1f}% efficient")
2810
+ output(f" Charge: {charge_current:.1f}A, {charge_power:.2f}kW, {ac_dc_loss * 100:.1f}% efficient")
2811
+ output(f" Discharge: {discharge_current:.1f}A, {discharge_limit:.2f}kW, {dc_ac_loss * 100:.1f}% efficient")
2791
2812
  output(f" Inverter: {inverter_power:.0f}W power consumption")
2792
2813
  output(f" BMS: {bms_power:.0f}W power consumption")
2793
2814
  if current_mode is not None:
@@ -2824,7 +2845,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2824
2845
  solcast_value = None
2825
2846
  solcast_profile = None
2826
2847
  if forecast is None and solcast_api_key is not None and solcast_api_key != 'my.solcast_api_key' and (system_time.hour in forecast_times or run_after == 0):
2827
- fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'))
2848
+ fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'), d=base_time)
2828
2849
  if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
2829
2850
  solcast_value = fsolcast.daily[forecast_day]['kwh']
2830
2851
  solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
@@ -2833,7 +2854,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2833
2854
  solar_value = None
2834
2855
  solar_profile = None
2835
2856
  if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
2836
- fsolar = Solar(quiet=True, shading=charge_config.get('shading'))
2857
+ fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
2837
2858
  if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
2838
2859
  solar_value = fsolar.daily[forecast_day]['kwh']
2839
2860
  solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
@@ -2871,6 +2892,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2871
2892
  if forecast is not None:
2872
2893
  expected = forecast
2873
2894
  generation_timed = [expected * x / sun_sum for x in sun_timed]
2895
+ output(f"\nForecast: {forecast:.1f}kWh")
2874
2896
  elif solcast_value is not None:
2875
2897
  expected = solcast_value
2876
2898
  generation_timed = solcast_timed
@@ -2889,7 +2911,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2889
2911
  update_settings = 0
2890
2912
  # produce time lines for charge, discharge and work mode
2891
2913
  charge_timed = [min([charge_limit, x * charge_config['pv_loss']]) for x in generation_timed]
2892
- discharge_timed = [min([discharge_limit, x / discharge_loss]) + bms_loss for x in consumption_timed]
2914
+ discharge_timed = [min([discharge_limit, x / dc_ac_loss]) + bms_loss for x in consumption_timed]
2893
2915
  work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
2894
2916
  for i in range(0, len(work_mode_timed)):
2895
2917
  # get work mode
@@ -2900,11 +2922,13 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2900
2922
  discharge_timed[i] = discharge_timed[i] * (1.0 - duration)
2901
2923
  work_mode_timed[i]['charge'] = charge_power * duration
2902
2924
  elif timed_mode > 0 and work_mode == 'ForceDischarge':
2903
- fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
2904
- fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
2905
- discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
2906
- elif force_charge > 0 and i >= int(time_to_start) and i < int(time_to_end):
2925
+ fdpwr = work_mode_timed[i]['fdpwr'] / dc_ac_loss / 1000
2926
+ fdpwr = min([discharge_limit, export_limit + discharge_timed[i], fdpwr])
2927
+ discharge_timed[i] = fdpwr * duration + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
2928
+ elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
2907
2929
  discharge_timed[i] = bms_loss
2930
+ if timed_mode > 1:
2931
+ work_mode_timed[i]['hold'] = 1
2908
2932
  elif timed_mode > 0 and work_mode == 'Backup':
2909
2933
  discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
2910
2934
  elif timed_mode > 0 and work_mode == 'Feedin':
@@ -2927,7 +2951,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2927
2951
  start_soc = int(start_residual / capacity * 100 + 0.5)
2928
2952
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
2929
2953
  target_soc = charge_config.get('target_soc')
2930
- target_kwh = capacity if full_charge is not None or force_charge == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
2954
+ target_kwh = capacity if full_charge is not None or bat_hold == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
2931
2955
  if target_kwh > (end_residual + kwh_needed):
2932
2956
  kwh_needed = target_kwh - end_residual
2933
2957
  elif test_charge is not None:
@@ -2944,18 +2968,14 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2944
2968
  start_timed = time_to_end
2945
2969
  end_timed = time_to_end
2946
2970
  end_soc = int(end_residual / capacity * 100 + 0.5)
2947
- # update min_soc for battery hold
2948
- if force_charge > 0 and timed_mode > 1:
2949
- for t in range(int(time_to_start), int(time_to_end)):
2950
- work_mode_timed[t]['min_soc'] = start_soc
2951
2971
  else:
2952
- if test_charge is None:
2953
- output(f"\nCharge needed {kwh_needed:.2f}kWh:")
2954
- charge_message = "with charge added"
2955
- output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
2956
2972
  # work out time to add kwh_needed to battery
2957
2973
  charge_rate = charge_power * charge_loss
2958
2974
  hours = kwh_needed / charge_rate
2975
+ if test_charge is None:
2976
+ output(f"\nCharge needed: {kwh_needed:.2f}kWh ({hours_time(hours)})")
2977
+ charge_message = "with charge added"
2978
+ output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
2959
2979
  # check if charge time exceeded or charge needed exceeds capacity
2960
2980
  hours_to_full = (capacity - start_residual) / charge_rate
2961
2981
  if hours > charge_time:
@@ -2964,20 +2984,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2964
2984
  kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
2965
2985
  required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # hold time to recover energy not added
2966
2986
  hours = required if required > hours and required < charge_time else charge_time
2967
- # round charge time
2987
+ # round charge time and work out what will actually be added
2968
2988
  min_hours = charge_config['min_hours']
2969
2989
  hours = int(hours / min_hours + 0.99) * min_hours
2990
+ kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
2970
2991
  # rework charge and discharge
2971
2992
  charge_period = get_best_charge_period(start_at, hours)
2972
2993
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
2973
2994
  price = charge_period.get('price') if charge_period is not None else None
2974
2995
  start_timed = time_to_start + charge_offset * steps_per_hour
2975
- end_timed = (start_timed + hours * steps_per_hour) if force_charge == 0 else time_to_end
2996
+ end_timed = start_timed + hours * steps_per_hour
2976
2997
  start_residual = interpolate(start_timed, bat_timed)
2977
- end_soc = min([int((start_residual + kwh_needed) / capacity * 100 + 0.5), 100])
2998
+ end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
2978
2999
  output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
2979
- output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}" + (f" at {price:5.2f}p/kWh" if price is not None else ""))
2980
- for i in range(int(time_to_start), int(end_timed) + 1):
3000
+ output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
3001
+ + (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh)")
3002
+ for i in range(int(time_to_start), int(time_to_end)):
2981
3003
  j = i + 1
2982
3004
  # work out time (fraction of hour) when charging in hour from i to j
2983
3005
  if start_timed >= i and end_timed < j:
@@ -2991,12 +3013,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2991
3013
  else:
2992
3014
  t = 0.0 # complete hour before start or after end
2993
3015
  output(f"i = {i}, j = {j}, t = {t}", 3)
2994
- if i >= start_timed:
3016
+ if i >= start_timed and i < end_timed:
3017
+ work_mode_timed[i]['mode'] = 'ForceCharge'
2995
3018
  work_mode_timed[i]['charge'] = charge_power * t
2996
- work_mode_timed[i]['max_soc'] = end_soc if timed_mode > 1 else target_soc if target_soc is not None else 100
3019
+ work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else max_soc
2997
3020
  work_mode_timed[i]['discharge'] *= (1-t)
2998
- elif force_charge > 0 and timed_mode > 1:
2999
- work_mode_timed[i]['min_soc'] = start_soc
3000
3021
  # rebuild the battery residual with any charge added and min_soc
3001
3022
  (bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
3002
3023
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
@@ -3005,6 +3026,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3005
3026
  output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
3006
3027
  if not charge_today:
3007
3028
  output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
3029
+ # setup charging
3030
+ if timed_mode > 1:
3031
+ periods = charge_periods(work_mode_timed, base_hour, min_soc, capacity)
3032
+ if update_settings > 0:
3033
+ set_schedule(periods = periods)
3034
+ else:
3035
+ # work out the charge times and set. First period is battery hold, second period is battery charge / hold
3036
+ start1 = round_time(base_hour + time_to_start / steps_per_hour)
3037
+ start2 = round_time(base_hour + start_timed / steps_per_hour)
3038
+ end1 = start1 if bat_hold == 0 else start2
3039
+ end2 = round_time(base_hour + (end_timed if bat_hold == 0 else time_to_end) / steps_per_hour)
3040
+ set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
3041
+ if update_settings == 0:
3042
+ output(f"\nNo changes made to charge settings")
3008
3043
  if show_data > 0:
3009
3044
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3010
3045
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
@@ -3012,7 +3047,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3012
3047
  t = 0
3013
3048
  while t < len(time_line) and bat_timed[t] is not None:
3014
3049
  col = h % data_wrap
3015
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3050
+ s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3016
3051
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3017
3052
  h += 1
3018
3053
  t += steps_per_hour
@@ -3053,27 +3088,12 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3053
3088
  data['capacity'] = capacity
3054
3089
  data['config'] = charge_config
3055
3090
  data['time'] = time_line
3056
- data['bat'] = bat_timed
3057
3091
  data['work_mode'] = work_mode_timed
3058
3092
  data['generation'] = generation_timed
3059
3093
  data['consumption'] = consumption_timed
3060
3094
  file = open(storage + file_name, 'w')
3061
3095
  json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
3062
3096
  file.close()
3063
- # setup charging
3064
- if update_settings == 1:
3065
- # work out the charge times and set. First period is battery hold, second period is battery charge / hold
3066
- start1 = round_time(base_hour + time_to_start / steps_per_hour)
3067
- start2 = round_time(base_hour + start_timed / steps_per_hour)
3068
- end1 = start1 if force_charge == 0 else start2
3069
- end2 = round_time(base_hour + (end_timed if force_charge == 0 else time_to_end) / steps_per_hour)
3070
- if timed_mode > 1:
3071
- periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, end_soc=end_soc, start_soc=start_soc)
3072
- set_schedule(periods = periods)
3073
- else:
3074
- set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1)
3075
- else:
3076
- output(f"\nNo changes made to charge settings")
3077
3097
  output_close(plot=show_plot)
3078
3098
  return None
3079
3099
 
@@ -3101,10 +3121,10 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3101
3121
  steps_per_hour = data.get('steps')
3102
3122
  capacity = data.get('capacity')
3103
3123
  time_line = data.get('time')
3104
- bat_timed = data.get('bat')
3105
3124
  generation_timed = data.get('generation')
3106
3125
  consumption_timed = data.get('consumption')
3107
3126
  work_mode_timed = data.get('work_mode')
3127
+ 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))]
3108
3128
  run_time = len(time_line)
3109
3129
  base_hour = int(time_hours(base_time[11:16]))
3110
3130
  start_day = base_time[:10]
@@ -3146,9 +3166,9 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3146
3166
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3147
3167
  h = base_hour
3148
3168
  t = 0
3149
- while t < len(time_line) and bat_timed[t] is not None:
3169
+ while t < len(time_line) and bat_timed[t] is not None and plots['SoC'][t] is not None:
3150
3170
  col = h % data_wrap
3151
- s += (f"\n {hours_time(time_line[t])}" + " " * col * 6) if t == 0 or col == 0 else ""
3171
+ s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3152
3172
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3153
3173
  h += 1
3154
3174
  t += steps_per_hour
@@ -3695,18 +3715,19 @@ class Solcast :
3695
3715
  Load Solcast Estimate / Actuals / Forecast daily yield
3696
3716
  """
3697
3717
 
3698
- def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None) :
3718
+ def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None, d=None) :
3699
3719
  # days sets the number of days to get for forecasts (and estimated if enabled)
3700
3720
  # reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
3701
3721
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3702
3722
  # The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
3703
3723
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3704
3724
  self.data = {}
3725
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
3705
3726
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3706
- self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
3727
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
3707
3728
  self.quarter = int(self.today[5:7]) // 3 % 4
3708
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
3709
- self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
3729
+ self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
3730
+ self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3710
3731
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
3711
3732
  if reload == 1 and os.path.exists(storage + self.save):
3712
3733
  os.remove(storage + self.save)
@@ -4042,13 +4063,14 @@ class Solar :
4042
4063
  """
4043
4064
 
4044
4065
  # get solar forecast and return total expected yield
4045
- def __init__(self, reload=0, quiet=False, shading=None):
4066
+ def __init__(self, reload=0, quiet=False, shading=None, d=None):
4046
4067
  global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
4047
4068
  self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
4048
- self.today = datetime.strftime(datetime.date(datetime.now()), '%Y-%m-%d')
4069
+ now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
4070
+ self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
4049
4071
  self.quarter = int(self.today[5:7]) // 3 % 4
4050
- self.tomorrow = datetime.strftime(datetime.date(datetime.now() + timedelta(days=1)), '%Y-%m-%d')
4051
- self.yesterday = datetime.strftime(datetime.date(datetime.now() - timedelta(days=1)), '%Y-%m-%d')
4072
+ self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
4073
+ self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
4052
4074
  self.arrays = None
4053
4075
  self.results = None
4054
4076
  self.save = solar_save #.replace('.', '_%.'.replace('%',self.today.replace('-','')))