Model/tests/domain/epc/test_main_heating_system_overlay.py
Khalim Conn-Kowlessar db3bf00602 fix(synthesis): Coherent Heating System — synthesisers own the whole boundary
A dwelling's heating is one conceptual system, but its fields are scattered
across EpcPropertyData (a gov-API schema mirror): the cluster on sap_heating, the
electricity tariff on sap_energy_source.meter_type, hot-water flags loose at top
level. Three places synthesise a heating system — Measure Options, Landlord
Overrides, EPC Prediction's donor — and each hand-copied a different ad-hoc
subset. The override and donor both dropped meter_type, so an electric-storage
system landed on the template's single-rate meter and billed overnight heat at
the peak rate: property 713406 scored SAP 13 (G) vs ~50 (E), inflating the HHRSH
measure to +45.8 and overshooting the plan to band A.

Establish a single Coherent Heating System boundary (CONTEXT.md) that every
synthesiser must cover, with a source-appropriate fill policy (ADR-0035):

- Override overlay *completes* the partial system the landlord named. Companion
  fields are now DERIVED from the SAP code, not hand-attached per archetype: the
  off-peak meter from the calculator's single off-peak classification (new
  OFF_PEAK_IMPLYING_HEATING_CODES = SAP §12 Rules 1-2), and an unobserved storage
  charge control defaults to the conservative manual control (Table 4e 2401). So
  adding a heating archetype is just adding its code — companions can't be
  forgotten. A contract test guards it (every off-peak code drags a Dual meter).
- Prediction's heating donor now *carries* the donor's meter_type alongside its
  sap_heating cluster — the donor is already coherent.

Coherence is a synthesis-time obligation only; the calculator still scores a real
lodged cert exactly as lodged.

Verified on 713406: baseline 13 -> 47.8 (E), matching its recorded rating; the
phantom HHRSH recommendation is gone and the plan no longer overshoots to A.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:46:11 +00:00

226 lines
8 KiB
Python

"""The Landlord-Override main-heating-system → heating Simulation Overlay mapping.
A main-heating-system value resolves to the SAP `sap_main_heating_code` the
calculator reads from the primary system; the overlay is whole-dwelling.
"""
from __future__ import annotations
import pytest
from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType
from domain.epc.property_overlays.main_fuel_overlay import fuel_overlay_for
from domain.epc.property_overlays.main_heating_system_overlay import (
_MAIN_HEATING_CODES,
main_heating_overlay_for,
)
from domain.sap10_calculator.tables.table_12a import (
OFF_PEAK_IMPLYING_HEATING_CODES,
)
from domain.epc.property_overlays.water_heating_overlay import (
water_heating_overlay_for,
)
from domain.modelling.scoring.overlay_applicator import apply_simulations
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
def test_gas_combi_overlays_the_primary_heating_code() -> None:
# Act
simulation = main_heating_overlay_for("Gas boiler, combi", 0)
# Assert — condensing combi is SAP Table 4b code 104.
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.sap_main_heating_code == 104
@pytest.mark.parametrize(
("main_heating_value", "code"),
[
("Gas boiler, regular", 102),
("Gas CPSU", 120),
("Electric storage heaters, fan", 404),
("Direct-acting electric", 191),
],
)
def test_heating_archetypes_decode_to_their_sap_codes(
main_heating_value: str, code: int
) -> None:
# Act
simulation = main_heating_overlay_for(main_heating_value, 0)
# Assert
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.sap_main_heating_code == code
@pytest.mark.parametrize(
("main_heating_value", "code"),
[
("Electric storage heaters, old", 401),
("Electric storage heaters, slimline", 402),
("Electric storage heaters, convector", 403),
],
)
def test_storage_heater_subtypes_decode_to_their_codes(
main_heating_value: str, code: int
) -> None:
# Act
simulation = main_heating_overlay_for(main_heating_value, 0)
# Assert
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.sap_main_heating_code == code
@pytest.mark.parametrize(
"main_heating_value",
[
"Electric storage heaters, old",
"Electric storage heaters, slimline",
"Electric storage heaters, convector",
"Electric storage heaters, fan",
],
)
def test_storage_heaters_carry_an_off_peak_meter(main_heating_value: str) -> None:
# Storage heaters run on an Economy 7 (off-peak) tariff by design; setting
# only the heating code while leaving a single-rate meter bills every heating
# kWh at the peak rate and collapses the score (an override-set storage
# dwelling left on an oil donor's single meter scored SAP 13 vs ~50 on
# Economy 7). The overlay carries the off-peak meter, like the HHRSH measure.
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.meter_type == "Dual"
@pytest.mark.parametrize(
"main_heating_value", ["Gas boiler, combi", "Direct-acting electric"]
)
def test_non_storage_heating_leaves_the_meter_untouched(
main_heating_value: str,
) -> None:
# Only storage heaters imply an off-peak tariff; gas and direct-acting
# electric (single-rate) keep whatever meter the dwelling already has.
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.meter_type is None
@pytest.mark.parametrize(
"main_heating_value",
[
"Electric storage heaters, old",
"Electric storage heaters, slimline",
"Electric storage heaters, convector",
"Electric storage heaters, fan",
],
)
def test_storage_heaters_drag_along_conservative_manual_charge_control(
main_heating_value: str,
) -> None:
# The landlord names the system, not its charge control. Manual charge
# control (Table 4e code 2401, +0.7 C MIT adjustment) is the lowest-SAP
# storage control, so it's the safe assumption that never over-credits.
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.main_heating_control == 2401
@pytest.mark.parametrize(
"main_heating_value", ["Gas boiler, combi", "Direct-acting electric"]
)
def test_non_storage_heating_does_not_drag_along_a_charge_control(
main_heating_value: str,
) -> None:
# Charge control is a storage-heater concept; other systems keep their own.
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.main_heating_control is None
def test_off_peak_archetypes_drag_a_dual_meter_others_do_not() -> None:
# Contract (the drag-along guard): the off-peak meter is derived from the SAP
# code via the calculator's single off-peak classification, so any heating
# archetype in the map whose code implies off-peak MUST synthesise a Dual
# meter — adding an off-peak system can never silently leave it single-rate —
# and a single-rate system must never gain one.
for value, code in _MAIN_HEATING_CODES.items():
simulation = main_heating_overlay_for(value, 0)
assert simulation is not None and simulation.heating is not None
expected = "Dual" if code in OFF_PEAK_IMPLYING_HEATING_CODES else None
assert simulation.heating.meter_type == expected, value
@pytest.mark.parametrize(
"main_heating_value",
["Unknown", "", "Air source heat pump", "Community heating"],
)
def test_unresolvable_or_unmodelled_heating_produces_no_overlay(
main_heating_value: str,
) -> None:
# Heat pumps (main_heating_index_number) and community heating (community
# codes) don't map to a Table 4b sap_main_heating_code yet — no overlay.
# Act
simulation = main_heating_overlay_for(main_heating_value, 0)
# Assert
assert simulation is None
def test_main_heating_override_remaps_the_primary_system_code() -> None:
# Arrange
baseline = build_epc()
overlay = main_heating_overlay_for("Gas boiler, regular", 0)
assert overlay is not None
# Act
result = apply_simulations(baseline, [overlay])
# Assert — the calculator reads the code off main_heating_details[0].
assert result.sap_heating.main_heating_details[0].sap_main_heating_code == 102
def test_the_three_heating_overrides_compose_without_conflict() -> None:
# Arrange — main_fuel, water_heating and main_heating_system all fold onto one
# HeatingOverlay surface but set DISJOINT fields, so they compose (the
# field-disjoint design that makes precedence moot for these three).
baseline = build_epc()
overlays = [
fuel_overlay_for("electricity", 0),
water_heating_overlay_for("Electric immersion, electricity", 0),
main_heating_overlay_for("Electric storage heaters, fan", 0),
]
assert all(o is not None for o in overlays)
# Act
result = apply_simulations(baseline, [o for o in overlays if o is not None])
# Assert — each override landed on its own field.
main = result.sap_heating.main_heating_details[0]
assert main.main_fuel_type == 29
assert main.sap_main_heating_code == 404
assert result.sap_heating.water_heating_code == 903
assert result.sap_heating.water_heating_fuel == 29
@pytest.mark.parametrize(
"member",
[m for m in MainHeatingSystemType if m is not MainHeatingSystemType.UNKNOWN],
)
def test_every_resolvable_main_heating_value_decodes(
member: MainHeatingSystemType,
) -> None:
# Act
simulation = main_heating_overlay_for(member.value, 0)
# Assert
assert simulation is not None