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