diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt
index 53cf8a682..fffc0a521 100644
--- a/.cspell/custom-dictionary-workspace.txt
+++ b/.cspell/custom-dictionary-workspace.txt
@@ -12,6 +12,7 @@ aiomqtt
aios
Alertfeed
allclose
+annualform
Anson
apexcharts
apikey
@@ -61,6 +62,7 @@ Chrg
citem
cloudfront
Codespaces
+COEFF
collapsable
compareform
configform
@@ -68,6 +70,8 @@ connack
Consolas
cooldown
coro
+corruptleg
+corruptplans
cprofile
creds
crosscharge
@@ -85,6 +89,7 @@ datalog
datap
datapoints
datas
+dataviz
dateutil
dayname
daynumber
@@ -93,6 +98,7 @@ dedup
dedupe
dend
denorm
+derate
derating
devcontainer
devcontainers
@@ -157,6 +163,8 @@ FLOMASTA
fontsize
fontweight
Forestfire
+formaction
+formmethod
foxess
foxesscloud
freephase
@@ -274,6 +282,7 @@ minmax
minsoc
minsocongrid
minuteb
+misclick
Mixergy
mkdocs
mlugg
@@ -301,6 +310,7 @@ nodischarge
nord
nordpool
nosc
+nosuchrun
nrgheat
numpy
OCPP
@@ -309,6 +319,7 @@ octoplus
octopoints
Ohme
ohmepy
+Okabe
onblur
onchange
oncharge
@@ -372,9 +383,12 @@ regname
relogin
remainings
remotecontrol
+renderable
resetmidnight
resultid
resultmid
+reweight
+reweighted
rname
Roboto
rowspan
@@ -463,6 +477,7 @@ timezone
tmrw
tojson
tottime
+trapezoidally
trapz
Trefor
treforsiphone
@@ -472,6 +487,7 @@ twinx
tzfile
tzpath
unconfigured
+unnormalised
unparseable
unsmoothed
unstaged
diff --git a/apps/predbat/annual.py b/apps/predbat/annual.py
new file mode 100644
index 000000000..01ad144f2
--- /dev/null
+++ b/apps/predbat/annual.py
@@ -0,0 +1,1596 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Annual prediction engine.
+
+Projects a year of household electricity costs using the real Predbat planning
+engine, reporting each month under four scenarios: no PV or battery, PV only
+(no battery), PV and battery without Predbat, and PV and battery with Predbat.
+Performs no HTTP itself; the weather and tariff modules own all network access.
+"""
+
+import calendar
+import math
+import os
+from datetime import date, datetime, timedelta
+
+import pytz
+
+from annual_costs import build_costs, build_payback, resolve_costs
+from annual_load import build_load_forecast, OctopusConsumptionLoadProfile, SyntheticLoadProfile
+from annual_tariff import AnnualTariff
+from annual_weather import AnnualWeather, resolve_postcode
+from const import MINUTE_WATT, PREDICT_STEP
+from prediction import Prediction
+
+VALID_SHAPES = ["night", "day", "flat"]
+
+DEFAULT_TIMEZONE = "Europe/London"
+DEFAULT_SAMPLES_PER_MONTH = 2
+DEFAULT_PV10_DERATE_FALLBACK = 0.7
+DEFAULT_DECLINATION = 35
+DEFAULT_AZIMUTH = 180
+DEFAULT_EFFICIENCY = 0.95
+DEFAULT_HYBRID = True
+
+# A typical domestic panel in 2026. Only used to turn a panel count into kWp.
+DEFAULT_PANEL_WATTS = 400.0
+
+# The Open-Meteo ERA5 archive, which the weather module draws on, starts in 1940.
+MINIMUM_YEAR = 1940
+
+# Substrings that mark a config value as secret and therefore scrubbable
+SECRET_MARKERS = ["_key", "password", "token", "secret"]
+
+
+class AnnualConfigError(ValueError):
+ """Raised when the annual prediction config is invalid or self-contradictory."""
+
+
+def scrub_secrets(config):
+ """Return a deep copy of the config with secret-looking values replaced by "xxx".
+
+ Mirrors the redaction ``create_debug_yaml()`` applies, so a results document or
+ debug dump can never carry an API key.
+ """
+ if isinstance(config, dict):
+ scrubbed = {}
+ for key, value in config.items():
+ if any(marker in str(key).lower() for marker in SECRET_MARKERS):
+ scrubbed[key] = "xxx"
+ else:
+ scrubbed[key] = scrub_secrets(value)
+ return scrubbed
+ if isinstance(config, list):
+ return [scrub_secrets(item) for item in config]
+ return config
+
+
+def _require_number(value, field, minimum=None, maximum=None, integer=False, exclusive_minimum=False):
+ """Coerce a config value to a number, raising AnnualConfigError with an actionable message.
+
+ Rejects booleans explicitly (``True`` silently becoming ``1.0`` would be a confusing
+ outcome), converts with ``int()``/``float()`` inside a try/except so a malformed value
+ never escapes as a bare ``ValueError``/``TypeError``, and enforces the optional bounds.
+ ``minimum`` is inclusive unless ``exclusive_minimum`` is set, in which case the value
+ must be strictly greater than it. ``maximum`` is always inclusive. When ``integer`` is
+ set, a fractional float (``2.5``) is rejected rather than silently truncated - plain
+ ``int()`` truncates without complaint, which would let a mistyped panel count or sample
+ rate through as a different, smaller whole number.
+ """
+ if isinstance(value, bool):
+ raise AnnualConfigError("{} must be a number, not a boolean (got {})".format(field, value))
+ if integer and isinstance(value, float) and not value.is_integer():
+ raise AnnualConfigError("{} must be a whole number, got {}".format(field, value))
+ try:
+ number = int(value) if integer else float(value)
+ except (TypeError, ValueError):
+ raise AnnualConfigError("{} must be a number, got {!r}".format(field, value))
+ if minimum is not None:
+ if exclusive_minimum and number <= minimum:
+ raise AnnualConfigError("{} must be greater than {}, got {}".format(field, minimum, number))
+ if not exclusive_minimum and number < minimum:
+ raise AnnualConfigError("{} must be at least {}, got {}".format(field, minimum, number))
+ if maximum is not None and number > maximum:
+ raise AnnualConfigError("{} must be at most {}, got {}".format(field, maximum, number))
+ return number
+
+
+def _coerce_bool(value):
+ """Coerce a config value to a bool the way the rest of the codebase does (see web.py's setting toggles).
+
+ A bare ``bool`` passes through unchanged. Anything else is compared, case-insensitively,
+ against the usual truthy string forms - ``bool("false")`` is ``True`` in plain Python
+ (any non-empty string is truthy), which would silently turn an explicit ``debug: "false"``
+ in a YAML/JSON config into debug mode being enabled.
+ """
+ if isinstance(value, bool):
+ return value
+ return str(value).strip().lower() in ("true", "on", "1", "yes")
+
+
+def _validate_solar(raw):
+ """Normalise the solar array list, applying defaults and rejecting arrays without kwp."""
+ if raw is None:
+ return []
+ if isinstance(raw, dict):
+ raw = [raw]
+ if not isinstance(raw, list):
+ raise AnnualConfigError("annual.solar must be a list of arrays")
+
+ arrays = []
+ for index, array in enumerate(raw):
+ if not isinstance(array, dict):
+ raise AnnualConfigError("annual.solar[{}] must be a mapping".format(index))
+ has_kwp = "kwp" in array
+ has_panels = "panels" in array
+ if has_kwp and has_panels:
+ # Two figures that disagree are a mistake worth surfacing. Guessing which the
+ # user meant would silently model a different system than they described.
+ raise AnnualConfigError("annual.solar[{}] has both kwp and panels; give one or the other, not both".format(index))
+ if not has_kwp and not has_panels:
+ raise AnnualConfigError("annual.solar[{}] is missing kwp (the array's peak power in kW) or panels (how many panels it has)".format(index))
+ normalised = dict(array)
+ if has_panels:
+ panels = _require_number(array["panels"], "annual.solar[{}].panels".format(index), minimum=0, exclusive_minimum=True, integer=True)
+ panel_watts = _require_number(array.get("panel_watts", DEFAULT_PANEL_WATTS), "annual.solar[{}].panel_watts".format(index), minimum=0, exclusive_minimum=True)
+ # Retained alongside the derived kwp so the web form can show back what the
+ # user actually typed rather than replacing it with a computed decimal.
+ normalised["panels"] = panels
+ normalised["panel_watts"] = panel_watts
+ normalised["kwp"] = panels * panel_watts / 1000.0
+ else:
+ normalised["kwp"] = _require_number(array["kwp"], "annual.solar[{}].kwp".format(index), minimum=0, exclusive_minimum=True)
+ # declination is a roof pitch in degrees: 0 (flat) to 90 (vertical) inclusive.
+ normalised["declination"] = _require_number(array.get("declination", DEFAULT_DECLINATION), "annual.solar[{}].declination".format(index), minimum=0, maximum=90)
+ # azimuth follows Predbat's convention (0 = north, 180 = south) and
+ # convert_azimuth() (solar_model.py) accepts negative values too, so the bound
+ # here is deliberately wide rather than tighter and risking rejecting a valid
+ # existing config.
+ normalised["azimuth"] = _require_number(array.get("azimuth", DEFAULT_AZIMUTH), "annual.solar[{}].azimuth".format(index), minimum=-360, maximum=360)
+ normalised["efficiency"] = _require_number(array.get("efficiency", DEFAULT_EFFICIENCY), "annual.solar[{}].efficiency".format(index), minimum=0, exclusive_minimum=True, maximum=1)
+ # annual_weather.py reads this with `array.get("azimuth_zero_south", False)`; coerced
+ # here for the same reason as "debug"/"hybrid" below - a "false" string left
+ # unconverted from a submitted form or YAML file is truthy in plain Python, which
+ # would silently flip which azimuth convention every array's PV geometry uses.
+ normalised["azimuth_zero_south"] = _coerce_bool(array.get("azimuth_zero_south", False))
+ arrays.append(normalised)
+ return arrays
+
+
+def _validate_battery(raw):
+ """Normalise the battery block, or return None for a run with no battery."""
+ if raw is None:
+ return None
+ if not isinstance(raw, dict):
+ raise AnnualConfigError("annual.battery must be a mapping")
+ if "size_kwh" not in raw:
+ raise AnnualConfigError("annual.battery is missing size_kwh")
+ if "inverter_kw" not in raw:
+ raise AnnualConfigError("annual.battery is missing inverter_kw")
+
+ inverter_kw = _require_number(raw["inverter_kw"], "annual.battery.inverter_kw", minimum=0, exclusive_minimum=True)
+ return {
+ "size_kwh": _require_number(raw["size_kwh"], "annual.battery.size_kwh", minimum=0, exclusive_minimum=True),
+ "inverter_kw": inverter_kw,
+ "export_limit_kw": _require_number(raw.get("export_limit_kw", inverter_kw), "annual.battery.export_limit_kw", minimum=0),
+ "hybrid": _coerce_bool(raw.get("hybrid", DEFAULT_HYBRID)),
+ "charge_rate_kw": _require_number(raw.get("charge_rate_kw", inverter_kw), "annual.battery.charge_rate_kw", minimum=0, exclusive_minimum=True),
+ "discharge_rate_kw": _require_number(raw.get("discharge_rate_kw", inverter_kw), "annual.battery.discharge_rate_kw", minimum=0, exclusive_minimum=True),
+ }
+
+
+def _validate_load(raw):
+ """Normalise the load block and enforce the Octopus / manual exclusivity rule."""
+ if not isinstance(raw, dict):
+ raise AnnualConfigError("annual.load is required and must be a mapping")
+
+ has_octopus = "octopus" in raw
+ octopus = raw.get("octopus")
+ has_manual = ("annual_kwh" in raw) or ("car_charging_kwh" in raw)
+
+ if has_octopus and has_manual:
+ raise AnnualConfigError("annual.load.octopus and annual.load.annual_kwh/car_charging_kwh are mutually exclusive: the Octopus consumption series already includes any car charging, so supplying both would double-count it")
+
+ if not has_octopus and "annual_kwh" not in raw:
+ raise AnnualConfigError("annual.load requires either annual_kwh or an octopus block")
+
+ if has_octopus:
+ if not isinstance(octopus, dict) or not octopus.get("api_key") or not octopus.get("account_id"):
+ raise AnnualConfigError("annual.load.octopus requires both api_key and account_id")
+ return {"octopus": dict(octopus), "shape": raw.get("shape", "flat"), "car_charging_kwh": 0.0}
+
+ shape = raw.get("shape", "flat")
+ if shape not in VALID_SHAPES:
+ raise AnnualConfigError("annual.load.shape must be one of {}, got '{}'".format(VALID_SHAPES, shape))
+
+ return {
+ "annual_kwh": _require_number(raw["annual_kwh"], "annual.load.annual_kwh", minimum=0),
+ "shape": shape,
+ "car_charging_kwh": _require_number(raw.get("car_charging_kwh", 0.0), "annual.load.car_charging_kwh", minimum=0),
+ "car_rate_kw": _require_number(raw.get("car_rate_kw", DEFAULT_CAR_RATE_KW), "annual.load.car_rate_kw", minimum=0, exclusive_minimum=True),
+ }
+
+
+def _validate_tariff(raw):
+ """Normalise the tariff block, requiring at least one import rate source.
+
+ A URL containing {dno_region} with no dno_region supplied is rejected here
+ rather than left to 404 at fetch time, where it would surface as an
+ unavailable month and read like an Octopus outage.
+ """
+ if not isinstance(raw, dict):
+ raise AnnualConfigError("annual.tariff is required and must be a mapping")
+ if not raw.get("import_octopus_url") and not raw.get("rates_import"):
+ raise AnnualConfigError("annual.tariff requires either import_octopus_url or rates_import")
+
+ templated = [name for name in ["import_octopus_url", "export_octopus_url"] if raw.get(name) and "{dno_region}" in raw[name]]
+ if templated and not raw.get("dno_region"):
+ raise AnnualConfigError("annual.tariff.{} uses {{dno_region}} but annual.tariff.dno_region is not set; supply your Octopus region letter, for example 'A' for Eastern England".format(", ".join(templated)))
+
+ tariff = dict(raw)
+ tariff["standing_charge_p_per_day"] = _require_number(raw.get("standing_charge_p_per_day", 0.0), "annual.tariff.standing_charge_p_per_day", minimum=0)
+ return tariff
+
+
+def _validated_costs(raw):
+ """Return the validated install-cost settings, as an AnnualConfigError on failure.
+
+ annual_costs.resolve_costs raises ValueError because it is a pure module with no
+ dependency on this one; translating here keeps every config problem a single
+ exception type for the CLI and web layer to catch.
+ """
+ try:
+ return resolve_costs(raw)
+ except ValueError as error:
+ raise AnnualConfigError(str(error))
+
+
+def validate_config(config, today=None):
+ """Validate and normalise an annual prediction config, returning a fully defaulted copy.
+
+ Accepts either the wrapped form ({"annual": {...}}) or the inner mapping directly.
+ Raises AnnualConfigError with an actionable message on any problem.
+ """
+ if not isinstance(config, dict):
+ raise AnnualConfigError("The annual config must be a mapping")
+
+ raw = config.get("annual", config)
+ if not isinstance(raw, dict):
+ raise AnnualConfigError("The annual config must be a mapping")
+
+ location = raw.get("location")
+ if not isinstance(location, dict):
+ raise AnnualConfigError("annual.location is required, with either a postcode or latitude and longitude")
+ if not location.get("postcode") and not ("latitude" in location and "longitude" in location):
+ raise AnnualConfigError("annual.location needs either a postcode or both latitude and longitude")
+
+ location = dict(location)
+ if "latitude" in location:
+ location["latitude"] = _require_number(location["latitude"], "annual.location.latitude", minimum=-90, maximum=90)
+ if "longitude" in location:
+ location["longitude"] = _require_number(location["longitude"], "annual.location.longitude", minimum=-180, maximum=180)
+
+ solar = _validate_solar(raw.get("solar"))
+ battery = _validate_battery(raw.get("battery"))
+ if not solar and battery is None:
+ raise AnnualConfigError("annual needs at least one of solar or battery: with neither there is nothing to evaluate")
+
+ samples_per_month = _require_number(raw.get("samples_per_month", DEFAULT_SAMPLES_PER_MONTH), "annual.samples_per_month", minimum=1, integer=True)
+
+ if today is None:
+ today = date.today()
+ # Capped at the most recent COMPLETE calendar year, not the current (in-progress) one:
+ # Open-Meteo answers a mid-year request with short but internally-consistent arrays, so
+ # _payload_problem() cannot tell a truncated current-year download from a genuinely
+ # complete one, and it gets cached with no expiry - permanently pinning the remaining
+ # months as "unavailable" until the work dir is deleted by hand. See annual_weather.py.
+ year = _require_number(raw.get("year", today.year - 1), "annual.year", minimum=MINIMUM_YEAR, maximum=today.year - 1, integer=True)
+
+ return {
+ "location": dict(location),
+ "year": year,
+ "solar": solar,
+ "battery": battery,
+ "load": _validate_load(raw.get("load")),
+ "tariff": _validate_tariff(raw.get("tariff")),
+ "samples_per_month": samples_per_month,
+ "costs": _validated_costs(raw.get("costs")),
+ "debug": _coerce_bool(raw.get("debug", False)),
+ "timezone": raw.get("timezone", DEFAULT_TIMEZONE),
+ "pv10_derate_fallback": _require_number(raw.get("pv10_derate_fallback", DEFAULT_PV10_DERATE_FALLBACK), "annual.pv10_derate_fallback", minimum=0, exclusive_minimum=True, maximum=1),
+ "raw": scrub_secrets(raw),
+ }
+
+
+# Minimal apps.yaml for a headless run. PredBat's Hass base class reads this at
+# construction time; nothing here talks to Home Assistant. timezone is quoted since it
+# is interpolated raw into YAML and a value containing ':' or '#' would otherwise be
+# invalid or reparsed as something other than a plain string.
+MINIMAL_APPS_YAML = """pred_bat:
+ module: predbat
+ class: PredBat
+ prefix: predbat
+ timezone: "{timezone}"
+ currency_symbols:
+ - '£'
+ - 'p'
+ threads: 0
+ db_enable: false
+ db_mirror_ha: false
+ db_primary: false
+ web_enable: false
+ mcp_enable: false
+ notify_devices: []
+ days_previous:
+ - 1
+ days_previous_weight:
+ - 1
+ forecast_hours: 48
+"""
+
+
+class AnnualNullHA:
+ """A no-op Home Assistant interface for headless annual runs.
+
+ Provides the subset of the interface PredBat touches during ``auto_config()``,
+ ``load_user_config()`` and ``fetch_config_options()``. Nothing is published and
+ no history exists, which is correct: every input the annual tool needs is
+ injected directly.
+ """
+
+ def __init__(self):
+ """Create an empty in-memory state store."""
+ self.history_enable = False
+ self.dummy_items = {}
+ self.db_primary = False
+
+ def get_state(self, entity_id, default=None, attribute=None, refresh=False, raw=False):
+ """Return a stored state, the supplied default, or all states when no entity is given."""
+ if not entity_id:
+ return {}
+ if entity_id in self.dummy_items:
+ result = self.dummy_items[entity_id]
+ if raw:
+ return result
+ if isinstance(result, dict):
+ return result.get(attribute, "") if attribute else result.get("state", default)
+ return default if attribute else result
+ return default
+
+ def set_state(self, entity_id, state, attributes=None):
+ """Store a state locally so subsequent reads round trip."""
+ self.dummy_items[entity_id] = state
+ return state
+
+ def get_history(self, entity_id, now=None, days=30):
+ """Return None: a headless annual run has no Home Assistant history."""
+ return None
+
+ def call_service(self, service, **kwargs):
+ """Accept and discard a service call."""
+ return None
+
+
+def write_minimal_apps_yaml(work_dir, timezone):
+ """Write the headless apps.yaml into ``work_dir`` and return its path."""
+ os.makedirs(work_dir, exist_ok=True)
+ path = os.path.join(work_dir, "apps.yaml")
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(MINIMAL_APPS_YAML.format(timezone=timezone))
+ return path
+
+
+def create_headless_predbat(work_dir, timezone, log):
+ """Construct a PredBat instance with no Home Assistant connection.
+
+ ``Hass.__init__`` is the only place that reads ``$PREDBAT_APPS_FILE``, and it does so
+ synchronously while ``PredBat()`` is constructed below, so the environment variable only
+ needs to be set for the duration of that one call. It is restored to whatever it held
+ before (unset if it was unset) in a ``finally`` block, so a later ``PredBat()`` in the
+ same process — including ``unit_test.py``'s own ``create_predbat()`` — never silently
+ picks up this work directory's apps.yaml. The predbat import is deliberately local to
+ this function so merely importing ``annual`` does not drag in the whole engine.
+ """
+ path = write_minimal_apps_yaml(work_dir, timezone)
+ previous_apps_file = os.environ.get("PREDBAT_APPS_FILE")
+ os.environ["PREDBAT_APPS_FILE"] = path
+ try:
+ import predbat
+
+ instance = predbat.PredBat()
+ finally:
+ if previous_apps_file is None:
+ os.environ.pop("PREDBAT_APPS_FILE", None)
+ else:
+ os.environ["PREDBAT_APPS_FILE"] = previous_apps_file
+
+ instance.states = {}
+ instance.log = log
+ instance.reset()
+ instance.update_time()
+ instance.ha_interface = AnnualNullHA()
+ instance.auto_config()
+ instance.load_user_config()
+ instance.fetch_config_options()
+ configure_offline_mode(instance)
+ instance.config_root = work_dir
+ instance.save_restore_dir = work_dir
+ instance.args["threads"] = 0
+ return instance
+
+
+def apply_hardware(predbat, battery, solar):
+ """Map the config's battery block onto the PredBat instance.
+
+ Rates are stored internally as kW per minute, matching
+ ``Compare.apply_hardware_overrides()``. With no battery block the system is
+ given zero capacity, which is how the no-battery scenario is expressed. This
+ function is the sole owner of ``battery_rate_max_export``: unlike the other
+ accumulators, it is hardware-derived rather than per-sample state, so
+ ``reset_sample_state()`` must never overwrite it. ``soc_kw``, the prediction's
+ starting SOC, is set deterministically here rather than clamped against
+ whatever it happened to hold before, since clamping only makes sense when a
+ caller has deliberately set a starting SOC first — a later task does that
+ explicitly, after calling this function.
+ """
+ if battery is None:
+ predbat.soc_max = 0.0
+ predbat.soc_kw = 0.0
+ predbat.battery_rate_max_charge = 0.0
+ # Synthetic configs have no separate DC figure to scale proportionally (unlike
+ # compare.py's apply_hardware_overrides(), which scales an inherited DC/AC ratio),
+ # so the DC rate is simply set equal to the AC rate.
+ predbat.battery_rate_max_charge_dc = 0.0
+ predbat.battery_rate_max_discharge = 0.0
+ predbat.battery_rate_max_export = 0.0
+ # A zero-capacity battery has no meaningful minimum reserve either.
+ predbat.battery_rate_min = 0.0
+ # Sum kWp across every array, not just the first: a PV-only run's export limit
+ # must match what the battery run would see from the same solar array(s), or the
+ # difference between scenarios - this tool's whole output - is computed against
+ # two different caps.
+ predbat.inverter_limit = (sum(array["kwp"] for array in solar) if solar else 5.0) * 1000 / MINUTE_WATT
+ predbat.export_limit = predbat.inverter_limit
+ predbat.inverter_hybrid = False
+ return
+
+ predbat.soc_max = battery["size_kwh"]
+ predbat.soc_kw = predbat.soc_max
+ predbat.inverter_limit = battery["inverter_kw"] * 1000 / MINUTE_WATT
+ predbat.export_limit = battery["export_limit_kw"] * 1000 / MINUTE_WATT
+ predbat.battery_rate_max_charge = battery["charge_rate_kw"] * 1000 / MINUTE_WATT
+ # Synthetic configs have no separate DC figure to scale proportionally (unlike
+ # compare.py's apply_hardware_overrides(), which scales an inherited DC/AC ratio), so
+ # the DC rate is simply set equal to the AC rate.
+ predbat.battery_rate_max_charge_dc = predbat.battery_rate_max_charge
+ predbat.battery_rate_max_discharge = battery["discharge_rate_kw"] * 1000 / MINUTE_WATT
+ predbat.battery_rate_max_export = predbat.battery_rate_max_discharge
+ predbat.inverter_hybrid = battery["hybrid"]
+
+
+def reset_sample_state(predbat):
+ """Reset every field a previous sample could have left behind.
+
+ Without this, a month's result silently depends on what ran before it: the
+ numbers stay plausible while becoming order-dependent. The list covers the
+ accumulators, the previous plan, the manual overrides, the starting SOC, the
+ full rate-derived family that ``calculate_plan`` seeds its best windows from,
+ the scenario-3 smart-car overrides ``_run_scenarios()`` sets on the with-car
+ leg, and the field ``tests/test_single_debug.py`` documents as leaking between
+ debug cases (``dynamic_load_baseline``).
+
+ Deliberately excluded: ``battery_rate_max_export`` is hardware-derived and
+ owned solely by ``apply_hardware()``, not reset here; the offline-mode
+ choices (Octopus intelligent charging disabled, load taken from
+ load_forecast, iBoost/carbon disabled, debug off) are one-shot configuration
+ handled by ``configure_offline_mode()``, not per-sample leaks.
+ """
+ predbat.dynamic_load_baseline = {}
+
+ predbat.soc_kw = 0.0
+
+ predbat.cost_today_sofar = 0
+ predbat.carbon_today_sofar = 0
+ predbat.iboost_today = 0
+ predbat.import_today_now = 0
+ predbat.export_today_now = 0
+ predbat.load_minutes_now = 0
+ predbat.pv_today_now = 0
+
+ predbat.manual_charge_times = []
+ predbat.manual_export_times = []
+ predbat.manual_freeze_charge_times = []
+ predbat.manual_freeze_export_times = []
+ predbat.manual_demand_times = []
+ predbat.manual_all_times = []
+
+ predbat.charge_limit_best = []
+ predbat.charge_window_best = []
+ predbat.export_window_best = []
+ predbat.export_limits_best = []
+ predbat.charge_limit = []
+ predbat.charge_window = []
+ predbat.export_window = []
+ predbat.export_limits = []
+ predbat.plan_valid = False
+
+ # The rate-derived family calculate_plan() reads to seed the best charge/export
+ # windows (plan.py). Clearing rate_import_replicated/rate_export_replicated but
+ # leaving these downstream products in place would look handled while still leaking.
+ predbat.low_rates = []
+ predbat.high_export_rates = []
+ predbat.rate_import = {}
+ predbat.rate_export = {}
+ predbat.rate_import_replicated = {}
+ predbat.rate_export_replicated = {}
+ predbat.rate_import_cost_threshold = 99
+ predbat.rate_export_cost_threshold = 99
+ predbat.rate_min = 0
+ predbat.rate_max = 0
+ predbat.rate_average = 0
+
+ predbat.load_inday_adjustment = 1.0
+ predbat.load_scaling_dynamic = None
+ predbat.manual_load_adjust = {}
+ predbat.savings_last_updated = None
+
+ # Scenario 3's smart-car overrides (_run_scenarios()). Reset here rather than relying on
+ # every consumer being gated on num_cars (prediction.py, plan.py) - that guarantee is
+ # fragile in a file that has already had several state-leak bugs. Safe to reset even
+ # though _run_scenarios() sets these on the with-car leg: prepare_sample() calls this
+ # function BEFORE that leg sets them, never after.
+ predbat.car_charging_planned = [False]
+ predbat.car_charging_limit = [0.0]
+ predbat.car_charging_soc = [0.0]
+ predbat.car_charging_rate = [DEFAULT_CAR_RATE_KW]
+ predbat.car_charging_battery_size = [50.0]
+ predbat.car_charging_plan_smart = [False]
+ predbat.car_charging_from_battery = False
+
+
+def configure_offline_mode(predbat):
+ """Apply the one-shot configuration choices that make sense only for an offline run.
+
+ These are deliberate choices, not per-sample leaks: they are set once, after
+ ``fetch_config_options()`` has populated its own defaults, and are not part of
+ ``reset_sample_state()`` because re-applying them every sample would silently
+ override whatever ``fetch_config_options()`` or a scenario override set.
+
+ ``fetch_config_options()`` derives ``calculate_best_charge``, ``calculate_best_export``,
+ ``set_charge_window`` and ``set_export_window`` from ``predbat_mode`` (``fetch.py``), and
+ the minimal headless ``apps.yaml`` has no ``mode`` key, so ``get_arg("mode")`` returns the
+ "Monitor" default — which sets all four False. With all four False, ``calculate_plan()``'s
+ charge- and export-window branches (``plan.py``, gated on
+ ``self.low_rates and self.calculate_best_charge and self.set_charge_window`` and the export
+ equivalent) never fire, ``charge_window_best``/``export_window_best`` fall back to the
+ (empty) live ``charge_window``/``export_window``, and the "with Predbat" scenario would plan
+ no charging or exporting at all — a silent demand-only system indistinguishable from
+ scenario 1. These four are therefore forced on here, matching what
+ ``PREDBAT_MODE_CONTROL_CHARGEDISCHARGE`` sets in a live install with full control enabled.
+ """
+ predbat.octopus_intelligent_charging = False
+ predbat.load_forecast_only = True
+ predbat.load_scaling = 1.0
+ predbat.load_scaling10 = 1.0
+ predbat.iboost_enable = False
+ predbat.carbon_enable = False
+ predbat.plan_debug = False
+ predbat.debug_enable = False
+ predbat.calculate_best_charge = True
+ predbat.calculate_best_export = True
+ predbat.set_charge_window = True
+ predbat.set_export_window = True
+
+
+def _percentile_indices(count, samples):
+ """Return ``samples`` distinct indices spread evenly through ``count`` sorted items.
+
+ Index i sits at percentile (i + 0.5) / samples, so two samples land at the 25th
+ and 75th percentiles and each represents an equal share of the month. Collisions
+ are resolved by scanning forward toward the end of the array and, failing that,
+ backward from the target, which only matters when the sample count approaches
+ the number of candidate days. With no candidates at all there is nothing to
+ index, so this returns an empty list rather than the meaningless index -1.
+ """
+ if count <= 0:
+ return []
+
+ chosen = []
+ used = set()
+ for index in range(samples):
+ target = min(count - 1, int(count * ((index + 0.5) / samples)))
+ while target in used and target < count - 1:
+ target += 1
+ while target in used and target > 0:
+ target -= 1
+ if target in used:
+ continue
+ used.add(target)
+ chosen.append(target)
+ return chosen
+
+
+def select_samples(weather, year, month, samples_per_month, has_solar=True):
+ """Choose the days to plan for one month, with the weight in days each represents.
+
+ Days are ranked by their *actual* PV energy and sampled at even percentiles, so an
+ unlucky sunny or dull draw cannot swing the month. Ranking uses actuals rather than
+ the forecast: the aim is to represent what the month really contained, not what was
+ predicted. Days without a following day are excluded because the 48 hour plan needs one.
+
+ Weights sum to the number of days in the month, up to ordinary floating-point
+ rounding, so a month with fewer usable candidates than requested is scaled up
+ rather than silently under-counted.
+ """
+ days_in_month = calendar.monthrange(year, month)[1]
+ all_days = [date(year, month, day) for day in range(1, days_in_month + 1)]
+
+ if has_solar:
+ candidates = [day for day in all_days if weather.has_actual(day) and weather.has_actual(day + timedelta(days=1))]
+ candidates.sort(key=lambda day: (weather.daily_actual_kwh(day), day))
+ else:
+ # With no PV there is nothing to rank by, so fall back to evenly spaced calendar days.
+ # Deliberately no following-day filter here: that guard exists solely to keep the
+ # sample within the weather series, and a battery-only run has no weather series to
+ # run past the end of. Day 2's rates come from the tariff module (which fetches the
+ # month plus a 2-day buffer, and the orchestrator additionally fetches the next month)
+ # and its load is synthetic and unbounded, so the last day of the month is a
+ # legitimate sample and must not be filtered out.
+ candidates = all_days
+
+ if not candidates:
+ return []
+
+ # _percentile_indices() already guarantees distinct indices, so no set() is needed here.
+ indices = _percentile_indices(len(candidates), samples_per_month)
+ chosen = sorted(candidates[index] for index in indices)
+ weight = days_in_month / float(len(chosen))
+ return [(day, weight) for day in chosen]
+
+
+DAY_MINUTES = 24 * 60
+PLAN_MINUTES = 48 * 60
+
+# Every sample starts from an empty battery. The compute_metric correction values
+# whatever charge is left at the end, so the starting level does not bias the cost.
+START_SOC_KWH = 0.0
+
+# Cars are charged at this rate when the config gives no explicit figure
+DEFAULT_CAR_RATE_KW = 7.4
+
+# Maximum charge slots the dumb-battery baseline is allowed, matching the
+# calculate_savings_max_charge_slots convention in calculate_yesterday()
+BASELINE_MAX_CHARGE_SLOTS = 1
+
+# The smart car in scenario 3 must be ready by this time, matching the
+# "car_charging_plan_time" default in config.py. Only the first day of the 48
+# hour plan is billed (see run_day()'s end_record), so a single morning ready
+# time is enough to give Predbat a fair one-shot charging decision to make.
+DEFAULT_CAR_READY_TIME = "07:00:00"
+
+# A charging session longer than this will not reliably fit inside a typical cheap
+# overnight band (Flux, Cosy), so car_charging_schedule() splits into more, shorter
+# sessions per week once a single weekly session would exceed it.
+CAR_SESSION_MAX_HOURS = 6.0
+
+# However many sessions a week would be needed to keep every session under
+# CAR_SESSION_MAX_HOURS, never plan more than one a day.
+MAX_SESSIONS_PER_WEEK = 7
+
+
+def car_charging_schedule(annual_kwh, car_rate_kw):
+ """Derive how often, and how much, a car charges per week from its annual energy.
+
+ Smearing an annual car figure evenly across 365 days is wrong in a way that
+ matters: a 2,500 kWh/year smear is 6.85 kWh/day, under an hour at 7.4 kW, which
+ fits trivially inside any cheap overnight window. A dumb timer would then get the
+ cheap rate just as easily as Predbat, and the EV would contribute almost nothing
+ to the measured saving. Real owners charge in sessions of tens of kWh that can
+ overflow a short cheap band, forcing part of the session onto an expensive rate
+ under a timer while Predbat spreads it across the cheapest half-hours instead -
+ that overflow is where smart charging earns its keep, and smearing deletes it.
+
+ The schedule is derived from the annual energy and the charger's power, not
+ configured directly: one session a week carries the whole week's energy unless
+ that session would run longer than ``CAR_SESSION_MAX_HOURS`` at the given rate, in
+ which case the week's energy is split across as many sessions as needed to bring
+ each under the cap, capped at ``MAX_SESSIONS_PER_WEEK`` (one a day). When even a
+ daily session cannot get under the cap (a very low charge rate against a very high
+ annual figure), seven sessions are still returned, but each will run long - the
+ caller is responsible for logging that the overflow this model is meant to capture
+ is then understated.
+
+ Returns (sessions_per_week, session_kwh). ``sessions_per_week * session_kwh`` equals
+ ``annual_kwh / 52.0`` to within floating-point rounding (division and its inverse),
+ so summing the week's sessions recovers the annual total.
+
+ Note a deliberate 52-vs-7 mismatch: a session is sized here as ``annual_kwh / 52.0``
+ per week, but ``run_day()`` blends it back in at ``sessions_per_week / 7`` of each
+ sampled day - and 52 weeks * 7 days = 364, one short of a real (365 or 366 day) year.
+ So the car energy actually modelled over a year is ``annual_kwh * 365 / 364``, about
+ +0.27% high. Harmless at that size, but it is a real error hiding behind two numbers
+ that look interchangeable: "fixing" only one of the 52 here or the 7 in ``run_day()``
+ (e.g. switching this to weeks-per-365-days) without the matching change to the other
+ would turn a harmless 0.27% into a much larger one. Keep the two divisors paired if
+ either is ever revisited.
+ """
+ weekly_kwh = annual_kwh / 52.0
+ if weekly_kwh <= 0 or car_rate_kw <= 0:
+ # A configured car (annual_kwh > 0) with an unusable rate is silently dropped here:
+ # (0, 0.0) reads to a caller exactly like "no car configured" at all. Not reachable
+ # through _validate_load() today (car_rate_kw is validated greater than zero there),
+ # but a caller reaching this function some other way gets no signal that a real car
+ # was discarded. Not raised, since this guard is defensive rather than a real
+ # validation boundary - see car_charging_overflow_warning() below for the one
+ # warning this module does emit about the schedule it derives.
+ return 0, 0.0
+
+ session_hours = weekly_kwh / car_rate_kw
+ if session_hours <= CAR_SESSION_MAX_HOURS:
+ return 1, weekly_kwh
+
+ sessions_per_week = min(MAX_SESSIONS_PER_WEEK, math.ceil(session_hours / CAR_SESSION_MAX_HOURS))
+ return sessions_per_week, weekly_kwh / sessions_per_week
+
+
+def car_charging_overflow_warning(car_charging_kwh, car_rate_kw):
+ """Return a warning message if the car's sessions cannot fit under the six-hour cap.
+
+ This is a static property of ``car_charging_kwh``/``car_rate_kw`` alone, not of any
+ particular sampled day, so a caller should log it once per run (``AnnualPredictor.run()``
+ does) rather than once per sampled day - the same condition would otherwise repeat
+ identically for every one of the roughly two dozen days a run samples. Returns None
+ when there is nothing to warn about, including when no car is configured at all.
+ """
+ if car_charging_kwh <= 0:
+ return None
+ sessions_per_week, session_kwh = car_charging_schedule(car_charging_kwh, car_rate_kw)
+ if sessions_per_week >= MAX_SESSIONS_PER_WEEK and session_kwh > car_rate_kw * CAR_SESSION_MAX_HOURS + 1e-6:
+ return (
+ "Annual: car charging needs {:.1f} kWh/week at {:.1f} kW, which cannot fit into {} sessions of {:.0f} hours or less; sessions are running long ({:.1f} hours), so the timer/Predbat overflow this model is meant to capture is understated".format(
+ sessions_per_week * session_kwh, car_rate_kw, MAX_SESSIONS_PER_WEEK, CAR_SESSION_MAX_HOURS, session_kwh / car_rate_kw
+ )
+ )
+ return None
+
+
+def build_step_data(predbat, pv_minute, pv_minute10):
+ """Build the 5-minute step arrays the Prediction engine consumes.
+
+ Mirrors the calls ``calculate_plan()`` makes in ``plan.py``. Because
+ ``load_forecast_only`` is set, the historical branch of ``step_data_history``
+ contributes nothing and the whole load profile comes from ``load_forecast``.
+ """
+ load_step = predbat.step_data_history(
+ predbat.load_minutes,
+ predbat.minutes_now,
+ forward=False,
+ scale_today=1.0,
+ scale_fixed=1.0,
+ type_load=True,
+ load_forecast=predbat.load_forecast,
+ load_scaling_dynamic=None,
+ cloud_factor=None,
+ load_adjust={},
+ load_baseline={},
+ )
+ pv_step = predbat.step_data_history(pv_minute, predbat.minutes_now, forward=True, cloud_factor=None)
+ pv10_step = predbat.step_data_history(pv_minute10, predbat.minutes_now, forward=True, cloud_factor=None, flip=True)
+ return load_step, pv_step, pv10_step
+
+
+def timer_charge_window(rate_import, car_kwh, car_rate_kw):
+ """Return the fixed off-peak timer windows a non-Predbat household would use.
+
+ Finds the cheapest contiguous band of each day and starts the charge there,
+ extending past the band if the car needs longer than the cheap rate lasts.
+ Returns one window per day of the 48 hour plan so the second day matches the first.
+ """
+ if car_kwh <= 0 or car_rate_kw <= 0:
+ return []
+
+ minutes_needed = int(round((car_kwh / car_rate_kw) * 60.0))
+ if minutes_needed <= 0:
+ return []
+
+ # fetch.py's step_data_history() samples load_forecast once every PREDICT_STEP (5) minutes,
+ # so a window whose length or start is not on that grid is billed at a quantised length
+ # rather than its true one (e.g. an 81 minute need would be billed as 85 minutes, +5%).
+ # Round the duration UP so the car is never under-delivered, and align the start DOWN to
+ # the grid.
+ minutes_needed = -(-minutes_needed // PREDICT_STEP) * PREDICT_STEP
+
+ windows = []
+ for day_offset in range(2):
+ base = day_offset * DAY_MINUTES
+ day_rates = {minute: rate_import.get(base + minute, 0.0) for minute in range(DAY_MINUTES)}
+ if not day_rates:
+ continue
+ cheapest = min(day_rates.values())
+ # The first minute of the longest run at the cheapest rate
+ start = None
+ best_start = 0
+ best_length = 0
+ for minute in range(DAY_MINUTES + 1):
+ at_cheapest = minute < DAY_MINUTES and day_rates[minute] <= cheapest + 1e-9
+ if at_cheapest and start is None:
+ start = minute
+ elif not at_cheapest and start is not None:
+ if minute - start > best_length:
+ best_length = minute - start
+ best_start = start
+ start = None
+ aligned_start = base + (best_start // PREDICT_STEP) * PREDICT_STEP
+
+ # _billed_result only costs minutes < DAY_MINUTES of the billed day (day_offset 0), so a
+ # window that runs past its own day's boundary gets only partially billed there: the
+ # baselines (scenarios 1/2) would then be charged for less car energy than scenario 3,
+ # which always bills the full session, making Predbat's saving look bigger than it is (or,
+ # on a day whose cheap band happens to run low, even negative). Pull the start back so the
+ # window still ends inside its own day whenever the session fits in a day at all. When
+ # minutes_needed itself exceeds a whole day, day_end - minutes_needed is before the day
+ # even starts, so clamp to the day's start instead of producing a negative offset - the
+ # window still overflows into the next day, which is unavoidable for a session that long.
+ day_end = base + DAY_MINUTES
+ if aligned_start + minutes_needed > day_end:
+ aligned_start = max(base, day_end - minutes_needed)
+
+ windows.append({"start": aligned_start, "end": aligned_start + minutes_needed})
+ return windows
+
+
+def add_car_to_load(load_forecast, windows, car_kwh):
+ """Return a copy of the cumulative load series with the car's energy inserted.
+
+ Used by the two baseline scenarios, where the car is simply extra load in a
+ fixed timer window rather than something Predbat schedules. ``car_kwh`` is
+ already the energy this leg is charging - a full weekly session, or zero on the
+ without-car leg ``run_day()`` blends against (see ``car_charging_schedule()``) -
+ and ``windows`` holds one window per day of the plan, each independently sized by
+ ``timer_charge_window`` to hold the full ``car_kwh`` — so every window gets
+ the full session amount, not a share of it. Dividing by ``len(windows)`` here
+ would silently bill only half the car's energy on the one day that matters
+ (day 1 is the only one ``_billed_result`` costs).
+ """
+ if not windows or car_kwh <= 0:
+ return dict(load_forecast)
+
+ per_window = car_kwh
+ additions = {}
+ for window in windows:
+ length = max(1, window["end"] - window["start"])
+ per_minute = per_window / length
+ for minute in range(window["start"], window["end"]):
+ additions[minute] = additions.get(minute, 0.0) + per_minute
+
+ result = {}
+ running_extra = 0.0
+ for minute in sorted(load_forecast.keys()):
+ result[minute] = load_forecast[minute] + running_extra
+ running_extra += additions.get(minute, 0.0)
+ return result
+
+
+def _apply_rates(predbat, rate_import, rate_export):
+ """Install the day's rates and run the scans the planner depends on."""
+ predbat.rate_import = rate_import
+ predbat.rate_export = rate_export
+ predbat.rate_low_threshold = 0
+ predbat.rate_high_threshold = 0
+
+ if predbat.rate_import:
+ predbat.rate_scan(predbat.rate_import, print=False)
+ predbat.rate_import, predbat.rate_import_replicated = predbat.rate_replicate(predbat.rate_import, is_import=True)
+ predbat.rate_scan(predbat.rate_import, print=False)
+ if predbat.rate_export:
+ predbat.rate_scan_export(predbat.rate_export, print=False)
+ predbat.rate_export, predbat.rate_export_replicated = predbat.rate_replicate(predbat.rate_export, is_import=False)
+ predbat.rate_scan_export(predbat.rate_export, print=False)
+
+ predbat.set_rate_thresholds()
+
+ if predbat.rate_export:
+ predbat.high_export_rates, export_lowest, _ = predbat.rate_scan_window(predbat.rate_export, 5, predbat.rate_export_cost_threshold, True)
+ if predbat.rate_high_threshold == 0 and export_lowest <= predbat.rate_export_max:
+ predbat.rate_export_cost_threshold = export_lowest
+ else:
+ predbat.high_export_rates = []
+
+ if predbat.rate_import:
+ predbat.low_rates, _, highest = predbat.rate_scan_window(predbat.rate_import, 5, predbat.rate_import_cost_threshold, False)
+ if predbat.rate_low_threshold == 0 and highest >= predbat.rate_min:
+ predbat.rate_import_cost_threshold = highest
+ else:
+ predbat.low_rates = []
+
+
+def _baseline_charge_window(predbat):
+ """Return the dumb battery's charge windows: the cheapest static band, charged to full.
+
+ Mirrors the baseline in ``calculate_yesterday()`` — a household without Predbat
+ that sets a timer for the cheapest rate and charges to 100%.
+ """
+ if not predbat.rate_import or predbat.soc_max <= 0:
+ return [], []
+ day_values = [value for minute, value in predbat.rate_import.items() if minute < DAY_MINUTES]
+ if not day_values or min(day_values) == max(day_values):
+ return [], []
+
+ combine = predbat.combine_charge_slots
+ predbat.combine_charge_slots = True
+ windows, _, _ = predbat.rate_scan_window(predbat.rate_import, 5, min(day_values), False, return_raw=True)
+ predbat.combine_charge_slots = combine
+
+ windows = [window for window in windows if window["start"] < PLAN_MINUTES][:BASELINE_MAX_CHARGE_SLOTS]
+ return windows, [predbat.soc_max for _ in windows]
+
+
+def _billed_result(predbat, end_record, pv_step):
+ """Run one scenario to completion and return its billed figures.
+
+ The battery-value correction (metric_end minus metric_start) values whatever
+ charge is left at the end, so a scenario cannot look cheap simply by finishing
+ on an empty battery. This is exactly the correction ``Compare.run_scenario()``
+ applies (``compare.py``), including passing zero for ``battery_cycle``,
+ ``metric_keep``, ``final_carbon_g``, ``import_kwh_battery``, ``import_kwh_house``
+ and ``export_kwh`` in the end-of-period ``compute_metric()`` call, matching what
+ the start-of-period call already zeroes. Passing the real values there instead
+ (an earlier version of this function did) would leak the optimiser's internal
+ planning heuristics into a reported billed cost: ``metric_keep`` in particular
+ is added into ``compute_metric()``'s "metric" unconditionally, not gated by a
+ zero-default weight the way the carbon/self-sufficiency/cycle terms are, so it
+ would inflate ``cost_p`` for any scenario whose plan happens to accrue it —
+ typically the battery scenarios, never the no-battery baseline — for a reason
+ that has nothing to do with money actually billed.
+
+ This ALWAYS runs with ``save=None`` (``run_prediction()``'s default) and must
+ keep doing so: ``save="best"`` (or ``"compare"``/``"yesterday"``) switches on
+ ``enable_standing_charge`` inside ``prediction.py`` (see ``_capture_plan()``,
+ which needs a save="best" run for an unrelated reason and re-runs the prediction
+ from scratch rather than reusing this one, precisely to keep that flag off here).
+ """
+ cost, import_kwh_battery, import_kwh_house, export_kwh, _, final_soc, _, battery_cycle, _, final_iboost, _ = predbat.run_prediction(
+ predbat.charge_limit_best, predbat.charge_window_best, predbat.export_window_best, predbat.export_limits_best, False, end_record=end_record
+ )
+ metric_start, _ = predbat.compute_metric(end_record, predbat.soc_kw, predbat.soc_kw, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
+ metric_end, _ = predbat.compute_metric(end_record, final_soc, final_soc, cost, cost, final_iboost, final_iboost, 0, 0, 0, 0, 0, 0)
+
+ pv_generated = sum(value for minute, value in pv_step.items() if minute < end_record)
+ return {
+ "cost_p": metric_end - metric_start,
+ "import_kwh": import_kwh_battery + import_kwh_house,
+ "export_kwh": export_kwh,
+ "pv_generated_kwh": pv_generated,
+ "battery_throughput_kwh": battery_cycle,
+ }
+
+
+def prepare_sample(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh):
+ """Inject every per-day input into the PredBat instance for one sampled day.
+
+ ``car_kwh`` is the energy this specific leg is charging (a full weekly session, or
+ zero for the without-car leg run_day() blends against - see run_day()), not the raw
+ annual config figure, so num_cars below reflects what this leg actually models.
+ """
+ reset_sample_state(predbat)
+
+ # calculate_plan() spins up a multiprocessing Pool sized from args["threads"] (plan.py)
+ # unless it is exactly 0. The annual tool plans hundreds of individual days per run, each a
+ # small, fast calculation, so per-day pool creation is both wasted overhead and, on a
+ # spawn-based multiprocessing start method, unsafe: pool workers read the module-level
+ # PRED_GLOBAL dict in prediction.py, which is only populated in the parent process. This
+ # matches create_headless_predbat()'s own choice for a fully offline run; it is forced here
+ # too so run_day() behaves the same way against a caller-supplied PredBat instance (such as
+ # the standard unit test fixture) that has not gone through that bootstrap.
+ predbat.args["threads"] = 0
+
+ predbat.midnight_utc = midnight_utc
+ predbat.now_utc = midnight_utc
+ predbat.minutes_now = 0
+ predbat.forecast_plan_hours = 48
+ predbat.forecast_minutes = PLAN_MINUTES
+ predbat.forecast_days = 2
+ predbat.end_record = PLAN_MINUTES
+
+ predbat.load_minutes = {}
+ predbat.load_minutes_age = 0
+ predbat.load_forecast = build_load_forecast(load_source, day, 2)
+
+ # set_rate_thresholds() (called from _apply_rates() below) reads num_cars to decide
+ # whether to widen rate_import_cost_threshold for car charging (fetch.py). num_cars is
+ # not part of reset_sample_state()'s leaked-state list — it is a value this leg's car_kwh
+ # determines, not a scenario override to merely clear — so it is set here, from the leg's
+ # own car_kwh, before _apply_rates() runs rather than left at whatever the previous
+ # sample's scenario 3 happened to leave it as (or the headless bootstrap's own default of 1).
+ predbat.num_cars = 1 if car_kwh > 0 else 0
+
+ rate_import, rate_export = tariff.rates_for(midnight_utc, PLAN_MINUTES)
+ _apply_rates(predbat, rate_import, rate_export)
+
+ apply_hardware(predbat, config["battery"], config["solar"])
+ predbat.soc_kw = START_SOC_KWH
+
+
+def _plan_smart_car(predbat, day, car_kwh):
+ """Configure a single smart-charged car and return its planned charging slots.
+
+ ``calculate_plan()`` never calls ``plan_car_charging()`` itself — that only
+ happens in the real fetch cycle (``fetch_sensor_data_car_planning()`` in
+ ``fetch.py``) or in ``Compare.recompute_car_charging()``, both of which this
+ headless tool bypasses. Without calling it here, ``car_charging_slots`` would
+ stay at whatever empty placeholder scenario setup left it as, ``in_car_slot()``
+ would report zero load for every minute, and the car would silently cost
+ nothing at all in the "with Predbat" scenario — a much bigger and more
+ misleading error than merely losing Predbat's optimisation credit. The
+ ready-by time and max price mirror ``config.py``'s defaults for a car with no
+ explicit user override.
+
+ Unlike ``timer_charge_window()`` (which always extends past the cheap band
+ until the car's full energy fits), ``plan_car_charging()`` stops at the
+ ready time and can therefore return a plan short of ``car_kwh`` if the
+ cheap-rate windows before then cannot hold it all. A shortfall here would
+ make scenario 3 look artificially cheap for delivering less car energy than
+ the other two scenarios are billed for — inflating Predbat's apparent
+ saving, the mirror image of ``add_car_to_load()``'s bug where the baseline
+ scenarios were billed for too little and Predbat's saving looked smaller
+ than it was — so a shortfall is logged rather than left to pass unnoticed.
+ """
+ predbat.car_charging_now = [False]
+ predbat.car_charging_plan_time = [DEFAULT_CAR_READY_TIME]
+ predbat.car_charging_plan_max_price = [0]
+ slots = predbat.plan_car_charging(0, predbat.low_rates)
+ planned_kwh = sum(slot.get("kwh", 0.0) for slot in slots)
+ if planned_kwh < car_kwh - 1e-6:
+ predbat.log("Warn: Annual: {} smart car plan only fitted {:.2f} of {:.2f} kWh into the cheap-rate windows before the {} ready time".format(day, planned_kwh, car_kwh, DEFAULT_CAR_READY_TIME))
+ return slots
+
+
+def _capture_plan(predbat, pv_step, pv_step10, load_step, load_step10, end_record):
+ """Return the current scenario's plan as the JSON structure the web plan renderer consumes.
+
+ This is the same ``raw_plan`` the live ``/plan`` page renders from - ``publish_html_plan()``
+ builds it from ``charge_limit_best``/``charge_window_best``/``export_*_best`` and the step
+ data, exactly as they stand for the scenario just costed. ``publish=False`` keeps it from
+ touching Home Assistant, so this is safe in a headless run: it only reads state and returns.
+
+ ``publish_html_plan()`` unconditionally reads ``predict_soc_best``/
+ ``predict_clipped_best``/``predict_iboost_best``/``predict_carbon_best``/
+ ``predict_metric_best`` off ``predbat``, which ``run_prediction()`` only populates
+ when called with ``save="best"`` (or ``"compare"``/``"yesterday"`` - see
+ ``plan.py``). This function therefore re-runs the prediction once more, purely to
+ populate that state, rather than asking the BILLED run (``_billed_result()``) to
+ pass ``save="best"`` itself. That is deliberate, not a missed optimisation:
+ ``save="best"`` also switches on ``enable_standing_charge`` inside
+ ``prediction.py``, which would add a standing charge into the returned cost - but
+ the annual engine already accounts for standing charge separately, as its own
+ per-month ``standing_charge_p`` field built from the annual config's tariff, not
+ from the live instance's unrelated ``metric_standing_charge`` (``fetch.py``). Do
+ not "simplify" this back to reusing the billed run's ``save`` flag - that
+ silently double-counts the standing charge in every debug-captured scenario. The
+ extra prediction is a real doubling of planning cost per scenario, but it only
+ happens under the opt-in debug flag.
+ """
+ predbat.run_prediction(predbat.charge_limit_best, predbat.charge_window_best, predbat.export_window_best, predbat.export_limits_best, False, end_record=end_record, save="best")
+ _, raw_plan = predbat.publish_html_plan(pv_step, pv_step10, load_step, load_step10, end_record, publish=False, prediction=predbat.prediction)
+ return raw_plan
+
+
+def _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh, car_rate_kw, plans=None):
+ """Run all four scenarios against one sampled day at a fixed car charging energy.
+
+ ``car_kwh`` is the actual energy this leg charges - either a full weekly charging
+ session or zero, never the smeared daily average an earlier version of this tool
+ used (see ``car_charging_schedule()``). ``run_day()`` calls this once when no car is
+ configured, or twice (once per leg) and blends the two sets of results when one is.
+
+ ``plans``, when supplied, is a dict this function fills in place with one entry per
+ scenario key, each holding that scenario's plan captured against the same PV and load
+ series it was billed against - a plan drawn from a different series would defeat the
+ point of the feature, which is cross-checking the billed numbers. Leaving it ``None``
+ (the default) skips every capture, so a non-debug run pays no extra cost. Each
+ capture (see ``_capture_plan()``) re-runs the prediction it captures from, rather
+ than reusing the billed ``_billed_result()`` run, so the billed ``cost_p``/
+ ``import_kwh``/etc are always from a ``save=None`` run and never carry the standing
+ charge ``save="best"`` adds - see ``test_annual_debug_capture()`` and
+ ``test_annual_integration.py``'s identical-billed-figures regression guard.
+ """
+ prepare_sample(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh)
+
+ actual_pv = weather.pv_minutes("actual", midnight_utc, PLAN_MINUTES) if config["solar"] else {}
+ forecast_pv = weather.pv_minutes("forecast", midnight_utc, PLAN_MINUTES) if config["solar"] else {}
+ p10_pv = weather.pv_minutes_p10(midnight_utc, PLAN_MINUTES, day.month) if config["solar"] else {}
+
+ timer_windows = timer_charge_window(predbat.rate_import, car_kwh, car_rate_kw)
+ baseline_load = add_car_to_load(predbat.load_forecast, timer_windows, car_kwh)
+
+ results = {}
+
+ # Scenario 1: no PV, no battery. The car still charges on the same timer, so the
+ # only difference between the scenarios is the system being evaluated.
+ predbat.num_cars = 0
+ predbat.load_forecast = baseline_load
+ load_step, actual_step, _ = build_step_data(predbat, actual_pv, actual_pv)
+ zero_step = {minute: 0.0 for minute in actual_step}
+ predbat.charge_limit_best = []
+ predbat.charge_window_best = []
+ predbat.export_window_best = []
+ predbat.export_limits_best = []
+ predbat.prediction = Prediction(predbat, zero_step, zero_step, load_step, load_step, soc_kw=0, soc_max=0)
+ results["no_pvbat"] = _billed_result(predbat, DAY_MINUTES, zero_step)
+ if plans is not None:
+ plans["no_pvbat"] = _capture_plan(predbat, zero_step, zero_step, load_step, load_step, DAY_MINUTES)
+
+ # Scenario 1b: PV but no battery. apply_hardware gives the array its real inverter
+ # and export limits - a PV-only system still has an inverter, and clipping matters -
+ # while soc_max=0 leaves it with nowhere to store surplus, so everything the house
+ # cannot use at the moment it is generated is exported. That difference is the whole
+ # point of this scenario: it is what makes PV-only payback different from a fixed
+ # fraction of the PV-plus-battery figure.
+ #
+ # Like scenarios 1 and 2 this is a single prediction with empty windows. It must NOT
+ # call calculate_plan(): the planner is what makes a run expensive, and there is
+ # nothing to plan without a battery.
+ apply_hardware(predbat, config["battery"], config["solar"])
+ predbat.soc_kw = 0
+ predbat.charge_limit_best = []
+ predbat.charge_window_best = []
+ predbat.export_window_best = []
+ predbat.export_limits_best = []
+ predbat.prediction = Prediction(predbat, actual_step, actual_step, load_step, load_step, soc_kw=0, soc_max=0)
+ results["pv_only"] = _billed_result(predbat, DAY_MINUTES, actual_step)
+ if plans is not None:
+ plans["pv_only"] = _capture_plan(predbat, actual_step, actual_step, load_step, load_step, DAY_MINUTES)
+
+ # Scenario 2: PV and battery on a dumb cheapest-rate timer, no export optimisation
+ apply_hardware(predbat, config["battery"], config["solar"])
+ predbat.soc_kw = START_SOC_KWH
+ charge_window, charge_limit = _baseline_charge_window(predbat)
+ predbat.charge_window_best = charge_window
+ predbat.charge_limit_best = charge_limit
+ predbat.export_window_best = []
+ predbat.export_limits_best = []
+ predbat.prediction = Prediction(predbat, actual_step, actual_step, load_step, load_step, soc_kw=START_SOC_KWH)
+ results["without_predbat"] = _billed_result(predbat, DAY_MINUTES, actual_step)
+ if plans is not None:
+ plans["without_predbat"] = _capture_plan(predbat, actual_step, actual_step, load_step, load_step, DAY_MINUTES)
+
+ # Scenario 3: Predbat plans on the FORECAST, then is costed against the ACTUALS.
+ # Skipping the Prediction swap below would hand Predbat perfect foresight.
+ predbat.load_forecast = build_load_forecast(load_source, day, 2)
+ if car_kwh > 0:
+ predbat.num_cars = 1
+ predbat.car_charging_planned = [True]
+ predbat.car_charging_plan_smart = [True]
+ predbat.car_charging_battery_size = [max(car_kwh * 2, 50.0)]
+ predbat.car_charging_limit = [car_kwh]
+ predbat.car_charging_soc = [0.0]
+ predbat.car_charging_rate = [car_rate_kw]
+ # In scenarios 1 and 2 the car's energy is baked into ordinary house load
+ # (add_car_to_load()), which the battery may serve freely. Leaving this False would
+ # pin discharge_rate_now to battery_rate_min for every car-charging minute
+ # (prediction.py), forcing scenario 3's battery idle exactly when the other two
+ # scenarios let it help — the same physics must apply to both sides of the comparison.
+ predbat.car_charging_from_battery = True
+ # low_rates was computed from this day's rates in prepare_sample() and untouched
+ # since, so it is safe to use for planning the car here.
+ predbat.car_charging_slots = [_plan_smart_car(predbat, day, car_kwh)]
+ else:
+ predbat.num_cars = 0
+ # A single empty slot list, not an empty outer list: fetch_config_options() always
+ # sizes car_charging_slots for at least the configured car count (fetch.py), and
+ # other code (including the standard test fixture's reset_inverter()) indexes
+ # car_charging_slots[0] unconditionally. num_cars=0 already keeps every car-planning
+ # loop from iterating it, so this is a shape placeholder rather than a live car.
+ predbat.car_charging_slots = [[]]
+
+ apply_hardware(predbat, config["battery"], config["solar"])
+ predbat.soc_kw = START_SOC_KWH
+ predbat.pv_forecast_minute = forecast_pv
+ predbat.pv_forecast_minute10 = p10_pv
+ predbat.calculate_plan(recompute=True, debug_mode=False, publish=False)
+
+ # Swap in the actuals before costing. There is no forecast/actual split for load (only PV
+ # has one) — predbat_load_step is just the household load re-sampled onto the PREDICT_STEP
+ # grid, identical regardless of which PV series build_step_data() was called with.
+ predbat_load_step, _, _ = build_step_data(predbat, forecast_pv, p10_pv)
+ predbat.prediction = Prediction(predbat, actual_step, actual_step, predbat_load_step, predbat_load_step, soc_kw=START_SOC_KWH)
+ results["with_predbat"] = _billed_result(predbat, DAY_MINUTES, actual_step)
+ if plans is not None:
+ plans["with_predbat"] = _capture_plan(predbat, actual_step, actual_step, predbat_load_step, predbat_load_step, DAY_MINUTES)
+
+ return results
+
+
+SCENARIO_KEYS = ["no_pvbat", "pv_only", "without_predbat", "with_predbat"]
+
+SCENARIO_FIELDS = ["cost_p", "import_kwh", "export_kwh", "pv_generated_kwh", "battery_throughput_kwh"]
+
+
+def _blend_results(with_car, without_car, fraction):
+ """Blend two full four-scenario result dicts field by field.
+
+ ``fraction`` is the share of the week charging actually happens
+ (``sessions_per_week / 7`` - see ``car_charging_schedule()``), so every field of
+ every scenario blends linearly: ``fraction * with_car + (1 - fraction) *
+ without_car``. ``pv_generated_kwh`` is identical between the two legs (the car has
+ no effect on solar generation), so its blend is a no-op - a useful self-check that
+ the two legs really are the same sampled day underneath.
+ """
+ return {key: {field: fraction * with_car[key][field] + (1 - fraction) * without_car[key][field] for field in SCENARIO_FIELDS} for key in SCENARIO_KEYS}
+
+
+def run_day(predbat, config, weather, tariff, load_source, day, midnight_utc, plans=None):
+ """Run all four scenarios against one sampled day and return their billed figures.
+
+ A configured car charges in weekly sessions, not a daily smear (see
+ ``car_charging_schedule()``), so the sampled day is planned TWICE when a car is
+ configured: once carrying a full session, once with no car at all, and the two are
+ blended by how often a session actually happens that week. Blending per sampled day,
+ rather than dedicating separate sample days to "car" and "no car", keeps the
+ irradiance-percentile stratification of the sampled days intact instead of
+ confounding solar percentile with charging state. A config with no car runs a
+ single leg, exactly as before this blending was introduced.
+
+ ``plans``, when supplied, is a list this function appends one entry per leg to, each
+ ``{"leg": ..., "scenarios": {...}}``: ``"single"`` for the no-car path, or
+ ``"with_car"`` followed by ``"without_car"`` for the blended path. Leaving it ``None``
+ (the default) appends nothing, so a non-debug run captures no plans.
+ """
+ car_charging_kwh = config["load"].get("car_charging_kwh", 0.0)
+ car_rate_kw = config["load"].get("car_rate_kw", DEFAULT_CAR_RATE_KW)
+
+ if car_charging_kwh <= 0:
+ leg_plans = {} if plans is not None else None
+ result = _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh=0.0, car_rate_kw=car_rate_kw, plans=leg_plans)
+ if plans is not None:
+ plans.append({"leg": "single", "scenarios": leg_plans})
+ return result
+
+ # The "sessions running long" overflow warning is a static property of the config
+ # (car_charging_kwh, car_rate_kw), not of this particular day, so it is emitted once
+ # per run by AnnualPredictor.run() (via car_charging_overflow_warning()) rather than
+ # here, where it would otherwise repeat once per sampled day.
+ sessions_per_week, session_kwh = car_charging_schedule(car_charging_kwh, car_rate_kw)
+
+ with_car_plans = {} if plans is not None else None
+ with_car = _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh=session_kwh, car_rate_kw=car_rate_kw, plans=with_car_plans)
+ if plans is not None:
+ plans.append({"leg": "with_car", "scenarios": with_car_plans})
+
+ without_car_plans = {} if plans is not None else None
+ without_car = _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh=0.0, car_rate_kw=car_rate_kw, plans=without_car_plans)
+ if plans is not None:
+ plans.append({"leg": "without_car", "scenarios": without_car_plans})
+
+ # sessions_per_week / 7, not / 52: this is a fraction of a WEEK (how many of its 7 days
+ # actually carry a session), independent of car_charging_schedule()'s own 52-weeks-per-year
+ # sizing above. See car_charging_schedule()'s docstring for the harmless ~+0.27% this
+ # 52-vs-7 pairing produces, and why the two must be changed together if either is.
+ fraction = sessions_per_week / float(MAX_SESSIONS_PER_WEEK)
+ return _blend_results(with_car, without_car, fraction)
+
+
+def average_rate(rates, minutes):
+ """Return the mean rate across the first ``minutes`` of a rate dict."""
+ values = [rates[minute] for minute in range(minutes) if minute in rates]
+ return (sum(values) / len(values)) if values else 0.0
+
+
+class AnnualPredictor:
+ """Projects a year of electricity costs under four scenarios using the Predbat engine."""
+
+ def __init__(self, config, log=None, storage=None, work_dir="./annual_work"):
+ """Validate the config and prepare the run."""
+ self.log = log or print
+ self.config = validate_config(config)
+ self.storage = storage
+ self.work_dir = work_dir
+ self.predbat = None
+ self.weather = None
+ self.tariff = None
+ self.load_source = None
+ self.caveats = []
+
+ async def _resolve_location(self, weather_fetch):
+ """Return (latitude, longitude) from the config, resolving a postcode if needed."""
+ location = self.config["location"]
+ if "latitude" in location and "longitude" in location:
+ return location["latitude"], location["longitude"]
+ resolved = await resolve_postcode(location["postcode"], weather_fetch, self.log)
+ if not resolved:
+ raise AnnualConfigError("annual.location.postcode '{}' could not be resolved; supply latitude and longitude instead".format(location["postcode"]))
+ return resolved
+
+ async def _build_load_source(self):
+ """Build the load profile source: synthetic, or Octopus consumption data.
+
+ The synthetic fallback is only used to backfill an isolated missing day within
+ an otherwise successful Octopus download; a download that fails outright raises
+ ``AnnualConfigError`` rather than silently substituting the synthetic profile.
+ """
+ load_config = self.config["load"]
+ year = self.config["year"]
+
+ if "octopus" not in load_config:
+ return SyntheticLoadProfile(annual_kwh=load_config["annual_kwh"], shape=load_config["shape"], year=year)
+
+ # A synthetic profile at the UK average backs the real data so an isolated
+ # missing day does not silently become zero consumption
+ fallback = SyntheticLoadProfile(annual_kwh=2700.0, shape="flat", year=year)
+ source = OctopusConsumptionLoadProfile(
+ api_key=load_config["octopus"]["api_key"],
+ account_id=load_config["octopus"]["account_id"],
+ log=self.log,
+ storage=self.storage,
+ fallback=fallback,
+ )
+ if not await source.fetch(year):
+ raise AnnualConfigError("Octopus consumption data could not be downloaded for {}; check the API key and account id".format(year))
+ return source
+
+ def _reweight_survivors(self, surviving_samples, days_in_month):
+ """Rescale surviving samples so they still represent a full month between them.
+
+ ``select_samples()`` gives every chosen sample an equal weight of
+ ``days_in_month / len(chosen)``, on the assumption all of them get planned
+ successfully. When one or more are dropped after a ``run_day()`` failure, keeping
+ their original weight would under-represent the month by the dropped days' share -
+ a month that lost half its samples would silently contribute about half its true
+ cost to the annual total, even though ``standing_charge_p`` and ``days`` still
+ reflect the full month. Recomputing the weight from the surviving count alone
+ keeps the total weight equal to ``days_in_month``; the caller still records
+ ``"degraded"``/``failed_days`` so the reduced sample count remains visible.
+ """
+ if not surviving_samples:
+ return surviving_samples
+ reweighted = days_in_month / float(len(surviving_samples))
+ return [(day, reweighted) for day, _ in surviving_samples]
+
+ def _month_scenarios(self, samples, day_results):
+ """Weight each sample's daily figures into monthly totals per scenario."""
+ totals = {key: {field: 0.0 for field in SCENARIO_FIELDS} for key in SCENARIO_KEYS}
+ for (_, weight), result in zip(samples, day_results):
+ for key in SCENARIO_KEYS:
+ for field in SCENARIO_FIELDS:
+ totals[key][field] += result[key][field] * weight
+ return totals
+
+ @staticmethod
+ def _synthesised_sides(fallback_months, year, month):
+ """Return the sorted sides ('import'/'export') whose rates for this one month came from the tariff's current-rates fallback.
+
+ ``fallback_months`` is ``AnnualTariff.fallback_months`` - a set of
+ ``(year, month, side)`` triples covering every month in the run, not just this
+ one - so this filters down to the (usually empty) subset that applies here.
+ Attached to each month's row so a synthesised month is not indistinguishable
+ from a real one in the results document, the chart or the table.
+ """
+ return sorted(side for fb_year, fb_month, side in fallback_months if fb_year == year and fb_month == month)
+
+ @staticmethod
+ def _tariff_fallback_caveats(fallback_months, unpaid_export_months, year):
+ """Return the caveat strings describing a tariff's current-rates fallback and/or wholly-unpriced export months.
+
+ Both are properties of the whole run (``AnnualTariff`` accumulates them across
+ every ``fetch_month`` call), not of any single sampled day, so this is called
+ once after the month loop rather than from inside it. Returns an empty list
+ when the tariff needed neither.
+
+ Both sets are filtered down to ``year`` first: December's spill fetch
+ (``fetch_month(year + 1, 1)``, done so the last sampled day's 48 hour plan has
+ rates to spill into) can itself hit either fallback, which would otherwise leak
+ a January-of-next-year entry into a caveat about this year's run - a document
+ for 2025 gaining a stray "2026-01" that names a month with no row anywhere in
+ it.
+ """
+ fallback_months = {entry for entry in fallback_months if entry[0] == year}
+ unpaid_export_months = {entry for entry in unpaid_export_months if entry[0] == year}
+ caveats = []
+ if fallback_months:
+ fallback_list = ", ".join("{}-{:02d} ({})".format(fb_year, fb_month, side) for fb_year, fb_month, side in sorted(fallback_months))
+ caveats.append("No historical rates were available for {} on this tariff, likely because it launched after {}. Those months' rates are today's rates repeated across the month, not what was actually charged then.".format(fallback_list, year))
+ if unpaid_export_months:
+ unpaid_list = ", ".join("{}-{:02d}".format(unpaid_year, unpaid_month) for unpaid_year, unpaid_month in sorted(unpaid_export_months))
+ caveats.append("No export rates at all (historical or current) could be found for {} on this tariff, so export was priced at zero for those months. If this tariff pays for export, savings for those months are understated.".format(unpaid_list))
+ return caveats
+
+ async def run(self, progress=None):
+ """Run the full annual projection and return the results document."""
+ year = self.config["year"]
+ samples_per_month = self.config["samples_per_month"]
+ has_solar = bool(self.config["solar"])
+
+ weather_client = AnnualWeather(
+ self.config["solar"],
+ latitude=0.0,
+ longitude=0.0,
+ log=self.log,
+ storage=self.storage,
+ p10_fallback=self.config["pv10_derate_fallback"],
+ )
+ latitude, longitude = await self._resolve_location(weather_client.fetch_json)
+ weather_client.latitude = latitude
+ weather_client.longitude = longitude
+
+ self.weather = await weather_client.fetch(year) if has_solar else None
+ if has_solar and not self.weather.forecast_available:
+ self.caveats.append("The Open-Meteo forecast archive did not cover {}, so Predbat planned against actuals and P10 used the flat {} derate. Savings are likely overstated.".format(year, self.config["pv10_derate_fallback"]))
+ elif has_solar and self.weather.fallback_months:
+ self.caveats.append("Months {} had too few forecast/actual day pairs, so their P10 used the flat {} derate.".format(sorted(self.weather.fallback_months), self.config["pv10_derate_fallback"]))
+ if has_solar:
+ self.caveats.append("The forecast-versus-ERA5 gap includes systematic model bias as well as forecast error, so measured solar uncertainty is slightly overstated.")
+ self.caveats.append("export_credit_p_estimate is money ALREADY included inside cost_p (which prices every export minute at its real rate); it is informational only - adding it to cost_p double-counts export income.")
+ self.caveats.append(
+ "The without_predbat baseline charges in the single cheapest contiguous band of each day, mirroring Predbat's own savings baseline. On a half-hourly tariff such as Agile the cheapest band is often one 30 minute slot, so the baseline is a more pessimistic comparator there than on a banded tariff (Economy 7, Cosy, Flux) where it covers the whole cheap period. Compare predbat_vs_baseline_p across tariffs with that in mind."
+ )
+
+ # A static property of the config, not of any one sampled day, so this is checked and
+ # logged exactly once here rather than inside run_day(), which runs once per sampled
+ # day (roughly two dozen times per year at the default samples_per_month).
+ car_overflow_warning = car_charging_overflow_warning(self.config["load"].get("car_charging_kwh", 0.0), self.config["load"].get("car_rate_kw", DEFAULT_CAR_RATE_KW))
+ if car_overflow_warning:
+ self.log("Warn: {}".format(car_overflow_warning))
+ self.caveats.append(car_overflow_warning)
+
+ self.predbat = create_headless_predbat(self.work_dir, self.config["timezone"], self.log)
+ self.load_source = await self._build_load_source()
+ self.tariff = AnnualTariff(self.config["tariff"], log=self.log, predbat=self.predbat, storage=self.storage, timezone=self.config["timezone"])
+
+ zone = pytz.timezone(self.config["timezone"])
+ months = []
+ total_units = 12
+ completed = 0
+
+ for month in range(1, 13):
+ if progress:
+ progress(completed, total_units, "Month {:02d}/{}".format(month, year))
+
+ days_in_month = calendar.monthrange(year, month)[1]
+ standing_charge_p = self.tariff.standing_charge_p_per_day * days_in_month
+
+ if not await self.tariff.fetch_month(year, month):
+ months.append({"month": month, "status": "unavailable", "reason": "no rate data available", "days": days_in_month, "standing_charge_p": standing_charge_p})
+ completed += 1
+ continue
+ # The 48 hour plan for the last sampled day can spill into the next month
+ next_year, next_month = (year, month + 1) if month < 12 else (year + 1, 1)
+ if not await self.tariff.fetch_month(next_year, next_month):
+ spill_message = "Rate data for {}-{:02d} could not be downloaded, so any plan hours for month {} spilling into it may be costed as free.".format(next_year, next_month, month)
+ self.log("Warn: Annual: {}".format(spill_message))
+ if spill_message not in self.caveats:
+ self.caveats.append(spill_message)
+
+ samples = select_samples(self.weather, year, month, samples_per_month, has_solar=has_solar)
+ if not samples:
+ months.append({"month": month, "status": "unavailable", "reason": "no usable weather days", "days": days_in_month, "standing_charge_p": standing_charge_p})
+ completed += 1
+ continue
+
+ surviving_samples = []
+ day_results = []
+ failed_days = []
+ month_plans = []
+ for day, weight in samples:
+ midnight_utc = zone.localize(datetime(day.year, day.month, day.day)).astimezone(pytz.utc)
+ day_plans = [] if self.config["debug"] else None
+ try:
+ result = run_day(self.predbat, self.config, self.weather, self.tariff, self.load_source, day, midnight_utc, plans=day_plans)
+ except Exception as exc: # noqa: BLE001 - one bad sample must not abort the whole year
+ self.log("Warn: Annual: {} in month {} failed to plan/cost ({}: {}); excluding it from this month's total".format(day.isoformat(), month, type(exc).__name__, exc))
+ failed_days.append(day.isoformat())
+ continue
+ surviving_samples.append((day, weight))
+ day_results.append(result)
+ if day_plans is not None:
+ month_plans.extend(dict(entry, day=day.isoformat()) for entry in day_plans)
+
+ if not day_results:
+ months.append({"month": month, "status": "unavailable", "reason": "every sampled day failed to plan", "days": days_in_month, "standing_charge_p": standing_charge_p, "failed_days": failed_days})
+ completed += 1
+ continue
+
+ if failed_days:
+ surviving_samples = self._reweight_survivors(surviving_samples, days_in_month)
+
+ totals = self._month_scenarios(surviving_samples, day_results)
+ first_midnight = zone.localize(datetime(surviving_samples[0][0].year, surviving_samples[0][0].month, surviving_samples[0][0].day)).astimezone(pytz.utc)
+ _, rate_export = self.tariff.rates_for(first_midnight, DAY_MINUTES)
+ export_rate = average_rate(rate_export, DAY_MINUTES)
+
+ scenarios = {}
+ for key in SCENARIO_KEYS:
+ entry = {field: totals[key][field] for field in SCENARIO_FIELDS}
+ # An approximation, not a second income stream: cost_p already prices export at
+ # the real per-minute export rate for every minute it happened, so the export
+ # credit is already inside it. This is a cruder second estimate of the same
+ # money (a single day's flat average export rate), kept only for a human-
+ # readable "how much of that came from export" figure. Adding it to cost_p
+ # double-counts the export income - see the results-document caveat below.
+ entry["export_credit_p_estimate"] = entry["export_kwh"] * export_rate
+ scenarios[key] = {name: round(value, 3) for name, value in entry.items()}
+
+ row = {
+ "month": month,
+ "status": "ok" if not failed_days else "degraded",
+ "days": days_in_month,
+ "sampled_days": [day.isoformat() for day, _ in surviving_samples],
+ "failed_days": failed_days,
+ "standing_charge_p": round(standing_charge_p, 3),
+ "scenarios": scenarios,
+ # Which sides of *this* month's rates (if any) came from the tariff's
+ # current-rates fallback rather than a real historical download -
+ # carried onto the row itself, not just into a run-wide caveat, so a
+ # synthesised month is not indistinguishable from a real one in the
+ # JSON, the chart or the table.
+ "rates_synthesised": self._synthesised_sides(self.tariff.fallback_months, year, month),
+ }
+ if self.config["debug"]:
+ row["plans"] = month_plans
+ months.append(row)
+ completed += 1
+
+ self.caveats.extend(self._tariff_fallback_caveats(self.tariff.fallback_months, self.tariff.unpaid_export_months, year))
+
+ if progress:
+ progress(total_units, total_units, "Complete")
+
+ return self._build_results(months)
+
+ def _build_results(self, months):
+ """Assemble the final results document from the per-month rows.
+
+ A month that is entirely ``"unavailable"`` (no rate data, no usable weather days, or
+ every sampled day failed) contributes nothing. An ``"ok"`` or ``"degraded"`` month
+ (some, but not all, of its sampled days failed - see ``run()``) still carries real
+ figures and is included. When no month is included at all, ``annual.scenarios`` and
+ ``annual.standing_charge_p`` are ``None`` and ``annual.savings`` is empty rather than
+ reporting a fabricated zero-cost, zero-saving year.
+ """
+ included = [entry for entry in months if entry["status"] in ("ok", "degraded")]
+ excluded = [entry["month"] for entry in months if entry["status"] not in ("ok", "degraded")]
+
+ annual_scenarios = None
+ standing_total = None
+ savings = {}
+ if included:
+ annual_scenarios = {}
+ for key in SCENARIO_KEYS:
+ annual_scenarios[key] = {field: round(sum(entry["scenarios"][key][field] for entry in included), 3) for field in SCENARIO_FIELDS + ["export_credit_p_estimate"]}
+ standing_total = round(sum(entry["standing_charge_p"] for entry in included), 3)
+ savings["pv_battery_vs_none_p"] = round(annual_scenarios["no_pvbat"]["cost_p"] - annual_scenarios["without_predbat"]["cost_p"], 3)
+ savings["predbat_vs_baseline_p"] = round(annual_scenarios["without_predbat"]["cost_p"] - annual_scenarios["with_predbat"]["cost_p"], 3)
+ else:
+ no_result_message = "No month produced a usable result, so no annual totals or savings could be calculated."
+ if no_result_message not in self.caveats:
+ self.caveats.append(no_result_message)
+
+ total_kwp = sum(float(array.get("kwp", 0) or 0) for array in self.config.get("solar") or [])
+ battery_kwh = float((self.config.get("battery") or {}).get("size_kwh", 0) or 0)
+ costs = build_costs(total_kwp, battery_kwh, self.config["costs"])
+ payback = build_payback(annual_scenarios, costs, len(included), self.config["costs"])
+ if not payback.get("available"):
+ payback_message = "Payback could not be calculated: {}".format(payback["reason"])
+ else:
+ payback_message = "Payback is simple payback - capital divided by the modelled annual saving. It ignores panel degradation, electricity price inflation, battery replacement and finance costs, so treat it as a comparison aid rather than a financial projection."
+ if payback_message not in self.caveats:
+ self.caveats.append(payback_message)
+
+ return {
+ "year": self.config["year"],
+ "config": self.config["raw"],
+ "months": months,
+ "annual": {
+ "scenarios": annual_scenarios,
+ "standing_charge_p": standing_total,
+ "savings": savings,
+ "months_included": len(included),
+ "months_excluded": excluded,
+ "costs": costs,
+ "payback": payback,
+ },
+ "caveats": self.caveats,
+ }
diff --git a/apps/predbat/annual_cli.py b/apps/predbat/annual_cli.py
new file mode 100644
index 000000000..427f43646
--- /dev/null
+++ b/apps/predbat/annual_cli.py
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Command line entry point for the annual prediction tool.
+
+Usage:
+ python3 annual_cli.py --config annual.yaml --out results.json
+"""
+
+import argparse
+import asyncio
+import calendar
+import contextlib
+import json
+import os
+import sys
+
+import yaml
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from annual import SCENARIO_KEYS, AnnualConfigError, AnnualPredictor # noqa: E402
+from storage import StorageLocalFiles # noqa: E402
+
+SCENARIO_LABELS = {"no_pvbat": "No PV/Battery", "pv_only": "PV Only", "without_predbat": "Without Predbat", "with_predbat": "With Predbat"}
+
+
+def _format_pence(pence, currency):
+ """Format a pence amount as an explicitly-labelled pounds figure.
+
+ ``cost_p`` and friends are stored in pence throughout the results document.
+ A bare "12.34" tells the reader nothing about the unit, so the default
+ ``currency="p"`` renders the pounds equivalent with a leading "£" (e.g.
+ "£12.34") rather than an unlabelled number. Any other ``currency`` value is
+ appended as a label instead, so a caller adapting this for a non-GBP config
+ still gets an unambiguous figure.
+ """
+ amount = pence / 100.0
+ if currency == "p":
+ return "£{:.2f}".format(amount)
+ return "{:.2f} {}".format(amount, currency)
+
+
+def format_table(results, currency="p"):
+ """Render the results document as a human-readable table.
+
+ Handles two cases the original design missed: a "degraded" month (some, but not
+ all, of its sampled days failed) is still costed and included in the annual total,
+ so it is rendered with its figures rather than being folded into the "unavailable"
+ branch; and when no month produced a usable result at all, ``annual["scenarios"]``
+ and ``annual["standing_charge_p"]`` are ``None`` and ``annual["savings"]`` is empty,
+ so the totals/savings section is skipped entirely instead of dividing ``None`` or
+ printing a fabricated zero-cost year.
+ """
+ lines = []
+ lines.append("Annual prediction for {}".format(results["year"]))
+ lines.append("")
+ header = "{:<6}".format("Month") + "".join("{:>20}".format(SCENARIO_LABELS[key]) for key in SCENARIO_KEYS)
+ lines.append(header)
+ lines.append("-" * len(header))
+
+ for entry in results["months"]:
+ name = calendar.month_abbr[entry["month"]]
+ if entry["status"] not in ("ok", "degraded"):
+ lines.append("{:<6}{:>60}".format(name, "unavailable - {}".format(entry.get("reason", "unknown"))))
+ continue
+ row = "{:<6}".format(name)
+ for key in SCENARIO_KEYS:
+ row += "{:>20}".format(_format_pence(entry["scenarios"][key]["cost_p"], currency))
+ if entry["status"] == "degraded":
+ row += " (degraded - some sampled days failed to plan)"
+ lines.append(row)
+
+ annual = results["annual"]
+ lines.append("-" * len(header))
+
+ if annual["scenarios"] is not None:
+ total_row = "{:<6}".format("Year")
+ for key in SCENARIO_KEYS:
+ total_row += "{:>20}".format(_format_pence(annual["scenarios"][key]["cost_p"], currency))
+ lines.append(total_row)
+ else:
+ lines.append("No annual total available: no month produced a usable result.")
+
+ lines.append("")
+ lines.append("Based on {} of 12 months.".format(annual["months_included"]))
+ if annual["months_excluded"]:
+ lines.append("Excluded months: {}".format(", ".join(calendar.month_abbr[month] for month in annual["months_excluded"])))
+
+ if annual["scenarios"] is not None:
+ lines.append("")
+ lines.append("Savings")
+ lines.append(" PV and battery vs no system: {}".format(_format_pence(annual["savings"].get("pv_battery_vs_none_p", 0.0), currency)))
+ lines.append(" Predbat vs without Predbat: {}".format(_format_pence(annual["savings"].get("predbat_vs_baseline_p", 0.0), currency)))
+ lines.append(" Standing charge (all scenarios): {}".format(_format_pence(annual["standing_charge_p"], currency)))
+ lines.append(" Export credit (with Predbat, estimate - already included in cost above): {}".format(_format_pence(annual["scenarios"]["with_predbat"]["export_credit_p_estimate"], currency)))
+
+ if results.get("caveats"):
+ lines.append("")
+ lines.append("Caveats")
+ for caveat in results["caveats"]:
+ lines.append(" - {}".format(caveat))
+
+ return "\n".join(lines)
+
+
+def _stderr_log(message):
+ """Write a log message to stderr, so machine mode's stdout stays pure JSON.
+
+ ``StorageLocalFiles`` and ``AnnualPredictor`` are normally given ``log=print``, which
+ writes to stdout. Under ``--machine`` that would interleave plain-text warnings - P10
+ fallbacks, missing rate data, failed sample days, car-charging shortfalls, postcode
+ resolution notices - ahead of the final JSON document, so a parent reading stdout would
+ see garbage-then-JSON and fail to parse it. Routing those same warnings to stderr instead
+ keeps them visible without ever touching stdout.
+ """
+ sys.stderr.write("{}\n".format(message))
+ sys.stderr.flush()
+
+
+def make_progress(quiet, machine=False):
+ """Return a progress callback writing to stderr, or None when quiet.
+
+ Machine mode emits one JSON object per line so the parent process never has
+ to parse prose - the human wording is free to change without breaking a
+ caller. Progress always goes to stderr so stdout carries only the result,
+ whichever mode is in use.
+ """
+ if quiet:
+ return None
+
+ if machine:
+
+ def progress(completed, total, message):
+ """Emit one JSON progress record to stderr."""
+ sys.stderr.write(json.dumps({"completed": completed, "total": total, "message": message}) + "\n")
+ sys.stderr.flush()
+
+ return progress
+
+ def progress(completed, total, message):
+ """Report progress to stderr so stdout stays parseable."""
+ sys.stderr.write("[{}/{}] {}\n".format(completed, total, message))
+ sys.stderr.flush()
+
+ return progress
+
+
+def main(argv=None):
+ """Parse arguments, run the projection, and write the results. Returns an exit code."""
+ parser = argparse.ArgumentParser(description="Project a year of electricity costs using the Predbat engine")
+ parser.add_argument("--config", required=True, help="Path to the annual prediction YAML config")
+ parser.add_argument("--out", default=None, help="Write the results JSON to this path")
+ parser.add_argument("--work-dir", default="./annual_work", help="Working directory for the headless Predbat instance and cache")
+ parser.add_argument("--quiet", action="store_true", help="Suppress progress output")
+ parser.add_argument("--machine", action="store_true", help="Emit results as JSON on stdout and progress as JSON on stderr, for a calling process")
+ args = parser.parse_args(argv)
+
+ try:
+ with open(args.config, "r") as handle:
+ config = yaml.safe_load(handle)
+ except (OSError, yaml.YAMLError) as error:
+ sys.stderr.write("Could not read config {}: {}\n".format(args.config, error))
+ return 2
+
+ # Under --machine, the engine's log must go to stderr rather than the default print()
+ # (stdout): predictor.run() can log warnings - P10 fallbacks, missing rate data, failed
+ # sample days, car-charging shortfalls, postcode resolution - and any of those landing on
+ # stdout ahead of the final json.dump() would corrupt the one-JSON-object contract a parent
+ # process depends on. The default (non-machine) path keeps log=print unchanged.
+ log = _stderr_log if args.machine else print
+
+ storage = StorageLocalFiles(args.work_dir, log)
+
+ # predictor.run() lazily imports the full Predbat engine (predbat.py) on its first call
+ # to create_headless_predbat(); that module's top-level self-update check
+ # (download.check_install()) writes plain text straight to real stdout via a bare
+ # print(), bypassing the log callable entirely. Routing our own log calls to stderr
+ # (above) cannot catch that, so in machine mode stdout itself is redirected to stderr for
+ # the duration of construction and run() - any stray print(), from this code or anything
+ # it pulls in, lands on stderr instead of corrupting the one-JSON-object stdout contract.
+ # The messages stay visible, just on the correct stream; only json.dump() below writes to
+ # the real stdout.
+ stdout_guard = contextlib.redirect_stdout(sys.stderr) if args.machine else contextlib.nullcontext()
+ try:
+ with stdout_guard:
+ # --quiet suppresses only the per-month progress lines (make_progress() below), never
+ # the warnings AnnualPredictor.log emits: P10 fallbacks, missing rate data, failed
+ # sample days and car-charging shortfalls must stay visible even in a quiet run, per
+ # the "failures are visible, never silent" contract.
+ predictor = AnnualPredictor(config, log=log, storage=storage, work_dir=args.work_dir)
+ results = asyncio.run(predictor.run(progress=make_progress(args.quiet, machine=args.machine)))
+ except AnnualConfigError as error:
+ sys.stderr.write("Config error: {}\n".format(error))
+ return 2
+
+ exit_code = 0
+ if args.out:
+ try:
+ with open(args.out, "w", encoding="utf-8") as handle:
+ json.dump(results, handle, indent=2)
+ # stderr, not stdout, so this confirmation is safe in both modes: --machine's
+ # stdout purity only concerns stdout, and stderr already carries plain text
+ # (config errors, progress) in machine mode.
+ sys.stderr.write("Results written to {}\n".format(args.out))
+ except (OSError, TypeError, ValueError) as error:
+ # The projection just took several minutes to compute; a failed write to
+ # --out must not throw the results away. The table (or JSON, in machine mode)
+ # below is still emitted and the failure is only reported through a non-zero
+ # exit code, so a caller relying on --out to have succeeded is not fooled into
+ # thinking it did.
+ sys.stderr.write("Could not write results to {}: {}\n".format(args.out, error))
+ exit_code = 1
+
+ if args.machine:
+ # The parent reads exactly one JSON object from stdout; the human table would
+ # corrupt it, so it is suppressed rather than merely reordered.
+ json.dump(results, sys.stdout)
+ sys.stdout.write("\n")
+ else:
+ print(format_table(results))
+
+ return exit_code
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/apps/predbat/annual_costs.py b/apps/predbat/annual_costs.py
new file mode 100644
index 000000000..c2ebe9e8a
--- /dev/null
+++ b/apps/predbat/annual_costs.py
@@ -0,0 +1,231 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Install-cost and payback model for the annual prediction tool.
+
+Pure arithmetic over plain dicts: no I/O, no Predbat state, no network. The engine
+calls this once per run to turn a system size into an estimated capital cost, and a
+year of modelled savings into a payback period.
+
+The PV rate interpolates between published band medians rather than stepping between
+them. A step function makes a 4.1 kWp system cost less in total than a 4.0 kWp one,
+because it drops onto the cheaper band's rate for its whole size - an artefact of the
+bucketing, not a real price. Interpolating between each band's midpoint keeps total
+cost monotonic across the whole range.
+"""
+
+import math
+
+# Published median install costs, GBP per kWp, for financial year 2025/26. Each is the
+# median for systems within a size band, so it describes the typical system at that
+# band's CENTRE - which is where it is anchored below.
+DEFAULT_COSTS = {
+ "battery_install_gbp": 500.0,
+ "battery_per_kwh_gbp": 300.0,
+ "pv_minimum_gbp": 2500.0,
+ "pv_rate_small_gbp_per_kwp": 1780.0, # band 0-4 kWp
+ "pv_rate_medium_gbp_per_kwp": 1697.0, # band 4-10 kWp
+ "pv_rate_large_gbp_per_kwp": 1262.0, # band 10-50 kWp
+ # Predbat itself. Zero when self-hosted; the hosted version is expected to charge
+ # around 100 a year. RECURRING, not capital - see payback_row().
+ "predbat_annual_gbp": 0.0,
+ # A real quote, which beats any model of one. Zero means "no quote" rather than "free"
+ # - a genuine zero-pound quote is not a thing, and treating 0 as unset matches how
+ # soc_max and the inverter limits are read elsewhere.
+ #
+ # Solar-only and solar-plus-battery, NOT solar and battery separately: a real quote is
+ # almost always for the whole installation, and nobody is handed a battery-only price
+ # to copy out. The battery is the remainder of the two, so a user with one whole-system
+ # quote can enter it directly and still get a PV-only payback out of the modelled solar
+ # figure, without doing any arithmetic themselves.
+ "quoted_pv_gbp": 0.0,
+ "quoted_total_gbp": 0.0,
+}
+
+# Accepted in a stored config but ignored: an earlier revision asked for a battery-only
+# price here. Rejecting it outright would make a config saved against that revision fail
+# to load, and silently reading it as a whole-system total would be worse - it would treat
+# a battery price as if it covered the solar too, and understate the system.
+_RETIRED_COST_KEYS = ("quoted_battery_gbp",)
+
+# Midpoints of the 0-4, 4-10 and 10-50 kWp bands the rates above are medians of.
+PV_RATE_ANCHORS_KWP = (2.0, 7.0, 30.0)
+
+_RATE_KEYS = ("pv_rate_small_gbp_per_kwp", "pv_rate_medium_gbp_per_kwp", "pv_rate_large_gbp_per_kwp")
+
+
+def resolve_costs(raw):
+ """Return the cost settings, merging any overrides over the defaults.
+
+ Every value must be a finite, non-negative number; anything else (including NaN
+ and +/-Infinity, both valid YAML) raises ValueError naming the field, rather than
+ silently falling back to a default and quietly costing the user's system at a
+ price they did not ask for.
+ """
+ settings = dict(DEFAULT_COSTS)
+ if not raw:
+ return settings
+ if not isinstance(raw, dict):
+ raise ValueError("annual.costs must be a mapping")
+ for key, value in raw.items():
+ if key in _RETIRED_COST_KEYS:
+ continue
+ if key not in DEFAULT_COSTS:
+ raise ValueError("annual.costs.{} is not a recognised cost setting".format(key))
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ raise ValueError("annual.costs.{} must be a number, got {!r}".format(key, value))
+ if not math.isfinite(number):
+ raise ValueError("annual.costs.{} must be a number, got {!r}".format(key, value))
+ if number < 0:
+ raise ValueError("annual.costs.{} must not be negative, got {}".format(key, number))
+ settings[key] = number
+ return settings
+
+
+def pv_rate_gbp_per_kwp(total_kwp, settings):
+ """Return the GBP-per-kWp rate for a system of this size.
+
+ Linear interpolation between the three band anchors, clamped flat beyond the first
+ and last. Clamping rather than extrapolating keeps a very small or very large system
+ on a published figure instead of a straight line run off the end of the data.
+ """
+ rates = [settings[key] for key in _RATE_KEYS]
+ anchors = PV_RATE_ANCHORS_KWP
+ size = float(total_kwp)
+ if size <= anchors[0]:
+ return rates[0]
+ if size >= anchors[-1]:
+ return rates[-1]
+ for index in range(len(anchors) - 1):
+ low, high = anchors[index], anchors[index + 1]
+ if low <= size <= high:
+ fraction = (size - low) / (high - low)
+ return rates[index] + fraction * (rates[index + 1] - rates[index])
+ return rates[-1]
+
+
+def pv_cost_gbp(total_kwp, settings):
+ """Return the estimated PV install cost, or zero when there is no PV.
+
+ The minimum install price applies to a real system only. A system of no panels
+ costs nothing - returning the minimum there would invent a cost for equipment the
+ user does not have and make a no-PV scenario look expensive.
+ """
+ size = float(total_kwp or 0)
+ if size <= 0:
+ return 0.0
+ return max(settings["pv_minimum_gbp"], size * pv_rate_gbp_per_kwp(size, settings))
+
+
+def battery_cost_gbp(size_kwh, settings):
+ """Return the estimated battery install cost, or zero when there is no battery."""
+ size = float(size_kwh or 0)
+ if size <= 0:
+ return 0.0
+ return settings["battery_install_gbp"] + settings["battery_per_kwh_gbp"] * size
+
+
+def build_costs(total_kwp, battery_kwh, settings):
+ """Return the capital cost breakdown for a system of this size."""
+ # A quote beats the model, and the two quotes are independent: a whole-system price on
+ # its own still leaves the PV-only payback resting on the modelled solar figure, and a
+ # solar-only price on its own still leaves the battery modelled.
+ #
+ # The battery is always the REMAINDER of the two rather than a figure of its own,
+ # because that is the shape real quotes come in - one price for the installation, and
+ # sometimes a separate one for solar alone. Deriving it means a user with a single
+ # whole-system quote does no arithmetic.
+ quoted_pv = float(settings.get("quoted_pv_gbp", 0) or 0)
+ quoted_total = float(settings.get("quoted_total_gbp", 0) or 0)
+ pv = quoted_pv if quoted_pv > 0 else pv_cost_gbp(total_kwp, settings)
+ if quoted_total > 0:
+ # max(0, ...) guards a whole-system quote that comes in under the solar figure
+ # beside it - a contradiction only the user can resolve, but one that must not
+ # produce a negative battery cost and a total that disagrees with its own parts.
+ battery = max(0.0, quoted_total - pv)
+ else:
+ battery = battery_cost_gbp(battery_kwh, settings)
+ return {
+ "pv_gbp": round(pv, 2),
+ "battery_gbp": round(battery, 2),
+ "total_gbp": round(pv + battery, 2),
+ # So the UI can label a real quote as such rather than presenting it as an
+ # estimate, and can leave the £/kWp note off a figure it does not describe.
+ "pv_quoted": quoted_pv > 0,
+ "battery_quoted": quoted_total > 0,
+ "pv_rate_gbp_per_kwp": round(pv_rate_gbp_per_kwp(total_kwp or 0, settings), 2) if (total_kwp or 0) > 0 and quoted_pv <= 0 else 0.0,
+ "total_kwp": round(float(total_kwp or 0), 3),
+ "battery_kwh": round(float(battery_kwh or 0), 3),
+ }
+
+
+def payback_row(capital_gbp, annual_saving_gbp, recurring_gbp=0.0):
+ """Return one payback row: capital divided by the net annual saving.
+
+ ``recurring_gbp`` is an ongoing yearly cost (Predbat's own fee), so it is subtracted
+ from the saving rather than added to capital. Adding it to capital would understate
+ it enormously - over a ten year payback a 100 a year fee is 1000, not 100.
+
+ A net saving of zero or less reports ``pays_back: False`` with no year count. A
+ negative payback period is meaningless, and dividing by a near-zero saving produces
+ a huge number that reads like an answer rather than the absence of one.
+ """
+ gross = float(annual_saving_gbp)
+ net = gross - float(recurring_gbp or 0.0)
+ row = {
+ "capital_gbp": round(float(capital_gbp), 2),
+ "gross_annual_saving_gbp": round(gross, 2),
+ "annual_saving_gbp": round(net, 2),
+ "predbat_annual_gbp": round(float(recurring_gbp or 0.0), 2),
+ }
+ if net <= 0:
+ row["pays_back"] = False
+ row["years"] = None
+ return row
+ row["pays_back"] = True
+ row["years"] = round(float(capital_gbp) / net, 2)
+ return row
+
+
+def build_payback(annual_scenarios, costs, months_included, settings):
+ """Return the payback block for a completed run, or a reason it could not be built.
+
+ Payback needs a full year. When a month is unavailable the annual totals cover less
+ than twelve months, so every saving is understated and every payback period
+ correspondingly overstated. Extrapolating a partial year to a full one would invent
+ savings for months the tool could not price, so this refuses instead and says which
+ it had - matching how the rest of the tool declines to count an unavailable month.
+ """
+ if not annual_scenarios:
+ return {"available": False, "reason": "No month produced a usable result, so there is nothing to pay back."}
+ if months_included != 12:
+ return {"available": False, "reason": "Payback needs a full year, but only {} of 12 months could be modelled. The missing months are named in the caveats.".format(months_included)}
+
+ baseline = annual_scenarios.get("no_pvbat", {}).get("cost_p")
+ if baseline is None:
+ return {"available": False, "reason": "The no-PV/battery baseline is missing, so there is nothing to compare against."}
+
+ # Every scenario the rows below need must actually be present with a numeric cost_p.
+ # A missing scenario is a different situation to a zero saving, and must not be
+ # silently treated as one - see saving_gbp() below, which indexes directly once this
+ # has passed, rather than defaulting a missing key to the baseline.
+ required = ("no_pvbat", "pv_only", "without_predbat", "with_predbat")
+ missing = [key for key in required if not isinstance(annual_scenarios.get(key, {}).get("cost_p"), (int, float))]
+ if missing:
+ return {"available": False, "reason": "The following scenario(s) are missing from this run, so there is nothing to compare against: {}.".format(", ".join(missing))}
+
+ def saving_gbp(key):
+ """Return the annual saving in GBP of one scenario against the no-system baseline."""
+ return (baseline - annual_scenarios[key]["cost_p"]) / 100.0
+
+ return {
+ "available": True,
+ "pv_only": payback_row(costs["pv_gbp"], saving_gbp("pv_only")),
+ "pv_battery": payback_row(costs["total_gbp"], saving_gbp("without_predbat")),
+ "pv_battery_predbat": payback_row(costs["total_gbp"], saving_gbp("with_predbat"), recurring_gbp=settings["predbat_annual_gbp"]),
+ }
diff --git a/apps/predbat/annual_http.py b/apps/predbat/annual_http.py
new file mode 100644
index 000000000..3314ed733
--- /dev/null
+++ b/apps/predbat/annual_http.py
@@ -0,0 +1,48 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Shared JSON-over-HTTP helper for the annual prediction tool's default fetchers.
+
+``annual_weather.py``, ``annual_tariff.py`` and ``annual_load.py`` each had their own
+default JSON fetcher, differing only in timeout, headers and the module-specific prefix
+on the warning log line. A third near-copy (this module collapses the first two plus
+``OctopusConsumptionLoadProfile``'s) was the trigger to extract one shared implementation
+instead of a fourth review flagging the same duplication again.
+"""
+
+import aiohttp
+
+
+async def fetch_json(url, log, log_prefix, headers, timeout_seconds, session=None):
+ """Download and decode one JSON document, returning None on any failure.
+
+ ``log_prefix`` names the caller in the warning line (e.g. "Open-Meteo request",
+ "Octopus rate request", "Octopus consumption request"), so the three callers keep
+ their own distinguishable log wording despite sharing this implementation.
+
+ ``session`` is optional: ``OctopusConsumptionLoadProfile`` passes one in so a single
+ TCP connection is reused across its meter-resolution call and its paginated
+ consumption downloads, exactly as it did before this helper was extracted. When
+ omitted, a fresh session is opened and closed around this one request, matching what
+ the weather and tariff fetchers did on their own.
+ """
+
+ async def _request(active_session):
+ """Issue the GET on the given session and return its decoded JSON, or None on a bad status."""
+ async with active_session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout_seconds)) as response:
+ if response.status not in [200, 201]:
+ log("Warn: Annual: {} to {} returned {}".format(log_prefix, url, response.status))
+ return None
+ return await response.json()
+
+ try:
+ if session is not None:
+ return await _request(session)
+ async with aiohttp.ClientSession() as new_session:
+ return await _request(new_session)
+ except (aiohttp.ClientError, ValueError, TimeoutError) as error:
+ log("Warn: Annual: {} to {} failed: {}".format(log_prefix, url, error))
+ return None
diff --git a/apps/predbat/annual_job.py b/apps/predbat/annual_job.py
new file mode 100644
index 000000000..3e6ebda9c
--- /dev/null
+++ b/apps/predbat/annual_job.py
@@ -0,0 +1,249 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Subprocess control for the Annual prediction run.
+
+The annual engine is one to three minutes of synchronous CPU work - two to six
+with a car - so running it inside the web server's event loop would freeze the
+whole Predbat interface and the five minute optimiser loop that shares it. It
+runs as a child process instead, handing progress back on stderr and the results
+document on stdout.
+
+This module owns the process and nothing else: no HTML, no Storage, no opinion
+about what the results mean. That is what lets it be tested against a stub child
+rather than the real engine.
+"""
+
+import asyncio
+import json
+import time
+
+# How much of the child's stderr to keep for the failure message. Enough to carry
+# a traceback, bounded so a chatty failure cannot grow without limit.
+MAX_ERROR_LINES = 20
+
+# Grace period between asking the child to stop and killing it outright
+CANCEL_GRACE_SECONDS = 5.0
+
+
+class AnnualJob:
+ """Runs one annual prediction child process at a time and tracks its progress."""
+
+ def __init__(self, log):
+ """Create an idle job that logs through the supplied callable."""
+ self.log = log
+ self.state = "idle"
+ self.completed = 0
+ self.total = 0
+ self.message = ""
+ self.error = None
+ self.results = None
+ self.started_at = None
+ self.finished_at = None
+ self._process = None
+ self._task = None
+ self._stderr_tail = []
+ # Bumped synchronously - with no `await` in between - the instant a new
+ # run is accepted. A supervisor captures the value current at its own
+ # spawn and must not touch shared state once it no longer matches: see
+ # the comment in _supervise for why comparing `self._process` alone is
+ # not enough.
+ self._generation = 0
+
+ def status(self):
+ """Return a JSON-serialisable snapshot for the polling endpoint."""
+ elapsed = 0
+ if self.started_at is not None:
+ end = self.finished_at if self.finished_at is not None else time.time()
+ elapsed = int(end - self.started_at)
+ return {
+ "state": self.state,
+ "completed": self.completed,
+ "total": self.total,
+ "message": self.message,
+ "elapsed": elapsed,
+ "error": self.error,
+ }
+
+ async def start(self, command):
+ """Spawn the child. Returns False when a run is already in progress.
+
+ Refusing rather than queueing is deliberate: two annual runs on the same
+ machine would compete for the same CPU and both would take twice as long.
+ """
+ if self.state == "running":
+ self.log("Warn: Annual: a run is already in progress, refusing to start another")
+ return False
+
+ # Bumped first, synchronously, before anything else: this is what a
+ # stale supervisor checks to know it has been superseded. Setting it
+ # here - rather than after the subprocess exists - closes the window
+ # where `self.state` already says "running" for the new run but
+ # `self._process` has not been reassigned yet, which would otherwise
+ # let an old supervisor's identity check pass by accident.
+ self._generation += 1
+ generation = self._generation
+
+ self.state = "running"
+ self.completed = 0
+ self.total = 0
+ self.message = "Starting"
+ self.error = None
+ self.results = None
+ self.started_at = time.time()
+ self.finished_at = None
+ self._stderr_tail = []
+
+ try:
+ process = await asyncio.create_subprocess_exec(*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ except (OSError, ValueError) as exception:
+ if self._generation == generation:
+ self.state = "failed"
+ self.finished_at = time.time()
+ self.error = "Could not start the annual run: {}".format(exception)
+ self.log("Warn: Annual: {}".format(self.error))
+ return False
+
+ self._process = process
+ self._task = asyncio.create_task(self._supervise(generation, process))
+ self._task.add_done_callback(self._on_task_done)
+ return True
+
+ async def cancel(self):
+ """Ask the child to stop, killing it if it does not. Returns False if nothing was running."""
+ if self.state != "running" or self._process is None:
+ return False
+ process = self._process
+ self.state = "cancelled"
+ self.finished_at = time.time()
+ self.message = "Cancelled"
+ try:
+ process.terminate()
+ except ProcessLookupError:
+ return True
+ try:
+ await asyncio.wait_for(process.wait(), timeout=CANCEL_GRACE_SECONDS)
+ except asyncio.TimeoutError:
+ self.log("Warn: Annual: the run did not stop when asked, killing it")
+ try:
+ process.kill()
+ except ProcessLookupError:
+ pass
+ else:
+ # Reap it - without this, cancel() could return before the
+ # child is actually gone, leaving returncode unset.
+ await process.wait()
+ return True
+
+ def _on_task_done(self, task):
+ """Log if the supervisor task ended by raising rather than settling a terminal state itself.
+
+ Every expected outcome inside `_supervise` is caught and turned into a
+ terminal state there. This callback is the fallback for anything that
+ still escapes - otherwise the job would be stuck reporting 'running'
+ forever, refusing every further start().
+ """
+ if task.cancelled():
+ return
+ exception = task.exception()
+ if exception is not None:
+ self.log("Warn: Annual: the supervisor task ended unexpectedly: {}".format(exception))
+
+ async def _read_progress(self, generation, stream):
+ """Consume the child's stderr, updating progress and keeping a tail for errors.
+
+ `generation` is the run this call was started for, captured by the
+ caller at spawn time. If a later run has since been accepted - because
+ cancel() returned before this stream had drained, and a fresh start()
+ followed straight after - the line is still consumed so the pipe keeps
+ draining, but it must not be allowed to overwrite the newer run's
+ progress or error tail.
+ """
+ while True:
+ line = await stream.readline()
+ if not line:
+ break
+ if self._generation != generation:
+ continue
+ text = line.decode("utf-8", errors="replace").strip()
+ if not text:
+ continue
+ self._stderr_tail.append(text)
+ if len(self._stderr_tail) > MAX_ERROR_LINES:
+ self._stderr_tail.pop(0)
+ try:
+ record = json.loads(text)
+ except ValueError:
+ # Not a progress record - the child is allowed to write plain
+ # warnings to stderr, and one bad line must not stop the parse.
+ continue
+ if isinstance(record, dict) and "completed" in record:
+ self.completed = record.get("completed", self.completed)
+ self.total = record.get("total", self.total)
+ self.message = record.get("message", self.message)
+
+ async def _supervise(self, generation, process):
+ """Read both streams to completion for this child, then settle the final state.
+
+ `generation` is captured by the caller at the instant this run was
+ accepted, before the child even existed. Comparing `self._process` to
+ this call's `process` is not sufficient on its own: `start()` sets
+ `self.state = "running"` before it awaits the subprocess's creation,
+ so there is a window where a new run is already "running" but
+ `self._process` still points at the previous child. A stale supervisor
+ resuming inside that window would see its own `process` still matching
+ `self._process`, and `self.state` no longer "cancelled", and wrongly
+ conclude it was safe to report its own child's outcome. The generation
+ counter has no such window - it is bumped synchronously as the very
+ first thing `start()` does - so it is the only reliable guard.
+ """
+ try:
+ stdout_data, _ = await asyncio.gather(process.stdout.read(), self._read_progress(generation, process.stderr))
+ await process.wait()
+ except Exception as exception: # noqa: BLE001 - a supervisor must never die silently, see _on_task_done
+ if self._generation == generation:
+ self.state = "failed"
+ self.finished_at = time.time()
+ self.error = "The annual run could not be read: {}".format(exception)
+ self.log("Warn: Annual: {}".format(self.error))
+ return
+
+ if self._generation != generation:
+ # Superseded by a later run - its own supervisor owns state now.
+ return
+
+ if self.state == "cancelled":
+ return
+
+ tail = "\n".join(self._stderr_tail)
+
+ if process.returncode != 0:
+ self.state = "failed"
+ self.finished_at = time.time()
+ self.error = "The annual run exited with code {}.\n{}".format(process.returncode, tail)
+ self.log("Warn: Annual: {}".format(self.error))
+ return
+
+ try:
+ results = json.loads(stdout_data.decode("utf-8", errors="replace"))
+ if not isinstance(results, dict):
+ raise ValueError("the results document was not a JSON object, got {}".format(type(results).__name__))
+ except ValueError as exception:
+ # A zero exit with unreadable output is worse than a crash: it would
+ # otherwise render as an empty result that looks like a real answer.
+ self.state = "failed"
+ self.finished_at = time.time()
+ self.results = None
+ self.error = "The annual run finished but its output could not be read: {}\n{}".format(exception, tail)
+ self.log("Warn: Annual: {}".format(self.error))
+ return
+
+ self.results = results
+ self.state = "complete"
+ self.finished_at = time.time()
+ self.message = "Complete"
+ if self.total:
+ self.completed = self.total
diff --git a/apps/predbat/annual_load.py b/apps/predbat/annual_load.py
new file mode 100644
index 000000000..9bec53985
--- /dev/null
+++ b/apps/predbat/annual_load.py
@@ -0,0 +1,300 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Load profile sources for the annual prediction tool.
+
+Produces the forward cumulative kWh series that Predbat consumes as
+``load_forecast`` when ``load_forecast_only`` is set, so no synthetic backwards
+history has to be fabricated.
+"""
+
+import base64
+import calendar
+from datetime import date, datetime, timedelta
+
+import aiohttp
+
+from annual_http import fetch_json
+from annual_profiles import DAY_BAND_SLOTS, MONTH_WEIGHTS, NIGHT_BAND_SLOTS, SHAPE_TILT_FRACTION, half_hour_shape
+
+MINUTES_PER_DAY = 24 * 60
+MINUTES_PER_SLOT = 30
+
+
+def tilt_shape(shape_values, direction):
+ """Move energy between the night and day bands, preserving the total exactly.
+
+ ``direction`` is one of "night" (move day energy into the night band), "day"
+ (the reverse), or "flat" (no change). The amount moved is
+ ``SHAPE_TILT_FRACTION`` of the source band's own energy, so the transfer can
+ never exceed what is available. Energy is taken from and added to individual
+ slots in proportion to their existing share of their band, which keeps the
+ within-band shape intact.
+ """
+ if direction == "flat":
+ return list(shape_values)
+
+ if direction == "night":
+ source_slots, dest_slots = DAY_BAND_SLOTS, NIGHT_BAND_SLOTS
+ elif direction == "day":
+ source_slots, dest_slots = NIGHT_BAND_SLOTS, DAY_BAND_SLOTS
+ else:
+ raise ValueError("Unknown load shape '{}', expected night, day or flat".format(direction))
+
+ tilted = list(shape_values)
+ source_total = sum(tilted[slot] for slot in source_slots)
+ dest_total = sum(tilted[slot] for slot in dest_slots)
+ if source_total <= 0 or dest_total <= 0:
+ return tilted
+
+ moved = source_total * SHAPE_TILT_FRACTION
+ for slot in source_slots:
+ tilted[slot] -= moved * (tilted[slot] / source_total)
+ for slot in dest_slots:
+ tilted[slot] += moved * (shape_values[slot] / dest_total)
+
+ # Push any floating-point residue into the largest slot so the total stays exact
+ residue = sum(shape_values) - sum(tilted)
+ largest = max(range(len(tilted)), key=lambda index: tilted[index])
+ tilted[largest] += residue
+ return tilted
+
+
+class LoadProfileSource:
+ """Base class for a source of daily household load profiles."""
+
+ def daily_kwh(self, day):
+ """Return the total household kWh for the given date."""
+ raise NotImplementedError
+
+ def minute_profile(self, day):
+ """Return a list of 1440 per-minute kWh values for the given date, or None if unavailable."""
+ raise NotImplementedError
+
+
+class SyntheticLoadProfile(LoadProfileSource):
+ """Load profile synthesised from an annual kWh total and a shape preference.
+
+ Monthly weights are normalised across the specific year's day counts so the
+ twelve monthly totals sum to exactly ``annual_kwh``.
+ """
+
+ def __init__(self, annual_kwh, shape, year):
+ """Build the synthetic profile for one calendar year."""
+ self.annual_kwh = float(annual_kwh)
+ self.shape = shape
+ self.year = year
+ self.slot_shape = tilt_shape(half_hour_shape(), shape)
+
+ weighted_days = 0.0
+ for month in range(1, 13):
+ days_in_month = calendar.monthrange(year, month)[1]
+ weighted_days += MONTH_WEIGHTS[month - 1] * days_in_month
+ self.base_daily_kwh = (self.annual_kwh / weighted_days) if weighted_days > 0 else 0.0
+
+ def daily_kwh(self, day):
+ """Return the total household kWh for the given date."""
+ return self.base_daily_kwh * MONTH_WEIGHTS[day.month - 1]
+
+ def minute_profile(self, day):
+ """Return a list of 1440 per-minute kWh values for the given date."""
+ total = self.daily_kwh(day)
+ profile = []
+ for slot_value in self.slot_shape:
+ per_minute = (total * slot_value) / MINUTES_PER_SLOT
+ profile.extend([per_minute] * MINUTES_PER_SLOT)
+ return profile
+
+
+def build_load_forecast(source, start_day, days):
+ """Build the cumulative kWh series Predbat reads as ``load_forecast``.
+
+ Keys are absolute minutes from midnight on ``start_day``. Predbat differences
+ consecutive entries via ``get_from_incrementing(..., backwards=False)``, so
+ the series must be cumulative and must include the final boundary minute
+ ``days * 1440`` for the last minute to be readable.
+
+ Days for which the source has no data contribute zero and are skipped by the
+ caller, which is responsible for logging the gap.
+ """
+ forecast = {0: 0.0}
+ running = 0.0
+ for day_offset in range(days):
+ day = start_day + timedelta(days=day_offset)
+ profile = source.minute_profile(day)
+ if profile is None:
+ profile = [0.0] * MINUTES_PER_DAY
+ base = day_offset * MINUTES_PER_DAY
+ for index, value in enumerate(profile):
+ running += value
+ forecast[base + index + 1] = running
+ return forecast
+
+
+OCTOPUS_API_BASE = "https://api.octopus.energy/v1"
+SLOTS_PER_DAY = 48
+
+
+def parse_consumption_results(results, log=None):
+ """Turn raw Octopus consumption rows into complete per-day half-hourly kWh lists.
+
+ Only days with all 48 slots present, each written exactly once, are returned.
+ A partially reported day is omitted entirely rather than returned short,
+ because a half-populated day looks like genuinely low consumption and would
+ silently understate the bill.
+
+ The autumn clock-change day gets 50 real half-hourly readings (local
+ 01:00-02:00 happens twice), but the slot index only has 48 possible values,
+ so the second pair of readings would silently overwrite the first and leave
+ an undercounted-but-"complete" day. Any day where a slot is written more than
+ once is therefore discarded too, exactly like a partial day, so the autumn
+ transition is handled symmetrically with the spring one (which already comes
+ in short at 46/48 and is rejected as partial).
+ """
+ by_day = {}
+ duplicate_slot_days = set()
+ for row in results or []:
+ start = row.get("interval_start")
+ consumption = row.get("consumption")
+ if start is None or consumption is None:
+ continue
+ try:
+ stamp = datetime.strptime(start[:16], "%Y-%m-%dT%H:%M")
+ except (ValueError, TypeError):
+ continue
+ slot = stamp.hour * 2 + (1 if stamp.minute >= 30 else 0)
+ day = stamp.date()
+ if day not in by_day:
+ by_day[day] = [None] * SLOTS_PER_DAY
+ if by_day[day][slot] is not None:
+ duplicate_slot_days.add(day)
+ continue
+ by_day[day][slot] = float(consumption)
+
+ complete = {}
+ for day, slots in by_day.items():
+ if day in duplicate_slot_days:
+ if log:
+ log("Warn: Annual: Octopus discarded {} - a half-hourly slot was reported more than once, likely a clock-change day".format(day))
+ continue
+ if all(value is not None for value in slots):
+ complete[day] = slots
+ return complete
+
+
+class OctopusConsumptionLoadProfile(LoadProfileSource):
+ """Load profile taken from the account's real half-hourly Octopus consumption.
+
+ The meter series already includes any EV charging, which is why the config
+ layer rejects an Octopus key alongside a separate car charging figure.
+ """
+
+ def __init__(self, api_key, account_id, log, storage=None, fallback=None):
+ """Set up the Octopus consumption source, optionally backed by a fallback profile."""
+ self.api_key = api_key
+ self.account_id = account_id
+ self.log = log
+ self.storage = storage
+ self.fallback = fallback
+ self.consumption = {}
+ self.missing_days = set()
+ self.mpan = None
+ self.serial = None
+
+ def _auth_header(self):
+ """Return the HTTP Basic auth header Octopus expects, API key as username."""
+ token = base64.b64encode("{}:".format(self.api_key).encode("utf-8")).decode("utf-8")
+ return {"Authorization": "Basic {}".format(token), "accept": "application/json", "user-agent": "predbat/1.0"}
+
+ async def _get_json(self, session, url):
+ """Fetch and decode one JSON page, returning None on any failure."""
+ return await fetch_json(url, self.log, "Octopus consumption request", self._auth_header(), 30, session=session)
+
+ async def resolve_meter(self, session):
+ """Resolve the account's MPAN and meter serial. Returns True on success."""
+ data = await self._get_json(session, "{}/accounts/{}/".format(OCTOPUS_API_BASE, self.account_id))
+ if not data:
+ return False
+ for prop in data.get("properties", []) or []:
+ for point in prop.get("electricity_meter_points", []) or []:
+ if point.get("is_export"):
+ continue
+ meters = point.get("meters", []) or []
+ if point.get("mpan") and meters:
+ self.mpan = point["mpan"]
+ self.serial = meters[-1].get("serial_number")
+ if self.serial:
+ self.log("Annual: Octopus resolved MPAN {} meter {}".format(self.mpan, self.serial))
+ return True
+ self.log("Warn: Annual: Octopus account {} has no usable electricity import meter".format(self.account_id))
+ return False
+
+ async def fetch(self, year):
+ """Download a calendar year of half-hourly consumption. Returns True on success."""
+ cache_key = "consumption_{}_{}".format(self.account_id, year)
+ if self.storage:
+ cached = await self.storage.load("annual", cache_key)
+ if isinstance(cached, dict) and cached:
+ self.consumption = {date.fromisoformat(key): value for key, value in cached.items()}
+ self.log("Annual: Octopus consumption for {} loaded from cache, {} days".format(year, len(self.consumption)))
+ return True
+
+ async with aiohttp.ClientSession() as session:
+ if not await self.resolve_meter(session):
+ return False
+
+ url = "{}/electricity-meter-points/{}/meters/{}/consumption/?period_from={}-01-01T00:00Z&period_to={}-01-01T00:00Z&page_size=25000&order_by=period".format(OCTOPUS_API_BASE, self.mpan, self.serial, year, year + 1)
+ rows = []
+ pages = 0
+ fetch_failed = False
+ while url and pages < 40:
+ data = await self._get_json(session, url)
+ if not data or "results" not in data:
+ fetch_failed = True
+ break
+ rows += data["results"]
+ url = data.get("next", None)
+ pages += 1
+
+ # A run only reaches its natural end when `url` runs out on its own. If a page request
+ # failed, or the safety cap was hit while pages remained, `rows` is a truncated
+ # download - it must not be parsed, used, or cached as if it were complete.
+ if fetch_failed or url:
+ self.log("Warn: Annual: Octopus consumption download for {} did not complete, discarding the partial download rather than caching it".format(year))
+ return False
+
+ self.consumption = parse_consumption_results(rows, log=self.log)
+ if not self.consumption:
+ self.log("Warn: Annual: Octopus returned no complete days of consumption for {}".format(year))
+ return False
+
+ self.log("Annual: Octopus consumption for {} downloaded, {} complete days".format(year, len(self.consumption)))
+ if self.storage:
+ await self.storage.save("annual", cache_key, {day.isoformat(): slots for day, slots in self.consumption.items()}, format="json")
+ return True
+
+ def daily_kwh(self, day):
+ """Return the total household kWh for the given date, recording a missing day like minute_profile does."""
+ slots = self.consumption.get(day)
+ if slots is not None:
+ return sum(slots)
+ self.missing_days.add(day)
+ if self.fallback:
+ return self.fallback.daily_kwh(day)
+ return 0.0
+
+ def minute_profile(self, day):
+ """Return 1440 per-minute kWh values, falling back or returning None when the day is missing."""
+ slots = self.consumption.get(day)
+ if slots is None:
+ self.missing_days.add(day)
+ if self.fallback:
+ return self.fallback.minute_profile(day)
+ return None
+ profile = []
+ for slot_value in slots:
+ profile.extend([slot_value / MINUTES_PER_SLOT] * MINUTES_PER_SLOT)
+ return profile
diff --git a/apps/predbat/annual_profiles.py b/apps/predbat/annual_profiles.py
new file mode 100644
index 000000000..9bd34a894
--- /dev/null
+++ b/apps/predbat/annual_profiles.py
@@ -0,0 +1,87 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Domestic load profile data tables for the annual prediction tool.
+
+Data only, no behaviour, so the shapes can be recalibrated against real
+consumption data without touching the code that consumes them.
+"""
+
+# Relative electricity consumption by hour of day for a typical UK domestic
+# property, index 0 = 00:00. Unnormalised; half_hour_shape() normalises to 1.0.
+# Shape: overnight trough, a modest morning peak, a midday plateau, and a
+# pronounced evening peak from about 17:00 to 21:00.
+HOURLY_SHAPE = [
+ 2.6, # 00:00
+ 2.4, # 01:00
+ 2.3, # 02:00
+ 2.2, # 03:00
+ 2.2, # 04:00
+ 2.4, # 05:00
+ 3.0, # 06:00
+ 3.8, # 07:00
+ 4.2, # 08:00
+ 4.1, # 09:00
+ 3.9, # 10:00
+ 3.8, # 11:00
+ 3.9, # 12:00
+ 3.8, # 13:00
+ 3.7, # 14:00
+ 3.9, # 15:00
+ 4.6, # 16:00
+ 5.8, # 17:00
+ 6.5, # 18:00
+ 6.3, # 19:00
+ 5.6, # 20:00
+ 5.0, # 21:00
+ 4.3, # 22:00
+ 3.4, # 23:00
+]
+
+# Relative daily consumption by month, index 0 = January. Captures the UK
+# winter/summer split, which drives much of the annual answer. These are daily
+# rates, so consumers must normalise by days-in-month to preserve the annual total.
+MONTH_WEIGHTS = [
+ 1.20, # January
+ 1.15, # February
+ 1.05, # March
+ 0.95, # April
+ 0.88, # May
+ 0.83, # June
+ 0.82, # July
+ 0.83, # August
+ 0.90, # September
+ 1.00, # October
+ 1.12, # November
+ 1.22, # December
+]
+
+# Proportion of the SOURCE band's own energy moved to the destination band when
+# the user selects a "night" or "day" biased profile. Expressed relative to the
+# source band rather than to the whole day so the transfer can never exceed the
+# energy available to move. Tunable against real data.
+SHAPE_TILT_FRACTION = 0.30
+
+# Half-hour slot indices, 0 = 00:00-00:30, 47 = 23:30-00:00.
+NIGHT_BAND_SLOTS = list(range(0, 14)) # 00:00 - 07:00
+DAY_BAND_SLOTS = list(range(14, 40)) # 07:00 - 20:00
+
+
+def half_hour_shape():
+ """Return the 48-slot half-hourly domestic shape, normalised to sum to exactly 1.0.
+
+ Each hourly weight is split evenly across its two half-hour slots. The final
+ slot absorbs any floating-point residue so the total is exactly 1.0.
+ """
+ total = float(sum(HOURLY_SHAPE))
+ shape = []
+ for weight in HOURLY_SHAPE:
+ half = (weight / total) / 2.0
+ shape.append(half)
+ shape.append(half)
+ residue = 1.0 - sum(shape)
+ shape[-1] += residue
+ return shape
diff --git a/apps/predbat/annual_store.py b/apps/predbat/annual_store.py
new file mode 100644
index 000000000..6e2560cc2
--- /dev/null
+++ b/apps/predbat/annual_store.py
@@ -0,0 +1,311 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Storage-backed history of annual prediction runs.
+
+Keeps the most recent runs so a user can flip between "with a 5 kWh battery" and
+"with a 10 kWh battery" without re-running either. Everything goes through the
+Storage abstraction rather than the filesystem, because there may not be one.
+"""
+
+import copy
+import datetime
+
+MAX_RUNS = 20
+STORAGE_MODULE = "annual"
+INDEX_NAME = "runs_index"
+
+
+def _run_key(run_id):
+ """Return the storage filename holding one run's results document."""
+ return "run_{}".format(run_id)
+
+
+def _plan_key(run_id, month, index):
+ """Return the storage filename holding one leg's captured plans."""
+ return "run_{}_plans_{:02d}_{}".format(run_id, int(month), int(index))
+
+
+def _describe_tariff(tariff):
+ """Return a short human name for the tariff a run used."""
+ if not isinstance(tariff, dict):
+ return "tariff"
+ url = tariff.get("import_octopus_url") or ""
+ for name in ["AGILE", "INTELLI-FLUX", "INTELLI", "FLUX", "COSY", "SNUG", "GO"]:
+ if name in url.upper():
+ return name.title().replace("Intelli-Flux", "Intelligent Flux").replace("Intelli", "Intelligent Go")
+ if tariff.get("rates_import"):
+ return "fixed rates"
+ return "tariff"
+
+
+def build_label(config):
+ """Return a short human label describing the configuration a run used.
+
+ A selector listing five bare timestamps tells the user nothing about which
+ run was which, which defeats the point of keeping more than one.
+ """
+ config = config if isinstance(config, dict) else {}
+ parts = []
+ battery = config.get("battery")
+ if isinstance(battery, dict) and battery.get("size_kwh"):
+ parts.append("{}kWh battery".format(battery["size_kwh"]))
+ else:
+ parts.append("no battery")
+
+ solar = config.get("solar")
+ if solar:
+ total_kwp = sum(array.get("kwp", 0) for array in solar if isinstance(array, dict))
+ if total_kwp:
+ parts.append("{}kWp".format(round(total_kwp, 2)))
+ else:
+ parts.append("no solar")
+
+ parts.append(_describe_tariff(config.get("tariff")))
+ return " · ".join(parts)
+
+
+def build_summary(results, config):
+ """Return the headline figures the compare table shows for one run.
+
+ Read once at save time and cached on the index entry, so comparing twenty runs
+ reads one small index rather than twenty results documents - a debug run's
+ document is several megabytes.
+
+ Every figure is None when it could not be computed, never zero: the compare table
+ renders None as "-" and a zero as a real cost, and confusing "we do not know" with
+ "it costs nothing" is exactly the failure this tool avoids elsewhere.
+ """
+ annual = (results or {}).get("annual") or {}
+ scenarios = annual.get("scenarios") or {}
+ costs = annual.get("costs") or {}
+ payback = annual.get("payback") or {}
+
+ # Matches the isinstance guard in annual_costs.py's payback calculation: a missing
+ # or malformed cost_p is a different situation to a zero saving, and must not be
+ # silently treated as one, whether the field is absent or simply not a number
+ # (as a partially-written or otherwise corrupt document might contain).
+ raw_baseline = (scenarios.get("no_pvbat") or {}).get("cost_p")
+ raw_predbat = (scenarios.get("with_predbat") or {}).get("cost_p")
+ baseline = raw_baseline if isinstance(raw_baseline, (int, float)) else None
+ predbat = raw_predbat if isinstance(raw_predbat, (int, float)) else None
+
+ payback_years = {}
+ if payback.get("available"):
+ for key in ["pv_only", "pv_battery", "pv_battery_predbat"]:
+ row = payback.get(key) or {}
+ # None for a row that does not pay back, so the table can say so rather
+ # than print a number that does not exist.
+ payback_years[key] = row.get("years") if row.get("pays_back") else None
+
+ return {
+ "total_kwp": costs.get("total_kwp"),
+ "battery_kwh": costs.get("battery_kwh"),
+ # What the system was reckoned to cost, so the compare table can show the price
+ # beside the payback - a payback period means little without knowing the outlay
+ # it is repaying. None rather than 0 when the run predates costs entirely.
+ "total_gbp": costs.get("total_gbp"),
+ "quoted": bool(costs.get("pv_quoted") or costs.get("battery_quoted")),
+ "tariff": _describe_tariff((config or {}).get("tariff") or {}),
+ "cost_with_predbat_p": predbat,
+ "saving_vs_none_p": (baseline - predbat) if (baseline is not None and predbat is not None) else None,
+ "payback_years": payback_years,
+ "payback_reason": None if payback.get("available") else payback.get("reason"),
+ "months_included": annual.get("months_included", 0),
+ }
+
+
+async def list_runs(storage):
+ """Return the stored runs newest-first, or an empty list when there are none.
+
+ A corrupt or unexpected index reads as empty rather than raising: the tab
+ must still render so the user can start a fresh run.
+ """
+ if not storage:
+ return []
+ index = await storage.load(STORAGE_MODULE, INDEX_NAME)
+ if not isinstance(index, list):
+ return []
+ return [entry for entry in index if isinstance(entry, dict) and entry.get("id")]
+
+
+async def load_run(storage, run_id):
+ """Return one run's results document, or None when it is unknown or missing."""
+ if not storage or not run_id:
+ return None
+ return await storage.load(STORAGE_MODULE, _run_key(run_id))
+
+
+async def _discard_run(storage, run_id, plan_keys=None):
+ """Remove an evicted run's stored document and plan keys so the ring does not leak them.
+
+ The real Storage component has no ``delete`` method, so the primary path is
+ to overwrite each document with ``None`` and set an expiry in the past, which
+ lets ``storage.cleanup()`` reclaim it later. A ``delete`` method is still
+ preferred when a backend (or the test's fake) provides one.
+ """
+ keys = [_run_key(run_id)] + list(plan_keys or [])
+ if hasattr(storage, "delete"):
+ for key in keys:
+ await storage.delete(STORAGE_MODULE, key)
+ return
+ expired = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)
+ for key in keys:
+ await storage.save(STORAGE_MODULE, key, None, format="json", expiry=expired)
+
+
+async def save_run(storage, results, config, run_id):
+ """Save a completed run and prune the ring to MAX_RUNS. Returns the run id.
+
+ Captured plans (present only on a debug run) are stripped out of the results
+ document and written under their own key per leg, one leg per sampled day, so
+ the results document - read to render totals that ignore the plans - stays
+ small, and each plan can be fetched on its own rather than re-reading a
+ several-megabyte document on every click of the plan viewer.
+
+ A small ``plan_index`` (month, index, day, leg - no payload) is recorded on the
+ index entry alongside ``plan_keys``, so the results page's plan viewer can build
+ its day/scenario selector from the index entry rather than from the document,
+ which - now that this same function has stripped the plans out of it - no
+ longer carries them.
+
+ ``results`` is deep-copied before anything is stripped from it, so the
+ caller's own dict (the web layer still holds it after this returns) is left
+ untouched.
+
+ The evicted run's document and plan keys are discarded as well as its index
+ entry, so the ring cannot leak documents that nothing references.
+ """
+ if not storage:
+ return run_id
+
+ stored = copy.deepcopy(results) if isinstance(results, dict) else results
+ plan_keys = []
+ plan_index = []
+ if isinstance(stored, dict):
+ for month in stored.get("months") or []:
+ if not isinstance(month, dict):
+ continue
+ plans = month.pop("plans", None)
+ if not isinstance(plans, list):
+ continue
+ for index, leg in enumerate(plans):
+ key = _plan_key(run_id, month.get("month", 0), index)
+ await storage.save(STORAGE_MODULE, key, leg, format="json")
+ plan_keys.append(key)
+ plan_index.append(
+ {
+ "month": month.get("month", 0),
+ "index": index,
+ "day": leg.get("day") if isinstance(leg, dict) else None,
+ "leg": leg.get("leg", "single") if isinstance(leg, dict) else "single",
+ }
+ )
+
+ await storage.save(STORAGE_MODULE, _run_key(run_id), stored, format="json")
+
+ annual = results.get("annual", {}) if isinstance(results, dict) else {}
+ entry = {
+ "id": run_id,
+ "timestamp": run_id,
+ "label": build_label(config),
+ "months_included": annual.get("months_included", 0),
+ "status": "ok" if annual.get("months_included") else "empty",
+ "summary": build_summary(results, config),
+ "plan_keys": plan_keys,
+ "plan_index": plan_index,
+ }
+
+ index = await list_runs(storage)
+ index = [existing for existing in index if existing.get("id") != run_id]
+ index.insert(0, entry)
+
+ for dropped in index[MAX_RUNS:]:
+ await _discard_run(storage, dropped["id"], dropped.get("plan_keys"))
+ index = index[:MAX_RUNS]
+
+ await storage.save(STORAGE_MODULE, INDEX_NAME, index, format="json")
+ return run_id
+
+
+async def load_plan(storage, run_id, month, index, scenario):
+ """Return one captured plan, or None when it cannot be resolved.
+
+ Reads the single leg's own key - about 177 KB - rather than the whole results
+ document, which for a debug run is several megabytes and would be re-read on every
+ click of the plan viewer's day and scenario selectors.
+
+ Falls back to a document that still carries its plans inline, so a run stored before
+ they were split out stays viewable instead of silently losing them.
+
+ Never raises: month, index and scenario arrive off an attacker-controlled query
+ string, and every failure to resolve is a None the caller turns into a 404.
+ """
+ if not storage or not run_id or not scenario:
+ return None
+ try:
+ month = int(month)
+ index = int(index)
+ except (TypeError, ValueError):
+ return None
+ if index < 0:
+ return None
+
+ leg = await storage.load(STORAGE_MODULE, _plan_key(run_id, month, index))
+ if isinstance(leg, dict):
+ scenarios = leg.get("scenarios")
+ return scenarios.get(scenario) if isinstance(scenarios, dict) else None
+
+ results = await load_run(storage, run_id)
+ if not isinstance(results, dict):
+ return None
+ for entry in results.get("months") or []:
+ if not isinstance(entry, dict) or entry.get("month") != month:
+ continue
+ plans = entry.get("plans")
+ if not isinstance(plans, list) or index >= len(plans):
+ return None
+ candidate = plans[index]
+ if not isinstance(candidate, dict):
+ return None
+ scenarios = candidate.get("scenarios")
+ return scenarios.get(scenario) if isinstance(scenarios, dict) else None
+ return None
+
+
+async def backfill_summaries(storage, runs):
+ """Return ``runs`` with any missing summary filled in, writing the index back once.
+
+ A run stored before summaries existed has none. Rather than re-reading its document
+ on every visit to the compare page - several megabytes for a debug run - its summary
+ is computed once and persisted, so every later visit is index-only.
+
+ The index is written at most once per call, and only when something was actually
+ filled in: a compare page that changes nothing must not write storage at all.
+ """
+ if not storage or not runs:
+ return runs or []
+
+ filled = False
+ for entry in runs:
+ if entry.get("summary") or not entry.get("id"):
+ continue
+ results = await load_run(storage, entry["id"])
+ if not isinstance(results, dict):
+ continue
+ # build_summary() already guards against a missing or non-numeric cost_p, but this
+ # loop reads arbitrary previously-stored documents, so one entry that is corrupt in
+ # some other way must not abandon the runs already filled - and, critically, must
+ # not skip the index write below that persists them.
+ try:
+ entry["summary"] = build_summary(results, results.get("config") or {})
+ except (TypeError, ValueError, AttributeError, KeyError):
+ continue
+ filled = True
+
+ if filled:
+ await storage.save(STORAGE_MODULE, INDEX_NAME, runs, format="json")
+ return runs
diff --git a/apps/predbat/annual_tariff.py b/apps/predbat/annual_tariff.py
new file mode 100644
index 000000000..dc98a63ed
--- /dev/null
+++ b/apps/predbat/annual_tariff.py
@@ -0,0 +1,500 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Historical tariff resolution for the annual prediction tool.
+
+Resolves import and export rates for a specific past date, either from an
+Octopus product URL using period_from/period_to, or from a static basic rates
+structure. Fetching is done a month at a time and sliced per sampled day.
+"""
+
+import calendar
+import hashlib
+from datetime import datetime, timedelta
+
+import pytz
+
+from annual_http import fetch_json
+from utils import minute_data
+
+MINUTES_PER_DAY = 24 * 60
+
+# How long a downloaded "current rates" snapshot (see _fetch_current_pattern) stays
+# cached to persistent storage. Unlike a per-month ranged download, which covers a
+# fixed historical date and can never change, this is a snapshot of "now" - without
+# an expiry, re-running the tool after a genuine price change would keep replaying
+# the first run's now-stale rates forever.
+CURRENT_PATTERN_CACHE_HOURS = 24
+
+
+def build_period_url(base_url, start_utc, end_utc):
+ """Append period_from/period_to to an Octopus rates URL, preserving existing query parameters.
+
+ ``page_size`` is only appended when the caller's URL does not already specify
+ one, so an explicit ``page_size=100`` is not silently overridden by a second,
+ conflicting ``page_size=1500`` appended after it.
+ """
+ separator = "&" if "?" in base_url else "?"
+ url = "{}{}period_from={}&period_to={}".format(base_url, separator, start_utc.strftime("%Y-%m-%dT%H:%MZ"), end_utc.strftime("%Y-%m-%dT%H:%MZ"))
+ if "page_size=" not in base_url:
+ url += "&page_size=1500"
+ return url
+
+
+class AnnualTariff:
+ """Import and export rates for arbitrary historical dates."""
+
+ def __init__(self, config, log, predbat, storage=None, fetch_json=None, timezone="UTC"):
+ """Configure the tariff from the annual config's ``tariff`` block.
+
+ Octopus product codes are region-suffixed. ``resolve_arg``'s ``extra_args``
+ substitutes ``{dno_region}`` from the config directly, without writing it
+ into ``predbat.args`` first, so a live Octopus component configured for a
+ different region is never clobbered by this tariff's region. Without a
+ region a URL silently 404s and the month is reported unavailable, which
+ looks like an outage rather than a config mistake.
+
+ ``timezone`` is the annual config's IANA timezone name, used only to key the
+ current-rates fallback pattern (see ``_fetch_current_pattern``) by local
+ minute-of-day rather than UTC.
+ """
+ self.config = config
+ self.log = log
+ self.predbat = predbat
+ self.storage = storage
+ self.fetch_json = fetch_json or self._default_fetch_json
+ self.timezone = pytz.timezone(timezone)
+ dno_region = config.get("dno_region")
+ self.import_url = self._resolve_url(config.get("import_octopus_url"), "import_octopus_url", dno_region)
+ self.export_url = self._resolve_url(config.get("export_octopus_url"), "export_octopus_url", dno_region)
+ # A short hash of each fully-resolved (region already substituted) URL, mixed
+ # into every cache key below. The web UI runs every job against one fixed work
+ # dir, so its on-disk cache is shared across runs, not scoped to a tariff or a
+ # run - without this, switching tariff and re-running would silently reuse the
+ # previous tariff's cached rates under the same bare "rates_import_2025_07"-
+ # style key: same key, wrong tariff, no error.
+ self._import_cache_id = self._url_cache_id(self.import_url) if self.import_url else None
+ self._export_cache_id = self._url_cache_id(self.export_url) if self.export_url else None
+ self.basic_import = config.get("rates_import")
+ self.basic_export = config.get("rates_export")
+ self.standing_charge_p_per_day = float(config.get("standing_charge_p_per_day", 0.0))
+ # Keyed by (year, month); each value is a dict of tz-aware UTC stamp to rate
+ self.import_rates = {}
+ self.export_rates = {}
+ self.available = set()
+ # Lazily computed, cached 1440-minute basic rate tables (see _basic_table);
+ # computing these afresh on every rates_for call would re-run basic_rates's
+ # per-entry logging and load_scaling_dynamic mutation roughly 365 times a year.
+ self._basic_import_table = None
+ self._basic_export_table = None
+ # Lazily fetched, cached current-rates fallback patterns (see
+ # _fetch_current_pattern): None means not yet fetched, {} means fetched but
+ # empty. fetch_month is called twelve times a run and must not re-download
+ # this twelve times.
+ self._current_pattern = {"import": None, "export": None}
+ # (year, month, "import"/"export") triples where a month's rates were
+ # synthesised from the current pattern instead of that month's own download,
+ # mirroring AnnualWeather.fallback_months so AnnualPredictor.run() can raise
+ # a caveat about it.
+ self.fallback_months = set()
+ # (year, month) pairs where export was priced at zero because neither the
+ # month's own ranged download nor the current-rates fallback produced any
+ # rows. Without this, that fact was only ever a log line - the original bug
+ # this module exists to fix, just relocated to the fallback's own failure
+ # path - so AnnualPredictor.run() surfaces it as a caveat too.
+ self.unpaid_export_months = set()
+
+ def _resolve_url(self, url, name, dno_region=None):
+ """Substitute templated arguments such as {dno_region} into a tariff URL, without mutating predbat.args."""
+ if not url:
+ return None
+ extra_args = {"dno_region": dno_region} if dno_region else None
+ return self.predbat.resolve_arg(name, url, indirect=False, extra_args=extra_args)
+
+ @staticmethod
+ def _url_cache_id(url):
+ """Return a short, stable identifier for a resolved tariff URL, for namespacing cache keys.
+
+ Two different tariffs - or two DNO regions of the same product, which resolve
+ to different URLs - must never share a cache entry, so this is mixed into every
+ cache key built below. A short hex digest of the fully resolved URL (region
+ already substituted in) is enough to make an accidental collision between two
+ genuinely different URLs practically impossible while keeping cache filenames
+ short and filesystem-safe.
+ """
+ return hashlib.sha256(url.encode("utf-8")).hexdigest()[:12]
+
+ async def _default_fetch_json(self, url):
+ """Download and decode one JSON document, returning None on any failure."""
+ return await fetch_json(url, self.log, "Octopus rate request", {"accept": "application/json", "user-agent": "predbat/1.0"}, 60)
+
+ async def _download_octopus(self, base_url, start_utc, end_utc, cache_key):
+ """Download every page of Octopus rates for a date range, returning (rows, failed)."""
+ return await self._download_rows(build_period_url(base_url, start_utc, end_utc), cache_key)
+
+ async def _download_rows(self, url, cache_key, expiry=None):
+ """Download every page of Octopus rates starting from the given URL.
+
+ Returns ``(rows, failed)``. ``failed`` is True only when a page request
+ errored or the page-cap safety limit was hit with pages still remaining - in
+ both cases we do not actually know what rates exist, so this must not be
+ confused with a request that completed successfully and genuinely found zero
+ rows (``rows == []``, ``failed == False``): only the latter is a real "no
+ history for this date" signal that the current-rates fallback should act on.
+ Collapsing both cases to a bare empty list, as an earlier version of this
+ method did, would let one transient 502 silently trigger the fallback.
+
+ Shared by the per-month ranged download and the bare-URL current-rates
+ download used by ``_fetch_current_pattern``; the only difference between
+ them is the URL each starts from. ``expiry`` is passed straight through to
+ ``storage.save`` - the bare-URL download is a snapshot of "now", so unlike
+ the per-month ranged downloads (which cover fixed, unchanging historical
+ dates) it must not be cached forever.
+ """
+ if self.storage:
+ cached = await self.storage.load("annual", cache_key)
+ if isinstance(cached, list) and cached:
+ return cached, False
+
+ rows = []
+ pages = 0
+ truncated = False
+ while url and pages < 10:
+ data = await self.fetch_json(url)
+ if not data or "results" not in data:
+ # A failed page means we do not know what we are missing. Caching a
+ # partial month would permanently pin wrong rates for that month.
+ self.log("Warn: Annual: rate download for {} stopped early at page {}; not caching a partial result".format(cache_key, pages))
+ truncated = True
+ break
+ rows += data["results"]
+ url = data.get("next", None)
+ pages += 1
+
+ # A run only reaches its natural end when `url` runs out on its own. If a page
+ # request failed, or the safety cap was hit while pages remained, `rows` is a
+ # truncated download - it must not be parsed, used, or cached as if complete,
+ # and the caller must be told this was a failure, not a genuine empty result.
+ if truncated or url:
+ if not truncated:
+ self.log("Warn: Annual: rate download for {} hit the {}-page cap with more pages remaining; not caching a partial result".format(cache_key, pages))
+ return [], True
+ if rows and self.storage:
+ await self.storage.save("annual", cache_key, rows, format="json", expiry=expiry)
+ return rows, False
+
+ @staticmethod
+ def _rows_to_stamped_rates(rows, start_utc, days):
+ """Convert Octopus rate rows into a dict of tz-aware UTC stamp to rate.
+
+ Reuses ``minute_data`` exactly as ``octopus.py`` does, then re-keys the
+ minute offsets back onto absolute timestamps so a single monthly download
+ can be sliced for any day within it.
+ """
+ parsed, _ = minute_data(rows, days + 1, start_utc, "value_inc_vat", "valid_from", backwards=False, to_key="valid_to")
+ return {start_utc + timedelta(minutes=minute): rate for minute, rate in parsed.items()}
+
+ def _rows_to_local_pattern(self, rows):
+ """Reduce Octopus rate rows to a repeating daily pattern keyed by local minute-of-day.
+
+ Octopus peak windows are defined on the local clock, so each row's
+ ``valid_from`` is converted to local time via ``self.timezone`` before its
+ minute-of-day is taken as the key - keying by UTC minute-of-day would slide
+ the peak by an hour across a DST boundary.
+
+ A row with no ``valid_to`` is how Octopus publishes a flat product's current,
+ open-ended rate - it is not a 30 minute row, it applies to every time of day
+ until superseded. The same is true of any row spanning a day or more (a
+ historical rate change on a flat product). Both are expanded across every one
+ of the 48 daily slots rather than just the 30 minutes after ``valid_from``;
+ treating an open-ended row as 30 minutes would leave 47 of 48 slots unrated,
+ which ``rates_for`` then silently treats as free. A row shorter than a day
+ (the half-hourly shape of a variable/Agile-style product) is still expanded
+ slot-by-slot as before. Where rows overlap on a slot, the slot keeps the rate
+ of whichever row has the most recent ``valid_from``, so a genuinely
+ time-limited row still overrides an open-ended base rate.
+ """
+ pattern = {}
+ sources = {}
+
+ def claim(minute_of_day, rate, valid_from):
+ """Give a local minute-of-day slot to a row's rate, unless a more recent row already claimed it."""
+ if sources.get(minute_of_day) is None or valid_from > sources[minute_of_day]:
+ pattern[minute_of_day] = rate
+ sources[minute_of_day] = valid_from
+
+ for row in rows:
+ try:
+ rate = float(row["value_inc_vat"])
+ valid_from = pytz.utc.localize(datetime.strptime(row["valid_from"], "%Y-%m-%dT%H:%M:%SZ"))
+ except (KeyError, TypeError, ValueError):
+ continue
+ valid_to_raw = row.get("valid_to")
+ valid_to = None
+ if valid_to_raw:
+ try:
+ valid_to = pytz.utc.localize(datetime.strptime(valid_to_raw, "%Y-%m-%dT%H:%M:%SZ"))
+ except ValueError:
+ valid_to = None
+
+ if valid_to is None or (valid_to - valid_from) >= timedelta(minutes=MINUTES_PER_DAY):
+ for minute_of_day in range(0, MINUTES_PER_DAY, 30):
+ claim(minute_of_day, rate, valid_from)
+ continue
+
+ slots = max(1, int((valid_to - valid_from).total_seconds() // 60 // 30))
+ for slot in range(slots):
+ local_dt = (valid_from + timedelta(minutes=30 * slot)).astimezone(self.timezone)
+ minute_of_day = local_dt.hour * 60 + local_dt.minute
+ claim(minute_of_day, rate, valid_from)
+ return pattern
+
+ @staticmethod
+ def _pattern_covers_full_day(pattern):
+ """Return True when a local-time daily pattern rates every one of the day's 48 standard half-hour slots.
+
+ ``_stamped_rates_from_pattern`` looks up each real minute by flooring its local
+ time onto the fixed ``0, 30, 60, ... 1410`` grid, so a pattern is only usable if
+ every one of those exact slots is present - not merely "48 rates somewhere in
+ the day". Two distinct ways a pattern can fall short of that both matter here:
+ a short-row (Agile-style) payload that only covers part of the day (for example
+ a bare request answered with a partial trailing window) leaves some grid slots
+ with no row at all; and a timezone whose UTC offset is not a whole multiple of
+ 30 minutes (for example Nepal's +05:45) converts every row onto a grid that
+ never lands on these exact slots, however many rows there are. Both must be
+ refused the same way - as no usable fallback - rather than one silently pricing
+ its gaps at zero while the other happens to fail closed by accident.
+
+ This proves *completeness*, not *coherence*: it confirms every slot has some
+ rate in it, not that all 48 came from one mutually consistent day's data. A
+ pattern built from rows spanning several different calendar days (or, in
+ principle, blended across more than one download) can pass this check while
+ having no single day it truly describes. That risk is accepted here rather than
+ solved - the current-rates fallback already deliberately projects "roughly now"
+ onto a historical month, so this is a difference of degree, not of kind, from
+ what the fallback already does by design.
+ """
+ return all(minute_of_day in pattern for minute_of_day in range(0, MINUTES_PER_DAY, 30))
+
+ async def _fetch_current_pattern(self, side):
+ """Download a tariff URL's current rates and reduce them to a repeating local-time daily pattern.
+
+ The bare URL (no ``period_from``/``period_to``) returns the tariff's current
+ rates, which for a fixed product repeat the same shape every day. Used as a
+ fallback when a historical month's own ranged download comes back empty -
+ typically because the tariff launched after that historical date. Cached on
+ the instance so ``fetch_month``, which runs up to twelve times a year, only
+ downloads this once per side.
+
+ A pattern that does not cover every slot of the local day (see
+ ``_pattern_covers_full_day``) is discarded here and treated as no pattern at
+ all - a bare request can legitimately answer with a partial trailing window,
+ and a partial pattern would otherwise price its uncovered hours at zero, which
+ is the exact failure mode this fallback exists to fix, not reproduce.
+
+ A download that genuinely fails (rather than genuinely finding nothing) still
+ ends up discarded the same way - there is no third state to fall back to - but
+ is logged distinctly, since "the tariff has no current rates" and "we could not
+ check because the download failed" are different facts and only one of them is
+ actually true here. Whichever it was, the result is cached for the rest of this
+ run like any other outcome (see the class docstring on ``fetch_month`` being
+ called up to twelve times), so a transient failure is not retried mid-run, but
+ it also is not misreported as a settled fact about the tariff.
+ """
+ cache_attr = "import" if side == "import" else "export"
+ cached = self._current_pattern[cache_attr]
+ if cached is not None:
+ return cached
+ url = self.import_url if side == "import" else self.export_url
+ if url:
+ cache_id = self._import_cache_id if side == "import" else self._export_cache_id
+ expiry = datetime.now(pytz.utc) + timedelta(hours=CURRENT_PATTERN_CACHE_HOURS)
+ rows, failed = await self._download_rows(url, "current_pattern_{}_{}".format(side, cache_id), expiry=expiry)
+ if failed:
+ self.log("Warn: Annual: the current-rates snapshot for {} could not be downloaded this run (a transient failure, not evidence the tariff has no current rates); no fallback is available for the rest of this run".format(side))
+ else:
+ rows = []
+ pattern = self._rows_to_local_pattern(rows)
+ if pattern and not self._pattern_covers_full_day(pattern):
+ # Reports how many of the *standard* grid slots were actually usable, not
+ # len(pattern) - a timezone offset that is not a multiple of 30 minutes can
+ # produce a pattern with a full 48 entries that still covers zero of the
+ # slots _stamped_rates_from_pattern will ever look up (see
+ # _pattern_covers_full_day), and "covers 48 of 48" would be a very
+ # confusing thing to log about a pattern that is about to be discarded.
+ covered = sum(1 for minute_of_day in range(0, MINUTES_PER_DAY, 30) if minute_of_day in pattern)
+ self.log("Warn: Annual: the current-rates pattern for {} covers only {} of {} half-hour slots of the local day; treating it as unavailable rather than pricing the gap at zero".format(side, covered, MINUTES_PER_DAY // 30))
+ pattern = {}
+ self._current_pattern[cache_attr] = pattern
+ return pattern
+
+ def _stamped_rates_from_pattern(self, pattern, start_utc, days):
+ """Repeat a local-time daily rate pattern across a UTC date range.
+
+ Produces the same ``{utc_stamp: rate}`` shape as ``_rows_to_stamped_rates``
+ - crucially, stamped at every minute, not just every 30 minutes: ``rates_for``
+ looks up one stamp per minute (``midnight_utc + timedelta(minutes=minute)``
+ for every ``minute`` in the window), and a dict only carrying the 48
+ half-hour boundaries would leave 1392 of every 1440 minutes unmatched, which
+ ``rates_for`` then silently prices at nothing. Each minute's UTC stamp is
+ converted to local time for that specific historical date before its
+ containing half-hour slot is looked up in the pattern, so the same local peak
+ window lands on the correct UTC hour either side of a DST boundary.
+ """
+ stamped = {}
+ if not pattern:
+ return stamped
+ total_minutes = days * MINUTES_PER_DAY
+ for minute in range(total_minutes):
+ stamp_utc = start_utc + timedelta(minutes=minute)
+ local_dt = stamp_utc.astimezone(self.timezone)
+ slot_minute_of_day = ((local_dt.hour * 60 + local_dt.minute) // 30) * 30
+ rate = pattern.get(slot_minute_of_day)
+ if rate is not None:
+ stamped[stamp_utc] = rate
+ return stamped
+
+ async def fetch_month(self, year, month):
+ """Fetch (or synthesise) the rates covering one calendar month plus a one day buffer.
+
+ Returns True when usable rates exist for the month. The buffer day lets the
+ last sampled day of the month complete its 48 hour plan.
+ """
+ key = (year, month)
+ if self.import_url or self.export_url:
+ days_in_month = calendar.monthrange(year, month)[1]
+ start_utc = pytz.utc.localize(datetime(year, month, 1))
+ end_utc = start_utc + timedelta(days=days_in_month + 2)
+ days = days_in_month + 2
+
+ import_rates = {}
+ export_rates = {}
+ export_unpaid = False
+ if self.import_url:
+ rows, failed = await self._download_octopus(self.import_url, start_utc, end_utc, "rates_import_{}_{}_{:02d}".format(self._import_cache_id, year, month))
+ if rows:
+ import_rates = self._rows_to_stamped_rates(rows, start_utc, days)
+ elif failed:
+ # A download failure tells us nothing about whether history exists;
+ # falling back here would let one transient error silently overwrite
+ # a real historical month with today's rates. Preserve the original
+ # "unavailable" behaviour exactly.
+ self.log("Warn: Annual: no import rates available for {}-{:02d}".format(year, month))
+ else:
+ pattern = await self._fetch_current_pattern("import")
+ if pattern:
+ import_rates = self._stamped_rates_from_pattern(pattern, start_utc, days)
+ self.fallback_months.add((year, month, "import"))
+ self.log("Warn: Annual: no import rates available for {}-{:02d}; using today's rates repeated across the month instead".format(year, month))
+ else:
+ self.log("Warn: Annual: no import rates available for {}-{:02d}".format(year, month))
+ if self.export_url:
+ rows, failed = await self._download_octopus(self.export_url, start_utc, end_utc, "rates_export_{}_{}_{:02d}".format(self._export_cache_id, year, month))
+ if rows:
+ export_rates = self._rows_to_stamped_rates(rows, start_utc, days)
+ elif failed:
+ self.log("Warn: Annual: no export rates available for {}-{:02d}, treating export as unpaid".format(year, month))
+ export_unpaid = True
+ else:
+ pattern = await self._fetch_current_pattern("export")
+ if pattern:
+ export_rates = self._stamped_rates_from_pattern(pattern, start_utc, days)
+ self.fallback_months.add((year, month, "export"))
+ self.log("Warn: Annual: no export rates available for {}-{:02d}; using today's rates repeated across the month instead".format(year, month))
+ else:
+ self.log("Warn: Annual: no export rates available for {}-{:02d}, treating export as unpaid".format(year, month))
+ export_unpaid = True
+
+ # Only import failing makes the month unusable: rates_for's gate is
+ # `self.import_url or self.export_url`, so an export-only configuration
+ # (no import_url) must not be marked unavailable just because
+ # `import_rates` is the empty dict it was initialised to above.
+ if self.import_url and not import_rates:
+ return False
+ # Recorded only now that the month is confirmed available: a month excluded
+ # entirely because import failed contributes nothing to the results at all,
+ # so telling the user "export was priced at zero" about it would be reporting
+ # a fact about a month that was never billed in the first place.
+ if export_unpaid:
+ self.unpaid_export_months.add((year, month))
+ self.import_rates[key] = import_rates
+ self.export_rates[key] = export_rates
+ self.available.add(key)
+ return True
+
+ # Basic rates repeat a fixed daily pattern, so nothing needs downloading -
+ # but without an import rate source there is nothing to price import with,
+ # and reporting the month available would silently price a year at zero.
+ if not self.basic_import:
+ self.log("Warn: Annual: no rate source configured for {}-{:02d} (no Octopus import URL and no rates_import); refusing to report this month as available".format(year, month))
+ return False
+ self.available.add(key)
+ return True
+
+ def month_available(self, year, month):
+ """Return True when usable rates exist for the given month."""
+ return (year, month) in self.available
+
+ def _basic_table(self, info, name, cache_attr):
+ """Compute and cache the 1440-minute basic rate table for one rate name.
+
+ ``basic_rates`` logs per entry and mutates ``predbat.load_scaling_dynamic``,
+ so recomputing it on every ``rates_for`` call (roughly 365 times a year)
+ would repeat that work for an identical table. Entries keyed by
+ ``day_of_week``/``date`` are stripped before calling it: ``basic_rates``
+ anchors those to ``predbat.midnight`` (the day the tool is run), and
+ ``rates_for`` then collapses everything to a single repeating day via
+ ``minute % MINUTES_PER_DAY`` - so honouring them would make a historical
+ replay depend on today's weekday rather than the sampled historical date.
+ """
+ cached = getattr(self, cache_attr)
+ if cached is not None:
+ return cached
+ usable = []
+ ignored = 0
+ for entry in info or []:
+ if isinstance(entry, dict) and ("day_of_week" in entry or "date" in entry):
+ ignored += 1
+ continue
+ usable.append(entry)
+ if ignored:
+ self.log("Warn: Annual: {} contains {} day_of_week/date entries, which are anchored to today's date rather than the sampled historical date and cannot be honoured during an annual replay; ignoring them".format(name, ignored))
+ table = self.predbat.basic_rates(usable, name)
+ setattr(self, cache_attr, table)
+ return table
+
+ def _basic_window(self, info, name, minutes, cache_attr):
+ """Expand a cached basic rates table across the requested window."""
+ rates = self._basic_table(info, name, cache_attr)
+ return {minute: rates.get(minute % MINUTES_PER_DAY, 0.0) for minute in range(minutes)}
+
+ def rates_for(self, midnight_utc, minutes):
+ """Return (import, export) rate dicts keyed by absolute minute from ``midnight_utc``."""
+ key = (midnight_utc.year, midnight_utc.month)
+
+ if self.import_url or self.export_url:
+ import_stamped = self.import_rates.get(key, {})
+ export_stamped = self.export_rates.get(key, {})
+ # A 48 hour window starting late in a month spills into the next month's download
+ next_key = (midnight_utc.year, midnight_utc.month + 1) if midnight_utc.month < 12 else (midnight_utc.year + 1, 1)
+ import_stamped = dict(import_stamped)
+ import_stamped.update(self.import_rates.get(next_key, {}))
+ export_stamped = dict(export_stamped)
+ export_stamped.update(self.export_rates.get(next_key, {}))
+
+ rate_import = {}
+ rate_export = {}
+ for minute in range(minutes):
+ stamp = midnight_utc + timedelta(minutes=minute)
+ if stamp in import_stamped:
+ rate_import[minute] = import_stamped[stamp]
+ if stamp in export_stamped:
+ rate_export[minute] = export_stamped[stamp]
+ return rate_import, rate_export
+
+ rate_import = self._basic_window(self.basic_import or [], "rates_import", minutes, "_basic_import_table")
+ rate_export = self._basic_window(self.basic_export or [], "rates_export", minutes, "_basic_export_table")
+ return rate_import, rate_export
diff --git a/apps/predbat/annual_weather.py b/apps/predbat/annual_weather.py
new file mode 100644
index 000000000..87feca6b5
--- /dev/null
+++ b/apps/predbat/annual_weather.py
@@ -0,0 +1,257 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Open-Meteo historical weather for the annual prediction tool.
+
+Downloads two archives per PV array: ERA5 reanalysis actuals (what really
+happened) and the archived short-range forecast for the same dates (what Predbat
+would have been looking at). The gap between them is genuine day-ahead forecast
+error, from which each month's P10 ratio is derived.
+"""
+
+import math
+from datetime import timedelta
+
+from annual_http import fetch_json
+from solar_model import convert_azimuth, gti_hourly_to_period_kwh
+
+ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
+FORECAST_ARCHIVE_URL = "https://historical-forecast-api.open-meteo.com/v1/forecast"
+
+HOURLY_VARIABLES = "global_tilted_irradiance,temperature_2m,wind_speed_10m"
+
+# A month needs at least this many usable forecast/actual day pairs before its
+# measured P10 ratio is trusted over the flat fallback.
+MIN_DAYS_FOR_P10 = 7
+
+# Fraction used for the P10 order statistic
+P10_FRACTION = 0.10
+
+
+def percentile(values, fraction):
+ """Return the order statistic at ``fraction`` through a list of values.
+
+ Uses the same convention as the Solcast ensemble P10 in ``solcast.py``:
+ sort ascending and take index ``ceil(n * fraction) - 1``, clamped to zero.
+ Returns 0.0 for an empty list.
+ """
+ if not values:
+ return 0.0
+ ordered = sorted(values)
+ index = max(0, math.ceil(len(ordered) * fraction) - 1)
+ return ordered[index]
+
+
+class WeatherYear:
+ """A year of per-array-summed PV energy, for both actuals and forecast."""
+
+ def __init__(self, actual_periods, forecast_periods, p10_ratios, forecast_available, fallback_months):
+ """Hold the converted period data and the derived monthly P10 ratios."""
+ self.actual_periods = actual_periods
+ self.forecast_periods = forecast_periods if forecast_available else actual_periods
+ self.p10_ratios = p10_ratios
+ self.forecast_available = forecast_available
+ self.fallback_months = fallback_months
+ self._daily_actual = self._daily_totals(self.actual_periods)
+
+ @staticmethod
+ def _daily_totals(periods):
+ """Sum hourly period energy into per-date totals."""
+ totals = {}
+ for stamp, kwh in periods.items():
+ day = stamp.date()
+ totals[day] = totals.get(day, 0.0) + kwh
+ return totals
+
+ def has_actual(self, day):
+ """Return True when actuals exist for the given date."""
+ return day in self._daily_actual
+
+ def daily_actual_kwh(self, day):
+ """Return the total actual PV kWh generated on the given date."""
+ return self._daily_actual.get(day, 0.0)
+
+ def p10_ratio(self, month):
+ """Return the P10 scaling ratio for the given month number, 1 = January."""
+ return self.p10_ratios.get(month, 1.0)
+
+ def pv_minutes(self, series, midnight_utc, minutes):
+ """Spread hourly period energy across per-minute kWh, keyed by absolute minute.
+
+ ``series`` is "actual" or "forecast". Minutes outside [0, minutes) are
+ discarded, so the caller always receives a window it asked for.
+ """
+ periods = self.actual_periods if series == "actual" else self.forecast_periods
+ result = {}
+ end_utc = midnight_utc + timedelta(minutes=minutes)
+ for stamp, kwh in periods.items():
+ if stamp < midnight_utc or stamp >= end_utc:
+ continue
+ offset = int((stamp - midnight_utc).total_seconds() // 60)
+ per_minute = kwh / 60.0
+ for minute in range(offset, offset + 60):
+ if 0 <= minute < minutes:
+ result[minute] = result.get(minute, 0.0) + per_minute
+ return result
+
+ def pv_minutes_p10(self, midnight_utc, minutes, month):
+ """Return the P10 per-minute series: the forecast series scaled by the month's ratio."""
+ ratio = self.p10_ratio(month)
+ return {minute: value * ratio for minute, value in self.pv_minutes("forecast", midnight_utc, minutes).items()}
+
+
+class AnnualWeather:
+ """Fetches and converts a calendar year of Open-Meteo data for one site."""
+
+ def __init__(self, arrays, latitude, longitude, log, storage=None, p10_fallback=0.7, fetch_json=None):
+ """Configure the site's PV arrays and the JSON fetcher used for downloads."""
+ self.arrays = arrays
+ self.latitude = latitude
+ self.longitude = longitude
+ self.log = log
+ self.storage = storage
+ self.p10_fallback = p10_fallback
+ self.fetch_json = fetch_json or self._default_fetch_json
+
+ async def _default_fetch_json(self, url):
+ """Download and decode one JSON document, returning None on any failure."""
+ return await fetch_json(url, self.log, "Open-Meteo request", {"accept": "application/json", "user-agent": "predbat/1.0"}, 120)
+
+ def _build_url(self, base, array, year):
+ """Build one Open-Meteo request URL for a single array and calendar year.
+
+ The window runs to 1 January of the following year so the final sampled
+ day still has the following day its 48 hour plan needs.
+ """
+ azimuth = array.get("azimuth", 180.0)
+ if not array.get("azimuth_zero_south", False):
+ azimuth = convert_azimuth(azimuth)
+ return "{}?latitude={}&longitude={}&start_date={}-01-01&end_date={}-01-01&hourly={}&tilt={}&azimuth={}&wind_speed_unit=ms&timezone=UTC".format(
+ base, self.latitude, self.longitude, year, year + 1, HOURLY_VARIABLES, array.get("declination", 35.0), azimuth
+ )
+
+ @staticmethod
+ def _payload_problem(data):
+ """Return why a payload cannot be trusted, or None when it is complete and usable.
+
+ Shared by the cache-write gate, the cache-read gate and the fetch failure log, so a
+ missing, empty or mid-year-truncated response is described identically wherever it
+ is encountered, and a poisoned cache entry is described the same way a bad live
+ download would be.
+ """
+ if not data:
+ return "no data"
+ hourly = data.get("hourly", {})
+ times = hourly.get("time", [])
+ gti_values = hourly.get("global_tilted_irradiance", [])
+ if not times or not gti_values:
+ return "no hourly values"
+ if len(gti_values) < len(times):
+ return "truncated ({} stamps, {} values)".format(len(times), len(gti_values))
+ return None
+
+ async def _fetch_series(self, base, year, cache_tag):
+ """Fetch one source for every array and return the summed hourly period energy.
+
+ Every configured array must produce a usable payload for the series to count as
+ available. If any array fails or returns an unusable payload, the whole series is
+ abandoned (an empty dict) rather than silently blending a partial result for one
+ array with complete results for the others, which would understate the true forecast
+ error and let the P10 derate collapse to an artificially optimistic value.
+ """
+ totals = {}
+ for index, array in enumerate(self.arrays):
+ url = self._build_url(base, array, year)
+ cache_key = "weather_{}_{}_{}_{}_{}".format(cache_tag, year, index, self.latitude, self.longitude)
+ data = None
+ if self.storage:
+ cached = await self.storage.load("annual", cache_key)
+ if self._payload_problem(cached) is None:
+ data = cached
+ # A cache entry written before this guard existed, or poisoned by a past
+ # rate-limit/error page, is discarded here and re-fetched below rather than
+ # trusted forever.
+ if not data:
+ fetched = await self.fetch_json(url)
+ problem = self._payload_problem(fetched)
+ if problem is None:
+ data = fetched
+ if self.storage:
+ await self.storage.save("annual", cache_key, data, format="json")
+ else:
+ self.log("Warn: Annual: {} data for array {} is unusable ({}); abandoning the {} series for {}".format(cache_tag, index, problem, cache_tag, year))
+ return {}
+
+ hourly = data["hourly"]
+ periods = gti_hourly_to_period_kwh(
+ hourly["time"],
+ hourly["global_tilted_irradiance"],
+ hourly.get("temperature_2m", []),
+ hourly.get("wind_speed_10m", []),
+ kwp=array.get("kwp", 3.0),
+ system_loss=1.0 - array.get("efficiency", 0.95),
+ shading_factors=array.get("shading_factors", None),
+ )
+ for stamp, values in periods.items():
+ totals[stamp] = totals.get(stamp, 0.0) + values["pv_estimate"]
+
+ return totals
+
+ def _derive_p10_ratios(self, actual_periods, forecast_periods, forecast_available):
+ """Derive each month's P10 ratio from the measured actual/forecast daily energy error."""
+ ratios = {}
+ fallback_months = set()
+
+ actual_daily = WeatherYear._daily_totals(actual_periods)
+ forecast_daily = WeatherYear._daily_totals(forecast_periods)
+
+ by_month = {}
+ if forecast_available:
+ for day, forecast_kwh in forecast_daily.items():
+ if forecast_kwh <= 0:
+ continue
+ if day not in actual_daily:
+ continue
+ by_month.setdefault(day.month, []).append(actual_daily[day] / forecast_kwh)
+
+ for month in range(1, 13):
+ samples = by_month.get(month, [])
+ if len(samples) >= MIN_DAYS_FOR_P10:
+ ratios[month] = min(1.0, percentile(samples, P10_FRACTION))
+ else:
+ ratios[month] = self.p10_fallback
+ fallback_months.add(month)
+
+ if fallback_months:
+ self.log("Warn: Annual: P10 fell back to the flat {} derate for months {}".format(self.p10_fallback, sorted(fallback_months)))
+
+ return ratios, fallback_months
+
+ async def fetch(self, year):
+ """Download and convert a calendar year, returning a populated WeatherYear."""
+ actual_periods = await self._fetch_series(ARCHIVE_URL, year, "actual")
+ forecast_periods = await self._fetch_series(FORECAST_ARCHIVE_URL, year, "forecast")
+ forecast_available = bool(forecast_periods)
+
+ if not forecast_available:
+ self.log("Warn: Annual: the Open-Meteo forecast archive returned nothing for {}; planning on actuals with the flat P10 derate".format(year))
+
+ ratios, fallback_months = self._derive_p10_ratios(actual_periods, forecast_periods, forecast_available)
+ return WeatherYear(actual_periods, forecast_periods, ratios, forecast_available, fallback_months)
+
+
+POSTCODE_URL = "https://api.postcodes.io/postcodes/{}"
+
+
+async def resolve_postcode(postcode, fetch_json, log):
+ """Resolve a UK postcode to (latitude, longitude), or None when it cannot be resolved."""
+ data = await fetch_json(POSTCODE_URL.format(postcode))
+ result = (data or {}).get("result", {}) if isinstance(data, dict) else {}
+ if "latitude" in result and "longitude" in result:
+ log("Annual: postcode {} resolved to latitude {} longitude {}".format(postcode, result["latitude"], result["longitude"]))
+ return result["latitude"], result["longitude"]
+ log("Warn: Annual: postcode {} could not be resolved".format(postcode))
+ return None
diff --git a/apps/predbat/solar_model.py b/apps/predbat/solar_model.py
new file mode 100644
index 000000000..9b39508af
--- /dev/null
+++ b/apps/predbat/solar_model.py
@@ -0,0 +1,134 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Shared photovoltaic conversion model.
+
+Converts Open-Meteo global tilted irradiance (GTI) into PV energy, applying a
+SAPM/PVWatts cell-temperature derate and integrating each hourly sample pair.
+Shared by the live Solcast/Open-Meteo forecast path and the annual prediction
+tool so the two cannot drift apart.
+"""
+
+import math
+from datetime import datetime, timedelta
+
+import pytz
+
+from utils import dp4
+
+# PVWatts / SAPM cell temperature model constants (glass/glass, open rack)
+# Equivalent to pvlib.temperature.sapm_cell with open_rack_glass_glass parameters
+_SAPM_A = -3.47
+_SAPM_B = -0.0594
+_SAPM_DELTA_T = 3.0
+
+# c-Si temperature coefficient: -0.4%/degC relative to STC (25degC)
+_TEMP_COEFF = 0.004
+_STC_TEMP_C = 25.0
+
+# Defaults used when a sample has no measured value
+_DEFAULT_TEMP_C = 25.0
+_DEFAULT_WIND_MS = 1.0
+
+# Applied when no ensemble P10 data is available
+_DEFAULT_P10_FALLBACK = 0.7
+
+
+def pvwatts_cell_temperature(poa_global, temp_air, wind_speed):
+ """Compute PV cell temperature using the SAPM (PVWatts) model.
+
+ Parameters correspond to a glass/glass module on an open rack (the most
+ common residential case). Formula: T_cell = T_air + GTI*exp(a + b*wind) + (GTI/1000)*deltaT
+ """
+ return temp_air + poa_global * math.exp(_SAPM_A + _SAPM_B * wind_speed) + (poa_global / 1000.0) * _SAPM_DELTA_T
+
+
+def convert_azimuth(az):
+ """
+ Convert azimuth from Predbat/Solcast convention to Forecast.solar/Open-Meteo convention.
+ Predbat/Solcast convention: 0 = North, -90 = East, 90 = West, 180 = South
+ Forecast.solar/Open-Meteo convention: 0 = South, -90 = East, 90 = West, +/-180 = North
+ """
+ if az >= 0:
+ az = 180 - az
+ else:
+ az = -180 - az
+
+ return az
+
+
+def _temperature_efficiency(gti, temp, wind):
+ """Return the cell-temperature efficiency multiplier for one irradiance sample."""
+ t_cell = pvwatts_cell_temperature(gti, temp, wind)
+ # No lower clamp on (t_cell - 25): cool cells genuinely produce more power.
+ # Cap at 1.1 (10% above STC) to prevent unrealistic gains at very cold temperatures.
+ return max(0.5, min(1.1, 1.0 - _TEMP_COEFF * (t_cell - _STC_TEMP_C)))
+
+
+def gti_hourly_to_period_kwh(times, gti_values, temp_values, wind_values, kwp, system_loss, shading_factors=None, p10_instant=None, p10_fallback=_DEFAULT_P10_FALLBACK):
+ """Convert hourly GTI samples into per-hour PV energy for a single array.
+
+ Open-Meteo returns point-in-time irradiance (W/m2) at the start of each hour, so the
+ samples are integrated trapezoidally across each adjacent pair rather than treated as
+ period energy.
+
+ Args:
+ times: list of ISO timestamp strings, "%Y-%m-%dT%H:%M", assumed UTC
+ gti_values: list of global tilted irradiance values in W/m2, aligned to times
+ temp_values: list of air temperatures in degC, aligned to times
+ wind_values: list of wind speeds in m/s, aligned to times
+ kwp: array peak power in kW
+ system_loss: fractional system loss, e.g. 0.05 for 95% efficiency
+ shading_factors: optional list of 12 per-month multipliers
+ p10_instant: optional dict of timestamp string to raw P10 kW, before temperature derate
+ p10_fallback: multiplier applied to P50 when p10_instant has no entry
+
+ Returns:
+ dict of tz-aware UTC hour-start datetime to {"pv_estimate": kWh, "pv_estimate10": kWh}
+ """
+ instant_kw = {}
+ instant_stamps = []
+
+ for idx, ts in enumerate(times):
+ if idx >= len(gti_values):
+ break
+ gti = gti_values[idx]
+ if gti is None:
+ gti = 0.0
+ temp = temp_values[idx] if idx < len(temp_values) and temp_values[idx] is not None else _DEFAULT_TEMP_C
+ wind = wind_values[idx] if idx < len(wind_values) and wind_values[idx] is not None else _DEFAULT_WIND_MS
+ eta_temp = _temperature_efficiency(gti, temp, wind)
+ pv50_inst = dp4((gti / 1000.0) * kwp * eta_temp * (1.0 - system_loss))
+ raw_p10 = p10_instant.get(ts) if p10_instant else None
+ # p10_instant was computed without temperature derating; apply eta_temp now
+ pv10_inst = dp4(min(raw_p10 * eta_temp, pv50_inst) if raw_p10 is not None else pv50_inst * p10_fallback)
+ try:
+ stamp = datetime.strptime(ts, "%Y-%m-%dT%H:%M")
+ stamp = stamp.replace(tzinfo=pytz.utc)
+ except (ValueError, TypeError):
+ continue
+ instant_kw[stamp] = (pv50_inst, pv10_inst)
+ instant_stamps.append(stamp)
+
+ period_data = {}
+ for i in range(len(instant_stamps) - 1):
+ stamp = instant_stamps[i]
+ next_stamp = instant_stamps[i + 1]
+ if (next_stamp - stamp) != timedelta(hours=1):
+ continue
+ pv50_start, pv10_start = instant_kw[stamp]
+ pv50_end, pv10_end = instant_kw[next_stamp]
+ pv50 = dp4(0.5 * (pv50_start + pv50_end))
+ pv10 = dp4(0.5 * (pv10_start + pv10_end))
+
+ if shading_factors and len(shading_factors) == 12:
+ shading_month = shading_factors[stamp.month - 1]
+ pv50 = dp4(pv50 * shading_month)
+ pv10 = dp4(pv10 * shading_month)
+
+ period_data[stamp] = {"pv_estimate": pv50, "pv_estimate10": pv10}
+
+ return period_data
diff --git a/apps/predbat/solcast.py b/apps/predbat/solcast.py
index 8d8327976..cae9233f0 100644
--- a/apps/predbat/solcast.py
+++ b/apps/predbat/solcast.py
@@ -23,26 +23,11 @@
import pytz
from datetime import datetime, timedelta, timezone
-# PVWatts / SAPM cell temperature model constants (glass/glass, open rack)
-# Equivalent to pvlib.temperature.sapm_cell with open_rack_glass_glass parameters
-_SAPM_A = -3.47
-_SAPM_B = -0.0594
-_SAPM_DELTA_T = 3.0
-
-
-def pvwatts_cell_temperature(poa_global, temp_air, wind_speed):
- """Compute PV cell temperature using the SAPM (PVWatts) model.
-
- Parameters correspond to a glass/glass module on an open rack (the most
- common residential case). Formula: T_cell = T_air + GTI*exp(a + b*wind) + (GTI/1000)*deltaT
- """
- return temp_air + poa_global * math.exp(_SAPM_A + _SAPM_B * wind_speed) + (poa_global / 1000.0) * _SAPM_DELTA_T
-
-
from const import TIME_FORMAT, TIME_FORMAT_SOLCAST
from utils import dp2, dp4, history_attribute_to_minute_data, minute_data, history_attribute, prune_today
from predbat_metrics import record_api_call, metrics
from component_base import ComponentBase
+from solar_model import convert_azimuth, gti_hourly_to_period_kwh, pvwatts_cell_temperature # noqa: F401 - re-exported for tests/test_open_meteo.py parity checks
"""
Solcast class deals with fetching solar predictions, processing the data and publishing the results.
@@ -240,19 +225,6 @@ async def cache_get_url(self, url, params, max_age=8 * 60):
URL_PERSONAL = "https://api.forecast.solar/{api_key}/estimate/{lat}/{lon}/{dec}/{az}/{kwp}?time=utc"
URL_PERSONAL_DUAL = "https://api.forecast.solar/{api_key}/estimate/{lat}/{lon}/{dec1}/{az1}/{kwp1}/{dec2}/{az2}/{kwp2}?time=utc"
- def convert_azimuth(self, az):
- """
- Convert azimuth from Predbat/Solcast convention to Forecast.solar/Open-Meteo convention.
- Predbat/Solcast convention: 0 = North, -90 = East, 90 = West, 180 = South
- Forecast.solar/Open-Meteo convention: 0 = South, -90 = East, 90 = West, ±180 = North
- """
- if az >= 0:
- az = 180 - az
- else:
- az = -180 - az
-
- return az
-
async def download_open_meteo_ensemble_data(self, lat, lon, tilt, az, kwp, system_loss):
"""
Download Open-Meteo ensemble data for P10 solar estimate.
@@ -309,7 +281,7 @@ async def download_open_meteo_data(self, configs=None):
tilt = config.get("declination", 35.0)
az = config.get("azimuth", 180.0)
if not config.get("azimuth_zero_south", False):
- az = self.convert_azimuth(az)
+ az = convert_azimuth(az)
kwp = config.get("kwp", 3.0)
system_loss = 1.0 - config.get("efficiency", 0.95)
shading_factors = config.get("shading_factors", None)
@@ -350,62 +322,24 @@ async def download_open_meteo_data(self, configs=None):
ensemble_p10 = await self.download_open_meteo_ensemble_data(lat, lon, tilt, az, kwp, system_loss)
- # Pass 1: compute instantaneous kW at each UTC timestamp sample.
- # Open-Meteo returns point-in-time irradiance (W/m²) at the start of each hour,
- # so we must integrate over the period rather than treating the sample as the period energy.
- instant_kw = {} # datetime stamp -> (pv50_kw, pv10_kw)
- instant_stamps = []
- for idx, ts in enumerate(times):
- if idx >= len(gti_values):
- break
- gti = gti_values[idx]
- if gti is None:
- gti = 0.0
- temp = temp_values[idx] if idx < len(temp_values) and temp_values[idx] is not None else 25.0
- wind = wind_values[idx] if idx < len(wind_values) and wind_values[idx] is not None else 1.0
- # Cell temperature via SAPM/PVWatts model: irradiance heats the cell above ambient
- t_cell = pvwatts_cell_temperature(gti, temp, wind)
- # c-Si temperature coefficient: -0.4%/°C relative to STC (25°C)
- # No lower clamp on (t_cell - 25): cool cells genuinely produce more power.
- # Cap at 1.1 (10% above STC) to prevent unrealistic gains at very cold temperatures.
- eta_temp = max(0.5, min(1.1, 1.0 - 0.004 * (t_cell - 25.0)))
- pv50_inst = dp4((gti / 1000.0) * kwp * eta_temp * (1.0 - system_loss))
- raw_p10 = ensemble_p10.get(ts)
- # ensemble_p10 was computed without temperature derating; apply eta_temp now
- pv10_inst = dp4(min(raw_p10 * eta_temp, pv50_inst) if raw_p10 is not None else pv50_inst * 0.7)
- try:
- stamp = datetime.strptime(ts, "%Y-%m-%dT%H:%M")
- stamp = stamp.replace(tzinfo=pytz.utc)
- except (ValueError, TypeError):
- continue
- instant_kw[stamp] = (pv50_inst, pv10_inst)
- instant_stamps.append(stamp)
-
- # Pass 2: trapezoidal integration — energy over [T, T+1h] = 0.5*(kW_at_T + kW_at_T+1h).
- # This correctly accounts for sunrise/sunset transitions where irradiance changes rapidly
- # within the hour, e.g. the first post-sunrise hour contains only partial sunshine.
- for i in range(len(instant_stamps) - 1):
- stamp = instant_stamps[i]
- next_stamp = instant_stamps[i + 1]
- if (next_stamp - stamp) != timedelta(hours=1):
- continue
- pv50_start, pv10_start = instant_kw[stamp]
- pv50_end, pv10_end = instant_kw[next_stamp]
- pv50 = dp4(0.5 * (pv50_start + pv50_end))
- pv10 = dp4(0.5 * (pv10_start + pv10_end))
-
- # Apply per-month site shading correction from Google Solar API if available
- if shading_factors and len(shading_factors) == 12:
- shading_month = shading_factors[stamp.month - 1]
- pv50 = dp4(pv50 * shading_month)
- pv10 = dp4(pv10 * shading_month)
-
- data_item = {"period_start": stamp.strftime(TIME_FORMAT), "pv_estimate": pv50, "pv_estimate10": pv10}
+ array_periods = gti_hourly_to_period_kwh(
+ times,
+ gti_values,
+ temp_values,
+ wind_values,
+ kwp=kwp,
+ system_loss=system_loss,
+ shading_factors=shading_factors,
+ p10_instant=ensemble_p10,
+ )
+ for stamp, values in array_periods.items():
+ pv50 = values["pv_estimate"]
+ pv10 = values["pv_estimate10"]
if stamp in period_data:
period_data[stamp]["pv_estimate"] = dp4(period_data[stamp]["pv_estimate"] + pv50)
period_data[stamp]["pv_estimate10"] = dp4(period_data[stamp]["pv_estimate10"] + pv10)
else:
- period_data[stamp] = data_item
+ period_data[stamp] = {"period_start": stamp.strftime(TIME_FORMAT), "pv_estimate": pv50, "pv_estimate10": pv10}
sorted_data = []
if period_data:
@@ -452,7 +386,7 @@ async def download_forecast_solar_data(self):
dec = config.get("declination", 35.0)
az = config.get("azimuth", 180.0)
if not config.get("azimuth_zero_south", False):
- az = self.convert_azimuth(az)
+ az = convert_azimuth(az)
kwp = config.get("kwp", 3.0)
efficiency = config.get("efficiency", 0.95)
api_key = config.get("api_key", None)
diff --git a/apps/predbat/tariff_catalogue.py b/apps/predbat/tariff_catalogue.py
new file mode 100644
index 000000000..d111c467d
--- /dev/null
+++ b/apps/predbat/tariff_catalogue.py
@@ -0,0 +1,201 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""Curated tariff catalogue for the Annual prediction tab's dropdown.
+
+The entries mirror the commented-out ``compare_list`` template in
+``config/apps.yaml`` so a user does not have to hand-copy a YAML block to get a
+realistic tariff. Compare's key names differ from the annual engine's, so the
+mapping lives here and nowhere else.
+"""
+
+# Compare writes rates_import_octopus_url; the annual engine reads import_octopus_url
+COMPARE_KEY_MAP = {
+ "rates_import_octopus_url": "import_octopus_url",
+ "rates_export_octopus_url": "export_octopus_url",
+}
+
+# Keys that mean the same thing in both and need no translation
+PASSTHROUGH_KEYS = ["rates_import", "rates_export"]
+
+# The dropdown's escape hatch: leaves the URL fields blank for a hand-entered tariff
+CUSTOM_ID = "custom"
+
+_OCTOPUS = "https://api.octopus.energy/v1/products"
+
+# The two Octopus flat-rate export products offered against each import tariff below.
+# "Fixed" (OUTGOING-VAR) pays one rate around the clock. "Prime" is flat only in the sense
+# that its rates are fixed for 12 months: it pays a materially higher rate over the evening
+# peak (16:00-19:00 local), so it rewards holding charge for the evening rather than
+# exporting whenever there is surplus. That makes it a genuinely different optimisation
+# target, which is why it is offered alongside Fixed rather than replacing it.
+#
+# Prime launched in June 2026, so it has no rates for the historical year the annual tool
+# replays. That is handled by AnnualTariff's current-rates fallback (annual_tariff.py) -
+# without it, an empty export download is treated as "export unpaid" and the whole year
+# silently prices export at zero. Do not add a new export product here without checking
+# that its rates actually exist for the modelled year, or that the fallback covers it.
+_OUTGOING_FIXED = "{}/OUTGOING-VAR-24-10-26/electricity-tariffs/E-1R-OUTGOING-VAR-24-10-26-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS)
+_OUTGOING_PRIME = "{}/OUTGOING-PRIME-FIX-12M-26-06-23/electricity-tariffs/E-1R-OUTGOING-PRIME-FIX-12M-26-06-23-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS)
+
+BUILTIN_TARIFFS = [
+ {"id": "cap_seg", "name": "Price cap import / SEG export", "rates_import": [{"rate": 24.86}], "rates_export": [{"rate": 4.1}]},
+ {
+ "id": "eon_next_drive",
+ "name": "Eon Next Drive import / Fixed export",
+ "rates_import": [{"rate": 6.7, "start": "00:00:00", "end": "07:00:00"}, {"rate": 24.86, "start": "07:00:00", "end": "00:00:00"}],
+ "rates_export": [{"rate": 16.5}],
+ },
+ {
+ "id": "igo_fixed",
+ "name": "Intelligent GO import / Fixed export",
+ "import_octopus_url": "{}/INTELLI-VAR-24-10-29/electricity-tariffs/E-1R-INTELLI-VAR-24-10-29-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_FIXED,
+ },
+ {
+ "id": "igo_prime",
+ "name": "Intelligent GO import / Prime export",
+ "import_octopus_url": "{}/INTELLI-VAR-24-10-29/electricity-tariffs/E-1R-INTELLI-VAR-24-10-29-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_PRIME,
+ },
+ {
+ "id": "igo_agile",
+ "name": "Intelligent GO import / Agile export",
+ "import_octopus_url": "{}/INTELLI-VAR-24-10-29/electricity-tariffs/E-1R-INTELLI-VAR-24-10-29-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": "{}/AGILE-OUTGOING-19-05-13/electricity-tariffs/E-1R-AGILE-OUTGOING-19-05-13-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ },
+ {
+ "id": "go_fixed",
+ "name": "GO import / Fixed export",
+ "import_octopus_url": "{}/GO-VAR-22-10-14/electricity-tariffs/E-1R-GO-VAR-22-10-14-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_FIXED,
+ },
+ {
+ "id": "go_prime",
+ "name": "GO import / Prime export",
+ "import_octopus_url": "{}/GO-VAR-22-10-14/electricity-tariffs/E-1R-GO-VAR-22-10-14-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_PRIME,
+ },
+ {
+ "id": "go_agile",
+ "name": "GO import / Agile export",
+ "import_octopus_url": "{}/GO-VAR-22-10-14/electricity-tariffs/E-1R-GO-VAR-22-10-14-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": "{}/AGILE-OUTGOING-19-05-13/electricity-tariffs/E-1R-AGILE-OUTGOING-19-05-13-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ },
+ {
+ "id": "agile_fixed",
+ "name": "Agile import / Fixed export",
+ "import_octopus_url": "{}/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_FIXED,
+ },
+ {
+ "id": "agile_prime",
+ "name": "Agile import / Prime export",
+ "import_octopus_url": "{}/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_PRIME,
+ },
+ {
+ "id": "agile_agile",
+ "name": "Agile import / Agile export",
+ "import_octopus_url": "{}/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": "{}/AGILE-OUTGOING-19-05-13/electricity-tariffs/E-1R-AGILE-OUTGOING-19-05-13-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ },
+ {
+ "id": "flux",
+ "name": "Flux import / Flux export",
+ "import_octopus_url": "{}/FLUX-IMPORT-23-02-14/electricity-tariffs/E-1R-FLUX-IMPORT-23-02-14-{{dno_region}}/standard-unit-rates".format(_OCTOPUS),
+ "export_octopus_url": "{}/FLUX-EXPORT-23-02-14/electricity-tariffs/E-1R-FLUX-EXPORT-23-02-14-{{dno_region}}/standard-unit-rates".format(_OCTOPUS),
+ },
+ {
+ "id": "cosy_fixed",
+ "name": "Cosy import / Fixed export",
+ "import_octopus_url": "{}/COSY-22-12-08/electricity-tariffs/E-1R-COSY-22-12-08-{{dno_region}}/standard-unit-rates".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_FIXED,
+ },
+ {
+ "id": "cosy_prime",
+ "name": "Cosy import / Prime export",
+ "import_octopus_url": "{}/COSY-22-12-08/electricity-tariffs/E-1R-COSY-22-12-08-{{dno_region}}/standard-unit-rates".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_PRIME,
+ },
+ {
+ "id": "cosy_agile",
+ "name": "Cosy import / Agile export",
+ "import_octopus_url": "{}/COSY-22-12-08/electricity-tariffs/E-1R-COSY-22-12-08-{{dno_region}}/standard-unit-rates".format(_OCTOPUS),
+ "export_octopus_url": "{}/AGILE-OUTGOING-19-05-13/electricity-tariffs/E-1R-AGILE-OUTGOING-19-05-13-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ },
+ {
+ "id": "snug_fixed",
+ "name": "Snug import / Fixed export",
+ "import_octopus_url": "{}/SNUG-24-11-07/electricity-tariffs/E-1R-SNUG-24-11-07-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_FIXED,
+ },
+ {
+ "id": "snug_prime",
+ "name": "Snug import / Prime export",
+ "import_octopus_url": "{}/SNUG-24-11-07/electricity-tariffs/E-1R-SNUG-24-11-07-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": _OUTGOING_PRIME,
+ },
+ {
+ "id": "iflux",
+ "name": "Intelligent Flux import / export",
+ # There is no INTELLI-FLUX-EXPORT product - Octopus publishes both the import and
+ # export rates for Intelligent Flux under the import product code below. Do not
+ # "fix" this to a distinct export code; that product does not exist and 404s.
+ "import_octopus_url": "{}/INTELLI-FLUX-IMPORT-23-07-14/electricity-tariffs/E-1R-INTELLI-FLUX-IMPORT-23-07-14-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ "export_octopus_url": "{}/INTELLI-FLUX-IMPORT-23-07-14/electricity-tariffs/E-1R-INTELLI-FLUX-IMPORT-23-07-14-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS),
+ },
+]
+
+
+def convert_compare_entry(entry):
+ """Convert one Compare ``compare_list`` entry into the annual engine's shape.
+
+ Returns None when the entry cannot be used - it is not a mapping, it has no
+ id, or it carries no rate source at all. Compare allows an entry with neither
+ (the 'current' pseudo-tariff, which means "whatever is configured"), and that
+ has no meaning here, so it is dropped rather than offered as a broken choice.
+ """
+ if not isinstance(entry, dict):
+ return None
+ if not entry.get("id") or not entry.get("name"):
+ return None
+
+ converted = {"id": entry["id"], "name": entry["name"]}
+ for source_key, target_key in COMPARE_KEY_MAP.items():
+ if entry.get(source_key):
+ converted[target_key] = entry[source_key]
+ for key in PASSTHROUGH_KEYS:
+ if entry.get(key):
+ converted[key] = entry[key]
+
+ if not converted.get("import_octopus_url") and not converted.get("rates_import"):
+ return None
+ return converted
+
+
+def merged_catalogue(compare_list=None):
+ """Return the dropdown's entries: built-ins, then the user's own, then Custom.
+
+ A user entry sharing a built-in id replaces it rather than appearing twice -
+ the user's own definition is the more specific one. Malformed entries are
+ skipped so one bad line in apps.yaml cannot empty the dropdown.
+ """
+ catalogue = [dict(entry) for entry in BUILTIN_TARIFFS]
+ by_id = {entry["id"]: index for index, entry in enumerate(catalogue)}
+
+ for entry in compare_list or []:
+ converted = convert_compare_entry(entry)
+ if converted is None:
+ continue
+ if converted["id"] in by_id:
+ catalogue[by_id[converted["id"]]] = converted
+ else:
+ by_id[converted["id"]] = len(catalogue)
+ catalogue.append(converted)
+
+ catalogue.append({"id": CUSTOM_ID, "name": "Custom - enter URLs below"})
+ return catalogue
diff --git a/apps/predbat/tests/annual_stub.py b/apps/predbat/tests/annual_stub.py
new file mode 100644
index 000000000..5b06583cc
--- /dev/null
+++ b/apps/predbat/tests/annual_stub.py
@@ -0,0 +1,88 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+
+"""A stand-in for annual_cli.py --machine, used to drive AnnualJob in tests.
+
+Behaviour is chosen by argv[1] so one script covers every case the job control
+has to survive, without ever running the real three-minute engine.
+"""
+
+import json
+import sys
+import time
+
+
+def main():
+ """Emit the behaviour named by argv[1] and exit with a matching code."""
+ mode = sys.argv[1] if len(sys.argv) > 1 else "ok"
+
+ if mode == "ok":
+ for step in range(1, 4):
+ sys.stderr.write(json.dumps({"completed": step, "total": 3, "message": "step {}".format(step)}) + "\n")
+ sys.stderr.flush()
+ json.dump({"year": 2025, "months": [], "annual": {"months_included": 0}}, sys.stdout)
+ return 0
+
+ if mode == "garbage_progress":
+ sys.stderr.write("not json at all\n")
+ sys.stderr.write(json.dumps({"completed": 1, "total": 1, "message": "recovered"}) + "\n")
+ sys.stderr.flush()
+ json.dump({"year": 2025, "months": [], "annual": {"months_included": 0}}, sys.stdout)
+ return 0
+
+ if mode == "fail":
+ sys.stderr.write("something went wrong\n")
+ return 3
+
+ if mode == "bad_output":
+ sys.stdout.write("this is not json")
+ return 0
+
+ if mode == "null_output":
+ # Valid JSON, but not the object AnnualJob's results document must be.
+ json.dump(None, sys.stdout)
+ return 0
+
+ if mode == "big_streams":
+ # Push well over the default OS pipe buffer (typically 64 KiB) through
+ # both stdout and stderr, so a job control that reads one stream to
+ # completion before draining the other would deadlock: this child
+ # would block writing to the second pipe while nothing reads it.
+ # Stderr is spread across many moderate lines rather than one huge
+ # one - a single line over the StreamReader's own line-length limit
+ # would raise an unrelated error and prove nothing about deadlocking.
+ #
+ # ~1 MiB, not ~200 KiB: the sequential-blocking failure mode this test
+ # exists to catch is "read stdout to completion, THEN read stderr" (or
+ # vice versa) - which only actually blocks once the stream being written
+ # first fills its OS pipe buffer AND the child still has more to write
+ # after that. 200 KiB against a ~192 KiB buffer left only 2% headroom -
+ # any small platform difference in pipe sizing could pass a genuinely
+ # sequential implementation by accident. 1 MiB is comfortably past any
+ # realistic pipe buffer, so the test discriminates reliably instead of
+ # by luck.
+ line_filler = "x" * 500
+ written = 0
+ step = 0
+ while written < 1000000:
+ step += 1
+ payload = json.dumps({"completed": step, "total": step, "message": line_filler})
+ sys.stderr.write(payload + "\n")
+ written += len(payload) + 1
+ sys.stderr.flush()
+ filler = "x" * 1000000
+ json.dump({"year": 2025, "months": [], "annual": {"months_included": 0}, "filler": filler}, sys.stdout)
+ return 0
+
+ if mode == "hang":
+ while True:
+ time.sleep(0.1)
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/apps/predbat/tests/open_meteo_live.py b/apps/predbat/tests/open_meteo_live.py
index 3ac6ff669..16b598e4b 100644
--- a/apps/predbat/tests/open_meteo_live.py
+++ b/apps/predbat/tests/open_meteo_live.py
@@ -41,6 +41,7 @@
from const import TIME_FORMAT # noqa: E402
from solcast import SolarAPI # noqa: E402
+from solar_model import convert_azimuth # noqa: E402
from tests.test_solcast import MockBase # noqa: E402
# ── Solar array definitions ────────────────────────────────────────────────────
@@ -225,9 +226,8 @@ async def run(fs_api_key: str = None, solcast_api_key: str = None, solcast_host:
print(f"Predbat PV forecast pipeline comparison — {today_str} UTC ({tz_name})")
print()
print(f"Arrays ({len(ARRAYS)} configured):")
- _tmp = SolarAPI.__new__(SolarAPI)
for i, a in enumerate(ARRAYS, 1):
- az_api = SolarAPI.convert_azimuth(_tmp, a["azimuth"])
+ az_api = convert_azimuth(a["azimuth"])
print(f" [{i}] postcode={a['postcode']} kwp={a['kwp']} declination={a['declination']}° azimuth={a['azimuth']}° (→API: {az_api:.0f}°) efficiency={a.get('efficiency', 1.0):.0%}")
print(f" cache: {_CACHE_ROOT}/cache/")
diff --git a/apps/predbat/tests/test_annual_bootstrap.py b/apps/predbat/tests/test_annual_bootstrap.py
new file mode 100644
index 000000000..2ae22aacd
--- /dev/null
+++ b/apps/predbat/tests/test_annual_bootstrap.py
@@ -0,0 +1,321 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the annual prediction headless bootstrap and state reset."""
+
+import os
+import tempfile
+
+import yaml
+
+from annual import DEFAULT_CAR_RATE_KW, AnnualNullHA, apply_hardware, configure_offline_mode, create_headless_predbat, reset_sample_state, write_minimal_apps_yaml
+from const import MINUTE_WATT
+
+
+def test_annual_bootstrap(my_predbat):
+ """Verify the minimal apps.yaml, the null HA interface, hardware mapping and state reset."""
+ failed = False
+ print("**** Testing annual bootstrap ****")
+
+ print("Test: write_minimal_apps_yaml produces a parseable pred_bat config")
+ with tempfile.TemporaryDirectory() as work_dir:
+ path = write_minimal_apps_yaml(work_dir, "Europe/London")
+ if not os.path.exists(path):
+ print(" ERROR: apps.yaml was not written to {}".format(path))
+ failed = True
+ with open(path, "r") as handle:
+ parsed = yaml.safe_load(handle)
+ if "pred_bat" not in parsed:
+ print(" ERROR: the written apps.yaml has no pred_bat key")
+ failed = True
+ else:
+ section = parsed["pred_bat"]
+ for key in ["module", "class", "prefix", "timezone", "currency_symbols", "threads"]:
+ if key not in section:
+ print(" ERROR: the written apps.yaml is missing '{}'".format(key))
+ failed = True
+ if section.get("threads") != 0:
+ print(" ERROR: threads must be 0 so plan runs are deterministic, got {}".format(section.get("threads")))
+ failed = True
+ if section.get("timezone") != "Europe/London":
+ print(" ERROR: the timezone should be written through, got {}".format(section.get("timezone")))
+ failed = True
+
+ print("Test: AnnualNullHA satisfies the interface PredBat calls without a Home Assistant")
+ null_ha = AnnualNullHA()
+ if null_ha.get_state("sensor.anything", default=7) != 7:
+ print(" ERROR: get_state should return the supplied default")
+ failed = True
+ if null_ha.get_state(None) != {}:
+ print(" ERROR: get_state with no entity should return an empty mapping of all states")
+ failed = True
+ if null_ha.get_history("sensor.anything") is not None:
+ print(" ERROR: get_history should return None when no history exists")
+ failed = True
+ null_ha.set_state("sensor.written", "5", attributes={"unit": "kWh"})
+ if null_ha.get_state("sensor.written") != "5":
+ print(" ERROR: set_state then get_state should round trip")
+ failed = True
+ if null_ha.call_service("some/service", value=1) is not None:
+ print(" ERROR: call_service should be a no-op returning None")
+ failed = True
+
+ print("Test: apply_hardware maps the battery block onto PredBat's internal units")
+ battery = {"size_kwh": 9.5, "inverter_kw": 5.0, "export_limit_kw": 3.6, "hybrid": True, "charge_rate_kw": 3.7, "discharge_rate_kw": 4.2}
+ apply_hardware(my_predbat, battery, [{"kwp": 5.6}])
+ if abs(my_predbat.soc_max - 9.5) > 1e-9:
+ print(" ERROR: soc_max expected 9.5, got {}".format(my_predbat.soc_max))
+ failed = True
+ if abs(my_predbat.soc_kw - 9.5) > 1e-9:
+ print(" ERROR: soc_kw should be set deterministically to soc_max, got {}".format(my_predbat.soc_kw))
+ failed = True
+ if abs(my_predbat.inverter_limit - (5.0 * 1000 / MINUTE_WATT)) > 1e-9:
+ print(" ERROR: inverter_limit should be in kW per minute, got {}".format(my_predbat.inverter_limit))
+ failed = True
+ if abs(my_predbat.export_limit - (3.6 * 1000 / MINUTE_WATT)) > 1e-9:
+ print(" ERROR: export_limit should be in kW per minute, got {}".format(my_predbat.export_limit))
+ failed = True
+ if abs(my_predbat.battery_rate_max_charge - (3.7 * 1000 / MINUTE_WATT)) > 1e-9:
+ print(" ERROR: battery_rate_max_charge should be in kW per minute, got {}".format(my_predbat.battery_rate_max_charge))
+ failed = True
+ if abs(my_predbat.battery_rate_max_discharge - (4.2 * 1000 / MINUTE_WATT)) > 1e-9:
+ print(" ERROR: battery_rate_max_discharge should be in kW per minute, got {}".format(my_predbat.battery_rate_max_discharge))
+ failed = True
+ if abs(my_predbat.battery_rate_max_export - (4.2 * 1000 / MINUTE_WATT)) > 1e-9:
+ print(" ERROR: battery_rate_max_export should equal the discharge rate, got {}".format(my_predbat.battery_rate_max_export))
+ failed = True
+ if my_predbat.inverter_hybrid is not True:
+ print(" ERROR: inverter_hybrid should be True")
+ failed = True
+
+ print("Test: apply_hardware with no battery produces a zero-capacity system")
+ apply_hardware(my_predbat, None, [{"kwp": 5.6}])
+ if my_predbat.soc_max != 0.0 or my_predbat.soc_kw != 0.0:
+ print(" ERROR: a battery-less run should have soc_max and soc_kw of 0, got {} / {}".format(my_predbat.soc_max, my_predbat.soc_kw))
+ failed = True
+ if my_predbat.battery_rate_max_charge != 0.0 or my_predbat.battery_rate_max_charge_dc != 0.0 or my_predbat.battery_rate_max_discharge != 0.0 or my_predbat.battery_rate_max_export != 0.0:
+ print(" ERROR: a battery-less run should zero every rate field")
+ failed = True
+ if my_predbat.battery_rate_min != 0.0:
+ print(" ERROR: a zero-capacity battery should have battery_rate_min of 0, got {}".format(my_predbat.battery_rate_min))
+ failed = True
+ if my_predbat.export_limit != my_predbat.inverter_limit:
+ print(" ERROR: export_limit should match inverter_limit when there is no battery")
+ failed = True
+ if my_predbat.inverter_hybrid is not False:
+ print(" ERROR: a battery-less run should not be a hybrid inverter")
+ failed = True
+
+ print("Test: apply_hardware with no battery sums kwp across every solar array, not just the first")
+ apply_hardware(my_predbat, None, [{"kwp": 5.6}, {"kwp": 4.0}])
+ expected_limit = (5.6 + 4.0) * 1000 / MINUTE_WATT
+ if abs(my_predbat.inverter_limit - expected_limit) > 1e-9:
+ print(" ERROR: inverter_limit should sum kwp across all arrays, expected {}, got {}".format(expected_limit, my_predbat.inverter_limit))
+ failed = True
+ if abs(my_predbat.export_limit - expected_limit) > 1e-9:
+ print(" ERROR: export_limit should track the summed inverter_limit, got {}".format(my_predbat.export_limit))
+ failed = True
+
+ print("Test: reset_sample_state clears the leaked fields but leaves apply_hardware's output alone")
+ apply_hardware(my_predbat, battery, [{"kwp": 5.6}])
+ configured_battery_rate_max_export = my_predbat.battery_rate_max_export
+ configured_inverter_limit = my_predbat.inverter_limit
+
+ my_predbat.dynamic_load_baseline = {5: 1.0}
+ my_predbat.soc_kw = 3.3
+ my_predbat.manual_charge_times = [1, 2, 3]
+ my_predbat.manual_export_times = [4]
+ my_predbat.manual_all_times = [5]
+ my_predbat.cost_today_sofar = 123.0
+ my_predbat.import_today_now = 4.0
+ my_predbat.export_today_now = 5.0
+ my_predbat.iboost_today = 6.0
+ my_predbat.carbon_today_sofar = 7.0
+ my_predbat.load_minutes_now = 8.0
+ my_predbat.pv_today_now = 9.0
+ my_predbat.charge_limit_best = [1.0]
+ my_predbat.charge_window_best = [{"start": 0, "end": 30}]
+ my_predbat.export_window_best = [{"start": 0, "end": 30}]
+ my_predbat.export_limits_best = [50.0]
+ my_predbat.plan_valid = True
+ my_predbat.low_rates = [{"start": 0, "end": 30}]
+ my_predbat.high_export_rates = [{"start": 0, "end": 30}]
+ my_predbat.rate_import = {0: 25.0}
+ my_predbat.rate_export = {0: 15.0}
+ my_predbat.rate_import_replicated = {0: 25.0}
+ my_predbat.rate_export_replicated = {0: 15.0}
+ my_predbat.rate_import_cost_threshold = 1.0
+ my_predbat.rate_export_cost_threshold = 2.0
+ my_predbat.rate_min = 5.0
+ my_predbat.rate_max = 6.0
+ my_predbat.rate_average = 7.0
+
+ # Scenario 3's smart-car overrides (_run_scenarios() in annual.py), leaked here the way a
+ # previous sample's with-car leg would leave them.
+ my_predbat.car_charging_planned = [True]
+ my_predbat.car_charging_limit = [20.0]
+ my_predbat.car_charging_soc = [10.0]
+ my_predbat.car_charging_rate = [22.0]
+ my_predbat.car_charging_battery_size = [100.0]
+ my_predbat.car_charging_plan_smart = [True]
+ my_predbat.car_charging_from_battery = True
+
+ reset_sample_state(my_predbat)
+
+ checks = [
+ ("dynamic_load_baseline", {}),
+ ("soc_kw", 0.0),
+ ("manual_charge_times", []),
+ ("manual_export_times", []),
+ ("manual_all_times", []),
+ ("cost_today_sofar", 0),
+ ("import_today_now", 0),
+ ("export_today_now", 0),
+ ("iboost_today", 0),
+ ("carbon_today_sofar", 0),
+ ("load_minutes_now", 0),
+ ("pv_today_now", 0),
+ ("charge_limit_best", []),
+ ("charge_window_best", []),
+ ("export_window_best", []),
+ ("export_limits_best", []),
+ ("plan_valid", False),
+ ("low_rates", []),
+ ("high_export_rates", []),
+ ("rate_import", {}),
+ ("rate_export", {}),
+ ("rate_import_replicated", {}),
+ ("rate_export_replicated", {}),
+ ("rate_import_cost_threshold", 99),
+ ("rate_export_cost_threshold", 99),
+ ("rate_min", 0),
+ ("rate_max", 0),
+ ("rate_average", 0),
+ ("car_charging_planned", [False]),
+ ("car_charging_limit", [0.0]),
+ ("car_charging_soc", [0.0]),
+ ("car_charging_rate", [DEFAULT_CAR_RATE_KW]),
+ ("car_charging_battery_size", [50.0]),
+ ("car_charging_plan_smart", [False]),
+ ("car_charging_from_battery", False),
+ ]
+ for name, expected in checks:
+ actual = getattr(my_predbat, name)
+ if actual != expected:
+ print(" ERROR: reset_sample_state left {} as {}, expected {}".format(name, actual, expected))
+ failed = True
+
+ print("Test: reset_sample_state must not clobber the hardware-derived fields apply_hardware owns")
+ if my_predbat.battery_rate_max_export != configured_battery_rate_max_export:
+ print(" ERROR: reset_sample_state changed battery_rate_max_export from {} to {}; it must be apply_hardware's alone".format(configured_battery_rate_max_export, my_predbat.battery_rate_max_export))
+ failed = True
+ if my_predbat.inverter_limit != configured_inverter_limit:
+ print(" ERROR: reset_sample_state changed inverter_limit from {} to {}; it must be apply_hardware's alone".format(configured_inverter_limit, my_predbat.inverter_limit))
+ failed = True
+
+ print("Test: configure_offline_mode applies the deliberate offline choices, separately from reset_sample_state")
+ my_predbat.octopus_intelligent_charging = True
+ my_predbat.load_forecast_only = False
+ my_predbat.load_scaling = 0.5
+ my_predbat.load_scaling10 = 0.5
+ my_predbat.iboost_enable = True
+ my_predbat.carbon_enable = True
+ my_predbat.plan_debug = True
+ my_predbat.debug_enable = True
+ my_predbat.calculate_best_charge = False
+ my_predbat.calculate_best_export = False
+ my_predbat.set_charge_window = False
+ my_predbat.set_export_window = False
+
+ configure_offline_mode(my_predbat)
+
+ offline_checks = [
+ ("octopus_intelligent_charging", False),
+ ("load_forecast_only", True),
+ ("load_scaling", 1.0),
+ ("load_scaling10", 1.0),
+ ("iboost_enable", False),
+ ("carbon_enable", False),
+ ("plan_debug", False),
+ ("debug_enable", False),
+ # Without these, calculate_plan()'s charge/export-window branches never fire (they are
+ # gated on calculate_best_charge/set_charge_window and the export equivalent), and the
+ # "with Predbat" scenario would silently plan no charging or exporting at all.
+ ("calculate_best_charge", True),
+ ("calculate_best_export", True),
+ ("set_charge_window", True),
+ ("set_export_window", True),
+ ]
+ for name, expected in offline_checks:
+ actual = getattr(my_predbat, name)
+ if actual != expected:
+ print(" ERROR: configure_offline_mode left {} as {}, expected {}".format(name, actual, expected))
+ failed = True
+
+ print("Test: reset_sample_state must not re-apply configure_offline_mode's choices")
+ # my_predbat is the shared suite fixture, so anything set here outlives this test.
+ # debug_enable in particular must be put back: kernel_supported() in
+ # prediction_kernel.py requires `not pred.debug_enable`, so leaving it True makes every
+ # later prediction in the whole suite bypass the C++ kernel and fall back to the Python
+ # engine - roughly 8x slower per plan, which looks like a hang in the slow tests rather
+ # than a leak from here.
+ previous_octopus_intelligent = my_predbat.octopus_intelligent_charging
+ previous_debug_enable = my_predbat.debug_enable
+ try:
+ my_predbat.octopus_intelligent_charging = True
+ my_predbat.debug_enable = True
+ reset_sample_state(my_predbat)
+ if my_predbat.octopus_intelligent_charging is not True:
+ print(" ERROR: reset_sample_state should not touch octopus_intelligent_charging any more")
+ failed = True
+ if my_predbat.debug_enable is not True:
+ print(" ERROR: reset_sample_state should not touch debug_enable any more")
+ failed = True
+ finally:
+ my_predbat.octopus_intelligent_charging = previous_octopus_intelligent
+ my_predbat.debug_enable = previous_debug_enable
+
+ print("Test: create_headless_predbat leaves the planner switched on, not the Monitor-mode default")
+ with tempfile.TemporaryDirectory() as work_dir:
+ headless = create_headless_predbat(work_dir, "Europe/London", print)
+ planner_checks = [
+ ("calculate_best_charge", True),
+ ("calculate_best_export", True),
+ ("set_charge_window", True),
+ ("set_export_window", True),
+ ]
+ for name, expected in planner_checks:
+ actual = getattr(headless, name)
+ if actual != expected:
+ print(" ERROR: create_headless_predbat left {} as {}, expected {} (the headless apps.yaml has no 'mode' key, so fetch_config_options() defaults to Monitor mode, which sets all four False)".format(name, actual, expected))
+ failed = True
+
+ print("Test: create_headless_predbat restores PREDBAT_APPS_FILE afterwards, even if it was previously unset")
+ saved_env = os.environ.get("PREDBAT_APPS_FILE")
+ try:
+ os.environ["PREDBAT_APPS_FILE"] = "/tmp/should-not-leak-annual-bootstrap-test.yaml"
+ with tempfile.TemporaryDirectory() as work_dir:
+ create_headless_predbat(work_dir, "Europe/London", print)
+ if os.environ.get("PREDBAT_APPS_FILE") != "/tmp/should-not-leak-annual-bootstrap-test.yaml":
+ print(" ERROR: PREDBAT_APPS_FILE was not restored to its prior value, got {}".format(os.environ.get("PREDBAT_APPS_FILE")))
+ failed = True
+
+ os.environ.pop("PREDBAT_APPS_FILE", None)
+ with tempfile.TemporaryDirectory() as work_dir:
+ create_headless_predbat(work_dir, "Europe/London", print)
+ if "PREDBAT_APPS_FILE" in os.environ:
+ print(" ERROR: PREDBAT_APPS_FILE should be unset again, got {}".format(os.environ.get("PREDBAT_APPS_FILE")))
+ failed = True
+ finally:
+ if saved_env is None:
+ os.environ.pop("PREDBAT_APPS_FILE", None)
+ else:
+ os.environ["PREDBAT_APPS_FILE"] = saved_env
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_cli.py b/apps/predbat/tests/test_annual_cli.py
new file mode 100644
index 000000000..450814c80
--- /dev/null
+++ b/apps/predbat/tests/test_annual_cli.py
@@ -0,0 +1,376 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the annual prediction command line output."""
+
+import io
+import os
+import tempfile
+from contextlib import redirect_stderr, redirect_stdout
+
+import annual_cli
+from annual_cli import format_table, make_progress
+
+
+class _StubPredictor:
+ """Stands in for AnnualPredictor so main()'s argument wiring can be tested in isolation.
+
+ Records the ``log`` callable it was constructed with rather than doing any real work,
+ so a test can assert what main() actually passes through under --quiet without needing
+ a real config, the network, or a headless PredBat instance.
+ """
+
+ captured_log = None
+
+ def __init__(self, config, log=None, storage=None, work_dir=None):
+ """Record the log callable and discard everything else."""
+ _StubPredictor.captured_log = log
+
+ async def run(self, progress=None):
+ """Report one fake progress step (if asked) and return canned results."""
+ if progress:
+ progress(0, 1, "stub")
+ return sample_results()
+
+
+class _WarningPredictor:
+ """Stub predictor whose run() logs a warning before returning results.
+
+ Mirrors a real mid-run warning (a P10 fallback, missing rate data, a failed sample day) so a
+ test can check where that warning lands: on stderr, never on stdout, in machine mode. The
+ regression this guards against is a stray warning-turned-print inside ``main()``'s machine
+ branch, which ``test_annual_cli_machine``'s unit-level checks of ``make_progress`` alone
+ cannot see, since that warning is emitted by the engine, not by the progress callback.
+ """
+
+ def __init__(self, config, log=None, storage=None, work_dir=None):
+ """Record the log callable so run() can use it."""
+ self._log = log
+
+ async def run(self, progress=None):
+ """Emit one warning through the recorded log callable, then return canned results."""
+ if self._log:
+ self._log("Warn: stub P10 fallback warning")
+ return sample_results()
+
+
+def sample_results():
+ """Return a small results document covering an ok month and an unavailable one."""
+ scenarios = {
+ "no_pvbat": {"cost_p": 12000.0, "import_kwh": 400.0, "export_kwh": 0.0, "pv_generated_kwh": 0.0, "battery_throughput_kwh": 0.0, "export_credit_p_estimate": 0.0},
+ "pv_only": {"cost_p": 10000.0, "import_kwh": 350.0, "export_kwh": 60.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 0.0, "export_credit_p_estimate": 180.0},
+ "without_predbat": {"cost_p": 8000.0, "import_kwh": 300.0, "export_kwh": 20.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 90.0, "export_credit_p_estimate": 300.0},
+ "with_predbat": {"cost_p": 6000.0, "import_kwh": 280.0, "export_kwh": 45.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 140.0, "export_credit_p_estimate": 675.0},
+ }
+ return {
+ "year": 2025,
+ "config": {},
+ "months": [
+ {"month": 1, "status": "ok", "days": 31, "sampled_days": ["2025-01-08", "2025-01-24"], "standing_charge_p": 1860.0, "scenarios": scenarios},
+ {"month": 2, "status": "unavailable", "reason": "no rate data available", "days": 28, "standing_charge_p": 1680.0},
+ ],
+ "annual": {
+ "scenarios": scenarios,
+ "standing_charge_p": 1860.0,
+ "savings": {"pv_battery_vs_none_p": 4000.0, "predbat_vs_baseline_p": 2000.0},
+ "months_included": 1,
+ "months_excluded": [2],
+ },
+ "caveats": ["An example caveat."],
+ }
+
+
+def sample_results_no_usable_month():
+ """Return a results document where every month is unavailable and no annual total exists.
+
+ Mirrors what ``AnnualPredictor._build_results`` returns when nothing is included:
+ ``annual.scenarios`` and ``annual.standing_charge_p`` are ``None`` and ``savings`` is
+ empty. ``format_table`` must not divide those ``None`` values or print them as zeroes.
+ """
+ return {
+ "year": 2025,
+ "config": {},
+ "months": [
+ {"month": 1, "status": "unavailable", "reason": "no rate data available", "days": 31, "standing_charge_p": 1860.0},
+ {"month": 2, "status": "unavailable", "reason": "no usable weather days", "days": 28, "standing_charge_p": 1680.0},
+ ],
+ "annual": {
+ "scenarios": None,
+ "standing_charge_p": None,
+ "savings": {},
+ "months_included": 0,
+ "months_excluded": [1, 2],
+ },
+ "caveats": ["No month produced a usable result, so no annual totals or savings could be calculated."],
+ }
+
+
+def sample_results_with_degraded_month():
+ """Return a results document containing one 'degraded' month, built from fewer samples.
+
+ Mirrors what ``AnnualPredictor.run()`` emits when some, but not all, of a month's
+ sampled days failed to plan: the month still carries real ``scenarios`` figures and
+ a non-empty ``failed_days`` list, and - unlike an "unavailable" month - it IS counted
+ in ``annual.months_included`` and is absent from ``annual.months_excluded``.
+ """
+ scenarios = {
+ "no_pvbat": {"cost_p": 9000.0, "import_kwh": 300.0, "export_kwh": 0.0, "pv_generated_kwh": 0.0, "battery_throughput_kwh": 0.0, "export_credit_p_estimate": 0.0},
+ "pv_only": {"cost_p": 7800.0, "import_kwh": 260.0, "export_kwh": 45.0, "pv_generated_kwh": 100.0, "battery_throughput_kwh": 0.0, "export_credit_p_estimate": 135.0},
+ "without_predbat": {"cost_p": 7000.0, "import_kwh": 250.0, "export_kwh": 15.0, "pv_generated_kwh": 100.0, "battery_throughput_kwh": 80.0, "export_credit_p_estimate": 200.0},
+ "with_predbat": {"cost_p": 5000.0, "import_kwh": 230.0, "export_kwh": 30.0, "pv_generated_kwh": 100.0, "battery_throughput_kwh": 120.0, "export_credit_p_estimate": 450.0},
+ }
+ return {
+ "year": 2025,
+ "config": {},
+ "months": [
+ {"month": 3, "status": "degraded", "days": 31, "sampled_days": ["2025-03-10"], "failed_days": ["2025-03-24"], "standing_charge_p": 1860.0, "scenarios": scenarios},
+ ],
+ "annual": {
+ "scenarios": scenarios,
+ "standing_charge_p": 1860.0,
+ "savings": {"pv_battery_vs_none_p": 2000.0, "predbat_vs_baseline_p": 2000.0},
+ "months_included": 1,
+ "months_excluded": [],
+ },
+ "caveats": [],
+ }
+
+
+def test_annual_cli(my_predbat):
+ """Verify the table output reports every month, including excluded ones."""
+ failed = False
+ print("**** Testing annual CLI output ****")
+
+ table = format_table(sample_results())
+
+ print("Test: the table names the year and every scenario")
+ for fragment in ["2025", "No PV/Battery", "Without Predbat", "With Predbat"]:
+ if fragment not in table:
+ print(" ERROR: the table should mention '{}'".format(fragment))
+ failed = True
+
+ print("Test: an unavailable month is shown as excluded, never as zero")
+ if "unavailable" not in table.lower():
+ print(" ERROR: the table must state that February was unavailable")
+ failed = True
+ if "no rate data available" not in table:
+ print(" ERROR: the table should state why the month was excluded")
+ failed = True
+
+ print("Test: annual savings appear")
+ if "Savings" not in table:
+ print(" ERROR: the table should include a savings section")
+ failed = True
+
+ print("Test: caveats are printed rather than buried in the JSON")
+ if "An example caveat." not in table:
+ print(" ERROR: caveats must be shown to the user")
+ failed = True
+
+ print("Test: the excluded-month count is stated alongside the annual totals")
+ if "1 of 12" not in table:
+ print(" ERROR: the table should state how many months are included, got:\n{}".format(table))
+ failed = True
+
+ print("Test: the export credit line warns it is already included in cost, not additional income")
+ if "export credit" not in table.lower():
+ print(" ERROR: the table should show an export credit line, got:\n{}".format(table))
+ failed = True
+ if "already included" not in table.lower():
+ print(" ERROR: the export credit line must warn it is already counted inside cost, to stop it being double-counted, got:\n{}".format(table))
+ failed = True
+
+ print("Test: a degraded month (some sampled days failed) is costed and included, not treated as unavailable")
+ degraded_table = format_table(sample_results_with_degraded_month())
+ if "unavailable" in degraded_table.lower():
+ print(" ERROR: a degraded month must not be rendered as unavailable, got:\n{}".format(degraded_table))
+ failed = True
+ if "£90.00" not in degraded_table:
+ print(" ERROR: the degraded month's no_pvbat cost (9000p = £90.00) should still be rendered, got:\n{}".format(degraded_table))
+ failed = True
+ if "Excluded months" in degraded_table:
+ print(" ERROR: a degraded month must not appear as excluded, got:\n{}".format(degraded_table))
+ failed = True
+ if "1 of 12" not in degraded_table:
+ print(" ERROR: the degraded month should still count towards months_included, got:\n{}".format(degraded_table))
+ failed = True
+ if "degraded" not in degraded_table.lower():
+ print(" ERROR: the table should signal that the month came from fewer samples than planned, got:\n{}".format(degraded_table))
+ failed = True
+
+ print("Test: when no month is usable, the table does not fabricate a zero-cost year")
+ empty_table = format_table(sample_results_no_usable_month())
+ if "0 of 12" not in empty_table:
+ print(" ERROR: the table should state 0 of 12 months were used, got:\n{}".format(empty_table))
+ failed = True
+ if "Savings" in empty_table:
+ print(" ERROR: the table must not print a savings section when there is no annual total")
+ failed = True
+ if "0.00" in empty_table:
+ print(" ERROR: the table must not render the missing annual total as a zero cost, got:\n{}".format(empty_table))
+ failed = True
+
+ print("Test: --quiet suppresses only progress output, never AnnualPredictor's warnings")
+ # Regression guard: --quiet used to pass log=lambda *a, **k: None, silencing the
+ # P10-fallback/missing-rate/failed-day/car-shortfall warnings along with progress, which
+ # broke the "failures are visible, never silent" contract. main() must still pass
+ # log=print through under --quiet, suppressing only make_progress()'s per-month lines.
+ original_predictor = annual_cli.AnnualPredictor
+ annual_cli.AnnualPredictor = _StubPredictor
+ with tempfile.TemporaryDirectory() as work_dir:
+ config_path = os.path.join(work_dir, "annual.yaml")
+ with open(config_path, "w") as handle:
+ handle.write("annual: {}\n")
+ try:
+ with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+ annual_cli.main(["--config", config_path, "--work-dir", os.path.join(work_dir, "work"), "--quiet"])
+ finally:
+ annual_cli.AnnualPredictor = original_predictor
+
+ if _StubPredictor.captured_log is not print:
+ print(" ERROR: --quiet should still construct AnnualPredictor with log=print, got {}".format(_StubPredictor.captured_log))
+ failed = True
+
+ return failed
+
+
+def test_annual_cli_machine(my_predbat):
+ """Verify machine mode emits JSON progress on stderr and nothing human on stdout."""
+ import io
+ import json
+ import sys
+
+ failed = False
+ print("**** Testing annual CLI machine mode ****")
+
+ print("Test: machine progress writes one JSON object per line to stderr")
+ captured = io.StringIO()
+ original_stderr = sys.stderr
+ sys.stderr = captured
+ try:
+ progress = make_progress(quiet=False, machine=True)
+ progress(3, 12, "Month 03/2025")
+ finally:
+ sys.stderr = original_stderr
+
+ line = captured.getvalue().strip()
+ try:
+ parsed = json.loads(line)
+ except ValueError:
+ print(" ERROR: machine progress should be JSON, got {!r}".format(line))
+ parsed = {}
+ failed = True
+ if parsed.get("completed") != 3 or parsed.get("total") != 12 or parsed.get("message") != "Month 03/2025":
+ print(" ERROR: unexpected progress payload {}".format(parsed))
+ failed = True
+
+ print("Test: human progress is unchanged when machine mode is off")
+ captured = io.StringIO()
+ sys.stderr = captured
+ try:
+ progress = make_progress(quiet=False, machine=False)
+ progress(3, 12, "Month 03/2025")
+ finally:
+ sys.stderr = original_stderr
+ if "[3/12]" not in captured.getvalue():
+ print(" ERROR: expected the human form, got {!r}".format(captured.getvalue()))
+ failed = True
+
+ print("Test: quiet still suppresses progress in both modes")
+ if make_progress(quiet=True, machine=False) is not None:
+ print(" ERROR: quiet should give no progress callback")
+ failed = True
+ if make_progress(quiet=True, machine=True) is not None:
+ print(" ERROR: quiet should give no progress callback in machine mode either")
+ failed = True
+
+ return failed
+
+
+def test_annual_cli_machine_end_to_end(my_predbat):
+ """Verify main() itself, not just make_progress(), keeps stdout pure JSON under --machine.
+
+ Two checks that unit-testing make_progress() alone cannot make:
+
+ 1. A real subprocess invocation with a config that fails validation before
+ predictor.run() is ever reached - confirming exit code, empty stdout and a readable
+ stderr message hold end-to-end, not just when main() is called in-process.
+ 2. An in-process call to main() with AnnualPredictor stubbed to log a warning from inside
+ run() (mirroring a P10 fallback or similar engine warning) - confirming that warning
+ lands on stderr and never on stdout, and that stdout still parses as exactly one JSON
+ object. This is the actual regression risk: a stray print() reachable only once
+ predictor.run() executes, which a bad-config-only check cannot exercise.
+ """
+ import io
+ import json
+ import subprocess
+ import sys
+
+ failed = False
+ print("**** Testing annual CLI machine mode end-to-end (main()) ****")
+
+ print("Test: a real subprocess run with --machine and a bad config emits nothing on stdout")
+ with tempfile.TemporaryDirectory() as work_dir:
+ config_path = os.path.join(work_dir, "bad.yaml")
+ with open(config_path, "w") as handle:
+ handle.write("annual: {}\n")
+ completed = subprocess.run(
+ [sys.executable, annual_cli.__file__, "--config", config_path, "--work-dir", os.path.join(work_dir, "work"), "--machine"],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ if completed.returncode != 2:
+ print(" ERROR: expected exit code 2 for a config error, got {}".format(completed.returncode))
+ failed = True
+ if completed.stdout != "":
+ print(" ERROR: stdout must be empty on a config error, got {!r}".format(completed.stdout))
+ failed = True
+ if "annual.location" not in completed.stderr:
+ print(" ERROR: expected a readable config error on stderr, got {!r}".format(completed.stderr))
+ failed = True
+
+ print("Test: a warning logged mid-run() lands on stderr, never on stdout, alongside clean JSON")
+ original_predictor = annual_cli.AnnualPredictor
+ annual_cli.AnnualPredictor = _WarningPredictor
+ stdout_capture = io.StringIO()
+ stderr_capture = io.StringIO()
+ try:
+ with tempfile.TemporaryDirectory() as work_dir:
+ config_path = os.path.join(work_dir, "annual.yaml")
+ with open(config_path, "w") as handle:
+ handle.write("annual: {}\n")
+ with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
+ exit_code = annual_cli.main(["--config", config_path, "--work-dir", os.path.join(work_dir, "work"), "--machine"])
+ finally:
+ annual_cli.AnnualPredictor = original_predictor
+
+ if exit_code != 0:
+ print(" ERROR: a successful run should exit 0, got {}".format(exit_code))
+ failed = True
+
+ stdout_text = stdout_capture.getvalue()
+ if "stub P10 fallback warning" in stdout_text:
+ print(" ERROR: the engine's warning leaked onto stdout, got {!r}".format(stdout_text))
+ failed = True
+ try:
+ parsed = json.loads(stdout_text)
+ except ValueError:
+ print(" ERROR: stdout should be exactly one JSON object even when the engine logs mid-run, got {!r}".format(stdout_text))
+ parsed = None
+ failed = True
+ if parsed is not None and parsed != sample_results():
+ print(" ERROR: stdout's JSON should match the results document exactly, got {}".format(parsed))
+ failed = True
+
+ if "stub P10 fallback warning" not in stderr_capture.getvalue():
+ print(" ERROR: the engine's warning should still be visible, on stderr, got {!r}".format(stderr_capture.getvalue()))
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_config.py b/apps/predbat/tests/test_annual_config.py
new file mode 100644
index 000000000..ea6022803
--- /dev/null
+++ b/apps/predbat/tests/test_annual_config.py
@@ -0,0 +1,441 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for annual prediction config validation."""
+
+from datetime import date
+
+from annual import AnnualConfigError, scrub_secrets, validate_config
+
+
+def base_config():
+ """Return a minimal valid annual config."""
+ return {
+ "annual": {
+ "location": {"latitude": 51.5, "longitude": -0.1},
+ "solar": [{"kwp": 5.6}],
+ "battery": {"size_kwh": 9.5, "inverter_kw": 5.0},
+ "load": {"annual_kwh": 3800},
+ "tariff": {"rates_import": [{"rate": 25.0}]},
+ }
+ }
+
+
+def expect_error(label, config, fragment, failed):
+ """Assert that validate_config rejects the config with a message containing fragment."""
+ try:
+ validate_config(config)
+ except AnnualConfigError as error:
+ if fragment.lower() not in str(error).lower():
+ print(" ERROR: {} raised '{}', expected it to mention '{}'".format(label, error, fragment))
+ return True
+ return failed
+ print(" ERROR: {} should have raised AnnualConfigError".format(label))
+ return True
+
+
+def expect_config_error_type(label, config, failed):
+ """Assert that validate_config raises AnnualConfigError specifically, not a bare ValueError/TypeError.
+
+ A malformed numeric field must be caught and re-raised as AnnualConfigError so that a
+ later CLI layer, which only catches that type, never sees a raw traceback.
+ """
+ try:
+ validate_config(config)
+ except AnnualConfigError:
+ return failed
+ except (ValueError, TypeError) as error:
+ print(" ERROR: {} raised a bare {} ('{}') instead of AnnualConfigError".format(label, type(error).__name__, error))
+ return True
+ print(" ERROR: {} should have raised AnnualConfigError".format(label))
+ return True
+
+
+def test_annual_config(my_predbat):
+ """Verify annual config defaulting, normalisation and rejection rules."""
+ failed = False
+ print("**** Testing annual config validation ****")
+
+ print("Test: a minimal config validates and gains defaults")
+ result = validate_config(base_config(), today=date(2026, 7, 25))
+ if result["year"] != 2025:
+ print(" ERROR: year should default to the most recent complete calendar year, got {}".format(result["year"]))
+ failed = True
+ if result["samples_per_month"] != 2:
+ print(" ERROR: samples_per_month should default to 2, got {}".format(result["samples_per_month"]))
+ failed = True
+ if result["timezone"] != "Europe/London":
+ print(" ERROR: timezone should default to Europe/London, got {}".format(result["timezone"]))
+ failed = True
+ if abs(result["pv10_derate_fallback"] - 0.7) > 1e-9:
+ print(" ERROR: pv10_derate_fallback should default to 0.7, got {}".format(result["pv10_derate_fallback"]))
+ failed = True
+ if result["load"]["shape"] != "flat":
+ print(" ERROR: load shape should default to flat, got {}".format(result["load"]["shape"]))
+ failed = True
+ if result["solar"][0]["declination"] != 35 or result["solar"][0]["azimuth"] != 180:
+ print(" ERROR: solar defaults should be declination 35 azimuth 180, got {}".format(result["solar"][0]))
+ failed = True
+ if abs(result["solar"][0]["efficiency"] - 0.95) > 1e-9:
+ print(" ERROR: solar efficiency should default to 0.95, got {}".format(result["solar"][0]["efficiency"]))
+ failed = True
+ if result["battery"]["charge_rate_kw"] != 5.0 or result["battery"]["discharge_rate_kw"] != 5.0:
+ print(" ERROR: charge and discharge rates should default to inverter_kw, got {}".format(result["battery"]))
+ failed = True
+ if result["battery"]["export_limit_kw"] != 5.0:
+ print(" ERROR: export_limit_kw should default to inverter_kw, got {}".format(result["battery"]))
+ failed = True
+ if result["battery"]["hybrid"] is not True:
+ print(" ERROR: hybrid should default to True, got {}".format(result["battery"].get("hybrid")))
+ failed = True
+
+ print("Test: an unwrapped config without the 'annual' key is accepted")
+ unwrapped = base_config()["annual"]
+ result = validate_config(unwrapped, today=date(2026, 7, 25))
+ if result["year"] != 2025:
+ print(" ERROR: an unwrapped config should validate the same way")
+ failed = True
+
+ print("Test: Octopus load together with a manual figure is rejected")
+ config = base_config()
+ config["annual"]["load"]["octopus"] = {"api_key": "sk_x", "account_id": "A-1"}
+ failed = expect_error("octopus plus annual_kwh", config, "mutually exclusive", failed)
+
+ config = base_config()
+ del config["annual"]["load"]["annual_kwh"]
+ config["annual"]["load"]["car_charging_kwh"] = 2500
+ config["annual"]["load"]["octopus"] = {"api_key": "sk_x", "account_id": "A-1"}
+ failed = expect_error("octopus plus car_charging_kwh", config, "mutually exclusive", failed)
+
+ print("Test: an empty octopus block alongside a manual figure is still rejected as mutually exclusive")
+ config = base_config()
+ config["annual"]["load"]["octopus"] = {}
+ failed = expect_error("empty octopus plus annual_kwh", config, "mutually exclusive", failed)
+
+ print("Test: an Octopus-only load block validates")
+ config = base_config()
+ del config["annual"]["load"]["annual_kwh"]
+ config["annual"]["load"]["octopus"] = {"api_key": "sk_x", "account_id": "A-1"}
+ result = validate_config(config, today=date(2026, 7, 25))
+ if result["load"].get("octopus", {}).get("account_id") != "A-1":
+ print(" ERROR: the Octopus load block should survive validation")
+ failed = True
+
+ print("Test: car_rate_kw is meaningless on an Octopus load (no separate car energy to rate) and is simply ignored")
+ config = base_config()
+ del config["annual"]["load"]["annual_kwh"]
+ config["annual"]["load"]["octopus"] = {"api_key": "sk_x", "account_id": "A-1"}
+ config["annual"]["load"]["car_rate_kw"] = 3.7
+ result = validate_config(config, today=date(2026, 7, 25))
+ if "car_rate_kw" in result["load"]:
+ print(" ERROR: car_rate_kw should not appear in an Octopus load block, got {}".format(result["load"]))
+ failed = True
+
+ print("Test: a missing battery block yields a two-scenario run")
+ config = base_config()
+ del config["annual"]["battery"]
+ result = validate_config(config, today=date(2026, 7, 25))
+ if result["battery"] is not None:
+ print(" ERROR: an omitted battery should normalise to None, got {}".format(result["battery"]))
+ failed = True
+
+ print("Test: a missing solar block is allowed for a battery-only run")
+ config = base_config()
+ del config["annual"]["solar"]
+ result = validate_config(config, today=date(2026, 7, 25))
+ if result["solar"] != []:
+ print(" ERROR: an omitted solar block should normalise to an empty list, got {}".format(result["solar"]))
+ failed = True
+
+ print("Test: omitting both solar and battery is rejected as pointless")
+ config = base_config()
+ del config["annual"]["solar"]
+ del config["annual"]["battery"]
+ failed = expect_error("neither solar nor battery", config, "at least one of solar or battery", failed)
+
+ print("Test: missing location is rejected")
+ config = base_config()
+ del config["annual"]["location"]
+ failed = expect_error("no location", config, "annual.location is required", failed)
+
+ print("Test: missing load is rejected")
+ config = base_config()
+ del config["annual"]["load"]
+ failed = expect_error("no load", config, "annual.load is required", failed)
+
+ print("Test: missing tariff is rejected")
+ config = base_config()
+ del config["annual"]["tariff"]
+ failed = expect_error("no tariff", config, "annual.tariff is required", failed)
+
+ print("Test: car_rate_kw defaults to 7.4 kW when omitted")
+ config = base_config()
+ result = validate_config(config, today=date(2026, 7, 25))
+ if result["load"]["car_rate_kw"] != 7.4:
+ print(" ERROR: car_rate_kw should default to 7.4, got {}".format(result["load"]["car_rate_kw"]))
+ failed = True
+
+ print("Test: an explicit car_rate_kw survives validation")
+ config = base_config()
+ config["annual"]["load"]["car_rate_kw"] = 3.7
+ result = validate_config(config, today=date(2026, 7, 25))
+ if result["load"]["car_rate_kw"] != 3.7:
+ print(" ERROR: car_rate_kw should survive validation as 3.7, got {}".format(result["load"]["car_rate_kw"]))
+ failed = True
+
+ print("Test: a zero car_rate_kw is rejected")
+ config = base_config()
+ config["annual"]["load"]["car_rate_kw"] = 0
+ failed = expect_error("zero car_rate_kw", config, "car_rate_kw", failed)
+
+ print("Test: a negative car_rate_kw is rejected")
+ config = base_config()
+ config["annual"]["load"]["car_rate_kw"] = -3.7
+ failed = expect_error("negative car_rate_kw", config, "car_rate_kw", failed)
+
+ print("Test: an unknown load shape is rejected")
+ config = base_config()
+ config["annual"]["load"]["shape"] = "sideways"
+ failed = expect_error("bad shape", config, "annual.load.shape must be one of", failed)
+
+ print("Test: a solar array without kwp is rejected")
+ config = base_config()
+ config["annual"]["solar"] = [{"declination": 30}]
+ failed = expect_error("array without kwp", config, "is missing kwp", failed)
+
+ print("Test: a panel count derives kwp at 400 W a panel")
+ config = base_config()
+ config["annual"]["solar"] = [{"panels": 13}]
+ validated = validate_config(config)
+ if abs(validated["solar"][0]["kwp"] - 5.2) > 0.001:
+ print(" ERROR: 13 panels at 400 W should be 5.2 kWp, got {}".format(validated["solar"][0]["kwp"]))
+ failed = True
+
+ print("Test: a custom panel wattage is honoured")
+ config = base_config()
+ config["annual"]["solar"] = [{"panels": 10, "panel_watts": 450}]
+ validated = validate_config(config)
+ if abs(validated["solar"][0]["kwp"] - 4.5) > 0.001:
+ print(" ERROR: 10 panels at 450 W should be 4.5 kWp, got {}".format(validated["solar"][0]["kwp"]))
+ failed = True
+
+ print("Test: the panel count and wattage survive validation for form round-tripping")
+ if validated["solar"][0].get("panels") != 10 or validated["solar"][0].get("panel_watts") != 450:
+ print(" ERROR: panels/panel_watts should be retained, got {}".format(validated["solar"][0]))
+ failed = True
+
+ print("Test: supplying both kwp and panels is rejected rather than silently preferring one")
+ config = base_config()
+ config["annual"]["solar"] = [{"kwp": 5.0, "panels": 13}]
+ try:
+ validate_config(config)
+ print(" ERROR: supplying both kwp and panels should be rejected")
+ failed = True
+ except AnnualConfigError as error:
+ if "panels" not in str(error) or "kwp" not in str(error):
+ print(" ERROR: the error should name both fields, got {}".format(error))
+ failed = True
+
+ print("Test: an array with neither kwp nor panels is rejected")
+ config = base_config()
+ config["annual"]["solar"] = [{"declination": 35}]
+ try:
+ validate_config(config)
+ print(" ERROR: an array with neither kwp nor panels should be rejected")
+ failed = True
+ except AnnualConfigError:
+ pass
+
+ print("Test: a fractional or zero panel count is rejected")
+ for bad in [0, 2.5, -3]:
+ config = base_config()
+ config["annual"]["solar"] = [{"panels": bad}]
+ try:
+ validate_config(config)
+ print(" ERROR: a panel count of {} should be rejected".format(bad))
+ failed = True
+ except AnnualConfigError:
+ pass
+
+ print("Test: samples_per_month below 1 is rejected")
+ config = base_config()
+ config["annual"]["samples_per_month"] = 0
+ failed = expect_error("zero samples", config, "annual.samples_per_month must be at least", failed)
+
+ print("Test: a postcode-only location validates")
+ config = base_config()
+ config["annual"]["location"] = {"postcode": "SW1A 1AA"}
+ result = validate_config(config, today=date(2026, 7, 25))
+ if result["location"].get("postcode") != "SW1A 1AA":
+ print(" ERROR: a postcode location should survive validation")
+ failed = True
+
+ print("Test: a templated tariff URL without dno_region is rejected up front")
+ config = base_config()
+ config["annual"]["tariff"] = {"import_octopus_url": "https://api.octopus.energy/v1/products/AGILE/electricity-tariffs/E-1R-AGILE-{dno_region}/standard-unit-rates/"}
+ failed = expect_error("templated url without region", config, "dno_region is not set", failed)
+
+ print("Test: a templated tariff URL with dno_region validates and is carried through")
+ config = base_config()
+ config["annual"]["tariff"] = {"import_octopus_url": "https://api.octopus.energy/v1/products/AGILE/electricity-tariffs/E-1R-AGILE-{dno_region}/standard-unit-rates/", "dno_region": "A"}
+ result = validate_config(config, today=date(2026, 7, 25))
+ if result["tariff"].get("dno_region") != "A":
+ print(" ERROR: dno_region should survive validation, got {}".format(result["tariff"].get("dno_region")))
+ failed = True
+
+ print("Test: both import and export templated URLs without dno_region are both named in the error")
+ config = base_config()
+ config["annual"]["tariff"] = {
+ "import_octopus_url": "https://api.octopus.energy/v1/products/AGILE/electricity-tariffs/E-1R-AGILE-{dno_region}/standard-unit-rates/",
+ "export_octopus_url": "https://api.octopus.energy/v1/products/AGILE-OUTGOING/electricity-tariffs/E-1R-AGILE-OUTGOING-{dno_region}/standard-unit-rates/",
+ }
+ try:
+ validate_config(config, today=date(2026, 7, 25))
+ print(" ERROR: both templated URLs without dno_region should have raised AnnualConfigError")
+ failed = True
+ except AnnualConfigError as error:
+ message = str(error)
+ if "import_octopus_url" not in message or "export_octopus_url" not in message:
+ print(" ERROR: the dno_region error should name both offending fields, got '{}'".format(message))
+ failed = True
+
+ print("Test: a non-numeric kwp raises AnnualConfigError, not a bare ValueError")
+ config = base_config()
+ config["annual"]["solar"] = [{"kwp": "not-a-number"}]
+ failed = expect_config_error_type("non-numeric kwp", config, failed)
+
+ print("Test: a None kwp raises AnnualConfigError, not a bare TypeError")
+ config = base_config()
+ config["annual"]["solar"] = [{"kwp": None}]
+ failed = expect_config_error_type("None kwp", config, failed)
+
+ print("Test: a non-numeric size_kwh raises AnnualConfigError, not a bare ValueError")
+ config = base_config()
+ config["annual"]["battery"]["size_kwh"] = "lots"
+ failed = expect_config_error_type("non-numeric size_kwh", config, failed)
+
+ print("Test: a None size_kwh raises AnnualConfigError, not a bare TypeError")
+ config = base_config()
+ config["annual"]["battery"]["size_kwh"] = None
+ failed = expect_config_error_type("None size_kwh", config, failed)
+
+ print("Test: a negative size_kwh is rejected")
+ config = base_config()
+ config["annual"]["battery"]["size_kwh"] = -5
+ failed = expect_error("negative size_kwh", config, "size_kwh", failed)
+
+ print("Test: a zero kwp is rejected")
+ config = base_config()
+ config["annual"]["solar"] = [{"kwp": 0}]
+ failed = expect_error("zero kwp", config, "kwp", failed)
+
+ print("Test: an efficiency above 1 is rejected")
+ config = base_config()
+ config["annual"]["solar"] = [{"kwp": 5.6, "efficiency": 1.5}]
+ failed = expect_error("efficiency 1.5", config, "efficiency", failed)
+
+ print("Test: a year in the future is rejected")
+ config = base_config()
+ config["annual"]["year"] = 2027
+ try:
+ validate_config(config, today=date(2026, 7, 25))
+ print(" ERROR: future year should have raised AnnualConfigError")
+ failed = True
+ except AnnualConfigError as error:
+ if "annual.year must be at most" not in str(error):
+ print(" ERROR: future year raised '{}', expected it to mention 'annual.year must be at most'".format(error))
+ failed = True
+
+ print("Test: a string declination and azimuth (as the web form always sends) are coerced to numbers")
+ # The web form deliberately submits every field as a string (see web_annual.py's
+ # config_from_post()); validate_config() is the single place that coerces and range
+ # checks, so a string here must survive as a number, not crash deep inside
+ # convert_azimuth() (solar_model.py) minutes into a run.
+ config = base_config()
+ config["annual"]["solar"] = [{"kwp": 5.6, "declination": "40", "azimuth": "170"}]
+ result = validate_config(config, today=date(2026, 7, 25))
+ declination = result["solar"][0]["declination"]
+ azimuth = result["solar"][0]["azimuth"]
+ if not isinstance(declination, (int, float)) or isinstance(declination, bool) or declination != 40:
+ print(" ERROR: a string declination should coerce to the number 40, got {!r}".format(declination))
+ failed = True
+ if not isinstance(azimuth, (int, float)) or isinstance(azimuth, bool) or azimuth != 170:
+ print(" ERROR: a string azimuth should coerce to the number 170, got {!r}".format(azimuth))
+ failed = True
+
+ print("Test: a negative azimuth within bounds validates (convert_azimuth's negative branch)")
+ config = base_config()
+ config["annual"]["solar"] = [{"kwp": 5.6, "azimuth": "-170"}]
+ result = validate_config(config, today=date(2026, 7, 25))
+ if result["solar"][0]["azimuth"] != -170:
+ print(" ERROR: a negative azimuth should survive validation, got {}".format(result["solar"][0]["azimuth"]))
+ failed = True
+
+ print("Test: a declination outside 0-90 degrees is rejected")
+ config = base_config()
+ config["annual"]["solar"] = [{"kwp": 5.6, "declination": 95}]
+ failed = expect_error("declination out of range", config, "declination", failed)
+
+ print("Test: an azimuth outside -360 to 360 degrees is rejected")
+ config = base_config()
+ config["annual"]["solar"] = [{"kwp": 5.6, "azimuth": 400}]
+ failed = expect_error("azimuth out of range", config, "azimuth", failed)
+
+ print("Test: string latitude and longitude (as the web form always sends) are coerced to numbers")
+ config = base_config()
+ config["annual"]["location"] = {"latitude": "51.5", "longitude": "-0.1"}
+ result = validate_config(config, today=date(2026, 7, 25))
+ latitude = result["location"]["latitude"]
+ longitude = result["location"]["longitude"]
+ if not isinstance(latitude, (int, float)) or isinstance(latitude, bool) or abs(latitude - 51.5) > 1e-9:
+ print(" ERROR: a string latitude should coerce to the number 51.5, got {!r}".format(latitude))
+ failed = True
+ if not isinstance(longitude, (int, float)) or isinstance(longitude, bool) or abs(longitude - (-0.1)) > 1e-9:
+ print(" ERROR: a string longitude should coerce to the number -0.1, got {!r}".format(longitude))
+ failed = True
+
+ print("Test: a latitude outside -90 to 90 degrees is rejected")
+ config = base_config()
+ config["annual"]["location"] = {"latitude": 95, "longitude": 0}
+ failed = expect_error("latitude out of range", config, "latitude", failed)
+
+ print("Test: a longitude outside -180 to 180 degrees is rejected")
+ config = base_config()
+ config["annual"]["location"] = {"latitude": 0, "longitude": 200}
+ failed = expect_error("longitude out of range", config, "longitude", failed)
+
+ print("Test: a negative costs value raises AnnualConfigError, not a bare ValueError")
+ # _validated_costs() must translate annual_costs.resolve_costs()'s ValueError into
+ # AnnualConfigError - the CLI and web layer only catch the latter, so a config
+ # problem inside the costs block must surface the same way every other config
+ # mistake in this file does, not leak the pure module's own exception type.
+ config = base_config()
+ config["annual"]["costs"] = {"battery_install_gbp": -100}
+ failed = expect_config_error_type("negative costs value", config, failed)
+
+ print("Test: an unrecognised costs key raises AnnualConfigError, not a bare ValueError")
+ config = base_config()
+ config["annual"]["costs"] = {"not_a_real_setting": 100}
+ failed = expect_config_error_type("unrecognised costs key", config, failed)
+
+ print("Test: scrub_secrets removes API keys without mutating the original")
+ config = base_config()
+ config["annual"]["load"]["octopus"] = {"api_key": "sk_live_secret", "account_id": "A-1"}
+ scrubbed = scrub_secrets(config)
+ if scrubbed["annual"]["load"]["octopus"]["api_key"] != "xxx":
+ print(" ERROR: api_key should be scrubbed, got {}".format(scrubbed["annual"]["load"]["octopus"]["api_key"]))
+ failed = True
+ if config["annual"]["load"]["octopus"]["api_key"] != "sk_live_secret":
+ print(" ERROR: scrub_secrets must not mutate its input")
+ failed = True
+ if scrubbed["annual"]["load"]["octopus"]["account_id"] != "A-1":
+ print(" ERROR: non-secret values should survive scrubbing")
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_costs.py b/apps/predbat/tests/test_annual_costs.py
new file mode 100644
index 000000000..6ea4a7082
--- /dev/null
+++ b/apps/predbat/tests/test_annual_costs.py
@@ -0,0 +1,247 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Unit tests for the annual install-cost and payback model.
+
+Everything here is pure arithmetic over plain dicts - no network, no Predbat
+instance, no plan run - so it is fast and registered as a non-slow test.
+"""
+
+from annual_costs import battery_cost_gbp, build_costs, build_payback, payback_row, pv_cost_gbp, pv_rate_gbp_per_kwp, resolve_costs
+
+
+def close(actual, expected, tolerance=0.01):
+ """Return True when two floats agree to within a tolerance."""
+ return abs(actual - expected) <= tolerance
+
+
+def test_annual_costs(my_predbat):
+ """Verify the cost bands, the minimum, and the payback arithmetic.
+
+ ``my_predbat`` is unused: this module is pure arithmetic with no Predbat state, but
+ the test runner calls every registered test with the shared instance uniformly.
+ """
+ failed = False
+ print("**** Testing annual cost and payback model ****")
+ settings = resolve_costs(None)
+
+ print("Test: the band rate sits exactly on each published median at its anchor")
+ for kwp, expected in [(2.0, 1780.0), (7.0, 1697.0), (30.0, 1262.0)]:
+ rate = pv_rate_gbp_per_kwp(kwp, settings)
+ if not close(rate, expected):
+ print(" ERROR: {} kWp should be {} per kWp, got {}".format(kwp, expected, rate))
+ failed = True
+
+ print("Test: the rate interpolates between anchors rather than stepping")
+ # A step function is what produces the 4.0/4.1 kWp discontinuity this design exists
+ # to avoid, so assert an actual in-between value, not merely "different".
+ if not close(pv_rate_gbp_per_kwp(4.5, settings), 1738.50):
+ print(" ERROR: 4.5 kWp should interpolate to 1738.50, got {}".format(pv_rate_gbp_per_kwp(4.5, settings)))
+ failed = True
+
+ print("Test: the rate is clamped flat outside the anchor span")
+ if not close(pv_rate_gbp_per_kwp(0.5, settings), 1780.0) or not close(pv_rate_gbp_per_kwp(45.0, settings), 1262.0):
+ print(" ERROR: the rate should clamp to 1780 below 2 kWp and 1262 above 30 kWp")
+ failed = True
+
+ print("Test: total PV cost never decreases as the system grows")
+ # This is the property that motivated interpolation over a step function.
+ previous = -1.0
+ size = 0.1
+ while size <= 50.0:
+ cost = pv_cost_gbp(size, settings)
+ if cost < previous - 0.001:
+ print(" ERROR: PV cost fell from {} to {} at {} kWp".format(previous, cost, size))
+ failed = True
+ break
+ previous = cost
+ size += 0.1
+
+ print("Test: the minimum applies to a small system and not to a large one")
+ if not close(pv_cost_gbp(1.0, settings), 2500.0):
+ print(" ERROR: a 1 kWp system should cost the 2500 minimum, got {}".format(pv_cost_gbp(1.0, settings)))
+ failed = True
+ if not close(pv_cost_gbp(5.0, settings), 8651.0, tolerance=1.0):
+ print(" ERROR: a 5 kWp system should cost about 8651, got {}".format(pv_cost_gbp(5.0, settings)))
+ failed = True
+
+ print("Test: no PV and no battery cost nothing, rather than the minimum")
+ if pv_cost_gbp(0, settings) != 0.0:
+ print(" ERROR: zero PV should cost nothing, got {}".format(pv_cost_gbp(0, settings)))
+ failed = True
+ if battery_cost_gbp(0, settings) != 0.0:
+ print(" ERROR: zero battery should cost nothing, got {}".format(battery_cost_gbp(0, settings)))
+ failed = True
+
+ print("Test: battery cost is the install fee plus the per-kWh rate")
+ if not close(battery_cost_gbp(9.5, settings), 500.0 + 300.0 * 9.5):
+ print(" ERROR: a 9.5 kWh battery should cost 3350, got {}".format(battery_cost_gbp(9.5, settings)))
+ failed = True
+
+ print("Test: custom settings override every default")
+ custom = resolve_costs({"battery_install_gbp": 0, "battery_per_kwh_gbp": 200, "pv_minimum_gbp": 0, "pv_rate_small_gbp_per_kwp": 1000, "pv_rate_medium_gbp_per_kwp": 900, "pv_rate_large_gbp_per_kwp": 800, "predbat_annual_gbp": 99})
+ if not close(battery_cost_gbp(10, custom), 2000.0) or not close(pv_rate_gbp_per_kwp(2.0, custom), 1000.0) or custom["predbat_annual_gbp"] != 99:
+ print(" ERROR: custom cost settings were not honoured: {}".format(custom))
+ failed = True
+
+ print("Test: NaN and Infinity are rejected, not accepted as valid costs")
+ # float("nan") < 0 is False, so the existing "must not be negative" guard alone lets
+ # NaN straight through; a NaN annual_saving_gbp then makes payback_row's net <= 0
+ # gate also False (NaN comparisons are always False), so it reports "pays_back:
+ # True, years: nan" and the page would render "nan years" as though it were a real
+ # answer. math.isfinite must catch both NaN and +/-Infinity before that can happen.
+ for bad in [float("nan"), float("inf"), float("-inf")]:
+ try:
+ resolve_costs({"predbat_annual_gbp": bad})
+ print(" ERROR: resolve_costs should reject a non-finite cost ({}), it was accepted".format(bad))
+ failed = True
+ except ValueError:
+ pass
+
+ print("Test: solar-only and whole-system quotes replace the modelled costs")
+ quoted = resolve_costs({"quoted_pv_gbp": 7000.0, "quoted_total_gbp": 11200.0})
+ costs = build_costs(5.0, 9.5, quoted)
+ if not close(costs["pv_gbp"], 7000.0) or not close(costs["total_gbp"], 11200.0):
+ print(" ERROR: both quotes should be used as given, got {}".format(costs))
+ failed = True
+ # The battery is the REMAINDER, so a user with one whole-system quote does no sums.
+ if not close(costs["battery_gbp"], 4200.0):
+ print(" ERROR: the battery should be the difference between the two quotes, got {}".format(costs))
+ failed = True
+ if not costs["pv_quoted"] or not costs["battery_quoted"]:
+ print(" ERROR: a quoted figure should be flagged so the UI can label it, got {}".format(costs))
+ failed = True
+
+ print("Test: a whole-system quote alone still leaves the PV-only payback on the modelled solar")
+ # The common case: one quote for the installation, nothing broken out for solar.
+ whole = build_costs(5.0, 9.5, resolve_costs({"quoted_total_gbp": 11200.0}))
+ modelled_pv = pv_cost_gbp(5.0, resolve_costs(None))
+ if not close(whole["pv_gbp"], modelled_pv):
+ print(" ERROR: with no solar-only quote the PV cost should still be modelled, got {}".format(whole))
+ failed = True
+ if not close(whole["total_gbp"], 11200.0) or not close(whole["battery_gbp"], 11200.0 - modelled_pv):
+ print(" ERROR: the total should be the quote and the battery its remainder, got {}".format(whole))
+ failed = True
+ if whole["pv_quoted"]:
+ print(" ERROR: the solar was not quoted here and must not be labelled as such, got {}".format(whole))
+ failed = True
+
+ print("Test: a solar-only quote alone still leaves the battery modelled")
+ solar_only = build_costs(5.0, 9.5, resolve_costs({"quoted_pv_gbp": 7000.0}))
+ if not close(solar_only["pv_gbp"], 7000.0) or not close(solar_only["battery_gbp"], battery_cost_gbp(9.5, resolve_costs(None))):
+ print(" ERROR: a solar-only quote should leave the battery modelled, got {}".format(solar_only))
+ failed = True
+
+ print("Test: a whole-system quote below the solar figure cannot make the battery negative")
+ # A contradiction only the user can resolve, but it must not produce a negative cost
+ # or a total that disagrees with its own parts.
+ contradictory = build_costs(5.0, 9.5, resolve_costs({"quoted_pv_gbp": 9000.0, "quoted_total_gbp": 8000.0}))
+ if contradictory["battery_gbp"] < 0:
+ print(" ERROR: the battery cost must not go negative, got {}".format(contradictory))
+ failed = True
+ if not close(contradictory["total_gbp"], contradictory["pv_gbp"] + contradictory["battery_gbp"]):
+ print(" ERROR: the total must always equal its parts, got {}".format(contradictory))
+ failed = True
+
+ print("Test: a retired battery-only quote key is ignored rather than rejected or misread")
+ # An earlier revision asked for a battery-only price. A config saved against it must
+ # still load, and must NOT have that figure read as a whole-system total.
+ legacy = build_costs(5.0, 9.5, resolve_costs({"quoted_battery_gbp": 4200.0}))
+ if not close(legacy["pv_gbp"], modelled_pv) or legacy["battery_quoted"]:
+ print(" ERROR: the retired key should be ignored entirely, got {}".format(legacy))
+ failed = True
+
+ print("Test: a zero quote means 'not quoted', not 'free'")
+ unquoted = build_costs(5.0, 9.5, resolve_costs({"quoted_pv_gbp": 0, "quoted_total_gbp": 0}))
+ if unquoted["pv_gbp"] <= 0 or unquoted["battery_gbp"] <= 0 or unquoted["pv_quoted"] or unquoted["battery_quoted"]:
+ print(" ERROR: a zero quote should fall back to the model, got {}".format(unquoted))
+ failed = True
+
+ print("Test: a quoted PV price drops the £/kWp note, which no longer describes it")
+ if build_costs(5.0, 9.5, quoted)["pv_rate_gbp_per_kwp"] != 0.0:
+ print(" ERROR: a quoted PV cost has no modelled rate to quote alongside it")
+ failed = True
+
+ print("Test: payback divides capital by the annual saving")
+ row = payback_row(10000.0, 1000.0)
+ if not row["pays_back"] or not close(row["years"], 10.0):
+ print(" ERROR: 10000 capital against 1000 a year should pay back in 10 years, got {}".format(row))
+ failed = True
+
+ print("Test: a zero or negative saving does not pay back")
+ for saving in [0.0, -50.0]:
+ row = payback_row(10000.0, saving)
+ if row["pays_back"] or row.get("years") is not None:
+ print(" ERROR: a saving of {} must not produce a payback period, got {}".format(saving, row))
+ failed = True
+
+ print("Test: a recurring cost reduces the saving and lengthens payback")
+ row = payback_row(10000.0, 1000.0, recurring_gbp=100.0)
+ if not close(row["years"], 11.111) or not close(row["annual_saving_gbp"], 900.0) or not close(row["gross_annual_saving_gbp"], 1000.0):
+ print(" ERROR: a 100/year fee should give 900 net and 11.11 years, got {}".format(row))
+ failed = True
+
+ print("Test: a recurring cost exceeding the saving means it never pays back")
+ # The case a "subscription as capital" implementation gets wrong: treating the fee as
+ # a one-off would still show a payback period here.
+ row = payback_row(10000.0, 80.0, recurring_gbp=100.0)
+ if row["pays_back"]:
+ print(" ERROR: a fee larger than the saving must not pay back, got {}".format(row))
+ failed = True
+
+ print("Test: payback is refused on a partial year rather than extrapolated")
+ scenarios = {"no_pvbat": {"cost_p": 200000.0}, "pv_only": {"cost_p": 130000.0}, "without_predbat": {"cost_p": 110000.0}, "with_predbat": {"cost_p": 80000.0}}
+ costs = build_costs(5.0, 9.5, settings)
+ partial = build_payback(scenarios, costs, 11, settings)
+ if partial.get("available") is not False or "11" not in str(partial.get("reason", "")):
+ print(" ERROR: 11 months should refuse payback and say why, got {}".format(partial))
+ failed = True
+
+ print("Test: a missing scenario is refused rather than treated as a zero saving")
+ # Reachable when a stored run predates the pv_only scenario, or an engine change
+ # drops one - .get(key, {}).get("cost_p", baseline) would silently read this as no
+ # saving at all ("never pays back"), which is a different claim to "cannot be priced".
+ incomplete = {"no_pvbat": {"cost_p": 200000.0}, "without_predbat": {"cost_p": 110000.0}, "with_predbat": {"cost_p": 80000.0}}
+ missing = build_payback(incomplete, costs, 12, settings)
+ if missing.get("available") is not False or "pv_only" not in str(missing.get("reason", "")):
+ print(" ERROR: a missing pv_only scenario should refuse payback and name it, got {}".format(missing))
+ failed = True
+
+ print("Test: a full year produces all three payback rows")
+ full = build_payback(scenarios, costs, 12, settings)
+ for key, capital in [("pv_only", costs["pv_gbp"]), ("pv_battery", costs["total_gbp"]), ("pv_battery_predbat", costs["total_gbp"])]:
+ if key not in full:
+ print(" ERROR: {} row missing from payback, got {}".format(key, full))
+ failed = True
+ elif not close(full[key]["capital_gbp"], capital):
+ print(" ERROR: {} should use capital {}, got {}".format(key, capital, full[key]["capital_gbp"]))
+ failed = True
+ # no_pvbat 200000p - pv_only 130000p = 70000p = 700 GBP a year against PV-only capital
+ if not close(full["pv_only"]["annual_saving_gbp"], 700.0):
+ print(" ERROR: the PV-only saving should be 700 a year, got {}".format(full["pv_only"]["annual_saving_gbp"]))
+ failed = True
+
+ print("Test: the Predbat fee changes only the Predbat row")
+ paid = build_payback(scenarios, costs, 12, resolve_costs({"predbat_annual_gbp": 100}))
+ if not close(paid["pv_only"]["annual_saving_gbp"], full["pv_only"]["annual_saving_gbp"]):
+ print(" ERROR: the Predbat fee must not touch the PV-only row")
+ failed = True
+ if not close(paid["pv_battery"]["annual_saving_gbp"], full["pv_battery"]["annual_saving_gbp"]):
+ print(" ERROR: the Predbat fee must not touch the PV+battery row")
+ failed = True
+ if not close(paid["pv_battery_predbat"]["annual_saving_gbp"], full["pv_battery_predbat"]["annual_saving_gbp"] - 100.0):
+ print(" ERROR: the Predbat fee should reduce the Predbat row's saving by 100")
+ failed = True
+ if not close(paid["pv_battery_predbat"]["capital_gbp"], full["pv_battery_predbat"]["capital_gbp"]):
+ print(" ERROR: the Predbat fee is recurring and must not change capital")
+ failed = True
+
+ if failed:
+ print("**** ERROR: annual cost tests failed ****")
+ return failed
diff --git a/apps/predbat/tests/test_annual_integration.py b/apps/predbat/tests/test_annual_integration.py
new file mode 100644
index 000000000..3ba38874e
--- /dev/null
+++ b/apps/predbat/tests/test_annual_integration.py
@@ -0,0 +1,355 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Integration tests for the annual prediction day runner.
+
+These run real Predbat plans, so they are registered as slow.
+"""
+
+from datetime import date, datetime
+
+import pytz
+
+from annual import DAY_MINUTES, DEFAULT_CAR_RATE_KW, MAX_SESSIONS_PER_WEEK, PLAN_MINUTES, SCENARIO_FIELDS, SCENARIO_KEYS, _run_scenarios, car_charging_schedule, run_day, validate_config
+from annual_load import SyntheticLoadProfile
+from tests.test_infra import reset_inverter
+
+SOLAR_CURVE = [0, 0, 0, 0, 0, 0, 0.1, 0.3, 0.5, 0.7, 0.85, 0.95, 1.0, 0.95, 0.85, 0.7, 0.5, 0.3, 0.1, 0, 0, 0, 0, 0]
+
+
+class StubWeather:
+ """A WeatherYear stand-in with a fixed daily solar curve and a forecast multiplier."""
+
+ def __init__(self, peak_kw, forecast_multiplier=1.0, p10_ratio_value=0.8):
+ """Configure the actual peak power and how much the forecast overstates it."""
+ self.peak_kw = peak_kw
+ self.forecast_multiplier = forecast_multiplier
+ self.p10_ratio_value = p10_ratio_value
+
+ def _series(self, midnight_utc, minutes, scale):
+ """Build a per-minute kWh series from the fixed daily curve."""
+ result = {}
+ for minute in range(minutes):
+ hour = (minute // 60) % 24
+ result[minute] = (self.peak_kw * SOLAR_CURVE[hour] * scale) / 60.0
+ return result
+
+ def pv_minutes(self, series, midnight_utc, minutes):
+ """Return the actual or forecast per-minute series."""
+ return self._series(midnight_utc, minutes, 1.0 if series == "actual" else self.forecast_multiplier)
+
+ def pv_minutes_p10(self, midnight_utc, minutes, month):
+ """Return the P10 series, the forecast scaled by the month ratio."""
+ return self._series(midnight_utc, minutes, self.forecast_multiplier * self.p10_ratio_value)
+
+ def has_actual(self, day):
+ """Every day has data in this stub."""
+ return True
+
+ def daily_actual_kwh(self, day):
+ """Return the fixed daily total."""
+ return sum(self._series(None, DAY_MINUTES, 1.0).values())
+
+ def p10_ratio(self, month):
+ """Return the fixed P10 ratio."""
+ return self.p10_ratio_value
+
+
+class StubTariff:
+ """A tariff stand-in with a cheap overnight band and an expensive evening peak."""
+
+ def __init__(self, cheap=7.0, normal=28.0, peak=45.0, export=15.0):
+ """Configure the four rate levels."""
+ self.cheap = cheap
+ self.normal = normal
+ self.peak = peak
+ self.export = export
+ self.standing_charge_p_per_day = 50.0
+
+ def rates_for(self, midnight_utc, minutes):
+ """Return (import, export) rate dicts keyed by absolute minute."""
+ rate_import = {}
+ rate_export = {}
+ for minute in range(minutes):
+ in_day = minute % DAY_MINUTES
+ if 30 <= in_day < 330:
+ rate_import[minute] = self.cheap
+ elif 16 * 60 <= in_day < 19 * 60:
+ rate_import[minute] = self.peak
+ else:
+ rate_import[minute] = self.normal
+ rate_export[minute] = self.export
+ return rate_import, rate_export
+
+ def month_available(self, year, month):
+ """Always available."""
+ return True
+
+
+def make_config(with_car=False):
+ """Return a validated annual config for the integration tests."""
+ raw = {
+ "location": {"latitude": 51.5, "longitude": -0.1},
+ "solar": [{"kwp": 5.0}],
+ "battery": {"size_kwh": 10.0, "inverter_kw": 5.0},
+ "load": {"annual_kwh": 3800, "shape": "flat"},
+ "tariff": {"rates_import": [{"rate": 28.0}]},
+ "year": 2025,
+ }
+ if with_car:
+ raw["load"]["car_charging_kwh"] = 2500
+ return validate_config(raw, today=date(2026, 7, 25))
+
+
+def run_one(my_predbat, config, weather, day):
+ """Run all three scenarios for one day and return the results dict."""
+ reset_inverter(my_predbat)
+ midnight = pytz.utc.localize(datetime(day.year, day.month, day.day))
+ load_source = SyntheticLoadProfile(annual_kwh=config["load"]["annual_kwh"], shape=config["load"]["shape"], year=config["year"])
+ return run_day(my_predbat, config, weather, StubTariff(), load_source, day, midnight)
+
+
+def _count_calculate_plan_calls(my_predbat, action):
+ """Run ``action`` and return how many times it called ``calculate_plan``.
+
+ run_day() plans a sampled day once when no car is configured and twice - a with-car
+ leg and a without-car leg it blends against - when one is. Counting the calls is the
+ only externally visible way to tell those two paths apart, since the blended result
+ of two identical no-car legs would be indistinguishable from a single leg's.
+ """
+ calls = {"n": 0}
+ original = my_predbat.calculate_plan
+
+ def counting_calculate_plan(*args, **kwargs):
+ """Count the call, then delegate to the real planner."""
+ calls["n"] += 1
+ return original(*args, **kwargs)
+
+ my_predbat.calculate_plan = counting_calculate_plan
+ try:
+ action()
+ finally:
+ # my_predbat is the shared suite fixture, so the patch must not outlive this call
+ del my_predbat.calculate_plan
+ return calls["n"]
+
+
+def test_annual_integration(my_predbat):
+ """Verify scenario ordering, the forecast/actuals split, and state isolation."""
+ failed = False
+ print("**** Testing annual integration ****")
+
+ config = make_config()
+ weather = StubWeather(peak_kw=4.0)
+ day = date(2025, 5, 15)
+
+ print("Test: the three scenarios run and produce the expected keys")
+ results = run_one(my_predbat, config, weather, day)
+ for key in ["no_pvbat", "pv_only", "without_predbat", "with_predbat"]:
+ if key not in results:
+ print(" ERROR: missing scenario '{}'".format(key))
+ failed = True
+ continue
+ for field in ["cost_p", "import_kwh", "export_kwh", "pv_generated_kwh", "battery_throughput_kwh"]:
+ if field not in results[key]:
+ print(" ERROR: scenario '{}' is missing field '{}'".format(key, field))
+ failed = True
+
+ print("Test: predbat_cost <= without_predbat_cost <= no_pvbat_cost")
+ if not failed:
+ predbat_cost = results["with_predbat"]["cost_p"]
+ baseline_cost = results["without_predbat"]["cost_p"]
+ none_cost = results["no_pvbat"]["cost_p"]
+ if not predbat_cost <= baseline_cost + 1e-6:
+ print(" ERROR: Predbat cost {} should not exceed the dumb baseline {}".format(predbat_cost, baseline_cost))
+ failed = True
+ if not baseline_cost <= none_cost + 1e-6:
+ print(" ERROR: the PV/battery baseline {} should not exceed the no-system cost {}".format(baseline_cost, none_cost))
+ failed = True
+
+ print("Test: the no-PV/no-battery scenario generates nothing and stores nothing")
+ if results["no_pvbat"]["pv_generated_kwh"] != 0.0:
+ print(" ERROR: the no-system scenario should generate no PV, got {}".format(results["no_pvbat"]["pv_generated_kwh"]))
+ failed = True
+ if results["no_pvbat"]["battery_throughput_kwh"] != 0.0:
+ print(" ERROR: the no-system scenario should cycle no battery, got {}".format(results["no_pvbat"]["battery_throughput_kwh"]))
+ failed = True
+
+ print("Test: pv_only generates PV but stores none of it")
+ pv_only = results["pv_only"]
+ if pv_only["pv_generated_kwh"] <= 0:
+ print(" ERROR: the PV-only scenario should generate PV, got {}".format(pv_only["pv_generated_kwh"]))
+ failed = True
+ if pv_only["battery_throughput_kwh"] != 0:
+ print(" ERROR: the PV-only scenario must have no battery throughput, got {}".format(pv_only["battery_throughput_kwh"]))
+ failed = True
+
+ print("Test: pv_only sits between the no-system baseline and PV-plus-battery on cost")
+ # Not an arbitrary ordering check: PV alone must beat no system (it displaces import),
+ # and must not beat PV plus a battery (the battery can only add value). A violation
+ # means the scenario is not modelling what its name says.
+ if not (results["no_pvbat"]["cost_p"] > pv_only["cost_p"] > results["without_predbat"]["cost_p"]):
+ print(" ERROR: expected no_pvbat > pv_only > without_predbat on cost, got {} / {} / {}".format(results["no_pvbat"]["cost_p"], pv_only["cost_p"], results["without_predbat"]["cost_p"]))
+ failed = True
+
+ print("Test: Predbat is billed on actuals, not on the forecast it planned against")
+ # pv_generated_kwh is NOT proof the swap ran: _billed_result() reads it from the pv_step
+ # ARGUMENT run_day() passes in, which is always actual_step regardless of the swap, so it
+ # would still read correctly even if the swap back to actuals were deleted entirely - the
+ # check on it below is a sanity check only. The decisive, non-noise-sensitive check is
+ # structural: after run_day() returns, predbat.prediction is the exact object annual.py
+ # builds from actual_step immediately before costing scenario 3 (the "swap"), and
+ # run_prediction() (called from _billed_result) reads pv data from THIS object, not from
+ # whatever calculate_plan() searched against. So predbat.prediction.pv_forecast_minute_step
+ # must total the ACTUAL pv energy, not the (here, 3x inflated) forecast calculate_plan()
+ # was given - a plan-search-noise-proof check, since actual and 3x-inflated-forecast
+ # totals differ by a factor of three, not a fraction of a percent.
+ midnight = pytz.utc.localize(datetime(day.year, day.month, day.day))
+ actual_pv_total = sum(weather.pv_minutes("actual", midnight, PLAN_MINUTES).values())
+ honest_prediction_pv_total = sum(my_predbat.prediction.pv_forecast_minute_step.values())
+ if abs(honest_prediction_pv_total - actual_pv_total) > 0.5:
+ print(" ERROR: after an honest-forecast run, predbat.prediction.pv_forecast_minute_step should total the actual PV energy ({}), got {}".format(actual_pv_total, honest_prediction_pv_total))
+ failed = True
+
+ inflated = StubWeather(peak_kw=4.0, forecast_multiplier=3.0)
+ inflated_forecast_total = sum(inflated.pv_minutes("forecast", midnight, PLAN_MINUTES).values())
+ inflated_results = run_one(my_predbat, config, inflated, day)
+ inflated_prediction_pv_total = sum(my_predbat.prediction.pv_forecast_minute_step.values())
+ if abs(inflated_prediction_pv_total - actual_pv_total) > 0.5:
+ print(" ERROR: with a 3x inflated forecast, pv_forecast_minute_step should still total the actual PV energy ({}), got {} (forecast alone totals {})".format(actual_pv_total, inflated_prediction_pv_total, inflated_forecast_total))
+ failed = True
+ if abs(inflated_prediction_pv_total - inflated_forecast_total) < 1.0:
+ print(" ERROR: predbat.prediction.pv_forecast_minute_step matches the inflated FORECAST total ({}) rather than actuals ({}) - the swap back to actuals did not happen".format(inflated_forecast_total, actual_pv_total))
+ failed = True
+
+ honest_pv = results["with_predbat"]["pv_generated_kwh"]
+ inflated_pv = inflated_results["with_predbat"]["pv_generated_kwh"]
+ if abs(inflated_pv - honest_pv) > 0.01:
+ print(" ERROR: reported PV should track actuals ({}) regardless of the forecast, got {}".format(honest_pv, inflated_pv))
+ failed = True
+
+ print("Test: state isolation - a day run in isolation matches the same day run after another")
+ isolated = run_one(my_predbat, config, weather, day)
+ _ = run_one(my_predbat, config, StubWeather(peak_kw=1.0), date(2025, 11, 20))
+ after_other = run_one(my_predbat, config, weather, day)
+ for scenario in ["no_pvbat", "pv_only", "without_predbat", "with_predbat"]:
+ for field in ["cost_p", "import_kwh", "export_kwh", "battery_throughput_kwh"]:
+ first = isolated[scenario][field]
+ second = after_other[scenario][field]
+ if abs(first - second) > 1e-6:
+ print(" ERROR: {}.{} changed from {} to {} depending on what ran before it".format(scenario, field, first, second))
+ failed = True
+
+ print("Test: a config with no car plans a single leg (one calculate_plan call), not two")
+ # Guards run_day()'s dispatch: a config with no car must take the cheap, single-leg path
+ # rather than always running both the with-car and without-car legs.
+ plan_calls = _count_calculate_plan_calls(my_predbat, lambda: run_one(my_predbat, config, weather, day))
+ if plan_calls != 1:
+ print(" ERROR: a config with no car should call calculate_plan exactly once, got {}".format(plan_calls))
+ failed = True
+
+ print("Test: a car charging config plans TWICE (a with-car leg and a without-car leg) and blends the two")
+ car_config = make_config(with_car=True)
+ car_results = {}
+
+ def _run_car_config():
+ """Run the car-charging config for one day and record its blended result."""
+ car_results.update(run_one(my_predbat, car_config, weather, day))
+
+ plan_calls = _count_calculate_plan_calls(my_predbat, _run_car_config)
+ if plan_calls != 2:
+ print(" ERROR: a config with a car should call calculate_plan exactly twice (with-car and without-car legs), got {}".format(plan_calls))
+ failed = True
+
+ print("Test: a car charging config still produces an ordered result")
+ if car_results["with_predbat"]["cost_p"] > car_results["without_predbat"]["cost_p"] + 1e-6:
+ print(" ERROR: with a car, Predbat cost {} should not exceed the timer baseline {}".format(car_results["with_predbat"]["cost_p"], car_results["without_predbat"]["cost_p"]))
+ failed = True
+ if car_results["no_pvbat"]["import_kwh"] <= results["no_pvbat"]["import_kwh"]:
+ print(" ERROR: adding a car should raise the no-system import, got {} vs {}".format(car_results["no_pvbat"]["import_kwh"], results["no_pvbat"]["import_kwh"]))
+ failed = True
+
+ print("Test: the blended result equals f * with-car leg + (1 - f) * standalone no-car leg")
+ # What this covers: run_day()'s WIRING - that it feeds the two legs into the blend in
+ # the right order, with the right fraction, and mutates nothing else along the way.
+ # The expected value below is computed with explicit arithmetic rather than by calling
+ # _blend_results(), so a bug inside _blend_results() cannot cancel itself out across
+ # both sides of the comparison.
+ #
+ # What it does NOT cover, despite the tempting reading: leg contamination. The manual
+ # replay below runs the same two legs through the same code path in the same order as
+ # run_day() does, so a reset_sample_state() regression would corrupt both sides
+ # identically and stay inside tolerance. Regression cover for the car-field reset is
+ # the state-based assertion in test_annual_bootstrap.py; blend arithmetic on fabricated
+ # inputs is covered in test_annual_scenarios.py.
+ car_annual_kwh = car_config["load"]["car_charging_kwh"]
+ car_rate_kw = car_config["load"]["car_rate_kw"]
+ sessions_per_week, session_kwh = car_charging_schedule(car_annual_kwh, car_rate_kw)
+ fraction = sessions_per_week / float(MAX_SESSIONS_PER_WEEK)
+
+ reset_inverter(my_predbat)
+ midnight = pytz.utc.localize(datetime(day.year, day.month, day.day))
+ car_load_source = SyntheticLoadProfile(annual_kwh=car_config["load"]["annual_kwh"], shape=car_config["load"]["shape"], year=car_config["year"])
+ with_car_leg = _run_scenarios(my_predbat, car_config, weather, StubTariff(), car_load_source, day, midnight, car_kwh=session_kwh, car_rate_kw=car_rate_kw)
+ standalone_no_car_leg = _run_scenarios(my_predbat, car_config, weather, StubTariff(), car_load_source, day, midnight, car_kwh=0.0, car_rate_kw=car_rate_kw)
+
+ run_day_blend = run_one(my_predbat, car_config, weather, day)
+ for key in SCENARIO_KEYS:
+ for field in SCENARIO_FIELDS:
+ expected_value = fraction * with_car_leg[key][field] + (1.0 - fraction) * standalone_no_car_leg[key][field]
+ actual_value = run_day_blend[key][field]
+ if abs(expected_value - actual_value) > 1e-6:
+ print(" ERROR: blended {}.{} = {}, expected f * with_car_leg + (1 - f) * standalone_no_car_leg = {}".format(key, field, actual_value, expected_value))
+ failed = True
+
+ print("Test: capturing plans does not change the billed figures (save leaking into the numbers)")
+ # _billed_result() threads save="best" into the SAME run_prediction() call the annual
+ # engine already makes when a scenario's plan is captured, rather than running a second
+ # prediction. save only gates copying predict_*_best onto predbat, logging and dashboard
+ # writes - all of which happen AFTER the billed tuple has already been computed and
+ # returned - so it must never change the figures a scenario is billed for. This proves
+ # that by running the identical scenario twice back to back and diffing every field,
+ # rather than assuming save is side-effect-free on the numbers.
+ reset_inverter(my_predbat)
+ capture_midnight = pytz.utc.localize(datetime(day.year, day.month, day.day))
+ capture_load_source = SyntheticLoadProfile(annual_kwh=config["load"]["annual_kwh"], shape=config["load"]["shape"], year=config["year"])
+ without_capture = _run_scenarios(my_predbat, config, weather, StubTariff(), capture_load_source, day, capture_midnight, car_kwh=0.0, car_rate_kw=DEFAULT_CAR_RATE_KW)
+ reset_inverter(my_predbat)
+ plans = {}
+ with_capture = _run_scenarios(my_predbat, config, weather, StubTariff(), capture_load_source, day, capture_midnight, car_kwh=0.0, car_rate_kw=DEFAULT_CAR_RATE_KW, plans=plans)
+ for key in SCENARIO_KEYS:
+ for field in SCENARIO_FIELDS:
+ if without_capture[key][field] != with_capture[key][field]:
+ print(" ERROR: {}.{} = {} without capture but {} with capture - save is leaking into the billed numbers".format(key, field, without_capture[key][field], with_capture[key][field]))
+ failed = True
+
+ print("Test: capturing plans does not leak predbat.debug_enable on")
+ # The annual "debug" flag means "save the plan info", nothing more - it must never be
+ # wired to Predbat's own debug_enable, which kernel_supported() requires False to use
+ # the fast C++ prediction kernel. Leaving debug_enable True on the shared fixture has
+ # already caused one apparent hang (an 8x slowdown from every later plan falling back
+ # to pure Python), so this pins it off after a capturing run.
+ if my_predbat.debug_enable is not False:
+ print(" ERROR: predbat.debug_enable should still be False after a capturing run, got {!r}".format(my_predbat.debug_enable))
+ failed = True
+
+ print("Test: a scenario's captured plan reflects ITS OWN state, not stale state from another scenario")
+ # no_pvbat runs with soc_max=0 (see _run_scenarios()), so every row of ITS captured plan
+ # must show 0% SoC. If capture were reading leftover state from a battery scenario -
+ # for example because predbat.prediction or the *_best arrays were not refreshed before
+ # the capture - this would be non-zero, which is what makes the check discriminating.
+ if any(row.get("soc_percent", 0) != 0 for row in plans["no_pvbat"]["rows"]):
+ print(" ERROR: no_pvbat's captured rows should all show 0% SoC (soc_max=0), got at least one non-zero soc_percent")
+ failed = True
+ for key in SCENARIO_KEYS:
+ if not plans[key]["rows"]:
+ print(" ERROR: {}'s captured plan has no rows - the viewer would render 'No plan data available'".format(key))
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_job.py b/apps/predbat/tests/test_annual_job.py
new file mode 100644
index 000000000..6552776ac
--- /dev/null
+++ b/apps/predbat/tests/test_annual_job.py
@@ -0,0 +1,251 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the Annual tab's subprocess job control."""
+
+import asyncio
+import os
+import sys
+import time
+
+from annual_job import AnnualJob
+
+STUB = os.path.join(os.path.dirname(__file__), "annual_stub.py")
+
+
+def stub_command(mode):
+ """Return the argv for the stub child in the given mode."""
+ return [sys.executable, STUB, mode]
+
+
+async def run_to_completion(job, mode, timeout=20):
+ """Start the stub in the given mode and wait for the job to leave 'running'."""
+ started = await job.start(stub_command(mode))
+ waited = 0.0
+ while job.state == "running" and waited < timeout:
+ await asyncio.sleep(0.1)
+ waited += 0.1
+ return started
+
+
+def test_annual_job(my_predbat):
+ """Verify progress parsing, completion, failure, cancellation and refusal to double-run."""
+ failed = False
+ print("**** Testing annual_job ****")
+ messages = []
+
+ print("Test: a successful run parses progress and returns the results document")
+ job = AnnualJob(log=messages.append)
+ started = asyncio.run(run_to_completion(job, "ok"))
+ if not started:
+ print(" ERROR: start() should return True for a fresh job")
+ failed = True
+ if job.state != "complete":
+ print(" ERROR: expected state 'complete', got {} ({})".format(job.state, job.status().get("error")))
+ failed = True
+ if job.status().get("completed") != 3 or job.status().get("total") != 3:
+ print(" ERROR: final progress should be 3/3, got {}".format(job.status()))
+ failed = True
+ if (job.results or {}).get("year") != 2025:
+ print(" ERROR: the results document should be parsed from stdout, got {}".format(job.results))
+ failed = True
+ elapsed_first = job.status().get("elapsed")
+ # elapsed is int()-truncated to whole seconds, so a sleep shorter than a second
+ # (0.2s, say) usually lands inside the same truncated second and would pass this
+ # assertion even if the freeze were removed and elapsed kept advancing with wall
+ # time - discriminating only by luck, on whichever side of a second boundary the
+ # first read happened to fall. Sleeping past a full second guarantees at least
+ # one more second has elapsed on the wall clock than before, so a still-advancing
+ # elapsed is caught deterministically rather than only sometimes.
+ time.sleep(1.1)
+ elapsed_second = job.status().get("elapsed")
+ if elapsed_first != elapsed_second:
+ print(" ERROR: elapsed should freeze once a run is complete, got {} then {}".format(elapsed_first, elapsed_second))
+ failed = True
+
+ print("Test: a malformed progress line does not crash the parser")
+ job = AnnualJob(log=messages.append)
+ asyncio.run(run_to_completion(job, "garbage_progress"))
+ if job.state != "complete":
+ print(" ERROR: a garbage progress line should not fail the run, got {}".format(job.state))
+ failed = True
+ # The proof that the parser recovered is the line *after* the garbage one
+ # being applied - completed/total went from 0/0 to 1/1. Asserting on the
+ # terminal message instead would test message policy, not the parser, and
+ # a correct implementation is free to replace the last progress message
+ # with a generic "Complete" once the run finishes.
+ if job.status().get("completed") != 1 or job.status().get("total") != 1:
+ print(" ERROR: parsing should recover after a bad line, got {}".format(job.status()))
+ failed = True
+
+ print("Test: a non-zero exit is reported as failed, with the child's stderr kept")
+ job = AnnualJob(log=messages.append)
+ asyncio.run(run_to_completion(job, "fail"))
+ if job.state != "failed":
+ print(" ERROR: expected state 'failed', got {}".format(job.state))
+ failed = True
+ error_text = job.status().get("error") or ""
+ if "something went wrong" not in error_text:
+ print(" ERROR: the child's stderr should be reported, got {!r}".format(error_text))
+ failed = True
+ if "exited with code 3" not in error_text:
+ print(" ERROR: the exit code should be reported precisely, got {!r}".format(error_text))
+ failed = True
+
+ print("Test: unparseable stdout is reported as failed rather than a silent empty result")
+ job = AnnualJob(log=messages.append)
+ asyncio.run(run_to_completion(job, "bad_output"))
+ if job.state != "failed":
+ print(" ERROR: unparseable output should fail the run, got {}".format(job.state))
+ failed = True
+ if job.results is not None:
+ print(" ERROR: no results should be exposed after a parse failure, got {}".format(job.results))
+ failed = True
+
+ print("Test: valid JSON that is not an object is reported as failed, not a silent empty result")
+ job = AnnualJob(log=messages.append)
+ asyncio.run(run_to_completion(job, "null_output"))
+ if job.state != "failed":
+ print(" ERROR: a non-object results document should fail the run, got {}".format(job.state))
+ failed = True
+ if job.results is not None:
+ print(" ERROR: no results should be exposed after a non-object parse, got {}".format(job.results))
+ failed = True
+
+ print("Test: streams bigger than a pipe buffer on both stdout and stderr do not deadlock")
+ job = AnnualJob(log=messages.append)
+ asyncio.run(run_to_completion(job, "big_streams", timeout=30))
+ if job.state != "complete":
+ print(" ERROR: a large-output run should still complete, got {} ({})".format(job.state, job.status().get("error")))
+ failed = True
+ if (job.results or {}).get("year") != 2025:
+ print(" ERROR: the large results document should still be parsed, got keys {}".format(list((job.results or {}).keys())))
+ failed = True
+
+ print("Test: a second start while running is refused, and cancel stops and reaps the child")
+
+ async def double_start_then_cancel():
+ """Start a hanging child, try to start another, then cancel."""
+ job = AnnualJob(log=messages.append)
+ first = await job.start(stub_command("hang"))
+ await asyncio.sleep(0.5)
+ second = await job.start(stub_command("hang"))
+ cancelled = await job.cancel()
+ waited = 0.0
+ while job.state == "running" and waited < 10:
+ await asyncio.sleep(0.1)
+ waited += 0.1
+ return first, second, cancelled, job
+
+ first, second, cancelled, job = asyncio.run(double_start_then_cancel())
+ if not first:
+ print(" ERROR: the first start should succeed")
+ failed = True
+ if second:
+ print(" ERROR: a second start while running must be refused")
+ failed = True
+ if not cancelled:
+ print(" ERROR: cancel should report that it acted")
+ failed = True
+ if job.state != "cancelled":
+ print(" ERROR: expected state 'cancelled', got {}".format(job.state))
+ failed = True
+ # Proof that the child was actually reaped, not just that the state string
+ # was set: `state` is assigned synchronously before terminate() is even
+ # called, so checking `state` alone would pass even if cancel() never
+ # touched the process.
+ if job._process is None or job._process.returncode is None:
+ print(" ERROR: cancel should have reaped the child, but returncode is {}".format(job._process and job._process.returncode))
+ failed = True
+
+ print("Test: a run started immediately after cancelling is not clobbered by the old supervisor")
+
+ async def cancel_then_restart_immediately():
+ """Cancel a hanging child, then start a fresh run before the old supervisor has drained its pipes."""
+ job = AnnualJob(log=messages.append)
+ await job.start(stub_command("hang"))
+ await asyncio.sleep(0.2)
+ await job.cancel()
+ # No delay here: the old supervisor task is still scheduled to run and
+ # may not have observed the cancellation yet - that overlap is exactly
+ # what let a stale supervisor stamp its state over a new run.
+ started = await job.start(stub_command("ok"))
+ waited = 0.0
+ while job.state == "running" and waited < 20:
+ await asyncio.sleep(0.1)
+ waited += 0.1
+ return started, job
+
+ restart_started, restart_job = asyncio.run(cancel_then_restart_immediately())
+ if not restart_started:
+ print(" ERROR: starting again immediately after cancel should succeed")
+ failed = True
+ if restart_job.state != "complete":
+ print(" ERROR: the new run should complete cleanly, got {} ({})".format(restart_job.state, restart_job.status().get("error")))
+ failed = True
+ if (restart_job.results or {}).get("year") != 2025:
+ print(" ERROR: the new run's results must not be clobbered by the superseded supervisor, got {}".format(restart_job.results))
+ failed = True
+
+ print("Test: cancelling an already-cancelled job is a no-op that reports nothing to act on")
+ second_cancel = asyncio.run(job.cancel())
+ if second_cancel:
+ print(" ERROR: cancelling a job that is not running should return False, got {}".format(second_cancel))
+ failed = True
+
+ print("Test: cancelling an idle job reports nothing to act on")
+ idle_job = AnnualJob(log=messages.append)
+ idle_cancel = asyncio.run(idle_job.cancel())
+ if idle_cancel:
+ print(" ERROR: cancelling an idle job should return False, got {}".format(idle_cancel))
+ failed = True
+
+ print("Test: a bad command is a start failure, not a silent no-op")
+
+ async def failed_start():
+ """Try to start a command that cannot be executed at all."""
+ job = AnnualJob(log=messages.append)
+ started = await job.start(["/nonexistent/predbat-annual-stub-does-not-exist"])
+ return job, started
+
+ bad_job, bad_started = asyncio.run(failed_start())
+ if bad_started:
+ print(" ERROR: starting a non-existent command should return False")
+ failed = True
+ if bad_job.state != "failed":
+ print(" ERROR: a start failure should leave the job in state 'failed', got {}".format(bad_job.state))
+ failed = True
+ if not any("Could not start the annual run" in message for message in messages):
+ print(" ERROR: a start failure should be logged, got {}".format(messages))
+ failed = True
+
+ print("Test: a fresh job reports idle with no results")
+ job = AnnualJob(log=messages.append)
+ if job.state != "idle" or job.results is not None:
+ print(" ERROR: a fresh job should be idle with no results, got {} / {}".format(job.state, job.results))
+ failed = True
+ if job.status().get("elapsed") != 0:
+ print(" ERROR: an idle job should report zero elapsed, got {}".format(job.status()))
+ failed = True
+
+ print("Test: a job that has already completed can be started again")
+ reused_job = AnnualJob(log=messages.append)
+ asyncio.run(run_to_completion(reused_job, "ok"))
+ if reused_job.state != "complete":
+ print(" ERROR: the first run on the reused job should complete, got {}".format(reused_job.state))
+ failed = True
+ second_started = asyncio.run(run_to_completion(reused_job, "ok"))
+ if not second_started:
+ print(" ERROR: starting again after completion should succeed")
+ failed = True
+ if reused_job.state != "complete":
+ print(" ERROR: the second run should also complete, got {}".format(reused_job.state))
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_load.py b/apps/predbat/tests/test_annual_load.py
new file mode 100644
index 000000000..98f1f40c1
--- /dev/null
+++ b/apps/predbat/tests/test_annual_load.py
@@ -0,0 +1,235 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the annual prediction load profile sources."""
+
+import calendar
+from datetime import date
+
+from annual_load import OctopusConsumptionLoadProfile, SyntheticLoadProfile, build_load_forecast, parse_consumption_results, tilt_shape
+from annual_profiles import DAY_BAND_SLOTS, NIGHT_BAND_SLOTS, half_hour_shape
+from tests.test_infra import run_async
+
+
+class FakeAnnualStorage:
+ """Minimal in-memory storage stub with async save/load, used to prove nothing is cached on a truncated download."""
+
+ def __init__(self):
+ """Start with an empty in-memory store and no recorded saves."""
+ self.store = {}
+ self.saved = False
+
+ async def load(self, namespace, key):
+ """Return the previously saved value for a key, or None if nothing was saved."""
+ return self.store.get((namespace, key))
+
+ async def save(self, namespace, key, value, format="json"):
+ """Record that a save happened and remember the value under the given key."""
+ self.saved = True
+ self.store[(namespace, key)] = value
+
+
+def test_annual_load(my_predbat):
+ """Verify the synthetic load profile preserves totals and tilts correctly."""
+ failed = False
+ print("**** Testing annual_load ****")
+
+ print("Test: tilt_shape preserves the daily total exactly")
+ base = half_hour_shape()
+ for direction in ["night", "day", "flat"]:
+ tilted = tilt_shape(base, direction)
+ total = sum(tilted)
+ if abs(total - 1.0) > 1e-9:
+ print(" ERROR: tilt '{}' changed the total to {}".format(direction, total))
+ failed = True
+ if any(value < 0 for value in tilted):
+ print(" ERROR: tilt '{}' produced a negative slot".format(direction))
+ failed = True
+
+ print("Test: tilt 'night' moves energy into the night band")
+ night_tilted = tilt_shape(base, "night")
+ base_night = sum(base[slot] for slot in NIGHT_BAND_SLOTS)
+ tilted_night = sum(night_tilted[slot] for slot in NIGHT_BAND_SLOTS)
+ if tilted_night <= base_night:
+ print(" ERROR: night tilt should raise the night band from {} to more, got {}".format(base_night, tilted_night))
+ failed = True
+
+ print("Test: tilt 'day' moves energy into the day band")
+ day_tilted = tilt_shape(base, "day")
+ base_day = sum(base[slot] for slot in DAY_BAND_SLOTS)
+ tilted_day = sum(day_tilted[slot] for slot in DAY_BAND_SLOTS)
+ if tilted_day <= base_day:
+ print(" ERROR: day tilt should raise the day band from {} to more, got {}".format(base_day, tilted_day))
+ failed = True
+
+ print("Test: tilt 'flat' is a no-op")
+ flat_tilted = tilt_shape(base, "flat")
+ if flat_tilted != base:
+ print(" ERROR: flat tilt should leave the shape unchanged")
+ failed = True
+
+ print("Test: the twelve monthly totals sum to annual_kwh")
+ annual_kwh = 3800.0
+ source = SyntheticLoadProfile(annual_kwh=annual_kwh, shape="flat", year=2025)
+ year_total = 0.0
+ for month in range(1, 13):
+ days_in_month = calendar.monthrange(2025, month)[1]
+ month_total = sum(source.daily_kwh(date(2025, month, day)) for day in range(1, days_in_month + 1))
+ year_total += month_total
+ if abs(year_total - annual_kwh) > 1e-6:
+ print(" ERROR: twelve months summed to {}, expected {}".format(year_total, annual_kwh))
+ failed = True
+
+ print("Test: January daily consumption exceeds July")
+ january = source.daily_kwh(date(2025, 1, 15))
+ july = source.daily_kwh(date(2025, 7, 15))
+ if january <= july:
+ print(" ERROR: January daily {} should exceed July daily {}".format(january, july))
+ failed = True
+
+ print("Test: minute_profile has 1440 entries summing to the day's kWh")
+ day = date(2025, 3, 10)
+ profile = source.minute_profile(day)
+ if len(profile) != 1440:
+ print(" ERROR: expected 1440 minutes, got {}".format(len(profile)))
+ failed = True
+ if abs(sum(profile) - source.daily_kwh(day)) > 1e-9:
+ print(" ERROR: minute profile sums to {}, expected {}".format(sum(profile), source.daily_kwh(day)))
+ failed = True
+
+ print("Test: build_load_forecast produces a cumulative series Predbat can difference")
+ forecast = build_load_forecast(source, date(2025, 3, 10), 2)
+ if forecast.get(0) != 0.0:
+ print(" ERROR: cumulative series must start at 0, got {}".format(forecast.get(0)))
+ failed = True
+ if 2 * 1440 not in forecast:
+ print(" ERROR: cumulative series must include the final boundary minute {}".format(2 * 1440))
+ failed = True
+ for minute in range(1, 2 * 1440 + 1):
+ if forecast[minute] < forecast[minute - 1] - 1e-12:
+ print(" ERROR: cumulative series decreased at minute {}".format(minute))
+ failed = True
+ break
+ expected_two_days = source.daily_kwh(date(2025, 3, 10)) + source.daily_kwh(date(2025, 3, 11))
+ if abs(forecast[2 * 1440] - expected_two_days) > 1e-9:
+ print(" ERROR: two-day total {} expected {}".format(forecast[2 * 1440], expected_two_days))
+ failed = True
+
+ print("Test: differencing the cumulative series recovers the per-minute profile")
+ first_minute = forecast[1] - forecast[0]
+ if abs(first_minute - profile[0]) > 1e-12:
+ print(" ERROR: differenced minute 0 gave {}, expected {}".format(first_minute, profile[0]))
+ failed = True
+
+ print("Test: a zero annual figure produces a zero profile rather than dividing by zero")
+ zero_source = SyntheticLoadProfile(annual_kwh=0.0, shape="flat", year=2025)
+ if sum(zero_source.minute_profile(day)) != 0.0:
+ print(" ERROR: zero annual kWh should give a zero profile")
+ failed = True
+
+ return failed
+
+
+def test_annual_load_octopus(my_predbat):
+ """Verify Octopus consumption parsing and its fallback behaviour."""
+ failed = False
+ print("**** Testing annual_load Octopus source ****")
+
+ print("Test: parse_consumption_results maps half-hourly readings onto dates")
+ results = []
+ for slot in range(48):
+ hour = slot // 2
+ minute = 30 * (slot % 2)
+ results.append(
+ {
+ "consumption": 0.25,
+ "interval_start": "2025-03-10T{:02d}:{:02d}:00Z".format(hour, minute),
+ "interval_end": "2025-03-10T{:02d}:{:02d}:00Z".format(hour, minute),
+ }
+ )
+ parsed = parse_consumption_results(results)
+ target = date(2025, 3, 10)
+ if target not in parsed:
+ print(" ERROR: expected {} in parsed output, got {}".format(target, list(parsed.keys())))
+ failed = True
+ elif len(parsed[target]) != 48:
+ print(" ERROR: expected 48 slots, got {}".format(len(parsed[target])))
+ failed = True
+ elif abs(sum(parsed[target]) - 12.0) > 1e-9:
+ print(" ERROR: expected 12.0 kWh for the day, got {}".format(sum(parsed[target])))
+ failed = True
+
+ print("Test: a partial day is reported as missing rather than silently understated")
+ partial = parse_consumption_results(results[:20])
+ if date(2025, 3, 10) in partial:
+ print(" ERROR: a day with only 20 of 48 slots must not be returned as complete")
+ failed = True
+
+ print("Test: minute_profile falls back to the synthetic source for a missing day")
+ fallback = SyntheticLoadProfile(annual_kwh=3800.0, shape="flat", year=2025)
+ source = OctopusConsumptionLoadProfile(api_key="x", account_id="A-1", log=print, fallback=fallback)
+ source.consumption = parse_consumption_results(results)
+
+ present = source.minute_profile(date(2025, 3, 10))
+ if abs(sum(present) - 12.0) > 1e-9:
+ print(" ERROR: present day should use real data summing to 12.0, got {}".format(sum(present)))
+ failed = True
+
+ absent = source.minute_profile(date(2025, 3, 11))
+ if abs(sum(absent) - fallback.daily_kwh(date(2025, 3, 11))) > 1e-9:
+ print(" ERROR: missing day should fall back to synthetic, got {}".format(sum(absent)))
+ failed = True
+ if date(2025, 3, 11) not in source.missing_days:
+ print(" ERROR: a fallback day must be recorded in missing_days")
+ failed = True
+
+ print("Test: no fallback and no data yields None so the caller can exclude the day")
+ bare = OctopusConsumptionLoadProfile(api_key="x", account_id="A-1", log=print)
+ if bare.minute_profile(date(2025, 3, 11)) is not None:
+ print(" ERROR: with no data and no fallback minute_profile must return None")
+ failed = True
+
+ print("Test: daily_kwh records a missing day in missing_days, consistent with minute_profile")
+ bare_daily = OctopusConsumptionLoadProfile(api_key="x", account_id="A-1", log=print)
+ bare_daily.daily_kwh(date(2025, 3, 12))
+ if date(2025, 3, 12) not in bare_daily.missing_days:
+ print(" ERROR: daily_kwh must record a missing day in missing_days just like minute_profile does")
+ failed = True
+
+ print("Test: a slot reported twice (e.g. the autumn clock-change day) discards that day entirely")
+ duplicate_slot_results = list(results) + [dict(results[0])]
+ duplicate_slot_parsed = parse_consumption_results(duplicate_slot_results)
+ if date(2025, 3, 10) in duplicate_slot_parsed:
+ print(" ERROR: a day with a slot reported twice must be discarded, not silently undercounted")
+ failed = True
+
+ print("Test: a page request failing mid-download is not cached and fetch() reports failure")
+ calls = []
+
+ async def fake_get_json(session, url):
+ """Return a canned meter resolution, one good consumption page with a next link, then a failure."""
+ calls.append(url)
+ if len(calls) == 1:
+ return {"properties": [{"electricity_meter_points": [{"mpan": "1200000000000", "is_export": False, "meters": [{"serial_number": "S1"}]}]}]}
+ if len(calls) == 2:
+ return {"results": results, "next": "https://api.octopus.energy/v1/electricity-meter-points/x/meters/y/consumption/?page=2"}
+ return None
+
+ fake_storage = FakeAnnualStorage()
+ truncated_source = OctopusConsumptionLoadProfile(api_key="x", account_id="A-2", log=print, storage=fake_storage)
+ truncated_source._get_json = fake_get_json
+ fetch_result = run_async(truncated_source.fetch(2025))
+ if fetch_result is not False:
+ print(" ERROR: a download that fails mid-pagination must return False, got {}".format(fetch_result))
+ failed = True
+ if fake_storage.saved:
+ print(" ERROR: a truncated download must not be written to storage")
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_profiles.py b/apps/predbat/tests/test_annual_profiles.py
new file mode 100644
index 000000000..161bb7c22
--- /dev/null
+++ b/apps/predbat/tests/test_annual_profiles.py
@@ -0,0 +1,72 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the annual prediction load profile data tables."""
+
+from annual_profiles import DAY_BAND_SLOTS, HOURLY_SHAPE, MONTH_WEIGHTS, NIGHT_BAND_SLOTS, SHAPE_TILT_FRACTION, half_hour_shape
+
+
+def test_annual_profiles(my_predbat):
+ """Verify the profile tables are well formed and normalise correctly."""
+ failed = False
+ print("**** Testing annual_profiles ****")
+
+ print("Test: HOURLY_SHAPE has 24 positive entries")
+ if len(HOURLY_SHAPE) != 24:
+ print(" ERROR: expected 24 hourly weights, got {}".format(len(HOURLY_SHAPE)))
+ failed = True
+ if any(value <= 0 for value in HOURLY_SHAPE):
+ print(" ERROR: all hourly weights must be positive")
+ failed = True
+
+ print("Test: half_hour_shape returns 48 values summing to 1.0")
+ shape = half_hour_shape()
+ if len(shape) != 48:
+ print(" ERROR: expected 48 half-hourly values, got {}".format(len(shape)))
+ failed = True
+ total = sum(shape)
+ if abs(total - 1.0) > 1e-9:
+ print(" ERROR: half_hour_shape must sum to 1.0, got {}".format(total))
+ failed = True
+
+ print("Test: the evening peak exceeds the overnight trough")
+ evening = sum(shape[36:42])
+ overnight = sum(shape[4:10])
+ if evening <= overnight:
+ print(" ERROR: evening 18:00-21:00 share {} should exceed overnight 02:00-05:00 share {}".format(evening, overnight))
+ failed = True
+
+ print("Test: MONTH_WEIGHTS has 12 positive entries with winter above summer")
+ if len(MONTH_WEIGHTS) != 12:
+ print(" ERROR: expected 12 month weights, got {}".format(len(MONTH_WEIGHTS)))
+ failed = True
+ if any(value <= 0 for value in MONTH_WEIGHTS):
+ print(" ERROR: all month weights must be positive")
+ failed = True
+ if MONTH_WEIGHTS[0] <= MONTH_WEIGHTS[6]:
+ print(" ERROR: January weight {} should exceed July weight {}".format(MONTH_WEIGHTS[0], MONTH_WEIGHTS[6]))
+ failed = True
+
+ print("Test: the night and day bands are disjoint and correctly sized")
+ if NIGHT_BAND_SLOTS != list(range(0, 14)):
+ print(" ERROR: NIGHT_BAND_SLOTS should cover 00:00-07:00, got {}".format(NIGHT_BAND_SLOTS))
+ failed = True
+ if DAY_BAND_SLOTS != list(range(14, 40)):
+ print(" ERROR: DAY_BAND_SLOTS should cover 07:00-20:00, got {}".format(DAY_BAND_SLOTS))
+ failed = True
+ if set(NIGHT_BAND_SLOTS) & set(DAY_BAND_SLOTS):
+ print(" ERROR: night and day bands must be disjoint")
+ failed = True
+
+ print("Test: SHAPE_TILT_FRACTION is a sane proportion")
+ if not 0.0 < SHAPE_TILT_FRACTION < 0.5:
+ print(" ERROR: SHAPE_TILT_FRACTION should be between 0 and 0.5, got {}".format(SHAPE_TILT_FRACTION))
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_results.py b/apps/predbat/tests/test_annual_results.py
new file mode 100644
index 000000000..2b1d5c9b5
--- /dev/null
+++ b/apps/predbat/tests/test_annual_results.py
@@ -0,0 +1,432 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Unit tests for assembling the annual prediction results document.
+
+Covers ``AnnualPredictor._build_results``, ``AnnualPredictor._month_scenarios`` and
+``average_rate`` against hand-built month rows and samples. None of this touches the
+network, a Predbat instance or a real plan run, so it is fast and registered as a
+non-slow test - unlike ``test_annual_integration``, which needs a real day run to
+exercise this same code from the top.
+"""
+
+import inspect
+from datetime import date
+
+from annual import SCENARIO_FIELDS, SCENARIO_KEYS, AnnualPredictor, _billed_result, _capture_plan, _run_scenarios, average_rate, run_day, validate_config
+from prediction import Prediction
+from tests.test_infra import reset_inverter
+
+
+def base_config():
+ """Return the same minimal valid annual config literal ``make_predictor()`` uses.
+
+ Kept as a standalone helper (rather than only living inside ``make_predictor()``) so
+ tests that need the raw dict itself - for example to pass through ``validate_config()``
+ directly - do not have to invent their own values.
+ """
+ return {
+ "location": {"latitude": 51.5, "longitude": -0.1},
+ "solar": [{"kwp": 5.0}],
+ "battery": {"size_kwh": 10.0, "inverter_kw": 5.0},
+ "load": {"annual_kwh": 3800, "shape": "flat"},
+ "tariff": {"rates_import": [{"rate": 28.0}]},
+ "year": 2025,
+ }
+
+
+def make_predictor():
+ """Return an ``AnnualPredictor`` built from a minimal valid config.
+
+ ``AnnualPredictor.__init__`` validates the config itself and touches no network or
+ Predbat state, so this is safe to call directly in a unit test.
+ """
+ return AnnualPredictor(base_config())
+
+
+def make_month_row(month, costs, status="ok"):
+ """Build a month row with the given status and per-scenario cost_p values.
+
+ Every other scenario field is fixed at 1.0 (or 0.5 for the derived export credit) so
+ the totals in each test are easy to predict by hand.
+ """
+ scenarios = {}
+ for key in SCENARIO_KEYS:
+ entry = {field: 1.0 for field in SCENARIO_FIELDS}
+ entry["cost_p"] = costs[key]
+ entry["export_credit_p_estimate"] = 0.5
+ scenarios[key] = entry
+ row = {"month": month, "status": status, "days": 30, "standing_charge_p": 100.0, "scenarios": scenarios}
+ if status == "degraded":
+ row["failed_days"] = ["2025-{:02d}-15".format(month)]
+ return row
+
+
+def make_unavailable_row(month, reason="no rate data available"):
+ """Build an 'unavailable' month row, as run() emits when a month has no usable result."""
+ return {"month": month, "status": "unavailable", "reason": reason, "days": 30, "standing_charge_p": 15.0}
+
+
+def test_annual_results(my_predbat):
+ """Verify _build_results, _month_scenarios and average_rate against hand-built inputs."""
+ failed = False
+ print("**** Testing annual results assembly ****")
+
+ print("Test: _build_results with every month ok sums totals across all months")
+ predictor = make_predictor()
+ months = [
+ make_month_row(1, {"no_pvbat": 100.0, "pv_only": 75.0, "without_predbat": 50.0, "with_predbat": 20.0}),
+ make_month_row(2, {"no_pvbat": 200.0, "pv_only": 140.0, "without_predbat": 80.0, "with_predbat": 30.0}),
+ ]
+ result = predictor._build_results(months)
+ if result["annual"]["scenarios"]["no_pvbat"]["cost_p"] != 300.0:
+ print(" ERROR: expected no_pvbat annual cost_p 300.0, got {}".format(result["annual"]["scenarios"]["no_pvbat"]["cost_p"]))
+ failed = True
+ if result["annual"]["scenarios"]["with_predbat"]["cost_p"] != 50.0:
+ print(" ERROR: expected with_predbat annual cost_p 50.0, got {}".format(result["annual"]["scenarios"]["with_predbat"]["cost_p"]))
+ failed = True
+ if result["annual"]["months_included"] != 2 or result["annual"]["months_excluded"] != []:
+ print(" ERROR: expected 2 months included and none excluded, got {} / {}".format(result["annual"]["months_included"], result["annual"]["months_excluded"]))
+ failed = True
+ expected_saving = 300.0 - 130.0
+ if result["annual"]["savings"]["pv_battery_vs_none_p"] != expected_saving:
+ print(" ERROR: expected pv_battery_vs_none_p {}, got {}".format(expected_saving, result["annual"]["savings"]["pv_battery_vs_none_p"]))
+ failed = True
+
+ print("Test: _build_results with a mix of ok and unavailable months excludes the unavailable one")
+ predictor = make_predictor()
+ months = [
+ make_month_row(1, {"no_pvbat": 100.0, "pv_only": 75.0, "without_predbat": 50.0, "with_predbat": 20.0}),
+ make_unavailable_row(2),
+ make_month_row(3, {"no_pvbat": 40.0, "pv_only": 25.0, "without_predbat": 10.0, "with_predbat": 5.0}),
+ ]
+ result = predictor._build_results(months)
+ if result["annual"]["scenarios"]["no_pvbat"]["cost_p"] != 140.0:
+ print(" ERROR: expected no_pvbat annual cost_p 140.0 (month 2 excluded), got {}".format(result["annual"]["scenarios"]["no_pvbat"]["cost_p"]))
+ failed = True
+ if result["annual"]["months_included"] != 2 or result["annual"]["months_excluded"] != [2]:
+ print(" ERROR: expected 2 months included and month 2 excluded, got {} / {}".format(result["annual"]["months_included"], result["annual"]["months_excluded"]))
+ failed = True
+
+ print("Test: _build_results includes a 'degraded' month (partial-sample) in totals, not excluded")
+ predictor = make_predictor()
+ months = [make_month_row(4, {"no_pvbat": 60.0, "pv_only": 42.5, "without_predbat": 25.0, "with_predbat": 10.0}, status="degraded")]
+ result = predictor._build_results(months)
+ if result["annual"]["months_included"] != 1 or result["annual"]["months_excluded"] != []:
+ print(" ERROR: expected the degraded month to be included, not excluded, got included={} excluded={}".format(result["annual"]["months_included"], result["annual"]["months_excluded"]))
+ failed = True
+ if result["annual"]["scenarios"]["no_pvbat"]["cost_p"] != 60.0:
+ print(" ERROR: expected the degraded month's totals to be counted, got {}".format(result["annual"]["scenarios"]["no_pvbat"]["cost_p"]))
+ failed = True
+
+ print("Test: _build_results with every month unavailable does not fabricate a zero-cost year")
+ predictor = make_predictor()
+ months = [make_unavailable_row(1), make_unavailable_row(2, reason="no usable weather days")]
+ result = predictor._build_results(months)
+ if result["annual"]["scenarios"] is not None:
+ print(" ERROR: expected annual scenarios to be None when nothing is included, got {}".format(result["annual"]["scenarios"]))
+ failed = True
+ if result["annual"]["standing_charge_p"] is not None:
+ print(" ERROR: expected annual standing_charge_p to be None when nothing is included, got {}".format(result["annual"]["standing_charge_p"]))
+ failed = True
+ if result["annual"]["savings"] != {}:
+ print(" ERROR: expected empty savings when nothing is included, got {}".format(result["annual"]["savings"]))
+ failed = True
+ if result["annual"]["months_included"] != 0 or result["annual"]["months_excluded"] != [1, 2]:
+ print(" ERROR: expected 0 months included and both excluded, got {} / {}".format(result["annual"]["months_included"], result["annual"]["months_excluded"]))
+ failed = True
+ if not any("no month produced a usable result" in caveat.lower() for caveat in result["caveats"]):
+ print(" ERROR: expected a caveat explaining that no month produced a usable result, got {}".format(result["caveats"]))
+ failed = True
+
+ print("Test: _month_scenarios weights uneven samples correctly")
+ predictor = make_predictor()
+ samples = [(date(2025, 1, 5), 3), (date(2025, 1, 20), 5)]
+ day_results = [
+ {key: {field: (10.0 if key == "no_pvbat" else 2.0) for field in SCENARIO_FIELDS} for key in SCENARIO_KEYS},
+ {key: {field: (2.0 if key == "no_pvbat" else 1.0) for field in SCENARIO_FIELDS} for key in SCENARIO_KEYS},
+ ]
+ totals = predictor._month_scenarios(samples, day_results)
+ expected_no_pvbat = 10.0 * 3 + 2.0 * 5
+ if totals["no_pvbat"]["cost_p"] != expected_no_pvbat:
+ print(" ERROR: expected weighted no_pvbat cost_p {}, got {}".format(expected_no_pvbat, totals["no_pvbat"]["cost_p"]))
+ failed = True
+ expected_with_predbat = 2.0 * 3 + 1.0 * 5
+ if totals["with_predbat"]["import_kwh"] != expected_with_predbat:
+ print(" ERROR: expected weighted with_predbat import_kwh {}, got {}".format(expected_with_predbat, totals["with_predbat"]["import_kwh"]))
+ failed = True
+
+ print("Test: a degraded month's survivor is reweighted to represent the full month, not its original share")
+ # select_samples() would have given each of an original two samples in a 31 day month a
+ # weight of 31/2 = 15.5. One of them failed run_day() and was dropped. Naively summing the
+ # survivor at its original 15.5 weight would silently under-count the month by half; the
+ # fix must recompute the survivor's weight from the surviving count (31/1 = 31) so the
+ # month's total still represents all 31 days, using the ONE plan that actually ran.
+ predictor = make_predictor()
+ original_samples = [(date(2025, 3, 10), 15.5), (date(2025, 3, 24), 15.5)]
+ surviving_samples = original_samples[:1]
+ day_result = {key: {field: 2.0 for field in SCENARIO_FIELDS} for key in SCENARIO_KEYS}
+ reweighted_samples = predictor._reweight_survivors(surviving_samples, days_in_month=31)
+ totals = predictor._month_scenarios(reweighted_samples, [day_result])
+ expected_reweighted = 2.0 * 31
+ wrong_if_unweighted = 2.0 * 15.5
+ if totals["no_pvbat"]["cost_p"] != expected_reweighted:
+ print(" ERROR: expected the degraded month's total to be the survivor's figure times the full 31 days ({}), got {} (original-weight total would have been {})".format(expected_reweighted, totals["no_pvbat"]["cost_p"], wrong_if_unweighted))
+ failed = True
+
+ print("Test: average_rate")
+ full_rates = {0: 10.0, 1: 20.0, 2: 30.0}
+ if average_rate(full_rates, 3) != 20.0:
+ print(" ERROR: expected average_rate of a full dict to be 20.0, got {}".format(average_rate(full_rates, 3)))
+ failed = True
+ partial_rates = {0: 10.0}
+ if average_rate(partial_rates, 3) != 10.0:
+ print(" ERROR: expected average_rate to ignore minutes missing from the dict, got {}".format(average_rate(partial_rates, 3)))
+ failed = True
+ if average_rate({}, 10) != 0.0:
+ print(" ERROR: expected average_rate of an empty dict to be 0.0 rather than raising, got {}".format(average_rate({}, 10)))
+ failed = True
+
+ print("Test: _synthesised_sides filters a run-wide fallback_months set down to the one requested month")
+ fallback_months = {(2025, 3, "export"), (2025, 3, "import"), (2025, 4, "export"), (2024, 3, "export")}
+ sides = predictor._synthesised_sides(fallback_months, 2025, 3)
+ if sides != ["export", "import"]:
+ print(" ERROR: expected ['export', 'import'] for (2025, 3), got {}".format(sides))
+ failed = True
+ if predictor._synthesised_sides(fallback_months, 2025, 5) != []:
+ print(" ERROR: a month with no fallback entries should return an empty list, got {}".format(predictor._synthesised_sides(fallback_months, 2025, 5)))
+ failed = True
+
+ print("Test: _tariff_fallback_caveats reports nothing when the tariff needed neither fallback")
+ if AnnualPredictor._tariff_fallback_caveats(set(), set(), 2025) != []:
+ print(" ERROR: expected no caveats when both sets are empty, got {}".format(AnnualPredictor._tariff_fallback_caveats(set(), set(), 2025)))
+ failed = True
+
+ print("Test: _tariff_fallback_caveats names the affected months for a current-rates fallback")
+ caveats = AnnualPredictor._tariff_fallback_caveats({(2025, 7, "import"), (2025, 7, "export")}, set(), 2025)
+ if len(caveats) != 1:
+ print(" ERROR: expected exactly one caveat for a fallback-only case, got {}".format(caveats))
+ failed = True
+ elif "2025-07" not in caveats[0] or "today's rates" not in caveats[0]:
+ print(" ERROR: expected the fallback caveat to name the month and explain what happened, got {!r}".format(caveats[0]))
+ failed = True
+
+ print("Test: _tariff_fallback_caveats separately reports a month where export could not be priced at all")
+ caveats = AnnualPredictor._tariff_fallback_caveats(set(), {(2025, 9)}, 2025)
+ if len(caveats) != 1:
+ print(" ERROR: expected exactly one caveat for an unpaid-export-only case, got {}".format(caveats))
+ failed = True
+ elif "2025-09" not in caveats[0] or "zero" not in caveats[0]:
+ print(" ERROR: expected the unpaid-export caveat to name the month and say export was priced at zero, got {!r}".format(caveats[0]))
+ failed = True
+
+ print("Test: _tariff_fallback_caveats returns both caveats together when both conditions apply")
+ caveats = AnnualPredictor._tariff_fallback_caveats({(2025, 7, "import")}, {(2025, 9)}, 2025)
+ if len(caveats) != 2:
+ print(" ERROR: expected both caveats when both sets are non-empty, got {}".format(caveats))
+ failed = True
+
+ print("Test: _tariff_fallback_caveats excludes a spill-month entry from the following year (fetch_month(year + 1, 1) can itself hit a fallback)")
+ # December's spill fetch loads January of the *next* year purely so the last sampled
+ # day's 48 hour plan has rates to spill into - there is no row for that month
+ # anywhere in this year's results document, so a caveat naming "2026-01" inside a
+ # 2025 report would refer to nothing a reader can find.
+ caveats = AnnualPredictor._tariff_fallback_caveats({(2025, 12, "import"), (2026, 1, "import")}, {(2026, 1)}, 2025)
+ if len(caveats) != 1:
+ print(" ERROR: expected only the fallback caveat (for 2025-12), the spill month's entries should be filtered out entirely, got {}".format(caveats))
+ failed = True
+ elif "2026-01" in caveats[0] or "2025-12" not in caveats[0]:
+ print(" ERROR: expected the caveat to name 2025-12 but not the spill month 2026-01, got {!r}".format(caveats[0]))
+ failed = True
+
+ print("Test: the annual block carries a cost breakdown")
+ predictor = make_predictor()
+ months = [make_month_row(month, {"no_pvbat": 1000.0, "pv_only": 700.0, "without_predbat": 600.0, "with_predbat": 400.0}) for month in range(1, 13)]
+ result = predictor._build_results(months)
+ costs = result["annual"]["costs"]
+ # base_config()'s solar is a single 5.0 kWp array and its battery is 10.0 kWh.
+ if abs(costs["total_kwp"] - 5.0) > 0.001 or abs(costs["battery_kwh"] - 10.0) > 0.001:
+ print(" ERROR: the cost block should reflect the configured system, got {}".format(costs))
+ failed = True
+ if abs(costs["battery_gbp"] - 3500.0) > 0.01:
+ print(" ERROR: a 10 kWh battery should cost 3500, got {}".format(costs["battery_gbp"]))
+ failed = True
+ if abs(costs["total_gbp"] - (costs["pv_gbp"] + costs["battery_gbp"])) > 0.01:
+ print(" ERROR: total should be pv plus battery, got {}".format(costs))
+ failed = True
+
+ print("Test: a full twelve months produces payback for all three options")
+ payback = result["annual"]["payback"]
+ if not payback.get("available"):
+ print(" ERROR: twelve months should produce payback, got {}".format(payback))
+ failed = True
+ for key in ["pv_only", "pv_battery", "pv_battery_predbat"]:
+ if key not in payback:
+ print(" ERROR: payback should include {}, got {}".format(key, list(payback)))
+ failed = True
+
+ print("Test: a partial year refuses payback rather than extrapolating")
+ partial_months = [make_month_row(month, {"no_pvbat": 1000.0, "pv_only": 700.0, "without_predbat": 600.0, "with_predbat": 400.0}) for month in range(1, 12)]
+ partial_months.append(make_unavailable_row(12))
+ partial = make_predictor()._build_results(partial_months)
+ if partial["annual"]["payback"].get("available") is not False:
+ print(" ERROR: eleven usable months should refuse payback, got {}".format(partial["annual"]["payback"]))
+ failed = True
+ if partial["annual"]["costs"]["total_gbp"] <= 0:
+ print(" ERROR: costs should still be reported even when payback cannot be")
+ failed = True
+
+ if test_annual_debug_flag():
+ failed = True
+
+ if test_annual_debug_capture(my_predbat):
+ failed = True
+
+ return failed
+
+
+def test_annual_debug_flag():
+ """Verify the debug flag defaults off and is coerced to a bool when set."""
+ failed = False
+ print("Test: debug defaults to False")
+ config = validate_config(base_config())
+ if config["debug"] is not False:
+ print(" ERROR: debug should default to False, got {!r}".format(config["debug"]))
+ failed = True
+
+ print("Test: debug is coerced to a bool, so a form's 'on' string does not become a truthy string")
+ raw = base_config()
+ raw["debug"] = "on"
+ config = validate_config(raw)
+ if config["debug"] is not True:
+ print(" ERROR: debug should coerce to True, got {!r}".format(config["debug"]))
+ failed = True
+
+ print("Test: debug is not echoed into the scrubbed raw config as a secret")
+ if config["raw"].get("debug") != "on":
+ print(" ERROR: raw config should retain the submitted value")
+ failed = True
+
+ print("Test: debug='false' (a non-empty string) is coerced to False, not the bare bool('false') == True bug")
+ # Plain Python's bool("false") is True (any non-empty string is truthy), which is
+ # exactly the trap _coerce_bool exists to avoid: an explicit, well-meaning "debug:
+ # false" in a submitted config must not silently turn debug mode on.
+ raw = base_config()
+ raw["debug"] = "false"
+ config = validate_config(raw)
+ if config["debug"] is not False:
+ print(" ERROR: debug='false' should coerce to False, got {!r}".format(config["debug"]))
+ failed = True
+
+ print("Test: a real bool for debug passes through unchanged")
+ raw = base_config()
+ raw["debug"] = True
+ config = validate_config(raw)
+ if config["debug"] is not True:
+ print(" ERROR: debug=True should stay True, got {!r}".format(config["debug"]))
+ failed = True
+ return failed
+
+
+def test_annual_debug_capture(my_predbat):
+ """Verify ``_capture_plan()`` returns the structure the web plan renderer requires.
+
+ ``_run_scenarios()`` needs a full weather/tariff/load-source rig to drive from a unit
+ test (see ``test_annual_integration.py``, registered as slow, which also carries the
+ regression guards that need that rig: identical billed figures with and without
+ capture, and that a capturing run never leaves ``predbat.debug_enable`` on). This
+ exercises the capture helper directly instead, against the same kind of minimal plan
+ state ``test_plan_json_rate_adjust.py`` builds - empty charge/export windows, no
+ search - drawing on the SAME two-call sequence production uses: ``_billed_result()``
+ with no ``save`` argument (unchanged since before this feature), followed by
+ ``_capture_plan()``, which does its own internal ``save="best"`` re-run to populate
+ ``predict_*_best`` without leaking a standing charge into the billed figures above.
+ An earlier version of both this test and the production code threaded ``save``
+ into ``_billed_result()`` itself; that was wrong on two counts, in order: first, it
+ was simply absent from production (``_billed_result()`` never passed ``save`` at
+ all, so ``_capture_plan()`` raised ``AttributeError`` there), and once that was
+ fixed by threading ``save="best"`` into the billed run, that in turn silently added
+ a standing charge into ``cost_p`` (``prediction.py``'s ``enable_standing_charge``),
+ which the annual engine already accounts for separately. Both regressions are
+ covered below and in ``test_annual_integration.py``.
+ """
+ failed = False
+ print("Test: _capture_plan returns a renderer-ready plan with non-empty rows")
+
+ reset_inverter(my_predbat)
+ my_predbat.forecast_minutes = 24 * 60
+ my_predbat.end_record = 24 * 60
+ my_predbat.debug_enable = False
+ my_predbat.soc_max = 10.0
+ my_predbat.soc_kw = 5.0
+ my_predbat.num_inverters = 1
+ my_predbat.reserve = 0.5
+
+ pv_step = {}
+ load_step = {}
+ for minute in range(0, my_predbat.forecast_minutes, 5):
+ pv_step[minute] = 0
+ load_step[minute] = 0.5 / (60 / 5)
+ my_predbat.prediction = Prediction(my_predbat, pv_step, pv_step, load_step, load_step, soc_kw=my_predbat.soc_kw, soc_max=my_predbat.soc_max)
+
+ # Empty windows/limits, exactly as _run_scenarios() passes for its "no system" scenario.
+ my_predbat.charge_limit_best = []
+ my_predbat.charge_window_best = []
+ my_predbat.export_window_best = []
+ my_predbat.export_limits_best = []
+
+ # The production call sequence: _run_scenarios() always calls _billed_result() with no
+ # save argument, then - only under debug - _capture_plan(), which does its own internal
+ # save="best" run. billed_before/billed_after bracket the capture so the regression
+ # guard below can prove the capture changed nothing about the billed numbers.
+ billed_before = _billed_result(my_predbat, my_predbat.end_record, pv_step)
+
+ plan = _capture_plan(my_predbat, pv_step, pv_step, load_step, load_step, my_predbat.end_record)
+ if not plan.get("rows"):
+ print(" ERROR: expected a non-empty 'rows' list, got {!r}".format(plan.get("rows")))
+ failed = True
+ if "soc_max" not in plan:
+ print(" ERROR: expected 'soc_max' in the captured plan")
+ failed = True
+ if "end_record" not in plan:
+ print(" ERROR: expected 'end_record' in the captured plan")
+ failed = True
+
+ print("Test: capturing a plan does not change the billed figures (no standing charge leak)")
+ # _capture_plan()'s internal save="best" run must not perturb anything _billed_result()
+ # reads (charge/export windows and limits, soc_kw, predbat.prediction), so an identical
+ # _billed_result() call straight after capture must reproduce the exact same figures -
+ # this is the direct, single-scenario mirror of test_annual_integration.py's regression
+ # guard, which proves the same thing through the full three-scenario production path.
+ billed_after = _billed_result(my_predbat, my_predbat.end_record, pv_step)
+ for field in billed_before:
+ if billed_before[field] != billed_after[field]:
+ print(" ERROR: {} = {} before capture but {} after - capturing a plan must not change the billed figures".format(field, billed_before[field], billed_after[field]))
+ failed = True
+
+ print("Test: capturing plans does not leak predbat.debug_enable on")
+ if my_predbat.debug_enable is not False:
+ print(" ERROR: predbat.debug_enable should still be False after a capturing run, got {!r}".format(my_predbat.debug_enable))
+ failed = True
+
+ print("Test: plans=None leaves the non-debug path untouched")
+ # _run_scenarios() needs a full weather/tariff/load-source rig to drive directly (see
+ # test_annual_integration.py, registered as slow), so this checks the contract that
+ # actually keeps a non-debug run untouched: plans defaults to None on both functions
+ # that thread it through, so a caller that never opts in triggers no capture at all.
+ scenarios_signature = inspect.signature(_run_scenarios)
+ if scenarios_signature.parameters["plans"].default is not None:
+ print(" ERROR: _run_scenarios' plans parameter should default to None, got {!r}".format(scenarios_signature.parameters["plans"].default))
+ failed = True
+ day_signature = inspect.signature(run_day)
+ if day_signature.parameters["plans"].default is not None:
+ print(" ERROR: run_day's plans parameter should default to None, got {!r}".format(day_signature.parameters["plans"].default))
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_sampling.py b/apps/predbat/tests/test_annual_sampling.py
new file mode 100644
index 000000000..812f5f7a6
--- /dev/null
+++ b/apps/predbat/tests/test_annual_sampling.py
@@ -0,0 +1,197 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for annual prediction sample day selection."""
+
+from datetime import date, timedelta
+
+from annual import _percentile_indices, select_samples
+
+
+class FakeWeather:
+ """A stub WeatherYear exposing only what select_samples needs."""
+
+ def __init__(self, daily):
+ """Hold a mapping of date to daily actual PV kWh."""
+ self.daily = daily
+
+ def has_actual(self, day):
+ """Return True when the date has actual PV data."""
+ return day in self.daily
+
+ def daily_actual_kwh(self, day):
+ """Return the actual PV kWh for the date."""
+ return self.daily.get(day, 0.0)
+
+
+def build_january(kwh_by_day, extra_days=1):
+ """Build a FakeWeather covering January plus a buffer into February."""
+ daily = {}
+ for day_number, kwh in kwh_by_day.items():
+ daily[date(2025, 1, day_number)] = kwh
+ for offset in range(extra_days):
+ daily[date(2025, 2, 1) + timedelta(days=offset)] = 1.0
+ return FakeWeather(daily)
+
+
+def build_december(kwh_by_day, extra_days=1):
+ """Build a FakeWeather covering December plus a buffer into January of the following year."""
+ daily = {}
+ for day_number, kwh in kwh_by_day.items():
+ daily[date(2025, 12, day_number)] = kwh
+ for offset in range(extra_days):
+ daily[date(2026, 1, 1) + timedelta(days=offset)] = 1.0
+ return FakeWeather(daily)
+
+
+def _check_indices(indices, count, samples, label, failed):
+ """Assert that indices from _percentile_indices() are distinct and within range.
+
+ Shared by the degenerate-case checks below so each one only has to state its
+ inputs and let this helper verify the two invariants every case must satisfy.
+ Returns the (possibly updated) ``failed`` flag.
+ """
+ if len(set(indices)) != len(indices):
+ print(" ERROR: {} produced duplicate indices, got {}".format(label, indices))
+ failed = True
+ if any(index < 0 or index >= count for index in indices):
+ print(" ERROR: {} produced an out-of-range index, got {} for count {}".format(label, indices, count))
+ failed = True
+ return failed
+
+
+def test_annual_sampling(my_predbat):
+ """Verify percentile sampling, weighting, determinism, degraded months and year boundaries."""
+ failed = False
+ print("**** Testing annual sample selection ****")
+
+ print("Test: _percentile_indices with no candidates returns nothing")
+ if _percentile_indices(0, 2) != []:
+ print(" ERROR: expected no indices for a candidate count of 0, got {}".format(_percentile_indices(0, 2)))
+ failed = True
+
+ print("Test: _percentile_indices with a single candidate always picks index 0")
+ for requested in (1, 2, 5):
+ single = _percentile_indices(1, requested)
+ if single != [0]:
+ print(" ERROR: expected [0] for count=1, samples={}, got {}".format(requested, single))
+ failed = True
+
+ print("Test: _percentile_indices with more samples requested than candidates stays in range")
+ over_requested = _percentile_indices(3, 7)
+ failed = _check_indices(over_requested, 3, 7, "samples > count", failed)
+
+ print("Test: _percentile_indices with samples equal to count selects every index")
+ exact = _percentile_indices(5, 5)
+ if sorted(exact) != list(range(5)):
+ print(" ERROR: expected every index 0-4 when samples == count, got {}".format(sorted(exact)))
+ failed = True
+ failed = _check_indices(exact, 5, 5, "samples == count", failed)
+
+ # January: day N generates N kWh, so the sorted order is simply day order
+ weather = build_january({day: float(day) for day in range(1, 32)})
+
+ print("Test: two samples land on the 25th and 75th percentile days")
+ samples = select_samples(weather, 2025, 1, 2)
+ if len(samples) != 2:
+ print(" ERROR: expected 2 samples, got {}".format(len(samples)))
+ failed = True
+ else:
+ days = [day.day for day, _ in samples]
+ # 31 candidates: indices int(31*0.25)=7 and int(31*0.75)=23 -> days 8 and 24
+ if days != [8, 24]:
+ print(" ERROR: expected days [8, 24], got {}".format(days))
+ failed = True
+
+ print("Test: weights sum to the number of days in the month")
+ total_weight = sum(weight for _, weight in samples)
+ if abs(total_weight - 31.0) > 1e-9:
+ print(" ERROR: weights should sum to 31, got {}".format(total_weight))
+ failed = True
+
+ print("Test: selection is deterministic")
+ if select_samples(weather, 2025, 1, 2) != samples:
+ print(" ERROR: repeated selection returned different days")
+ failed = True
+
+ print("Test: samples are returned in date order")
+ ordered = [day for day, _ in select_samples(weather, 2025, 1, 4)]
+ if ordered != sorted(ordered):
+ print(" ERROR: samples should be returned in date order, got {}".format(ordered))
+ failed = True
+
+ print("Test: samples are distinct")
+ four = select_samples(weather, 2025, 1, 4)
+ if len({day for day, _ in four}) != 4:
+ print(" ERROR: expected 4 distinct days, got {}".format([day for day, _ in four]))
+ failed = True
+
+ print("Test: a day without a following day is excluded, since the 48 hour plan needs one")
+ truncated = build_january({day: float(day) for day in range(1, 32)}, extra_days=0)
+ truncated_days = [day for day, _ in select_samples(truncated, 2025, 1, 2)]
+ if date(2025, 1, 31) in truncated_days:
+ print(" ERROR: 31 January has no following day and must not be sampled")
+ failed = True
+
+ print("Test: a month with fewer candidates than samples uses every candidate")
+ sparse = build_january({1: 5.0, 2: 9.0, 3: 1.0})
+ sparse_samples = select_samples(sparse, 2025, 1, 4)
+ # Day 3 has no following day (4 January is absent), so only days 1 and 2 are usable
+ if len(sparse_samples) != 2:
+ print(" ERROR: expected 2 usable samples, got {}".format(len(sparse_samples)))
+ failed = True
+ if abs(sum(weight for _, weight in sparse_samples) - 31.0) > 1e-9:
+ print(" ERROR: weights must still sum to 31 when samples are scarce, got {}".format(sum(w for _, w in sparse_samples)))
+ failed = True
+
+ print("Test: a month with no usable candidates returns nothing")
+ if select_samples(FakeWeather({}), 2025, 1, 2) != []:
+ print(" ERROR: a month with no weather data should return no samples")
+ failed = True
+
+ print("Test: a battery-only run with no solar falls back to evenly spaced calendar days")
+ no_solar = select_samples(FakeWeather({}), 2025, 1, 2, has_solar=False)
+ if len(no_solar) != 2:
+ print(" ERROR: with no solar, expected 2 calendar samples, got {}".format(len(no_solar)))
+ failed = True
+ elif [day.day for day in [entry[0] for entry in no_solar]] != [8, 24]:
+ print(" ERROR: expected evenly spaced days [8, 24], got {}".format([entry[0].day for entry in no_solar]))
+ failed = True
+
+ print("Test: a battery-only run still includes the last day of the month when requested")
+ # samples_per_month == days_in_month so every calendar day, including the last, is chosen;
+ # weather has no data at all, confirming the no-solar branch never needs a following-day check
+ no_solar_full_month = select_samples(FakeWeather({}), 2025, 12, 31, has_solar=False)
+ if len(no_solar_full_month) != 31:
+ print(" ERROR: expected all 31 December calendar days, got {}".format(len(no_solar_full_month)))
+ failed = True
+ else:
+ last_day, last_weight = no_solar_full_month[-1]
+ if last_day != date(2025, 12, 31):
+ print(" ERROR: expected the last sample to be 31 December, got {}".format(last_day))
+ failed = True
+ if abs(last_weight - 1.0) > 1e-9:
+ print(" ERROR: expected a weight of 1.0 for 31 December, got {}".format(last_weight))
+ failed = True
+
+ print("Test: a December selection with solar consults 1 January of the following year")
+ december = build_december({day: float(day) for day in range(1, 32)})
+ december_days = [day for day, _ in select_samples(december, 2025, 12, 31)]
+ if date(2025, 12, 31) not in december_days:
+ print(" ERROR: 31 December should be included when 1 January of the following year has data")
+ failed = True
+
+ print("Test: a December selection with solar excludes 31 December when the new year has no data")
+ december_truncated = build_december({day: float(day) for day in range(1, 32)}, extra_days=0)
+ december_truncated_days = [day for day, _ in select_samples(december_truncated, 2025, 12, 31)]
+ if date(2025, 12, 31) in december_truncated_days:
+ print(" ERROR: 31 December has no following day in the next year and must not be sampled")
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_scenarios.py b/apps/predbat/tests/test_annual_scenarios.py
new file mode 100644
index 000000000..013781671
--- /dev/null
+++ b/apps/predbat/tests/test_annual_scenarios.py
@@ -0,0 +1,209 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the annual prediction three-scenario day runner."""
+
+from annual import DAY_MINUTES, PLAN_MINUTES, SCENARIO_FIELDS, SCENARIO_KEYS, _blend_results, add_car_to_load, car_charging_schedule, timer_charge_window
+
+
+def flat_rates(cheap_start, cheap_end, cheap_rate, peak_rate):
+ """Build a 48 hour import rate dict with one cheap overnight band per day."""
+ rates = {}
+ for minute in range(PLAN_MINUTES):
+ in_day = minute % DAY_MINUTES
+ rates[minute] = cheap_rate if cheap_start <= in_day < cheap_end else peak_rate
+ return rates
+
+
+def test_annual_scenarios(my_predbat):
+ """Verify the timer charge window and car load insertion helpers."""
+ failed = False
+ print("**** Testing annual scenario helpers ****")
+
+ print("Test: timer_charge_window finds the cheapest band and sizes it to the car's energy")
+ rates = flat_rates(cheap_start=30, cheap_end=330, cheap_rate=7.0, peak_rate=30.0)
+ window = timer_charge_window(rates, car_kwh=14.8, car_rate_kw=7.4)
+ if not window:
+ print(" ERROR: expected a charge window")
+ failed = True
+ else:
+ first = window[0]
+ if first["start"] != 30:
+ print(" ERROR: the window should start at the cheap band start 30, got {}".format(first["start"]))
+ failed = True
+ # 14.8 kWh at 7.4 kW is 2 hours
+ if first["end"] - first["start"] != 120:
+ print(" ERROR: expected a 120 minute window for 14.8 kWh at 7.4 kW, got {}".format(first["end"] - first["start"]))
+ failed = True
+
+ print("Test: a car needing more than the cheap band gets a window extended beyond it")
+ long_window = timer_charge_window(rates, car_kwh=74.0, car_rate_kw=7.4)
+ if long_window[0]["end"] - long_window[0]["start"] < 300:
+ print(" ERROR: a 10 hour charge should extend past the 5 hour cheap band, got {} minutes".format(long_window[0]["end"] - long_window[0]["start"]))
+ failed = True
+
+ print("Test: a slower car_rate_kw produces a roughly proportionally longer window for the same energy")
+ # Guards the annual.py config-validation fix that lets a configured car_rate_kw actually
+ # reach timer_charge_window (previously validate_config() silently dropped it and every
+ # run used the 7.4 kW default regardless of what was configured).
+ fast_window = timer_charge_window(rates, car_kwh=14.8, car_rate_kw=7.4)
+ slow_window = timer_charge_window(rates, car_kwh=14.8, car_rate_kw=3.7)
+ fast_length = fast_window[0]["end"] - fast_window[0]["start"]
+ slow_length = slow_window[0]["end"] - slow_window[0]["start"]
+ if abs(slow_length - 2 * fast_length) > 5:
+ print(" ERROR: halving car_rate_kw from 7.4 to 3.7 should roughly double the window length ({} minutes), got {} minutes for the fast case and {} minutes for the slow case".format(2 * fast_length, fast_length, slow_length))
+ failed = True
+
+ print("Test: zero car energy produces no window")
+ if timer_charge_window(rates, car_kwh=0.0, car_rate_kw=7.4) != []:
+ print(" ERROR: no car energy should produce no window")
+ failed = True
+
+ print("Test: timer_charge_window rounds the duration up to a 5 minute grid, never under-delivering")
+ # 81 minutes of raw need at 60kW/hr rate: 81/60*60 = 81.0 kWh, not a multiple of 5 minutes.
+ # step_data_history() only samples load_forecast every 5 minutes, so an unaligned window
+ # would be billed at a quantised (and here shorter) length than the car actually needs.
+ quantised_window = timer_charge_window(rates, car_kwh=81.0, car_rate_kw=60.0)
+ quantised_length = quantised_window[0]["end"] - quantised_window[0]["start"]
+ if quantised_length != 85:
+ print(" ERROR: an 81 minute need should round up to 85 minutes (next multiple of 5), got {}".format(quantised_length))
+ failed = True
+ if quantised_window[0]["start"] % 5 != 0:
+ print(" ERROR: the window start should be aligned to a 5 minute boundary, got {}".format(quantised_window[0]["start"]))
+ failed = True
+
+ print("Test: a cheapest band late in the day is pulled back so the billed day still gets the FULL car session")
+ # Regression guard for the bug where a day-0 window could run past DAY_MINUTES: the
+ # cheapest half-hour here is at 23:00 (minute 1380), and a 34 kWh session at 7.4 kW needs
+ # about 4.6 hours - far more than the 60 minutes left in the day from 23:00. Without
+ # clamping, the window would end at 1380 + ~280 = ~1660, so _billed_result (which only
+ # costs minutes < DAY_MINUTES) would bill the baselines for only the ~60 minutes that fall
+ # before midnight while scenario 3 (with_predbat) is always billed for the full session -
+ # phantom baseline saving that can invert a month's headline predbat_vs_baseline_p.
+ late_rates = flat_rates(cheap_start=1380, cheap_end=1410, cheap_rate=7.0, peak_rate=30.0)
+ late_car_kwh = 34.0
+ late_window = timer_charge_window(late_rates, car_kwh=late_car_kwh, car_rate_kw=7.4)
+ day_zero_window = late_window[0]
+ if day_zero_window["start"] < 0:
+ print(" ERROR: the day-0 window start must never go negative, got {}".format(day_zero_window["start"]))
+ failed = True
+ if day_zero_window["end"] > DAY_MINUTES:
+ print(" ERROR: the day-0 window must end within the billed day (< {}), got end={} (start={})".format(DAY_MINUTES, day_zero_window["end"], day_zero_window["start"]))
+ failed = True
+ late_baseline_load = add_car_to_load({minute: 0.0 for minute in range(DAY_MINUTES + 1)}, late_window, car_kwh=late_car_kwh)
+ billed_day_energy = late_baseline_load[DAY_MINUTES]
+ if abs(billed_day_energy - late_car_kwh) > 1e-6:
+ print(" ERROR: the billed day should receive the full {} kWh session (matching what scenario 3 is always billed for), got {} kWh".format(late_car_kwh, billed_day_energy))
+ failed = True
+
+ print("Test: add_car_to_load inserts the car's full energy on EACH day, not split across the plan, and stays cumulative")
+ base = {minute: 0.01 * minute for minute in range(PLAN_MINUTES + 1)}
+ with_car = add_car_to_load(base, window, car_kwh=14.8)
+ added_total = with_car[PLAN_MINUTES] - base[PLAN_MINUTES]
+ if abs(added_total - 14.8 * 2) > 1e-6:
+ print(" ERROR: expected 14.8 kWh added on each of the two days ({} total), got {}".format(14.8 * 2, added_total))
+ failed = True
+ added_day_one = with_car[DAY_MINUTES] - base[DAY_MINUTES]
+ if abs(added_day_one - 14.8) > 1e-6:
+ print(" ERROR: the billed first day (minutes 0..{}) should receive the full 14.8 kWh, not a share of it, got {}".format(DAY_MINUTES - 1, added_day_one))
+ failed = True
+ for minute in range(1, PLAN_MINUTES + 1):
+ if with_car[minute] < with_car[minute - 1] - 1e-12:
+ print(" ERROR: the series stopped being cumulative at minute {}".format(minute))
+ failed = True
+ break
+
+ print("Test: add_car_to_load leaves minutes before the window untouched")
+ if abs(with_car[10] - base[10]) > 1e-12:
+ print(" ERROR: minutes before the window must be unchanged")
+ failed = True
+
+ print("Test: add_car_to_load does not mutate its input")
+ if abs(base[PLAN_MINUTES] - 0.01 * PLAN_MINUTES) > 1e-9:
+ print(" ERROR: add_car_to_load must not mutate the input series")
+ failed = True
+
+ print("Test: the car repeats on the second day of the window")
+ second_day = [entry for entry in window if entry["start"] >= DAY_MINUTES]
+ if not second_day:
+ print(" ERROR: the timer should also charge on day two of the 48 hour window")
+ failed = True
+
+ print("Test: car_charging_schedule gives a modest annual figure a single weekly session")
+ # 1500 kWh/year at 7.4 kW is a 28.8 kWh/week session, under the 6 hour cap (3.9 hours).
+ sessions, session_kwh = car_charging_schedule(annual_kwh=1500, car_rate_kw=7.4)
+ if sessions != 1:
+ print(" ERROR: expected 1 session/week for a modest annual figure, got {}".format(sessions))
+ failed = True
+ if abs(session_kwh - 1500 / 52.0) > 1e-9:
+ print(" ERROR: a single weekly session should carry the whole week's energy ({}), got {}".format(1500 / 52.0, session_kwh))
+ failed = True
+
+ print("Test: a session that would exceed 6 hours splits into enough sessions that each is under 6 hours")
+ # 2500 kWh/year at 7.4 kW is a 48.08 kWh/week need - 6.50 hours as one session, so it must split.
+ sessions, session_kwh = car_charging_schedule(annual_kwh=2500, car_rate_kw=7.4)
+ if sessions <= 1:
+ print(" ERROR: a session over 6 hours should have split into more than 1 session/week, got {}".format(sessions))
+ failed = True
+ session_hours = session_kwh / 7.4
+ if session_hours > 6.0 + 1e-9:
+ print(" ERROR: every split session should be under 6 hours, got {:.3f} hours across {} sessions".format(session_hours, sessions))
+ failed = True
+
+ print("Test: the session count caps at 7 even when that still exceeds 6 hours per session")
+ # 8000 kWh/year at 3.0 kW is a 153.8 kWh/week need - 51.3 hours as one session. Even 7
+ # sessions (7.3 hours each) cannot get under the 6 hour cap, so the cap must still hold at 7
+ # rather than climbing past a session a day.
+ sessions, session_kwh = car_charging_schedule(annual_kwh=8000, car_rate_kw=3.0)
+ if sessions != 7:
+ print(" ERROR: expected the session count to cap at 7/week, got {}".format(sessions))
+ failed = True
+ if session_kwh / 3.0 <= 6.0:
+ print(" ERROR: expected this case to still exceed 6 hours per session even at the cap, got {:.3f} hours".format(session_kwh / 3.0))
+ failed = True
+
+ print("Test: sessions_per_week * session_kwh * 52 recovers the annual total")
+ for annual_kwh, car_rate_kw in [(1500, 7.4), (2500, 7.4), (8000, 7.4), (1500, 3.0), (2500, 3.0), (8000, 3.0)]:
+ sessions, session_kwh = car_charging_schedule(annual_kwh, car_rate_kw)
+ recovered = sessions * session_kwh * 52.0
+ if abs(recovered - annual_kwh) > 1e-6:
+ print(" ERROR: {} kWh/year at {} kW should recover to {} annual kWh, got {} ({} sessions of {:.3f} kWh)".format(annual_kwh, car_rate_kw, annual_kwh, recovered, sessions, session_kwh))
+ failed = True
+
+ print("Test: no annual energy produces no sessions")
+ sessions, session_kwh = car_charging_schedule(annual_kwh=0, car_rate_kw=7.4)
+ if sessions != 0 or session_kwh != 0.0:
+ print(" ERROR: zero annual energy should produce 0 sessions of 0 kWh, got {} sessions of {} kWh".format(sessions, session_kwh))
+ failed = True
+
+ print("Test: _blend_results blends every field of every scenario as fraction * with + (1 - fraction) * without")
+ with_car_results = {key: {field: 10.0 for field in SCENARIO_FIELDS} for key in SCENARIO_KEYS}
+ without_car_results = {key: {field: 2.0 for field in SCENARIO_FIELDS} for key in SCENARIO_KEYS}
+ with_car_results["with_predbat"]["cost_p"] = 100.0
+ without_car_results["with_predbat"]["cost_p"] = 20.0
+ fraction = 3.0 / 7.0
+ blended = _blend_results(with_car_results, without_car_results, fraction)
+ expected_cost = fraction * 100.0 + (1 - fraction) * 20.0
+ if abs(blended["with_predbat"]["cost_p"] - expected_cost) > 1e-9:
+ print(" ERROR: expected blended with_predbat cost_p {}, got {}".format(expected_cost, blended["with_predbat"]["cost_p"]))
+ failed = True
+ expected_other = fraction * 10.0 + (1 - fraction) * 2.0
+ if abs(blended["no_pvbat"]["import_kwh"] - expected_other) > 1e-9:
+ print(" ERROR: expected blended no_pvbat import_kwh {}, got {}".format(expected_other, blended["no_pvbat"]["import_kwh"]))
+ failed = True
+
+ print("Test: _blend_results is a no-op for a field identical in both legs (pv_generated_kwh)")
+ identical = {key: {field: 5.0 for field in SCENARIO_FIELDS} for key in SCENARIO_KEYS}
+ blended = _blend_results(identical, identical, fraction=0.4)
+ for key in SCENARIO_KEYS:
+ if blended[key]["pv_generated_kwh"] != 5.0:
+ print(" ERROR: blending identical legs should be a no-op, got {} for {}".format(blended[key]["pv_generated_kwh"], key))
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_store.py b/apps/predbat/tests/test_annual_store.py
new file mode 100644
index 000000000..3d8e73119
--- /dev/null
+++ b/apps/predbat/tests/test_annual_store.py
@@ -0,0 +1,452 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the Annual tab's five-run store."""
+
+import asyncio
+import datetime
+
+from annual_store import INDEX_NAME, MAX_RUNS, STORAGE_MODULE, _discard_run, backfill_summaries, build_label, build_summary, list_runs, load_plan, load_run, save_run
+from tests.test_infra import run_async
+
+
+class FakeStorage:
+ """An in-memory stand-in for the Storage component that supports ``delete``.
+
+ Exercises the primary eviction path in ``annual_store``, where a genuine
+ ``delete`` method is available and preferred over the fallback.
+ """
+
+ def __init__(self):
+ """Start with nothing stored."""
+ self.store = {}
+ self.deleted = []
+ self.save_calls = []
+
+ async def save(self, module, filename, data, format="yaml", expiry=None):
+ """Record a saved value and remember that a save happened."""
+ self.store[(module, filename)] = data
+ self.save_calls.append((module, filename))
+
+ async def load(self, module, filename):
+ """Return a stored value, or None."""
+ return self.store.get((module, filename))
+
+ async def delete(self, module, filename):
+ """Remove a stored value and record that it happened."""
+ self.deleted.append(filename)
+ self.store.pop((module, filename), None)
+
+
+class FakeStorageNoDelete:
+ """An in-memory stand-in for the Storage component with no ``delete`` method.
+
+ This matches the real Storage component's actual surface (``save``, ``load``,
+ ``age``, ``cleanup``, ``fetch_cached`` — no ``delete``), so it exercises the
+ fallback eviction path: overwriting the evicted document with ``None`` and a
+ past expiry, via ``save``, rather than calling a method that does not exist.
+ """
+
+ def __init__(self):
+ """Start with nothing stored."""
+ self.store = {}
+ self.expiries = {}
+
+ async def save(self, module, filename, data, format="yaml", expiry=None):
+ """Record a saved value and the expiry it was saved with, if any."""
+ self.store[(module, filename)] = data
+ self.expiries[(module, filename)] = expiry
+
+ async def load(self, module, filename):
+ """Return a stored value, or None."""
+ return self.store.get((module, filename))
+
+
+def sample_results(cost):
+ """Return a minimal results document with a distinguishing cost."""
+ return {"year": 2025, "annual": {"scenarios": {"with_predbat": {"cost_p": cost}}, "months_included": 12}, "months": []}
+
+
+def sample_config(size_kwh=9.5):
+ """Return a minimal validated-shape config."""
+ return {"battery": {"size_kwh": size_kwh}, "solar": [{"kwp": 5.6}], "tariff": {"import_octopus_url": "https://example.com/AGILE-24-10-01/x"}}
+
+
+def test_annual_store(my_predbat):
+ """Verify saving, listing, loading, eviction (both code paths) and label generation."""
+ failed = False
+ print("**** Testing annual_store ****")
+
+ print("Test: a saved run appears in the index and can be loaded back")
+ storage = FakeStorage()
+ run_id = asyncio.run(save_run(storage, sample_results(100), sample_config(), "run-1"))
+ if run_id != "run-1":
+ print(" ERROR: save_run should return the id it was given, got {}".format(run_id))
+ failed = True
+ index = asyncio.run(list_runs(storage))
+ if len(index) != 1 or index[0]["id"] != "run-1":
+ print(" ERROR: expected one indexed run, got {}".format(index))
+ failed = True
+ loaded = asyncio.run(load_run(storage, "run-1"))
+ if (loaded or {}).get("annual", {}).get("scenarios", {}).get("with_predbat", {}).get("cost_p") != 100:
+ print(" ERROR: the loaded run should match what was saved, got {}".format(loaded))
+ failed = True
+
+ print("Test: the index is newest-first")
+ asyncio.run(save_run(storage, sample_results(200), sample_config(), "run-2"))
+ index = asyncio.run(list_runs(storage))
+ if [entry["id"] for entry in index] != ["run-2", "run-1"]:
+ print(" ERROR: expected newest first, got {}".format([entry["id"] for entry in index]))
+ failed = True
+
+ print("Test: a sixth run evicts the oldest AND deletes its stored document (delete-capable backend)")
+ storage = FakeStorage()
+ for number in range(1, MAX_RUNS + 2):
+ asyncio.run(save_run(storage, sample_results(number), sample_config(), "run-{}".format(number)))
+ index = asyncio.run(list_runs(storage))
+ if len(index) != MAX_RUNS:
+ print(" ERROR: the ring should hold {} runs, got {}".format(MAX_RUNS, len(index)))
+ failed = True
+ if "run-1" in [entry["id"] for entry in index]:
+ print(" ERROR: the oldest run should have been evicted from the index")
+ failed = True
+ if "run_run-1" not in storage.deleted:
+ print(" ERROR: the evicted run's document should be deleted, deletions were {}".format(storage.deleted))
+ failed = True
+ if asyncio.run(load_run(storage, "run-1")) is not None:
+ print(" ERROR: an evicted run should no longer load")
+ failed = True
+
+ print("Test: a sixth run evicts the oldest via the fallback path when delete is unavailable")
+ no_delete_storage = FakeStorageNoDelete()
+ for number in range(1, MAX_RUNS + 2):
+ asyncio.run(save_run(no_delete_storage, sample_results(number), sample_config(), "run-{}".format(number)))
+ index = asyncio.run(list_runs(no_delete_storage))
+ if len(index) != MAX_RUNS:
+ print(" ERROR: the ring should hold {} runs without delete, got {}".format(MAX_RUNS, len(index)))
+ failed = True
+ if "run-1" in [entry["id"] for entry in index]:
+ print(" ERROR: the oldest run should have been evicted from the index without delete")
+ failed = True
+ if asyncio.run(load_run(no_delete_storage, "run-1")) is not None:
+ print(" ERROR: an evicted run should read back as None when the fallback blanking is used")
+ failed = True
+ evicted_expiry = no_delete_storage.expiries.get((STORAGE_MODULE, "run_run-1"))
+ if evicted_expiry is None:
+ print(" ERROR: the fallback should set an expiry on the blanked document so cleanup() can reclaim it")
+ failed = True
+ elif evicted_expiry.tzinfo is None:
+ print(" ERROR: the fallback expiry should be timezone-aware, got {!r}".format(evicted_expiry))
+ failed = True
+ elif evicted_expiry >= datetime.datetime.now(datetime.timezone.utc):
+ print(" ERROR: the fallback expiry should be in the past, got {!r}".format(evicted_expiry))
+ failed = True
+
+ print("Test: loading an unknown or missing run returns None rather than raising")
+ if asyncio.run(load_run(storage, "does-not-exist")) is not None:
+ print(" ERROR: an unknown run id should give None")
+ failed = True
+
+ print("Test: an index entry whose document is missing is reported, not rendered empty")
+ storage = FakeStorage()
+ asyncio.run(save_run(storage, sample_results(1), sample_config(), "orphan"))
+ storage.store.pop((STORAGE_MODULE, "run_orphan"))
+ if asyncio.run(load_run(storage, "orphan")) is not None:
+ print(" ERROR: a missing document should give None so the caller can say so")
+ failed = True
+
+ print("Test: an empty store lists nothing rather than raising")
+ if asyncio.run(list_runs(FakeStorage())) != []:
+ print(" ERROR: an empty store should list no runs")
+ failed = True
+
+ print("Test: the label describes the configuration, not just a timestamp")
+ label = build_label(sample_config(size_kwh=9.5))
+ if "9.5" not in label or "5.6" not in label:
+ print(" ERROR: the label should name the battery and array size, got {!r}".format(label))
+ failed = True
+ if "Agile" not in label:
+ print(" ERROR: the label should name the tariff, got {!r}".format(label))
+ failed = True
+
+ print("Test: a label is still produced for a config with no battery or solar")
+ label = build_label({"tariff": {"rates_import": [{"rate": 25.0}]}})
+ if not label:
+ print(" ERROR: a sparse config should still produce a label")
+ failed = True
+
+ print("Test: build_label tolerates a config that is not a dict at all")
+ for not_a_config in [None, "not-a-dict", 42, [], [1, 2, 3], {"tariff": "also-not-a-dict"}]:
+ label = build_label(not_a_config)
+ if not label:
+ print(" ERROR: build_label should still return a non-empty string for {!r}, got {!r}".format(not_a_config, label))
+ failed = True
+
+ print("Test: the index survives a corrupt stored value")
+ storage = FakeStorage()
+ storage.store[(STORAGE_MODULE, INDEX_NAME)] = "not a list"
+ if asyncio.run(list_runs(storage)) != []:
+ print(" ERROR: a corrupt index should read as empty rather than raising")
+ failed = True
+
+ print("Test: build_summary lifts the headline figures off a results document")
+ results = {
+ "annual": {
+ "scenarios": {"no_pvbat": {"cost_p": 180000.0}, "with_predbat": {"cost_p": 66000.0}},
+ "months_included": 12,
+ "costs": {"total_kwp": 5.6, "battery_kwh": 9.5},
+ "payback": {
+ "available": True,
+ "pv_only": {"pays_back": True, "years": 17.8},
+ "pv_battery": {"pays_back": True, "years": 13.61},
+ "pv_battery_predbat": {"pays_back": False, "years": None},
+ },
+ }
+ }
+ config = {"tariff": {"import_octopus_url": "https://api.octopus.energy/v1/products/AGILE-24-10-01/x/"}}
+ summary = build_summary(results, config)
+ if summary["total_kwp"] != 5.6 or summary["battery_kwh"] != 9.5:
+ print(" ERROR: the summary should carry the system size, got {}".format(summary))
+ failed = True
+ if summary["cost_with_predbat_p"] != 66000.0:
+ print(" ERROR: expected the with_predbat cost, got {}".format(summary))
+ failed = True
+ if summary["saving_vs_none_p"] != 114000.0:
+ print(" ERROR: saving should be no_pvbat minus with_predbat, got {}".format(summary))
+ failed = True
+ if summary["payback_years"]["pv_battery"] != 13.61:
+ print(" ERROR: expected the pv_battery payback, got {}".format(summary))
+ failed = True
+ if summary["payback_years"]["pv_battery_predbat"] is not None:
+ print(" ERROR: a row that does not pay back must carry None, not a number, got {}".format(summary))
+ failed = True
+
+ print("Test: a run with no usable month summarises as unknown rather than zero")
+ empty = build_summary({"annual": {"scenarios": None, "months_included": 0}}, {})
+ if empty["cost_with_predbat_p"] is not None or empty["saving_vs_none_p"] is not None:
+ print(" ERROR: no usable month means unknown, not zero, got {}".format(empty))
+ failed = True
+
+ print("Test: a non-numeric or missing cost_p summarises as unknown rather than raising")
+ malformed = build_summary({"annual": {"scenarios": {"no_pvbat": {"cost_p": 180000.0}, "with_predbat": {"cost_p": "66000"}}, "months_included": 12}}, {})
+ if malformed["cost_with_predbat_p"] is not None or malformed["saving_vs_none_p"] is not None:
+ print(" ERROR: a string cost_p should summarise as unknown, not a number, got {}".format(malformed))
+ failed = True
+ none_baseline = build_summary({"annual": {"scenarios": {"no_pvbat": {"cost_p": None}, "with_predbat": {"cost_p": 66000.0}}, "months_included": 12}}, {})
+ if none_baseline["saving_vs_none_p"] is not None:
+ print(" ERROR: a missing baseline cost_p should summarise as unknown, got {}".format(none_baseline))
+ failed = True
+
+ print("Test: an unavailable payback keeps its reason for the compare table to show")
+ unavailable = build_summary(
+ {"annual": {"scenarios": {"no_pvbat": {"cost_p": 10.0}, "with_predbat": {"cost_p": 5.0}}, "months_included": 11, "payback": {"available": False, "reason": "Payback needs a full year, but only 11 of 12 months could be modelled."}}}, {}
+ )
+ if unavailable["payback_years"] != {} or "11 of 12" not in (unavailable.get("payback_reason") or ""):
+ print(" ERROR: an unavailable payback should carry its reason, got {}".format(unavailable))
+ failed = True
+
+ print("Test: save_run records the summary on the index entry")
+ storage = FakeStorage()
+ asyncio.run(save_run(storage, results, config, "20260728-090000"))
+ index = asyncio.run(list_runs(storage))
+ if not index or not index[0].get("summary"):
+ print(" ERROR: save_run should record a summary, got {}".format(index))
+ failed = True
+
+ print("Test: backfill fills every summary-less entry from its document and writes the index back exactly once")
+ storage = FakeStorage()
+ asyncio.run(save_run(storage, results, config, "20260728-090100"))
+ asyncio.run(save_run(storage, results, config, "20260728-090101"))
+ # Simulate two runs stored before summaries existed - with only one stale entry, "wrote
+ # once" cannot be told apart from "wrote once per entry", so this uses two.
+ stale = asyncio.run(list_runs(storage))
+ for entry in stale:
+ entry.pop("summary", None)
+ asyncio.run(storage.save(STORAGE_MODULE, INDEX_NAME, stale, format="json"))
+ writes_before = len(storage.save_calls)
+ filled = asyncio.run(backfill_summaries(storage, asyncio.run(list_runs(storage))))
+ if not all(entry.get("summary") for entry in filled):
+ print(" ERROR: backfill should fill every missing summary, got {}".format(filled))
+ failed = True
+ if len(storage.save_calls) != writes_before + 1:
+ print(" ERROR: backfill should write the index exactly once regardless of how many entries were filled, got {} writes".format(len(storage.save_calls) - writes_before))
+ failed = True
+
+ print("Test: backfill survives one corrupt document and still fills and saves the others")
+ storage = FakeStorage()
+ asyncio.run(save_run(storage, results, config, "20260728-090200"))
+ asyncio.run(save_run(storage, results, config, "20260728-090201"))
+ stale = asyncio.run(list_runs(storage))
+ for entry in stale:
+ entry.pop("summary", None)
+ # One run's stored document is corrupt in a way build_summary's own guards cannot catch -
+ # a scenario that is a string rather than a dict - so it must be skipped, not abort the loop.
+ corrupt_id = stale[-1]["id"]
+ asyncio.run(storage.save(STORAGE_MODULE, "run_{}".format(corrupt_id), {"annual": {"scenarios": {"no_pvbat": "not-a-dict"}, "months_included": 12}}, format="json"))
+ asyncio.run(storage.save(STORAGE_MODULE, INDEX_NAME, stale, format="json"))
+ writes_before = len(storage.save_calls)
+ filled = asyncio.run(backfill_summaries(storage, asyncio.run(list_runs(storage))))
+ good_entries = [entry for entry in filled if entry["id"] != corrupt_id]
+ if not good_entries or not good_entries[0].get("summary"):
+ print(" ERROR: backfill should still fill the good entry despite the corrupt one, got {}".format(filled))
+ failed = True
+ if len(storage.save_calls) != writes_before + 1:
+ print(" ERROR: backfill should still write the index exactly once despite the corrupt entry, got {} writes".format(len(storage.save_calls) - writes_before))
+ failed = True
+
+ print("Test: backfill writes nothing when every entry already has a summary")
+ storage = FakeStorage()
+ asyncio.run(save_run(storage, results, config, "20260728-090400"))
+ asyncio.run(save_run(storage, results, config, "20260728-090401"))
+ writes_before = len(storage.save_calls)
+ asyncio.run(backfill_summaries(storage, asyncio.run(list_runs(storage))))
+ if len(storage.save_calls) != writes_before:
+ print(" ERROR: a compare page that changes nothing must not write storage, got {} writes".format(len(storage.save_calls) - writes_before))
+ failed = True
+
+ print("Test: save_run strips the plans out of the stored document and keys them per leg")
+ storage = FakeStorage()
+ debug_results = {
+ "annual": {"scenarios": {"no_pvbat": {"cost_p": 10.0}, "with_predbat": {"cost_p": 5.0}}, "months_included": 12},
+ "months": [
+ {
+ "month": 1,
+ "status": "ok",
+ "plans": [
+ {"day": "2025-01-08", "leg": "with_car", "scenarios": {"with_predbat": {"rows": [1]}}},
+ {"day": "2025-01-08", "leg": "without_car", "scenarios": {"with_predbat": {"rows": [2]}}},
+ ],
+ },
+ {"month": 2, "status": "ok", "plans": [{"day": "2025-02-10", "leg": "single", "scenarios": {"pv_only": {"rows": [3]}}}]},
+ ],
+ }
+ run_async(save_run(storage, debug_results, {}, "20260728-091000"))
+ stored = run_async(load_run(storage, "20260728-091000"))
+ if any("plans" in month for month in stored["months"]):
+ print(" ERROR: the stored document must not carry the plans, got {}".format(stored["months"]))
+ failed = True
+ index = run_async(list_runs(storage))
+ if len(index[0].get("plan_keys") or []) != 3:
+ print(" ERROR: three legs should produce three plan keys, got {}".format(index[0].get("plan_keys")))
+ failed = True
+
+ print("Test: save_run records a plan_index entry per leg, carrying the labels the viewer needs")
+ plan_index = index[0].get("plan_index") or []
+ if len(plan_index) != 3:
+ print(" ERROR: three legs should produce three plan_index entries, got {}".format(plan_index))
+ failed = True
+ expected = [
+ {"month": 1, "index": 0, "day": "2025-01-08", "leg": "with_car"},
+ {"month": 1, "index": 1, "day": "2025-01-08", "leg": "without_car"},
+ {"month": 2, "index": 0, "day": "2025-02-10", "leg": "single"},
+ ]
+ if plan_index != expected:
+ print(" ERROR: expected {}, got {}".format(expected, plan_index))
+ failed = True
+
+ print("Test: load_plan returns the right scenario from its own key")
+ plan = run_async(load_plan(storage, "20260728-091000", 1, 1, "with_predbat"))
+ if plan != {"rows": [2]}:
+ print(" ERROR: expected the without_car leg's plan, got {}".format(plan))
+ failed = True
+
+ print("Test: a non-debug run writes no plan keys at all")
+ storage = FakeStorage()
+ run_async(save_run(storage, {"annual": {"months_included": 12}, "months": [{"month": 1, "status": "ok"}]}, {}, "20260728-091100"))
+ if run_async(list_runs(storage))[0].get("plan_keys"):
+ print(" ERROR: a run with no plans should record no plan keys")
+ failed = True
+
+ print("Test: load_plan falls back to a document that still has its plans embedded")
+ # A run stored before this split. It must stay viewable rather than silently losing
+ # its plans.
+ legacy_storage = FakeStorage()
+ run_async(legacy_storage.save(STORAGE_MODULE, "run_legacy", {"months": [{"month": 3, "plans": [{"leg": "single", "scenarios": {"pv_only": {"rows": [9]}}}]}]}, format="json"))
+ if run_async(load_plan(legacy_storage, "legacy", 3, 0, "pv_only")) != {"rows": [9]}:
+ print(" ERROR: a legacy embedded-plans run should still resolve")
+ failed = True
+
+ print("Test: the legacy fallback bounds-checks the embedded plans list rather than indexing past the end")
+ # legacy_storage's "run_legacy" document has exactly one plan, at index 0, for month
+ # 3. Deleting the bound check in load_plan's fallback (`index >= len(plans)`) would
+ # let `plans[index]` raise IndexError here instead of resolving to None.
+ for args in [("legacy", 3, 1, "pv_only"), ("legacy", 3, 99, "pv_only")]:
+ try:
+ if run_async(load_plan(legacy_storage, *args)) is not None:
+ print(" ERROR: {} should resolve to None".format(args))
+ failed = True
+ except Exception as error:
+ print(" ERROR: {} raised {} instead of returning None".format(args, type(error).__name__))
+ failed = True
+
+ print("Test: load_plan's legacy fallback resolves None, not an exception, when 'plans' itself is not a list")
+ for corrupt_plans in [{"0": "not a list"}, "also not a list", 42, None]:
+ corrupt_legacy_storage = FakeStorage()
+ run_async(corrupt_legacy_storage.save(STORAGE_MODULE, "run_corruptplans", {"months": [{"month": 5, "plans": corrupt_plans}]}, format="json"))
+ try:
+ result = run_async(load_plan(corrupt_legacy_storage, "corruptplans", 5, 0, "with_predbat"))
+ except Exception as error:
+ print(" ERROR: a legacy document whose plans is {!r} raised {} instead of returning None".format(corrupt_plans, type(error).__name__))
+ failed = True
+ else:
+ if result is not None:
+ print(" ERROR: a legacy document whose plans is {!r} should resolve to None, got {!r}".format(corrupt_plans, result))
+ failed = True
+
+ print("Test: load_plan resolves None, not an exception, when the primary key itself holds a corrupt leg blob")
+ corrupt_leg_storage = FakeStorage()
+ run_async(corrupt_leg_storage.save(STORAGE_MODULE, "run_corruptleg_plans_01_0", "not a dict", format="json"))
+ run_async(corrupt_leg_storage.save(STORAGE_MODULE, "run_corruptleg_plans_01_1", ["not", "a", "dict"], format="json"))
+ run_async(corrupt_leg_storage.save(STORAGE_MODULE, "run_corruptleg_plans_01_2", {"leg": "single"}, format="json")) # a dict with no "scenarios" key
+ run_async(corrupt_leg_storage.save(STORAGE_MODULE, "run_corruptleg", {"months": [{"month": 1, "status": "ok"}]}, format="json"))
+ for index in [0, 1, 2]:
+ try:
+ result = run_async(load_plan(corrupt_leg_storage, "corruptleg", 1, index, "with_predbat"))
+ except Exception as error:
+ print(" ERROR: a corrupt leg blob at index {} raised {} instead of returning None".format(index, type(error).__name__))
+ failed = True
+ else:
+ if result is not None:
+ print(" ERROR: a corrupt leg blob at index {} should resolve to None, got {!r}".format(index, result))
+ failed = True
+
+ print("Test: load_plan returns None rather than raising for anything it cannot resolve, against a run that actually exists")
+ # Uses its own storage holding the real "20260728-091000" run, not the leftover
+ # legacy_storage above (which only contains "run_legacy") - reusing that would let
+ # every case here short-circuit on "run not found" before reaching the out-of-range
+ # month/index or bad-scenario logic it claims to exercise.
+ resolve_storage = FakeStorage()
+ run_async(save_run(resolve_storage, debug_results, {}, "20260728-091000"))
+ for args in [
+ ("20260728-091000", 99, 0, "with_predbat"), # month not present
+ ("20260728-091000", 1, 99, "with_predbat"), # index out of range for a real month
+ ("20260728-091000", 1, 0, "nope"), # scenario not recorded on that leg
+ ("nosuchrun", 1, 0, "with_predbat"), # run id not present
+ ("20260728-091000", "not-a-number", 0, "with_predbat"), # non-integer month
+ ("20260728-091000", 1, "not-a-number", "with_predbat"), # non-integer index
+ ("20260728-091000", 1, -1, "with_predbat"), # negative index
+ ]:
+ try:
+ if run_async(load_plan(resolve_storage, *args)) is not None:
+ print(" ERROR: {} should resolve to None".format(args))
+ failed = True
+ except Exception as error:
+ print(" ERROR: {} raised {} instead of returning None".format(args, type(error).__name__))
+ failed = True
+
+ print("Test: discarding an evicted run expires its plan keys, not just its document")
+ storage = FakeStorage()
+ run_async(save_run(storage, debug_results, {}, "20260728-091200"))
+ keys_before = [key for key in storage.store if "plans" in str(key)]
+ run_async(_discard_run(storage, "20260728-091200", run_async(list_runs(storage))[0].get("plan_keys")))
+ if any(storage.store.get(key) is not None for key in keys_before):
+ print(" ERROR: an evicted run's plan keys should be discarded too")
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_tariff.py b/apps/predbat/tests/test_annual_tariff.py
new file mode 100644
index 000000000..43be6386c
--- /dev/null
+++ b/apps/predbat/tests/test_annual_tariff.py
@@ -0,0 +1,777 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the annual prediction tariff module."""
+
+import asyncio
+from datetime import date, datetime, timedelta
+
+import pytz
+
+from annual_tariff import AnnualTariff, build_period_url
+
+
+def build_agile_results(start_day, days, base_rate):
+ """Build a synthetic Octopus half-hourly rate payload with a repeating daily shape."""
+ results = []
+ stamp = pytz.utc.localize(datetime(start_day.year, start_day.month, start_day.day))
+ for slot in range(days * 48):
+ valid_from = stamp + timedelta(minutes=30 * slot)
+ valid_to = valid_from + timedelta(minutes=30)
+ # Cheap overnight, expensive in the evening peak
+ hour = valid_from.hour
+ rate = base_rate * (0.3 if hour < 5 else (2.0 if 16 <= hour < 19 else 1.0))
+ results.append(
+ {
+ "value_inc_vat": round(rate, 4),
+ "valid_from": valid_from.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "valid_to": valid_to.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+ )
+ return results
+
+
+def build_current_pattern_rows(days=2, base_rate=9.0, peak_rate=16.0):
+ """Build a synthetic bare-URL "current rates" payload with a fixed daily two-rate shape.
+
+ Mirrors the verified real-world product OUTGOING-PRIME-FIX-12M-26-06-23: a 9.0p base
+ rate with a 16.0p peak between 15:00 and 18:00 UTC, repeating daily. The anchor date is
+ in July, so this UTC window is genuinely observed as 16:00-19:00 local (BST) - matching
+ the real product's documented behaviour and giving the DST-boundary test something
+ real to catch. Rows are returned newest first, as the real bare endpoint does.
+ """
+ results = []
+ start = pytz.utc.localize(datetime(2026, 7, 20))
+ for slot in range(days * 48):
+ valid_from = start + timedelta(minutes=30 * slot)
+ valid_to = valid_from + timedelta(minutes=30)
+ rate = peak_rate if 15 <= valid_from.hour < 18 else base_rate
+ results.append(
+ {
+ "value_inc_vat": rate,
+ "valid_from": valid_from.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "valid_to": valid_to.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+ )
+ results.reverse()
+ return results
+
+
+def build_month_aware_fetch(rate_by_month):
+ """Build a fetch stub whose synthesised page's base rate depends on the requested month.
+
+ Parses ``period_from``/``period_to`` out of the requested URL and serves a single,
+ non-paginated page covering exactly that range. Because the base rate depends on the
+ requested start month, a test can tell which month's download actually produced a
+ given stamped rate - this is what makes it possible to prove ``rates_for``'s
+ month-boundary merge picks up the *next* month's own download for dates that spill
+ into it, rather than silently reusing the requesting month's download for those dates.
+ """
+
+ async def fetch(url):
+ """Serve a single page of synthetic rates sized to the requested period_from/period_to."""
+ start_str = url.split("period_from=")[1][:10]
+ end_str = url.split("period_to=")[1][:10]
+ start_day = date(*(int(part) for part in start_str.split("-")))
+ end_day = date(*(int(part) for part in end_str.split("-")))
+ days = (end_day - start_day).days
+ base_rate = rate_by_month.get(start_day.month, 20.0)
+ return {"results": build_agile_results(start_day, days, base_rate), "next": None}
+
+ return fetch
+
+
+class FakeAnnualStorage:
+ """Minimal in-memory storage stub with async save/load, used to prove a truncated download is never cached."""
+
+ def __init__(self):
+ """Start with an empty in-memory store."""
+ self.store = {}
+ self.expiries = {}
+
+ async def load(self, namespace, key):
+ """Return the previously saved value for a key, or None if nothing was saved."""
+ return self.store.get((namespace, key))
+
+ async def save(self, namespace, key, value, format="json", expiry=None):
+ """Record the value under the given key as if it had been persisted to disk, capturing any expiry passed."""
+ self.store[(namespace, key)] = value
+ self.expiries[(namespace, key)] = expiry
+
+
+def test_annual_tariff(my_predbat):
+ """Verify Octopus date-ranged rate fetching, pagination, slicing, caching guards and basic rates."""
+ failed = False
+ print("**** Testing annual_tariff ****")
+
+ print("Test: build_period_url appends the date range without losing existing query parameters")
+ start = pytz.utc.localize(datetime(2025, 3, 1))
+ end = pytz.utc.localize(datetime(2025, 4, 1))
+ plain = build_period_url("https://example.com/rates/", start, end)
+ if "?period_from=2025-03-01T00:00Z" not in plain or "period_to=2025-04-01T00:00Z" not in plain:
+ print(" ERROR: expected a period range in {}".format(plain))
+ failed = True
+ existing = build_period_url("https://example.com/rates/?page_size=100", start, end)
+ if "&period_from=" not in existing or "page_size=100" not in existing:
+ print(" ERROR: existing query parameters must be preserved, got {}".format(existing))
+ failed = True
+ if existing.count("page_size=100") != 1 or "page_size=1500" in existing:
+ print(" ERROR: an explicit page_size must be preserved as-is, not duplicated or overridden, got {}".format(existing))
+ failed = True
+
+ print("Test: an Octopus URL tariff resolves rates for a specific date, following pagination")
+ page_two = {"results": build_agile_results(date(2025, 3, 16), 16, 20.0), "next": None}
+ page_one = {"results": build_agile_results(date(2025, 3, 1), 15, 20.0), "next": "https://example.com/page2"}
+
+ calls = []
+
+ async def fake_fetch(url):
+ """Serve two pages of rate data and record the URLs requested."""
+ calls.append(url)
+ return page_two if "page2" in url else page_one
+
+ config = {"import_octopus_url": "https://example.com/import/", "export_octopus_url": "https://example.com/export/", "standing_charge_p_per_day": 60.0}
+ tariff = AnnualTariff(config, log=print, predbat=my_predbat, fetch_json=fake_fetch)
+ ok = asyncio.run(tariff.fetch_month(2025, 3))
+ if not ok:
+ print(" ERROR: fetch_month should succeed with valid payloads")
+ failed = True
+ if not tariff.month_available(2025, 3):
+ print(" ERROR: March 2025 should be reported as available")
+ failed = True
+ if len(calls) != 4:
+ print(" ERROR: expected 4 requests (2 pages x import and export), got {}".format(len(calls)))
+ failed = True
+
+ print("Test: rates_for returns a 48 hour window keyed by absolute minute, for both import and export")
+ midnight = pytz.utc.localize(datetime(2025, 3, 10))
+ rate_import, rate_export = tariff.rates_for(midnight, 48 * 60)
+ if len(rate_import) < 48 * 60:
+ print(" ERROR: expected at least {} import rate minutes, got {}".format(48 * 60, len(rate_import)))
+ failed = True
+ if rate_import.get(0) is None:
+ print(" ERROR: minute 0 must have an import rate")
+ failed = True
+ # 02:00 is in the cheap overnight band, 17:00 is in the peak band
+ if not rate_import[120] < rate_import[17 * 60]:
+ print(" ERROR: overnight rate {} should be below peak rate {}".format(rate_import[120], rate_import[17 * 60]))
+ failed = True
+ if abs(rate_import[120] - 6.0) > 0.01:
+ print(" ERROR: overnight rate expected 6.0, got {}".format(rate_import[120]))
+ failed = True
+ # The export URL is fed the same synthetic payload shape as import in this fixture,
+ # so its overnight/peak values must match the same 20.0 base rate formula. Asserting
+ # on actual values (not just presence) proves the export branch is truly exercised -
+ # deleting it outright would previously still pass this test.
+ if len(rate_export) < 48 * 60:
+ print(" ERROR: expected at least {} export rate minutes, got {}".format(48 * 60, len(rate_export)))
+ failed = True
+ if abs(rate_export[120] - 6.0) > 0.01:
+ print(" ERROR: export overnight rate expected 6.0, got {}".format(rate_export[120]))
+ failed = True
+ if abs(rate_export[17 * 60] - 40.0) > 0.01:
+ print(" ERROR: export peak rate expected 40.0, got {}".format(rate_export[17 * 60]))
+ failed = True
+
+ print("Test: the second day of the window carries the following day's rates")
+ if rate_import.get(24 * 60 + 120) is None:
+ print(" ERROR: the second day of the window must be populated")
+ failed = True
+
+ print("Test: an export-only tariff (no import URL) is reported available when its export download succeeds")
+ export_only_config = {"export_octopus_url": "https://example.com/export/", "standing_charge_p_per_day": 0.0}
+ export_only_tariff = AnnualTariff(export_only_config, log=print, predbat=my_predbat, fetch_json=fake_fetch)
+ if not asyncio.run(export_only_tariff.fetch_month(2025, 3)):
+ print(" ERROR: an export-only tariff should be available once its export download succeeds")
+ failed = True
+ if not export_only_tariff.month_available(2025, 3):
+ print(" ERROR: March 2025 should be reported as available for an export-only tariff")
+ failed = True
+
+ print("Test: a 48 hour window starting on the last day of a month carries the following month's own download, across a December to January year boundary")
+ dec_jan_config = {"import_octopus_url": "https://example.com/import/", "standing_charge_p_per_day": 0.0}
+ dec_jan_tariff = AnnualTariff(dec_jan_config, log=print, predbat=my_predbat, fetch_json=build_month_aware_fetch({12: 50.0, 1: 90.0}))
+ if not asyncio.run(dec_jan_tariff.fetch_month(2025, 12)):
+ print(" ERROR: December 2025 should fetch successfully")
+ failed = True
+ if not asyncio.run(dec_jan_tariff.fetch_month(2026, 1)):
+ print(" ERROR: January 2026 should fetch successfully")
+ failed = True
+ boundary_midnight = pytz.utc.localize(datetime(2025, 12, 31))
+ boundary_import, _ = dec_jan_tariff.rates_for(boundary_midnight, 48 * 60)
+ # Day one (31 Dec) overnight reflects December's own download: 50.0 * 0.3 = 15.0
+ if abs(boundary_import[120] - 15.0) > 0.01:
+ print(" ERROR: December overnight rate expected 15.0, got {}".format(boundary_import[120]))
+ failed = True
+ # Day two (1 Jan) overnight must reflect January's own download (90.0 * 0.3 = 27.0),
+ # not December's download merely extending into the buffer day at the old rate (15.0) -
+ # dropping the next-month merge entirely would leave this at 15.0 and still pass every
+ # other assertion above, since December's own buffer days also contain stamps for 1 Jan.
+ if abs(boundary_import[24 * 60 + 120] - 27.0) > 0.01:
+ print(" ERROR: January overnight rate expected 27.0 (carried from the following month's own fetch), got {}".format(boundary_import[24 * 60 + 120]))
+ failed = True
+
+ print("Test: a failed download reports the month as unavailable rather than returning zeros")
+
+ async def failing_fetch(url):
+ """Simulate a download failure."""
+ return None
+
+ broken = AnnualTariff(config, log=print, predbat=my_predbat, fetch_json=failing_fetch)
+ if asyncio.run(broken.fetch_month(2025, 4)):
+ print(" ERROR: fetch_month should report failure when the download fails")
+ failed = True
+ if broken.month_available(2025, 4):
+ print(" ERROR: a failed month must not be reported as available")
+ failed = True
+
+ print("Test: a failed page and a page-cap hit both refuse to cache a partial download")
+
+ async def failing_page_fetch(url):
+ """Simulate the very first page request failing outright."""
+ return None
+
+ fail_storage = FakeAnnualStorage()
+ fail_tariff = AnnualTariff({"import_octopus_url": "https://example.com/import/", "standing_charge_p_per_day": 0.0}, log=print, predbat=my_predbat, storage=fail_storage, fetch_json=failing_page_fetch)
+ if asyncio.run(fail_tariff.fetch_month(2025, 5)):
+ print(" ERROR: fetch_month should fail when the first page request fails")
+ failed = True
+ if fail_storage.store:
+ print(" ERROR: a failed page must not be cached, got {}".format(fail_storage.store))
+ failed = True
+
+ async def never_ending_fetch(url):
+ """Always report another page is available, to exercise the pagination safety cap."""
+ return {"results": build_agile_results(date(2025, 6, 1), 1, 20.0), "next": url + "&more"}
+
+ cap_storage = FakeAnnualStorage()
+ cap_tariff = AnnualTariff({"import_octopus_url": "https://example.com/import/", "standing_charge_p_per_day": 0.0}, log=print, predbat=my_predbat, storage=cap_storage, fetch_json=never_ending_fetch)
+ if asyncio.run(cap_tariff.fetch_month(2025, 6)):
+ print(" ERROR: fetch_month should fail when the page cap is hit with pages still remaining")
+ failed = True
+ if cap_storage.store:
+ print(" ERROR: a page-cap truncation must not be cached, got {}".format(cap_storage.store))
+ failed = True
+
+ print("Test: {dno_region} in a tariff URL is substituted from config without mutating predbat.args")
+ region_calls = []
+
+ async def region_fetch(url):
+ """Record the URL requested and serve a minimal single page of results."""
+ region_calls.append(url)
+ return {"results": build_agile_results(date(2025, 7, 1), 1, 20.0), "next": None}
+
+ dno_region_before = my_predbat.args.get("dno_region")
+ region_config = {"import_octopus_url": "https://example.com/rates-{dno_region}/", "dno_region": "A", "standing_charge_p_per_day": 0.0}
+ region_tariff = AnnualTariff(region_config, log=print, predbat=my_predbat, fetch_json=region_fetch)
+ if region_tariff.import_url != "https://example.com/rates-A/":
+ print(" ERROR: expected the {{dno_region}} template resolved to A, got {}".format(region_tariff.import_url))
+ failed = True
+ asyncio.run(region_tariff.fetch_month(2025, 7))
+ if not region_calls or "rates-A/" not in region_calls[0]:
+ print(" ERROR: expected the requested URL to contain the resolved region, got {}".format(region_calls))
+ failed = True
+ if my_predbat.args.get("dno_region") != dno_region_before:
+ print(" ERROR: dno_region must not be written into predbat.args (would clobber a live Octopus component's own region)")
+ failed = True
+
+ print("Test: basic rates repeat a fixed daily pattern across the window")
+ basic_config = {
+ "rates_import": [{"start": "00:00:00", "end": "05:00:00", "rate": 7.0}, {"start": "05:00:00", "end": "00:00:00", "rate": 30.0}],
+ "rates_export": [{"rate": 15.0}],
+ "standing_charge_p_per_day": 45.0,
+ }
+ basic = AnnualTariff(basic_config, log=print, predbat=my_predbat, fetch_json=fake_fetch)
+ if not asyncio.run(basic.fetch_month(2025, 3)):
+ print(" ERROR: basic rates should always be available")
+ failed = True
+ basic_import, basic_export = basic.rates_for(midnight, 48 * 60)
+ if abs(basic_import[120] - 7.0) > 0.001:
+ print(" ERROR: basic overnight import expected 7.0, got {}".format(basic_import[120]))
+ failed = True
+ if abs(basic_import[10 * 60] - 30.0) > 0.001:
+ print(" ERROR: basic daytime import expected 30.0, got {}".format(basic_import[10 * 60]))
+ failed = True
+ if abs(basic_import[24 * 60 + 120] - 7.0) > 0.001:
+ print(" ERROR: basic rates must repeat on day two, got {}".format(basic_import[24 * 60 + 120]))
+ failed = True
+ if abs(basic_export[10 * 60] - 15.0) > 0.001:
+ print(" ERROR: basic export expected 15.0, got {}".format(basic_export[10 * 60]))
+ failed = True
+
+ print("Test: an unconfigured tariff (no URLs and no rates_import) is reported unavailable rather than pricing at an implicit zero")
+ empty_config = {"standing_charge_p_per_day": 0.0}
+ empty_tariff = AnnualTariff(empty_config, log=print, predbat=my_predbat, fetch_json=fake_fetch)
+ if asyncio.run(empty_tariff.fetch_month(2025, 3)):
+ print(" ERROR: an unconfigured tariff (no import URL and no rates_import) must not be reported as available")
+ failed = True
+ if empty_tariff.month_available(2025, 3):
+ print(" ERROR: an unconfigured tariff must not be reported as available")
+ failed = True
+
+ print("Test: day_of_week/date basic rate entries are ignored (not applied using today's real weekday) and a warning is logged")
+ logged = []
+
+ def capturing_log(message):
+ """Capture log messages for assertion, while still printing them for visibility."""
+ logged.append(message)
+ print(message)
+
+ weekday_config = {
+ "rates_import": [{"start": "00:00:00", "end": "00:00:00", "rate": 12.0}, {"start": "00:00:00", "end": "00:00:00", "rate": 99.0, "day_of_week": "1,2,3,4,5"}],
+ "rates_export": [{"rate": 5.0}],
+ "standing_charge_p_per_day": 0.0,
+ }
+ weekday_tariff = AnnualTariff(weekday_config, log=capturing_log, predbat=my_predbat, fetch_json=fake_fetch)
+ if not asyncio.run(weekday_tariff.fetch_month(2025, 3)):
+ print(" ERROR: basic rates should always be available")
+ failed = True
+ weekday_import, _ = weekday_tariff.rates_for(midnight, 24 * 60)
+ if abs(weekday_import[0] - 12.0) > 0.001:
+ print(" ERROR: the day_of_week entry must be ignored during a historical replay, expected the base rate 12.0, got {}".format(weekday_import[0]))
+ failed = True
+ if not any(("day_of_week" in message) and ("ignoring" in message.lower()) for message in logged):
+ print(" ERROR: expected a warning that day_of_week/date entries cannot be honoured and are being ignored, got {}".format(logged))
+ failed = True
+
+ print("Test: basic rates are computed once and cached, not recomputed on every rates_for call")
+ basic_rates_calls = []
+ original_basic_rates = my_predbat.basic_rates
+
+ def counting_basic_rates(info, rtype, prev=None, rate_replicate=None):
+ """Wrap basic_rates to count how many times it is actually invoked, then delegate to the real implementation."""
+ basic_rates_calls.append(rtype)
+ return original_basic_rates(info, rtype, prev=prev, rate_replicate=rate_replicate)
+
+ my_predbat.basic_rates = counting_basic_rates
+ try:
+ caching_tariff = AnnualTariff(basic_config, log=print, predbat=my_predbat, fetch_json=fake_fetch)
+ asyncio.run(caching_tariff.fetch_month(2025, 3))
+ caching_tariff.rates_for(midnight, 48 * 60)
+ caching_tariff.rates_for(midnight, 48 * 60)
+ caching_tariff.rates_for(pytz.utc.localize(datetime(2025, 3, 11)), 48 * 60)
+ finally:
+ my_predbat.basic_rates = original_basic_rates
+ if basic_rates_calls.count("rates_import") != 1 or basic_rates_calls.count("rates_export") != 1:
+ print(" ERROR: expected basic_rates called exactly once per rate type across three rates_for calls, got {}".format(basic_rates_calls))
+ failed = True
+
+ print("Test: standing charge is carried through from config")
+ if abs(tariff.standing_charge_p_per_day - 60.0) > 0.001:
+ print(" ERROR: standing charge expected 60.0, got {}".format(tariff.standing_charge_p_per_day))
+ failed = True
+
+ print("Test: a month with real historical rates does not trigger the current-rates fallback")
+ if tariff.fallback_months:
+ print(" ERROR: a tariff with real historical data must not record a fallback, got {}".format(tariff.fallback_months))
+ failed = True
+
+ print("Test: an empty ranged download falls back to the bare-URL current-rates pattern, keyed by local time of day")
+ bare_calls = {"import": 0, "export": 0}
+
+ async def fallback_fetch(url):
+ """Serve an empty ranged download (a tariff with no historical rates for this date) and a two-rate bare-URL pattern for the current rates, counting bare-URL requests per side."""
+ if "period_from" in url:
+ return {"results": [], "next": None}
+ if "/import/" in url:
+ bare_calls["import"] += 1
+ elif "/export/" in url:
+ bare_calls["export"] += 1
+ return {"results": build_current_pattern_rows(), "next": None}
+
+ fallback_config = {"import_octopus_url": "https://example.com/import/", "export_octopus_url": "https://example.com/export/", "standing_charge_p_per_day": 0.0}
+ fallback_tariff = AnnualTariff(fallback_config, log=print, predbat=my_predbat, fetch_json=fallback_fetch, timezone="Europe/London")
+
+ if not asyncio.run(fallback_tariff.fetch_month(2025, 7)):
+ print(" ERROR: fetch_month should fall back to the current pattern and report July 2025 available")
+ failed = True
+ if not fallback_tariff.month_available(2025, 7):
+ print(" ERROR: July 2025 should be reported available via the fallback pattern")
+ failed = True
+ if (2025, 7, "import") not in fallback_tariff.fallback_months or (2025, 7, "export") not in fallback_tariff.fallback_months:
+ print(" ERROR: expected both (2025, 7, 'import') and (2025, 7, 'export') recorded as fallback months, got {}".format(fallback_tariff.fallback_months))
+ failed = True
+
+ print("Test: the fallback pattern places the peak rate at the correct local hour either side of a DST boundary")
+ # The synthetic bare pattern's peak is 15:00-18:00 UTC as genuinely observed in BST
+ # (see build_current_pattern_rows), i.e. 16:00-19:00 local. Keying by UTC minute-of-day
+ # instead of local would leave the peak at UTC 15:00-18:00 in both months below, so July's
+ # assertion would still pass but January's would not - which is exactly what this pair of
+ # assertions is designed to catch.
+ july_midnight = pytz.utc.localize(datetime(2025, 7, 10))
+ july_import, july_export = fallback_tariff.rates_for(july_midnight, 24 * 60)
+ if abs(july_export[15 * 60] - 16.0) > 0.01:
+ print(" ERROR: July (BST) peak export at UTC 15:00 (local 16:00) expected 16.0, got {}".format(july_export.get(15 * 60)))
+ failed = True
+ if abs(july_export[14 * 60 + 30] - 9.0) > 0.01:
+ print(" ERROR: July (BST) base export at UTC 14:30 (local 15:30) expected 9.0, got {}".format(july_export.get(14 * 60 + 30)))
+ failed = True
+ if abs(july_import[15 * 60] - 16.0) > 0.01:
+ print(" ERROR: import must fall back the same way as export; July peak import at UTC 15:00 expected 16.0, got {}".format(july_import.get(15 * 60)))
+ failed = True
+
+ if not asyncio.run(fallback_tariff.fetch_month(2025, 1)):
+ print(" ERROR: fetch_month should fall back to the current pattern for January 2025 too")
+ failed = True
+ if not fallback_tariff.month_available(2025, 1):
+ print(" ERROR: January 2025 should remain available via the fallback pattern (import falling back must not return False)")
+ failed = True
+ jan_midnight = pytz.utc.localize(datetime(2025, 1, 10))
+ jan_import, jan_export = fallback_tariff.rates_for(jan_midnight, 24 * 60)
+ if abs(jan_export[16 * 60] - 16.0) > 0.01:
+ print(" ERROR: January (GMT) peak export at UTC 16:00 (local 16:00) expected 16.0, got {}".format(jan_export.get(16 * 60)))
+ failed = True
+ if abs(jan_export[15 * 60] - 9.0) > 0.01:
+ print(" ERROR: January (GMT) base export at UTC 15:00 expected 9.0 (the peak must not have slid an hour early as it would with a UTC-keyed pattern), got {}".format(jan_export.get(15 * 60)))
+ failed = True
+
+ print("Test: the current-rates pattern is downloaded at most once per side across twelve fetch_month calls")
+ count_calls = {"import": 0, "export": 0}
+
+ async def counting_fallback_fetch(url):
+ """Serve an always-empty ranged download and a bare-URL pattern, counting bare-URL requests per side."""
+ if "period_from" in url:
+ return {"results": [], "next": None}
+ if "/import/" in url:
+ count_calls["import"] += 1
+ elif "/export/" in url:
+ count_calls["export"] += 1
+ return {"results": build_current_pattern_rows(), "next": None}
+
+ counting_tariff = AnnualTariff(fallback_config, log=print, predbat=my_predbat, fetch_json=counting_fallback_fetch, timezone="Europe/London")
+ for fallback_month in range(1, 13):
+ asyncio.run(counting_tariff.fetch_month(2025, fallback_month))
+ if count_calls["import"] != 1 or count_calls["export"] != 1:
+ print(" ERROR: expected the bare-URL current pattern requested exactly once per side across twelve fetch_month calls, got {}".format(count_calls))
+ failed = True
+
+ print("Test: a tariff whose ranged and bare downloads are both empty is not silently priced free")
+ empty_pattern_logged = []
+
+ def capture_empty_pattern_log(message):
+ """Capture log messages for assertion, while still printing them for visibility."""
+ empty_pattern_logged.append(message)
+ print(message)
+
+ async def all_empty_fetch(url):
+ """Simulate a tariff with no historical rates and no current rates either - every request returns nothing."""
+ return {"results": [], "next": None}
+
+ all_empty_config = {"import_octopus_url": "https://example.com/import/", "export_octopus_url": "https://example.com/export/", "standing_charge_p_per_day": 0.0}
+ all_empty_tariff = AnnualTariff(all_empty_config, log=capture_empty_pattern_log, predbat=my_predbat, fetch_json=all_empty_fetch, timezone="Europe/London")
+ if asyncio.run(all_empty_tariff.fetch_month(2025, 8)):
+ print(" ERROR: fetch_month should report unavailable when both the ranged and bare downloads are empty")
+ failed = True
+ if all_empty_tariff.month_available(2025, 8):
+ print(" ERROR: August 2025 must not be reported available when nothing at all was returned")
+ failed = True
+ if all_empty_tariff.fallback_months:
+ print(" ERROR: no fallback should be recorded when the bare pattern is also empty, got {}".format(all_empty_tariff.fallback_months))
+ failed = True
+ if not any(("export" in message) and ("unpaid" in message) for message in empty_pattern_logged):
+ print(" ERROR: expected the existing 'treating export as unpaid' warning when export has no rates at all, got {}".format(empty_pattern_logged))
+ failed = True
+ if (2025, 8) in all_empty_tariff.unpaid_export_months:
+ # Import also found nothing here, so the whole month is excluded from the
+ # results entirely (fetch_month returned False above) - it contributes no row,
+ # no cost, nothing. Surfacing "export was priced at zero" about a month that was
+ # never billed at all would describe a month that doesn't appear anywhere in the
+ # results document.
+ print(" ERROR: a month already unavailable because import failed must not also be recorded in unpaid_export_months, got {}".format(all_empty_tariff.unpaid_export_months))
+ failed = True
+
+ print("Test: an open-ended (valid_to: null) row - how Octopus publishes a flat product's current rate - fills every slot of the day, not just 30 minutes")
+ open_ended_calls = []
+
+ async def open_ended_fetch(url):
+ """Serve an empty ranged download and, for the bare URL, a single open-ended row exactly as OUTGOING-VAR-24-10-26 (a real flat export product) publishes its current rate."""
+ if "period_from" in url:
+ return {"results": [], "next": None}
+ open_ended_calls.append(url)
+ return {"results": [{"value_inc_vat": 12.0, "valid_from": "2026-03-01T00:00:00Z", "valid_to": None}], "next": None}
+
+ open_ended_config = {"export_octopus_url": "https://example.com/export/", "standing_charge_p_per_day": 0.0}
+ open_ended_tariff = AnnualTariff(open_ended_config, log=print, predbat=my_predbat, fetch_json=open_ended_fetch, timezone="Europe/London")
+ if not asyncio.run(open_ended_tariff.fetch_month(2025, 4)):
+ print(" ERROR: fetch_month should fall back to the open-ended current pattern")
+ failed = True
+ open_ended_midnight = pytz.utc.localize(datetime(2025, 4, 10))
+ _, open_ended_export = open_ended_tariff.rates_for(open_ended_midnight, 24 * 60)
+ if len(open_ended_export) != 24 * 60:
+ print(" ERROR: an open-ended row must rate every one of the day's 1440 minutes, got {} minutes rated".format(len(open_ended_export)))
+ failed = True
+ if any(abs(rate - 12.0) > 0.001 for rate in open_ended_export.values()):
+ print(" ERROR: every minute should carry the open-ended rate 12.0, got distinct values {}".format(set(open_ended_export.values())))
+ failed = True
+
+ print("Test: a genuinely time-limited row still overrides an open-ended base rate for the slots it covers (most-recent-valid_from precedence)")
+
+ # Mirrors the real OUTGOING-VAR-24-10-26 payload verified during review: a closed
+ # historical rate (15.0p, in force until the rate change) plus the open-ended
+ # current rate that superseded it (12.0p). The open-ended row's valid_from is more
+ # recent, so it must win for every slot, not just the 30 minutes after it starts.
+ async def precedence_fetch(url):
+ """Serve an empty ranged download and, for the bare URL, an open-ended current rate plus the closed rate it superseded."""
+ if "period_from" in url:
+ return {"results": [], "next": None}
+ return {
+ "results": [
+ {"value_inc_vat": 12.0, "valid_from": "2026-03-01T00:00:00Z", "valid_to": None},
+ {"value_inc_vat": 15.0, "valid_from": "2024-10-25T23:00:00Z", "valid_to": "2026-03-01T00:00:00Z"},
+ ],
+ "next": None,
+ }
+
+ precedence_tariff = AnnualTariff(open_ended_config, log=print, predbat=my_predbat, fetch_json=precedence_fetch, timezone="Europe/London")
+ if not asyncio.run(precedence_tariff.fetch_month(2025, 5)):
+ print(" ERROR: fetch_month should fall back to the current pattern")
+ failed = True
+ precedence_midnight = pytz.utc.localize(datetime(2025, 5, 10))
+ _, precedence_export = precedence_tariff.rates_for(precedence_midnight, 24 * 60)
+ if len(precedence_export) != 24 * 60 or any(abs(rate - 12.0) > 0.001 for rate in precedence_export.values()):
+ print(" ERROR: the more recent open-ended row (12.0) should win every slot over the older closed row (15.0), got values {}".format(set(precedence_export.values())))
+ failed = True
+
+ print("Test: a partial-day bare-URL payload (a legitimate partial trailing window, not an error) is refused as a fallback rather than accepted with holes")
+ # Only 10 of 24 hours worth of half-hourly rows - exactly the shape a bare request
+ # can legitimately be answered with depending on when it is made. Accepting this as
+ # a pattern would leave 28 of the day's 48 slots unrated, which prices those hours
+ # at zero - the exact failure mode the current-rates fallback exists to fix.
+ partial_logged = []
+
+ def capture_partial_log(message):
+ """Capture log messages for assertion, while still printing them for visibility."""
+ partial_logged.append(message)
+ print(message)
+
+ async def partial_day_fetch(url):
+ """Serve an empty ranged download and, for the bare URL, only 10 hours of half-hourly rows."""
+ if "period_from" in url:
+ return {"results": [], "next": None}
+ return {"results": build_current_pattern_rows(days=1, base_rate=9.0, peak_rate=9.0)[:20], "next": None}
+
+ partial_config = {"import_octopus_url": "https://example.com/import/", "export_octopus_url": "https://example.com/export/", "standing_charge_p_per_day": 0.0}
+ partial_tariff = AnnualTariff(partial_config, log=capture_partial_log, predbat=my_predbat, fetch_json=partial_day_fetch, timezone="Europe/London")
+ if asyncio.run(partial_tariff.fetch_month(2025, 6)):
+ print(" ERROR: a partial-day current-rates pattern must not be accepted as a fallback; import should report the month unavailable")
+ failed = True
+ if partial_tariff.month_available(2025, 6):
+ print(" ERROR: June 2025 must not be available from a partial-day pattern")
+ failed = True
+ if partial_tariff.fallback_months:
+ print(" ERROR: a partial-day pattern must not be recorded as a usable fallback, got {}".format(partial_tariff.fallback_months))
+ failed = True
+ if (2025, 6) in partial_tariff.unpaid_export_months:
+ # Import's only candidate pattern is the same partial-day payload, so import is
+ # unavailable too and the whole month is excluded (fetch_month returned False
+ # above) - it must not also show up as an "export priced at zero" month it was
+ # never billed for.
+ print(" ERROR: a month already unavailable because import failed must not also be recorded in unpaid_export_months, got {}".format(partial_tariff.unpaid_export_months))
+ failed = True
+ if not any("half-hour slot" in message.lower() for message in partial_logged):
+ print(" ERROR: expected a warning explaining the pattern was refused for incomplete coverage, got {}".format(partial_logged))
+ failed = True
+
+ print("Test: a timezone whose UTC offset is not a whole multiple of 30 minutes (Asia/Kathmandu, +05:45) fails closed rather than producing a pattern with holes")
+ # _stamped_rates_from_pattern looks up every real minute by flooring its local time
+ # onto the fixed 0, 30, 60, ... grid. Kathmandu's +05:45 offset is 345 minutes, and
+ # 345 % 30 == 15, so every half-hourly UTC row converts to a local time exactly 15
+ # minutes off that grid - :15 and :45 past the hour, never :00 or :30. No amount of
+ # half-hourly bare data can ever produce a pattern that lands on the standard grid
+ # under this offset, so this must fail closed by design (like the partial-day case
+ # above), not be quietly accepted with every lookup silently missing.
+ kathmandu_tariff = AnnualTariff(fallback_config, log=print, predbat=my_predbat, fetch_json=fallback_fetch, timezone="Asia/Kathmandu")
+ if asyncio.run(kathmandu_tariff.fetch_month(2025, 6)):
+ print(" ERROR: a +05:45 offset timezone cannot produce a half-hour-grid-aligned pattern and must fail closed, got the month reported available")
+ failed = True
+ if kathmandu_tariff.month_available(2025, 6):
+ print(" ERROR: a +05:45 offset timezone must not report the month available via a misaligned pattern")
+ failed = True
+ if kathmandu_tariff.fallback_months:
+ print(" ERROR: a +05:45 offset timezone must not record a fallback it cannot actually deliver, got {}".format(kathmandu_tariff.fallback_months))
+ failed = True
+
+ print("Test: export priced at zero IS surfaced as unpaid when the month is otherwise available (import succeeds, export finds nothing at all)")
+ # The mirror image of the two tests just above: there, suppressing unpaid_export_months
+ # was correct because the whole month was excluded anyway. Here import succeeds, so the
+ # month is billed and included in the results - which is exactly when a reader needs to
+ # be told export contributed nothing to it.
+ mixed_config = {"import_octopus_url": "https://example.com/import/", "export_octopus_url": "https://example.com/export/", "standing_charge_p_per_day": 0.0}
+
+ async def import_ok_export_empty_fetch(url):
+ """Serve real, paginated import data on every request but answer every export request (ranged and bare alike) with nothing at all."""
+ if "/import/" in url:
+ return {"results": build_agile_results(date(2025, 9, 1), 32, 20.0), "next": None}
+ return {"results": [], "next": None}
+
+ mixed_tariff = AnnualTariff(mixed_config, log=print, predbat=my_predbat, fetch_json=import_ok_export_empty_fetch, timezone="Europe/London")
+ if not asyncio.run(mixed_tariff.fetch_month(2025, 9)):
+ print(" ERROR: the month should be available - import succeeded even though export found nothing at all")
+ failed = True
+ if (2025, 9) not in mixed_tariff.unpaid_export_months:
+ print(" ERROR: export-priced-at-zero should be surfaced when the month IS otherwise available (import succeeded), got {}".format(mixed_tariff.unpaid_export_months))
+ failed = True
+
+ print("Test: a genuine download failure (not a genuinely empty result) does not trigger the current-rates fallback")
+ failure_bare_calls = []
+
+ async def failure_then_bare_fetch(url):
+ """Simulate the ranged download failing outright (not a clean zero-row response) while the bare URL would happily serve a pattern if it were ever called."""
+ if "period_from" in url:
+ return None
+ failure_bare_calls.append(url)
+ return {"results": build_current_pattern_rows(), "next": None}
+
+ failure_config = {"import_octopus_url": "https://example.com/import/", "standing_charge_p_per_day": 0.0}
+ failure_tariff = AnnualTariff(failure_config, log=print, predbat=my_predbat, fetch_json=failure_then_bare_fetch, timezone="Europe/London")
+ if asyncio.run(failure_tariff.fetch_month(2025, 9)):
+ print(" ERROR: a genuine download failure must not fall back and must report the month unavailable, exactly as before this feature existed")
+ failed = True
+ if failure_tariff.month_available(2025, 9):
+ print(" ERROR: September 2025 must not be reported available after a genuine download failure")
+ failed = True
+ if failure_tariff.fallback_months:
+ print(" ERROR: a genuine download failure must not be recorded as a fallback, got {}".format(failure_tariff.fallback_months))
+ failed = True
+ if failure_bare_calls:
+ print(" ERROR: the bare-URL current pattern must never be requested after a genuine download failure (only after a confirmed, genuinely empty result), got {} calls".format(len(failure_bare_calls)))
+ failed = True
+
+ print("Test: the page-cap safety limit is also treated as a failure, not a genuinely empty result")
+ cap_bare_calls = []
+
+ async def cap_then_bare_fetch(url):
+ """Simulate the ranged download hitting the page-cap (always reporting another page) while the bare URL would happily serve a pattern if it were ever called."""
+ if "period_from" in url:
+ return {"results": build_agile_results(date(2025, 10, 1), 1, 20.0), "next": url + "&more"}
+ cap_bare_calls.append(url)
+ return {"results": build_current_pattern_rows(), "next": None}
+
+ cap_config = {"import_octopus_url": "https://example.com/import/", "standing_charge_p_per_day": 0.0}
+ cap_fallback_tariff = AnnualTariff(cap_config, log=print, predbat=my_predbat, fetch_json=cap_then_bare_fetch, timezone="Europe/London")
+ if asyncio.run(cap_fallback_tariff.fetch_month(2025, 10)):
+ print(" ERROR: a page-cap truncation must not fall back and must report the month unavailable")
+ failed = True
+ if cap_bare_calls:
+ print(" ERROR: the bare-URL current pattern must never be requested after a page-cap truncation, got {} calls".format(len(cap_bare_calls)))
+ failed = True
+
+ print("Test: the current-rates snapshot is cached with an expiry, not forever")
+ expiry_storage = FakeAnnualStorage()
+ expiry_tariff = AnnualTariff(fallback_config, log=print, predbat=my_predbat, storage=expiry_storage, fetch_json=fallback_fetch, timezone="Europe/London")
+ asyncio.run(expiry_tariff.fetch_month(2025, 11))
+ export_pattern_key = ("annual", "current_pattern_export_{}".format(AnnualTariff._url_cache_id(expiry_tariff.export_url)))
+ export_pattern_expiry = expiry_storage.expiries.get(export_pattern_key)
+ if export_pattern_expiry is None:
+ print(" ERROR: expected the current-rates pattern cache entry to carry a non-None expiry (a 'now' snapshot must not be cached forever), got {}".format(expiry_storage.expiries))
+ failed = True
+
+ # The per-month ranged download, by contrast, covers a fixed historical date that can
+ # never change, so it must still be cached with no expiry - proved here against a real,
+ # non-empty ranged download (the expiry_tariff case above never actually cached a ranged
+ # entry at all, since its ranged downloads were empty, so it couldn't tell them apart).
+ history_storage = FakeAnnualStorage()
+ history_tariff = AnnualTariff(config, log=print, predbat=my_predbat, storage=history_storage, fetch_json=fake_fetch, timezone="Europe/London")
+ asyncio.run(history_tariff.fetch_month(2025, 3))
+ history_export_key = ("annual", "rates_export_{}_2025_03".format(AnnualTariff._url_cache_id(history_tariff.export_url)))
+ if history_export_key not in history_storage.expiries:
+ print(" ERROR: expected the real historical download to have been cached at all, got {}".format(history_storage.store.keys()))
+ failed = True
+ if history_storage.expiries.get(history_export_key) is not None:
+ print(" ERROR: a per-month historical download must still be cached with no expiry, got {}".format(history_storage.expiries.get(history_export_key)))
+ failed = True
+
+ print("Test: two tariffs with different URLs sharing one storage do not see each other's cached rates (the ranged, per-month path)")
+ # Reproduces the reviewer's scenario: the web UI runs every job against one fixed
+ # work dir, so its on-disk cache is shared across runs and tariffs, not scoped to
+ # either. Before the URL was mixed into the cache key, tariff B here would have been
+ # silently served tariff A's cached rows under the same bare "rates_import_2025_10"
+ # key - reported status "ok", no rates_synthesised marker, no caveat, wrong price.
+ shared_storage = FakeAnnualStorage()
+
+ async def tariff_a_fetch(url):
+ """Serve tariff A's own distinct rate shape (base rate 5.0p)."""
+ return {"results": build_agile_results(date(2025, 10, 1), 32, 5.0), "next": None}
+
+ async def tariff_b_fetch(url):
+ """Serve tariff B's own distinct rate shape (base rate 30.0p) - must never be shadowed by A's cache entry."""
+ return {"results": build_agile_results(date(2025, 10, 1), 32, 30.0), "next": None}
+
+ tariff_a_config = {"import_octopus_url": "https://example.com/tariff-a/import/", "standing_charge_p_per_day": 0.0}
+ tariff_b_config = {"import_octopus_url": "https://example.com/tariff-b/import/", "standing_charge_p_per_day": 0.0}
+
+ tariff_a = AnnualTariff(tariff_a_config, log=print, predbat=my_predbat, storage=shared_storage, fetch_json=tariff_a_fetch, timezone="Europe/London")
+ if not asyncio.run(tariff_a.fetch_month(2025, 10)):
+ print(" ERROR: tariff A should fetch successfully")
+ failed = True
+ tariff_b = AnnualTariff(tariff_b_config, log=print, predbat=my_predbat, storage=shared_storage, fetch_json=tariff_b_fetch, timezone="Europe/London")
+ if not asyncio.run(tariff_b.fetch_month(2025, 10)):
+ print(" ERROR: tariff B should fetch successfully")
+ failed = True
+
+ cache_midnight = pytz.utc.localize(datetime(2025, 10, 10))
+ a_import, _ = tariff_a.rates_for(cache_midnight, 60)
+ b_import, _ = tariff_b.rates_for(cache_midnight, 60)
+ # Minute 0 (00:00) is in the cheap overnight band: base_rate * 0.3
+ if abs(a_import[0] - 1.5) > 0.01:
+ print(" ERROR: tariff A's own rate expected 1.5 (5.0 * 0.3), got {}".format(a_import.get(0)))
+ failed = True
+ if abs(b_import[0] - 9.0) > 0.01:
+ print(" ERROR: tariff B's own rate expected 9.0 (30.0 * 0.3), got {} - if this is 1.5 instead, tariff B was served tariff A's cached rows".format(b_import.get(0)))
+ failed = True
+
+ print("Test: the same tariff URL across two instances still hits its own cache (this is what makes re-runs fast)")
+
+ async def must_not_be_called(url):
+ """Fail the test outright if called - a second instance of the same URL must be served entirely from cache."""
+ raise AssertionError("fetch_json must not be called; a second AnnualTariff for the same URL should be served from its own cached rows")
+
+ tariff_a_again = AnnualTariff(tariff_a_config, log=print, predbat=my_predbat, storage=shared_storage, fetch_json=must_not_be_called, timezone="Europe/London")
+ if not asyncio.run(tariff_a_again.fetch_month(2025, 10)):
+ print(" ERROR: a second instance of the same tariff URL should be served from cache and still succeed")
+ failed = True
+ a_again_import, _ = tariff_a_again.rates_for(cache_midnight, 60)
+ if abs(a_again_import[0] - 1.5) > 0.01:
+ print(" ERROR: the cached rate should still be tariff A's own 1.5, got {}".format(a_again_import.get(0)))
+ failed = True
+
+ print("Test: two tariffs with different URLs sharing one storage do not see each other's cached current-rates pattern (the bare-URL fallback path)")
+ pattern_storage = FakeAnnualStorage()
+
+ async def tariff_c_bare_fetch(url):
+ """Serve an empty ranged download and, for the bare URL, tariff C's own flat rate (5.0p)."""
+ if "period_from" in url:
+ return {"results": [], "next": None}
+ return {"results": [{"value_inc_vat": 5.0, "valid_from": "2026-03-01T00:00:00Z", "valid_to": None}], "next": None}
+
+ async def tariff_d_bare_fetch(url):
+ """Serve an empty ranged download and, for the bare URL, tariff D's own flat rate (30.0p) - must never be shadowed by C's cache entry."""
+ if "period_from" in url:
+ return {"results": [], "next": None}
+ return {"results": [{"value_inc_vat": 30.0, "valid_from": "2026-03-01T00:00:00Z", "valid_to": None}], "next": None}
+
+ tariff_c_config = {"export_octopus_url": "https://example.com/tariff-c/export/", "standing_charge_p_per_day": 0.0}
+ tariff_d_config = {"export_octopus_url": "https://example.com/tariff-d/export/", "standing_charge_p_per_day": 0.0}
+
+ tariff_c = AnnualTariff(tariff_c_config, log=print, predbat=my_predbat, storage=pattern_storage, fetch_json=tariff_c_bare_fetch, timezone="Europe/London")
+ asyncio.run(tariff_c.fetch_month(2025, 11))
+ tariff_d = AnnualTariff(tariff_d_config, log=print, predbat=my_predbat, storage=pattern_storage, fetch_json=tariff_d_bare_fetch, timezone="Europe/London")
+ asyncio.run(tariff_d.fetch_month(2025, 11))
+
+ cache_midnight_2 = pytz.utc.localize(datetime(2025, 11, 10))
+ _, c_export = tariff_c.rates_for(cache_midnight_2, 60)
+ _, d_export = tariff_d.rates_for(cache_midnight_2, 60)
+ if abs(c_export.get(0, -1) - 5.0) > 0.01:
+ print(" ERROR: tariff C's own export rate expected 5.0, got {}".format(c_export.get(0)))
+ failed = True
+ if abs(d_export.get(0, -1) - 30.0) > 0.01:
+ print(" ERROR: tariff D's own export rate expected 30.0, got {} - if this is 5.0 instead, tariff D was served tariff C's cached current-rates pattern".format(d_export.get(0)))
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_annual_weather.py b/apps/predbat/tests/test_annual_weather.py
new file mode 100644
index 000000000..9315915c3
--- /dev/null
+++ b/apps/predbat/tests/test_annual_weather.py
@@ -0,0 +1,322 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the annual prediction Open-Meteo weather module."""
+
+import asyncio
+from datetime import date, datetime, timedelta
+
+import pytz
+
+from annual_weather import AnnualWeather, percentile
+
+ARRAYS = [{"kwp": 5.0, "declination": 35, "azimuth": 180, "efficiency": 0.95}]
+TWO_ARRAYS = [
+ {"kwp": 5.0, "declination": 35, "azimuth": 180, "efficiency": 0.95},
+ {"kwp": 3.0, "declination": 45, "azimuth": 180, "efficiency": 0.95},
+]
+
+
+class FakeAnnualStorage:
+ """Minimal in-memory async Storage stand-in for exercising the cache path with no network I/O."""
+
+ def __init__(self):
+ """Start with an empty cache and no recorded saves."""
+ self.cache = {}
+ self.saved_keys = []
+
+ async def load(self, module, filename):
+ """Return the cached payload for a module/filename pair, or None if never saved."""
+ return self.cache.get((module, filename))
+
+ async def save(self, module, filename, data, format="json", expiry=None):
+ """Record a cache write, keyed by module and filename, ignoring format/expiry."""
+ self.cache[(module, filename)] = data
+ self.saved_keys.append((module, filename))
+
+
+def build_hourly(start_day, days, peak_gti):
+ """Build a synthetic Open-Meteo hourly payload with a fixed daily irradiance curve."""
+ times = []
+ gti = []
+ temp = []
+ wind = []
+ curve = [0, 0, 0, 0, 0, 0, 0.1, 0.3, 0.5, 0.7, 0.85, 0.95, 1.0, 0.95, 0.85, 0.7, 0.5, 0.3, 0.1, 0, 0, 0, 0, 0]
+ for offset in range(days):
+ day = start_day + timedelta(days=offset)
+ for hour in range(24):
+ times.append("{}T{:02d}:00".format(day.isoformat(), hour))
+ gti.append(peak_gti * curve[hour])
+ temp.append(15.0)
+ wind.append(1.5)
+ return {"hourly": {"time": times, "global_tilted_irradiance": gti, "temperature_2m": temp, "wind_speed_10m": wind}}
+
+
+def test_annual_weather(my_predbat):
+ """Verify weather fetching, P10 derivation from forecast error, and the fallback path."""
+ failed = False
+ print("**** Testing annual_weather ****")
+
+ print("Test: percentile picks the expected order statistic")
+ values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
+ got = percentile(values, 0.10)
+ if got != 1.0:
+ print(" ERROR: 10th percentile of 1..10 expected 1.0, got {}".format(got))
+ failed = True
+ if percentile([], 0.10) != 0.0:
+ print(" ERROR: percentile of an empty list should be 0.0")
+ failed = True
+
+ # Actuals peak at 800, forecast peaks at 1000, so every day's actual/forecast ratio is 0.8
+ start = date(2025, 1, 1)
+ actual_payload = build_hourly(start, 40, 800.0)
+ forecast_payload = build_hourly(start, 40, 1000.0)
+
+ async def fake_fetch(url):
+ """Return the archive or forecast payload depending on the host in the URL."""
+ if "archive-api" in url:
+ return actual_payload
+ return forecast_payload
+
+ weather = AnnualWeather(ARRAYS, latitude=51.5, longitude=-0.1, log=print, fetch_json=fake_fetch)
+ year = asyncio.run(weather.fetch(2025))
+
+ print("Test: the forecast archive is reported as available")
+ if not year.forecast_available:
+ print(" ERROR: forecast_available should be True when both payloads parse")
+ failed = True
+
+ print("Test: January's P10 ratio reflects the measured 0.8 forecast error")
+ ratio = year.p10_ratio(1)
+ # Every day in the fixture uses the identical curve, so the day-to-day ratio - and hence
+ # the P10 order statistic over it - is fully deterministic. The shared solar model's
+ # GTI-dependent cell-temperature derate (Task 1) means this is not the raw 800/1000 GTI
+ # ratio: a naive implementation that skipped the temperature derate would land on exactly
+ # 0.8, so a tight tolerance around the true measured value also catches that regression.
+ if abs(ratio - 0.8162221180559525) > 1e-6:
+ print(" ERROR: expected the measured P10 ratio of 0.8162221180559525, got {}".format(ratio))
+ failed = True
+
+ print("Test: actual daily energy is below forecast daily energy")
+ day = date(2025, 1, 10)
+ if not year.has_actual(day):
+ print(" ERROR: expected actual data for {}".format(day))
+ failed = True
+ midnight = pytz.utc.localize(datetime(2025, 1, 10, 0, 0))
+ actual_minutes = year.pv_minutes("actual", midnight, 24 * 60)
+ forecast_minutes = year.pv_minutes("forecast", midnight, 24 * 60)
+ actual_total = sum(actual_minutes.values())
+ forecast_total = sum(forecast_minutes.values())
+ if actual_total <= 0:
+ print(" ERROR: actual PV for {} should be positive, got {}".format(day, actual_total))
+ failed = True
+ if forecast_total <= actual_total:
+ print(" ERROR: forecast total {} should exceed actual total {}".format(forecast_total, actual_total))
+ failed = True
+
+ print("Test: pv_minutes covers a 48 hour window and is keyed by absolute minute")
+ two_day = year.pv_minutes("actual", midnight, 48 * 60)
+ if abs(sum(two_day.values()) - (actual_total + year.daily_actual_kwh(date(2025, 1, 11)))) > 0.01:
+ print(" ERROR: the 48 hour window should equal two days of actual energy")
+ failed = True
+
+ print("Test: pv_minutes discards minutes at or beyond a window that ends mid-hour")
+ # 32.5 hours: deliberately not hour-aligned, and lands inside an hour with non-zero
+ # irradiance in the fixture curve (hour 8 of the day, curve value 0.5), so the cut is
+ # meaningful rather than falling in an always-zero night hour.
+ partial_window_minutes = 32 * 60 + 30
+ partial_window = year.pv_minutes("actual", midnight, partial_window_minutes)
+ if max(partial_window.keys()) >= partial_window_minutes:
+ print(" ERROR: pv_minutes must not emit minutes at or beyond the window length")
+ failed = True
+ dropped_minute = partial_window_minutes + 10
+ if dropped_minute not in two_day:
+ print(" ERROR: test fixture problem: minute {} should exist in the full 48 hour window".format(dropped_minute))
+ failed = True
+ if dropped_minute in partial_window:
+ print(" ERROR: minute {} is beyond the requested {} minute window and must be dropped".format(dropped_minute, partial_window_minutes))
+ failed = True
+
+ print("Test: pv_minutes_p10 scales the forecast series by the month ratio")
+ p10_minutes = year.pv_minutes_p10(midnight, 24 * 60, 1)
+ expected = forecast_total * year.p10_ratio(1)
+ if abs(sum(p10_minutes.values()) - expected) > 0.01:
+ print(" ERROR: P10 total {} expected {}".format(sum(p10_minutes.values()), expected))
+ failed = True
+
+ print("Test: a missing forecast archive falls back and records the degradation")
+
+ async def actuals_only_fetch(url):
+ """Serve actuals and fail every forecast request."""
+ if "archive-api" in url:
+ return actual_payload
+ return None
+
+ degraded_weather = AnnualWeather(ARRAYS, latitude=51.5, longitude=-0.1, log=print, fetch_json=actuals_only_fetch, p10_fallback=0.7)
+ degraded = asyncio.run(degraded_weather.fetch(2025))
+ if degraded.forecast_available:
+ print(" ERROR: forecast_available should be False when the forecast archive is empty")
+ failed = True
+ if abs(degraded.p10_ratio(1) - 0.7) > 1e-9:
+ print(" ERROR: expected the 0.7 fallback ratio, got {}".format(degraded.p10_ratio(1)))
+ failed = True
+ if 1 not in degraded.fallback_months:
+ print(" ERROR: January should be recorded in fallback_months")
+ failed = True
+ degraded_forecast = degraded.pv_minutes("forecast", midnight, 24 * 60)
+ degraded_actual = degraded.pv_minutes("actual", midnight, 24 * 60)
+ if abs(sum(degraded_forecast.values()) - sum(degraded_actual.values())) > 1e-9:
+ print(" ERROR: with no forecast archive the forecast series must fall back to actuals")
+ failed = True
+
+ print("Test: a month with fewer than seven usable days falls back")
+ sparse_actual = build_hourly(date(2025, 1, 1), 4, 800.0)
+ sparse_forecast = build_hourly(date(2025, 1, 1), 4, 1000.0)
+
+ async def sparse_fetch(url):
+ """Serve only four days of data."""
+ return sparse_actual if "archive-api" in url else sparse_forecast
+
+ sparse_weather = AnnualWeather(ARRAYS, latitude=51.5, longitude=-0.1, log=print, fetch_json=sparse_fetch, p10_fallback=0.7)
+ sparse = asyncio.run(sparse_weather.fetch(2025))
+ if abs(sparse.p10_ratio(1) - 0.7) > 1e-9:
+ print(" ERROR: a four-day month should fall back to 0.7, got {}".format(sparse.p10_ratio(1)))
+ failed = True
+
+ print("Test: the P10 order statistic is the 10th percentile day, not the mean or the minimum")
+ # Build February directly at the _derive_p10_ratios level, bypassing the GTI/solar-model
+ # conversion, so the per-day actual/forecast ratios are exact and independently chosen:
+ # 20 days, sorted ratios [0.5, 0.55, 0.9 x18]. With n=20 and fraction=0.10 the order
+ # statistic index is ceil(20*0.10)-1 = 1, i.e. the *second* smallest sample (0.55) - clearly
+ # distinct from the minimum (0.5), the mean (0.8625) and the median/mode (0.9).
+ february_ratios = [0.5, 0.55] + [0.9] * 18
+ february_actual_periods = {}
+ february_forecast_periods = {}
+ for day_index, day_ratio in enumerate(february_ratios, start=1):
+ stamp = pytz.utc.localize(datetime(2025, 2, day_index, 12, 0))
+ february_forecast_periods[stamp] = 10.0
+ february_actual_periods[stamp] = 10.0 * day_ratio
+ february_ratio_map, february_fallback_months = weather._derive_p10_ratios(february_actual_periods, february_forecast_periods, True)
+ expected_february_p10 = sorted(february_ratios)[1]
+ if abs(february_ratio_map.get(2, -1.0) - expected_february_p10) > 1e-9:
+ print(" ERROR: expected February's P10 ratio to be the 10th percentile day {}, got {}".format(expected_february_p10, february_ratio_map.get(2)))
+ failed = True
+ if abs(february_ratio_map.get(2, -1.0) - min(february_ratios)) < 1e-9:
+ print(" ERROR: February's P10 ratio must not equal the minimum day's ratio")
+ failed = True
+ mean_february_ratio = sum(february_ratios) / len(february_ratios)
+ if abs(february_ratio_map.get(2, -1.0) - mean_february_ratio) < 1e-9:
+ print(" ERROR: February's P10 ratio must not equal the mean of the month's ratios")
+ failed = True
+ if 2 in february_fallback_months:
+ print(" ERROR: 20 usable day-pairs should not trigger the flat-derate fallback")
+ failed = True
+
+ print("Test: a cached response is only written when the payload is usable, and a cache hit avoids re-fetching")
+ cache_storage = FakeAnnualStorage()
+ fetch_calls = {"n": 0}
+
+ async def counting_fetch(url):
+ """Return the good fixture payloads while counting how many times it is called."""
+ fetch_calls["n"] += 1
+ return actual_payload if "archive-api" in url else forecast_payload
+
+ cached_weather = AnnualWeather(ARRAYS, latitude=51.5, longitude=-0.1, log=print, storage=cache_storage, fetch_json=counting_fetch)
+ cached_year_first = asyncio.run(cached_weather.fetch(2025))
+ if len(cache_storage.saved_keys) != 2:
+ print(" ERROR: a usable actual payload and a usable forecast payload should both be cached, got {} saves".format(len(cache_storage.saved_keys)))
+ failed = True
+ calls_after_first_fetch = fetch_calls["n"]
+ cached_year_second = asyncio.run(cached_weather.fetch(2025))
+ if fetch_calls["n"] != calls_after_first_fetch:
+ print(" ERROR: a cache hit should not call fetch_json again, but call count went from {} to {}".format(calls_after_first_fetch, fetch_calls["n"]))
+ failed = True
+ first_total = sum(cached_year_first.pv_minutes("actual", midnight, 24 * 60).values())
+ second_total = sum(cached_year_second.pv_minutes("actual", midnight, 24 * 60).values())
+ if abs(first_total - second_total) > 1e-9:
+ print(" ERROR: a cache hit should reproduce the same result as a fresh fetch, got {} vs {}".format(first_total, second_total))
+ failed = True
+
+ print("Test: a truncated payload is rejected and never cached")
+ truncated_storage = FakeAnnualStorage()
+ truncated_payload = build_hourly(start, 5, 800.0)
+ truncated_payload["hourly"]["global_tilted_irradiance"] = truncated_payload["hourly"]["global_tilted_irradiance"][:-5]
+
+ async def truncated_fetch(url):
+ """Always return a payload whose irradiance list is shorter than its time list."""
+ return truncated_payload
+
+ truncated_weather = AnnualWeather(ARRAYS, latitude=51.5, longitude=-0.1, log=print, storage=truncated_storage, fetch_json=truncated_fetch)
+ truncated_year = asyncio.run(truncated_weather.fetch(2025))
+ if truncated_year.forecast_available:
+ print(" ERROR: a truncated payload must not count as an available forecast series")
+ failed = True
+ if truncated_year.actual_periods:
+ print(" ERROR: a truncated payload must not be used for the actual series either")
+ failed = True
+ if truncated_storage.saved_keys:
+ print(" ERROR: a truncated payload must never be cached, but {} saves were recorded".format(truncated_storage.saved_keys))
+ failed = True
+
+ print("Test: a cache entry poisoned before this guard existed is discarded and re-fetched, not trusted forever")
+ poisoned_storage = FakeAnnualStorage()
+ poisoned_storage.cache[("annual", "weather_actual_2025_0_51.5_-0.1")] = truncated_payload
+ recovering_calls = {"n": 0}
+
+ async def recovering_fetch(url):
+ """Return good fixture payloads, counting calls to prove the poisoned cache was bypassed."""
+ recovering_calls["n"] += 1
+ return actual_payload if "archive-api" in url else forecast_payload
+
+ recovering_weather = AnnualWeather(ARRAYS, latitude=51.5, longitude=-0.1, log=print, storage=poisoned_storage, fetch_json=recovering_fetch)
+ recovering_year = asyncio.run(recovering_weather.fetch(2025))
+ if not recovering_year.has_actual(date(2025, 1, 10)):
+ print(" ERROR: a poisoned cache entry should be discarded and re-fetched rather than trusted forever")
+ failed = True
+ if recovering_calls["n"] == 0:
+ print(" ERROR: fetch_json should have been called since the cached actual payload was poisoned")
+ failed = True
+
+ print("Test: one array failing to parse abandons the whole series rather than blending a partial result")
+
+ async def forecast_array_fails_fetch(url):
+ """Serve good actuals for both arrays but fail the second array's forecast request."""
+ if "archive-api" in url:
+ return actual_payload
+ if "tilt=45" in url:
+ return None
+ return forecast_payload
+
+ partial_forecast_weather = AnnualWeather(TWO_ARRAYS, latitude=51.5, longitude=-0.1, log=print, fetch_json=forecast_array_fails_fetch, p10_fallback=0.7)
+ partial_forecast_year = asyncio.run(partial_forecast_weather.fetch(2025))
+ if partial_forecast_year.forecast_available:
+ print(" ERROR: forecast_available should be False when one of several arrays fails to parse")
+ failed = True
+ if 1 not in partial_forecast_year.fallback_months:
+ print(" ERROR: a forecast series abandoned by one failed array should still record the flat-derate fallback")
+ failed = True
+ if not partial_forecast_year.has_actual(date(2025, 1, 10)):
+ print(" ERROR: the actual series should still be populated when only the forecast fetch failed")
+ failed = True
+
+ print("Test: the actual series is also abandoned entirely when one of several arrays fails on that side")
+
+ async def actual_array_fails_fetch(url):
+ """Fail the second array's actual request while every forecast request succeeds."""
+ if "archive-api" in url:
+ return None if "tilt=45" in url else actual_payload
+ return forecast_payload
+
+ partial_actual_weather = AnnualWeather(TWO_ARRAYS, latitude=51.5, longitude=-0.1, log=print, fetch_json=actual_array_fails_fetch, p10_fallback=0.7)
+ partial_actual_year = asyncio.run(partial_actual_weather.fetch(2025))
+ if partial_actual_year.has_actual(date(2025, 1, 10)):
+ print(" ERROR: the actual series should be empty when one of several arrays fails to parse")
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_solar_model.py b/apps/predbat/tests/test_solar_model.py
new file mode 100644
index 000000000..d597827af
--- /dev/null
+++ b/apps/predbat/tests/test_solar_model.py
@@ -0,0 +1,137 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the shared solar GTI to kW conversion model."""
+
+from datetime import datetime
+
+import pytz
+
+from solar_model import convert_azimuth, gti_hourly_to_period_kwh, pvwatts_cell_temperature
+
+FLAT_TIMES = ["2025-06-01T{:02d}:00".format(hour) for hour in range(4)]
+
+
+def stamp_for(text):
+ """Return the tz-aware UTC datetime for an Open-Meteo timestamp string."""
+ return pytz.utc.localize(datetime.strptime(text, "%Y-%m-%dT%H:%M"))
+
+
+def test_solar_model(my_predbat):
+ """Verify the shared solar model against hand-derived values."""
+ failed = False
+ print("**** Testing solar_model ****")
+
+ print("Test: convert_azimuth maps the Predbat convention onto the Open-Meteo one")
+ for predbat_az, expected in [(180, 0), (90, 90), (270, -90), (0, 180)]:
+ result = convert_azimuth(predbat_az)
+ if result != expected:
+ print(" ERROR: convert_azimuth({}) expected {}, got {}".format(predbat_az, expected, result))
+ failed = True
+
+ print("Test: pvwatts_cell_temperature matches the SAPM formula")
+ # T_cell = 25 + 1000*exp(-3.47 + -0.0594*0) + (1000/1000)*3.0
+ # = 25 + 1000*0.031117 + 3 = 59.117
+ hot = pvwatts_cell_temperature(1000.0, 25.0, 0.0)
+ if abs(hot - 59.117) > 0.001:
+ print(" ERROR: cell temperature expected 59.117, got {}".format(hot))
+ failed = True
+ if pvwatts_cell_temperature(0.0, 20.0, 1.5) != 20.0:
+ print(" ERROR: zero irradiance should give ambient temperature")
+ failed = True
+
+ print("Test: a constant-irradiance hour converts to the hand-derived energy")
+ # eta = 1 - 0.004*(59.117 - 25) = 0.863532; pv = (1000/1000) * 1 kWp * eta * 1.0
+ # Both endpoints are equal so the trapezoid returns the same value.
+ flat_gti = [1000.0] * 4
+ flat_temp = [25.0] * 4
+ flat_wind = [0.0] * 4
+ result = gti_hourly_to_period_kwh(FLAT_TIMES, flat_gti, flat_temp, flat_wind, kwp=1.0, system_loss=0.0)
+ if len(result) != 3:
+ print(" ERROR: 4 samples should yield 3 integrated periods, got {}".format(len(result)))
+ failed = True
+ first = result.get(stamp_for(FLAT_TIMES[0]))
+ if first is None:
+ print(" ERROR: missing the first period")
+ failed = True
+ elif abs(first["pv_estimate"] - 0.8635) > 0.0001:
+ print(" ERROR: expected 0.8635 kWh, got {}".format(first["pv_estimate"]))
+ failed = True
+
+ print("Test: cold panels are allowed to exceed their STC rating")
+ # T_cell = 0 + 200*exp(-3.47 - 0.0594) + 0.6 = 6.4645; eta = 1.074142 (above 1.0)
+ cold = gti_hourly_to_period_kwh(FLAT_TIMES, [200.0] * 4, [0.0] * 4, [1.0] * 4, kwp=1.0, system_loss=0.0)
+ cold_first = cold[stamp_for(FLAT_TIMES[0])]
+ if abs(cold_first["pv_estimate"] - 0.2148) > 0.0001:
+ print(" ERROR: expected 0.2148 kWh for cold panels, got {}".format(cold_first["pv_estimate"]))
+ failed = True
+
+ print("Test: the trapezoid integrates a rising ramp to the mean of its endpoints")
+ ramp = gti_hourly_to_period_kwh(FLAT_TIMES, [0.0, 1000.0, 1000.0, 0.0], [25.0] * 4, [0.0] * 4, kwp=1.0, system_loss=0.0)
+ # Endpoints 0.0 and 0.8635 average to 0.43175, rounded to 4 places
+ if abs(ramp[stamp_for(FLAT_TIMES[0])]["pv_estimate"] - 0.4318) > 0.0001:
+ print(" ERROR: expected 0.4318 kWh across the sunrise hour, got {}".format(ramp[stamp_for(FLAT_TIMES[0])]["pv_estimate"]))
+ failed = True
+
+ print("Test: zero irradiance produces zero energy")
+ dark = gti_hourly_to_period_kwh(FLAT_TIMES, [0.0] * 4, [15.0] * 4, [1.0] * 4, kwp=5.0, system_loss=0.05)
+ if any(entry["pv_estimate"] != 0.0 for entry in dark.values()):
+ print(" ERROR: zero irradiance should give zero energy, got {}".format(dark))
+ failed = True
+
+ print("Test: system_loss and kwp scale the output linearly")
+ scaled = gti_hourly_to_period_kwh(FLAT_TIMES, flat_gti, flat_temp, flat_wind, kwp=2.0, system_loss=0.5)
+ if abs(scaled[stamp_for(FLAT_TIMES[0])]["pv_estimate"] - first["pv_estimate"]) > 0.0001:
+ print(" ERROR: doubling kwp and halving efficiency should cancel out, got {}".format(scaled[stamp_for(FLAT_TIMES[0])]["pv_estimate"]))
+ failed = True
+
+ print("Test: p10_fallback scales the P10 series and defaults to 0.7")
+ if abs(first["pv_estimate10"] - round(first["pv_estimate"] * 0.7, 4)) > 0.0001:
+ print(" ERROR: the default P10 fallback should be 0.7, got {}".format(first["pv_estimate10"]))
+ failed = True
+ half = gti_hourly_to_period_kwh(FLAT_TIMES, flat_gti, flat_temp, flat_wind, kwp=1.0, system_loss=0.0, p10_fallback=0.5)
+ half_first = half[stamp_for(FLAT_TIMES[0])]
+ if abs(half_first["pv_estimate10"] - round(half_first["pv_estimate"] * 0.5, 4)) > 0.0001:
+ print(" ERROR: p10_fallback 0.5 not applied, got {}".format(half_first["pv_estimate10"]))
+ failed = True
+
+ print("Test: p10_instant overrides the fallback and is capped at P50")
+ ensemble = {FLAT_TIMES[index]: 0.1 for index in range(4)}
+ with_ensemble = gti_hourly_to_period_kwh(FLAT_TIMES, flat_gti, flat_temp, flat_wind, kwp=1.0, system_loss=0.0, p10_instant=ensemble)
+ ensemble_first = with_ensemble[stamp_for(FLAT_TIMES[0])]
+ if ensemble_first["pv_estimate10"] >= ensemble_first["pv_estimate"]:
+ print(" ERROR: an ensemble P10 below P50 should stay below it, got {}".format(ensemble_first["pv_estimate10"]))
+ failed = True
+ huge = {FLAT_TIMES[index]: 99.0 for index in range(4)}
+ capped = gti_hourly_to_period_kwh(FLAT_TIMES, flat_gti, flat_temp, flat_wind, kwp=1.0, system_loss=0.0, p10_instant=huge)
+ capped_first = capped[stamp_for(FLAT_TIMES[0])]
+ if abs(capped_first["pv_estimate10"] - capped_first["pv_estimate"]) > 0.0001:
+ print(" ERROR: an ensemble P10 above P50 should be capped at P50, got {}".format(capped_first["pv_estimate10"]))
+ failed = True
+
+ print("Test: shading_factors apply the correct month")
+ shaded = gti_hourly_to_period_kwh(FLAT_TIMES, flat_gti, flat_temp, flat_wind, kwp=1.0, system_loss=0.0, shading_factors=[0.5] * 12)
+ if abs(shaded[stamp_for(FLAT_TIMES[0])]["pv_estimate"] - round(first["pv_estimate"] * 0.5, 4)) > 0.0001:
+ print(" ERROR: a 0.5 shading factor was not applied, got {}".format(shaded[stamp_for(FLAT_TIMES[0])]["pv_estimate"]))
+ failed = True
+
+ print("Test: a gap in the timestamps is not integrated across")
+ gapped_times = ["2025-06-01T00:00", "2025-06-01T01:00", "2025-06-01T05:00"]
+ gapped = gti_hourly_to_period_kwh(gapped_times, [1000.0] * 3, [25.0] * 3, [0.0] * 3, kwp=1.0, system_loss=0.0)
+ if len(gapped) != 1:
+ print(" ERROR: only the contiguous hour pair should integrate, got {} periods".format(len(gapped)))
+ failed = True
+
+ print("Test: a None irradiance sample is treated as zero rather than raising")
+ with_none = gti_hourly_to_period_kwh(FLAT_TIMES, [None, 1000.0, 1000.0, None], [25.0] * 4, [0.0] * 4, kwp=1.0, system_loss=0.0)
+ if abs(with_none[stamp_for(FLAT_TIMES[0])]["pv_estimate"] - 0.4318) > 0.0001:
+ print(" ERROR: a None sample should behave as zero, got {}".format(with_none[stamp_for(FLAT_TIMES[0])]["pv_estimate"]))
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_solcast.py b/apps/predbat/tests/test_solcast.py
index 3fd294ad9..573dd8b04 100644
--- a/apps/predbat/tests/test_solcast.py
+++ b/apps/predbat/tests/test_solcast.py
@@ -19,6 +19,7 @@
import aiohttp
from solcast import SolarAPI
+from solar_model import convert_azimuth
from storage import StorageLocalFiles
from tests.test_infra import run_async, create_aiohttp_mock_response
@@ -284,9 +285,9 @@ def test_convert_azimuth(my_predbat):
]
for solcast_az, expected in test_cases:
- result = test_api.solar.convert_azimuth(solcast_az)
+ result = convert_azimuth(solcast_az)
if result != expected:
- print(f"ERROR: convert_azimuth({solcast_az}) = {result}, expected {expected}")
+ print("ERROR: convert_azimuth({}) = {}, expected {}".format(solcast_az, result, expected))
failed = True
finally:
test_api.cleanup()
diff --git a/apps/predbat/tests/test_tariff_catalogue.py b/apps/predbat/tests/test_tariff_catalogue.py
new file mode 100644
index 000000000..7d6139d0c
--- /dev/null
+++ b/apps/predbat/tests/test_tariff_catalogue.py
@@ -0,0 +1,143 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the tariff catalogue used by the Annual tab's dropdown."""
+
+from tariff_catalogue import BUILTIN_TARIFFS, CUSTOM_ID, convert_compare_entry, merged_catalogue
+
+
+def test_tariff_catalogue(my_predbat):
+ """Verify the built-in catalogue, the Compare key mapping, and the merge."""
+ failed = False
+ print("**** Testing tariff_catalogue ****")
+
+ print("Test: every built-in entry has an id, a name and at least an import URL")
+ if not BUILTIN_TARIFFS:
+ print(" ERROR: the built-in catalogue is empty")
+ failed = True
+ seen_ids = set()
+ for entry in BUILTIN_TARIFFS:
+ for key in ["id", "name"]:
+ if not entry.get(key):
+ print(" ERROR: entry {} is missing '{}'".format(entry, key))
+ failed = True
+ if not entry.get("import_octopus_url") and not entry.get("rates_import"):
+ print(" ERROR: entry {} has neither an import URL nor fixed rates".format(entry.get("id")))
+ failed = True
+ if entry.get("id") in seen_ids:
+ print(" ERROR: duplicate id {}".format(entry.get("id")))
+ failed = True
+ seen_ids.add(entry.get("id"))
+
+ print("Test: the built-in catalogue contains exactly the expected ids")
+ expected_ids = [
+ "cap_seg",
+ "eon_next_drive",
+ "igo_fixed",
+ "igo_prime",
+ "igo_agile",
+ "go_fixed",
+ "go_prime",
+ "go_agile",
+ "agile_fixed",
+ "agile_prime",
+ "agile_agile",
+ "flux",
+ "cosy_fixed",
+ "cosy_prime",
+ "cosy_agile",
+ "snug_fixed",
+ "snug_prime",
+ "iflux",
+ ]
+ actual_ids = [entry["id"] for entry in BUILTIN_TARIFFS]
+ if actual_ids != expected_ids:
+ print(" ERROR: built-in ids changed, expected {} got {}".format(expected_ids, actual_ids))
+ failed = True
+
+ print("Test: no built-in entry uses Compare's key names")
+ for entry in BUILTIN_TARIFFS:
+ for stale in ["rates_import_octopus_url", "rates_export_octopus_url"]:
+ if stale in entry:
+ print(" ERROR: entry {} still uses Compare's key '{}'".format(entry.get("id"), stale))
+ failed = True
+
+ print("Test: convert_compare_entry maps Compare's URL keys onto the engine's")
+ converted = convert_compare_entry(
+ {
+ "id": "agile_agile",
+ "name": "Agile import/Agile export",
+ "rates_import_octopus_url": "https://example.com/import/",
+ "rates_export_octopus_url": "https://example.com/export/",
+ }
+ )
+ if converted is None:
+ print(" ERROR: a valid Compare entry should convert")
+ failed = True
+ else:
+ if converted.get("import_octopus_url") != "https://example.com/import/":
+ print(" ERROR: import URL not mapped, got {}".format(converted))
+ failed = True
+ if converted.get("export_octopus_url") != "https://example.com/export/":
+ print(" ERROR: export URL not mapped, got {}".format(converted))
+ failed = True
+ if "rates_import_octopus_url" in converted:
+ print(" ERROR: the Compare key should not survive conversion")
+ failed = True
+
+ print("Test: fixed rate structures pass through unchanged")
+ converted = convert_compare_entry({"id": "cap", "name": "Price cap", "rates_import": [{"rate": 24.86}], "rates_export": [{"rate": 4.1}]})
+ if converted is None or converted.get("rates_import") != [{"rate": 24.86}]:
+ print(" ERROR: fixed rates should pass through, got {}".format(converted))
+ failed = True
+
+ print("Test: an entry with no usable rate source is rejected rather than shown")
+ if convert_compare_entry({"id": "current", "name": "Current Tariff"}) is not None:
+ print(" ERROR: an entry with no rates should be rejected")
+ failed = True
+ if convert_compare_entry({"name": "No id"}) is not None:
+ print(" ERROR: an entry with no id should be rejected")
+ failed = True
+ if convert_compare_entry("not a dict") is not None:
+ print(" ERROR: a non-dict should be rejected rather than raising")
+ failed = True
+
+ print("Test: merged_catalogue with no user list returns the built-ins plus Custom")
+ merged = merged_catalogue(None)
+ if len(merged) != len(BUILTIN_TARIFFS) + 1:
+ print(" ERROR: expected {} entries, got {}".format(len(BUILTIN_TARIFFS) + 1, len(merged)))
+ failed = True
+ if merged[-1]["id"] != CUSTOM_ID:
+ print(" ERROR: Custom should be the last entry, got {}".format(merged[-1]))
+ failed = True
+
+ print("Test: a user's compare_list is merged in and does not clobber a built-in id")
+ builtin_id = BUILTIN_TARIFFS[0]["id"]
+ merged = merged_catalogue(
+ [
+ {"id": builtin_id, "name": "My override", "rates_import_octopus_url": "https://example.com/mine/"},
+ {"id": "my_tariff", "name": "My tariff", "rates_import": [{"rate": 20.0}]},
+ ]
+ )
+ ids = [entry["id"] for entry in merged]
+ if ids.count(builtin_id) != 1:
+ print(" ERROR: a user entry sharing a built-in id should not duplicate it, ids were {}".format(ids))
+ failed = True
+ if "my_tariff" not in ids:
+ print(" ERROR: a new user entry should appear, ids were {}".format(ids))
+ failed = True
+
+ print("Test: a malformed user entry is skipped rather than breaking the dropdown")
+ merged = merged_catalogue([{"junk": True}, None, "string", {"id": "ok", "name": "Ok", "rates_import": [{"rate": 5.0}]}])
+ ids = [entry["id"] for entry in merged]
+ if "ok" not in ids:
+ print(" ERROR: the valid entry should survive alongside malformed ones, ids were {}".format(ids))
+ failed = True
+
+ return failed
diff --git a/apps/predbat/tests/test_web_annual.py b/apps/predbat/tests/test_web_annual.py
new file mode 100644
index 000000000..97e901330
--- /dev/null
+++ b/apps/predbat/tests/test_web_annual.py
@@ -0,0 +1,2195 @@
+# -----------------------------------------------------------------------------
+# 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
+
+"""Tests for the Annual tab's prefill and configuration handling."""
+
+import asyncio
+import builtins
+import copy
+import json
+import re
+from unittest.mock import patch
+
+from aiohttp import web as aiohttp_web
+
+from annual import AnnualConfigError, validate_config
+from annual_store import list_runs, load_run, save_run
+from tariff_catalogue import CUSTOM_ID
+from web import WebInterface
+from web_annual import DEFAULT_CONFIG, AnnualPage, _json_for_script
+
+
+class FakeRequest:
+ """A minimal aiohttp-request stand-in exposing only what the handlers read."""
+
+ def __init__(self, postdata=None, query=None):
+ """Store the posted fields and query string a handler will read."""
+ self._postdata = postdata or {}
+ self.query = query or {}
+
+ async def post(self):
+ """Return the posted fields, mimicking aiohttp's async ``Request.post()``."""
+ return self._postdata
+
+
+class RaceStorage:
+ """A Storage stand-in whose ``save()`` yields once, forcing concurrent callers to interleave.
+
+ A fake with no yield point at all would hide a race that only manifests once two
+ callers are genuinely interleaved by the event loop - real storage backends do
+ real I/O and therefore always yield somewhere.
+ """
+
+ def __init__(self):
+ """Start with nothing stored and no calls recorded."""
+ self.store = {}
+ self.save_calls = []
+
+ async def save(self, module, filename, data, format="yaml", expiry=None):
+ """Record the call, yield control once, then write."""
+ self.save_calls.append((module, filename))
+ await asyncio.sleep(0)
+ self.store[(module, filename)] = data
+
+ async def load(self, module, filename):
+ """Return a stored value, or None."""
+ return self.store.get((module, filename))
+
+
+def make_page(my_predbat):
+ """Return an AnnualPage backed by a WebInterface over the test fixture."""
+ return AnnualPage(WebInterface(my_predbat, web_port=5054))
+
+
+def option_tag(html_text, value):
+ """Return the ``