foxesscloud 2.5.7__py3-none-any.whl → 2.5.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foxesscloud/foxesscloud.py +130 -113
- foxesscloud/openapi.py +127 -112
- {foxesscloud-2.5.7.dist-info → foxesscloud-2.5.8.dist-info}/METADATA +11 -2
- foxesscloud-2.5.8.dist-info/RECORD +7 -0
- foxesscloud-2.5.7.dist-info/RECORD +0 -7
- {foxesscloud-2.5.7.dist-info → foxesscloud-2.5.8.dist-info}/LICENCE +0 -0
- {foxesscloud-2.5.7.dist-info → foxesscloud-2.5.8.dist-info}/WHEEL +0 -0
- {foxesscloud-2.5.7.dist-info → foxesscloud-2.5.8.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: 01 October 2024
|
5
5
|
By: Tony Matthews
|
6
6
|
"""
|
7
7
|
##################################################################################################
|
@@ -10,7 +10,7 @@ By: Tony Matthews
|
|
10
10
|
# ALL RIGHTS ARE RESERVED © Tony Matthews 2023
|
11
11
|
##################################################################################################
|
12
12
|
|
13
|
-
version = "1.
|
13
|
+
version = "1.7.0"
|
14
14
|
print(f"FoxESS-Cloud version {version}")
|
15
15
|
|
16
16
|
debug_setting = 1
|
@@ -83,16 +83,17 @@ def query_date(d, offset = None):
|
|
83
83
|
return {'year': t.year, 'month': t.month, 'day': t.day, 'hour': t.hour, 'minute': t.minute, 'second': t.second}
|
84
84
|
|
85
85
|
# interpolate a result from a list of values
|
86
|
-
def interpolate(f, v):
|
86
|
+
def interpolate(f, v, wrap=0):
|
87
87
|
if len(v) == 0:
|
88
88
|
return None
|
89
89
|
if f < 0.0:
|
90
90
|
return v[0]
|
91
|
-
elif f >= len(v) - 1:
|
91
|
+
elif wrap == 0 and f >= len(v) - 1:
|
92
92
|
return v[-1]
|
93
|
-
i = int(f)
|
94
|
-
x = f
|
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
|
@@ -648,12 +649,12 @@ def get_charge():
|
|
648
649
|
|
649
650
|
# helper to format time period structure
|
650
651
|
def time_period(t):
|
651
|
-
result = f"{t['startTime']['hour']:02d}:{t['startTime']['minute']:02d}
|
652
|
+
result = f"{t['startTime']['hour']:02d}:{t['startTime']['minute']:02d}-{t['endTime']['hour']:02d}:{t['endTime']['minute']:02d}"
|
652
653
|
if t['startTime']['hour'] != t['endTime']['hour'] or t['startTime']['minute'] != t['endTime']['minute']:
|
653
654
|
result += f" Charge from grid" if t['enableGrid'] else f" Force Charge"
|
654
655
|
return result
|
655
656
|
|
656
|
-
def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force=0):
|
657
|
+
def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force=0, enable=1):
|
657
658
|
global token, device_sn, battery_settings, debug_setting, messages, schedule
|
658
659
|
if get_device() is None:
|
659
660
|
return None
|
@@ -703,6 +704,8 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
|
|
703
704
|
output(f"\nSetting time periods:", 1)
|
704
705
|
output(f" Time Period 1 = {time_period(battery_settings['times'][0])}", 1)
|
705
706
|
output(f" Time Period 2 = {time_period(battery_settings['times'][1])}", 1)
|
707
|
+
if enable == 0:
|
708
|
+
return battery_settings
|
706
709
|
# set charge times
|
707
710
|
data = {'sn': device_sn, 'times': battery_settings.get('times')}
|
708
711
|
setting_delay()
|
@@ -721,35 +724,6 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
|
|
721
724
|
output(f"success", 2)
|
722
725
|
return battery_settings
|
723
726
|
|
724
|
-
def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, end_soc=100, start_soc=10):
|
725
|
-
output(f"\nConfiguring schedule",1)
|
726
|
-
charge = []
|
727
|
-
st1 = time_hours(st1)
|
728
|
-
en1 = time_hours(en1)
|
729
|
-
st2 = time_hours(st2)
|
730
|
-
en2 = time_hours(en2)
|
731
|
-
span = None
|
732
|
-
if st2 is not None and en2 is not None and st2 != en2:
|
733
|
-
charge.append({'start': st2, 'end': en2, 'mode': 'ForceCharge', 'min_soc': min_soc, 'max_soc': end_soc})
|
734
|
-
span = {'start': st2, 'end': en2}
|
735
|
-
if st1 is not None and en1 is not None and st1 != en1:
|
736
|
-
charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': start_soc})
|
737
|
-
span = {'start': st1, 'end': en2}
|
738
|
-
elif st1 is not None and en1 is not None and st1 != en1:
|
739
|
-
span = {'start': st1, 'end': en1}
|
740
|
-
if round_time(en1 - st1) > 0.25:
|
741
|
-
st3 = round_time(en1 - 5 / 60)
|
742
|
-
charge.append({'start': st1, 'end': st3, 'mode': 'SelfUse', 'min_soc': start_soc})
|
743
|
-
st1 = st3
|
744
|
-
charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': min_soc})
|
745
|
-
strategy = get_strategy(remove=span, 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
727
|
##################################################################################################
|
754
728
|
# get min soc settings and save in battery_settings
|
755
729
|
##################################################################################################
|
@@ -1270,7 +1244,7 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
|
|
1270
1244
|
return None
|
1271
1245
|
if quiet == 0:
|
1272
1246
|
s = f" {hours_time(start)}-{hours_time(end)} {mode}, minsoc {min_soc}%"
|
1273
|
-
s += f", maxsoc {max_soc}%" if max_soc is not None else ""
|
1247
|
+
s += f", maxsoc {max_soc}%" if max_soc is not None and mode == 'ForceCharge' else ""
|
1274
1248
|
s += f", fdPwr {fdpwr}W, fdSoC {fdsoc}%" if mode == 'ForceDischarge' else ""
|
1275
1249
|
s += f", {price:.2f}p/kWh" if price is not None else ""
|
1276
1250
|
output(s, 1)
|
@@ -2122,7 +2096,7 @@ def hours_difference(t1, t2):
|
|
2122
2096
|
# time periods for Octopus Flux
|
2123
2097
|
octopus_flux = {
|
2124
2098
|
'name': 'Octopus Flux',
|
2125
|
-
'off_peak1': {'start': 2.0, 'end': 5.0, '
|
2099
|
+
'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1}, # off-peak period 1 / am charging period
|
2126
2100
|
'peak1': {'start': 16.0, 'end': 19.0 }, # peak period 1
|
2127
2101
|
'forecast_times': [21, 22], # hours in a day to get a forecast
|
2128
2102
|
'strategy': [
|
@@ -2133,16 +2107,16 @@ octopus_flux = {
|
|
2133
2107
|
# time periods for Intelligent Octopus
|
2134
2108
|
intelligent_octopus = {
|
2135
2109
|
'name': 'Intelligent Octopus',
|
2136
|
-
'off_peak1': {'start': 23.5, 'end': 5.5, '
|
2110
|
+
'off_peak1': {'start': 23.5, 'end': 5.5, 'hold': 1},
|
2137
2111
|
'forecast_times': [21, 22]
|
2138
2112
|
}
|
2139
2113
|
|
2140
2114
|
# time periods for Octopus Cosy
|
2141
2115
|
octopus_cosy = {
|
2142
2116
|
'name': 'Octopus Cosy',
|
2143
|
-
'off_peak1': {'start': 4.0, 'end': 7.0, '
|
2144
|
-
'off_peak2': {'start': 13.0, 'end': 16.0, '
|
2145
|
-
'off_peak3': {'start': 22.0, 'end': 24.0, '
|
2117
|
+
'off_peak1': {'start': 4.0, 'end': 7.0, 'hold': 1},
|
2118
|
+
'off_peak2': {'start': 13.0, 'end': 16.0, 'hold': 0},
|
2119
|
+
'off_peak3': {'start': 22.0, 'end': 24.0, 'hold': 0},
|
2146
2120
|
'peak1': {'start': 16.0, 'end': 19.0 },
|
2147
2121
|
'forecast_times': [10, 11, 21, 22]
|
2148
2122
|
}
|
@@ -2150,15 +2124,15 @@ octopus_cosy = {
|
|
2150
2124
|
# time periods for Octopus Go
|
2151
2125
|
octopus_go = {
|
2152
2126
|
'name': 'Octopus Go',
|
2153
|
-
'off_peak1': {'start': 0.5, 'end': 4.5, '
|
2127
|
+
'off_peak1': {'start': 0.5, 'end': 4.5, 'hold': 1},
|
2154
2128
|
'forecast_times': [21, 22]
|
2155
2129
|
}
|
2156
2130
|
|
2157
2131
|
# time periods for Agile Octopus
|
2158
2132
|
agile_octopus = {
|
2159
2133
|
'name': 'Agile Octopus',
|
2160
|
-
'off_peak1': {'start': 0.0, 'end': 6.0, '
|
2161
|
-
'off_peak2': {'start': 12.0, 'end': 16.0, '
|
2134
|
+
'off_peak1': {'start': 0.0, 'end': 6.0, 'hold': 1},
|
2135
|
+
'off_peak2': {'start': 12.0, 'end': 16.0, 'hold': 0},
|
2162
2136
|
'peak1': {'start': 16.0, 'end': 19.0 },
|
2163
2137
|
'forecast_times': [9, 10, 21, 22],
|
2164
2138
|
'strategy': [],
|
@@ -2168,27 +2142,27 @@ agile_octopus = {
|
|
2168
2142
|
# time periods for British Gas Electric Driver
|
2169
2143
|
bg_driver = {
|
2170
2144
|
'name': 'British Gas Electric Driver',
|
2171
|
-
'off_peak1': {'start': 0.0, 'end': 5.0, '
|
2145
|
+
'off_peak1': {'start': 0.0, 'end': 5.0, 'hold': 1},
|
2172
2146
|
'forecast_times': [21, 22]
|
2173
2147
|
}
|
2174
2148
|
|
2175
2149
|
# time periods for EON Next Drive
|
2176
2150
|
eon_drive = {
|
2177
2151
|
'name': 'EON NextDrive',
|
2178
|
-
'off_peak1': {'start': 0.0, 'end': 7.0, '
|
2152
|
+
'off_peak1': {'start': 0.0, 'end': 7.0, 'hold': 1},
|
2179
2153
|
'forecast_times': [21, 22]
|
2180
2154
|
}
|
2181
2155
|
|
2182
2156
|
# time periods for Economy 7
|
2183
2157
|
economy_7 = {
|
2184
2158
|
'name': 'Eco 7',
|
2185
|
-
'off_peak1': {'start': 0.5, 'end': 7.5, '
|
2159
|
+
'off_peak1': {'start': 0.5, 'end': 7.5, 'hold': 1, 'gmt': 1},
|
2186
2160
|
'forecast_times': [21, 22]
|
2187
2161
|
}
|
2188
2162
|
|
2189
2163
|
# custom time periods / template
|
2190
2164
|
custom_periods = {'name': 'Custom',
|
2191
|
-
'off_peak1': {'start': 2.0, 'end': 5.0, '
|
2165
|
+
'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1},
|
2192
2166
|
'peak1': {'start': 16.0, 'end': 19.0 },
|
2193
2167
|
'forecast_times': [21, 22]
|
2194
2168
|
}
|
@@ -2208,8 +2182,10 @@ test_strategy = [
|
|
2208
2182
|
{'start': 21, 'end': 22, 'mode': 'ForceCharge'}]
|
2209
2183
|
|
2210
2184
|
# return a strategy that has been sorted and filtered for charge times:
|
2211
|
-
def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=
|
2185
|
+
def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=24, timed_mode=1):
|
2212
2186
|
global tariff, base_time
|
2187
|
+
if timed_mode == 0:
|
2188
|
+
return []
|
2213
2189
|
if use is None:
|
2214
2190
|
use = tariff
|
2215
2191
|
base_time_adjust = 0
|
@@ -2218,11 +2194,12 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit
|
|
2218
2194
|
if tariff.get('strategy') is not None:
|
2219
2195
|
for s in tariff['strategy']:
|
2220
2196
|
strategy.append(s)
|
2221
|
-
if use.get('agile') is not None and use['agile'].get('strategy') is not None:
|
2197
|
+
if timed_mode > 1 and use.get('agile') is not None and use['agile'].get('strategy') is not None:
|
2222
2198
|
base_time_adjust = hours_difference(base_time, use['agile'].get('base_time') )
|
2223
2199
|
for s in use['agile']['strategy']:
|
2224
|
-
if limit is None
|
2225
|
-
|
2200
|
+
hour = (s['hour'] - base_time_adjust) if limit is not None and s.get('hour') is not None else None
|
2201
|
+
if hour is None or (hour >= 0 and hour < limit):
|
2202
|
+
s['valid_for'] = [hour * steps_per_hour + i for i in range(0, steps_per_hour // 2)] if hour is not None else None
|
2226
2203
|
strategy.append(s)
|
2227
2204
|
if strategy is None or len(strategy) == 0:
|
2228
2205
|
return []
|
@@ -2271,8 +2248,8 @@ tariff_config = {
|
|
2271
2248
|
'region': "H", # region code to use for Octopus API
|
2272
2249
|
'update_time': 16.5, # time in hours when tomrow's data can be fetched
|
2273
2250
|
'weighting': None, # weights for weighted average
|
2274
|
-
'plunge_price': [3,
|
2275
|
-
'plunge_slots':
|
2251
|
+
'plunge_price': [3, 3], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
|
2252
|
+
'plunge_slots': 6, # number of 30 minute slots to use
|
2276
2253
|
'data_wrap': 6, # prices to show per line
|
2277
2254
|
'show_data': 1, # show pricing data
|
2278
2255
|
'show_plot': 1 # plot pricing data
|
@@ -2363,7 +2340,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2363
2340
|
col = (now.hour * 2) % data_wrap
|
2364
2341
|
s = f"\nPrice p/kWh inc VAT on {today}:"
|
2365
2342
|
for i in range(0, len(prices)):
|
2366
|
-
s +=
|
2343
|
+
s += f"\n {prices[i]['time']}" if i == 0 or col == 0 else ""
|
2367
2344
|
s += f" {prices[i]['price']:4.1f}"
|
2368
2345
|
col = (col + 1) % data_wrap
|
2369
2346
|
output(s)
|
@@ -2481,7 +2458,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
|
|
2481
2458
|
use[key]['start'] = time_hours(t[1])
|
2482
2459
|
use[key]['end'] = time_hours(t[2])
|
2483
2460
|
if len(t) > 3:
|
2484
|
-
use[key]['
|
2461
|
+
use[key]['hold'] = t[3]
|
2485
2462
|
gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
|
2486
2463
|
output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
|
2487
2464
|
# update dynamic charge times
|
@@ -2556,10 +2533,11 @@ def timed_list(data, base_hour, run_time):
|
|
2556
2533
|
result = []
|
2557
2534
|
h = base_hour
|
2558
2535
|
for t in range(0, run_time):
|
2559
|
-
result.append(
|
2536
|
+
result.append(interpolate(h, data, wrap=1))
|
2560
2537
|
h = round_time(h + 1 / steps_per_hour)
|
2561
2538
|
return result
|
2562
2539
|
|
2540
|
+
# align forecast with base_hour and expand to cover run_time
|
2563
2541
|
def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
|
2564
2542
|
global steps_per_hour
|
2565
2543
|
profile = []
|
@@ -2588,10 +2566,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
|
|
2588
2566
|
min_soc_now = min_soc
|
2589
2567
|
max_soc_now = max_soc
|
2590
2568
|
current_mode = 'SelfUse' if current_mode is None else current_mode
|
2591
|
-
strategy = get_strategy()
|
2569
|
+
strategy = get_strategy(timed_mode=timed_mode)
|
2592
2570
|
h = base_hour
|
2593
2571
|
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,
|
2572
|
+
period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0, 'charge': 0.0,
|
2573
|
+
'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
|
2595
2574
|
if strategy is not None:
|
2596
2575
|
period['mode'] = 'SelfUse'
|
2597
2576
|
for d in strategy:
|
@@ -2624,6 +2603,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
|
|
2624
2603
|
for i in range(0, len(work_mode_timed)):
|
2625
2604
|
bat_timed.append(kwh_current)
|
2626
2605
|
w = work_mode_timed[i]
|
2606
|
+
w['kwh'] = kwh_current
|
2627
2607
|
max_now = w['max_soc'] * capacity / 100
|
2628
2608
|
if kwh_current < max_now and w['charge'] > 0.0:
|
2629
2609
|
kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
|
@@ -2651,6 +2631,40 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
|
|
2651
2631
|
kwh_min = kwh_current
|
2652
2632
|
return (bat_timed, kwh_min)
|
2653
2633
|
|
2634
|
+
def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
|
2635
|
+
global steps_per_hour
|
2636
|
+
output(f"\nConfiguring schedule:",1)
|
2637
|
+
strategy = []
|
2638
|
+
start = base_hour
|
2639
|
+
periods = []
|
2640
|
+
for t in range(0, min([24 * steps_per_hour, len(work_mode_timed)])):
|
2641
|
+
period = periods[0] if len(periods) > 0 else work_mode_timed[0]
|
2642
|
+
next_period = work_mode_timed[t]
|
2643
|
+
h = base_hour + t / steps_per_hour
|
2644
|
+
if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
|
2645
|
+
s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
|
2646
|
+
if period['mode'] == 'ForceDischarge':
|
2647
|
+
s['fdsoc'] = period.get('fdsoc')
|
2648
|
+
s['fdpwr'] = period.get('fdpwr')
|
2649
|
+
elif period['mode'] == 'ForceCharge':
|
2650
|
+
s['max_soc'] = period.get('max_soc')
|
2651
|
+
elif period['mode'] == 'SelfUse' and period['hold'] == 1:
|
2652
|
+
s['min_soc'] = min([int(period['kwh'] / capacity * 100 + 0.5), 100])
|
2653
|
+
for p in periods:
|
2654
|
+
p['min_soc'] = s['min_soc']
|
2655
|
+
if s['mode'] != 'SelfUse' or s['min_soc'] != min_soc:
|
2656
|
+
strategy.append(s)
|
2657
|
+
start = h
|
2658
|
+
periods = []
|
2659
|
+
periods.append(work_mode_timed[t])
|
2660
|
+
if len(strategy) > 0 and strategy[-1]['min_soc'] != min_soc:
|
2661
|
+
strategy.append({'start': start %24, 'end': (start + 1 / steps_per_hour) % 24, 'mode': 'SelfUse', 'min_soc': min_soc})
|
2662
|
+
periods = []
|
2663
|
+
for s in strategy:
|
2664
|
+
periods.append(set_period(segment = s, quiet=0))
|
2665
|
+
return periods
|
2666
|
+
|
2667
|
+
|
2654
2668
|
# Battery open circuit voltage (OCV) from 0% to 100% SoC
|
2655
2669
|
# 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
|
2656
2670
|
lifepo4_curve = [51.00, 51.50, 52.00, 52.30, 52.60, 52.80, 52.90, 53.00, 53.10, 53.30, 54.00]
|
@@ -2713,7 +2727,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2713
2727
|
# show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
|
2714
2728
|
# run_after: 0 over-rides 'forecast_times'. The default is 1.
|
2715
2729
|
# forecast_times: list of hours when forecast can be fetched (UTC)
|
2716
|
-
# force_charge: 1 =
|
2730
|
+
# force_charge: 1 = hold battery, 2 = charge for whole period
|
2717
2731
|
|
2718
2732
|
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2719
2733
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
@@ -2771,10 +2785,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2771
2785
|
if tariff is not None and tariff.get(k) is not None:
|
2772
2786
|
start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2773
2787
|
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, '
|
2788
|
+
hold = 0 if tariff[k].get('hold') is not None and tariff[k]['hold'] == 0 else force_charge
|
2789
|
+
times.append({'key': k, 'start': start, 'end': end, 'hold': hold})
|
2776
2790
|
if len(times) == 0:
|
2777
|
-
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), '
|
2791
|
+
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'hold': force_charge})
|
2778
2792
|
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2779
2793
|
time_to_end1 = None
|
2780
2794
|
for t in times:
|
@@ -2804,7 +2818,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2804
2818
|
run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
|
2805
2819
|
run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
|
2806
2820
|
time_line = [round_time(base_hour + x / steps_per_hour - (hour_adjustment if x >= time_change else 0)) for x in range(0, run_time)]
|
2807
|
-
|
2821
|
+
bat_hold = times[0]['hold']
|
2808
2822
|
# if we need to do a full charge, full_charge is the date, otherwise None
|
2809
2823
|
full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
|
2810
2824
|
if type(full_charge) is int: # value = day of month
|
@@ -2814,7 +2828,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2814
2828
|
if debug_setting > 2:
|
2815
2829
|
output(f"\ntoday = {today}, tomorrow = {tomorrow}, time_shift = {time_shift}")
|
2816
2830
|
output(f"times = {times}")
|
2817
|
-
output(f"start_at = {start_at}, end_by = {end_by},
|
2831
|
+
output(f"start_at = {start_at}, end_by = {end_by}, bat_hold = {bat_hold}")
|
2818
2832
|
output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
|
2819
2833
|
output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
|
2820
2834
|
output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
|
@@ -2842,7 +2856,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2842
2856
|
model = device.get('deviceType')
|
2843
2857
|
else:
|
2844
2858
|
current_soc = test_soc
|
2845
|
-
capacity = 14.
|
2859
|
+
capacity = 14.54
|
2846
2860
|
residual = test_soc * capacity / 100
|
2847
2861
|
bat_volt = 315.4
|
2848
2862
|
bat_power = 0.0
|
@@ -2887,7 +2901,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2887
2901
|
output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
|
2888
2902
|
charge_current = derated_current
|
2889
2903
|
else:
|
2890
|
-
|
2904
|
+
bat_hold = 2
|
2891
2905
|
output(f" Full charge set")
|
2892
2906
|
# inverter losses
|
2893
2907
|
inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
|
@@ -2960,16 +2974,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2960
2974
|
solcast_value = None
|
2961
2975
|
solcast_profile = None
|
2962
2976
|
if forecast is None and solcast_api_key is not None and solcast_api_key != 'my.solcast_api_key' and (system_time.hour in forecast_times or run_after == 0):
|
2963
|
-
fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'))
|
2977
|
+
fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'), d=base_time)
|
2964
2978
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2965
2979
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2966
2980
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
2967
2981
|
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")
|
2982
|
+
output(f"\nSolcast: {tomorrow} {fsolcast.daily[tomorrow]['kwh']:.1f}kWh")
|
2983
|
+
# get forecast.solar data and produce time line
|
2969
2984
|
solar_value = None
|
2970
2985
|
solar_profile = None
|
2971
2986
|
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'))
|
2987
|
+
fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
|
2973
2988
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2974
2989
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2975
2990
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
@@ -3007,6 +3022,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3007
3022
|
if forecast is not None:
|
3008
3023
|
expected = forecast
|
3009
3024
|
generation_timed = [expected * x / sun_sum for x in sun_timed]
|
3025
|
+
output(f"\nForecast: {forecast:.1f}kWh")
|
3010
3026
|
elif solcast_value is not None:
|
3011
3027
|
expected = solcast_value
|
3012
3028
|
generation_timed = solcast_timed
|
@@ -3039,8 +3055,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3039
3055
|
fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
|
3040
3056
|
fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
|
3041
3057
|
discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
|
3042
|
-
elif
|
3058
|
+
elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
|
3043
3059
|
discharge_timed[i] = bms_loss
|
3060
|
+
if timed_mode > 1:
|
3061
|
+
work_mode_timed[i]['hold'] = 1
|
3044
3062
|
elif timed_mode > 0 and work_mode == 'Backup':
|
3045
3063
|
discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
|
3046
3064
|
elif timed_mode > 0 and work_mode == 'Feedin':
|
@@ -3063,7 +3081,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3063
3081
|
start_soc = int(start_residual / capacity * 100 + 0.5)
|
3064
3082
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
|
3065
3083
|
target_soc = charge_config.get('target_soc')
|
3066
|
-
target_kwh = capacity if full_charge is not None or
|
3084
|
+
target_kwh = capacity if full_charge is not None or bat_hold == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
|
3067
3085
|
if target_kwh > (end_residual + kwh_needed):
|
3068
3086
|
kwh_needed = target_kwh - end_residual
|
3069
3087
|
elif test_charge is not None:
|
@@ -3080,13 +3098,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3080
3098
|
start_timed = time_to_end
|
3081
3099
|
end_timed = time_to_end
|
3082
3100
|
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
3101
|
else:
|
3088
3102
|
if test_charge is None:
|
3089
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh
|
3103
|
+
output(f"\nCharge needed: {kwh_needed:.2f}kWh")
|
3090
3104
|
charge_message = "with charge added"
|
3091
3105
|
output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
|
3092
3106
|
# work out time to add kwh_needed to battery
|
@@ -3100,20 +3114,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3100
3114
|
kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
|
3101
3115
|
required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # time to recover energy not added
|
3102
3116
|
hours = required if required > hours and required < charge_time else charge_time
|
3103
|
-
# round charge time
|
3117
|
+
# round charge time and work out what will actually be added
|
3104
3118
|
min_hours = charge_config['min_hours']
|
3105
3119
|
hours = int(hours / min_hours + 0.99) * min_hours
|
3120
|
+
kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
|
3106
3121
|
# rework charge and discharge
|
3107
3122
|
charge_period = get_best_charge_period(start_at, hours)
|
3108
3123
|
charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
|
3109
3124
|
price = charge_period.get('price') if charge_period is not None else None
|
3110
3125
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
3111
|
-
end_timed =
|
3126
|
+
end_timed = start_timed + hours * steps_per_hour
|
3112
3127
|
start_residual = interpolate(start_timed, bat_timed)
|
3113
|
-
end_soc = min([int((start_residual +
|
3128
|
+
end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
|
3114
3129
|
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
|
-
|
3130
|
+
output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
|
3131
|
+
+ (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh)")
|
3132
|
+
for i in range(int(time_to_start), int(time_to_end)):
|
3117
3133
|
j = i + 1
|
3118
3134
|
# work out time (fraction of hour) when charging in hour from i to j
|
3119
3135
|
if start_timed >= i and end_timed < j:
|
@@ -3127,12 +3143,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3127
3143
|
else:
|
3128
3144
|
t = 0.0 # complete hour before start or after end
|
3129
3145
|
output(f"i = {i}, j = {j}, t = {t}", 3)
|
3130
|
-
if i >= start_timed:
|
3146
|
+
if i >= start_timed and i < end_timed:
|
3147
|
+
work_mode_timed[i]['mode'] = 'ForceCharge'
|
3131
3148
|
work_mode_timed[i]['charge'] = charge_power * t
|
3132
|
-
work_mode_timed[i]['max_soc'] =
|
3149
|
+
work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else 100
|
3133
3150
|
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
3151
|
# rebuild the battery residual with the charge added and min_soc
|
3137
3152
|
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3138
3153
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
@@ -3141,6 +3156,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3141
3156
|
output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
|
3142
3157
|
if not charge_today:
|
3143
3158
|
output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
|
3159
|
+
# setup charging
|
3160
|
+
if timed_mode > 1:
|
3161
|
+
periods = charge_periods(work_mode_timed, base_hour, min_soc, capacity)
|
3162
|
+
if update_settings > 0:
|
3163
|
+
set_schedule(periods = periods)
|
3164
|
+
else:
|
3165
|
+
# work out the charge times and set. First period is battery hold, second period is battery charge / hold
|
3166
|
+
start1 = round_time(base_hour + time_to_start / steps_per_hour)
|
3167
|
+
start2 = round_time(base_hour + start_timed / steps_per_hour)
|
3168
|
+
end1 = start1 if bat_hold == 0 else start2
|
3169
|
+
end2 = round_time(base_hour + (end_timed if bat_hold == 0 else time_to_end) / steps_per_hour)
|
3170
|
+
set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
|
3171
|
+
if update_settings == 0:
|
3172
|
+
output(f"\nNo changes made to charge settings")
|
3144
3173
|
if show_data > 0:
|
3145
3174
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3146
3175
|
s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
|
@@ -3148,7 +3177,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3148
3177
|
t = 0
|
3149
3178
|
while t < len(time_line) and bat_timed[t] is not None:
|
3150
3179
|
col = h % data_wrap
|
3151
|
-
s +=
|
3180
|
+
s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
|
3152
3181
|
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
|
3153
3182
|
h += 1
|
3154
3183
|
t += steps_per_hour
|
@@ -3196,20 +3225,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3196
3225
|
file = open(storage + file_name, 'w')
|
3197
3226
|
json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3198
3227
|
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
3228
|
output_close(plot=show_plot)
|
3214
3229
|
return None
|
3215
3230
|
|
@@ -3285,7 +3300,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3285
3300
|
t = 0
|
3286
3301
|
while t < len(time_line) and bat_timed[t] is not None:
|
3287
3302
|
col = h % data_wrap
|
3288
|
-
s +=
|
3303
|
+
s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
|
3289
3304
|
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
|
3290
3305
|
h += 1
|
3291
3306
|
t += steps_per_hour
|
@@ -3833,18 +3848,19 @@ class Solcast :
|
|
3833
3848
|
Load Solcast Estimate / Actuals / Forecast daily yield
|
3834
3849
|
"""
|
3835
3850
|
|
3836
|
-
def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None) :
|
3851
|
+
def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None, d=None) :
|
3837
3852
|
# days sets the number of days to get for forecasts (and estimated if enabled)
|
3838
3853
|
# reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
|
3839
3854
|
# The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
|
3840
3855
|
# The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
|
3841
3856
|
global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
|
3842
3857
|
self.data = {}
|
3858
|
+
now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
|
3843
3859
|
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(
|
3860
|
+
self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
|
3845
3861
|
self.quarter = int(self.today[5:7]) // 3 % 4
|
3846
|
-
self.tomorrow = datetime.strftime(datetime.date(
|
3847
|
-
self.yesterday = datetime.strftime(datetime.date(
|
3862
|
+
self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
|
3863
|
+
self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
|
3848
3864
|
self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
|
3849
3865
|
if reload == 1 and os.path.exists(storage + self.save):
|
3850
3866
|
os.remove(storage + self.save)
|
@@ -4180,13 +4196,14 @@ class Solar :
|
|
4180
4196
|
"""
|
4181
4197
|
|
4182
4198
|
# get solar forecast and return total expected yield
|
4183
|
-
def __init__(self, reload=0, quiet=False, shading=None):
|
4199
|
+
def __init__(self, reload=0, quiet=False, shading=None, d=None):
|
4184
4200
|
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
|
4185
4201
|
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4186
|
-
|
4202
|
+
now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
|
4203
|
+
self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
|
4187
4204
|
self.quarter = int(self.today[5:7]) // 3 % 4
|
4188
|
-
self.tomorrow = datetime.strftime(datetime.date(
|
4189
|
-
self.yesterday = datetime.strftime(datetime.date(
|
4205
|
+
self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
|
4206
|
+
self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
|
4190
4207
|
self.arrays = None
|
4191
4208
|
self.results = None
|
4192
4209
|
self.save = solar_save #.replace('.', '_%.'.replace('%',self.today.replace('-','')))
|
foxesscloud/openapi.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
##################################################################################################
|
2
2
|
"""
|
3
3
|
Module: Fox ESS Cloud using Open API
|
4
|
-
Updated:
|
4
|
+
Updated: 01 October 2024
|
5
5
|
By: Tony Matthews
|
6
6
|
"""
|
7
7
|
##################################################################################################
|
@@ -10,7 +10,7 @@ By: Tony Matthews
|
|
10
10
|
# ALL RIGHTS ARE RESERVED © Tony Matthews 2024
|
11
11
|
##################################################################################################
|
12
12
|
|
13
|
-
version = "2.5.
|
13
|
+
version = "2.5.8"
|
14
14
|
print(f"FoxESS-Cloud Open API version {version}")
|
15
15
|
|
16
16
|
debug_setting = 1
|
@@ -106,16 +106,17 @@ def query_time(d, time_span):
|
|
106
106
|
return (t_begin * 1000, t_end * 1000)
|
107
107
|
|
108
108
|
# interpolate a result from a list of values
|
109
|
-
def interpolate(f, v):
|
109
|
+
def interpolate(f, v, wrap=0):
|
110
110
|
if len(v) == 0:
|
111
111
|
return None
|
112
112
|
if f < 0.0:
|
113
113
|
return v[0]
|
114
|
-
elif f >= len(v) - 1:
|
114
|
+
elif wrap == 0 and f >= len(v) - 1:
|
115
115
|
return v[-1]
|
116
|
-
i = int(f)
|
117
|
-
x = f
|
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):
|
@@ -594,12 +595,12 @@ def get_charge():
|
|
594
595
|
# helper to format time period structure
|
595
596
|
def time_period(t, n):
|
596
597
|
(enable, start, end) = (t['enable1'], t['startTime1'], t['endTime1']) if n == 1 else (t['enable2'], t['startTime2'], t['endTime2'])
|
597
|
-
result = f"{start['hour']:02d}:{start['minute']:02d}
|
598
|
+
result = f"{start['hour']:02d}:{start['minute']:02d}-{end['hour']:02d}:{end['minute']:02d}"
|
598
599
|
if start['hour'] != end['hour'] or start['minute'] != end['minute']:
|
599
600
|
result += f" Charge from grid" if enable else f" Force Charge"
|
600
601
|
return result
|
601
602
|
|
602
|
-
def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force = 0):
|
603
|
+
def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force = 0, enable=1):
|
603
604
|
global token, device_sn, battery_settings, debug_setting, time_period_vars
|
604
605
|
if get_device() is None:
|
605
606
|
return None
|
@@ -649,6 +650,8 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
|
|
649
650
|
output(f"\nSetting time periods:", 1)
|
650
651
|
output(f" Time Period 1 = {time_period(battery_settings['times'], 1)}", 1)
|
651
652
|
output(f" Time Period 2 = {time_period(battery_settings['times'], 2)}", 1)
|
653
|
+
if enable == 0:
|
654
|
+
return battery_settings
|
652
655
|
# set charge times
|
653
656
|
body = {'sn': device_sn}
|
654
657
|
for k in ['enable1', 'startTime1', 'endTime1', 'enable2', 'startTime2', 'endTime2']:
|
@@ -669,36 +672,6 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
|
|
669
672
|
output(f"success", 2)
|
670
673
|
return battery_settings
|
671
674
|
|
672
|
-
def charge_periods(st1=None, en1=None, st2=None, en2=None, min_soc=10, end_soc=100, start_soc=10):
|
673
|
-
output(f"\nConfiguring schedule",1)
|
674
|
-
charge = []
|
675
|
-
st1 = time_hours(st1)
|
676
|
-
en1 = time_hours(en1)
|
677
|
-
st2 = time_hours(st2)
|
678
|
-
en2 = time_hours(en2)
|
679
|
-
span = None
|
680
|
-
if st2 is not None and en2 is not None and st2 != en2:
|
681
|
-
charge.append({'start': st2, 'end': en2, 'mode': 'ForceCharge', 'min_soc': min_soc, 'max_soc': end_soc})
|
682
|
-
span = {'start': st2, 'end': en2}
|
683
|
-
if st1 is not None and en1 is not None and st1 != en1:
|
684
|
-
charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': start_soc})
|
685
|
-
span = {'start': st1, 'end': en2}
|
686
|
-
elif st1 is not None and en1 is not None and st1 != en1:
|
687
|
-
span = {'start': st1, 'end': en1}
|
688
|
-
if round_time(en1 - st1) > 0.25:
|
689
|
-
st3 = round_time(en1 - 5 / 60)
|
690
|
-
charge.append({'start': st1, 'end': st3, 'mode': 'SelfUse', 'min_soc': start_soc})
|
691
|
-
st1 = st3
|
692
|
-
charge.append({'start': st1, 'end': en1, 'mode': 'SelfUse', 'min_soc': min_soc})
|
693
|
-
strategy = get_strategy(remove=span, 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
675
|
##################################################################################################
|
703
676
|
# get min soc settings and save in battery_settings
|
704
677
|
##################################################################################################
|
@@ -1147,7 +1120,7 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
|
|
1147
1120
|
return None
|
1148
1121
|
if quiet == 0:
|
1149
1122
|
s = f" {hours_time(start)}-{hours_time(end)} {mode}, minsoc {min_soc}%"
|
1150
|
-
s += f", maxsoc {max_soc}%" if max_soc is not None else ""
|
1123
|
+
s += f", maxsoc {max_soc}%" if max_soc is not None and mode == 'ForceCharge' else ""
|
1151
1124
|
s += f", fdPwr {fdpwr}W, fdSoC {fdsoc}%" if mode == 'ForceDischarge' else ""
|
1152
1125
|
s += f", {price:.2f}p/kWh" if price is not None else ""
|
1153
1126
|
output(s, 1)
|
@@ -1986,7 +1959,7 @@ def hours_difference(t1, t2):
|
|
1986
1959
|
# time periods for Octopus Flux
|
1987
1960
|
octopus_flux = {
|
1988
1961
|
'name': 'Octopus Flux',
|
1989
|
-
'off_peak1': {'start': 2.0, 'end': 5.0, '
|
1962
|
+
'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1}, # off-peak period 1 / am charging period
|
1990
1963
|
'peak1': {'start': 16.0, 'end': 19.0 }, # peak period 1
|
1991
1964
|
'forecast_times': [21, 22], # hours in a day to get a forecast
|
1992
1965
|
'strategy': [
|
@@ -1997,16 +1970,16 @@ octopus_flux = {
|
|
1997
1970
|
# time periods for Intelligent Octopus
|
1998
1971
|
intelligent_octopus = {
|
1999
1972
|
'name': 'Intelligent Octopus',
|
2000
|
-
'off_peak1': {'start': 23.5, 'end': 5.5, '
|
1973
|
+
'off_peak1': {'start': 23.5, 'end': 5.5, 'hold': 1},
|
2001
1974
|
'forecast_times': [21, 22]
|
2002
1975
|
}
|
2003
1976
|
|
2004
1977
|
# time periods for Octopus Cosy
|
2005
1978
|
octopus_cosy = {
|
2006
1979
|
'name': 'Octopus Cosy',
|
2007
|
-
'off_peak1': {'start': 4.0, 'end': 7.0, '
|
2008
|
-
'off_peak2': {'start': 13.0, 'end': 16.0, '
|
2009
|
-
'off_peak3': {'start': 22.0, 'end': 24.0, '
|
1980
|
+
'off_peak1': {'start': 4.0, 'end': 7.0, 'hold': 1},
|
1981
|
+
'off_peak2': {'start': 13.0, 'end': 16.0, 'hold': 0},
|
1982
|
+
'off_peak3': {'start': 22.0, 'end': 24.0, 'hold': 0},
|
2010
1983
|
'peak1': {'start': 16.0, 'end': 19.0 },
|
2011
1984
|
'forecast_times': [10, 11, 21, 22]
|
2012
1985
|
}
|
@@ -2014,15 +1987,15 @@ octopus_cosy = {
|
|
2014
1987
|
# time periods for Octopus Go
|
2015
1988
|
octopus_go = {
|
2016
1989
|
'name': 'Octopus Go',
|
2017
|
-
'off_peak1': {'start': 0.5, 'end': 4.5, '
|
1990
|
+
'off_peak1': {'start': 0.5, 'end': 4.5, 'hold': 1},
|
2018
1991
|
'forecast_times': [21, 22]
|
2019
1992
|
}
|
2020
1993
|
|
2021
1994
|
# time periods for Agile Octopus
|
2022
1995
|
agile_octopus = {
|
2023
1996
|
'name': 'Agile Octopus',
|
2024
|
-
'off_peak1': {'start': 0.0, 'end': 6.0, '
|
2025
|
-
'off_peak2': {'start': 12.0, 'end': 16.0, '
|
1997
|
+
'off_peak1': {'start': 0.0, 'end': 6.0, 'hold': 1},
|
1998
|
+
'off_peak2': {'start': 12.0, 'end': 16.0, 'hold': 0},
|
2026
1999
|
'peak1': {'start': 16.0, 'end': 19.0 },
|
2027
2000
|
'forecast_times': [9, 10, 21, 22],
|
2028
2001
|
'strategy': [],
|
@@ -2032,27 +2005,27 @@ agile_octopus = {
|
|
2032
2005
|
# time periods for British Gas Electric Driver
|
2033
2006
|
bg_driver = {
|
2034
2007
|
'name': 'British Gas Electric Driver',
|
2035
|
-
'off_peak1': {'start': 0.0, 'end': 5.0, '
|
2008
|
+
'off_peak1': {'start': 0.0, 'end': 5.0, 'hold': 1},
|
2036
2009
|
'forecast_times': [21, 22]
|
2037
2010
|
}
|
2038
2011
|
|
2039
2012
|
# time periods for EON Next Drive
|
2040
2013
|
eon_drive = {
|
2041
2014
|
'name': 'EON NextDrive',
|
2042
|
-
'off_peak1': {'start': 0.0, 'end': 7.0, '
|
2015
|
+
'off_peak1': {'start': 0.0, 'end': 7.0, 'hold': 1},
|
2043
2016
|
'forecast_times': [21, 22]
|
2044
2017
|
}
|
2045
2018
|
|
2046
2019
|
# time periods for Economy 7
|
2047
2020
|
economy_7 = {
|
2048
2021
|
'name': 'Eco 7',
|
2049
|
-
'off_peak1': {'start': 0.5, 'end': 7.5, '
|
2022
|
+
'off_peak1': {'start': 0.5, 'end': 7.5, 'hold': 1, 'gmt': 1},
|
2050
2023
|
'forecast_times': [21, 22]
|
2051
2024
|
}
|
2052
2025
|
|
2053
2026
|
# custom time periods / template
|
2054
2027
|
custom_periods = {'name': 'Custom',
|
2055
|
-
'off_peak1': {'start': 2.0, 'end': 5.0, '
|
2028
|
+
'off_peak1': {'start': 2.0, 'end': 5.0, 'hold': 1},
|
2056
2029
|
'peak1': {'start': 16.0, 'end': 19.0 },
|
2057
2030
|
'forecast_times': [21, 22]
|
2058
2031
|
}
|
@@ -2072,8 +2045,10 @@ test_strategy = [
|
|
2072
2045
|
{'start': 21, 'end': 22, 'mode': 'ForceCharge'}]
|
2073
2046
|
|
2074
2047
|
# return a strategy that has been sorted and filtered for charge times:
|
2075
|
-
def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=
|
2048
|
+
def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit=24, timed_mode=1):
|
2076
2049
|
global tariff, base_time
|
2050
|
+
if timed_mode == 0:
|
2051
|
+
return []
|
2077
2052
|
if use is None:
|
2078
2053
|
use = tariff
|
2079
2054
|
base_time_adjust = 0
|
@@ -2082,11 +2057,12 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0, limit
|
|
2082
2057
|
if tariff.get('strategy') is not None:
|
2083
2058
|
for s in tariff['strategy']:
|
2084
2059
|
strategy.append(s)
|
2085
|
-
if use.get('agile') is not None and use['agile'].get('strategy') is not None:
|
2060
|
+
if timed_mode > 1 and use.get('agile') is not None and use['agile'].get('strategy') is not None:
|
2086
2061
|
base_time_adjust = hours_difference(base_time, use['agile'].get('base_time') )
|
2087
2062
|
for s in use['agile']['strategy']:
|
2088
|
-
if limit is None
|
2089
|
-
|
2063
|
+
hour = (s['hour'] - base_time_adjust) if limit is not None and s.get('hour') is not None else None
|
2064
|
+
if hour is None or (hour >= 0 and hour < limit):
|
2065
|
+
s['valid_for'] = [hour * steps_per_hour + i for i in range(0, steps_per_hour // 2)] if hour is not None else None
|
2090
2066
|
strategy.append(s)
|
2091
2067
|
if strategy is None or len(strategy) == 0:
|
2092
2068
|
return []
|
@@ -2135,8 +2111,8 @@ tariff_config = {
|
|
2135
2111
|
'region': "H", # region code to use for Octopus API
|
2136
2112
|
'update_time': 16.5, # time in hours when tomrow's data can be fetched
|
2137
2113
|
'weighting': None, # weights for weighted average
|
2138
|
-
'plunge_price': [3,
|
2139
|
-
'plunge_slots':
|
2114
|
+
'plunge_price': [3, 3], # plunge price in p/kWh inc VAT over 24 hours from 7am, 7pm
|
2115
|
+
'plunge_slots': 6, # number of 30 minute slots to use
|
2140
2116
|
'data_wrap': 6, # prices to show per line
|
2141
2117
|
'show_data': 1, # show pricing data
|
2142
2118
|
'show_plot': 1 # plot pricing data
|
@@ -2227,7 +2203,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2227
2203
|
col = (now.hour * 2) % data_wrap
|
2228
2204
|
s = f"\nPrice p/kWh inc VAT on {today}:"
|
2229
2205
|
for i in range(0, len(prices)):
|
2230
|
-
s +=
|
2206
|
+
s += f"\n {prices[i]['time']}" if i == 0 or col == 0 else ""
|
2231
2207
|
s += f" {prices[i]['price']:4.1f}"
|
2232
2208
|
col = (col + 1) % data_wrap
|
2233
2209
|
output(s)
|
@@ -2345,7 +2321,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
|
|
2345
2321
|
use[key]['start'] = time_hours(t[1])
|
2346
2322
|
use[key]['end'] = time_hours(t[2])
|
2347
2323
|
if len(t) > 3:
|
2348
|
-
use[key]['
|
2324
|
+
use[key]['hold'] = t[3]
|
2349
2325
|
gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
|
2350
2326
|
output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
|
2351
2327
|
# update dynamic charge times
|
@@ -2420,10 +2396,11 @@ def timed_list(data, base_hour, run_time):
|
|
2420
2396
|
result = []
|
2421
2397
|
h = base_hour
|
2422
2398
|
for t in range(0, run_time):
|
2423
|
-
result.append(
|
2399
|
+
result.append(interpolate(h, data, wrap=1))
|
2424
2400
|
h = round_time(h + 1 / steps_per_hour)
|
2425
2401
|
return result
|
2426
2402
|
|
2403
|
+
# align forecast with base_hour and expand to cover run_time
|
2427
2404
|
def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
|
2428
2405
|
global steps_per_hour
|
2429
2406
|
profile = []
|
@@ -2452,10 +2429,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
|
|
2452
2429
|
min_soc_now = min_soc
|
2453
2430
|
max_soc_now = max_soc
|
2454
2431
|
current_mode = 'SelfUse' if current_mode is None else current_mode
|
2455
|
-
strategy = get_strategy()
|
2432
|
+
strategy = get_strategy(timed_mode=timed_mode)
|
2456
2433
|
h = base_hour
|
2457
2434
|
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,
|
2435
|
+
period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0, 'charge': 0.0,
|
2436
|
+
'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
|
2459
2437
|
if strategy is not None:
|
2460
2438
|
period['mode'] = 'SelfUse'
|
2461
2439
|
for d in strategy:
|
@@ -2488,6 +2466,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
|
|
2488
2466
|
for i in range(0, len(work_mode_timed)):
|
2489
2467
|
bat_timed.append(kwh_current)
|
2490
2468
|
w = work_mode_timed[i]
|
2469
|
+
w['kwh'] = kwh_current
|
2491
2470
|
max_now = w['max_soc'] * capacity / 100
|
2492
2471
|
if kwh_current < max_now and w['charge'] > 0.0:
|
2493
2472
|
kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
|
@@ -2515,6 +2494,40 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
|
|
2515
2494
|
kwh_min = kwh_current
|
2516
2495
|
return (bat_timed, kwh_min)
|
2517
2496
|
|
2497
|
+
def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
|
2498
|
+
global steps_per_hour
|
2499
|
+
output(f"\nConfiguring schedule:",1)
|
2500
|
+
strategy = []
|
2501
|
+
start = base_hour
|
2502
|
+
periods = []
|
2503
|
+
for t in range(0, min([24 * steps_per_hour, len(work_mode_timed)])):
|
2504
|
+
period = periods[0] if len(periods) > 0 else work_mode_timed[0]
|
2505
|
+
next_period = work_mode_timed[t]
|
2506
|
+
h = base_hour + t / steps_per_hour
|
2507
|
+
if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
|
2508
|
+
s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
|
2509
|
+
if period['mode'] == 'ForceDischarge':
|
2510
|
+
s['fdsoc'] = period.get('fdsoc')
|
2511
|
+
s['fdpwr'] = period.get('fdpwr')
|
2512
|
+
elif period['mode'] == 'ForceCharge':
|
2513
|
+
s['max_soc'] = period.get('max_soc')
|
2514
|
+
elif period['mode'] == 'SelfUse' and period['hold'] == 1:
|
2515
|
+
s['min_soc'] = min([int(period['kwh'] / capacity * 100 + 0.5), 100])
|
2516
|
+
for p in periods:
|
2517
|
+
p['min_soc'] = s['min_soc']
|
2518
|
+
if s['mode'] != 'SelfUse' or s['min_soc'] != min_soc:
|
2519
|
+
strategy.append(s)
|
2520
|
+
start = h
|
2521
|
+
periods = []
|
2522
|
+
periods.append(work_mode_timed[t])
|
2523
|
+
if len(strategy) > 0 and strategy[-1]['min_soc'] != min_soc:
|
2524
|
+
strategy.append({'start': start %24, 'end': (start + 1 / steps_per_hour) % 24, 'mode': 'SelfUse', 'min_soc': min_soc})
|
2525
|
+
periods = []
|
2526
|
+
for s in strategy:
|
2527
|
+
periods.append(set_period(segment = s, quiet=0))
|
2528
|
+
return periods
|
2529
|
+
|
2530
|
+
|
2518
2531
|
# Battery open circuit voltage (OCV) from 0% to 100% SoC
|
2519
2532
|
# 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
|
2520
2533
|
lifepo4_curve = [51.00, 51.50, 52.00, 52.30, 52.60, 52.80, 52.90, 53.00, 53.10, 53.30, 54.00]
|
@@ -2577,7 +2590,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2577
2590
|
# show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
|
2578
2591
|
# run_after: 0 over-rides 'forecast_times'. The default is 1.
|
2579
2592
|
# forecast_times: list of hours when forecast can be fetched (UTC)
|
2580
|
-
# force_charge: 1 =
|
2593
|
+
# force_charge: 1 = hold battery, 2 = charge for whole period
|
2581
2594
|
|
2582
2595
|
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2583
2596
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
@@ -2635,10 +2648,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2635
2648
|
if tariff is not None and tariff.get(k) is not None:
|
2636
2649
|
start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2637
2650
|
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, '
|
2651
|
+
hold = 0 if tariff[k].get('hold') is not None and tariff[k]['hold'] == 0 else force_charge
|
2652
|
+
times.append({'key': k, 'start': start, 'end': end, 'hold': hold})
|
2640
2653
|
if len(times) == 0:
|
2641
|
-
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), '
|
2654
|
+
times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'hold': force_charge})
|
2642
2655
|
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2643
2656
|
time_to_end1 = None
|
2644
2657
|
for t in times:
|
@@ -2668,7 +2681,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2668
2681
|
run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
|
2669
2682
|
run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
|
2670
2683
|
time_line = [round_time(base_hour + x / steps_per_hour - (hour_adjustment if x >= time_change else 0)) for x in range(0, run_time)]
|
2671
|
-
|
2684
|
+
bat_hold = times[0]['hold']
|
2672
2685
|
# if we need to do a full charge, full_charge is the date, otherwise None
|
2673
2686
|
full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
|
2674
2687
|
if type(full_charge) is int: # value = day of month
|
@@ -2706,7 +2719,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2706
2719
|
model = device.get('deviceType')
|
2707
2720
|
else:
|
2708
2721
|
current_soc = test_soc
|
2709
|
-
capacity = 14.
|
2722
|
+
capacity = 14.54
|
2710
2723
|
residual = test_soc * capacity / 100
|
2711
2724
|
bat_volt = 315.4
|
2712
2725
|
bat_power = 0.0
|
@@ -2751,7 +2764,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2751
2764
|
output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
|
2752
2765
|
charge_current = derated_current
|
2753
2766
|
else:
|
2754
|
-
|
2767
|
+
bat_hold = 2
|
2755
2768
|
output(f" Full charge set")
|
2756
2769
|
# inverter losses
|
2757
2770
|
inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
|
@@ -2824,7 +2837,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2824
2837
|
solcast_value = None
|
2825
2838
|
solcast_profile = None
|
2826
2839
|
if forecast is None and solcast_api_key is not None and solcast_api_key != 'my.solcast_api_key' and (system_time.hour in forecast_times or run_after == 0):
|
2827
|
-
fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'))
|
2840
|
+
fsolcast = Solcast(quiet=True, reload=reload, shading=charge_config.get('shading'), d=base_time)
|
2828
2841
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2829
2842
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2830
2843
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
@@ -2833,7 +2846,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2833
2846
|
solar_value = None
|
2834
2847
|
solar_profile = None
|
2835
2848
|
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'))
|
2849
|
+
fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
|
2837
2850
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2838
2851
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2839
2852
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
@@ -2871,6 +2884,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2871
2884
|
if forecast is not None:
|
2872
2885
|
expected = forecast
|
2873
2886
|
generation_timed = [expected * x / sun_sum for x in sun_timed]
|
2887
|
+
output(f"\nForecast: {forecast:.1f}kWh")
|
2874
2888
|
elif solcast_value is not None:
|
2875
2889
|
expected = solcast_value
|
2876
2890
|
generation_timed = solcast_timed
|
@@ -2903,8 +2917,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2903
2917
|
fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
|
2904
2918
|
fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
|
2905
2919
|
discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
|
2906
|
-
elif
|
2920
|
+
elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
|
2907
2921
|
discharge_timed[i] = bms_loss
|
2922
|
+
if timed_mode > 1:
|
2923
|
+
work_mode_timed[i]['hold'] = 1
|
2908
2924
|
elif timed_mode > 0 and work_mode == 'Backup':
|
2909
2925
|
discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
|
2910
2926
|
elif timed_mode > 0 and work_mode == 'Feedin':
|
@@ -2927,7 +2943,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2927
2943
|
start_soc = int(start_residual / capacity * 100 + 0.5)
|
2928
2944
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
|
2929
2945
|
target_soc = charge_config.get('target_soc')
|
2930
|
-
target_kwh = capacity if full_charge is not None or
|
2946
|
+
target_kwh = capacity if full_charge is not None or bat_hold == 2 else (target_soc / 100 * capacity) if target_soc is not None else 0
|
2931
2947
|
if target_kwh > (end_residual + kwh_needed):
|
2932
2948
|
kwh_needed = target_kwh - end_residual
|
2933
2949
|
elif test_charge is not None:
|
@@ -2944,13 +2960,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2944
2960
|
start_timed = time_to_end
|
2945
2961
|
end_timed = time_to_end
|
2946
2962
|
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
2963
|
else:
|
2952
2964
|
if test_charge is None:
|
2953
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh:")
|
2965
|
+
output(f"\nCharge needed: {kwh_needed:.2f}kWh:")
|
2954
2966
|
charge_message = "with charge added"
|
2955
2967
|
output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
|
2956
2968
|
# work out time to add kwh_needed to battery
|
@@ -2964,20 +2976,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2964
2976
|
kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
|
2965
2977
|
required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # hold time to recover energy not added
|
2966
2978
|
hours = required if required > hours and required < charge_time else charge_time
|
2967
|
-
# round charge time
|
2979
|
+
# round charge time and work out what will actually be added
|
2968
2980
|
min_hours = charge_config['min_hours']
|
2969
2981
|
hours = int(hours / min_hours + 0.99) * min_hours
|
2982
|
+
kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
|
2970
2983
|
# rework charge and discharge
|
2971
2984
|
charge_period = get_best_charge_period(start_at, hours)
|
2972
2985
|
charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
|
2973
2986
|
price = charge_period.get('price') if charge_period is not None else None
|
2974
2987
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
2975
|
-
end_timed =
|
2988
|
+
end_timed = start_timed + hours * steps_per_hour
|
2976
2989
|
start_residual = interpolate(start_timed, bat_timed)
|
2977
|
-
end_soc = min([int((start_residual +
|
2990
|
+
end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
|
2978
2991
|
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
|
-
|
2992
|
+
output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
|
2993
|
+
+ (f" at {price:.2f}p" if price is not None else "") + f" ({kwh_added:.2f}kWh)")
|
2994
|
+
for i in range(int(time_to_start), int(time_to_end)):
|
2981
2995
|
j = i + 1
|
2982
2996
|
# work out time (fraction of hour) when charging in hour from i to j
|
2983
2997
|
if start_timed >= i and end_timed < j:
|
@@ -2991,12 +3005,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2991
3005
|
else:
|
2992
3006
|
t = 0.0 # complete hour before start or after end
|
2993
3007
|
output(f"i = {i}, j = {j}, t = {t}", 3)
|
2994
|
-
if i >= start_timed:
|
3008
|
+
if i >= start_timed and i < end_timed:
|
3009
|
+
work_mode_timed[i]['mode'] = 'ForceCharge'
|
2995
3010
|
work_mode_timed[i]['charge'] = charge_power * t
|
2996
|
-
work_mode_timed[i]['max_soc'] =
|
3011
|
+
work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else 100
|
2997
3012
|
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
3013
|
# rebuild the battery residual with any charge added and min_soc
|
3001
3014
|
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3002
3015
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
@@ -3005,6 +3018,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3005
3018
|
output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
|
3006
3019
|
if not charge_today:
|
3007
3020
|
output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
|
3021
|
+
# setup charging
|
3022
|
+
if timed_mode > 1:
|
3023
|
+
periods = charge_periods(work_mode_timed, base_hour, min_soc, capacity)
|
3024
|
+
if update_settings > 0:
|
3025
|
+
set_schedule(periods = periods)
|
3026
|
+
else:
|
3027
|
+
# work out the charge times and set. First period is battery hold, second period is battery charge / hold
|
3028
|
+
start1 = round_time(base_hour + time_to_start / steps_per_hour)
|
3029
|
+
start2 = round_time(base_hour + start_timed / steps_per_hour)
|
3030
|
+
end1 = start1 if bat_hold == 0 else start2
|
3031
|
+
end2 = round_time(base_hour + (end_timed if bat_hold == 0 else time_to_end) / steps_per_hour)
|
3032
|
+
set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
|
3033
|
+
if update_settings == 0:
|
3034
|
+
output(f"\nNo changes made to charge settings")
|
3008
3035
|
if show_data > 0:
|
3009
3036
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3010
3037
|
s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
|
@@ -3012,7 +3039,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3012
3039
|
t = 0
|
3013
3040
|
while t < len(time_line) and bat_timed[t] is not None:
|
3014
3041
|
col = h % data_wrap
|
3015
|
-
s +=
|
3042
|
+
s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
|
3016
3043
|
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
|
3017
3044
|
h += 1
|
3018
3045
|
t += steps_per_hour
|
@@ -3060,20 +3087,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3060
3087
|
file = open(storage + file_name, 'w')
|
3061
3088
|
json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3062
3089
|
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
3090
|
output_close(plot=show_plot)
|
3078
3091
|
return None
|
3079
3092
|
|
@@ -3148,7 +3161,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3148
3161
|
t = 0
|
3149
3162
|
while t < len(time_line) and bat_timed[t] is not None:
|
3150
3163
|
col = h % data_wrap
|
3151
|
-
s +=
|
3164
|
+
s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
|
3152
3165
|
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
|
3153
3166
|
h += 1
|
3154
3167
|
t += steps_per_hour
|
@@ -3695,18 +3708,19 @@ class Solcast :
|
|
3695
3708
|
Load Solcast Estimate / Actuals / Forecast daily yield
|
3696
3709
|
"""
|
3697
3710
|
|
3698
|
-
def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None) :
|
3711
|
+
def __init__(self, days = 7, reload = 2, quiet = False, estimated=0, shading=None, d=None) :
|
3699
3712
|
# days sets the number of days to get for forecasts (and estimated if enabled)
|
3700
3713
|
# reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
|
3701
3714
|
# The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
|
3702
3715
|
# The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
|
3703
3716
|
global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
|
3704
3717
|
self.data = {}
|
3718
|
+
now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
|
3705
3719
|
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(
|
3720
|
+
self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
|
3707
3721
|
self.quarter = int(self.today[5:7]) // 3 % 4
|
3708
|
-
self.tomorrow = datetime.strftime(datetime.date(
|
3709
|
-
self.yesterday = datetime.strftime(datetime.date(
|
3722
|
+
self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
|
3723
|
+
self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
|
3710
3724
|
self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
|
3711
3725
|
if reload == 1 and os.path.exists(storage + self.save):
|
3712
3726
|
os.remove(storage + self.save)
|
@@ -4042,13 +4056,14 @@ class Solar :
|
|
4042
4056
|
"""
|
4043
4057
|
|
4044
4058
|
# get solar forecast and return total expected yield
|
4045
|
-
def __init__(self, reload=0, quiet=False, shading=None):
|
4059
|
+
def __init__(self, reload=0, quiet=False, shading=None, d=None):
|
4046
4060
|
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
|
4047
4061
|
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4048
|
-
|
4062
|
+
now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
|
4063
|
+
self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
|
4049
4064
|
self.quarter = int(self.today[5:7]) // 3 % 4
|
4050
|
-
self.tomorrow = datetime.strftime(datetime.date(
|
4051
|
-
self.yesterday = datetime.strftime(datetime.date(
|
4065
|
+
self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
|
4066
|
+
self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
|
4052
4067
|
self.arrays = None
|
4053
4068
|
self.results = None
|
4054
4069
|
self.save = solar_save #.replace('.', '_%.'.replace('%',self.today.replace('-','')))
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: foxesscloud
|
3
|
-
Version: 2.5.
|
3
|
+
Version: 2.5.8
|
4
4
|
Summary: library for accessing Fox ESS cloud data using Open API
|
5
5
|
Author-email: Tony Matthews <tony@quasair.co.uk>
|
6
6
|
Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
|
@@ -130,7 +130,7 @@ You can change inverter settings using:
|
|
130
130
|
|
131
131
|
```
|
132
132
|
f.set_min(minSocOnGrid, minSoc)
|
133
|
-
f.set_charge(ch1, st1, en1, ch2, st2, en2)
|
133
|
+
f.set_charge(ch1, st1, en1, ch2, st2, en2, enable)
|
134
134
|
f.set_period(start, end, mode, min_soc, max_soc, fdsoc, fdpwr, price, segment)
|
135
135
|
f.charge_periods(st0, en0, st1, en1, st2, en2, min_soc, target_soc, start_soc)
|
136
136
|
f.set_schedule(periods, enable)
|
@@ -147,6 +147,7 @@ set_charge() takes the charge times from the battery_settings and applies these
|
|
147
147
|
+ ch2: enable charge from grid for period 2 (True or False)
|
148
148
|
+ st2: the start time for period 2
|
149
149
|
+ en2: the end time for period 2
|
150
|
+
+ enable: set to 0 to show settings but stop inverter settings being updated. Default is 1.
|
150
151
|
|
151
152
|
set_period() returns a period structure that can be used to build a list for set_schedule()
|
152
153
|
+ start, end, mode: required parameters. end time is exclusive e.g. end at '07:00' will set a period end time of '06:59'
|
@@ -783,6 +784,14 @@ This setting can be:
|
|
783
784
|
|
784
785
|
# Version Info
|
785
786
|
|
787
|
+
2.5.8<br>
|
788
|
+
Fix incorrect charging setup when force_charge=1.
|
789
|
+
Rework charge_periods() to consolidate charge periods to reduce number of time segments when timed_mode=2.
|
790
|
+
Add 'enable' parameter to set_charge().
|
791
|
+
Change 'force' to 'hold' in preset tariffs.
|
792
|
+
Stop plunge slots being used when timed_mode is 0 or 1.
|
793
|
+
Change default plunge_price to [3,3] and plunge_slots to 6.
|
794
|
+
|
786
795
|
2.5.7<br>
|
787
796
|
Fix problem with schedules being set for plunge periods that are more than 24 hours in the future.
|
788
797
|
Add date to plunge period display.
|
@@ -0,0 +1,7 @@
|
|
1
|
+
foxesscloud/foxesscloud.py,sha256=9WivKa8ysTZ52aTgPmTxJYDAnAz5TobibgvpJkrK_KM,211855
|
2
|
+
foxesscloud/openapi.py,sha256=SCr8VOz1aP0Nk5vlUi3-kJ-AMNpW0STlVWQRbbABjBE,205502
|
3
|
+
foxesscloud-2.5.8.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
|
4
|
+
foxesscloud-2.5.8.dist-info/METADATA,sha256=Aon8oxksMZP4Z9NgWKOwsz7Dpo8raj8A2cIjakNL_xM,56304
|
5
|
+
foxesscloud-2.5.8.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
6
|
+
foxesscloud-2.5.8.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
|
7
|
+
foxesscloud-2.5.8.dist-info/RECORD,,
|
@@ -1,7 +0,0 @@
|
|
1
|
-
foxesscloud/foxesscloud.py,sha256=KgIasVkQ0jtUemF4jRczfHg3AMTGX3Z3seNN6YOKQTE,211201
|
2
|
-
foxesscloud/openapi.py,sha256=OXJ0_YFX60yes_0L7GCnyIdGfZ7eNptwnsi5NUkMEr4,204841
|
3
|
-
foxesscloud-2.5.7.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
|
4
|
-
foxesscloud-2.5.7.dist-info/METADATA,sha256=9gTkXkNX3iKhASjDewdprOANQO2Ac9diiE7TUhpnbuw,55827
|
5
|
-
foxesscloud-2.5.7.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
6
|
-
foxesscloud-2.5.7.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
|
7
|
-
foxesscloud-2.5.7.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|