test(modelling): secondary-heating-removal cascade validation (ADR-0028)

Two cascade tests on the worksheet-pinned 001431 build_epc() (the user's
before/after Summary PDFs trip the documented 001431 window-extraction bug, so
the repo's sanctioned 001431 baseline is used instead):
- electric-storage main (code 402) + secondary 691: removal reproduces the
  secondary-removed cert at delta 0 — RdSAP §A.2.2 re-forces a default secondary,
  matching the user's F35→F35 example;
- gas combi main (code 104) + secondary 691: removal strictly raises SAP
  (74.22→77.61) — the Table 11 fraction reallocates to the cheaper main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 15:32:15 +00:00
parent f9a89a8e11
commit 797b71cd13

View file

@ -14,6 +14,7 @@ is a named generator/overlay/calculator gap to fix, never a tolerance to widen
from __future__ import annotations
import copy
from dataclasses import replace
from typing import Final
@ -46,6 +47,9 @@ from domain.modelling.generators.solid_wall_recommendation import (
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
@ -53,6 +57,17 @@ 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
@ -1122,3 +1137,58 @@ def test_solar_before_baselines() -> None:
# 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