From 8a29b63a042ed05298532c031bdaef59e9842953 Mon Sep 17 00:00:00 2001 From: Liam Scanlon Date: Fri, 22 May 2026 04:55:55 +0000 Subject: [PATCH] rate_replicate: optional N-day historical-mean fallback (default off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds rate_history_days_average parameter. When > 0, predbat fetches N days of HA recorder history for the rate source entity, builds 48 half-hour-of-day mean buckets, and uses them as a fallback in rate_replicate() when neither 24h-back copy nor modulo lookup matches. Without this, users whose rate sensor only publishes a short forward window (Amber Australia: 16h; some fixed-tariff template sensors) fall to the rate_last constant repeat, which kills diurnal shape in the plan window past the sensor's coverage. Bucket builder uses step-function rate-at-time sampling: for each historical 30-min slot in the lookback window, the rate in effect at that slot start is sampled (latest history row with ts <= slot start). One sample per slot per day — no spike bias from HA's minimal_response returning only state-change events. Defaults: feature disabled (rate_history_days_average=0). Existing behavior unchanged when off. Tests: 9 new subtests covering disabled mode, empty history, all-bad states, scaling, diurnal shape recovery, spike resistance, and rate_replicate integration. Existing rate_replicate suite still passes. --- apps/predbat/config.py | 16 ++ apps/predbat/fetch.py | 131 ++++++++++- .../tests/test_rate_history_average.py | 214 ++++++++++++++++++ apps/predbat/unit_test.py | 2 + docs/apps-yaml.md | 3 + docs/energy-rates.md | 22 ++ 6 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 apps/predbat/tests/test_rate_history_average.py diff --git a/apps/predbat/config.py b/apps/predbat/config.py index e2eda8005..196460d75 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -461,6 +461,18 @@ "enable": "expert_mode", "default": 0.0, }, + { + "name": "rate_history_days_average", + "friendly_name": "Rate History Days Average", + "type": "input_number", + "min": 0, + "max": 30, + "step": 1, + "unit": "days", + "icon": "mdi:chart-bell-curve", + "enable": "expert_mode", + "default": 0, + }, { "name": "metric_inday_adjust_damping", "friendly_name": "In-day adjustment damping factor", @@ -2164,6 +2176,10 @@ "futurerate_adjust_import": {"type": "boolean"}, "futurerate_adjust_export": {"type": "boolean"}, "futurerate_adjust_auto": {"type": "boolean"}, + "rate_history_source_import": {"type": "string", "empty": False}, + "rate_history_source_export": {"type": "string", "empty": False}, + "rate_history_scaling_import": {"type": "float"}, + "rate_history_scaling_export": {"type": "float"}, "futurerate_peak_start": {"type": "string", "empty": False}, "futurerate_peak_end": {"type": "string", "empty": False}, "octopus_region": {"type": "string", "empty": False}, diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 5e8c92595..db116b17e 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -18,7 +18,8 @@ dictionaries for use by the prediction engine. """ -from datetime import datetime, timedelta +import bisect +from datetime import datetime, timedelta, timezone from utils import minutes_to_time, str2time, dp1, dp2, dp3, dp4, time_string_to_stamp, minute_data, get_now_from_cumulative from const import MINUTE_WATT, PREDICT_STEP, TIME_FORMAT, PREDBAT_MODE_OPTIONS, PREDBAT_MODE_CONTROL_SOC, PREDBAT_MODE_CONTROL_CHARGEDISCHARGE, PREDBAT_MODE_CONTROL_CHARGE, PREDBAT_MODE_MONITOR from predbat_metrics import metrics @@ -939,7 +940,17 @@ def fetch_sensor_data(self, save=True): self.rate_scan(self.rate_import, print=False) self.rate_max_base = self.rate_max # True peak rate before saving sessions / overrides inflate it self.rate_min_base = self.rate_min # True off-peak rate before free sessions / overrides deflate it - self.rate_import, self.rate_import_replicated = self.rate_replicate(self.rate_import, self.io_adjusted, is_import=True) + history_buckets_import = None + if self.rate_history_days_average > 0: + source = self.rate_history_source_import or self.get_arg("metric_octopus_import", indirect=False) + history_buckets_import = self.build_rate_history_buckets( + source, self.rate_history_days_average, scaling=self.rate_history_scaling_import + ) + if history_buckets_import: + self.log("Built rate history buckets from {} days of {}".format(self.rate_history_days_average, source)) + self.rate_import, self.rate_import_replicated = self.rate_replicate( + self.rate_import, self.io_adjusted, is_import=True, history_buckets=history_buckets_import + ) self.rate_import_no_io = self.rate_import.copy() for car_n in range(self.num_cars): self.rate_import = self.rate_add_io_slots(car_n, self.rate_import, self.octopus_slots[car_n]) @@ -957,7 +968,15 @@ def fetch_sensor_data(self, save=True): # Replicate and scan export rates if self.rate_export: self.rate_scan_export(self.rate_export, print=False) - self.rate_export, self.rate_export_replicated = self.rate_replicate(self.rate_export, is_import=False) + history_buckets_export = None + if self.rate_history_days_average > 0: + source = self.rate_history_source_export or self.get_arg("metric_octopus_export", indirect=False) + history_buckets_export = self.build_rate_history_buckets( + source, self.rate_history_days_average, scaling=self.rate_history_scaling_export + ) + self.rate_export, self.rate_export_replicated = self.rate_replicate( + self.rate_export, is_import=False, history_buckets=history_buckets_export + ) # For export tariff only load the saving session if enabled if self.rate_export_max > 0: self.load_saving_slot(self.octopus_saving_slots, export=True, rate_replicate=self.rate_export_replicated) @@ -1372,9 +1391,99 @@ def download_ge_data(self, now_utc): self.log("Downloaded {} datapoints from GECloudData going back {} days".format(len(self.load_minutes), self.load_minutes_age)) return True - def rate_replicate(self, rates, rate_io={}, is_import=True, is_gas=False): + def build_rate_history_buckets(self, entity_id, days, scaling=1.0): """ - We don't get enough hours of data for Octopus, so lets assume it repeats until told others + Build 48 half-hour-of-day mean rate buckets from N days of HA recorder history. + + For each historical half-hour slot in the lookback window, the rate in effect + at that slot's start is sampled via step-function lookup (latest history row + with timestamp <= slot start). This yields one sample per slot per day, + avoiding the spike bias that comes from counting state-change events directly + (HA's ``minimal_response`` history returns only state transitions, so a brief + spike contributes the same weight as a long plateau). + + Returns a ``{0..47: mean_rate}`` dict in the planner's rate units (after + applying ``scaling`` to the raw sensor state), or ``None`` if no usable + history is available. Empty buckets fall back to the overall mean across all + sampled slots so every bucket has a value. + + Args: + entity_id: HA entity to query (e.g. ``sensor.amber_general_price``). + days: Number of days of history to look back (must be > 0). + scaling: Multiplier applied to each raw state value to convert into the + planner's rate units (e.g. ``100.0`` to convert $/kWh to c/kWh). + """ + if not entity_id or days <= 0: + return None + + history = self.get_history_wrapper(entity_id=entity_id, days=days, required=False) + if not history or not history[0]: + self.log("Warn: rate_history_days_average enabled but no history for {}".format(entity_id)) + return None + + # Build a sorted timeline of (utc_timestamp, value) samples from history. + timeline = [] + for record in history[0]: + state = record.get("state") + if state in (None, "", "unknown", "unavailable"): + continue + try: + value = float(state) * scaling + except (TypeError, ValueError): + continue + ts_raw = record.get("last_changed") or record.get("last_updated") + if isinstance(ts_raw, datetime): + ts = ts_raw + elif isinstance(ts_raw, str): + try: + ts = datetime.fromisoformat(ts_raw.replace("Z", "+00:00")) + except ValueError: + continue + else: + continue + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + timeline.append((ts, value)) + + if not timeline: + return None + timeline.sort(key=lambda x: x[0]) + timeline_ts = [t for t, _ in timeline] + + # Walk every 30-minute slot start over the lookback window. For each slot, + # the rate in effect is the latest sample with ts <= slot_start. + buckets = {i: [] for i in range(48)} + slot = self.now_utc - timedelta(days=days) + # Align to a clean half-hour boundary at or after the lookback start. + if slot.minute % 30 or slot.second or slot.microsecond: + offset = 30 - (slot.minute % 30) + slot = (slot + timedelta(minutes=offset)).replace(second=0, microsecond=0) + + end = self.now_utc + while slot < end: + idx = bisect.bisect_right(timeline_ts, slot) - 1 + if idx >= 0: + rate_value = timeline[idx][1] + slot_local = slot.astimezone(self.local_tz) + bucket = slot_local.hour * 2 + (1 if slot_local.minute >= 30 else 0) + buckets[bucket].append(rate_value) + slot += timedelta(minutes=30) + + all_samples = [v for vs in buckets.values() for v in vs] + if not all_samples: + return None + overall_mean = sum(all_samples) / len(all_samples) + return {i: (sum(vs) / len(vs)) if vs else overall_mean for i, vs in buckets.items()} + + def rate_replicate(self, rates, rate_io={}, is_import=True, is_gas=False, history_buckets=None): + """ + We don't get enough hours of data for Octopus, so lets assume it repeats until told others. + + When ``history_buckets`` is supplied (from :meth:`build_rate_history_buckets`), + gaps not satisfied by 24h-back copy are filled from the historical half-hour-of-day + mean instead of the ``rate_last`` constant fallback. This restores diurnal shape + for users whose live rate sensor only publishes a short forward window + (e.g. Amber Australia's 16h window). """ minute = -24 * 60 rate_last = 0 @@ -1403,6 +1512,13 @@ def rate_replicate(self, rates, rate_io={}, is_import=True, is_gas=False): elif minute_mod in rates: rate_offset = rates[minute_mod] using_last = False + elif history_buckets: + # No 24h-back or modulo match: use historical half-hour-of-day mean. + slot_local = (self.midnight_utc + timedelta(minutes=minute)).astimezone(self.local_tz) + bucket = slot_local.hour * 2 + (1 if slot_local.minute >= 30 else 0) + rate_offset = history_buckets[bucket] + adjust_type = "history_avg" + using_last = False else: # Missing rate within 24 hours - fill with dummy last rate rate_offset = rate_last @@ -2144,6 +2260,11 @@ def fetch_config_options(self): self.metric_self_sufficiency = self.get_arg("metric_self_sufficiency") self.metric_future_rate_offset_import = self.get_arg("metric_future_rate_offset_import") self.metric_future_rate_offset_export = self.get_arg("metric_future_rate_offset_export") + self.rate_history_days_average = int(self.get_arg("rate_history_days_average", 0) or 0) + self.rate_history_source_import = self.get_arg("rate_history_source_import", None, indirect=False) or None + self.rate_history_source_export = self.get_arg("rate_history_source_export", None, indirect=False) or None + self.rate_history_scaling_import = float(self.get_arg("rate_history_scaling_import", 1.0) or 1.0) + self.rate_history_scaling_export = float(self.get_arg("rate_history_scaling_export", 1.0) or 1.0) self.metric_inday_adjust_damping = self.get_arg("metric_inday_adjust_damping") self.metric_pv_calibration_enable = self.get_arg("metric_pv_calibration_enable") self.metric_dynamic_load_adjust = self.get_arg("metric_dynamic_load_adjust") diff --git a/apps/predbat/tests/test_rate_history_average.py b/apps/predbat/tests/test_rate_history_average.py new file mode 100644 index 000000000..e22e0b367 --- /dev/null +++ b/apps/predbat/tests/test_rate_history_average.py @@ -0,0 +1,214 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt: off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +""" +Tests for rate_history_days_average — the historical-mean fallback used by +rate_replicate() when the rate sensor doesn't expose 24h-old slots. +""" + +from datetime import datetime, timedelta, timezone + + +def _seed_history(my_predbat, samples): + """ + Replace TestHAInterface.history with the supplied list of dicts + (state, last_changed). + """ + my_predbat.ha_interface.history = list(samples) + my_predbat.ha_interface.history_enable = True + + +def _half_hour_walk(start_utc, count): + """Yield count half-hour-aligned UTC timestamps starting at start_utc.""" + for i in range(count): + yield start_utc + timedelta(minutes=30 * i) + + +def _bucket_of(local_dt): + return local_dt.hour * 2 + (1 if local_dt.minute >= 30 else 0) + + +def _test_disabled_returns_none(my_predbat): + """rate_history_days_average=0 → builder returns None even with seeded history.""" + _seed_history(my_predbat, [{"state": 0.20, "last_changed": datetime.now(timezone.utc)}]) + result = my_predbat.build_rate_history_buckets("sensor.fake_rate", days=0, scaling=100.0) + assert result is None, "days=0 should disable the feature" + + +def _test_no_history_returns_none(my_predbat): + """Empty history → None.""" + _seed_history(my_predbat, []) + result = my_predbat.build_rate_history_buckets("sensor.fake_rate", days=7, scaling=100.0) + assert result is None, "empty history should yield None" + + +def _test_only_unavailable_returns_none(my_predbat): + """All states unavailable/unknown → None.""" + now = my_predbat.now_utc + samples = [ + {"state": "unavailable", "last_changed": now - timedelta(hours=1)}, + {"state": "unknown", "last_changed": now - timedelta(hours=2)}, + {"state": None, "last_changed": now - timedelta(hours=3)}, + ] + _seed_history(my_predbat, samples) + result = my_predbat.build_rate_history_buckets("sensor.fake_rate", days=7, scaling=100.0) + assert result is None, "only-bad states should yield None" + + +def _test_scaling_applied(my_predbat): + """A constant $/kWh history with scaling=100 yields constant c/kWh buckets.""" + now = my_predbat.now_utc + samples = [] + for ts in _half_hour_walk(now - timedelta(days=3), 48 * 3): + samples.append({"state": 0.20, "last_changed": ts}) + _seed_history(my_predbat, samples) + result = my_predbat.build_rate_history_buckets("sensor.fake_rate", days=3, scaling=100.0) + assert result is not None, "expected buckets" + assert len(result) == 48, "expected 48 buckets" + for hh, mean in result.items(): + assert abs(mean - 20.0) < 0.01, "bucket {} expected ~20 c got {}".format(hh, mean) + + +def _test_diurnal_shape_preserved(my_predbat): + """Seed an artificial diurnal curve and confirm buckets recover the shape.""" + now = my_predbat.now_utc + # Build 5 days of synthetic data: each slot's rate depends only on bucket. + samples = [] + for ts in _half_hour_walk(now - timedelta(days=5), 48 * 5): + local = ts.astimezone(my_predbat.local_tz) + bucket = _bucket_of(local) + # Pattern: cheap overnight (10), expensive evening peak (50), midday trough (15) + if bucket >= 36 and bucket < 44: # 18:00 - 22:00 local + rate = 0.50 + elif bucket >= 22 and bucket < 30: # 11:00 - 15:00 local + rate = 0.15 + else: + rate = 0.20 + samples.append({"state": rate, "last_changed": ts}) + _seed_history(my_predbat, samples) + result = my_predbat.build_rate_history_buckets("sensor.fake_rate", days=5, scaling=100.0) + assert result is not None + assert abs(result[40] - 50.0) < 1.0, "evening peak bucket should be ~50c, got {}".format(result[40]) + assert abs(result[24] - 15.0) < 1.0, "midday bucket should be ~15c, got {}".format(result[24]) + assert abs(result[0] - 20.0) < 1.0, "overnight bucket should be ~20c, got {}".format(result[0]) + + +def _test_spike_resistance(my_predbat): + """A single 5-minute spike should not dominate the bucket mean.""" + now = my_predbat.now_utc + samples = [] + # 7 days of steady 0.15 $/kWh at half-hour cadence + for ts in _half_hour_walk(now - timedelta(days=7), 48 * 7): + samples.append({"state": 0.15, "last_changed": ts}) + # Inject a transient spike (a single state-change event) at one moment + spike_time = now - timedelta(days=2, hours=12, minutes=2) + samples.append({"state": 1.50, "last_changed": spike_time}) + # And a recovery sample 5 min later + samples.append({"state": 0.15, "last_changed": spike_time + timedelta(minutes=5)}) + samples.sort(key=lambda r: r["last_changed"]) + _seed_history(my_predbat, samples) + result = my_predbat.build_rate_history_buckets("sensor.fake_rate", days=7, scaling=100.0) + assert result is not None + # Every bucket should be very close to 15c — the spike fell between two slot boundaries. + for hh, mean in result.items(): + assert mean < 25.0, "bucket {} got {}, spike contaminated it".format(hh, mean) + + +def _test_replicate_uses_history(my_predbat): + """rate_replicate fills unknown slots from history_buckets and propagates 24h forward. + + Note on tagging: rate_replicate walks minute-by-minute and adds each filled value + back into the rates dict. Once a history-derived value lands at minute M, the + slot at M+1440 will be filled via the 24h-back branch (tag ``copy``), not the + history branch — but the *value* is identical because it was sourced from history + one cycle earlier. So the ``history_avg`` tag is expected only within the first + 24 hours of the gap; later slots carry it forward as ``copy`` with the same value. + """ + rates = {0: 25.0} # one known slot at minute=0 + history_buckets = {i: float(i) for i in range(48)} # distinct per-bucket markers + out_rates, out_replicated = my_predbat.rate_replicate( + rates, is_import=True, history_buckets=history_buckets + ) + + # The first filled slot via history is the one immediately after the seed where + # neither 24h-back nor modulo match — verify at least one minute carries the tag. + history_tagged = [m for m, tag in out_replicated.items() if tag == "history_avg"] + assert history_tagged, "expected at least one minute tagged history_avg, got tags={}".format(set(out_replicated.values())) + + # And the value at a future minute should match the historical bucket for the + # corresponding local time-of-day (regardless of whether the tag is history_avg + # or 'copy' from a 24h-back propagation of the same bucket). + sample_minute = 5 * 60 # +5h from midnight_utc + slot_local = (my_predbat.midnight_utc + timedelta(minutes=sample_minute)).astimezone(my_predbat.local_tz) + expected_bucket = _bucket_of(slot_local) + assert out_rates[sample_minute] == float(expected_bucket), ( + "minute {} expected bucket {} value {} got {}".format(sample_minute, expected_bucket, float(expected_bucket), out_rates[sample_minute]) + ) + + +def _test_replicate_prefers_24h_back(my_predbat): + """When 24h-back is present, history_buckets must NOT be used.""" + # Seed a known 24h-back rate, plus a history_buckets with a clearly different value. + yesterday_minute = -24 * 60 # one day before midnight_utc + rates = {yesterday_minute: 99.0} # 24h-back known + history_buckets = {i: 0.0 for i in range(48)} # all zeros — would be obvious if used + out_rates, out_replicated = my_predbat.rate_replicate( + rates, is_import=True, history_buckets=history_buckets + ) + # minute = 0 is exactly 24h after yesterday_minute; should copy the 99.0 + assert out_rates[0] == 99.0, "24h-back copy should win over history_avg, got {}".format(out_rates[0]) + assert out_replicated.get(0) == "copy", \ + "expected 'copy' tag for 24h-back, got {}".format(out_replicated.get(0)) + + +def _test_replicate_without_history_unchanged(my_predbat): + """Default behavior (history_buckets=None) is unchanged: falls to rate_last.""" + rates = {0: 25.0} + out_rates, out_replicated = my_predbat.rate_replicate(rates, is_import=True) + # When history is absent and no other branch matches, the existing rate_last + # fallback applies — minute 60 should equal rate_last (25.0). + assert out_rates[60] == 25.0, "without history, fallback should be rate_last (25.0), got {}".format(out_rates[60]) + assert out_replicated.get(60) == "copy" + + +def test_rate_history_average(my_predbat): + """Top-level harness compatible with the unit_test.py registry.""" + sub_tests = [ + ("disabled_returns_none", _test_disabled_returns_none), + ("no_history_returns_none", _test_no_history_returns_none), + ("only_unavailable_returns_none", _test_only_unavailable_returns_none), + ("scaling_applied", _test_scaling_applied), + ("diurnal_shape_preserved", _test_diurnal_shape_preserved), + ("spike_resistance", _test_spike_resistance), + ("replicate_uses_history", _test_replicate_uses_history), + ("replicate_prefers_24h_back", _test_replicate_prefers_24h_back), + ("replicate_without_history_unchanged", _test_replicate_without_history_unchanged), + ] + + print("\n" + "=" * 70) + print("RATE HISTORY AVERAGE TEST SUITE") + print("=" * 70) + + failed = 0 + passed = 0 + for name, func in sub_tests: + try: + func(my_predbat) + print(" PASS {}".format(name)) + passed += 1 + except AssertionError as exc: + print(" FAIL {}: {}".format(name, exc)) + failed += 1 + except Exception as exc: # pylint: disable=broad-except + print(" ERROR {}: {!r}".format(name, exc)) + failed += 1 + + print("Result: {} passed, {} failed".format(passed, failed)) + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index b7e1a77f9..ad5e1e701 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -100,6 +100,7 @@ from tests.test_integer_config import test_integer_config_entities, test_expose_config_preserves_integer from tests.test_plan_json_rate_adjust import run_test_plan_json_rate_adjust from tests.test_rate_replicate_missing_slots import test_rate_replicate +from tests.test_rate_history_average import test_rate_history_average from tests.test_find_charge_window import test_find_charge_window from tests.test_random_scenarios import generate_scenarios, save_scenarios, run_scenarios_from_file, compare_results, profile_scenario from tests.test_carbon import test_carbon @@ -229,6 +230,7 @@ def main(): ("multi_car_iog", run_multi_car_iog_tests, "Multi-car IOG tests", False), ("rate_add_io_slots", run_rate_add_io_slots_tests, "Rate add IO slots tests", False), ("rate_replicate", test_rate_replicate, "Rate replicate comprehensive tests (missing slots, IO, offsets, gas)", False), + ("rate_history_average", test_rate_history_average, "Historical-mean fallback for rate_replicate", False), ("find_charge_window", test_find_charge_window, "Find charge window gap handling tests", False), ("find_charge_rate", test_find_charge_rate, "Find charge rate tests", False), ("find_charge_rate_string_temp", test_find_charge_rate_string_temperature, "Find charge rate string temperature", False), diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 0a504f3b5..9e34898a7 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1526,6 +1526,9 @@ These are described in detail in [Energy Rates](energy-rates.md) and are listed - **futurerate_adjust_import** and **futurerate_adjust_export** - Whether tomorrow's predicted import or export prices should be adjusted based on market prices or not - **futurerate_adjust_auto** - Auto-detect which of import/export are Agile and calibrate only those rates; overrides `futurerate_adjust_import` / `futurerate_adjust_export`; requires the Octopus Energy integration or Predbat's Octopus Component - **futurerate_peak_start** and **futurerate_peak_end** - start/end times for peak-rate adjustment +- **rate_history_days_average** - Number of days of historical rate data to build a half-hour-of-day mean for filling gaps in the forward rate window. Set to `0` (default) to disable. +- **rate_history_source_import** and **rate_history_source_export** - Home Assistant entity to query for historical rate data. If not set, falls back to `metric_octopus_import` / `metric_octopus_export` +- **rate_history_scaling_import** and **rate_history_scaling_export** - Multiplier to convert sensor rate values to Predbat's internal rate units (e.g. `100.0` to convert $/kWh to c/kWh). Defaults to `1.0` - **carbon_postcode** - Postcode to retrieve Carbon intensity grid information for - **carbon_automatic** - Retrieve Carbon intensity information automatically based upon postcode - **carbon_intensity** - Carbon intensity of the grid in half-hour slots from an integration. diff --git a/docs/energy-rates.md b/docs/energy-rates.md index a74a01ecf..e88fb9556 100644 --- a/docs/energy-rates.md +++ b/docs/energy-rates.md @@ -550,6 +550,28 @@ Or, if you prefer to set import/export calibration manually rather than auto-det futurerate_peak_end: "19:00:00" ``` +## Rate history fallback + +When Predbat builds its plan, it needs energy rates for the full plan horizon (typically 48 hours). The `rate_replicate` mechanism fills unknown future slots by copying rates from 24 hours earlier. However, some rate sensors only publish a short forward window — for example, Amber Australia publishes approximately 16 hours of future rates, leaving the remaining 32 hours of the plan horizon with no live rate data. + +When `rate_history_days_average` is enabled, Predbat queries the Home Assistant recorder history for the configured rate entity and builds a per-half-hour-of-day mean profile. Any future slot that cannot be filled from a 24-hour-back copy is instead filled from this historical profile, restoring the diurnal price shape rather than falling back to a constant last-known rate. + +The builder samples one value per half-hour slot per day (using step-function lookup: the rate in effect at each slot start), making it resistant to transient price spikes that could otherwise skew the mean. + +```yaml + rate_history_days_average: 7 + rate_history_source_import: 'sensor.amber_general_price' + rate_history_scaling_import: 100.0 +``` + +The configuration options are: + +- **rate_history_days_average** - Number of days of HA recorder history to use for the half-hour-of-day mean profile. When set to `0` (the default) the feature is disabled. Recommended range `3`–`14`. +- **rate_history_source_import** and **rate_history_source_export** - Home Assistant entity to query for historical rate data (e.g. `sensor.amber_general_price`). If not set, falls back to the entity configured in `metric_octopus_import` / `metric_octopus_export`. +- **rate_history_scaling_import** and **rate_history_scaling_export** - Multiplier applied to each historical rate value to convert it to Predbat's internal rate units. For example, if your rate sensor publishes in $/kWh and you use c/kWh elsewhere, set this to `100.0`. Defaults to `1.0`. + +Note that these items are only available in *expert mode* in Home Assistant. + ## Axle VPP [Axle in the UK](https://vpp.axle.energy/landing) provide a Virtual Power Plant (VPP) service to the National Grid. In times of strain in the energy grid, Axle will command inverters to export, and in return you get paid £1/kWh.