2828from predbat_metrics import metrics
2929import time
3030
31+ # Octopus Intelligent (IOG) charge-skew gradient.
32+ # io_adjusted (planned-dispatch) slots are at-risk: Octopus may move or remove them later.
33+ # Instead of a flat penalty we apply a signed per-hour gradient across each contiguous IOG
34+ # run: the earliest slots are discounted (ranked below equally-priced firm slots, so they
35+ # are filled first) while the latest slots are penalised (so distant, more-likely-to-vanish
36+ # IOG slots are not relied upon). Firm slots sit neutrally in the middle at the pivot point.
37+ # The discount only applies to imminent slots (within IO_ADJUST_DISCOUNT_HORIZON_HOURS of
38+ # now); distant future dispatch periods keep only the penalty side until they draw closer,
39+ # which is re-evaluated every optimisation cycle.
40+ IO_ADJUST_SLOPE = 1.0 # Pence per hour into the IOG run
41+ IO_ADJUST_PIVOT_HOURS = 1.5 # Hours into the run where the adjustment crosses zero (firm level)
42+ IO_ADJUST_MAX_DISCOUNT = 3.0 # Maximum pence discount applied to the earliest IOG slots
43+ IO_ADJUST_MAX_PENALTY = 10.0 # Maximum pence penalty applied to the latest IOG slots
44+ IO_ADJUST_DISCOUNT_HORIZON_HOURS = 3.0 # Only discount IOG slots that start within this many hours of now
45+
3146
3247def slots_around (target_slots , slot_lengths ):
3348 """
@@ -1915,6 +1930,46 @@ def sort_window_by_time(self, windows):
19151930 window_sorted .sort (key = self .window_sort_func_start )
19161931 return window_sorted
19171932
1933+ def _io_run_starts (self , windows ):
1934+ """
1935+ Map each io_adjusted (Octopus Intelligent) window start to the start minute of its
1936+ contiguous IOG run.
1937+
1938+ A run is a maximal sequence of io_adjusted windows that are contiguous in time
1939+ (each window's start equal to the previous window's end). A firm window or a time
1940+ gap breaks the run. Firm windows are not included in the result. Input windows may
1941+ arrive in any order.
1942+ """
1943+ io_windows = sorted ((w for w in windows if self .io_adjusted .get (w ["start" ], False )), key = lambda w : w ["start" ])
1944+ run_starts = {}
1945+ run_start = None
1946+ prev_end = None
1947+ for window in io_windows :
1948+ start = window ["start" ]
1949+ if run_start is None or start != prev_end :
1950+ run_start = start
1951+ run_starts [start ] = run_start
1952+ prev_end = window ["end" ]
1953+ return run_starts
1954+
1955+ def _io_rate_adjustment (self , window_start , run_start ):
1956+ """
1957+ Return the signed rate adjustment (pence) for an io_adjusted window.
1958+
1959+ Earliest slots in the run are discounted (negative) so they rank below equally-priced
1960+ firm slots and are filled first; latest slots are penalised (positive) so distant IOG
1961+ slots are not relied upon. The discount is only applied to imminent slots (starting
1962+ within IO_ADJUST_DISCOUNT_HORIZON_HOURS of now); the penalty side always applies.
1963+ """
1964+ hours_in = (window_start - run_start ) / 60.0
1965+ hours_ahead = max (window_start - self .minutes_now , 0 ) / 60.0
1966+ gradient = (hours_in - IO_ADJUST_PIVOT_HOURS ) * IO_ADJUST_SLOPE
1967+ gradient = max (- IO_ADJUST_MAX_DISCOUNT , min (IO_ADJUST_MAX_PENALTY , gradient ))
1968+ if hours_ahead > IO_ADJUST_DISCOUNT_HORIZON_HOURS :
1969+ # Distant period: suppress the discount but keep any penalty
1970+ gradient = max (gradient , 0.0 )
1971+ return gradient
1972+
19181973 def sort_window_by_price_combined (self , charge_windows , export_windows , calculate_import_low_export = False , calculate_export_high_import = False ):
19191974 """
19201975 Sort windows into price sets
@@ -1927,6 +1982,7 @@ def sort_window_by_price_combined(self, charge_windows, export_windows, calculat
19271982 pv_forecast_minute_step = self .prediction .pv_forecast_minute_step
19281983
19291984 # Add charge windows
1985+ charge_io_run_starts = self ._io_run_starts (charge_windows )
19301986 if self .calculate_best_charge :
19311987 id = 0
19321988 for window in charge_windows :
@@ -1935,10 +1991,9 @@ def sort_window_by_price_combined(self, charge_windows, export_windows, calculat
19351991 if self .carbon_enable :
19361992 carbon_intensity = self .carbon_intensity .get (max (window ["start" ] - self .minutes_now , 0 ), 0 )
19371993 average += dp1 (carbon_intensity * self .carbon_metric / 1000.0 )
1938- is_adjusted = self .io_adjusted .get (window ["start" ], False )
1939- if is_adjusted :
1940- # The risk that IOG adjusted slots will disappear means we should penalise them based on how far in the future they are
1941- average += min ((max (window ["start" ] - self .minutes_now , 0 ) // 60 ), 10 )
1994+ if window ["start" ] in charge_io_run_starts :
1995+ # IOG (planned-dispatch) slot: apply the earlier-charge skew gradient
1996+ average += self ._io_rate_adjustment (window ["start" ], charge_io_run_starts [window ["start" ]])
19421997 average += self .metric_self_sufficiency
19431998 average = dp2 (average ) # Round to nearest 0.01 penny to avoid too many bands
19441999 if calculate_import_low_export :
@@ -1972,6 +2027,7 @@ def sort_window_by_price_combined(self, charge_windows, export_windows, calculat
19722027 id += 1
19732028
19742029 # Add export windows
2030+ export_io_run_starts = self ._io_run_starts (export_windows )
19752031 if self .calculate_best_export :
19762032 id = 0
19772033 for window in export_windows :
@@ -1983,10 +2039,9 @@ def sort_window_by_price_combined(self, charge_windows, export_windows, calculat
19832039 average = dp1 (average ) # Round to nearest 0.01 penny to avoid too many bands
19842040 if calculate_export_high_import :
19852041 average_import = dp2 ((self .rate_import .get (window ["start" ], 0 ) + self .rate_import .get (window ["end" ] - PREDICT_STEP , 0 )) / 2 )
1986- is_adjusted = self .io_adjusted .get (window ["start" ], False )
1987- if is_adjusted :
1988- # The risk that IOG adjusted slots will disappear means we should penalise them based on how far in the future they are
1989- average_import += min ((max (window ["start" ] - self .minutes_now , 0 ) // 60 ), 10 )
2042+ if window ["start" ] in export_io_run_starts :
2043+ # IOG (planned-dispatch) slot on the import side: apply the earlier-charge skew gradient
2044+ average_import += self ._io_rate_adjustment (window ["start" ], export_io_run_starts [window ["start" ]])
19902045 else :
19912046 average_import = 0
19922047 window_start = window ["start" ]
0 commit comments