ASHP sizing targets the Appendix-N efficiency peak (realistic installed pump) 🟩

Sizing selects the aroTHERM plus rung nearest PSR ~0.8 (0.8 x design heat
loss) — the Appendix-N efficiency peak, reproducing the pump a real
installer fits — rather than the MCS PSR>=1.0 capacity target, which
oversizes. Validated against the relodged Elmhurst ASHP cert (delta 0
held at the 5 kW Vaillant); the two self-snapshot pins re-pin to their
correctly-sized larger pumps, and the orchestrator/harness thread the
calculator's design heat loss so production sizes to the dwelling. Manual
SapResult test stubs carry the new design_heat_loss_kw field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-03 11:02:13 +00:00
parent cb91954bb4
commit bf240e3315
8 changed files with 77 additions and 27 deletions

View file

@ -153,17 +153,25 @@ _ASHP_SIZING_LADDER: tuple[tuple[float, int], ...] = (
(7.93, 110273), # aroTHERM plus 10 kW
(11.48, 110281), # aroTHERM plus 12 kW
)
# SAP 10.2 Appendix-N heat-pump efficiency peaks around PSR 0.8 (output / design
# heat loss); sizing to it reproduces real installer choices (accredited-cert
# validated) rather than the MCS capacity target (PSR 1.0), which oversizes.
_EFFICIENCY_PEAK_PSR: float = 0.8
def select_ashp_pcdb_id(design_heat_loss_kw: float) -> int:
"""The PCDB heat-pump record to install for a dwelling with
`design_heat_loss_kw`: the smallest ladder rung whose rated output meets the
load (PSR >= 1.0), capped at the largest rung when the load exceeds the
domestic aroTHERM plus range (ADR-0049)."""
for max_output_kw, pcdb_id in _ASHP_SIZING_LADDER:
if max_output_kw >= design_heat_loss_kw:
return pcdb_id
return _ASHP_SIZING_LADDER[-1][1]
`design_heat_loss_kw` (ADR-0049): the ladder rung whose rated output sits
nearest the SAP 10.2 Appendix-N efficiency peak PSR ~= 0.8, i.e. a rated
output of ``0.8 x design_heat_loss`` which reproduces the pump a real
installer fits (validated against the relodged Elmhurst ASHP cert) and keeps
the heat pump clear of the low-PSR efficiency collapse. A load beyond the
domestic aroTHERM plus range naturally selects the largest rung."""
target_output_kw: float = _EFFICIENCY_PEAK_PSR * design_heat_loss_kw
return min(
_ASHP_SIZING_LADDER,
key=lambda rung: abs(rung[0] - target_output_kw),
)[1]
# --- Gas boiler upgrade (Heating/HW expansion): replace an existing wet boiler

View file

@ -273,10 +273,13 @@ def candidate_recommendations(
if solar_insights is not None and "solarPotential" in solar_insights
else None
)
# The SAP design heat loss sizes the ASHP to the dwelling (ADR-0049).
design_heat_loss_kw: float = Sap10Calculator().calculate(epc).design_heat_loss_kw
return _candidate_recommendations(
epc,
products or ProductJsonRepository(catalogue_path),
planning_restrictions,
solar_potential,
considered_measures,
design_heat_loss_kw,
)

View file

@ -332,7 +332,13 @@ def _candidate_recommendations(
MeasureType.SYSTEM_TUNE_UP,
MeasureType.SYSTEM_TUNE_UP_ZONED,
),
lambda: recommend_heating(effective_epc, products, planning_restrictions, considered_measures),
lambda: recommend_heating(
effective_epc,
products,
planning_restrictions,
considered_measures,
design_heat_loss_kw=design_heat_loss_kw,
),
),
(
admitted(MeasureType.SECONDARY_HEATING_REMOVAL),
@ -385,9 +391,20 @@ def _scored_candidate_groups(
) -> list[list[ScoredOption]]:
"""One group per Recommendation: each Option scored independently against
the baseline (role-1 warm-start signal, ADR-0016)."""
# The SAP design heat loss sizes the ASHP to the dwelling (ADR-0049); read it
# off a baseline score, which the group scoring computes anyway.
baseline_result = scorer.score(effective_epc, []).sap_result
design_heat_loss_kw: Optional[float] = (
baseline_result.design_heat_loss_kw if baseline_result is not None else None
)
groups: list[list[ScoredOption]] = []
for recommendation in _candidate_recommendations(
effective_epc, products, planning_restrictions, solar_potential, considered_measures
effective_epc,
products,
planning_restrictions,
solar_potential,
considered_measures,
design_heat_loss_kw,
):
options = list(recommendation.options)
impacts: list[MeasureImpact] = independent_option_impacts(

View file

@ -40,6 +40,7 @@ def _sap_result(
space_heating_kwh_per_yr=0.0,
space_cooling_kwh_per_yr=0.0,
fabric_energy_efficiency_kwh_per_m2_yr=0.0,
design_heat_loss_kw=0.0,
main_heating_fuel_kwh_per_yr=main_heating_fuel_kwh_per_yr,
main_2_heating_fuel_kwh_per_yr=main_2_heating_fuel_kwh_per_yr,
secondary_heating_fuel_kwh_per_yr=secondary_heating_fuel_kwh_per_yr,

View file

@ -664,7 +664,11 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None:
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_gas_boiler_001431_before.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
recommendation: Recommendation | None = recommend_heating(
before,
_AnyProduct(),
design_heat_loss_kw=Sap10Calculator().calculate(before).design_heat_loss_kw,
)
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
@ -678,12 +682,15 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None:
# 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).
# Re-pinned after ADR-0049 sizes the pump to this dwelling's 9.69 kW design
# heat loss (the 10 kW aroTHERM plus, 110273) instead of the fixed 5 kW unit,
# which was undersized here — the correctly-sized pump raises the end-state SAP.
_assert_overlay_scores(
before,
option.overlay,
sap=51.99820176096402,
co2=1065.7593506066496,
pe=10995.781557709413,
sap=69.6498827017577,
co2=698.896585317197,
pe=7271.044741124457,
)
@ -697,7 +704,11 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_gas_boiler_instant_hw_001431_before.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
recommendation: Recommendation | None = recommend_heating(
before,
_AnyProduct(),
design_heat_loss_kw=Sap10Calculator().calculate(before).design_heat_loss_kw,
)
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
@ -709,12 +720,15 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw
# 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).
# Re-pinned after ADR-0049 sizes the pump to this dwelling's 12.87 kW design
# heat loss (the capped 12 kW aroTHERM plus, 110281) instead of the fixed 5 kW
# unit, which was badly undersized here — the sized pump raises the SAP.
_assert_overlay_scores(
before,
option.overlay,
sap=39.00740809309464,
co2=1845.8588018295509,
pe=18944.42568846759,
sap=72.22779402266684,
co2=870.0854694592007,
pe=9025.363011043328,
)
@ -734,7 +748,11 @@ def test_ashp_overlay_reproduces_the_relodged_after_from_a_system_boiler_with_cy
after: EpcPropertyData = parse_recommendation_summary(
"ashp_from_system_boiler_with_cylinder_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
recommendation: Recommendation | None = recommend_heating(
before,
_AnyProduct(),
design_heat_loss_kw=Sap10Calculator().calculate(before).design_heat_loss_kw,
)
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"

View file

@ -203,9 +203,10 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None:
# bungalow regardless of current system or efficiency (ADR-0024).
baseline: EpcPropertyData = _gas_boiler_house()
# Act — a ~4 kW design heat loss sizes to the 5 kW aroTHERM plus (110257).
# Act — a 5.5 kW design heat loss sizes to the 5 kW aroTHERM plus (110257):
# PSR-0.8 target ~4.4 kW, nearest the 4.37 kW rung.
recommendation: Recommendation | None = recommend_heating(
baseline, _StubProducts(), design_heat_loss_kw=4.0
baseline, _StubProducts(), design_heat_loss_kw=5.5
)
# Assert — the ASHP bundle carries the absolute heat-pump end-state.
@ -771,16 +772,16 @@ def test_boiler_upgrade_leaves_adequate_controls_unchanged() -> None:
assert overlay.main_heating_control is None
def test_ashp_sizing_selects_the_smallest_pump_meeting_the_design_heat_loss() -> None:
# A 6 kW design heat loss needs a pump whose rated output covers it: the 7 kW
# aroTHERM plus (PCDB 110265, ~6.40 kW), the smallest ladder rung meeting the
# load — not the fixed 5 kW unit (4.37 kW) that would sit below the load and
# collapse SAP's Appendix-N PSR efficiency.
def test_ashp_sizing_targets_the_appendix_n_efficiency_peak() -> None:
# A 6 kW design heat loss sizes to the SAP Appendix-N efficiency peak
# (PSR ~= 0.8, so ~4.8 kW rated output): the 5 kW aroTHERM plus (PCDB 110257,
# ~4.37 kW) — what a real installer fits — not the oversized 7 kW unit, and
# well clear of the low-PSR efficiency collapse.
# Act
pcdb_id = select_ashp_pcdb_id(design_heat_loss_kw=6.0)
# Assert — the 7 kW aroTHERM plus rung.
assert pcdb_id == 110265
# Assert — the 5 kW aroTHERM plus rung.
assert pcdb_id == 110257
def test_ashp_sizing_caps_at_the_largest_pump_for_a_high_heat_loss_dwelling() -> None:

View file

@ -40,6 +40,7 @@ def _sap_result(
space_heating_kwh_per_yr=0.0,
space_cooling_kwh_per_yr=0.0,
fabric_energy_efficiency_kwh_per_m2_yr=0.0,
design_heat_loss_kw=0.0,
main_heating_fuel_kwh_per_yr=0.0,
main_2_heating_fuel_kwh_per_yr=0.0,
secondary_heating_fuel_kwh_per_yr=0.0,

View file

@ -128,6 +128,7 @@ def _sap_result_with_lighting() -> SapResult:
space_heating_kwh_per_yr=0.0,
space_cooling_kwh_per_yr=0.0,
fabric_energy_efficiency_kwh_per_m2_yr=0.0,
design_heat_loss_kw=0.0,
main_heating_fuel_kwh_per_yr=0.0,
main_2_heating_fuel_kwh_per_yr=0.0,
secondary_heating_fuel_kwh_per_yr=0.0,