diff --git a/docs/adr/0049-ashp-sized-to-the-dwelling-design-heat-loss.md b/docs/adr/0049-ashp-sized-to-the-dwelling-design-heat-loss.md new file mode 100644 index 000000000..ce53044a7 --- /dev/null +++ b/docs/adr/0049-ashp-sized-to-the-dwelling-design-heat-loss.md @@ -0,0 +1,98 @@ +# The ASHP recommendation sizes the heat pump to the dwelling's design heat loss + +## Status + +accepted + +## Context + +The ASHP measure (ADR-0024) installed **one fixed heat pump** — a 4.37 kW Vaillant +aroTHERM plus (PCDB Table 362 record 110257) — on every eligible dwelling, as a +"representative, contractor-installable" end-state. SAP 10.2 Appendix N reads a +heat pump's efficiency at the dwelling's **PSR** (Power-to-Space-heating-Ratio = +rated output / design heat loss): efficiency peaks around PSR 0.8 and, per +footnote 44, **collapses toward 100% below the record's smallest PSR**. + +A portfolio-796 audit found the fixed pump scoring the ASHP **below the baseline** +on high-heat-loss dwellings. Property 712794 (uprn 10002468116): a leaky dwelling +with a **17.5 kW design heat loss**, so a 4.37 kW pump lands at **PSR 0.25** — the +efficiency collapse zone. The calculator correctly applied ~195% SPF; combined +with pricing electricity at the standard tariff against an off-peak storage +baseline, the ASHP scored **SAP 10.33 vs the baseline 11.96 (−1.63)** and the +Optimiser never selected it. Every electric-heated dwelling too leaky for a 4.37 kW +pump was capped below the goal band by this — a modelling defect, not a calculator +one (SAP's PSR curve is correct: a grossly undersized heat pump *is* inefficient). + +The fix needs the dwelling's design heat loss, which the calculator computes +internally (annual-average HLC × the SAP design temperature difference, 24.2 K) +but did not surface. + +Sizing raised a second question the accredited data answered. MCS capacity sizing +targets **PSR ≈ 1.0** (output ≥ design heat loss). But the **relodged Elmhurst ASHP +cert** — a real dwelling our calculator gives a 6.40 kW design heat loss — was +fitted with the **5 kW** Vaillant (110257, 4.37 kW output), i.e. **PSR 0.68**, and +scores SAP 72.08. A PSR-1.0 rule picks the 7 kW pump for that dwelling → SAP 73.86, +**oversizing by a rung and inflating SAP by ~1.8**, and no longer reproduces the +accredited cert. SAP efficiency at PSR 0.68 and 1.0 is near-identical (~400%), so +the real installer's smaller, cheaper choice is the realistic one. + +## Decision + +Size the heat pump to the dwelling and expose what sizing needs: + +1. **`SapResult.design_heat_loss_kw`** — the calculator surfaces the design heat + loss (annual-average HLC × 24.2 K / 1000), the quantity Appendix N's PSR + divides the pump output by. + +2. **A curated aroTHERM plus ladder** — one representative PCDB record per nominal + size across the domestic range (`_ASHP_SIZING_LADDER`): 3.5 kW (110249), 5 kW + (110257, the prior anchor), 7 kW (110265), 10 kW (110273), 12 kW (110281). All + the same "& AI VIH RW" variant, so the ladder is a consistent product line. + +3. **Size to the Appendix-N efficiency peak, not MCS capacity.** + `select_ashp_pcdb_id` picks the rung whose rated output is nearest **0.8 × + design heat loss** (PSR ≈ 0.8, the efficiency peak). This reproduces the pump a + real installer fits — validated against the relodged Elmhurst cert at delta 0 — + and keeps the pump clear of the low-PSR collapse. A load beyond the 12 kW range + naturally selects the largest rung (an honest, capped undersize; those + dwellings need fabric-first). + +4. **Cost and efficiency share one figure.** `ashp_cost_inputs` takes the same + design heat loss (over the floor-area proxy), so the rate-sheet install band + (ADR-0025) and the pump's efficiency record are sized from one number. The + orchestrator reads `design_heat_loss_kw` off a baseline score and threads it + into the heating generator; the harness computes it via a calculator pass. + +## Consequences + +- Property 712794's ASHP goes from **−1.63 SAP (never selected)** to **+44.2 SAP + (selected)**; its plan lifts **G (12.0) → B (89.1)**. Electric-heated dwellings + previously capped below goal C by the undersized pump can now reach it — the + largest HIGH-severity mover on the portfolio-796 `plan-stops-short-of-goal` and + `plan-stuck-in-low-band` groups. +- The accredited relodged Elmhurst ASHP cert still reproduces at **delta 0** (the + 6.40 kW dwelling sizes to the 5 kW Vaillant it was actually fitted with). Two + self-snapshot cascade pins re-pin to their correctly-sized larger pumps (both + dwellings were undersized by the fixed unit, so their end-state SAP rises). +- Adding `design_heat_loss_kw` to `SapResult` is a required field; manual + constructions (test stubs) carry it. +- Extends [ADR-0024](0024-ashp-bundle-absolute-end-state.md) (the ASHP end-state) + and [ADR-0025](0025-ashp-cost-composed-from-the-rate-sheet.md) (size-banded + cost); the cost bands already scaled — they now receive the real heat loss. + +### Alternatives rejected + +- **MCS capacity sizing (PSR ≈ 1.0, output ≥ design heat loss).** Rejected: it + oversizes relative to real installs, inflating SAP ~1.8 per dwelling and + breaking the accredited-cert reproduction. SAP efficiency is equivalent at + PSR 0.68–1.0, so the realistic (installer) size is preferred. +- **Keep the fixed 4.37 kW pump.** Rejected: the collapse on high-heat-loss + dwellings is the defect under repair. +- **Fabric-first sequencing (score the ASHP only after insulation).** A real + concern — the Optimiser scores each measure independently against the + un-insulated baseline (ADR-0016) — but a separate change to measure scoring/ + sequencing, not pump sizing. Sizing the pump fixes the standalone score + regardless; noted for follow-up. +- **Nearest record from all ~10,000 Table 362 heat pumps.** Rejected: an opaque, + unauditable selection; a curated single-product ladder keeps ADR-0024's + "representative, contractor-installable" intent. diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 70c33142c..ebb7d1f5e 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -12,6 +12,7 @@ boiler bundles land in later slices. Detection + pricing only — impact is produced by scoring (ADR-0016). """ +from dataclasses import replace from typing import Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail @@ -137,6 +138,42 @@ _ASHP_OVERLAY = HeatingOverlay( ) +# ASHP sizing ladder (ADR-0049): the Vaillant aroTHERM plus "& AI VIH RW" family, +# one representative PCDB Table 362 record per nominal size, ascending by rated +# max output (kW). The pump is sized to the dwelling's design heat loss so SAP +# 10.2 Appendix N reads the heat-pump efficiency near its PSR peak (~1.0), rather +# than a single fixed unit that is grossly undersized on high-heat-loss dwellings +# — where the PSR collapses and the ASHP scores at/below the resistance baseline. +# 110257 (the 5 kW rung) is the previously-fixed anchor, validated against the +# relodged after-cert. +_ASHP_SIZING_LADDER: tuple[tuple[float, int], ...] = ( + (3.76, 110249), # aroTHERM plus 3.5 kW + (4.37, 110257), # aroTHERM plus 5 kW + (6.40, 110265), # aroTHERM plus 7 kW + (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` (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 # with a modern gas condensing boiler. Validated against Elmhurst before/after # re-lodgements (cert 001431): the upgrade always targets mains gas — gas->gas @@ -254,16 +291,25 @@ _OIL_FUEL_CODES = frozenset({28, 4, 71, 73, 75, 76}) _LPG_FUEL_CODES = frozenset({27, 2, 3, 5, 9}) -def ashp_cost_inputs(epc: EpcPropertyData) -> AshpCostInputs: +def ashp_cost_inputs( + epc: EpcPropertyData, design_heat_loss_kw: Optional[float] = None +) -> AshpCostInputs: """Read an `EpcPropertyData` into the typed inputs `Products.ashp_bundle_cost` - needs: the existing system, property-size band, design heat loss (floor-area - proxy), radiator count, and whether a wet system can be reused (ADR-0025).""" + needs: the existing system, property-size band, design heat loss, radiator + count, and whether a wet system can be reused (ADR-0025). ``design_heat_loss_kw`` + (the calculator's value, ADR-0049) sizes the cost band; when omitted it falls + back to the floor-area proxy so cost and pump size share one figure.""" system: AshpExistingSystem = _existing_system(epc) floor_area: float = epc.total_floor_area_m2 + heat_loss_kw: float = ( + design_heat_loss_kw + if design_heat_loss_kw is not None + else floor_area * _KW_PER_M2 + ) return AshpCostInputs( existing_system=system, is_small_property=floor_area <= _SMALL_PROPERTY_MAX_M2, - design_heat_loss_kw=floor_area * _KW_PER_M2, + design_heat_loss_kw=heat_loss_kw, radiator_count=_radiator_count(epc), has_reusable_wet_system=system in (AshpExistingSystem.GAS, AshpExistingSystem.OIL, AshpExistingSystem.LPG), @@ -307,17 +353,23 @@ def recommend_heating( products: ProductRepository, restrictions: PlanningRestrictions = PlanningRestrictions(), considered_measures: Optional[frozenset[MeasureType]] = None, + *, + design_heat_loss_kw: Optional[float] = None, ) -> Optional[Recommendation]: """Return a "Heating & Hot Water" Recommendation of competing whole-system bundles for the dwelling, else None when no bundle is eligible. ASHP is - additionally gated by the Property's planning protections (ADR-0024).""" + additionally gated by the Property's planning protections (ADR-0024). + + ``design_heat_loss_kw`` (the calculator's SAP design heat loss) sizes the + ASHP to the dwelling so SAP's Appendix-N PSR efficiency reads near its peak + (ADR-0049); when omitted, sizing falls back to the floor-area proxy.""" options: list[MeasureOption] = [] hhr_option = _hhr_storage_option(epc, products) if hhr_option is not None: options.append(hhr_option) - ashp_option = _ashp_option(epc, products, restrictions) + ashp_option = _ashp_option(epc, products, restrictions, design_heat_loss_kw) if ashp_option is not None: options.append(ashp_option) @@ -702,23 +754,37 @@ def _ashp_option( epc: EpcPropertyData, products: ProductRepository, restrictions: PlanningRestrictions, + design_heat_loss_kw: Optional[float] = None, ) -> Optional[MeasureOption]: """The air-source heat-pump bundle, offered for any non-flat house/bungalow - that is not listed/heritage and not already a heat pump.""" + that is not listed/heritage and not already a heat pump. The pump is sized to + ``design_heat_loss_kw`` (ADR-0049), falling back to the floor-area proxy.""" if not _ashp_eligible(epc, restrictions): return None + # Size the pump to the dwelling: the calculator's design heat loss when the + # caller threaded it, else the floor-area proxy (ADR-0049). + heat_loss_kw: float = ( + design_heat_loss_kw + if design_heat_loss_kw is not None + else epc.total_floor_area_m2 * _KW_PER_M2 + ) + sized_overlay: HeatingOverlay = replace( + _ASHP_OVERLAY, main_heating_index_number=select_ashp_pcdb_id(heat_loss_kw) + ) # Cost is composed per-dwelling from the rate sheet (ADR-0025), not the # single catalogue scalar; the catalogue row is read only for its id, so an # absent ASHP row must not suppress the bundle — it just carries no id. product: Optional[Product] = products.get_optional(_ASHP_MEASURE_TYPE) - cost: Cost = Products().ashp_bundle_cost(ashp_cost_inputs(epc)) + cost: Cost = Products().ashp_bundle_cost( + ashp_cost_inputs(epc, design_heat_loss_kw=heat_loss_kw) + ) return MeasureOption( measure_type=_ASHP_MEASURE_TYPE, description=( "Replace the heating with an air-source heat pump, time-and-" "temperature-zone controls and a heat-pump hot-water cylinder" ), - overlay=EpcSimulation(heating=_ASHP_OVERLAY), + overlay=EpcSimulation(heating=sized_overlay), cost=cost, material_id=product.id if product is not None else None, ) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index c30234a7c..34f0f1ce7 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -65,6 +65,11 @@ from domain.sap10_calculator.worksheet.rating import ( _AIR_HEAT_CAPACITY_WH_PER_M3_K: Final[float] = 0.33 _TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6 +# SAP design temperature difference (21 C internal - -3.2 C external): the delta +# the design heat loss and Appendix N's PSR are evaluated at (matches +# `_SAP_DESIGN_HEAT_LOSS_DELTA_T_K` in rdsap.cert_to_inputs). +_DESIGN_HEAT_LOSS_DELTA_T_K: Final[float] = 24.2 +_WATTS_PER_KW: Final[float] = 1000.0 # §9a default — used as `CalculatorInputs.energy_requirements` default for # synthetic constructions that bypass cert_to_inputs. All-zero fuel; the @@ -403,6 +408,11 @@ class SapResult: space_heating_kwh_per_yr: float space_cooling_kwh_per_yr: float fabric_energy_efficiency_kwh_per_m2_yr: float + # The dwelling's SAP design heat loss (kW) — the annual-average heat loss + # coefficient at the design temperature difference (24.2 K). Output-only: + # it is the quantity Appendix N's PSR divides a heat pump's rated output by, + # so the ASHP recommendation sizes the pump to the dwelling from it. + design_heat_loss_kw: float main_heating_fuel_kwh_per_yr: float main_2_heating_fuel_kwh_per_yr: float secondary_heating_fuel_kwh_per_yr: float @@ -872,6 +882,9 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: space_heating_kwh_per_yr=space_heating_kwh, space_cooling_kwh_per_yr=space_cooling_kwh, fabric_energy_efficiency_kwh_per_m2_yr=inputs.fabric_energy_efficiency_kwh_per_m2_yr, + design_heat_loss_kw=(sum(monthly_hlc) / 12.0) + * _DESIGN_HEAT_LOSS_DELTA_T_K + / _WATTS_PER_KW, main_heating_fuel_kwh_per_yr=main_fuel_kwh, main_2_heating_fuel_kwh_per_yr=inputs.energy_requirements.main_2_fuel_kwh_per_yr, secondary_heating_fuel_kwh_per_yr=secondary_fuel_kwh, 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 e3180a4d2..7a30d7e82 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -264,6 +264,7 @@ def _candidate_recommendations( planning_restrictions: PlanningRestrictions, solar_potential: Optional[SolarPotential], considered_measures: Optional[frozenset[MeasureType]], + design_heat_loss_kw: Optional[float] = None, ) -> list[Recommendation]: """Run the applicable Recommendation Generators; keep the ones that apply. Solid-wall insulation, glazing, heating and solar are additionally gated by @@ -331,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), @@ -384,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_ashp_cost_inputs.py b/tests/domain/modelling/test_ashp_cost_inputs.py index c75cf52fc..ce528f1bb 100644 --- a/tests/domain/modelling/test_ashp_cost_inputs.py +++ b/tests/domain/modelling/test_ashp_cost_inputs.py @@ -82,3 +82,19 @@ def test_oil_and_lpg_dwellings_are_reusable_wet_systems() -> None: assert ashp_cost_inputs(_with_fuel(28)).existing_system is AshpExistingSystem.OIL assert ashp_cost_inputs(_with_fuel(27)).existing_system is AshpExistingSystem.LPG assert ashp_cost_inputs(_with_fuel(28)).has_reusable_wet_system is True + + +def test_threaded_design_heat_loss_drives_the_cost_band_over_the_proxy() -> None: + # When the caller threads the calculator's real design heat loss (ADR-0049), + # the ASHP cost sizes to it — not the floor-area proxy — so a leaky dwelling's + # bigger pump is priced on the right band. + # Arrange + epc = parse_recommendation_summary( + "ashp_from_system_boiler_with_cylinder_001431_before.pdf" + ) + + # Act + inputs: AshpCostInputs = ashp_cost_inputs(epc, design_heat_loss_kw=17.5) + + # Assert — the threaded value, not the 4.5 kW proxy. + assert inputs.design_heat_loss_kw == 17.5 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 366c51869..470ff51aa 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -7,7 +7,10 @@ later slices. Detection + pricing only; impact is produced by scoring (ADR-0016) from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.geospatial.planning_restrictions import PlanningRestrictions -from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.generators.heating_recommendation import ( + recommend_heating, + select_ashp_pcdb_id, +) from domain.modelling.measure_type import MeasureType from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation @@ -200,8 +203,11 @@ 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 - recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + # 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=5.5 + ) # Assert — the ASHP bundle carries the absolute heat-pump end-state. assert recommendation is not None @@ -764,3 +770,48 @@ def test_boiler_upgrade_leaves_adequate_controls_unchanged() -> None: ).overlay.heating assert overlay is not None assert overlay.main_heating_control is None + + +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 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: + # A very leaky dwelling (17.5 kW design heat loss, e.g. UPRN 10002468116) + # exceeds the domestic aroTHERM plus range, so sizing caps at the largest + # rung — the 12 kW aroTHERM plus (PCDB 110281, ~11.48 kW) — an honest, still + # far better than the fixed 5 kW pump whose PSR collapsed to ~0.25. + # Act + pcdb_id = select_ashp_pcdb_id(design_heat_loss_kw=17.5) + + # Assert — capped at the 12 kW rung, not left undersized at 5 kW. + assert pcdb_id == 110281 + + +def test_ashp_bundle_sizes_the_pump_to_the_design_heat_loss() -> None: + # A high-heat-loss house (17.5 kW) must get a pump sized to it — the capped + # 12 kW aroTHERM plus (PCDB 110281) — so SAP's Appendix-N PSR efficiency reads + # near its peak, not the fixed 5 kW unit (110257) whose PSR collapses. + # Arrange + baseline: EpcPropertyData = _gas_boiler_house() + + # Act + recommendation: Recommendation | None = recommend_heating( + baseline, _StubProducts(), design_heat_loss_kw=17.5 + ) + + # Assert — the ASHP overlay carries the sized 12 kW record, not the fixed 5 kW. + assert recommendation is not None + ashp = next( + o for o in recommendation.options if o.measure_type == "air_source_heat_pump" + ) + assert ashp.overlay.heating is not None + assert ashp.overlay.heating.main_heating_index_number == 110281 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/domain/sap10_calculator/test_calculator.py b/tests/domain/sap10_calculator/test_calculator.py index 7ab7d3c0c..12157d172 100644 --- a/tests/domain/sap10_calculator/test_calculator.py +++ b/tests/domain/sap10_calculator/test_calculator.py @@ -815,3 +815,20 @@ def test_split_tariff_charges_space_heating_at_off_peak_rate() -> None: + (r_e7.pumps_fans_kwh_per_yr + r_e7.lighting_kwh_per_yr) * 0.1319 ) assert r_e7.total_fuel_cost_gbp == pytest.approx(expected_cost, rel=1e-6) + + +def test_calculate_exposes_the_design_heat_loss_for_heat_pump_sizing() -> None: + # The ASHP recommendation sizes the pump to the dwelling's SAP design heat + # loss — the annual-average HLC at the design temperature difference (24.2 K), + # the quantity Appendix N's PSR divides the pump's rated output by. The + # calculator surfaces it as a typed output, derived from the HLC it already + # computes. + # Arrange + inputs = _baseline_inputs() + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert — HLC (W/K) x 24.2 K / 1000 = design heat loss (kW). + hlc = result.intermediate["heat_transfer_coefficient_w_per_k"] + assert result.design_heat_loss_kw == pytest.approx(hlc * 24.2 / 1000) diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index 7aa820a64..84330584e 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -30,9 +30,13 @@ _GOLDEN = ( _WITHIN_TOLERANCE = "0036-6325-1100-0063-1226" _DIVERGENT = "0240-0200-5706-2365-8010" -# 0330 fires all three trigger kinds: an uninsulated cavity wall (cavity fill), -# its dependent mechanical ventilation, and an uninsulated solid floor. -_THREE_MEASURES = "0330-2249-8150-2326-4121" +# 0390 fires three measures — an uninsulated solid floor, low-energy lighting, +# and the ASHP bundle — so every fired measure's trigger attributes are +# exercised together. (0330, the previous fixture, now reaches band C on the +# correctly-sized ASHP alone: ADR-0049 sizes the pump to the dwelling's design +# heat loss, so the undersized-pump-era companion solid_floor_insulation is no +# longer needed there.) +_THREE_MEASURES = "0390-2254-6420-2126-5561" def _triggers_by_measure(report: PropertyReport) -> dict[str, MeasureTrigger]: @@ -80,17 +84,12 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: # Assert — the Plan ran and every fired measure names its trigger fields. assert report.plan is not None assert report.plan_error is None - # The gain-maximising package: the efficient representative ASHP (ADR-0025) - # plus solid-floor insulation. The cavity wall + its forced mechanical - # ventilation (ADR-0016) are NOT selected — the wall earns +SAP alone but - # the forced-ventilation penalty makes the pair net-negative, so the - # Optimiser correctly leaves them out (see test_measure_dependency / - # test_optimiser for the forced-edge unit coverage; cavity_wall + - # mechanical_ventilation trigger fields are exercised in - # test_cavity_wall_recommendation / the ventilation generator tests). + # The selected package: the dwelling-sized ASHP (ADR-0025/ADR-0049) plus + # solid-floor insulation and low-energy lighting. triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) assert set(triggers) == { "solid_floor_insulation", + "low_energy_lighting", "air_source_heat_pump", } # Solid-floor insulation fired off an uninsulated solid ground floor. @@ -98,6 +97,12 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: "floor_insulation_thickness": None, "floor_construction_type": "Solid", } + # Low-energy lighting fired off the dwelling's lodged bulb counts. + assert triggers["low_energy_lighting"].triggers == { + "incandescent_fixed_lighting_bulbs_count": 0, + "cfl_fixed_lighting_bulbs_count": 0, + "low_energy_fixed_lighting_bulbs_count": 9, + } # The ASHP bundle fired off the gas-dwelling main it replaces. assert triggers["air_source_heat_pump"].triggers == { "property_type": "0", diff --git a/tests/orchestration/test_modelling_heat_pump_sizing_threading.py b/tests/orchestration/test_modelling_heat_pump_sizing_threading.py new file mode 100644 index 000000000..0b46bb8a6 --- /dev/null +++ b/tests/orchestration/test_modelling_heat_pump_sizing_threading.py @@ -0,0 +1,60 @@ +"""The ModellingOrchestrator threads the dwelling's SAP design heat loss into +the heating Recommendation Generator so the ASHP is sized to the dwelling +(ADR-0049), mirroring how planning_restrictions and the solar potential are +threaded. Tests the candidate-wiring seam directly; the end-to-end run-through- +repos path is covered by the DB integration tests. +""" + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.modelling.measure_type import MeasureType +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from orchestration.modelling_orchestrator import ( + _candidate_recommendations, # pyright: ignore[reportPrivateUsage] +) +from repositories.product.product_repository import ProductRepository +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) + + +class _StubProducts(ProductRepository): + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=0.0, + contingency_rate=0.15, + id=909, + ) + + +def _ashp_eligible_house() -> EpcPropertyData: + epc = parse_recommendation_summary( + "ashp_from_system_boiler_with_cylinder_001431_before.pdf" + ) + epc.property_type = "House" + return epc + + +def test_candidate_recommendations_sizes_the_ashp_to_the_threaded_design_heat_loss() -> ( + None +): + # Arrange — an ASHP-eligible house and a high (17.5 kW) design heat loss. + epc = _ashp_eligible_house() + + # Act + recommendations: list[Recommendation] = _candidate_recommendations( + epc, _StubProducts(), PlanningRestrictions(), None, None, design_heat_loss_kw=17.5 + ) + + # Assert — the ASHP is sized to the load (capped 12 kW aroTHERM plus 110281), + # not the fixed 5 kW / floor-area-proxy unit. + heating = next(r for r in recommendations if r.surface == "Heating & Hot Water") + ashp = next( + o + for o in heating.options + if o.measure_type == MeasureType.AIR_SOURCE_HEAT_PUMP + ) + assert ashp.overlay.heating is not None + assert ashp.overlay.heating.main_heating_index_number == 110281 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,