Model/tests/domain/modelling/test_elmhurst_cascade_pins.py
Khalim Conn-Kowlessar fc7c4d2d3b fix(climate): compute EPC CO2/PE on the postcode demand cascade (SAP 10.2 Appendix U p.124)
The SAP/EI rating is computed on UK-average weather (Appendix U Tables
U1-U3 region 0) so ratings are nationally comparable, but Appendix U
paragraph 1 (PDF p.124) requires that "other calculations (such as for
energy use and costs on EPCs) are done using local weather. Weather data
for each postcode district are taken from the PCDB". `Sap10Calculator.
calculate` ran ONE cascade (UK-average) and fed it to SAP, CO2 AND primary
energy, so every cert's EPC-displayed CO2/PE were computed on the wrong
climate. Because most of England is warmer than the UK-average, this
systematically OVER-counted heating demand on the emissions/PE outputs.

The two cascades (`cert_to_inputs` rating, `cert_to_demand_inputs`
postcode) already existed; this wires the demand cascade into the
production entry point and grafts its CO2/PE onto the rating result (SAP
unchanged). The corpus gauge's longstanding +5% CO2/PE over-estimate was
mostly this climate bug, NOT (as previously diagnosed) per-cert mapper
fidelity:
  CO2 MAE 0.26 -> 0.12 t/yr  (bias +0.18 -> +0.04)
  PE  MAE 13.6 -> 3.8 kWh/m2 (bias +9.0  -> +0.24)
  SAP within-0.5 = 69.7% (rating cascade, unchanged)

Worksheet-validated to 1e-4 on simulated case 45 (heat-pump ground-floor
flat, postcode W6): the P960 prints the current dwelling twice — Block 1
on UK-average weather (SAP 60.5318, CO2 692.13) and Block 2 on postcode
weather (CO2 626.78, PE 6581.59). Both reproduce exactly. Added a tracked
case-45 Summary fixture + two-cascade cascade pin as a permanent guard,
and ratcheted the corpus CO2/PE ceilings to 0.13 / 4.2. The e2e Elmhurst
suite (Block-1 line refs) now pins the rating cascade directly; the two
Vaillant overlay snapshots refreshed to demand-cascade CO2/PE.

pyright not installed in this codespace (strict gate not run locally);
change is type-trivial (dataclasses.replace over SapResult).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:15:34 +00:00

1199 lines
50 KiB
Python

"""Elmhurst before/after cascade pins for the Recommendation Generators.
Each measure has an Elmhurst `before` Summary (baseline cert) and an `after`
Summary (the same cert re-lodged with the measure applied). The pin drives the
matching generator on the parsed `before`, scores its Option's overlay through
the `PackageScorer`, and asserts the result equals the calculator's score on
the parsed `after` at `abs(diff) <= 1e-4` for SAP / CO2 / primary energy.
This is the real cert→generator→overlay→calculator cascade, not a per-section
isolation test (see `[[feedback-cascade-pin-methodology]]`): a non-zero delta
is a named generator/overlay/calculator gap to fix, never a tolerance to widen
(`[[feedback-zero-error-strict]]`).
"""
from __future__ import annotations
import copy
from dataclasses import replace
from typing import Final
import pytest
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
PhotovoltaicArray,
PvBatteries,
PvBattery,
)
from domain.modelling.scoring.package_scorer import PackageScorer, Score
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from domain.modelling.generators.floor_recommendation import recommend_floor_insulation
from domain.modelling.generators.roof_recommendation import (
recommend_roof_insulation,
)
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
SolarOverlay,
)
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.generators.solid_wall_recommendation import (
recommend_solid_wall,
)
from domain.modelling.generators.glazing_recommendation import recommend_glazing
from domain.modelling.generators.lighting_recommendation import recommend_lighting
from domain.modelling.generators.heating_recommendation import recommend_heating
from domain.modelling.generators.secondary_heating_recommendation import (
recommend_secondary_heating_removal,
)
from domain.modelling.scoring.overlay_applicator import apply_simulations
from domain.modelling.recommendation import MeasureOption
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
from repositories.product.product_repository import ProductRepository
from tests.domain.modelling._elmhurst_recommendation import (
parse_recommendation_summary,
)
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_001431 import (
build_epc as build_001431_epc,
)
# RdSAP §A.2.2 forces a secondary system for electric-storage mains; SAP code
# 402 (slimline storage) is in that set. Code 104 (a gas combi boiler) is not.
_ELECTRIC_STORAGE_MAIN_CODE: Final[int] = 402
_STANDARD_ELECTRICITY_FUEL: Final[int] = 30
# SAP 10.2 Table 4a code 691 — electric panel/convector/radiant heaters, the
# fixed secondary the user's example cert lodges.
_SECONDARY_ELECTRIC_PANEL_CODE: Final[int] = 691
# Pin tolerance: the Summary PDFs are deterministic test vectors, so the
# overlay must reproduce the re-lodged cert exactly. Matches the worksheet
# e2e tolerance.
_PIN_ABS: Final[float] = 1e-4
# RdSAP wall_insulation_type codes for solid-wall insulation (Elmhurst
# Summary "E External" / "I Internal"); cf. domain/sap10_ml/rdsap_uvalues.py.
_WALL_INSULATION_EXTERNAL: Final[int] = 1
_WALL_INSULATION_INTERNAL: Final[int] = 3
# Recommended solid-wall insulation depth (mm); the calculator's λ default
# (0.04 W/m·K) matches Elmhurst's lodged thermal conductivity.
_SOLID_WALL_INSULATION_MM: Final[int] = 100
class _AnyProduct(ProductRepository):
"""In-memory ProductRepository returning a fixed Product for any Measure
Type. The pins assert the SAP cascade, not Cost, so the unit cost is
immaterial — only the generator's overlay is exercised."""
def get(self, measure_type: str) -> Product:
return Product(
measure_type=measure_type, unit_cost_per_m2=1.0, contingency_rate=0.0
)
def _assert_overlay_reproduces_after(
before: EpcPropertyData, after: EpcPropertyData, overlay: EpcSimulation
) -> None:
"""Score ``overlay`` on ``before`` and assert it matches the calculator's
score on the re-lodged ``after`` across all three metrics."""
calculator = Sap10Calculator()
relodged: SapResult = calculator.calculate(after)
scored: Score = PackageScorer(calculator).score(before, [overlay])
assert abs(scored.sap_continuous - relodged.sap_score_continuous) <= _PIN_ABS
assert abs(scored.co2_kg_per_yr - relodged.co2_kg_per_yr) <= _PIN_ABS
assert (
abs(scored.primary_energy_kwh_per_yr - relodged.primary_energy_kwh_per_yr)
<= _PIN_ABS
)
def _assert_overlay_scores(
before: EpcPropertyData,
overlay: EpcSimulation,
*,
sap: float,
co2: float,
pe: float,
) -> None:
"""Score ``overlay`` on ``before`` and assert it matches the given snapshot
of SAP / CO2 / primary energy. Used where the relodged after-cert predates
the Vaillant product swap (it lodges the old heat-pump index): the snapshot
is taken as correct because the same overlay reproduces the corrected
Vaillant cert at delta 0 in the boiler-3 pin (ADR-0025)."""
scored: Score = PackageScorer(Sap10Calculator()).score(before, [overlay])
assert abs(scored.sap_continuous - sap) <= _PIN_ABS
assert abs(scored.co2_kg_per_yr - co2) <= _PIN_ABS
assert abs(scored.primary_energy_kwh_per_yr - pe) <= _PIN_ABS
def test_cavity_wall_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"cavity_wall_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"cavity_wall_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_cavity_wall(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_solid_brick_ewi_overlay_reproduces_the_relodged_after() -> None:
# Arrange — 100 mm external wall insulation on a solid-brick main wall.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_after.pdf"
)
overlay = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
wall_insulation_type=_WALL_INSULATION_EXTERNAL,
wall_insulation_thickness=_SOLID_WALL_INSULATION_MM,
)
}
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solid_brick_iwi_overlay_reproduces_the_relodged_after() -> None:
# Arrange — 100 mm internal wall insulation on a solid-brick main wall
# (also lowers the thermal-mass parameter, unlike EWI).
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_iwi_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"solid_brick_iwi_001431_after.pdf"
)
overlay = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
wall_insulation_type=_WALL_INSULATION_INTERNAL,
wall_insulation_thickness=_SOLID_WALL_INSULATION_MM,
)
}
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solid_brick_generator_offers_ewi_and_iwi_each_pinning_its_after() -> None:
# Arrange — one uninsulated solid-brick before, two re-lodged afters.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
ewi_after: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_after.pdf"
)
iwi_after: EpcPropertyData = parse_recommendation_summary(
"solid_brick_iwi_001431_after.pdf"
)
# Act — solid brick is suitable for both, unrestricted.
recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct())
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — both Options offered, and each Option's overlay reproduces its
# own re-lodged after at the pin tolerance.
assert set(options) == {"external_wall_insulation", "internal_wall_insulation"}
_assert_overlay_reproduces_after(
before, ewi_after, options["external_wall_insulation"].overlay
)
_assert_overlay_reproduces_after(
before, iwi_after, options["internal_wall_insulation"].overlay
)
def test_system_built_generator_offers_ewi_and_iwi_each_pinning_its_after() -> None:
# Arrange — system-built (precast concrete) takes both Options like solid
# brick (ADR-0019): one uninsulated before, two re-lodged afters.
before: EpcPropertyData = parse_recommendation_summary(
"system_built_ewi_001431_before.pdf"
)
ewi_after: EpcPropertyData = parse_recommendation_summary(
"system_built_ewi_001431_after.pdf"
)
iwi_after: EpcPropertyData = parse_recommendation_summary(
"system_built_iwi_001431_after.pdf"
)
# Act
recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct())
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — both Options offered, each reproducing its own re-lodged after.
assert set(options) == {"external_wall_insulation", "internal_wall_insulation"}
_assert_overlay_reproduces_after(
before, ewi_after, options["external_wall_insulation"].overlay
)
_assert_overlay_reproduces_after(
before, iwi_after, options["internal_wall_insulation"].overlay
)
def test_timber_frame_generator_offers_iwi_only_pinning_its_after() -> None:
# Arrange — timber frame takes IWI but EWI is not constructable (ADR-0019).
before: EpcPropertyData = parse_recommendation_summary(
"timber_frame_iwi_001431_before.pdf"
)
iwi_after: EpcPropertyData = parse_recommendation_summary(
"timber_frame_iwi_001431_after.pdf"
)
# Act
recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct())
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — IWI only, and it reproduces the re-lodged after.
assert set(options) == {"internal_wall_insulation"}
_assert_overlay_reproduces_after(
before, iwi_after, options["internal_wall_insulation"].overlay
)
def test_conservation_area_drops_ewi_but_keeps_iwi() -> None:
# Arrange — a conservation area blocks the external-appearance change only.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
# Act
recommendation: Recommendation | None = recommend_solid_wall(
before, _AnyProduct(), PlanningRestrictions(in_conservation_area=True)
)
# Assert — IWI survives, EWI is gone.
assert recommendation is not None
assert {option.measure_type for option in recommendation.options} == {
"internal_wall_insulation"
}
def test_listed_building_blocks_all_solid_wall_insulation() -> None:
# Arrange — listed/heritage protect the fabric, so both EWI and IWI go.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
# Act / Assert
assert (
recommend_solid_wall(
before, _AnyProduct(), PlanningRestrictions(is_listed=True)
)
is None
)
def test_flat_drops_ewi_but_keeps_iwi() -> None:
# Arrange — a flat can take IWI to its own unit, but EWI needs whole-block
# coordination (ADR-0019). property_type "Flat" is the Elmhurst name form.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
flat: EpcPropertyData = replace(before, property_type="Flat")
# Act
recommendation: Recommendation | None = recommend_solid_wall(flat, _AnyProduct())
# Assert
assert recommendation is not None
assert {option.measure_type for option in recommendation.options} == {
"internal_wall_insulation"
}
def test_flat_detected_from_api_property_type_code() -> None:
# Arrange — the API path lodges property_type as a stringified code
# ("2" = Flat per PROPERTY_TYPE_LOOKUP), not the name.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
flat: EpcPropertyData = replace(before, property_type="2")
# Act
recommendation: Recommendation | None = recommend_solid_wall(flat, _AnyProduct())
# Assert — same gate fires regardless of representation.
assert recommendation is not None
assert {option.measure_type for option in recommendation.options} == {
"internal_wall_insulation"
}
def test_cavity_wall_gets_no_solid_wall_recommendation() -> None:
# Arrange — a cavity wall is handled by recommend_cavity_wall, never here.
before: EpcPropertyData = parse_recommendation_summary(
"cavity_wall_001431_before.pdf"
)
# Act / Assert
assert recommend_solid_wall(before, _AnyProduct()) is None
def test_loft_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"loft_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"loft_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_roof_generator_insulates_a_sloping_ceiling_pinning_its_after() -> None:
# Arrange — a pitched roof with an uninsulated sloping ceiling; the re-lodged
# after raises its insulation from As Built to 100 mm (ADR-0021).
before: EpcPropertyData = parse_recommendation_summary(
"sloping_ceiling_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"sloping_ceiling_001431_after.pdf"
)
# Act — the dispatcher detects "sloping ceiling" and offers the sloping
# measure (not loft).
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — one sloping-ceiling Option whose overlay reproduces the after.
assert set(options) == {"sloping_ceiling_insulation"}
_assert_overlay_reproduces_after(
before, after, options["sloping_ceiling_insulation"].overlay
)
def test_roof_generator_insulates_a_thatched_roof_as_loft_pinning_its_after() -> None:
# Arrange — a thatched pitched roof. Thatch is NOT excluded: the covering
# doesn't block insulating the loft floor, so it takes loft (joist)
# insulation, re-lodged None → 300 mm (ADR-0021).
before: EpcPropertyData = parse_recommendation_summary(
"loft_thatched_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"loft_thatched_001431_after.pdf"
)
# Act — the dispatcher routes a thatched roof to the loft branch.
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — one loft Option whose overlay reproduces the after.
assert set(options) == {"loft_insulation"}
_assert_overlay_reproduces_after(
before, after, options["loft_insulation"].overlay
)
def test_roof_generator_insulates_a_flat_roof_pinning_its_after() -> None:
# Arrange — a flat roof, uninsulated (As Built → None on the Elmhurst path);
# the re-lodged after raises it to 200 mm (ADR-0021).
before: EpcPropertyData = parse_recommendation_summary(
"flat_roof_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"flat_roof_001431_after.pdf"
)
# Act
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — one flat-roof Option whose overlay reproduces the after.
assert set(options) == {"flat_roof_insulation"}
_assert_overlay_reproduces_after(
before, after, options["flat_roof_insulation"].overlay
)
def test_solid_floor_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"solid_floor_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"solid_floor_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_floor_insulation(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_suspended_floor_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"suspended_floor_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"suspended_floor_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_floor_insulation(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None:
# The full-SAP pin below is xfail (draught-proofing coupling), but the
# overlay's actual job — turning every single-glazed window into the
# relodged spec — is deterministic and must hold exactly: it proves the
# generator detects BOTH single-glazing codes (1 and 15) on the real cert.
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct())
assert recommendation is not None
# Act — apply the overlay to the parsed before.
applied: EpcPropertyData = apply_simulations(
before, [recommendation.options[0].overlay]
)
# Assert — every window's glazing_type + lodged U/g matches the after.
def _window_spec(epc: EpcPropertyData) -> list[tuple[object, object, object]]:
specs: list[tuple[object, object, object]] = []
for window in epc.sap_windows:
details = window.window_transmission_details
specs.append(
(
window.glazing_type,
details.u_value if details is not None else None,
details.solar_transmittance if details is not None else None,
)
)
return specs
assert _window_spec(applied) == _window_spec(after)
_GLAZING_DRAUGHT_COUPLING_REASON: Final[str] = (
"Blocked on the glazing measure's draught-proofing coupling. The window "
"U/g overlay reproduces the after's 14 windows EXACTLY (all four single-"
"glazed panes — codes 1 and 15 — become the relodged double/secondary "
"spec). The residual ~0.7 SAP is a secondary effect the overlay does not "
"model: replacing the single-glazed (lodged draught_proofed=No) windows "
"with sealed units re-lodges percent_draughtproofed 84->100 (~0.3 SAP) and "
"lowers fabric heat loss by ~+150 kWh space heating (~0.4 SAP) not yet "
"isolated. Flips green once the glazing overlay propagates draught-proofing "
"(and the residual fabric coupling is modelled)."
)
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
def test_double_glazing_overlay_reproduces_the_relodged_after() -> None:
# Arrange — cert 001431 lodges four single-glazed windows (codes 1 and 15,
# "single glazing, known data"); the after re-lodges every one as double
# (gt=5, U=1.40, g=0.72).
before: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct())
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
def test_secondary_glazing_overlay_reproduces_the_relodged_after() -> None:
# Arrange — a planning protection forces secondary glazing; the after
# re-lodges every single-glazed window as secondary (gt=11, U=2.90, g=0.85).
before: EpcPropertyData = parse_recommendation_summary(
"secondary_glazing_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"secondary_glazing_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_glazing(
before, _AnyProduct(), PlanningRestrictions(in_conservation_area=True)
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_lighting_overlay_reproduces_the_relodged_after_zero_existing_leds() -> None:
# Arrange — a dwelling with no LEDs (20 incandescent); the after re-lodges
# all 20 as LED. Lighting only changes bulb counts → Appendix L (232), with
# no fabric coupling, so the full-SAP pin closes cleanly.
before: EpcPropertyData = parse_recommendation_summary(
"low_energy_lighting_zero_leds_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"low_energy_lighting_zero_leds_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_lighting(before, _AnyProduct())
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_lighting_overlay_reproduces_the_relodged_after_some_existing_leds() -> None:
# Arrange — a dwelling with some LEDs already (5 LED + 15 incandescent); the
# after re-lodges all 20 as LED. Exercises the partial-upgrade path: the
# overlay tops led up to the total rather than starting from zero.
before: EpcPropertyData = parse_recommendation_summary(
"low_energy_lighting_some_leds_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"low_energy_lighting_some_leds_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_lighting(before, _AnyProduct())
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_hhr_storage_overlay_reproduces_the_relodged_after_from_electric_storage() -> None:
# Arrange — an existing electric storage system re-lodged as high-heat-
# retention storage (Table 4a 402 -> 409, control 2401 -> 2404), gaining an
# off-peak immersion cylinder and a dual meter (ADR-0024).
before: EpcPropertyData = parse_recommendation_summary(
"hhr_storage_from_electric_storage_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"hhr_storage_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o
for o in recommendation.options
if o.measure_type == "high_heat_retention_storage_heaters"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_hhr_storage_overlay_reproduces_the_relodged_after_from_no_system() -> None:
# Arrange — a "no system present" electric dwelling re-lodged as HHR storage;
# the same absolute-target overlay must reproduce the common after.
before: EpcPropertyData = parse_recommendation_summary(
"hhr_storage_from_no_system_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"hhr_storage_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o
for o in recommendation.options
if o.measure_type == "high_heat_retention_storage_heaters"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None:
# Arrange — a typical mains-gas combi house re-cast as an air-source heat
# pump (fuel 26 -> 30, SAP code 104 -> Vaillant aroTHERM plus 5 kW index
# 110257 + category 4, control 2106 -> 2210), off mains gas, gaining a heat-
# pump cylinder (ADR-0024). The boiler-1 after-cert predates the Vaillant
# swap (it lodges the old index 101413), so this snapshots the Vaillant
# overlay's own output rather than re-pinning a stale relodged PDF — taken as
# correct because the same overlay reproduces the corrected Vaillant cert at
# delta 0 in the system-boiler pin below.
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_gas_boiler_001431_before.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
)
# Act / Assert — re-pinned after merging main's fabric fixes (roof "Unknown"
# U → Table 18 default, Room-in-Roof U leak), which shift this 001431
# dwelling's baseline fabric and so the ASHP end-state SAP. Still a snapshot
# of the Vaillant overlay's own output, validated transitively by the
# system-boiler pin below (which reproduces a real Vaillant cert at delta 0).
# CO2/PE are the postcode DEMAND cascade now that `Sap10Calculator.
# calculate` computes EPC emissions/PE on local weather (SAP 10.2
# Appendix U p.124); SAP is unchanged (UK-average rating cascade).
_assert_overlay_scores(
before,
option.overlay,
sap=51.99820176096402,
co2=1065.7593506066496,
pe=10995.781557709413,
)
def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw() -> None:
# Arrange — a gas boiler whose hot water is electric/instantaneous (water-
# heating SAP code 909, no cylinder) re-cast as an ASHP. Exercises the
# overlay's water_heating_code reset (909 -> 901, "from the heat pump") that
# boiler-1 didn't (its HW was already 901). Snapshots the Vaillant overlay's
# output (the after-cert predates the Vaillant swap), validated transitively
# by the system-boiler pin below.
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_gas_boiler_instant_hw_001431_before.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
)
# Act / Assert — re-pinned after merging main's fabric fixes (see the
# boiler-1 pin above); the same merge also resolved this cert's main-fuel
# mapper gap (§14.2 mains-gas derivation), so its raw before now baselines —
# see `test_gas_boiler_instant_hw_before_baselines`.
# CO2/PE are the postcode DEMAND cascade now (see the boiler-1 pin above);
# SAP is unchanged (UK-average rating cascade).
_assert_overlay_scores(
before,
option.overlay,
sap=39.00740809309464,
co2=1845.8588018295509,
pe=18944.42568846759,
)
def test_ashp_overlay_reproduces_the_relodged_after_from_a_system_boiler_with_cylinder() -> None:
# Arrange — a mains-gas *regular/system* boiler (SAP code 101, not a combi)
# that already heats its own hot-water cylinder (size 2 / insulation type 2 /
# 80 mm) re-lodged as an ASHP. This exercises the cylinder OVERWRITE path that
# boiler-1/boiler-2 didn't: those added a cylinder where none existed, whereas
# here the overlay must overwrite the existing cylinder to the fixed heat-pump
# cylinder (size 4 / insulation type 1 / 50 mm). The dwelling also goes off
# mains gas (fuel 26 -> 30, code 101 -> Vaillant aroTHERM plus 5 kW index
# 110257 + category 4, control 2113 -> 2210). After-cert re-lodged with the
# Vaillant: ASHP raises this dwelling's SAP 63.85 -> 72.30.
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_system_boiler_with_cylinder_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"ashp_from_system_boiler_with_cylinder_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
)
# Act / Assert — the absolute overlay overwrites the existing cylinder and
# reproduces the after exactly.
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_gas_boiler_instant_hw_before_baselines() -> None:
# The Modelling pipeline baselines the dwelling before modelling it, so the
# before must be scorable on its own. This was previously blocked: the gas
# boiler lodged with EES 'BGB' / SAP code 102 derived no main_fuel_type, so
# Sap10Calculator raised MissingMainFuelType. Merging main's mapper fix
# (resolve gas-boiler main fuel from the §14.2 mains-gas meter) closed that
# gap, so the raw before now baselines.
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_gas_boiler_instant_hw_001431_before.pdf"
)
# Act
result: SapResult = Sap10Calculator().calculate(before)
# Assert — it baselines without raising, on mains gas.
assert result.sap_score_continuous > 0.0
def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None:
# Arrange — a mains-gas wet boiler (SAP code 114) heating an uninsulated
# hot-water cylinder (no insulation, no thermostat) re-lodged as a new gas
# condensing boiler with a cylinder (SAP code 102, fanned flue), the cylinder
# jacketed (insulation type 2 / 80 mm) and given a thermostat. The boiler
# upgrade leaves the (already adequate) controls + cylinder size + meter
# unchanged. Validates the boiler-with-cylinder option end-state at delta 0.
#
# NB the absolute SAP on this dwelling is subject to a separate Summary-path
# mapper roof-fidelity gap (our calculator reads the roof better-insulated
# than Elmhurst, so it scores ~75 where Elmhurst prints 56); the gap is
# identical on before + after (the boiler measure never touches the roof), so
# it cancels and this pin still proves the overlay applies Elmhurst's exact
# heating field-delta. Tracked on the calculator branch, not here.
before: EpcPropertyData = parse_recommendation_summary(
"boiler_cyl_gas_001431_before.pdf"
)
# The cert lodges code 114 (already condensing), which the efficiency gate
# excludes from a like-for-like swap; recast to a pre-1998 non-condensing
# boiler (110) so the upgrade is offered. The overlay overwrites the code to
# 102 regardless, so this changes only eligibility, not the validated result.
before.sap_heating.main_heating_details[0].sap_main_heating_code = 110
after: EpcPropertyData = parse_recommendation_summary(
"boiler_cyl_gas_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_boiler_combi_overlay_reproduces_the_relodged_after() -> None:
# Arrange — a mains-gas combi (SAP code 112, no cylinder) with inadequate
# controls (2111 "TRVs and bypass" — no room thermostat, so no boiler
# interlock) re-lodged as a new gas condensing combi (code 104, fanned flue)
# with full programmer + room thermostat + TRV controls (2106). No cylinder,
# so no cylinder components. Validates the combi end-state + the controls-
# when-inadequate upgrade at delta 0. (Same Summary-path roof gap as the
# with-cylinder pin — it cancels across before/after.)
before: EpcPropertyData = parse_recommendation_summary(
"boiler_combi_gas_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"boiler_combi_gas_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_oil_combi_overlay_reproduces_the_relodged_after() -> None:
# Arrange — an OIL combi (fuel 28, SAP code 130, no cylinder) on a mains-gas
# street re-lodged as a gas condensing combi (fuel 28->26, code 104, fanned
# flue). Validates the non-gas -> gas conversion: the upgrade targets gas
# because a mains-gas connection is present (ADR-0024 revised). Controls are
# already adequate (2106), so they are unchanged.
before: EpcPropertyData = parse_recommendation_summary(
"boiler_combi_oil_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"boiler_combi_oil_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_boiler_with_already_insulated_cylinder_overlay_reproduces_the_relodged_after() -> None:
# Arrange — a gas boiler heating an ALREADY-jacketed cylinder (insulation
# type 2 / 80 mm) with no thermostat, re-lodged as a new gas condensing
# boiler (code 102) with a cylinder thermostat added. Validates the cylinder
# path's skip-jacket branch (the 80 mm jacket is not re-applied) while the
# thermostat is still added. (Sourced from an LPG re-lodgement; the Summary
# mapper reads its fuel as mains gas — fuel 26 — so this exercises the gas
# cylinder path, not a true LPG conversion. The LPG fuel-mapping gap is a
# separate mapper-front concern.)
before: EpcPropertyData = parse_recommendation_summary(
"boiler_cyl_lpg_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"boiler_cyl_lpg_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_coal_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None:
# Arrange — a SOLID-FUEL (coal) boiler (fuel 11, SAP code 153) heating a
# cylinder, on a mains-gas street, re-lodged as a gas condensing boiler
# (fuel 11->26, code 102, fanned flue + boiler flue type 2). Exercises the
# non-gas -> gas conversion for a solid-fuel boiler AND the new
# `boiler_flue_type` end-state (coal's before lodged none; every other cert
# already lodged flue type 2). The cylinder is already 80 mm insulated so the
# jacket is skipped; only the thermostat is added.
#
# The relodged after predates the user-locked "always add a cylinder
# thermostat when absent" rule, so it stale-lodged thermostat 'N'; the test
# corrects it to the rule's end-state 'Y' (the same correction the gas
# with-cylinder after received by re-lodging). Controls are already adequate
# (2106), so they are unchanged.
before: EpcPropertyData = parse_recommendation_summary(
"boiler_cyl_coal_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"boiler_cyl_coal_001431_after.pdf"
)
after.sap_heating.cylinder_thermostat = "Y"
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
@pytest.mark.parametrize(
"before_fixture, after_fixture, measure_type",
[
# The system tune-up keeps the existing boiler and forces the heating
# controls to a fixed end-state (standard 2106 / zone 2110) ABSOLUTELY —
# proven by reproducing each common after from two different starting
# controls (2101 "no control" and 2113 "room thermostat and TRVs") — plus
# the conditional cylinder jacket + thermostat (both befores are
# uninsulated / un-thermostatted, so both fire).
(
"tune_up_from_2101_001431_before.pdf",
"tune_up_standard_001431_after.pdf",
"system_tune_up",
),
(
"tune_up_from_2113_001431_before.pdf",
"tune_up_standard_001431_after.pdf",
"system_tune_up",
),
(
"tune_up_from_2101_001431_before.pdf",
"tune_up_zoned_001431_after.pdf",
"system_tune_up_zoned",
),
(
"tune_up_from_2113_001431_before.pdf",
"tune_up_zoned_001431_after.pdf",
"system_tune_up_zoned",
),
],
)
def test_system_tune_up_overlay_reproduces_the_relodged_after(
before_fixture: str, after_fixture: str, measure_type: str
) -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(before_fixture)
after: EpcPropertyData = parse_recommendation_summary(after_fixture)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == measure_type
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
# --- Solar PV cascade pins (ADR-0026) -------------------------------------
#
# The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp,
# varied orientation/pitch/overshading) — deterministic test vectors chosen to
# exercise the overlay -> calculator PV path across the config space, NOT the
# Google-derived production arrays. So these pins hand-build the SolarOverlay
# matching each after-cert's lodged arrays (the generator's own overlay is
# Google-sourced and validated separately in test_solar_recommendation /
# test_solar_overshading); the cascade proves `_fold_solar` + the calculator
# reproduce Elmhurst's PV re-lodgement exactly.
#
# All five certs share one main-heating system lodged with EES code 'WGK' /
# Main Heating SAP code 502, which the Elmhurst mapper does not yet derive a
# main_fuel_type for (it maps to '' -> MissingMainFuelType). The solar overlay
# never touches heating, so the pins patch the shared fuel to mains gas (26) on
# both before and after identically — the heating contribution is then equal on
# both sides and the delta isolates the PV change. The unresolved raw baseline
# is a separate mapper-front gap, tripwired by `test_solar_before_baselines`.
_SOLAR_MAINS_GAS_FUEL: Final[int] = 26
def _parse_solar(fixture_name: str) -> EpcPropertyData:
"""Parse a solar before/after Summary, patching the shared main-heating
fuel to mains gas (the EES 'WGK' / SAP code 502 mapper-front gap — see the
section note). Applied identically on before + after, so it cancels in the
PV delta."""
epc: EpcPropertyData = parse_recommendation_summary(fixture_name)
main = epc.sap_heating.main_heating_details[0]
if not main.main_fuel_type:
main.main_fuel_type = _SOLAR_MAINS_GAS_FUEL
return epc
def test_solar_overlay_reproduces_relodged_after_se_sw_shaded() -> None:
# Arrange — two shaded planes: SE (octant 4) at 30° pitch under significant
# shading (code 3), and SW (octant 6) at 45° pitch under modest shading
# (code 2); each a 1.00 kWp array. Exercises the overshading + orientation
# spread.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_1.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=2, orientation=4, overshading=3),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=6, overshading=2),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solar_overlay_reproduces_relodged_after_e_w_unshaded() -> None:
# Arrange — E (octant 3) at 60° pitch and W (octant 7) at 45° pitch, both
# unshaded (code 1). Exercises the steeper pitches with no shading.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_2.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=3, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=7, overshading=1),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solar_overlay_reproduces_relodged_after_nw_n_unshaded() -> None:
# Arrange — NW (octant 8) at 60° pitch and N (octant 1) at 45° pitch, both
# unshaded. The least-productive orientations (the N plane in particular)
# exercise the low-yield end of the SAP Appendix M output.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_3.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_battery_cert_currently_reproduced_by_the_no_battery_overlay() -> None:
# Tripwire (user-requested): the "with battery" cert lodges a §19 5 kWh
# battery, but the current Elmhurst extractor does NOT parse it (the parsed
# EpcPropertyData has pv_batteries=None). So the cert currently scores
# identically to its no-battery twin, and the *no-battery* overlay (same NW/N
# arrays) reproduces it exactly. When the extractor learns to parse the §19
# Batteries block, the after-cert will gain ~+1.1 SAP from the 5 kWh battery
# and THIS PIN WILL FAIL — the fix is then to switch to the with-battery
# overlay below (which the calculator already models, see the next test).
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_with_battery_001431_after.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_battery_overlay_raises_sap_above_its_no_battery_twin() -> None:
# The calculator DOES model a PV battery (App M monthly self-consumption), so
# the recommendation's battery variant is a meaningful, higher-SAP Option —
# even though the example cert's battery is not yet parsed. This pins the fix
# target for the tripwire above: once the extractor parses the §19 battery,
# the with-battery overlay should reproduce the (then battery-bearing) cert.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
arrays = [
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1),
]
no_battery = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=arrays,
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
with_battery = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=arrays,
pv_diverter_present=True,
is_dwelling_export_capable=True,
pv_batteries=PvBatteries(pv_battery=PvBattery(battery_capacity=5.0)),
)
)
# Act
scorer = PackageScorer(Sap10Calculator())
sap_no_battery: float = scorer.score(before, [no_battery]).sap_continuous
sap_with_battery: float = scorer.score(before, [with_battery]).sap_continuous
# Assert — the 5 kWh battery raises SAP by a meaningful margin.
assert sap_with_battery > sap_no_battery + 1e-3
_SOLAR_FUEL_GAP_REASON: Final[str] = (
"Blocked on the Elmhurst mapper deriving main_fuel_type for the main heating "
"lodged with EES code 'WGK' / Main Heating SAP code 502: it currently maps to "
"'' (empty), so Sap10Calculator raises MissingMainFuelType when baselining the "
"raw solar before. The solar overlay never touches heating, so the cascade "
"pins above patch the shared fuel to mains gas (26) on before + after to "
"isolate the PV delta — only baselining the unmodified before is blocked. "
"Flips green once the mapper derives mains gas from the WGK/502 lodgement. "
"Owner: mapper/extractor front."
)
@pytest.mark.xfail(strict=True, reason=_SOLAR_FUEL_GAP_REASON)
def test_solar_before_baselines() -> None:
# The Modelling pipeline baselines the dwelling before modelling it, so the
# before must be scorable on its own. This solar cert is not yet: its main
# fuel is unresolved (see reason). A failing tripwire for the mapper fix.
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"solar_pv_001431_before.pdf"
)
# Act / Assert — currently raises MissingMainFuelType.
Sap10Calculator().calculate(before)
# --- Secondary Heating Removal (ADR-0028) ----------------------------------
# The user's Elmhurst before/after Summary for this measure (cert 001431,
# electric-storage main + secondary 691) cannot be parsed — that PDF export
# trips the documented 001431 Summary window-extraction bug. So these pins use
# the worksheet-pinned `build_epc()` (a validated real-001431 representation,
# the repo's sanctioned 001431 baseline) with the secondary configuration set on
# it, exercising the real generator → overlay → calculator cascade.
def test_secondary_removal_on_an_electric_storage_main_is_a_no_op() -> None:
# Arrange — 001431 recast to an electric-storage main (SAP code 402, fuel 30)
# with a lodged secondary (691). RdSAP §A.2.2 forces a default secondary back
# on storage mains, so removal reproduces the after at delta 0 — exactly why
# the user's before/after Summaries both print SAP F35.
before: EpcPropertyData = build_001431_epc()
main = before.sap_heating.main_heating_details[0]
main.sap_main_heating_code = _ELECTRIC_STORAGE_MAIN_CODE
main.main_fuel_type = _STANDARD_ELECTRICITY_FUEL
main.main_heating_index_number = None
before.sap_heating.secondary_heating_type = _SECONDARY_ELECTRIC_PANEL_CODE
after: EpcPropertyData = copy.deepcopy(before)
after.sap_heating.secondary_heating_type = None
after.sap_heating.secondary_fuel_type = None
recommendation: Recommendation | None = recommend_secondary_heating_removal(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert — the overlay reproduces the secondary-removed cert at delta 0.
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_secondary_removal_on_a_non_forced_main_raises_sap() -> None:
# Arrange — 001431's lodged gas combi (SAP code 104, NOT a forced-secondary
# main) with an added electric secondary (691). Removing it reallocates the
# Table 11 secondary fraction to the cheaper gas main, so cost-based SAP rises
# (the value path the forced-secondary example can't exercise).
before: EpcPropertyData = build_001431_epc()
before.sap_heating.secondary_heating_type = _SECONDARY_ELECTRIC_PANEL_CODE
recommendation: Recommendation | None = recommend_secondary_heating_removal(
before, _AnyProduct()
)
assert recommendation is not None
scorer = PackageScorer(Sap10Calculator())
# Act
with_secondary: Score = scorer.score(before, [])
removed: Score = scorer.score(before, [recommendation.options[0].overlay])
# Assert — removal strictly raises SAP (delta well above the pin tolerance).
assert removed.sap_continuous - with_secondary.sap_continuous > _PIN_ABS