foxesscloud 2.5.6__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 +133 -114
- foxesscloud/openapi.py +130 -113
- {foxesscloud-2.5.6.dist-info → foxesscloud-2.5.8.dist-info}/METADATA +15 -2
- foxesscloud-2.5.8.dist-info/RECORD +7 -0
- foxesscloud-2.5.6.dist-info/RECORD +0 -7
- {foxesscloud-2.5.6.dist-info → foxesscloud-2.5.8.dist-info}/LICENCE +0 -0
- {foxesscloud-2.5.6.dist-info → foxesscloud-2.5.8.dist-info}/WHEEL +0 -0
- {foxesscloud-2.5.6.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)[:(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):
|
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,13 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
|
|
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
|
-
|
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
|
2203
|
+
strategy.append(s)
|
2226
2204
|
if strategy is None or len(strategy) == 0:
|
2227
2205
|
return []
|
2228
2206
|
updated = []
|
@@ -2270,8 +2248,8 @@ tariff_config = {
|
|
2270
2248
|
'region': "H", # region code to use for Octopus API
|
2271
2249
|
'update_time': 16.5, # time in hours when tomrow's data can be fetched
|
2272
2250
|
'weighting': None, # weights for weighted average
|
2273
|
-
'plunge_price': [3,
|
2274
|
-
'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
|
2275
2253
|
'data_wrap': 6, # prices to show per line
|
2276
2254
|
'show_data': 1, # show pricing data
|
2277
2255
|
'show_plot': 1 # plot pricing data
|
@@ -2344,7 +2322,8 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2344
2322
|
output(f"\nPlunge slots:", 1)
|
2345
2323
|
for t in plunge:
|
2346
2324
|
strategy.append(prices[t])
|
2347
|
-
|
2325
|
+
date = (now + timedelta(hours = prices[t]['hour'])).strftime("%Y-%m-%d")
|
2326
|
+
output(f" {format_period(prices[t])} at {prices[t]['price']:.1f}p on {date}", 1)
|
2348
2327
|
tariff['agile']['strategy'] = strategy
|
2349
2328
|
for key in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
|
2350
2329
|
if tariff.get(key) is None:
|
@@ -2361,7 +2340,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2361
2340
|
col = (now.hour * 2) % data_wrap
|
2362
2341
|
s = f"\nPrice p/kWh inc VAT on {today}:"
|
2363
2342
|
for i in range(0, len(prices)):
|
2364
|
-
s +=
|
2343
|
+
s += f"\n {prices[i]['time']}" if i == 0 or col == 0 else ""
|
2365
2344
|
s += f" {prices[i]['price']:4.1f}"
|
2366
2345
|
col = (col + 1) % data_wrap
|
2367
2346
|
output(s)
|
@@ -2479,7 +2458,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
|
|
2479
2458
|
use[key]['start'] = time_hours(t[1])
|
2480
2459
|
use[key]['end'] = time_hours(t[2])
|
2481
2460
|
if len(t) > 3:
|
2482
|
-
use[key]['
|
2461
|
+
use[key]['hold'] = t[3]
|
2483
2462
|
gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
|
2484
2463
|
output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
|
2485
2464
|
# update dynamic charge times
|
@@ -2554,10 +2533,11 @@ def timed_list(data, base_hour, run_time):
|
|
2554
2533
|
result = []
|
2555
2534
|
h = base_hour
|
2556
2535
|
for t in range(0, run_time):
|
2557
|
-
result.append(
|
2536
|
+
result.append(interpolate(h, data, wrap=1))
|
2558
2537
|
h = round_time(h + 1 / steps_per_hour)
|
2559
2538
|
return result
|
2560
2539
|
|
2540
|
+
# align forecast with base_hour and expand to cover run_time
|
2561
2541
|
def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
|
2562
2542
|
global steps_per_hour
|
2563
2543
|
profile = []
|
@@ -2586,10 +2566,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
|
|
2586
2566
|
min_soc_now = min_soc
|
2587
2567
|
max_soc_now = max_soc
|
2588
2568
|
current_mode = 'SelfUse' if current_mode is None else current_mode
|
2589
|
-
strategy = get_strategy()
|
2569
|
+
strategy = get_strategy(timed_mode=timed_mode)
|
2590
2570
|
h = base_hour
|
2591
2571
|
for i in range(0, run_time):
|
2592
|
-
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}
|
2593
2574
|
if strategy is not None:
|
2594
2575
|
period['mode'] = 'SelfUse'
|
2595
2576
|
for d in strategy:
|
@@ -2622,6 +2603,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
|
|
2622
2603
|
for i in range(0, len(work_mode_timed)):
|
2623
2604
|
bat_timed.append(kwh_current)
|
2624
2605
|
w = work_mode_timed[i]
|
2606
|
+
w['kwh'] = kwh_current
|
2625
2607
|
max_now = w['max_soc'] * capacity / 100
|
2626
2608
|
if kwh_current < max_now and w['charge'] > 0.0:
|
2627
2609
|
kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
|
@@ -2649,6 +2631,40 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
|
|
2649
2631
|
kwh_min = kwh_current
|
2650
2632
|
return (bat_timed, kwh_min)
|
2651
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
|
+
|
2652
2668
|
# Battery open circuit voltage (OCV) from 0% to 100% SoC
|
2653
2669
|
# 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
|
2654
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]
|
@@ -2711,7 +2727,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2711
2727
|
# show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
|
2712
2728
|
# run_after: 0 over-rides 'forecast_times'. The default is 1.
|
2713
2729
|
# forecast_times: list of hours when forecast can be fetched (UTC)
|
2714
|
-
# force_charge: 1 =
|
2730
|
+
# force_charge: 1 = hold battery, 2 = charge for whole period
|
2715
2731
|
|
2716
2732
|
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2717
2733
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
@@ -2769,10 +2785,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2769
2785
|
if tariff is not None and tariff.get(k) is not None:
|
2770
2786
|
start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2771
2787
|
end = round_time(time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2772
|
-
|
2773
|
-
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})
|
2774
2790
|
if len(times) == 0:
|
2775
|
-
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})
|
2776
2792
|
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2777
2793
|
time_to_end1 = None
|
2778
2794
|
for t in times:
|
@@ -2802,7 +2818,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2802
2818
|
run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
|
2803
2819
|
run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
|
2804
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)]
|
2805
|
-
|
2821
|
+
bat_hold = times[0]['hold']
|
2806
2822
|
# if we need to do a full charge, full_charge is the date, otherwise None
|
2807
2823
|
full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
|
2808
2824
|
if type(full_charge) is int: # value = day of month
|
@@ -2812,7 +2828,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2812
2828
|
if debug_setting > 2:
|
2813
2829
|
output(f"\ntoday = {today}, tomorrow = {tomorrow}, time_shift = {time_shift}")
|
2814
2830
|
output(f"times = {times}")
|
2815
|
-
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}")
|
2816
2832
|
output(f"base_hour = {base_hour}, hour_adjustment = {hour_adjustment}, change_hour = {change_hour}, time_change = {time_change}")
|
2817
2833
|
output(f"time_to_start = {time_to_start}, run_time = {run_time}, charge_today = {charge_today}")
|
2818
2834
|
output(f"time_to_next = {time_to_next}, full_charge = {full_charge}")
|
@@ -2840,7 +2856,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2840
2856
|
model = device.get('deviceType')
|
2841
2857
|
else:
|
2842
2858
|
current_soc = test_soc
|
2843
|
-
capacity = 14.
|
2859
|
+
capacity = 14.54
|
2844
2860
|
residual = test_soc * capacity / 100
|
2845
2861
|
bat_volt = 315.4
|
2846
2862
|
bat_power = 0.0
|
@@ -2885,7 +2901,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2885
2901
|
output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
|
2886
2902
|
charge_current = derated_current
|
2887
2903
|
else:
|
2888
|
-
|
2904
|
+
bat_hold = 2
|
2889
2905
|
output(f" Full charge set")
|
2890
2906
|
# inverter losses
|
2891
2907
|
inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
|
@@ -2958,16 +2974,17 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2958
2974
|
solcast_value = None
|
2959
2975
|
solcast_profile = None
|
2960
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):
|
2961
|
-
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)
|
2962
2978
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2963
2979
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2964
2980
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
2965
2981
|
solcast_from = time_hours(fsolcast.daily[today]['from']) if fsolcast.daily[today].get('from') is not None else 0
|
2966
|
-
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
|
2967
2984
|
solar_value = None
|
2968
2985
|
solar_profile = None
|
2969
2986
|
if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
|
2970
|
-
fsolar = Solar(quiet=True, shading=charge_config.get('shading'))
|
2987
|
+
fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
|
2971
2988
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2972
2989
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2973
2990
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
@@ -3005,6 +3022,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3005
3022
|
if forecast is not None:
|
3006
3023
|
expected = forecast
|
3007
3024
|
generation_timed = [expected * x / sun_sum for x in sun_timed]
|
3025
|
+
output(f"\nForecast: {forecast:.1f}kWh")
|
3008
3026
|
elif solcast_value is not None:
|
3009
3027
|
expected = solcast_value
|
3010
3028
|
generation_timed = solcast_timed
|
@@ -3037,8 +3055,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3037
3055
|
fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
|
3038
3056
|
fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
|
3039
3057
|
discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
|
3040
|
-
elif
|
3058
|
+
elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
|
3041
3059
|
discharge_timed[i] = bms_loss
|
3060
|
+
if timed_mode > 1:
|
3061
|
+
work_mode_timed[i]['hold'] = 1
|
3042
3062
|
elif timed_mode > 0 and work_mode == 'Backup':
|
3043
3063
|
discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
|
3044
3064
|
elif timed_mode > 0 and work_mode == 'Feedin':
|
@@ -3061,7 +3081,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3061
3081
|
start_soc = int(start_residual / capacity * 100 + 0.5)
|
3062
3082
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
|
3063
3083
|
target_soc = charge_config.get('target_soc')
|
3064
|
-
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
|
3065
3085
|
if target_kwh > (end_residual + kwh_needed):
|
3066
3086
|
kwh_needed = target_kwh - end_residual
|
3067
3087
|
elif test_charge is not None:
|
@@ -3078,13 +3098,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3078
3098
|
start_timed = time_to_end
|
3079
3099
|
end_timed = time_to_end
|
3080
3100
|
end_soc = int(end_residual / capacity * 100 + 0.5)
|
3081
|
-
# update min_soc for battery hold
|
3082
|
-
if force_charge > 0 and timed_mode > 1:
|
3083
|
-
for t in range(int(time_to_start), int(time_to_end)):
|
3084
|
-
work_mode_timed[t]['min_soc'] = start_soc
|
3085
3101
|
else:
|
3086
3102
|
if test_charge is None:
|
3087
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh
|
3103
|
+
output(f"\nCharge needed: {kwh_needed:.2f}kWh")
|
3088
3104
|
charge_message = "with charge added"
|
3089
3105
|
output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
|
3090
3106
|
# work out time to add kwh_needed to battery
|
@@ -3098,20 +3114,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3098
3114
|
kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
|
3099
3115
|
required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # time to recover energy not added
|
3100
3116
|
hours = required if required > hours and required < charge_time else charge_time
|
3101
|
-
# round charge time
|
3117
|
+
# round charge time and work out what will actually be added
|
3102
3118
|
min_hours = charge_config['min_hours']
|
3103
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)
|
3104
3121
|
# rework charge and discharge
|
3105
3122
|
charge_period = get_best_charge_period(start_at, hours)
|
3106
3123
|
charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
|
3107
3124
|
price = charge_period.get('price') if charge_period is not None else None
|
3108
3125
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
3109
|
-
end_timed =
|
3126
|
+
end_timed = start_timed + hours * steps_per_hour
|
3110
3127
|
start_residual = interpolate(start_timed, bat_timed)
|
3111
|
-
end_soc = min([int((start_residual +
|
3128
|
+
end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
|
3112
3129
|
output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
|
3113
|
-
output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
|
3114
|
-
|
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)):
|
3115
3133
|
j = i + 1
|
3116
3134
|
# work out time (fraction of hour) when charging in hour from i to j
|
3117
3135
|
if start_timed >= i and end_timed < j:
|
@@ -3125,12 +3143,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3125
3143
|
else:
|
3126
3144
|
t = 0.0 # complete hour before start or after end
|
3127
3145
|
output(f"i = {i}, j = {j}, t = {t}", 3)
|
3128
|
-
if i >= start_timed:
|
3146
|
+
if i >= start_timed and i < end_timed:
|
3147
|
+
work_mode_timed[i]['mode'] = 'ForceCharge'
|
3129
3148
|
work_mode_timed[i]['charge'] = charge_power * t
|
3130
|
-
work_mode_timed[i]['max_soc'] =
|
3149
|
+
work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else 100
|
3131
3150
|
work_mode_timed[i]['discharge'] *= (1-t)
|
3132
|
-
elif force_charge > 0 and timed_mode > 1:
|
3133
|
-
work_mode_timed[i]['min_soc'] = start_soc
|
3134
3151
|
# rebuild the battery residual with the charge added and min_soc
|
3135
3152
|
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3136
3153
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
@@ -3139,6 +3156,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3139
3156
|
output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
|
3140
3157
|
if not charge_today:
|
3141
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")
|
3142
3173
|
if show_data > 0:
|
3143
3174
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3144
3175
|
s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
|
@@ -3146,7 +3177,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3146
3177
|
t = 0
|
3147
3178
|
while t < len(time_line) and bat_timed[t] is not None:
|
3148
3179
|
col = h % data_wrap
|
3149
|
-
s +=
|
3180
|
+
s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
|
3150
3181
|
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
|
3151
3182
|
h += 1
|
3152
3183
|
t += steps_per_hour
|
@@ -3194,20 +3225,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3194
3225
|
file = open(storage + file_name, 'w')
|
3195
3226
|
json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3196
3227
|
file.close()
|
3197
|
-
# setup charging
|
3198
|
-
if update_settings == 1:
|
3199
|
-
# work out the charge times and set. First period is battery hold, second period is battery charge / hold
|
3200
|
-
start1 = round_time(base_hour + time_to_start / steps_per_hour)
|
3201
|
-
start2 = round_time(base_hour + start_timed / steps_per_hour)
|
3202
|
-
end1 = start1 if force_charge == 0 else start2
|
3203
|
-
end2 = round_time(base_hour + (end_timed if force_charge == 0 else time_to_end) / steps_per_hour)
|
3204
|
-
if timed_mode > 1:
|
3205
|
-
periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, end_soc=end_soc, start_soc=start_soc)
|
3206
|
-
set_schedule(periods = periods)
|
3207
|
-
else:
|
3208
|
-
set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1)
|
3209
|
-
else:
|
3210
|
-
output(f"\nNo changes made to charge settings")
|
3211
3228
|
output_close(plot=show_plot)
|
3212
3229
|
return None
|
3213
3230
|
|
@@ -3283,7 +3300,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3283
3300
|
t = 0
|
3284
3301
|
while t < len(time_line) and bat_timed[t] is not None:
|
3285
3302
|
col = h % data_wrap
|
3286
|
-
s +=
|
3303
|
+
s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
|
3287
3304
|
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
|
3288
3305
|
h += 1
|
3289
3306
|
t += steps_per_hour
|
@@ -3831,18 +3848,19 @@ class Solcast :
|
|
3831
3848
|
Load Solcast Estimate / Actuals / Forecast daily yield
|
3832
3849
|
"""
|
3833
3850
|
|
3834
|
-
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) :
|
3835
3852
|
# days sets the number of days to get for forecasts (and estimated if enabled)
|
3836
3853
|
# reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
|
3837
3854
|
# The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
|
3838
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
|
3839
3856
|
global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
|
3840
3857
|
self.data = {}
|
3858
|
+
now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
|
3841
3859
|
self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
|
3842
|
-
self.today = datetime.strftime(datetime.date(
|
3860
|
+
self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
|
3843
3861
|
self.quarter = int(self.today[5:7]) // 3 % 4
|
3844
|
-
self.tomorrow = datetime.strftime(datetime.date(
|
3845
|
-
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')
|
3846
3864
|
self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
|
3847
3865
|
if reload == 1 and os.path.exists(storage + self.save):
|
3848
3866
|
os.remove(storage + self.save)
|
@@ -4178,13 +4196,14 @@ class Solar :
|
|
4178
4196
|
"""
|
4179
4197
|
|
4180
4198
|
# get solar forecast and return total expected yield
|
4181
|
-
def __init__(self, reload=0, quiet=False, shading=None):
|
4199
|
+
def __init__(self, reload=0, quiet=False, shading=None, d=None):
|
4182
4200
|
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
|
4183
4201
|
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4184
|
-
|
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')
|
4185
4204
|
self.quarter = int(self.today[5:7]) // 3 % 4
|
4186
|
-
self.tomorrow = datetime.strftime(datetime.date(
|
4187
|
-
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')
|
4188
4207
|
self.arrays = None
|
4189
4208
|
self.results = None
|
4190
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)[:(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):
|
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,13 @@ def get_strategy(use=None, strategy=None, quiet=1, remove=None, reserve=0):
|
|
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
|
-
|
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
|
2066
|
+
strategy.append(s)
|
2090
2067
|
if strategy is None or len(strategy) == 0:
|
2091
2068
|
return []
|
2092
2069
|
updated = []
|
@@ -2134,8 +2111,8 @@ tariff_config = {
|
|
2134
2111
|
'region': "H", # region code to use for Octopus API
|
2135
2112
|
'update_time': 16.5, # time in hours when tomrow's data can be fetched
|
2136
2113
|
'weighting': None, # weights for weighted average
|
2137
|
-
'plunge_price': [3,
|
2138
|
-
'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
|
2139
2116
|
'data_wrap': 6, # prices to show per line
|
2140
2117
|
'show_data': 1, # show pricing data
|
2141
2118
|
'show_plot': 1 # plot pricing data
|
@@ -2208,7 +2185,8 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2208
2185
|
output(f"\nPlunge slots:", 1)
|
2209
2186
|
for t in plunge:
|
2210
2187
|
strategy.append(prices[t])
|
2211
|
-
|
2188
|
+
date = (now + timedelta(hours = prices[t]['hour'])).strftime("%Y-%m-%d")
|
2189
|
+
output(f" {format_period(prices[t])} at {prices[t]['price']:.1f}p on {date}", 1)
|
2212
2190
|
tariff['agile']['strategy'] = strategy
|
2213
2191
|
for key in ['off_peak1', 'off_peak2', 'off_peak3', 'off_peak4']:
|
2214
2192
|
if tariff.get(key) is None:
|
@@ -2225,7 +2203,7 @@ def get_agile_times(tariff=agile_octopus, d=None):
|
|
2225
2203
|
col = (now.hour * 2) % data_wrap
|
2226
2204
|
s = f"\nPrice p/kWh inc VAT on {today}:"
|
2227
2205
|
for i in range(0, len(prices)):
|
2228
|
-
s +=
|
2206
|
+
s += f"\n {prices[i]['time']}" if i == 0 or col == 0 else ""
|
2229
2207
|
s += f" {prices[i]['price']:4.1f}"
|
2230
2208
|
col = (col + 1) % data_wrap
|
2231
2209
|
output(s)
|
@@ -2343,7 +2321,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
|
|
2343
2321
|
use[key]['start'] = time_hours(t[1])
|
2344
2322
|
use[key]['end'] = time_hours(t[2])
|
2345
2323
|
if len(t) > 3:
|
2346
|
-
use[key]['
|
2324
|
+
use[key]['hold'] = t[3]
|
2347
2325
|
gmt = ' GMT' if tariff[key].get('gmt') is not None else ''
|
2348
2326
|
output(f" {key} period: {hours_time(t[1])}-{hours_time(t[2])}{gmt}")
|
2349
2327
|
# update dynamic charge times
|
@@ -2418,10 +2396,11 @@ def timed_list(data, base_hour, run_time):
|
|
2418
2396
|
result = []
|
2419
2397
|
h = base_hour
|
2420
2398
|
for t in range(0, run_time):
|
2421
|
-
result.append(
|
2399
|
+
result.append(interpolate(h, data, wrap=1))
|
2422
2400
|
h = round_time(h + 1 / steps_per_hour)
|
2423
2401
|
return result
|
2424
2402
|
|
2403
|
+
# align forecast with base_hour and expand to cover run_time
|
2425
2404
|
def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_offset=0):
|
2426
2405
|
global steps_per_hour
|
2427
2406
|
profile = []
|
@@ -2450,10 +2429,11 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
|
|
2450
2429
|
min_soc_now = min_soc
|
2451
2430
|
max_soc_now = max_soc
|
2452
2431
|
current_mode = 'SelfUse' if current_mode is None else current_mode
|
2453
|
-
strategy = get_strategy()
|
2432
|
+
strategy = get_strategy(timed_mode=timed_mode)
|
2454
2433
|
h = base_hour
|
2455
2434
|
for i in range(0, run_time):
|
2456
|
-
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}
|
2457
2437
|
if strategy is not None:
|
2458
2438
|
period['mode'] = 'SelfUse'
|
2459
2439
|
for d in strategy:
|
@@ -2486,6 +2466,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
|
|
2486
2466
|
for i in range(0, len(work_mode_timed)):
|
2487
2467
|
bat_timed.append(kwh_current)
|
2488
2468
|
w = work_mode_timed[i]
|
2469
|
+
w['kwh'] = kwh_current
|
2489
2470
|
max_now = w['max_soc'] * capacity / 100
|
2490
2471
|
if kwh_current < max_now and w['charge'] > 0.0:
|
2491
2472
|
kwh_current += min([w['charge'], charge_limit - w['pv']]) * charge_loss / steps_per_hour
|
@@ -2513,6 +2494,40 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
|
|
2513
2494
|
kwh_min = kwh_current
|
2514
2495
|
return (bat_timed, kwh_min)
|
2515
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
|
+
|
2516
2531
|
# Battery open circuit voltage (OCV) from 0% to 100% SoC
|
2517
2532
|
# 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
|
2518
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]
|
@@ -2575,7 +2590,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
|
|
2575
2590
|
# show_plot: 1 plots battery SoC, 2 plots battery residual. Default = 1
|
2576
2591
|
# run_after: 0 over-rides 'forecast_times'. The default is 1.
|
2577
2592
|
# forecast_times: list of hours when forecast can be fetched (UTC)
|
2578
|
-
# force_charge: 1 =
|
2593
|
+
# force_charge: 1 = hold battery, 2 = charge for whole period
|
2579
2594
|
|
2580
2595
|
def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
|
2581
2596
|
forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
|
@@ -2633,10 +2648,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2633
2648
|
if tariff is not None and tariff.get(k) is not None:
|
2634
2649
|
start = round_time(time_hours(tariff[k]['start']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2635
2650
|
end = round_time(time_hours(tariff[k]['end']) + (time_offset if tariff[k].get('gmt') is not None else 0))
|
2636
|
-
|
2637
|
-
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})
|
2638
2653
|
if len(times) == 0:
|
2639
|
-
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})
|
2640
2655
|
output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
|
2641
2656
|
time_to_end1 = None
|
2642
2657
|
for t in times:
|
@@ -2666,7 +2681,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2666
2681
|
run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
|
2667
2682
|
run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
|
2668
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)]
|
2669
|
-
|
2684
|
+
bat_hold = times[0]['hold']
|
2670
2685
|
# if we need to do a full charge, full_charge is the date, otherwise None
|
2671
2686
|
full_charge = charge_config['full_charge'] if charge_key == 'off_peak1' else None
|
2672
2687
|
if type(full_charge) is int: # value = day of month
|
@@ -2704,7 +2719,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2704
2719
|
model = device.get('deviceType')
|
2705
2720
|
else:
|
2706
2721
|
current_soc = test_soc
|
2707
|
-
capacity = 14.
|
2722
|
+
capacity = 14.54
|
2708
2723
|
residual = test_soc * capacity / 100
|
2709
2724
|
bat_volt = 315.4
|
2710
2725
|
bat_power = 0.0
|
@@ -2749,7 +2764,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2749
2764
|
output(f" Charge current reduced from {charge_current:.0f}A to {derated_current:.0f}A" )
|
2750
2765
|
charge_current = derated_current
|
2751
2766
|
else:
|
2752
|
-
|
2767
|
+
bat_hold = 2
|
2753
2768
|
output(f" Full charge set")
|
2754
2769
|
# inverter losses
|
2755
2770
|
inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
|
@@ -2822,7 +2837,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2822
2837
|
solcast_value = None
|
2823
2838
|
solcast_profile = None
|
2824
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):
|
2825
|
-
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)
|
2826
2841
|
if fsolcast is not None and hasattr(fsolcast, 'daily') and fsolcast.daily.get(forecast_day) is not None:
|
2827
2842
|
solcast_value = fsolcast.daily[forecast_day]['kwh']
|
2828
2843
|
solcast_timed = forecast_value_timed(fsolcast, today, tomorrow, base_hour, run_time, time_offset)
|
@@ -2831,7 +2846,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2831
2846
|
solar_value = None
|
2832
2847
|
solar_profile = None
|
2833
2848
|
if forecast is None and solar_arrays is not None and (system_time.hour in forecast_times or run_after == 0):
|
2834
|
-
fsolar = Solar(quiet=True, shading=charge_config.get('shading'))
|
2849
|
+
fsolar = Solar(quiet=True, shading=charge_config.get('shading'), d=base_time)
|
2835
2850
|
if fsolar is not None and hasattr(fsolar, 'daily') and fsolar.daily.get(forecast_day) is not None:
|
2836
2851
|
solar_value = fsolar.daily[forecast_day]['kwh']
|
2837
2852
|
solar_timed = forecast_value_timed(fsolar, today, tomorrow, base_hour, run_time, 0)
|
@@ -2869,6 +2884,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2869
2884
|
if forecast is not None:
|
2870
2885
|
expected = forecast
|
2871
2886
|
generation_timed = [expected * x / sun_sum for x in sun_timed]
|
2887
|
+
output(f"\nForecast: {forecast:.1f}kWh")
|
2872
2888
|
elif solcast_value is not None:
|
2873
2889
|
expected = solcast_value
|
2874
2890
|
generation_timed = solcast_timed
|
@@ -2901,8 +2917,10 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2901
2917
|
fdpwr = work_mode_timed[i]['fdpwr'] / discharge_loss / 1000
|
2902
2918
|
fdpwr = min([discharge_limit, export_limit + discharge_timed[i] * charge_loss, fdpwr])
|
2903
2919
|
discharge_timed[i] = fdpwr * duration / charge_loss + discharge_timed[i] * (1.0 - duration) - charge_timed[i] * duration
|
2904
|
-
elif
|
2920
|
+
elif bat_hold > 0 and i >= int(time_to_start) and i < int(time_to_end):
|
2905
2921
|
discharge_timed[i] = bms_loss
|
2922
|
+
if timed_mode > 1:
|
2923
|
+
work_mode_timed[i]['hold'] = 1
|
2906
2924
|
elif timed_mode > 0 and work_mode == 'Backup':
|
2907
2925
|
discharge_timed[i] = bms_loss if charge_timed[i] == 0.0 else 0.0
|
2908
2926
|
elif timed_mode > 0 and work_mode == 'Feedin':
|
@@ -2925,7 +2943,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2925
2943
|
start_soc = int(start_residual / capacity * 100 + 0.5)
|
2926
2944
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends without charging
|
2927
2945
|
target_soc = charge_config.get('target_soc')
|
2928
|
-
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
|
2929
2947
|
if target_kwh > (end_residual + kwh_needed):
|
2930
2948
|
kwh_needed = target_kwh - end_residual
|
2931
2949
|
elif test_charge is not None:
|
@@ -2942,13 +2960,9 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2942
2960
|
start_timed = time_to_end
|
2943
2961
|
end_timed = time_to_end
|
2944
2962
|
end_soc = int(end_residual / capacity * 100 + 0.5)
|
2945
|
-
# update min_soc for battery hold
|
2946
|
-
if force_charge > 0 and timed_mode > 1:
|
2947
|
-
for t in range(int(time_to_start), int(time_to_end)):
|
2948
|
-
work_mode_timed[t]['min_soc'] = start_soc
|
2949
2963
|
else:
|
2950
2964
|
if test_charge is None:
|
2951
|
-
output(f"\nCharge needed {kwh_needed:.2f}kWh:")
|
2965
|
+
output(f"\nCharge needed: {kwh_needed:.2f}kWh:")
|
2952
2966
|
charge_message = "with charge added"
|
2953
2967
|
output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
|
2954
2968
|
# work out time to add kwh_needed to battery
|
@@ -2962,20 +2976,22 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2962
2976
|
kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
|
2963
2977
|
required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # hold time to recover energy not added
|
2964
2978
|
hours = required if required > hours and required < charge_time else charge_time
|
2965
|
-
# round charge time
|
2979
|
+
# round charge time and work out what will actually be added
|
2966
2980
|
min_hours = charge_config['min_hours']
|
2967
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)
|
2968
2983
|
# rework charge and discharge
|
2969
2984
|
charge_period = get_best_charge_period(start_at, hours)
|
2970
2985
|
charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
|
2971
2986
|
price = charge_period.get('price') if charge_period is not None else None
|
2972
2987
|
start_timed = time_to_start + charge_offset * steps_per_hour
|
2973
|
-
end_timed =
|
2988
|
+
end_timed = start_timed + hours * steps_per_hour
|
2974
2989
|
start_residual = interpolate(start_timed, bat_timed)
|
2975
|
-
end_soc = min([int((start_residual +
|
2990
|
+
end_soc = min([int((start_residual + kwh_added) / capacity * 100 + 0.5), 100])
|
2976
2991
|
output(f" Start SoC: {start_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(start_timed, time_line))} ({start_residual:.2f}kWh)")
|
2977
|
-
output(f" Charge to: {end_soc:.0f}% {hours_time(adjusted_hour(start_timed, time_line))}-{hours_time(adjusted_hour(end_timed, time_line))}"
|
2978
|
-
|
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)):
|
2979
2995
|
j = i + 1
|
2980
2996
|
# work out time (fraction of hour) when charging in hour from i to j
|
2981
2997
|
if start_timed >= i and end_timed < j:
|
@@ -2989,12 +3005,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
2989
3005
|
else:
|
2990
3006
|
t = 0.0 # complete hour before start or after end
|
2991
3007
|
output(f"i = {i}, j = {j}, t = {t}", 3)
|
2992
|
-
if i >= start_timed:
|
3008
|
+
if i >= start_timed and i < end_timed:
|
3009
|
+
work_mode_timed[i]['mode'] = 'ForceCharge'
|
2993
3010
|
work_mode_timed[i]['charge'] = charge_power * t
|
2994
|
-
work_mode_timed[i]['max_soc'] =
|
3011
|
+
work_mode_timed[i]['max_soc'] = target_soc if target_soc is not None else 100
|
2995
3012
|
work_mode_timed[i]['discharge'] *= (1-t)
|
2996
|
-
elif force_charge > 0 and timed_mode > 1:
|
2997
|
-
work_mode_timed[i]['min_soc'] = start_soc
|
2998
3013
|
# rebuild the battery residual with any charge added and min_soc
|
2999
3014
|
(bat_timed, x) = battery_timed(work_mode_timed, kwh_current, capacity, time_to_next)
|
3000
3015
|
end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
|
@@ -3003,6 +3018,20 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3003
3018
|
output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
|
3004
3019
|
if not charge_today:
|
3005
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")
|
3006
3035
|
if show_data > 0:
|
3007
3036
|
data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
|
3008
3037
|
s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
|
@@ -3010,7 +3039,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3010
3039
|
t = 0
|
3011
3040
|
while t < len(time_line) and bat_timed[t] is not None:
|
3012
3041
|
col = h % data_wrap
|
3013
|
-
s +=
|
3042
|
+
s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
|
3014
3043
|
s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
|
3015
3044
|
h += 1
|
3016
3045
|
t += steps_per_hour
|
@@ -3058,20 +3087,6 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
|
|
3058
3087
|
file = open(storage + file_name, 'w')
|
3059
3088
|
json.dump(data, file, sort_keys = True, indent=4, ensure_ascii= False)
|
3060
3089
|
file.close()
|
3061
|
-
# setup charging
|
3062
|
-
if update_settings == 1:
|
3063
|
-
# work out the charge times and set. First period is battery hold, second period is battery charge / hold
|
3064
|
-
start1 = round_time(base_hour + time_to_start / steps_per_hour)
|
3065
|
-
start2 = round_time(base_hour + start_timed / steps_per_hour)
|
3066
|
-
end1 = start1 if force_charge == 0 else start2
|
3067
|
-
end2 = round_time(base_hour + (end_timed if force_charge == 0 else time_to_end) / steps_per_hour)
|
3068
|
-
if timed_mode > 1:
|
3069
|
-
periods = charge_periods(st1=start1, en1=end1, st2=start2, en2=end2, min_soc=min_soc, end_soc=end_soc, start_soc=start_soc)
|
3070
|
-
set_schedule(periods = periods)
|
3071
|
-
else:
|
3072
|
-
set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1)
|
3073
|
-
else:
|
3074
|
-
output(f"\nNo changes made to charge settings")
|
3075
3090
|
output_close(plot=show_plot)
|
3076
3091
|
return None
|
3077
3092
|
|
@@ -3146,7 +3161,7 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
|
|
3146
3161
|
t = 0
|
3147
3162
|
while t < len(time_line) and bat_timed[t] is not None:
|
3148
3163
|
col = h % data_wrap
|
3149
|
-
s +=
|
3164
|
+
s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
|
3150
3165
|
s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
|
3151
3166
|
h += 1
|
3152
3167
|
t += steps_per_hour
|
@@ -3693,18 +3708,19 @@ class Solcast :
|
|
3693
3708
|
Load Solcast Estimate / Actuals / Forecast daily yield
|
3694
3709
|
"""
|
3695
3710
|
|
3696
|
-
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) :
|
3697
3712
|
# days sets the number of days to get for forecasts (and estimated if enabled)
|
3698
3713
|
# reload: 0 = use solcast.json, 1 = load new forecast, 2 = use solcast.json if date matches
|
3699
3714
|
# The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
|
3700
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
|
3701
3716
|
global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
|
3702
3717
|
self.data = {}
|
3718
|
+
now = datetime.now() if d is None else datetime.strptime(d, '%Y-%m-%d %H:%M')
|
3703
3719
|
self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
|
3704
|
-
self.today = datetime.strftime(datetime.date(
|
3720
|
+
self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
|
3705
3721
|
self.quarter = int(self.today[5:7]) // 3 % 4
|
3706
|
-
self.tomorrow = datetime.strftime(datetime.date(
|
3707
|
-
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')
|
3708
3724
|
self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
|
3709
3725
|
if reload == 1 and os.path.exists(storage + self.save):
|
3710
3726
|
os.remove(storage + self.save)
|
@@ -4040,13 +4056,14 @@ class Solar :
|
|
4040
4056
|
"""
|
4041
4057
|
|
4042
4058
|
# get solar forecast and return total expected yield
|
4043
|
-
def __init__(self, reload=0, quiet=False, shading=None):
|
4059
|
+
def __init__(self, reload=0, quiet=False, shading=None, d=None):
|
4044
4060
|
global solar_arrays, solar_save, solar_total, solar_url, solar_api_key, storage
|
4045
4061
|
self.shading = None if shading is None else shading if shading.get('solar') is None else shading['solar']
|
4046
|
-
|
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')
|
4047
4064
|
self.quarter = int(self.today[5:7]) // 3 % 4
|
4048
|
-
self.tomorrow = datetime.strftime(datetime.date(
|
4049
|
-
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')
|
4050
4067
|
self.arrays = None
|
4051
4068
|
self.results = None
|
4052
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,18 @@ 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
|
+
|
795
|
+
2.5.7<br>
|
796
|
+
Fix problem with schedules being set for plunge periods that are more than 24 hours in the future.
|
797
|
+
Add date to plunge period display.
|
798
|
+
|
786
799
|
2.5.6<br>
|
787
800
|
Change plunge slots to 8 and plungs pricing to [3,10].
|
788
801
|
Change min_hours setting in charge_needed to 0.5 (30 minutes) and round up charge times to increments of this.
|
@@ -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=pvRR5Wjbb0EIfXT59wtG5kGgGANxV-USjOZoPDzwaL4,210980
|
2
|
-
foxesscloud/openapi.py,sha256=vj0hP-8IsD30IK5ylCqp9BahvhtgYOIbkgrccbjcaLE,204620
|
3
|
-
foxesscloud-2.5.6.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
|
4
|
-
foxesscloud-2.5.6.dist-info/METADATA,sha256=o3Vxj9OGsJayuifK48DkVlv2W2l4Oht8-KIuzrIB6DU,55678
|
5
|
-
foxesscloud-2.5.6.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
6
|
-
foxesscloud-2.5.6.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
|
7
|
-
foxesscloud-2.5.6.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|