|
22 | 22 | from datetime import datetime, timedelta |
23 | 23 | from multiprocessing import Pool, cpu_count |
24 | 24 | from const import PREDICT_STEP, PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90, TIME_FORMAT, MINUTE_WATT |
| 25 | + |
| 26 | +# How many windows the post-settle plan pass revisits when calculate_second_pass is off. The near-term |
| 27 | +# windows are the ones about to be executed, so a small budget keeps the common path cheap; raising it |
| 28 | +# picks up value further out at a proportional cost in planning time. |
| 29 | +PLAN_PASS_WINDOW_BUDGET = 8 |
25 | 30 | from utils import calc_percent_limit, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, calc_percent_limit, in_car_slot |
26 | 31 | from prediction import Prediction, wrapped_run_prediction_single, wrapped_run_prediction_charge, wrapped_run_prediction_charge_min_max, wrapped_run_prediction_export, wrapped_run_prediction_charge_min_max |
27 | 32 | from prediction_kernel import kernel_status_summary |
@@ -2874,19 +2879,30 @@ def update_target_values(self): |
2874 | 2879 | for window_n in range(len(self.charge_limit_best)): |
2875 | 2880 | self.charge_window_best[window_n]["target"] = self.charge_limit_best[window_n] |
2876 | 2881 |
|
2877 | | - def tweak_plan(self, end_record): |
2878 | | - """ |
2879 | | - Tweak existing plan only |
| 2882 | + def optimise_plan_pass(self, end_record, budget=0, debug_mode=False): |
| 2883 | + """Re-optimise each charge and export window of the settled plan, in time order. |
| 2884 | +
|
| 2885 | + This is the pass that runs after the levels and detailed passes have chosen the plan's shape. |
| 2886 | + Each window is re-optimised against the whole plan and the change is kept only when it improves |
| 2887 | + on the plan we were handed, so the pass is monotonic. |
| 2888 | +
|
| 2889 | + budget caps how many windows are visited; 0 visits every window in the record. The cheap default |
| 2890 | + exists because the near-term windows are the ones about to be executed, but the cap is what makes |
| 2891 | + the fast path miss value further out - see calculate_second_pass, which runs unbudgeted. |
| 2892 | +
|
| 2893 | + The metric is measured from the plan we were handed rather than taken from the caller: |
| 2894 | + optimise_swap_export runs before this and mutates the plan without its return value being used, |
| 2895 | + so the caller's metric is already stale. |
2880 | 2896 |
|
2881 | | - The metric is measured from the plan we were handed rather than taken from the caller: optimise_swap_export |
2882 | | - runs immediately before this and mutates the plan without its return value being used, so the caller's |
2883 | | - metric is already stale. Each window change below is then only kept when it improves on that baseline. |
| 2897 | + Each export window's start is reset to start_orig before it is re-optimised. Without that reset a |
| 2898 | + window trimmed by an earlier pass can only ever be trimmed further, so the pass cannot recover a |
| 2899 | + window it narrowed on a plan that has since changed underneath it. |
2884 | 2900 | """ |
2885 | 2901 | record_charge_windows = max(self.max_charge_windows(end_record + self.minutes_now, self.charge_window_best), 1) |
2886 | 2902 | record_export_windows = max(self.max_charge_windows(end_record + self.minutes_now, self.export_window_best), 1) |
2887 | 2903 | selected = self.plan_metric_now(end_record) |
2888 | 2904 | curr = self.currency_symbols[1] |
2889 | | - self.log("Tweak plan optimisation started metric {}{}, cost {}{}".format(dp2(selected[0]), curr, dp2(selected[1]), curr)) |
| 2905 | + self.log("Plan pass optimisation started metric {}{}, cost {}{}, budget {}".format(dp2(selected[0]), curr, dp2(selected[1]), curr, budget if budget else "unlimited")) |
2890 | 2906 | count = 0 |
2891 | 2907 | window_sorted, window_index = self.sort_window_by_time_combined(self.charge_window_best[:record_charge_windows], self.export_window_best[:record_export_windows]) |
2892 | 2908 | for key in window_sorted: |
@@ -2930,16 +2946,20 @@ def tweak_plan(self, end_record): |
2930 | 2946 | self.export_window_best[window_n]["start"] = best_start |
2931 | 2947 | candidate = (best_metric_plan, best_cost, best_keep, best_cycle, best_carbon, best_import) |
2932 | 2948 | selected = self.keep_window_change_if_improved(selected, candidate, typ, window_n, snapshot) |
| 2949 | + if (count % 16) == 0 and self.debug_enable: |
| 2950 | + log_metric, log_cost, log_keep, log_cycle, log_carbon, log_import = selected |
| 2951 | + self.log("Plan pass type {} window {} metric {} metric_keep {} carbon {} import {} cost {}".format(typ, window_n, log_metric, dp2(log_keep), dp0(log_carbon), dp2(log_import), dp2(log_cost))) |
2933 | 2952 | count += 1 |
2934 | | - if count >= 8: |
| 2953 | + if budget and count >= budget: |
2935 | 2954 | break |
2936 | 2955 |
|
2937 | 2956 | best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = selected |
2938 | 2957 | self.log( |
2939 | | - "Tweak optimisation finished metric {}{}, cost {}{}, metric_keep {}kWh, cycle {}kWh, carbon {}kg, import {}kWh, changed {} window(s)".format( |
| 2958 | + "Plan pass optimisation finished metric {}{}, cost {}{}, metric_keep {}kWh, cycle {}kWh, carbon {}kg, import {}kWh, visited {} window(s)".format( |
2940 | 2959 | dp2(best_metric), curr, dp2(best_cost), curr, dp2(best_keep), dp2(best_cycle), dp0(best_carbon), dp2(best_import), count |
2941 | 2960 | ) |
2942 | 2961 | ) |
| 2962 | + self.plan_write_debug(debug_mode, "plan_pass.html", self.pv_forecast_minute_step, self.pv_forecast_minute10_step, self.load_minutes_step, self.load_minutes_step10, end_record) |
2943 | 2963 | return best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import |
2944 | 2964 |
|
2945 | 2965 | def plan_write_debug(self, debug_mode, name, pv_forecast_minute_step, pv_forecast_minute10_step, load_minutes_step, load_minutes_step10, end_record, test=False, prediction=None): |
@@ -3503,66 +3523,6 @@ def allow_this_export_window(self, export_window_n): |
3503 | 3523 | return True |
3504 | 3524 | return False |
3505 | 3525 |
|
3506 | | - def optimise_full_second_pass(self, best_metric, best_cost, best_keep, best_soc_min, best_cycle, best_carbon, best_import, best_battery_value, record_charge_windows, record_export_windows, debug_mode=False): |
3507 | | - """ |
3508 | | - Second pass optimisation of the charge and export windows |
3509 | | - """ |
3510 | | - self.log("Second pass optimisation started") |
3511 | | - |
3512 | | - # Baseline the plan we were handed, for the same reason as tweak_plan: optimise_swap_export has already |
3513 | | - # mutated it since the caller's metric was measured. |
3514 | | - selected = self.plan_metric_now(self.end_record) |
3515 | | - |
3516 | | - count = 0 |
3517 | | - window_sorted, window_index = self.sort_window_by_time_combined(self.charge_window_best[:record_charge_windows], self.export_window_best[:record_export_windows]) |
3518 | | - for key in window_sorted: |
3519 | | - typ = window_index[key]["type"] |
3520 | | - window_n = window_index[key]["id"] |
3521 | | - if typ == "c": |
3522 | | - if self.allow_this_charge_window(window_n): |
3523 | | - snapshot = self.plan_window_snapshot(typ, window_n) |
3524 | | - best_soc, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import, best_metric_plan = self.optimise_charge_limit( |
3525 | | - window_n, |
3526 | | - record_charge_windows, |
3527 | | - self.charge_limit_best, |
3528 | | - self.charge_window_best, |
3529 | | - self.export_window_best, |
3530 | | - self.export_limits_best, |
3531 | | - end_record=self.end_record, |
3532 | | - ) |
3533 | | - self.charge_limit_best[window_n] = best_soc |
3534 | | - candidate = (best_metric_plan, best_cost, best_keep, best_cycle, best_carbon, best_import) |
3535 | | - selected = self.keep_window_change_if_improved(selected, candidate, typ, window_n, snapshot) |
3536 | | - elif typ == "d": |
3537 | | - if self.allow_this_export_window(window_n): |
3538 | | - snapshot = self.plan_window_snapshot(typ, window_n) |
3539 | | - best_soc, best_start, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import, best_metric_plan = self.optimise_export( |
3540 | | - window_n, |
3541 | | - record_export_windows, |
3542 | | - self.charge_limit_best, |
3543 | | - self.charge_window_best, |
3544 | | - self.export_window_best, |
3545 | | - self.export_limits_best, |
3546 | | - end_record=self.end_record, |
3547 | | - ) |
3548 | | - self.export_limits_best[window_n] = best_soc |
3549 | | - self.export_window_best[window_n]["start_orig"] = self.export_window_best[window_n].get("start_orig", self.export_window_best[window_n]["start"]) |
3550 | | - self.export_window_best[window_n]["start"] = best_start |
3551 | | - candidate = (best_metric_plan, best_cost, best_keep, best_cycle, best_carbon, best_import) |
3552 | | - selected = self.keep_window_change_if_improved(selected, candidate, typ, window_n, snapshot) |
3553 | | - if (count % 16) == 0: |
3554 | | - log_metric, log_cost, log_keep, log_cycle, log_carbon, log_import = selected |
3555 | | - self.log("Final optimisation type {} window {} metric {} metric_keep {} best_carbon {} best_import {} cost {}".format(typ, window_n, log_metric, dp2(log_keep), dp0(log_carbon), dp2(log_import), dp2(log_cost))) |
3556 | | - count += 1 |
3557 | | - |
3558 | | - # best_battery_value and best_soc_min stay as the caller passed them in, as they did before this pass |
3559 | | - # measured its own metric |
3560 | | - best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = selected |
3561 | | - self.log("Second pass optimisation finished metric {} cost {} metric_keep {} cycle {} carbon {} import {}".format(best_metric, dp2(best_cost), dp2(best_keep), dp2(best_cycle), dp0(best_carbon), dp2(best_import))) |
3562 | | - |
3563 | | - self.plan_write_debug(debug_mode, "plan_pass2.html", self.pv_forecast_minute_step, self.pv_forecast_minute10_step, self.load_minutes_step, self.load_minutes_step10, self.end_record) |
3564 | | - return best_metric, best_cost, best_keep, best_soc_min, best_cycle, best_carbon, best_import, best_battery_value |
3565 | | - |
3566 | 3526 | def optimise_detailed_pass( |
3567 | 3527 | self, |
3568 | 3528 | best_price_charge, |
@@ -4126,26 +4086,20 @@ def optimise_all_windows(self, best_metric, metric_keep, debug_mode=False): |
4126 | 4086 | record_export_windows, |
4127 | 4087 | debug_mode=debug_mode, |
4128 | 4088 | ) |
4129 | | - # Second pass optimisation |
4130 | | - if self.calculate_second_pass: |
4131 | | - # Full second pass (slower) |
4132 | | - best_metric, best_cost, best_keep, best_soc_min, best_cycle, best_carbon, best_import, best_battery_value = self.optimise_full_second_pass( |
4133 | | - best_metric, best_cost, best_keep, best_soc_min, best_cycle, best_carbon, best_import, best_battery_value, record_charge_windows, record_export_windows, debug_mode=debug_mode |
4134 | | - ) |
4135 | | - else: |
4136 | | - # Tweak plan (faster) |
4137 | | - best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = self.tweak_plan(self.end_record) |
| 4089 | + # Re-optimise each window of the settled plan. calculate_second_pass lifts the window budget so |
| 4090 | + # every window in the record is revisited rather than just the near-term ones (slower). |
| 4091 | + best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = self.optimise_plan_pass(self.end_record, budget=0 if self.calculate_second_pass else PLAN_PASS_WINDOW_BUDGET, debug_mode=debug_mode) |
4138 | 4092 |
|
4139 | 4093 | # Export more solar - enable freeze export on idle solar windows if it doesn't cost too much |
4140 | 4094 | if self.export_more_solar: |
4141 | 4095 | best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = self.optimise_solar(best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import, record_export_windows, debug_mode=debug_mode) |
4142 | 4096 |
|
4143 | 4097 | # Swaps run once all other passes have settled. The export swap can only defer an export that |
4144 | | - # already exists when it runs, and tweak/second/solar all turn exports on - tweak_plan only walks |
4145 | | - # the first few windows of the plan, so the exports it adds are always at the front, exactly the |
4146 | | - # ones the swap exists to push back. Running the swap before them left those pinned in place |
4147 | | - # (#4478). The charge swap follows for the mirror-image reason: a strictly-improving pairwise |
4148 | | - # charge move must not be subsequently undone by a non-monotonic pass. |
| 4098 | + # already exists when it runs, and the plan pass and solar pass both turn exports on - on the |
| 4099 | + # budgeted plan pass the windows it reaches are the near-term ones, so the exports it adds are at |
| 4100 | + # the front, exactly the ones the swap exists to push back. Running the swap before them left |
| 4101 | + # those pinned in place (#4478). The charge swap follows for the mirror-image reason: a |
| 4102 | + # strictly-improving pairwise charge move must not be subsequently undone by a non-monotonic pass. |
4149 | 4103 | self.optimise_swap_export(record_charge_windows, record_export_windows, debug_mode=debug_mode) |
4150 | 4104 | self.plan_write_debug(debug_mode, "plan_swap_final.html", self.pv_forecast_minute_step, self.pv_forecast_minute10_step, self.load_minutes_step, self.load_minutes_step10, self.end_record) |
4151 | 4105 | self.optimise_swap_charge(record_charge_windows, debug_mode=debug_mode) |
|
0 commit comments