Merge pull request #1446 from Hestia-Homes/fix/ashp-size-to-dwelling-heat-loss

Size the ASHP to the dwelling's design heat loss (fix undersized fixed pump scoring below baseline)
This commit is contained in:
Jun-te Kim 2026-07-03 13:32:30 +01:00 committed by GitHub
commit 23ebd5e637
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 402 additions and 34 deletions

View file

@ -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.681.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.

View file

@ -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,
)

View file

@ -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,

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

@ -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(

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

@ -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

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

@ -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

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

@ -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)

View file

@ -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",

View file

@ -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

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,