Skip to content

Commit 38485b4

Browse files
feat: strengthen Octopus Intelligent earlier-charge skew
Replace the flat io_adjusted penalty in sort_window_by_price_combined with a signed per-run gradient: the earliest slots of a contiguous IOG (planned-dispatch) run are discounted so they rank below equally-priced firm slots and are filled first, while the latest slots keep the penalty so distant, more-likely-to-vanish IOG slots are not relied upon. Firm slots sit neutrally at the pivot. The discount only applies to imminent slots (within a horizon of now); distant future dispatch periods keep only the penalty side until they draw closer, re-evaluated each cycle. Adds _io_run_starts and _io_rate_adjustment helpers plus tunable constants, and a new test module covering the helpers, the sort wiring, and the plan-level skew. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf8f848 commit 38485b4

3 files changed

Lines changed: 463 additions & 8 deletions

File tree

apps/predbat/plan.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@
2828
from predbat_metrics import metrics
2929
import 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

3247
def 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

Comments
 (0)