Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 75 additions & 8 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2272,6 +2272,65 @@ def rate_minmax(self, rates):

return dp2(rate_min), dp2(rate_max), dp2(rate_average), rate_min_minute, rate_max_minute

def rate_minmax_excluding_saving(self, rates, rate_replicate):
"""
Work out min/max/average over the forecast window, excluding only "saving"-tagged
minutes that are inflated relative to the tariff's own rates - not every minute the
"saving" tag touches.

"saving" (set by load_saving_slot()/load_axle_slot()/load_free_slot()) marks two
economically opposite things with the same string: a saving-session/Axle event REWARD,
added on top of the tariff rate (GH#5050 - a synthetic one-off high that should not be
allowed to raise the automatic threshold above every genuine tariff rate), and a
free/discounted import session, which SUBTRACTS from or floors the rate (a genuinely
cheap slot the automatic threshold should be free to pick as "low rate"). Excluding every
"saving" minute regardless of direction throws the cheap ones away too - with a flat
tariff plus one free slot, that leaves only the flat rate, rate_max == rate_min, and
set_rate_thresholds() takes its rate_max + 0.1 branch, which sets the threshold above
every rate and makes the whole ordinary-price day read as "low" (Copilot review on
#5052 - reproduced with exactly that scenario before fixing).

Two passes: the first pass over the untagged minutes alone sets a baseline max. The
second pass then excludes a "saving"-tagged minute only if its rate exceeds that
baseline max - the one shape (a reward) that can distort the automatic threshold - and
admits every other minute, tagged or not, including a "saving"-tagged discount at or
below the baseline: a lower rate never inflates rate_max, and rate_min existing to reach
it is the whole point of a free/discount session.

Falls back to the plain (unfiltered) min/max/average when every minute in range is tagged
"saving" - an event that covers the whole forecast window leaves no genuine tariff minute to
scan, and a 99999/0/0 result would make every downstream comparison in set_rate_thresholds()
behave as if there were no data at all, which is worse than the boosted-but-real numbers.
"""
baseline_max = 0
baseline_n = 0

for minute in range(self.minutes_now, self.forecast_minutes + self.minutes_now):
if minute in rates and rate_replicate.get(minute) != "saving":
baseline_max = max(baseline_max, rates[minute])
baseline_n += 1

if baseline_n == 0:
return self.rate_minmax(rates)[:3]

rate_min = 99999
rate_max = 0
rate_total = 0
rate_n = 0

for minute in range(self.minutes_now, self.forecast_minutes + self.minutes_now):
if minute not in rates:
continue
rate = rates[minute]
if rate_replicate.get(minute) == "saving" and rate > baseline_max:
Comment thread
chalfontchubby marked this conversation as resolved.
Outdated
continue
rate_min = min(rate_min, rate)
rate_max = max(rate_max, rate)
rate_total += rate
rate_n += 1

return dp2(rate_min), dp2(rate_max), dp2(rate_total / rate_n)

def rate_base_min_max(self, rates):
"""
Gap-fill `rates` into a "base" import curve (replicated, but without IO-slot/saving-session/
Expand Down Expand Up @@ -2357,28 +2416,36 @@ def set_rate_thresholds(self):
car_planning_on_rates = self.num_cars > 0 and not self.octopus_intelligent_charging
car_charging_max_price = max(self.car_charging_plan_max_price[: self.num_cars]) if car_planning_on_rates else 0.0

# Threshold stats are computed over the tariff's own rates, excluding minutes a saving
# session / Axle VPP event boosted (GH#5050) - self.rate_min/rate_max/rate_average (and the
# export equivalents) include those synthetic minutes and are used elsewhere (dashboard
# sensors, graph scaling, plan.py pricing) where the real boosted price is exactly what is
# wanted, so this is a separate, narrower scan rather than a change to those.
rate_min, rate_max, rate_average = self.rate_minmax_excluding_saving(self.rate_import, self.rate_import_replicated)
rate_export_min, rate_export_max, rate_export_average = self.rate_minmax_excluding_saving(self.rate_export, self.rate_export_replicated)

if self.rate_low_threshold > 0:
self.rate_import_cost_threshold = dp2(self.rate_average * self.rate_low_threshold)
self.rate_import_cost_threshold = dp2(rate_average * self.rate_low_threshold)
else:
# In automatic mode select the only rate or everything but the most expensive
if (self.rate_max == self.rate_min) or (self.rate_export_max > self.rate_max) or have_alerts or have_manual_soc:
self.rate_import_cost_threshold = self.rate_max + 0.1
if (rate_max == rate_min) or (rate_export_max > rate_max) or have_alerts or have_manual_soc:
self.rate_import_cost_threshold = rate_max + 0.1
else:
self.rate_import_cost_threshold = self.rate_max - 0.5
self.rate_import_cost_threshold = rate_max - 0.5

# When we plan car on the rates we need to include all the rates up to the max car price
if car_planning_on_rates:
self.rate_import_cost_threshold = max(self.rate_import_cost_threshold, car_charging_max_price + 0.1)

# Compute the export rate threshold
if self.rate_high_threshold > 0:
self.rate_export_cost_threshold = dp2(self.rate_export_average * self.rate_high_threshold)
self.rate_export_cost_threshold = dp2(rate_export_average * self.rate_high_threshold)
else:
# In automatic mode select the only rate or everything but the most cheapest
if (self.rate_export_max == self.rate_export_min) or (self.rate_export_min > self.rate_min) or have_alerts:
self.rate_export_cost_threshold = self.rate_export_min - 0.1
if (rate_export_max == rate_export_min) or (rate_export_min > rate_min) or have_alerts:
self.rate_export_cost_threshold = rate_export_min - 0.1
else:
self.rate_export_cost_threshold = self.rate_export_min + 0.5
self.rate_export_cost_threshold = rate_export_min + 0.5

self.log(
"Rate thresholds (for charge/export) are import {}{} ({}{}), export {}{} ({}{})".format(
Expand Down
Loading
Loading