From bf240e33155bd80bd9fddcc506f6576e8fc04795 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 11:02:13 +0000 Subject: [PATCH] =?UTF-8?q?ASHP=20sizing=20targets=20the=20Appendix-N=20ef?= =?UTF-8?q?ficiency=20peak=20(realistic=20installed=20pump)=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../generators/heating_recommendation.py | 22 ++++++++---- harness/console.py | 3 ++ orchestration/modelling_orchestrator.py | 21 +++++++++-- tests/domain/billing/test_energy_breakdown.py | 1 + .../modelling/test_elmhurst_cascade_pins.py | 36 ++++++++++++++----- .../modelling/test_heating_recommendation.py | 19 +++++----- .../test_calculator_rebaseliner.py | 1 + .../test_property_baseline_orchestrator.py | 1 + 8 files changed, 77 insertions(+), 27 deletions(-) diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 44173de98..ebb7d1f5e 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -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 diff --git a/harness/console.py b/harness/console.py index b60528832..a9aa222b5 100644 --- a/harness/console.py +++ b/harness/console.py @@ -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, ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index ae43ca85c..7a30d7e82 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -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( diff --git a/tests/domain/billing/test_energy_breakdown.py b/tests/domain/billing/test_energy_breakdown.py index f754bc662..630c314b1 100644 --- a/tests/domain/billing/test_energy_breakdown.py +++ b/tests/domain/billing/test_energy_breakdown.py @@ -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, diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index c99f7b6f0..a52cf9787 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -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" diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 2477150ef..470ff51aa 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -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: diff --git a/tests/domain/property_baseline/test_calculator_rebaseliner.py b/tests/domain/property_baseline/test_calculator_rebaseliner.py index f6002bd48..49a2364dc 100644 --- a/tests/domain/property_baseline/test_calculator_rebaseliner.py +++ b/tests/domain/property_baseline/test_calculator_rebaseliner.py @@ -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, diff --git a/tests/orchestration/test_property_baseline_orchestrator.py b/tests/orchestration/test_property_baseline_orchestrator.py index 912dc9085..b4125059d 100644 --- a/tests/orchestration/test_property_baseline_orchestrator.py +++ b/tests/orchestration/test_property_baseline_orchestrator.py @@ -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,