|
| 1 | +min_soc = 5 # % Minimum battery SOC |
| 2 | +action = 'auto' |
| 3 | +# Pricing Decisions |
| 4 | +IMPORT_TOLERANCE = 1 |
| 5 | +BATTERY_SOC_NEEDED = 9 |
| 6 | +BUY_DELTA_THRESHOLD = 87 |
| 7 | +CUT_OFF_THRESHOLD = 47 |
| 8 | +MORNING_SELL_MARGIN = 82 |
| 9 | +MORNING_SELL_SOC = 42 |
| 10 | +BATTERY_EXPORT_SOC_THRESHOLD = 24 |
| 11 | +GOOD_SUN_DAY = 44 |
| 12 | +GOOD_SUN_HOUR = 64 |
| 13 | +BAD_SUN_DAY_KEEP_SOC = 16 |
| 14 | +ALWAYS_IMPORT_SOC = 22 # The battery SOC at which we always import |
| 15 | + |
| 16 | +# Determine Local Time |
1 | 17 | hour = interval_time.hour |
2 | | -desired_soc = 20.0 |
3 | | -min_sell_soc = 30 # 'Take the money (sell > 80c)' down to this SOC |
4 | | -sell_price_threshold = 85 # 20 cents |
5 | | -sell_price_threshold_1 = 20 # sell in morning |
6 | | -sell_price_threshold_2 = 1000 # Sell during peak |
7 | | -buy_price_morning = 5 # Morning buy price |
8 | | -buy_top_up_price = 20 |
9 | | -# This is when we expect the solar production to meet our house load |
10 | | -minutes_after_sunrise_solar_matches_load = 30 |
11 | | -solar_production_time = sunrise + timedelta(minutes=minutes_after_sunrise_solar_matches_load) |
| 18 | +current_hour = interval_time.hour |
| 19 | +gti = weather_data.get('hourly', {}).get('global_tilted_irradiance_instant', [-1] * 48) |
| 20 | +tomorrow_morning_hours_away = 24 - hour |
| 21 | +global_tilted_irradiance_tomorrow = sum(gti[tomorrow_morning_hours_away:]) |
| 22 | +soc_diff = 0.0 |
| 23 | +time_left = 0.0 |
| 24 | +soc_diff_remaining = 0.0 |
| 25 | +night_reserve = BATTERY_SOC_NEEDED |
| 26 | +if global_tilted_irradiance_tomorrow < (GOOD_SUN_DAY * 100): |
| 27 | + night_reserve += BAD_SUN_DAY_KEEP_SOC |
| 28 | +if forecast and hour > 16: |
| 29 | + morning_peak = max(forecast[:-6]) |
| 30 | + if morning_peak > 400: |
| 31 | + night_reserve += 10 |
| 32 | +elif forecast and hour < 6: |
| 33 | + morning_away = hour * 2 |
| 34 | + morning_peak = max(forecast[morning_away:]) |
| 35 | + if morning_peak > 400: |
| 36 | + night_reserve += 10 |
12 | 37 |
|
13 | | -if 0 <= hour < 6: |
14 | | - if sell_price > sell_price_threshold: |
15 | | - action = 'export' |
16 | | - reason = f'nsw: sell price greater than {sell_price_threshold} cents between midnight and 6am' |
17 | | - elif buy_price < buy_price_morning and battery_soc >= desired_soc: |
18 | | - action = 'import' |
19 | | - reason = f'nsw: buy price less than {buy_price_morning} cents between midnight and 6am' |
| 38 | +def find_first_index(gti, threshold): |
| 39 | + for i in range(len(gti)): |
| 40 | + if gti[i] > threshold: |
| 41 | + return i |
| 42 | + return -1 |
| 43 | + |
| 44 | +def find_last_index(gti, threshold): |
| 45 | + for i in range(len(gti)): |
| 46 | + if gti[i] < threshold: |
| 47 | + return i |
| 48 | + return -1 |
| 49 | + |
| 50 | +first_good_gti = find_first_index(gti, GOOD_SUN_HOUR * 10) |
| 51 | +last_good_gti = find_last_index(gti[:24], GOOD_SUN_HOUR * 10) |
| 52 | +solar_charge_time = (interval_time.hour >= first_good_gti) and (interval_time.hour <= last_good_gti) |
| 53 | +daytime = sunrise.time() < interval_time.time() < sunset.time() |
| 54 | +if daytime: |
| 55 | + soc_diff = battery_soc - night_reserve |
| 56 | + time_left = (((sunset - interval_time).total_seconds() - 1800) % 86400) / 3600 |
| 57 | +elif not daytime: |
| 58 | + night_hours = (((sunrise - sunset).total_seconds()) % 86400) / 3600 |
| 59 | + time_left = (((sunrise - interval_time).total_seconds() + 1800) % 86400) / 3600 |
| 60 | + soc_diff = (battery_soc - night_reserve) |
| 61 | + soc_diff_remaining = battery_soc - night_reserve * (time_left / night_hours) |
| 62 | + |
| 63 | +action = 'auto' |
| 64 | +if rrp > 990: |
| 65 | + action = 'export' |
| 66 | + reason = f'RRP {rrp} is high, exporting' |
| 67 | +reason = f'Default to auto: {solar_charge_time} soc {night_reserve:.0f}%->{soc_diff:.1f}%, time left {time_left:.1f}h, battery soc {battery_soc:.1f}%' |
| 68 | +global_tilted_irradiance_today = sum(gti) |
| 69 | +if global_tilted_irradiance_today > (GOOD_SUN_DAY * 100): |
| 70 | + if interval_time.hour < 12: |
| 71 | + low_buy_price = min(buy_forecast) |
| 72 | + if rrp < 0: |
| 73 | + action = 'charge' |
| 74 | + elif action in ['auto', 'charge', 'import'] and sell_price > low_buy_price: |
| 75 | + action = 'discharge' |
| 76 | + reason += ' not charging too early' |
| 77 | + |
| 78 | + if interval_time.hour < 10: |
| 79 | + if battery_soc > MORNING_SELL_SOC: |
| 80 | + low_buy_price = min(buy_forecast) |
| 81 | + if sell_price > (low_buy_price + MORNING_SELL_MARGIN): |
| 82 | + action = 'export' |
| 83 | + reason += f' lots of SOC, good sun and better buys coming {low_buy_price}c' |
| 84 | + else: |
| 85 | + reason += f' low buy not enough {low_buy_price}c' |
| 86 | +else: |
| 87 | + reason += f' low PV day {global_tilted_irradiance_today:.2f}W/m2' |
| 88 | + |
| 89 | +if 14 < interval_time.hour < 16 and battery_soc < 60 and action != 'import' and buy_price < 30: |
| 90 | + if buy_forecast and buy_price > min(buy_forecast[:6]): |
| 91 | + reason += ' wait for lower buy soon' |
20 | 92 | else: |
21 | | - action = 'auto' |
22 | | - reason = 'nsw: default to auto mode between midnight and 6am' |
23 | | -# Stop charging/discharging between 6 AM and 1 PM |
24 | | -if 6 <= hour < 13: |
25 | | - if sell_price > sell_price_threshold_1: |
26 | | - action = 'export' |
27 | | - reason = f'nsw: sell price greater than {sell_price_threshold_1} cents between 6am and 1pm' |
28 | | - elif battery_soc > 50 and buy_price > 0: |
29 | | - action = 'auto' |
30 | | - reason = 'nsw: buy price is over 0 wait for afternoon to buy' |
31 | | - elif buy_price < buy_price_morning: |
32 | 93 | action = 'import' |
33 | | - reason = f'nsw: buy price less than {buy_price_morning} cents between 6am and 1pm' |
34 | | - else: |
| 94 | + reason += ' panic buy SOC < 50' |
| 95 | + |
| 96 | +global_tilted_irradiance_past = sum(gti[:interval_time.hour]) |
| 97 | +global_tilted_irradiance_to_2pm = sum(gti[:15]) |
| 98 | +reason += f" tomorrow PV {global_tilted_irradiance_tomorrow:.1f}W/m2" |
| 99 | +if 4 < interval_time.hour < 16 and buy_forecast and battery_soc: |
| 100 | + charge_fors = max(1, int(6 * battery_soc / 100)) |
| 101 | + low_buy_price = round(max(sorted(buy_forecast)[:charge_fors]), 2) |
| 102 | + precent_pv_past = round(global_tilted_irradiance_past / global_tilted_irradiance_to_2pm * 100, 2) |
| 103 | + reason += f' pv {precent_pv_past}% vs {battery_soc}%' |
| 104 | + tolerant_low_price = round(low_buy_price * ((100 + IMPORT_TOLERANCE) / 100), 2) |
| 105 | + if action in ['auto', 'charge'] and (battery_soc - ALWAYS_IMPORT_SOC) < precent_pv_past: |
| 106 | + if buy_forecast and buy_price > min(buy_forecast[:6]) and battery_soc > 85: |
| 107 | + reason += ' wait for lower buy soon' |
| 108 | + elif buy_price < tolerant_low_price: |
| 109 | + action = 'import' |
| 110 | + reason += f' buy price {buy_price} is lower than {tolerant_low_price}' |
| 111 | + else: |
| 112 | + if action == 'import': |
| 113 | + action = 'auto' |
| 114 | + reason += f' wait on {action} not import {tolerant_low_price}' |
| 115 | + if action == 'import' and current_hour < 10 and global_tilted_irradiance_to_2pm > 4000: |
35 | 116 | action = 'auto' |
36 | | - reason = 'nsw: default to auto mode between 6am and 1pm' |
37 | | -# Stop charging/discharging between 6 AM and 1 PM |
38 | | -if 13 <= hour < 15: |
39 | | - if sell_price > sell_price_threshold_2: |
40 | | - action = 'export' |
41 | | - reason = f'nsw: sell price greater than {sell_price_threshold_2} cents between 6am and 1pm' |
42 | | - elif battery_soc < 60 and buy_price < sell_price_threshold_2: |
43 | | - action = 'import' |
44 | | - reason = f'nsw: buy price less than {sell_price_threshold_2} cents between 1pm and 3pm' |
45 | | - else: |
| 117 | + reason += f' wait for more sun before importing {global_tilted_irradiance_to_2pm} to go' |
| 118 | +if action == 'export' and current_hour > 16 and battery_soc < min_soc: |
| 119 | + action = 'auto' |
| 120 | + reason += f' battery SOC < {min_soc}%' |
| 121 | +# no point exporting unless this sell price is |
| 122 | +# lower than the sorted forecasted sell prices |
| 123 | +windows_can_export = int(battery_soc / (100 - BATTERY_EXPORT_SOC_THRESHOLD)) |
| 124 | +if sell_forecast and action == 'export': |
| 125 | + # Count sells above the current sell_price |
| 126 | + sorted_sell_forecast = sorted(sell_forecast) |
| 127 | + cut_off = sorted_sell_forecast[min(windows_can_export, len(sorted_sell_forecast) - 1)] |
| 128 | + max_buy_forecast = max(buy_forecast) |
| 129 | + if sell_price < (max_buy_forecast * BUY_DELTA_THRESHOLD / 100): |
46 | 130 | action = 'auto' |
47 | | - reason = 'nsw: default to auto mode between 6am and 1pm' |
48 | | -if rrp < 0: |
49 | | - feed_in_limitation = 0 |
50 | | - reason += f' setting feed in to {feed_in_limitation}' |
51 | | -# Ensure 'auto' action during peak demand times from 3 PM to 9 PM, unless sell_price > 20 cents |
52 | | -elif 15 <= hour < 21: |
53 | | - if sell_price > sell_price_threshold and battery_soc >= desired_soc: |
54 | | - action = 'export' |
55 | | - reason = f'nsw: sell price greater than {sell_price_threshold} cents during peak hours' |
56 | | - else: |
| 131 | + reason += f' sell price {sell_price} is higher than buy price {max_buy_forecast}' |
| 132 | + elif sell_price < (cut_off - CUT_OFF_THRESHOLD): |
57 | 133 | action = 'auto' |
58 | | - reason = f'nsw: hour between 3pm and 9pm or battery SOC below {desired_soc}%' |
59 | | -# Manage battery between 9 PM and midnight |
60 | | -if 21 <= hour < 24: |
61 | | - if sell_price > sell_price_threshold: |
62 | | - action = 'export' |
63 | | - reason = f'nsw: sell price greater than {sell_price_threshold} cents between 9pm and midnight' |
64 | | - elif buy_price < 10 and battery_soc < 30: |
65 | | - action = 'import' |
66 | | - reason = 'nsw: buy price less than 10 cents between 9pm and midnight' |
| 134 | + reason += f' sell price {sell_price} is lower than 3c within {cut_off}' |
67 | 135 | else: |
68 | | - action = 'auto' |
69 | | - reason = 'nsw: default to auto mode between 9pm and midnight' |
| 136 | + reason += f' okay to export {windows_can_export}' |
70 | 137 |
|
71 | | -if (interval_time.hour > 15) and battery_soc > 80 and sell_price > 10: |
72 | | - best_upcoming = max(sell_forecast) |
73 | | - if best_upcoming < (sell_price + 5): |
| 138 | +if action == 'export' and battery_soc > night_reserve and interval_time > sunset and buy_price < 98: |
| 139 | + action = 'auto' |
| 140 | + reason += ' in night reserve mode' |
| 141 | + if buy_price > 25: |
74 | 142 | action = 'export' |
75 | | - reason = f'nsw: {best_upcoming} < sell within 5c of max' |
76 | | - else: |
77 | | - reason += f' best upcoming: {best_upcoming}c' |
| 143 | + solar = 'curtail' |
| 144 | + feed_in_power_limitation = 1000 |
| 145 | + reason += f' aim higher during high prices {feed_in_power_limitation}' |
78 | 146 |
|
79 | | -if (hour < 5) and battery_soc > desired_soc and sell_price > 20: |
80 | | - action = 'export' |
81 | | - reason = f'nsw: pre 5am use it or lose it down to {desired_soc}%' |
82 | | - |
83 | | -if (hour > 21 or hour < 5) and (battery_soc < desired_soc): |
84 | | - if (buy_price < buy_top_up_price): |
85 | | - best_upcoming = min(buy_forecast) |
86 | | - if buy_price < (best_upcoming + 2): |
87 | | - action = 'import' |
88 | | - reason = f'nsw: low soc and price under top up and within 2 cents of best upcoming {best_upcoming}' |
89 | | - else: |
90 | | - reason += f' not within 2 cents of best {best_upcoming}' |
91 | | - else: |
92 | | - reason += f' waiting to top up {buy_price} < {buy_top_up_price}' |
| 147 | +if sell_forecast and buy_forecast and soc_diff_remaining > 25: |
| 148 | + high_sell_price = max(sorted(sell_forecast)[:-3]) if sell_forecast else 0 |
| 149 | + if high_sell_price < (sell_price + 5): |
| 150 | + action = 'export' |
| 151 | + reason += f' export: with 5c of high_sell_price {high_sell_price}c/kWh' |
| 152 | +if sell_forecast and buy_forecast and soc_diff_remaining > 15: |
| 153 | + high_sell_price = max(sorted(sell_forecast)[:-3]) if sell_forecast else 0 |
| 154 | + low_buy_price = min(buy_forecast) * 1.3 |
| 155 | + if sell_price > low_buy_price: |
| 156 | + action = 'export' |
| 157 | + reason += f' export: sell price {sell_price} is higher than low buy price {low_buy_price}c/kWh' |
| 158 | +if not daytime and time_left < 2 and soc_diff_remaining > 0: |
| 159 | + high_sell_price = max(sorted(sell_forecast)[:-3]) if sell_forecast else 0 |
| 160 | + if sell_price > (high_sell_price - 5): |
| 161 | + action = 'export' |
| 162 | + reason += f' export: sell price {sell_price} is higher than high sell price {high_sell_price}c/kWh' |
93 | 163 |
|
94 | | -if 4 < hour < 8 and interval_time < solar_production_time and battery_soc > 10 and sell_price > 15: |
95 | | - action = 'export' |
96 | | - reason += f'nsw: before solar production time use it or lose it {solar_production_time}' |
| 164 | +if sell_forecast and soc_diff < 5: |
| 165 | + # Now we only want to export if the RRP is high enough |
| 166 | + high_sell_price = max(sorted(sell_forecast)[:-3]) if sell_forecast else 0 |
| 167 | + if sell_price < high_sell_price and action == 'export': |
| 168 | + action = 'auto' |
| 169 | + reason += f' waiting for {high_sell_price}c, battery SOC {battery_soc:.1f}%' |
97 | 170 |
|
98 | | -if rrp > 800 and battery_soc > min_sell_soc: |
99 | | - action = 'export' |
100 | | - reason += f'take the money down to {min_sell_soc}%' |
| 171 | +# Spike Hacking |
| 172 | +if forecast and action == 'export': |
| 173 | + over_count = int(np.sum(np.array(forecast) > (rrp + 2000))) |
| 174 | + is_spike = (max(forecast) - rrp) > 1000 |
| 175 | + if rrp > 1000: |
| 176 | + action = 'export' |
| 177 | + feed_in_power_limitation = 20000 |
| 178 | + reason += f' exporting at ${rrp}/MWH, feed in power limitation {feed_in_power_limitation}W' |
| 179 | + elif is_spike and over_count > 1: |
| 180 | + action = 'auto' |
| 181 | + reason += f' not exporting, {over_count} prices over sell price {sell_price}c' |
| 182 | + else: |
| 183 | + reason += f' exporting at ${rrp}/MWH' |
| 184 | +if battery_soc and battery_soc < 10 and action == 'export': |
| 185 | + action = 'auto' |
| 186 | + reason += ' battery SOC < 7%' |
| 187 | +# Stop always exporting if battery SOC is low |
| 188 | +always_export_rrp = 1000 |
| 189 | +if soc_diff < 10: |
| 190 | + always_export_rrp = None |
| 191 | +elif battery_soc < (soc_diff + 20): |
| 192 | + always_export_rrp = 10000 |
| 193 | + reason += f" increasing always_export_rrp: {always_export_rrp}" |
| 194 | +if battery_soc < night_reserve: |
| 195 | + always_export_rrp = None |
| 196 | + reason += f" remove always_export_rrp under night reserve: {night_reserve:.1f}%" |
0 commit comments