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/foxesscloud.py +171 -147
- foxesscloud/openapi.py +168 -146
- {foxesscloud-2.5.7.dist-info → foxesscloud-2.5.9.dist-info}/METADATA +55 -39
- foxesscloud-2.5.9.dist-info/RECORD +7 -0
- foxesscloud-2.5.7.dist-info/RECORD +0 -7
- {foxesscloud-2.5.7.dist-info → foxesscloud-2.5.9.dist-info}/LICENCE +0 -0
- {foxesscloud-2.5.7.dist-info → foxesscloud-2.5.9.dist-info}/WHEEL +0 -0
- {foxesscloud-2.5.7.dist-info → foxesscloud-2.5.9.dist-info}/top_level.txt +0 -0
foxesscloud/openapi.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
##################################################################################################
|
2
2
|
"""
|
3
3
|
Module: Fox ESS Cloud using Open API
|
4
|
-
Updated:
|
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.
|
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
|
118
|
-
|
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
|
-
|
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}
|
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, '
|
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, '
|
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, '
|
2008
|
-
'off_peak2': {'start': 13.0, 'end': 16.0, '
|
2009
|
-
'off_peak3': {'start': 22.0, 'end': 24.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, '
|
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, '
|
2025
|
-
'off_peak2': {'start': 12.0, 'end': 16.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, '
|
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, '
|
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, '
|
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, '
|
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=
|
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
|
2089
|
-
|
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,
|
2139
|
-
'plunge_slots':
|
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 +=
|
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]['
|
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(
|
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()
|
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,
|
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']
|
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 (
|
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
|
-
'
|
2536
|
-
'pv_loss': 0.95, # loss converting PV power to battery charge power
|
2537
|
-
'
|
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':
|
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 =
|
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, #
|
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 =
|
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
|
-
|
2639
|
-
times.append({'key': k, 'start': start, 'end': end, '
|
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), '
|
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
|
-
|
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.
|
2730
|
+
capacity = 14.54
|
2710
2731
|
residual = test_soc * capacity / 100
|
2711
|
-
bat_volt =
|
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
|
-
|
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
|
-
|
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
|
-
|
2776
|
-
discharge_limit = device_power /
|
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 /
|
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, {
|
2790
|
-
output(f" Discharge: {discharge_current:.1f}A, {discharge_limit:.2f}kW, {
|
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 /
|
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'] /
|
2904
|
-
fdpwr = min([discharge_limit, export_limit + discharge_timed[i]
|
2905
|
-
discharge_timed[i] = fdpwr * duration
|
2906
|
-
elif
|
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
|
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 =
|
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 +
|
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))}"
|
2980
|
-
|
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'] =
|
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 +=
|
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 +=
|
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(
|
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(
|
3709
|
-
self.yesterday = datetime.strftime(datetime.date(
|
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
|
-
|
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(
|
4051
|
-
self.yesterday = datetime.strftime(datetime.date(
|
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('-','')))
|