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 ``