diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 190b7aa24..dab65b35d 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -522,6 +522,7 @@ submessage subnormal subnormals substep +subtractable sunspec sunsynk supabase diff --git a/apps/predbat/tests/test_annual_heat.py b/apps/predbat/tests/test_annual_heat.py new file mode 100644 index 000000000..531b9fc77 --- /dev/null +++ b/apps/predbat/tests/test_annual_heat.py @@ -0,0 +1,752 @@ +# ----------------------------------------------------------------------------- +# 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 heat pump and gas boiler model. + +Pure arithmetic over a synthetic temperature series - no network, no Predbat +instance, no plan run - so it is fast and registered as a non-slow test. +""" + +import math +from datetime import date, datetime, timedelta + +import pytz + +from annual_costs import build_heat_costs, build_heat_payback, resolve_costs +from annual_heat import ( + HOURS_PER_DAY, + MAX_COP, + MIN_COP, + SETBACK_FACTOR, + SETBACK_HOURS, + AnnualHeatError, + HeatModel, + HeatPumpLoadProfile, + HeatPumpRemovedLoadProfile, + carnot_cop, + resolve_heat, +) +from annual_weather import temperature_by_local_day + + +def close(actual, expected, tolerance=0.01): + """Return True when two floats agree to within a tolerance.""" + return abs(actual - expected) <= tolerance + + +def sine_year(year, mean=10.0, amplitude=7.0): + """Return a full year of hourly temperatures with a seasonal and a daily cycle. + + Deliberately not a flat series: the whole point of the model is that heat demand + and COP both move with temperature, so a constant year would pass tests that a + real one would fail. + """ + days = 366 if (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)) else 365 + series = {} + for offset in range(days): + day = date(year, 1, 1) + timedelta(days=offset) + seasonal = mean - amplitude * math.cos(2 * math.pi * offset / days) + series[day] = [seasonal + 3.0 * math.sin(2 * math.pi * (hour - 9) / 24.0) for hour in range(HOURS_PER_DAY)] + return series + + +class StubLoad: + """A minimal LoadProfileSource returning a flat household load.""" + + def __init__(self, daily=10.0, missing=None): + """Hold the flat daily total and any dates that should report no data.""" + self.daily = daily + self.missing = missing or set() + + def daily_kwh(self, day): + """Return the flat daily household total.""" + return self.daily + + def minute_profile(self, day): + """Return a flat per-minute profile, or None for a date with no data.""" + if day in self.missing: + return None + return [self.daily / 1440.0] * 1440 + + +def base_config(**overrides): + """Return a valid heat config with the given fields overridden.""" + config = {"annual_gas_kwh": 12000, "water_gas_kwh": 2400, "boiler_efficiency": 0.85, "scop": 3.2, "gas_p_per_kwh": 6.4, "gas_standing_charge_p_per_day": 32.0} + config.update(overrides) + return config + + +def test_annual_heat(my_predbat): + """Verify the heat demand model, the COP curve, the gas costing and the payback. + + ``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 heat pump / gas model ****") + year = 2023 + temperatures = temperature_year(year) + + print("Test: no heat block, or an explicitly disabled one, models no heat pump") + for raw in [None, {}, {"enable": False}, {"enable": "false"}, {"enable": "no"}]: + if resolve_heat(raw) is not None: + print(" ERROR: {} should not enable a heat pump".format(raw)) + failed = True + if resolve_heat({"enable": True}) is None: + print(" ERROR: an explicitly enabled heat block should produce settings") + failed = True + + print("Test: delivered heat is gas times boiler efficiency, split space vs water") + settings = resolve_heat(base_config()) + if not close(settings["space_heat_kwh"], (12000 - 2400) * 0.85): + print(" ERROR: space heat should be {}, got {}".format((12000 - 2400) * 0.85, settings["space_heat_kwh"])) + failed = True + # Hot water uses its OWN gas efficiency (a stored cylinder is worse than space heating) + if not close(settings["water_heat_kwh"], 2400 * 0.80): + print(" ERROR: water heat should be {}, got {}".format(2400 * 0.80, settings["water_heat_kwh"])) + failed = True + + print("Test: hot water gas cannot exceed the gas total") + try: + resolve_heat(base_config(annual_gas_kwh=2000, water_gas_kwh=3000)) + print(" ERROR: water gas above the total should be rejected") + failed = True + except AnnualHeatError: + pass + + print("Test: a non-numeric or non-finite setting is rejected by name") + for field, value in [("scop", "abc"), ("gas_p_per_kwh", float("nan")), ("boiler_efficiency", float("inf")), ("annual_gas_kwh", True)]: + try: + resolve_heat(base_config(**{field: value})) + print(" ERROR: {}={!r} should have been rejected".format(field, value)) + failed = True + except AnnualHeatError as error: + if field not in str(error): + print(" ERROR: the error for {} should name the field, got '{}'".format(field, error)) + failed = True + + print("Test: an out-of-range setting is rejected") + for field, value in [("boiler_efficiency", 1.5), ("boiler_efficiency", 0), ("scop", 0.5), ("flow_temp_c", 90), ("base_temp_c", 40)]: + try: + resolve_heat(base_config(**{field: value})) + print(" ERROR: {}={} should have been rejected".format(field, value)) + failed = True + except AnnualHeatError: + pass + + print("Test: the Carnot COP rises as the outdoor temperature rises, and is floored on lift") + if not carnot_cop(0, 45) < carnot_cop(10, 45): + print(" ERROR: Carnot COP should improve with a warmer outdoor temperature") + failed = True + if not close(carnot_cop(44, 45), carnot_cop(40, 45)): + print(" ERROR: the lift floor should make a tiny lift behave like the minimum lift") + failed = True + + model = HeatModel(resolve_heat(base_config()), temperatures, year) + + print("Test: the realised space-heating SCOP matches the configured one") + if not close(model.realised_scop(), 3.2, tolerance=0.05): + print(" ERROR: realised SCOP should be about 3.2, got {}".format(model.realised_scop())) + failed = True + + print("Test: a different configured SCOP is honoured") + for wanted in [2.5, 4.0]: + other = HeatModel(resolve_heat(base_config(scop=wanted)), temperatures, year) + if not close(other.realised_scop(), wanted, tolerance=0.05): + print(" ERROR: realised SCOP should be about {}, got {}".format(wanted, other.realised_scop())) + failed = True + + print("Test: COP is worse when it is colder, and hot water is always worse than space heating") + if not model.space_cop(-2) < model.space_cop(12): + print(" ERROR: COP should be lower on a cold day than a mild one") + failed = True + for outdoor in [-5, 0, 5, 10, 15]: + if not model.water_cop(outdoor) < model.space_cop(outdoor): + print(" ERROR: hot water COP should be below space heating COP at {}C".format(outdoor)) + failed = True + if not MIN_COP <= model.space_cop(outdoor) <= MAX_COP: + print(" ERROR: space COP at {}C is outside the clamp: {}".format(outdoor, model.space_cop(outdoor))) + failed = True + + print("Test: the twelve months' space heat sums to the configured annual figure") + monthly = sum(model.month_space_heat_kwh(month) for month in range(1, 13)) + if not close(monthly, model.settings["space_heat_kwh"], tolerance=1.0): + print(" ERROR: monthly space heat should sum to {}, got {}".format(model.settings["space_heat_kwh"], monthly)) + failed = True + + print("Test: a temperature series running past the year still delivers the whole year's heat") + # The weather download deliberately ends on 1 January of the FOLLOWING year, so that a + # sample on 31 December still has the second day its 48 hour plan needs. Those extra + # days must not be counted in the degree-day denominator: doing so spread the year's + # heat over 366 days instead of 365 and quietly lost about 0.8% of it, and with it the + # same slice of the gas bill the comparison rests on. + with_spill = dict(temperatures) + with_spill[date(year + 1, 1, 1)] = [-5.0] * HOURS_PER_DAY + spilled = HeatModel(resolve_heat(base_config()), with_spill, year) + spilled_total = sum(spilled.month_space_heat_kwh(month) for month in range(1, 13)) + if not close(spilled_total, spilled.settings["space_heat_kwh"], tolerance=1.0): + print(" ERROR: a series spilling into the next year should still deliver {} kWh, got {}".format(spilled.settings["space_heat_kwh"], spilled_total)) + failed = True + if not close(spilled.realised_scop(), 3.2, tolerance=0.05): + print(" ERROR: the spill day should not drag the realised SCOP, got {}".format(spilled.realised_scop())) + failed = True + + print("Test: winter carries far more space heat than summer") + january = model.month_space_heat_kwh(1) + july = model.month_space_heat_kwh(7) + if not january > july * 5: + print(" ERROR: January ({}) should carry much more space heat than July ({})".format(january, july)) + failed = True + + print("Test: hot water is weather independent and sums to the configured annual figure") + water = sum(model.month_water_heat_kwh(month) for month in range(1, 13)) + if not close(water, model.settings["water_heat_kwh"], tolerance=1.0): + print(" ERROR: monthly water heat should sum to {}, got {}".format(model.settings["water_heat_kwh"], water)) + failed = True + + print("Test: the month scale corrects a biased sample onto the month's own demand") + # Two of January's coldest days, which over-represent the month + january_days = sorted((day for day in temperatures if day.month == 1), key=lambda day: sum(temperatures[day]))[:2] + samples = [(day, 31 / 2.0) for day in january_days] + scale = model.set_month_scale(1, samples) + if not scale < 1.0: + print(" ERROR: sampling January's coldest days should scale demand DOWN, got {}".format(scale)) + failed = True + corrected = sum(model.day_space_heat_kwh(day) * weight for day, weight in samples) + if not close(corrected, model.month_space_heat_kwh(1), tolerance=0.5): + print(" ERROR: the corrected sample should carry the month's total {}, got {}".format(model.month_space_heat_kwh(1), corrected)) + failed = True + + print("Test: a month with no heat at all leaves the scale at 1.0 rather than dividing by zero") + warm = HeatModel(resolve_heat(base_config()), temperature_year(year, mean=30.0, amplitude=0.0, ensure_heat_day=True), year) + if warm.set_month_scale(7, [(date(year, 7, 10), 31.0)]) != 1.0: + print(" ERROR: a month with no sampled heat should leave the scale at 1.0") + failed = True + + print("Test: the overnight setback trims demand but keeps the day's shape") + model.set_month_scale(1, [(date(year, 1, 15), 31.0)]) + hourly = model.hourly_electricity_kwh(date(year, 1, 15)) + if len(hourly) != 24: + print(" ERROR: hourly electricity should have 24 entries, got {}".format(len(hourly))) + failed = True + if not all(value >= 0 for value in hourly): + print(" ERROR: hourly electricity must never be negative") + failed = True + + print("Test: the per-minute profile has 1440 entries and matches the hourly total") + minutes = model.minute_electricity_kwh(date(year, 1, 15)) + if len(minutes) != 1440: + print(" ERROR: minute electricity should have 1440 entries, got {}".format(len(minutes))) + failed = True + if not close(sum(minutes), sum(hourly), tolerance=0.001): + print(" ERROR: the minute profile should sum to the hourly total {}, got {}".format(sum(hourly), sum(minutes))) + failed = True + + print("Test: a summer day still draws hot water electricity but little or no space heat") + model.set_month_scale(7, [(date(year, 7, 15), 31.0)]) + summer = sum(model.minute_electricity_kwh(date(year, 7, 15))) + model.set_month_scale(1, [(date(year, 1, 15), 31.0)]) + winter = sum(model.minute_electricity_kwh(date(year, 1, 15))) + if not summer > 0: + print(" ERROR: a summer day should still draw hot water electricity, got {}".format(summer)) + failed = True + if not winter > summer * 2: + print(" ERROR: a winter day ({}) should draw much more than a summer one ({})".format(winter, summer)) + failed = True + + print("Test: the gas bill is delivered heat divided by boiler efficiency, at the gas rate") + gas = model.month_gas(1) + # Space heating and hot water each divide by their OWN efficiency - a stored cylinder + # is worse than space heating, and sharing one figure breaks the reconciliation below. + expected_gas_kwh = model.month_space_heat_kwh(1) / 0.85 + model.month_water_heat_kwh(1) / 0.80 + if not close(gas["gas_kwh"], expected_gas_kwh, tolerance=0.1): + print(" ERROR: January gas should be {} kWh, got {}".format(expected_gas_kwh, gas["gas_kwh"])) + failed = True + if not close(gas["gas_unit_cost_p"], expected_gas_kwh * 6.4, tolerance=1.0): + print(" ERROR: January gas cost should be {}p, got {}".format(expected_gas_kwh * 6.4, gas["gas_unit_cost_p"])) + failed = True + if not close(gas["gas_standing_charge_p"], 31 * 32.0): + print(" ERROR: January standing charge should be {}p, got {}".format(31 * 32.0, gas["gas_standing_charge_p"])) + failed = True + + print("Test: a missing temperature series is refused rather than modelled around") + try: + HeatModel(resolve_heat(base_config()), {}, year) + print(" ERROR: an empty temperature series should have been refused") + failed = True + except AnnualHeatError: + pass + + print("Test: a year that never needs heating is refused rather than dividing by zero") + try: + HeatModel(resolve_heat(base_config()), temperature_year(year, mean=30.0, amplitude=0.0), year) + print(" ERROR: a year with no heating demand should have been refused") + failed = True + except AnnualHeatError: + pass + + failed = test_heat_load_profile(model, year) or failed + failed = test_heat_costs() or failed + failed = test_temperature_local_days() or failed + failed = test_smart_cylinder(temperatures, year) or failed + failed = test_existing_heat_pump(temperatures, year) or failed + + if failed: + print("**** ERROR: annual heat tests failed ****") + return failed + + +def temperature_year(year, mean=10.0, amplitude=7.0, ensure_heat_day=False): + """Return a synthetic year of hourly temperatures keyed by local date. + + ``ensure_heat_day`` drops one day to freezing so a warm year still has SOME heating + demand, which is what the month-scale test needs: a year with no demand at all is + refused by HeatModel outright and could not reach the scale logic. + """ + series = sine_year(year, mean=mean, amplitude=amplitude) + if ensure_heat_day: + series[date(year, 1, 5)] = [0.0] * HOURS_PER_DAY + return series + + +def test_heat_load_profile(model, year): + """Verify the heat pump load source adds to, rather than replaces, the household load.""" + failed = False + print("Test: the heat pump load source adds heat pump electricity onto the base load") + base = StubLoad(daily=10.0) + combined = HeatPumpLoadProfile(base, model) + day = date(year, 1, 15) + model.set_month_scale(1, [(day, 31.0)]) + + base_profile = base.minute_profile(day) + combined_profile = combined.minute_profile(day) + heat_profile = model.minute_electricity_kwh(day) + if len(combined_profile) != 1440: + print(" ERROR: the combined profile should have 1440 entries, got {}".format(len(combined_profile))) + failed = True + for index in [0, 500, 1000, 1439]: + expected = base_profile[index] + heat_profile[index] + if not close(combined_profile[index], expected, tolerance=1e-9): + print(" ERROR: minute {} should be {}, got {}".format(index, expected, combined_profile[index])) + failed = True + if not close(combined.daily_kwh(day), 10.0 + sum(model.hourly_electricity_kwh(day)), tolerance=0.001): + print(" ERROR: the combined daily total should be base plus heat pump") + failed = True + + print("Test: a day the base source has no data for stays missing rather than becoming heat-pump-only") + missing_base = StubLoad(daily=10.0, missing={day}) + if HeatPumpLoadProfile(missing_base, model).minute_profile(day) is not None: + print(" ERROR: a missing base day must pass None through, not return heat pump load alone") + failed = True + return failed + + +def test_heat_costs(): + """Verify the heat pump capital model and its payback rows.""" + failed = False + print("Test: the grant comes off capital, and a quote beats the estimate") + settings = resolve_costs(None) + costs = build_heat_costs(settings) + if not close(costs["install_gbp"], 13000.0) or not close(costs["grant_gbp"], 7500.0) or not close(costs["net_gbp"], 5500.0): + print(" ERROR: default heat pump costs should be 13000/7500/5500, got {}".format(costs)) + failed = True + if costs["quoted"]: + print(" ERROR: the default costs are an estimate, not a quote") + failed = True + + quoted = build_heat_costs(resolve_costs({"quoted_heat_pump_gbp": 9000})) + if not close(quoted["net_gbp"], 1500.0) or not quoted["quoted"]: + print(" ERROR: a 9000 quote less the 7500 grant should be 1500 and flagged as quoted, got {}".format(quoted)) + failed = True + + print("Test: a grant larger than the price cannot produce a negative capital cost") + generous = build_heat_costs(resolve_costs({"quoted_heat_pump_gbp": 5000, "heat_pump_grant_gbp": 7500})) + if generous["net_gbp"] < 0: + print(" ERROR: net capital should never be negative, got {}".format(generous["net_gbp"])) + failed = True + + print("Test: heat pump payback needs a full year") + scenarios = {key: {"annual_saving_p": 55000.0} for key in ["no_pvbat", "pv_only", "without_predbat", "with_predbat"]} + partial = build_heat_payback(scenarios, costs, 11) + if partial.get("available"): + print(" ERROR: an 11 month year should not produce a heat pump payback") + failed = True + if "11" not in partial.get("reason", ""): + print(" ERROR: the refusal should say how many months were modelled, got '{}'".format(partial.get("reason"))) + failed = True + + print("Test: a full year pays back capital divided by the saving, per scenario") + full = build_heat_payback(scenarios, costs, 12) + if not full.get("available"): + print(" ERROR: a full year should produce a heat pump payback, got {}".format(full)) + failed = True + # 5500 capital against 550 a year + elif not close(full["scenarios"]["with_predbat"]["years"], 10.0): + print(" ERROR: 5500 capital against 550 a year should be 10 years, got {}".format(full["scenarios"]["with_predbat"]["years"])) + failed = True + + print("Test: a heat pump that costs more than it saves does not report a payback period") + losing = build_heat_payback({key: {"annual_saving_p": -12000.0} for key in scenarios}, costs, 12) + if losing["scenarios"]["with_predbat"]["pays_back"] or losing["scenarios"]["with_predbat"]["years"] is not None: + print(" ERROR: a negative saving must not report a payback period, got {}".format(losing["scenarios"]["with_predbat"])) + failed = True + return failed + + +def test_temperature_local_days(): + """Verify the UTC-to-local-day regrouping, including both clock changes.""" + failed = False + print("Test: a UTC temperature series regroups into 24 values per local day") + utc = pytz.utc + series = {} + start = datetime(2023, 6, 1, 0, 0, tzinfo=utc) + for hour in range(24 * 5): + series[start + timedelta(hours=hour)] = 12.0 + (hour % 24) * 0.1 + by_day = temperature_by_local_day(series, "Europe/London") + + for day, hourly in by_day.items(): + if len(hourly) != 24: + print(" ERROR: {} should have 24 hourly values, got {}".format(day, len(hourly))) + failed = True + + print("Test: local midnight in British Summer Time is 23:00 UTC the day before") + # 1 June 2023 is BST (UTC+1), so local midnight on the 2nd is 23:00 UTC on the 1st + if date(2023, 6, 2) in by_day: + expected = series[datetime(2023, 6, 1, 23, 0, tzinfo=utc)] + if not close(by_day[date(2023, 6, 2)][0], expected): + print(" ERROR: local midnight should be 23:00 UTC the day before ({}), got {}".format(expected, by_day[date(2023, 6, 2)][0])) + failed = True + + print("Test: the autumn clock change averages its doubled hour instead of dropping a day") + autumn = {} + for hour in range(24 * 3): + autumn[datetime(2023, 10, 28, 0, 0, tzinfo=utc) + timedelta(hours=hour)] = 8.0 + autumn_days = temperature_by_local_day(autumn, "Europe/London") + if date(2023, 10, 29) not in autumn_days: + print(" ERROR: the 25 hour local day should still be present") + failed = True + elif len(autumn_days[date(2023, 10, 29)]) != 24: + print(" ERROR: the 25 hour local day should still produce 24 values, got {}".format(len(autumn_days[date(2023, 10, 29)]))) + failed = True + + print("Test: the spring clock change fills its missing hour instead of dropping a day") + spring = {} + for hour in range(24 * 3): + spring[datetime(2023, 3, 25, 0, 0, tzinfo=utc) + timedelta(hours=hour)] = 5.0 + spring_days = temperature_by_local_day(spring, "Europe/London") + if date(2023, 3, 26) not in spring_days: + print(" ERROR: the 23 hour local day should still be present") + failed = True + elif len(spring_days[date(2023, 3, 26)]) != 24: + print(" ERROR: the 23 hour local day should still produce 24 values, got {}".format(len(spring_days[date(2023, 3, 26)]))) + failed = True + + print("Test: a day with too few readings is dropped rather than mostly invented") + sparse = {datetime(2023, 6, 10, hour, 0, tzinfo=utc): 10.0 for hour in range(4)} + if temperature_by_local_day(sparse, "Europe/London"): + print(" ERROR: a day with only 4 readings should be dropped") + failed = True + + print("Test: the setback constants are a trim, not a shutdown") + if not 0 < SETBACK_FACTOR < 1: + print(" ERROR: the setback factor should be between 0 and 1, got {}".format(SETBACK_FACTOR)) + failed = True + if not SETBACK_HOURS: + print(" ERROR: there should be some setback hours") + failed = True + return failed + + +def cheap_night_rates(cheap=7.0, normal=28.0, peak=45.0, days=2): + """Return plan rates with a cheap 00:30-05:30 band and an expensive 16:00-19:00 peak.""" + rates = {} + for minute in range(days * 1440): + in_day = minute % 1440 + if 30 <= in_day < 330: + rates[minute] = cheap + elif 16 * 60 <= in_day < 19 * 60: + rates[minute] = peak + else: + rates[minute] = normal + return rates + + +def test_smart_cylinder(temperatures, year): + """Verify the hot water cylinder's charge scheduling, smart and fixed.""" + failed = False + day = date(year, 6, 15) # summer, so hot water dominates and space heat is near zero + + print("Test: hot water uses its own gas efficiency, and the gas total still reconciles") + settings = resolve_heat(base_config(water_boiler_efficiency=0.75)) + if not close(settings["water_heat_kwh"], 2400 * 0.75): + print(" ERROR: water heat should use its own efficiency, got {}".format(settings["water_heat_kwh"])) + failed = True + model = HeatModel(settings, temperatures, year) + # The identity the whole gas side rests on: space gas + water gas == the bill + gas_total = sum(model.month_gas(month)["gas_kwh"] for month in range(1, 13)) + if not close(gas_total, 12000.0, tolerance=1.0): + print(" ERROR: the year's modelled gas should equal the configured 12000 kWh, got {}".format(gas_total)) + failed = True + + print("Test: the cylinder's standing loss is charged to the heat pump, not to the gas boiler") + model = HeatModel(resolve_heat(base_config(cylinder_standing_loss_kwh_per_day=1.5)), temperatures, year) + if not close(model.water_charge_per_day_kwh - model.water_heat_per_day_kwh, 1.5): + print(" ERROR: the heat pump should carry the 1.5 kWh/day standing loss, got {}".format(model.water_charge_per_day_kwh - model.water_heat_per_day_kwh)) + failed = True + # The gas bill must be unaffected by the NEW cylinder's loss + with_loss = HeatModel(resolve_heat(base_config(cylinder_standing_loss_kwh_per_day=3.0)), temperatures, year).month_gas(1)["gas_kwh"] + without = HeatModel(resolve_heat(base_config(cylinder_standing_loss_kwh_per_day=0.0)), temperatures, year).month_gas(1)["gas_kwh"] + if not close(with_loss, without, tolerance=0.01): + print(" ERROR: the heat pump cylinder's standing loss must not change the gas bill ({} vs {})".format(with_loss, without)) + failed = True + + print("Test: a full charge is the cylinder's volume times its temperature rise") + model = HeatModel(resolve_heat(base_config(cylinder_volume_l=180, hot_water_target_c=50, cold_mains_c=10)), temperatures, year) + # 180 kg * 4.186 kJ/kg/K * 40 K / 3600 = 8.37 kWh + if not close(model.full_charge_kwh, 8.372, tolerance=0.01): + print(" ERROR: a 180 L charge over 40 K should be 8.37 kWh, got {}".format(model.full_charge_kwh)) + failed = True + + print("Test: one charge a day is the default, and it finishes before the water is wanted") + model = HeatModel(resolve_heat(base_config()), temperatures, year) + model.set_month_scale(6, [(day, 30.0)]) + windows = model.water_charge_windows(day) + if len(windows) != 1: + print(" ERROR: the default should be a single daily charge, got {}".format(len(windows))) + failed = True + elif windows[0][1] > 7 * 60: + print(" ERROR: the charge should finish by 07:00, ends at minute {}".format(windows[0][1])) + failed = True + + print("Test: the whole day's hot water energy is delivered, however it is scheduled") + for smart in [False, True]: + m = HeatModel(resolve_heat(base_config(smart_hot_water=smart)), temperatures, year) + m.set_month_scale(6, [(day, 30.0)]) + if smart: + m.set_plan_rates(day, cheap_night_rates()) + delivered = sum(energy for _, _, energy in m.water_charge_windows(day)) + if not close(delivered, m.water_charge_per_day_kwh, tolerance=0.001): + print(" ERROR: smart={} should still deliver {} kWh, got {}".format(smart, m.water_charge_per_day_kwh, delivered)) + failed = True + + print("Test: a smart cylinder moves its charge into the cheap overnight band") + smart = HeatModel(resolve_heat(base_config(smart_hot_water=True)), temperatures, year) + smart.set_month_scale(6, [(day, 30.0)]) + smart.set_plan_rates(day, cheap_night_rates()) + start, end, _ = smart.water_charge_windows(day)[0] + if not (30 <= start and end <= 330): + print(" ERROR: the smart charge should sit inside the 00:30-05:30 cheap band, got {}-{}".format(start, end)) + failed = True + + print("Test: the smart charge is genuinely cheaper than the fixed one") + rates = cheap_night_rates() + fixed = HeatModel(resolve_heat(base_config(smart_hot_water=False)), temperatures, year) + fixed.set_month_scale(6, [(day, 30.0)]) + + def window_cost(m): + """Return what the day's cylinder charges would cost at the plan rates.""" + total = 0.0 + for start, end, energy in m.water_charge_windows(day): + per_minute = energy / max(1, end - start) + total += sum(per_minute * rates[minute] for minute in range(start, end)) + return total + + if not window_cost(smart) < window_cost(fixed): + print(" ERROR: the smart charge ({}) should cost less than the fixed one ({})".format(window_cost(smart), window_cost(fixed))) + failed = True + + print("Test: with no rates known, a smart cylinder falls back to the fixed schedule") + blind = HeatModel(resolve_heat(base_config(smart_hot_water=True)), temperatures, year) + blind.set_month_scale(6, [(day, 30.0)]) + blind.set_plan_rates(None, None) + if blind.water_charge_windows(day) != fixed.water_charge_windows(day): + print(" ERROR: a smart cylinder with no rates should behave exactly like the fixed one") + failed = True + + print("Test: on a flat tariff the smart charge does not drift away from the draw-off") + flat = HeatModel(resolve_heat(base_config(smart_hot_water=True)), temperatures, year) + flat.set_month_scale(6, [(day, 30.0)]) + flat.set_plan_rates(day, {minute: 25.0 for minute in range(2880)}) + if flat.water_charge_windows(day) != fixed.water_charge_windows(day): + print(" ERROR: with every window equally priced the charge should sit where the timer put it, got {} vs {}".format(flat.water_charge_windows(day), fixed.water_charge_windows(day))) + failed = True + + print("Test: two charges a day do not overlap, and both finish before their draw-off") + twice = HeatModel(resolve_heat(base_config(smart_hot_water=True, water_charges_per_day=2)), temperatures, year) + twice.set_month_scale(6, [(day, 30.0)]) + twice.set_plan_rates(day, cheap_night_rates()) + windows = twice.water_charge_windows(day) + if len(windows) != 2: + print(" ERROR: two charges a day should produce two windows, got {}".format(len(windows))) + failed = True + else: + if windows[0][1] > windows[1][0]: + print(" ERROR: the two charges overlap: {} and {}".format(windows[0], windows[1])) + failed = True + if windows[0][1] > 7 * 60 or windows[1][1] > 16 * 60: + print(" ERROR: each charge should finish by its ready time, got {}".format(windows)) + failed = True + + print("Test: the charge lasts longer when the heat pump puts less power into the cylinder") + slow = HeatModel(resolve_heat(base_config(dhw_kw=1.5)), temperatures, year) + quick = HeatModel(resolve_heat(base_config(dhw_kw=6.0)), temperatures, year) + for m in (slow, quick): + m.set_month_scale(6, [(day, 30.0)]) + slow_len = slow.water_charge_windows(day)[0][1] - slow.water_charge_windows(day)[0][0] + quick_len = quick.water_charge_windows(day)[0][1] - quick.water_charge_windows(day)[0][0] + if not slow_len > quick_len: + print(" ERROR: a 1.5 kW charge ({} min) should take longer than a 6 kW one ({} min)".format(slow_len, quick_len)) + failed = True + + print("Test: the minute profile still reconciles with the hourly one") + smart_minutes = smart.minute_electricity_kwh(day) + smart_hours = smart.hourly_electricity_kwh(day) + if len(smart_minutes) != 1440 or len(smart_hours) != 24: + print(" ERROR: profile lengths wrong: {} minutes, {} hours".format(len(smart_minutes), len(smart_hours))) + failed = True + if not close(sum(smart_minutes), sum(smart_hours), tolerance=1e-9): + print(" ERROR: the minute and hourly profiles disagree: {} vs {}".format(sum(smart_minutes), sum(smart_hours))) + failed = True + + print("Test: a cylinder charged overnight in winter costs more electricity than one charged at noon") + # Charging when it is coldest is genuinely less efficient, so shifting is a real + # trade-off rather than a free win - the COP must follow the hour the charge lands in. + winter = date(year, 1, 15) + night = HeatModel(resolve_heat(base_config(smart_hot_water=True)), temperatures, year) + night.set_month_scale(1, [(winter, 31.0)]) + night.set_plan_rates(winter, cheap_night_rates()) + noon_rates = {minute: (7.0 if 11 * 60 <= (minute % 1440) < 14 * 60 else 40.0) for minute in range(2880)} + noon = HeatModel(resolve_heat(base_config(smart_hot_water=True, water_charges_per_day=2)), temperatures, year) + noon.set_month_scale(1, [(winter, 31.0)]) + noon.set_plan_rates(winter, noon_rates) + night_start = night.water_charge_windows(winter)[0][0] + if not night_start < 6 * 60: + print(" ERROR: the overnight cheap band should pull the charge before 06:00, got minute {}".format(night_start)) + failed = True + + print("Test: a flow temperature below the cylinder target is rejected") + try: + resolve_heat(base_config(water_flow_temp_c=45, hot_water_target_c=50)) + print(" ERROR: a flow temperature below the cylinder target should be rejected") + failed = True + except AnnualHeatError as error: + if "water_flow_temp_c" not in str(error): + print(" ERROR: the error should name water_flow_temp_c, got '{}'".format(error)) + failed = True + + print("Test: a fractional number of charges a day is rejected rather than truncated") + try: + resolve_heat(base_config(water_charges_per_day=1.5)) + print(" ERROR: 1.5 charges a day should be rejected") + failed = True + except AnnualHeatError: + pass + + if failed: + print("**** ERROR: smart cylinder tests failed ****") + return failed + + +def test_existing_heat_pump(temperatures, year): + """Verify the existing-owner mode: measured electricity in, gas counterfactual out.""" + failed = False + print("Test: an existing owner's heat is derived from measured electricity, not a gas bill") + settings = resolve_heat({"existing_heat_pump": True, "heat_pump_kwh": 4000, "water_heat_pump_kwh": 900, "scop": 3.25, "flow_temp_c": 35, "water_cop": 3.35}) + if settings["space_heat_kwh"] is not None or settings["water_heat_kwh"] is not None: + print(" ERROR: heat should be left for HeatModel to derive, got {}".format(settings["space_heat_kwh"])) + failed = True + + model = HeatModel(settings, temperatures, year) + # 3100 kWh of space electricity at the realised SCOP, 900 of water at the manual COP + expected_space = (4000 - 900) * model.realised_scop() + if not close(settings["space_heat_kwh"], expected_space, tolerance=1.0): + print(" ERROR: space heat should be {} kWh, got {}".format(expected_space, settings["space_heat_kwh"])) + failed = True + if not close(settings["water_heat_kwh"], 900 * 3.35, tolerance=1.0): + print(" ERROR: water heat should be {} kWh, got {}".format(900 * 3.35, settings["water_heat_kwh"])) + failed = True + + print("Test: a manual hot water COP overrides the flow-temperature derivation") + if not close(model.water_cop(-5), 3.35) or not close(model.water_cop(15), 3.35): + print(" ERROR: a manual water COP should be flat across temperatures, got {} / {}".format(model.water_cop(-5), model.water_cop(15))) + failed = True + derived = HeatModel(resolve_heat({"existing_heat_pump": True, "heat_pump_kwh": 4000, "water_heat_pump_kwh": 900, "scop": 3.25, "flow_temp_c": 35}), temperatures, year) + if close(derived.water_cop(5), 3.35): + print(" ERROR: without an override the water COP should be derived, not 3.35") + failed = True + + print("Test: hot water electricity cannot exceed the heat pump's total") + try: + resolve_heat({"existing_heat_pump": True, "heat_pump_kwh": 1000, "water_heat_pump_kwh": 2000}) + print(" ERROR: hot water above the heat pump total should be rejected") + failed = True + except AnnualHeatError: + pass + + print("Test: the derived gas bill reconciles with the derived heat") + gas_total = sum(model.month_gas(month)["gas_kwh"] for month in range(1, 13)) + if not close(gas_total, settings["annual_gas_kwh"], tolerance=2.0): + print(" ERROR: monthly gas should sum to the derived annual {} kWh, got {}".format(settings["annual_gas_kwh"], gas_total)) + failed = True + + print("Test: removing the heat pump recovers the house without it, and adding it back restores the original") + # Big enough to absorb a January day's heat pump load: a 4000 kWh/year heat pump + # averages around 26 kWh of electricity a day in January, far above its annual mean, + # which is exactly why the subtraction has to be done per minute rather than on a + # yearly total. + base = StubLoad(daily=45.0) + day = date(year, 1, 15) + model.set_month_scale(1, [(day, 31.0)]) + removed = HeatPumpRemovedLoadProfile(base, model) + restored = HeatPumpLoadProfile(removed, model) + + base_profile = base.minute_profile(day) + removed_profile = removed.minute_profile(day) + restored_profile = restored.minute_profile(day) + heat_profile = model.minute_electricity_kwh(day) + + # Energy conservation, which holds whether or not the floor at zero bit: what came out + # is what went in, less the heat pump, plus whatever could not be taken away. + if not close(sum(removed_profile), sum(base_profile) - sum(heat_profile) + removed.unsubtracted_kwh, tolerance=0.01): + print(" ERROR: the baseline does not reconcile: {} vs {} - {} + {}".format(sum(removed_profile), sum(base_profile), sum(heat_profile), removed.unsubtracted_kwh)) + failed = True + + print("Test: with headroom to spare, removing then re-adding the heat pump restores the original exactly") + # A flat stub house drawing 1.9 kW cannot absorb a 3 kW cylinder charge, so the floor + # at zero bites on a 45 kWh day and the round trip is lossy by exactly that much. Give + # it real headroom and the identity the whole two-leg comparison rests on must hold + # exactly: the two legs differ by the heat pump and by nothing else. + roomy_base = StubLoad(daily=200.0) + roomy_removed = HeatPumpRemovedLoadProfile(roomy_base, model) + roomy_restored = HeatPumpLoadProfile(roomy_removed, model) + if roomy_removed.unsubtracted_kwh > 0.001: + print(" ERROR: a 200 kWh day should absorb the heat pump with nothing left over, got {}".format(roomy_removed.unsubtracted_kwh)) + failed = True + if not close(sum(roomy_restored.minute_profile(day)), 200.0, tolerance=0.01): + print(" ERROR: removing then re-adding should restore 200 kWh, got {}".format(sum(roomy_restored.minute_profile(day)))) + failed = True + + print("Test: a heat pump too big for the stated household load is recorded, not silently dropped") + tiny = HeatPumpRemovedLoadProfile(StubLoad(daily=0.5), model) + profile = tiny.minute_profile(day) + if min(profile) < 0: + print(" ERROR: the baseline must never go negative") + failed = True + if tiny.unsubtracted_kwh <= 0: + print(" ERROR: the un-subtractable energy should have been recorded, got {}".format(tiny.unsubtracted_kwh)) + failed = True + + print("Test: a missing base day stays missing rather than becoming a negative baseline") + if HeatPumpRemovedLoadProfile(StubLoad(daily=20.0, missing={day}), model).minute_profile(day) is not None: + print(" ERROR: a missing base day must pass None through") + failed = True + + if failed: + print("**** ERROR: existing heat pump tests failed ****") + return failed diff --git a/apps/predbat/tests/test_annual_integration.py b/apps/predbat/tests/test_annual_integration.py index 96176a866..463532caa 100644 --- a/apps/predbat/tests/test_annual_integration.py +++ b/apps/predbat/tests/test_annual_integration.py @@ -12,7 +12,8 @@ These run real Predbat plans, so they are registered as slow. """ -from datetime import date, datetime +import math +from datetime import date, datetime, timedelta import pytz @@ -377,3 +378,121 @@ def _run_car_config(): failed = True return failed + + +def make_heat_model(year=2025, scop=3.2): + """Return a HeatModel over a cold synthetic winter, ready for a January sample.""" + from annual_heat import HeatModel, resolve_heat + + temperatures = {} + for offset in range(400): + day = date(year, 1, 1) + timedelta(days=offset) + # A cold January easing into a mild spring, so a January sample has real demand + seasonal = 2.0 + 12.0 * (offset / 200.0) + temperatures[day] = [seasonal + 2.0 * math.sin(2 * math.pi * (hour - 9) / 24.0) for hour in range(24)] + settings = resolve_heat({"annual_gas_kwh": 14000, "water_gas_kwh": 2500, "scop": scop}) + return HeatModel(settings, temperatures, year) + + +def test_annual_heat_integration(my_predbat): + """Verify the heat pump leg runs through the real scenarios and costs more than gas. + + The point of this test is that the heat pump is modelled as ORDINARY electrical load: + it goes through the same planner, battery and PV as any other load, and the answer is + the difference between two real runs rather than an arithmetic estimate bolted on the + side. Anything that silently stopped the heat pump load reaching the prediction (a + load source that replaced rather than wrapped, a leg planned against the wrong series) + would show up here as two legs with identical costs. + """ + from annual_heat import HeatPumpLoadProfile + + failed = False + print("**** Testing annual heat pump integration ****") + + config = make_config() + weather = StubWeather(peak_kw=4.0) + day = date(2025, 1, 15) + midnight = pytz.utc.localize(datetime(day.year, day.month, day.day)) + model = make_heat_model() + model.set_month_scale(1, [(day, 31.0)]) + + base_load = SyntheticLoadProfile(annual_kwh=config["load"]["annual_kwh"], shape=config["load"]["shape"], year=config["year"]) + heat_load = HeatPumpLoadProfile(base_load, model) + + print("Test: a heat pump day draws meaningfully more electricity than the base load") + heat_kwh = sum(model.hourly_electricity_kwh(day)) + if heat_kwh <= 1.0: + print(" ERROR: a mid-January heat pump day should draw well over 1 kWh, got {}".format(heat_kwh)) + failed = True + + reset_inverter(my_predbat) + gas_leg = run_day(my_predbat, config, weather, StubTariff(), base_load, day, midnight) + reset_inverter(my_predbat) + heat_leg = run_day(my_predbat, config, weather, StubTariff(), heat_load, day, midnight) + + print("Test: both legs produce every scenario") + for key in SCENARIO_KEYS: + for leg_name, leg in [("gas", gas_leg), ("heat pump", heat_leg)]: + if key not in leg: + print(" ERROR: the {} leg is missing scenario '{}'".format(leg_name, key)) + failed = True + + if not failed: + print("Test: the heat pump leg costs more and imports more in every scenario") + for key in SCENARIO_KEYS: + if heat_leg[key]["cost_p"] <= gas_leg[key]["cost_p"]: + print(" ERROR: {}: the heat pump leg ({}) should cost more than the gas leg ({})".format(key, heat_leg[key]["cost_p"], gas_leg[key]["cost_p"])) + failed = True + if heat_leg[key]["import_kwh"] <= gas_leg[key]["import_kwh"]: + print(" ERROR: {}: the heat pump leg should import more than the gas leg".format(key)) + failed = True + + print("Test: the extra import is EXACTLY the heat pump's own energy, neither scaled nor double counted") + # With no PV and no battery there is nothing to store or generate, so every kWh the + # heat pump draws has to arrive as an extra imported kWh - no more, no less. That + # makes this an exact identity rather than a tolerance, and it is the single + # sharpest check that the heat pump load reaches the prediction untouched: a load + # source that replaced rather than wrapped, a profile applied twice, or Predbat's + # load_scaling being applied to it would all break it. + # + # Only the FIRST day of the 48 hour plan counts, the same day cost_p is billed for: + # import_kwh is read from the end_record-bounded snapshot (see _billed_result). This + # doubles as the regression guard for that bounding - before 2026-08-02 import_kwh + # was the running total over the whole plan, and this identity only held against + # both days' heat. Only the difference between the legs is asserted here, so the + # constant background load both legs carry cancels out. + extra_import = heat_leg["no_pvbat"]["import_kwh"] - gas_leg["no_pvbat"]["import_kwh"] + if abs(extra_import - heat_kwh) > 0.01: + print(" ERROR: no_pvbat extra import {} should equal the heat pump's {} kWh over the billed day".format(extra_import, heat_kwh)) + failed = True + + print("Test: the extra import is the billed day's heat alone, not the whole 48 hour plan") + # Stated separately from the identity above because it is the thing that regressed: + # an unbounded import_kwh lands almost exactly on the two-day figure, which is a + # plausible-looking number rather than an obviously broken one. + plan_heat_kwh = heat_kwh + sum(model.hourly_electricity_kwh(day + timedelta(days=1))) + if abs(extra_import - plan_heat_kwh) < 0.01: + print(" ERROR: no_pvbat extra import {} covers both plan days ({} kWh), so import_kwh is not bounded to the billed day".format(extra_import, plan_heat_kwh)) + failed = True + + print("Test: PV and the battery reduce the heat pump's extra cost") + # The whole reason the heat pump goes through the scenarios rather than being + # costed at an average rate: a household with PV, a battery and Predbat should + # absorb some of the heat pump's demand more cheaply than one with neither. + bare_extra = heat_leg["no_pvbat"]["cost_p"] - gas_leg["no_pvbat"]["cost_p"] + predbat_extra = heat_leg["with_predbat"]["cost_p"] - gas_leg["with_predbat"]["cost_p"] + if predbat_extra >= bare_extra: + print(" ERROR: with PV/battery/Predbat the heat pump's extra cost ({}) should be below the no-system extra ({})".format(predbat_extra, bare_extra)) + failed = True + + print("Test: PV generation is unchanged between the legs") + # A useful self-check that the two legs really are the same sampled day: the heat + # pump changes what the house consumes, never what the roof produces. + for key in SCENARIO_KEYS: + if abs(heat_leg[key]["pv_generated_kwh"] - gas_leg[key]["pv_generated_kwh"]) > 1e-6: + print(" ERROR: {}: PV generation should be identical between the legs".format(key)) + failed = True + + if failed: + print("**** ERROR: annual heat pump integration tests failed ****") + return failed diff --git a/apps/predbat/tests/test_web_annual.py b/apps/predbat/tests/test_web_annual.py index 43aa242c4..ff3f26e8a 100644 --- a/apps/predbat/tests/test_web_annual.py +++ b/apps/predbat/tests/test_web_annual.py @@ -12,6 +12,7 @@ import asyncio import builtins import copy +import datetime import json import re from unittest.mock import patch @@ -22,7 +23,7 @@ from annual_store import list_runs, load_run, save_run from tariff_catalogue import BASELINE_DEFAULT_IMPORT_ID, CUSTOM_ID, EXPORT_TARIFFS, IMPORT_TARIFFS, NO_EXPORT_ID, PRICE_CAP_IMPORT_P from web import WebInterface -from web_annual import DEFAULT_CONFIG, AnnualPage, _json_for_script +from web_annual import DEFAULT_CONFIG, UK_TEMPERATURE_MEAN_C, AnnualPage, _json_for_script class FakeRequest: @@ -2525,3 +2526,317 @@ def test_web_annual_plan_route(my_predbat): failed = True return failed + + +def heat_results(): + """Return a results document whose run included the heat pump comparison.""" + results = sample_run_results() + results["annual"]["months_included"] = 12 + results["annual"]["heat"] = { + "available": True, + "summary": { + "annual_gas_kwh": 12000.0, + "realised_scop": 3.19, + "configured_scop": 3.2, + "remove_gas_supply": True, + "flow_temp_c": 45.0, + "water_flow_temp_c": 52.0, + }, + "costs": {"install_gbp": 13000.0, "grant_gbp": 7500.0, "net_gbp": 5500.0, "quoted": False}, + "months_included": 12, + "heat_kwh": 10200.0, + "space_heat_kwh": 8160.0, + "water_heat_kwh": 2040.0, + "gas_kwh": 12000.0, + "gas_unit_cost_p": 76800.0, + "gas_standing_charge_p": 11680.0, + "gas_total_p": 88480.0, + "gas_avoided_p": 88480.0, + "heat_pump_kwh_estimate": 3197.5, + "scenarios": { + "no_pvbat": {"electricity_gas_p": 18000.0, "electricity_heatpump_p": 105000.0, "extra_electricity_p": 87000.0, "annual_saving_p": 1480.0}, + "pv_only": {"electricity_gas_p": 13000.0, "electricity_heatpump_p": 95000.0, "extra_electricity_p": 82000.0, "annual_saving_p": 6480.0}, + "without_predbat": {"electricity_gas_p": 9000.0, "electricity_heatpump_p": 78000.0, "extra_electricity_p": 69000.0, "annual_saving_p": 19480.0}, + "with_predbat": {"electricity_gas_p": 6600.0, "electricity_heatpump_p": 61000.0, "extra_electricity_p": 54400.0, "annual_saving_p": 34080.0}, + }, + "payback": { + "available": True, + "scenarios": { + "no_pvbat": {"capital_gbp": 5500.0, "annual_saving_gbp": 14.8, "pays_back": True, "years": 371.62}, + "pv_only": {"capital_gbp": 5500.0, "annual_saving_gbp": 64.8, "pays_back": True, "years": 84.88}, + "without_predbat": {"capital_gbp": 5500.0, "annual_saving_gbp": 194.8, "pays_back": True, "years": 28.23}, + "with_predbat": {"capital_gbp": 5500.0, "annual_saving_gbp": 340.8, "pays_back": True, "years": 16.14}, + }, + }, + } + return results + + +def test_web_annual_heat(my_predbat): + """Verify the heating form section, its post round-trip, and the results rendering.""" + failed = False + print("**** Testing web_annual heating section ****") + + page = make_page(my_predbat) + + print("Test: the form shows a Heating group, unticked by default") + config = page.prefill_config() + form = page.render_form(config) + if "Heating" not in form: + print(" ERROR: the form is missing the 'Heating' group") + failed = True + for field in ["heat_enable", "heat_annual_gas_kwh", "heat_water_gas_kwh", "heat_gas_p_per_kwh", "heat_gas_standing_charge_p_per_day", "heat_scop", "heat_flow_temp_c", "heat_remove_gas_supply"]: + if 'id="{}"'.format(field) not in form: + print(" ERROR: the form is missing the '{}' field".format(field)) + failed = True + + print("Test: the form warns that the comparison doubles the run time") + if "doubles how long a run takes" not in form: + print(" ERROR: the form should say the heat pump comparison doubles the run time") + failed = True + + print("Test: an unticked box means no heat pump is modelled") + posted = page.config_from_post(valid_postdata()) + if posted["heat"]["enable"]: + print(" ERROR: an absent heat_enable checkbox should not enable the heat pump") + failed = True + validated = validate_config(posted, today=datetime.date(2026, 7, 25)) + if validated["heat"] is not None: + print(" ERROR: a disabled heat block should validate to None, got {}".format(validated["heat"])) + failed = True + + print("Test: a ticked box round-trips every heating field through validation") + postdata = valid_postdata() + postdata.update( + { + "heat_enable": "on", + "heat_annual_gas_kwh": "14200", + "heat_water_gas_kwh": "2600", + "heat_gas_p_per_kwh": "6.1", + "heat_gas_standing_charge_p_per_day": "31.5", + "heat_scop": "3.4", + "heat_flow_temp_c": "40", + "heat_remove_gas_supply": "on", + } + ) + posted = page.config_from_post(postdata) + validated = validate_config(posted, today=datetime.date(2026, 7, 25)) + heat = validated["heat"] + if heat is None: + print(" ERROR: a ticked heat_enable should produce a heat block") + failed = True + else: + for field, expected in [("annual_gas_kwh", 14200.0), ("water_gas_kwh", 2600.0), ("gas_p_per_kwh", 6.1), ("gas_standing_charge_p_per_day", 31.5), ("scop", 3.4), ("flow_temp_c", 40.0)]: + if abs(heat[field] - expected) > 1e-6: + print(" ERROR: {} should round-trip as {}, got {}".format(field, expected, heat[field])) + failed = True + if not heat["remove_gas_supply"]: + print(" ERROR: a ticked heat_remove_gas_supply should be True") + failed = True + # (14200 - 2600) * 0.85 + if abs(heat["space_heat_kwh"] - 9860.0) > 1.0: + print(" ERROR: space heat should be 9860 kWh, got {}".format(heat["space_heat_kwh"])) + failed = True + + print("Test: an unticked 'gas disconnected' box is carried through as False") + postdata.pop("heat_remove_gas_supply") + validated = validate_config(page.config_from_post(postdata), today=datetime.date(2026, 7, 25)) + if validated["heat"]["remove_gas_supply"]: + print(" ERROR: an absent heat_remove_gas_supply checkbox should be False") + failed = True + + print("Test: a bad heating value is reported as a validation error, not a crash") + postdata["heat_scop"] = "not a number" + message = page.validation_error(page.config_from_post(postdata)) + if not message or "scop" not in message.lower(): + print(" ERROR: a non-numeric SCOP should produce a validation error naming it, got '{}'".format(message)) + failed = True + + print("Test: a saved heat config is reflected back into the form") + saved = page.prefill_config() + saved["heat"] = {"enable": True, "annual_gas_kwh": 9100, "scop": 4.1, "remove_gas_supply": False} + form = page.render_form(saved) + if 'id="heat_enable" name="heat_enable" onchange="annualHeatChanged()" checked' not in form: + print(" ERROR: an enabled heat config should tick the box") + failed = True + if 'value="9100"' not in form: + print(" ERROR: the saved gas figure should show in the form") + failed = True + if 'id="heat-fields" style="display:block"' not in form: + print(" ERROR: an enabled heat config should show the heating fields") + failed = True + + print("Test: a run with no heating renders no heating section at all") + if page._render_heat(sample_run_results()): + print(" ERROR: a run without a heat block should render nothing") + failed = True + + print("Test: the heating results render the gas bill, the per-scenario saving and the payback") + rendered = page._render_heat(heat_results()) + for fragment in ["Heat pump versus gas boiler", "Gas bill today", "£768.00", "£116.80", "£884.80"]: + if fragment not in rendered: + print(" ERROR: the heating results are missing '{}'".format(fragment)) + failed = True + for label in ["No PV/Battery", "PV only", "Without Predbat", "With Predbat"]: + if label not in rendered: + print(" ERROR: the heating results are missing the '{}' scenario row".format(label)) + failed = True + if "16.1 years" not in rendered: + print(" ERROR: the heating results should show the with-Predbat payback period") + failed = True + if "£5,500" not in rendered: + print(" ERROR: the heating results should show the net capital after the grant") + failed = True + + print("Test: a scenario the heat pump loses money on is not shown as a payback") + losing = heat_results() + losing["annual"]["heat"]["scenarios"]["no_pvbat"]["annual_saving_p"] = -5000.0 + losing["annual"]["heat"]["payback"]["scenarios"]["no_pvbat"] = {"capital_gbp": 5500.0, "annual_saving_gbp": -50.0, "pays_back": False, "years": None} + rendered = page._render_heat(losing) + if "does not pay back" not in rendered: + print(" ERROR: a losing scenario should say it does not pay back") + failed = True + + print("Test: an unavailable heat block renders its reason rather than an empty table") + unavailable = sample_run_results() + unavailable["annual"]["heat"] = {"available": False, "reason": "No month produced a usable heat pump result.", "summary": {}, "costs": {}} + rendered = page._render_heat(unavailable) + if "No month produced a usable heat pump result." not in rendered: + print(" ERROR: an unavailable heat block should render its reason") + failed = True + + print("Test: keeping the gas supply is explained rather than silently ignored") + kept = heat_results() + kept["annual"]["heat"]["summary"]["remove_gas_supply"] = False + kept["annual"]["heat"]["gas_avoided_p"] = 76800.0 + rendered = page._render_heat(kept) + if "keeping the gas supply" not in rendered: + print(" ERROR: keeping the gas supply should be spelled out in the results") + failed = True + + if failed: + print("**** ERROR: web_annual heating tests failed ****") + return failed + + +# The COP and weather-compensation curves from a real Daikin install, as they appear in a +# working predheat block. Used rather than round numbers so the arithmetic is exercised +# against something with the awkward shape real curves have. +PREDHEAT_COP_CURVE = {-20: 2.10, -18: 2.15, -16: 2.2, -14: 2.25, -12: 2.3, -10: 2.4, -8: 2.5, -6: 2.6, -4: 2.7, -2: 2.8, 0: 2.9, 2: 3.1, 4: 3.3, 6: 3.6, 8: 3.8, 10: 3.9, 12: 4.1, 14: 4.3, 16: 4.3, 18: 4.3, 20: 4.3} +PREDHEAT_COMPENSATION = {-20: 45.0, 0: 45.0, 5: 36.0, 10: 30.0, 20: 30.0} + + +def test_web_annual_predheat_prefill(my_predbat): + """Verify the heating section is prefilled from Predbat's own Predheat configuration.""" + failed = False + print("**** Testing web_annual Predheat prefill ****") + + page = make_page(my_predbat) + saved_args = copy.deepcopy(my_predbat.args) + try: + print("Test: with no predheat block the heating section is left at the example values") + my_predbat.args.pop("predheat", None) + if page._heat_from_args() is not None: + print(" ERROR: no predheat block should prefill nothing, got {}".format(page._heat_from_args())) + failed = True + form = page.render_form(page.prefill_config()) + if "Predbat already has you on a heat pump" in form: + print(" ERROR: the heat pump banner must not show without a predheat block") + failed = True + + print("Test: a COP curve collapses to a seasonal figure below its own maximum") + seasonal = page._seasonal_cop(PREDHEAT_COP_CURVE) + if seasonal is None: + print(" ERROR: the COP curve should produce a seasonal figure") + failed = True + else: + # Below the curve's 4.3 ceiling, because heat is delivered when it is cold, but + # comfortably above its cold-end values, because -20C essentially never happens. + if not 3.2 < seasonal < 3.8: + print(" ERROR: seasonal COP for this real Daikin curve should land around 3.5, got {}".format(seasonal)) + failed = True + if seasonal >= max(PREDHEAT_COP_CURVE.values()): + print(" ERROR: the seasonal COP must be below the curve's maximum, got {}".format(seasonal)) + failed = True + + print("Test: an unreachable cold tail does not drag the seasonal COP down") + # The bug this pins: weighting by heat demand alone treats a -40C hour as arriving + # as often as a +5C one, so bolting a deep cold tail onto a curve would wreck the + # figure. Frequency weighting has to make the tail nearly irrelevant in the UK. + with_tail = dict(PREDHEAT_COP_CURVE) + with_tail.update({-40: 1.2, -35: 1.3, -30: 1.5, -25: 1.8}) + tailed = page._seasonal_cop(with_tail) + if abs(tailed - seasonal) > 0.05: + print(" ERROR: an unreachable cold tail moved the seasonal COP from {} to {}".format(seasonal, tailed)) + failed = True + + print("Test: the seasonal COP is a harmonic (heat over electricity) mean, not arithmetic") + # Same temperature either side of the mean so the frequency weights match, leaving + # only the harmonic combination to distinguish the two. + pair = {UK_TEMPERATURE_MEAN_C - 4.0: 2.0, UK_TEMPERATURE_MEAN_C - 4.0 - 1e-9: 2.0} + if page._seasonal_cop(pair) is None or abs(page._seasonal_cop(pair) - 2.0) > 1e-6: + print(" ERROR: two identical points should give exactly that COP, got {}".format(page._seasonal_cop(pair))) + failed = True + arithmetic = page._seasonal_cop({5.0: 2.0, 5.0 - 1e-9: 6.0}) + if arithmetic is None or not 2.9 < arithmetic < 3.1: + print(" ERROR: COP 2 and 6 at the same temperature should combine harmonically to 3.0, got {}".format(arithmetic)) + failed = True + + print("Test: an empty or unusable curve prefills nothing rather than a wrong number") + for curve in [None, {}, {"nonsense": "x"}, {20: 3.0}]: + if page._seasonal_cop(curve) is not None: + print(" ERROR: curve {} should not produce a seasonal COP, got {}".format(curve, page._seasonal_cop(curve))) + failed = True + + print("Test: the weather-compensation curve gives a heat-weighted flow temperature") + flow = page._seasonal_flow_temp(PREDHEAT_COMPENSATION) + if flow is None or not 30.0 <= flow <= 45.0: + print(" ERROR: the flow temperature should land between the curve's own bounds, got {}".format(flow)) + failed = True + + print("Test: a predheat block in pump mode prefills SCOP and flow, and warns about double counting") + my_predbat.args["predheat"] = {"mode": "pump", "heat_pump_efficiency": PREDHEAT_COP_CURVE, "weather_compensation": PREDHEAT_COMPENSATION} + prefilled = page._heat_from_args() + if not prefilled or prefilled.get("predheat_mode") != "pump": + print(" ERROR: predheat mode should be reported as pump, got {}".format(prefilled)) + failed = True + elif "scop" not in prefilled or "flow_temp_c" not in prefilled: + print(" ERROR: both SCOP and flow temperature should be prefilled, got {}".format(prefilled)) + failed = True + + config = page.prefill_config() + if "predheat_mode" in (config.get("heat") or {}): + print(" ERROR: predheat_mode is live state and must not be stored in the saved config") + failed = True + if abs((config["heat"] or {}).get("scop", 0) - prefilled["scop"]) > 1e-6: + print(" ERROR: the prefilled SCOP should reach the config, got {}".format(config.get("heat"))) + failed = True + + print("Test: the comparison is NOT switched on by the prefill") + if config["heat"].get("enable"): + print(" ERROR: prefilling must not enable the heat pump comparison - it doubles the run time") + failed = True + + print("Test: the form warns that an existing heat pump is already inside the load figure") + form = page.render_form(config) + for fragment in ["Predbat already has you on a heat pump", "already includes", "counted twice"]: + if fragment not in form: + print(" ERROR: the form is missing the double-counting warning fragment '{}'".format(fragment)) + failed = True + + print("Test: a gas-mode predheat block prefills without the heat pump warning") + my_predbat.args["predheat"] = {"mode": "gas"} + form = page.render_form(page.prefill_config()) + if "Predbat already has you on a heat pump" in form: + print(" ERROR: a gas-mode predheat block must not claim a heat pump is installed") + failed = True + if "mode: gas" not in form: + print(" ERROR: a gas-mode predheat block should still be reflected in the form") + failed = True + finally: + my_predbat.args.clear() + my_predbat.args.update(saved_args) + + if failed: + print("**** ERROR: web_annual Predheat prefill tests failed ****") + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 991a3caab..87cf072ef 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -106,6 +106,8 @@ test_web_annual_plan_route, test_web_annual_post_numeric_coercion, test_web_annual_results, + test_web_annual_heat, + test_web_annual_predheat_prefill, test_web_annual_routes, test_web_annual_routes_registered, test_web_annual_run_refuses_while_running, @@ -237,7 +239,8 @@ from tests.test_annual_sampling import test_annual_sampling from tests.test_annual_scenarios import test_annual_scenarios from tests.test_annual_results import test_annual_results -from tests.test_annual_integration import test_annual_integration +from tests.test_annual_integration import test_annual_integration, test_annual_heat_integration +from tests.test_annual_heat import test_annual_heat from tests.test_annual_cli import test_annual_cli, test_annual_cli_machine, test_annual_cli_machine_end_to_end from tests.test_annual_job import test_annual_job from tests.test_tariff_catalogue import test_tariff_catalogue @@ -307,6 +310,16 @@ def run_annual_integration_isolated(my_predbat): return test_annual_integration(create_predbat()) +def run_annual_heat_integration_isolated(my_predbat): + """Run the annual heat pump integration test against a freshly created instance. + + Isolated for the same reason as run_annual_integration_isolated: it plans a year of sampled days + and never sets up the state it depends on, so run against the shared instance it inherits whatever + ambient state happens to be there. + """ + return test_annual_heat_integration(create_predbat()) + + def run_window_cache_tests_isolated(my_predbat): """Run the window bounds cache tests against a freshly created instance. @@ -424,6 +437,8 @@ def main(): ("web_annual_form", test_web_annual_form, "Annual web tab form tests", False), ("web_annual_routes", test_web_annual_routes, "Annual web tab route tests", False), ("web_annual_results", test_web_annual_results, "Annual web tab results tests", False), + ("web_annual_heat", test_web_annual_heat, "Annual web tab heat pump / gas heating section tests", False), + ("web_annual_predheat_prefill", test_web_annual_predheat_prefill, "Annual web tab Predheat prefill tests", False), ("web_annual_terminal_state", test_web_annual_terminal_state, "Annual web tab terminal-state claim/no-redirect-loop tests", False), ("web_annual_error_isolation", test_web_annual_error_isolation, "Annual web tab per-request error isolation tests", False), ("web_annual_routes_registered", test_web_annual_routes_registered, "Annual web tab route registration test", False), @@ -591,6 +606,7 @@ def main(): ("annual_job", test_annual_job, "Annual subprocess job control tests", False), ("annual_store", test_annual_store, "Annual run store tests", False), ("annual_costs", test_annual_costs, "Annual install cost and payback model tests", False), + ("annual_heat", test_annual_heat, "Annual prediction heat pump / gas boiler model tests", False), ("tariff_catalogue", test_tariff_catalogue, "Tariff catalogue tests", False), ("predheat", test_predheat, "PredHeat heat pump/gas prediction tests (tables, physics loop, scheduling)", False), ("web_mcp", test_web_mcp, "MCP server tests (OAuth flow, JWT tokens, PKCE, endpoint auth, tool dispatch)", False), @@ -599,6 +615,7 @@ def main(): ("update_time", test_update_time, "update_time clock frame tests (naive/aware agreement, manual slot round trip)", False), ("fetch_sensor_data", test_fetch_sensor_data, "fetch_sensor_data tests (history ingest, rates, cost so far, keep floors)", False), ("annual_integration", run_annual_integration_isolated, "Annual prediction integration tests", True), + ("annual_heat_integration", run_annual_heat_integration_isolated, "Annual prediction heat pump integration tests", True), ("load_ml", test_load_ml, "ML Load Forecaster tests (MLP, training, persistence, validation)", True), # ML training memory: dataset construction, normalisation dtype and statistics accuracy ("ml_memory", run_ml_memory_tests, "ML training memory tests", False),