test(modelling): ASHP before/after cascade pin (001431) at 1e-4

A typical mains-gas combi house re-lodged as an air-source heat pump closes at
1e-4 (gas-boiler 1 example from the technical specialist). Closes one named gap
the pin surfaced: a whole-system replacement to a PCDB-indexed system left the
old Table 4a sap_main_heating_code (104) beside the new heat-pump index, and the
stale code won the calculator's efficiency dispatch (hot water billed at boiler
not HP efficiency, ΔSAP 3.98). _fold_heating now enforces the mutual exclusion
of the two efficiency anchors (setting an index clears the SAP code and vice
versa). Also fixed a pre-existing pyright annotation in the lighting applicator
test. ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 17:01:40 +00:00
parent a9da21c4b6
commit a1fc697d93
5 changed files with 75 additions and 1 deletions

View file

@ -90,6 +90,16 @@ def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None:
value = getattr(overlay, field_name)
if value is not None:
setattr(main, field_name, value)
# `main_heating_index_number` (PCDB-resolved, e.g. a heat pump) and
# `sap_main_heating_code` (Table 4a-resolved, e.g. storage heaters) are
# mutually-exclusive efficiency anchors: a whole-system replacement to one
# must clear the other, else a stale code from the old system wins the
# calculator's dispatch (e.g. a gas-boiler code 104 left beside a heat-pump
# index makes hot water use boiler efficiency, not the HP SCOP).
if overlay.main_heating_index_number is not None:
main.sap_main_heating_code = None
elif overlay.sap_main_heating_code is not None:
main.main_heating_index_number = None
for field_name in _SAP_HEATING_FIELDS:
value = getattr(overlay, field_name)
if value is not None:

Binary file not shown.

View file

@ -620,3 +620,24 @@ def test_hhr_storage_overlay_reproduces_the_relodged_after_from_no_system() -> N
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_ashp_overlay_reproduces_the_relodged_after_from_a_gas_boiler() -> None:
# Arrange — a typical mains-gas combi house re-lodged as an air-source heat
# pump (fuel 26 -> 30, SAP code 104 -> PCDB index 101413 + category 4,
# control 2106 -> 2210), off mains gas, gaining a heat-pump cylinder
# (ADR-0024).
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_gas_boiler_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"ashp_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
_assert_overlay_reproduces_after(before, after, option.overlay)

View file

@ -327,11 +327,54 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None:
assert baseline.sap_energy_source.mains_gas == original_mains_gas
def test_heating_index_overlay_clears_a_stale_sap_main_heating_code() -> None:
# Arrange — 000490's gas combi lodges a Table 4a code; an ASHP bundle sets a
# PCDB index instead. The two are mutually-exclusive efficiency anchors, so
# the stale code must be cleared or it wins the calculator's dispatch.
baseline: EpcPropertyData = build_epc()
baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 104
# Act
result: EpcPropertyData = apply_simulations(
baseline,
[
EpcSimulation(
heating=HeatingOverlay(
main_heating_index_number=101413, main_heating_category=4
)
)
],
)
# Assert — the index is set and the old SAP code is gone.
main = result.sap_heating.main_heating_details[0]
assert main.main_heating_index_number == 101413
assert main.sap_main_heating_code is None
def test_heating_sap_code_overlay_clears_a_stale_index() -> None:
# Arrange — a dwelling with a PCDB-indexed system; an HHR storage bundle sets
# a Table 4a code instead, so the stale index must be cleared.
baseline: EpcPropertyData = build_epc()
baseline.sap_heating.main_heating_details[0].main_heating_index_number = 8262
# Act
result: EpcPropertyData = apply_simulations(
baseline,
[EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=409))],
)
# Assert
main = result.sap_heating.main_heating_details[0]
assert main.sap_main_heating_code == 409
assert main.main_heating_index_number is None
def test_baseline_lighting_is_not_mutated_by_a_lighting_overlay() -> None:
# Arrange — 000490 lodges 8 low-energy-unknown bulbs, 0 LED.
baseline: EpcPropertyData = build_epc()
original_led: int = baseline.led_fixed_lighting_bulbs_count
original_lel: int = baseline.low_energy_fixed_lighting_bulbs_count
original_lel: int | None = baseline.low_energy_fixed_lighting_bulbs_count
# Act — fold an all-LED overlay (led = the 8 total).
_: EpcPropertyData = apply_simulations(