mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
commit
23ebd5e637
14 changed files with 402 additions and 34 deletions
98
docs/adr/0049-ashp-sized-to-the-dwelling-design-heat-loss.md
Normal file
98
docs/adr/0049-ashp-sized-to-the-dwelling-design-heat-loss.md
Normal 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.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.
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue