Slice 101b: HP cert 0380 — cavity+EWI wall U + Table 11 cat-4 secondary

Two HP-specific cascade gaps blocking cert 0380:

(a) Cavity wall + filled cavity + external insulation:
    Cert 0380's `walls[0].description="Cavity wall, filled cavity and
    external insulation"` with `wall_insulation_type=6` +
    `wall_insulation_thickness="100mm"`. RdSAP 10 §4-4 (page 73) lists
    "cavity plus external" as a distinct insulation type code (6 in
    the API schema; 7 is "cavity plus internal"). The U-value is the
    composite U = 1 / (1/U_filled + R_ins) per §5.8 page 40 + Table 14
    R-value lookup, with the cascade-2-d.p. round matching the dr87
    worksheet's column display.

    For cert 0380: U_filled (age D)=0.7 + R_ins (100mm @ λ=0.04)=2.5
    → U_unrounded=0.2545 → rounded 0.25 (worksheet exact). Walls HLC
    14.87 → 11.6150 (= worksheet 11.6150). (37) total fabric heat
    loss 99.34 → **96.0889** (= worksheet 96.0889 EXACT).

    Added `WALL_INSULATION_CAVITY_PLUS_EXTERNAL: Final[int] = 6` and
    `WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7` constants
    + `_WALL_INSULATION_LAMBDA_W_PER_MK = 0.04` default thermal
    conductivity. New `u_wall` branch fires when cavity + composite
    insulation type + non-zero thickness.

(b) SAP 10.2 Table 11 secondary fraction — missing cat-4 entry:
    The dict `_SECONDARY_HEATING_FRACTION_BY_CATEGORY` had entries
    for cats 1/2/3/5/6/7/10 but DID NOT include cat 4 (heat pump),
    despite the inline comment explicitly noting "Cat 4 (heat pump):
    0.00 (HP eff includes any secondary)". Cert 0380 lodges
    `secondary_heating_type=691` + `main_heating_category=4` (HP,
    PCDB idx 104568), so the cascade fell through to the DEFAULT
    fraction 0.10 — billing 547 kWh × 13.19 p/kWh = £72 as
    "secondary heating" that the worksheet correctly shows as £0.

    Added `4: 0.00` to the dict.

Effect on cert 0380 API path:
- walls HLC 14.87 → 11.62 (worksheet exact)
- (37) total HLC 99.34 → 96.09 (worksheet exact)
- main_heating_cost £282 → £314 (worksheet £316)
- secondary_heating £72 → £0 (worksheet £0)
- sap_continuous 87.62 → 90.48 (Δ -0.89 → +1.97 — over-correcting
  because hot-water cascade is still cascade-£66 vs worksheet £204
  including electric shower; HP HW-COP + electric-shower cost are
  the next slices).

No golden cert residual shifts (cohort certs don't lodge HP cat 4
or composite cavity+EWI walls).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-26 22:37:48 +00:00 committed by Jun-te Kim
parent 7874374bcf
commit a736db3f4a
3 changed files with 103 additions and 2 deletions

View file

@ -586,6 +586,70 @@ def test_api_0380_glazing_type_14_resolves_to_post_2022_dg_u_value() -> None:
assert abs(td.solar_transmittance - 0.72) <= 1e-4
def test_api_0380_wall_with_external_insulation_routes_to_filled_cavity_u() -> None:
# Arrange — cert 0380's top-level walls[0].description lodges
# "Cavity wall, filled cavity and external insulation". The
# worksheet uses U=0.25 for the (29a) external-walls entry — the
# very-low-U "filled cavity + external insulation" composite that
# RdSAP 10 §5 routes through Table 6's filled-cavity row (with a
# further EWI reduction). Our cascade was computing U=0.32 via
# the as-built Table 13 bucketed cascade because
# `_described_as_insulated` only matches the past-participle
# "insulated" — "insulation" (noun) on its own falls through to
# False. Cert 0380's lodgement uses the noun form.
#
# Fix: `_described_as_insulated` should also match the noun
# "insulation" (excluding the existing "no insulation" hard
# negation), so cavity walls described as carrying insulation
# route to the cascade's Filled-cavity branch.
doc = json.loads(_API_0380_JSON.read_text())
epc = EpcPropertyDataMapper.from_api_response(doc)
# Act
from domain.sap10_calculator.rdsap.cert_to_inputs import (
heat_transmission_section_from_cert,
)
ht = heat_transmission_section_from_cert(epc)
# Assert — main-wall HLC ≈ 46.46 m² × 0.25 = 11.62 W/K (worksheet
# exact). Tolerance 1e-2 absorbs sub-component rounding; the
# 1e-4 chain test downstream tightens to the cascade floor.
worksheet_walls_w_per_k = 11.62
assert abs(ht.walls_w_per_k - worksheet_walls_w_per_k) <= 1e-2
def test_api_0380_heat_pump_no_secondary_heating_per_table_11() -> None:
# Arrange — SAP 10.2 Table 11 explicitly notes "Cat 4 (heat pump):
# 0.00 (HP eff includes any secondary)" — heat pumps don't apply a
# Table 11 secondary fraction even when the cert lodges a secondary
# heating type, because the HP efficiency already incorporates any
# supplementary heat source. The `_SECONDARY_HEATING_FRACTION_BY_
# CATEGORY` dict in cert_to_inputs.py had entries for categories
# 1/2/3/5/6/7/10 but DID NOT include cat 4 — so HP certs with a
# lodged secondary fell through to the DEFAULT 0.10, billing 10%
# of space-heating cost as "secondary" (cert 0380: £72 secondary
# vs worksheet £0).
#
# Cert 0380 lodges secondary_heating_type=691 + main_heating_
# category=4 (HP, PCDB idx 104568). Worksheet line (242) "Space
# heating - secondary" shows 0.0 kWh; cascade was producing
# 547.30 kWh. Fix: dict entry `4: 0.0`.
doc = json.loads(_API_0380_JSON.read_text())
epc = EpcPropertyDataMapper.from_api_response(doc)
# Act
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import (
cert_to_inputs, SAP_10_2_SPEC_PRICES,
)
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
# Assert — secondary heating contributes 0 kWh / £0 on HP certs.
assert result.secondary_heating_fuel_kwh_per_yr == 0.0
def test_api_9501_room_in_roof_surfaces_populated() -> None:
# Arrange — cert 9501's API JSON lodges measured RR detail under
# `sap_room_in_roof.room_in_roof_details`: two gable walls

View file

@ -265,6 +265,12 @@ _SECONDARY_HEATING_FRACTION_BY_CATEGORY: Final[dict[int, float]] = {
1: 0.10,
2: 0.10,
3: 0.10,
4: 0.00, # Heat pump: HP eff includes any secondary contribution
# per SAP 10.2 Table 11 explicit footnote; supersedes the
# 0.10 DEFAULT below which would erroneously bill 10% of
# space-heating cost as secondary on HP certs that lodge
# a secondary_heating_type code (cert 0380: 547 kWh @
# 13.19 p/kWh = £72 vs worksheet £0).
5: 0.10,
6: 0.10,
7: 0.15,

View file

@ -14,6 +14,7 @@ evidence" rule in spec section 6.2.3.
from __future__ import annotations
import re
from decimal import ROUND_HALF_UP, Decimal
from enum import Enum
from math import log, pi
from typing import Final, Optional
@ -125,9 +126,16 @@ WALL_UNKNOWN: Final[int] = 10
# 5 = none specified (rare)
# 6 = filled cavity + external insulation
# 7 = filled cavity + internal insulation
# Only the filled-cavity dispatch is wired here; the other codes will be
# handled in subsequent slices.
WALL_INSULATION_FILLED_CAVITY: Final[int] = 2
WALL_INSULATION_CAVITY_PLUS_EXTERNAL: Final[int] = 6
WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7
# RdSAP 10 §4-6 (page 73): default thermal conductivity of insulation when
# no documentary evidence is available. Used to convert the lodged
# `wall_insulation_thickness` (mm) into thermal resistance (m²K/W) via
# R = (thickness_mm / 1000) / λ for composite wall U-value calc
# (cavity + external/internal insulation).
_WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04
_AGE_BANDS: Final[tuple[str, ...]] = tuple("ABCDEFGHIJKLM")
@ -353,6 +361,29 @@ def u_wall(
wall_type = construction
else:
wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)
if wall_type == WALL_CAVITY and wall_insulation_type in (
WALL_INSULATION_CAVITY_PLUS_EXTERNAL,
WALL_INSULATION_CAVITY_PLUS_INTERNAL,
) and insulation_thickness_mm is not None and insulation_thickness_mm > 0:
# RdSAP 10 §4-4 + §4-6 (page 73): composite "filled cavity plus
# external/internal insulation" — U_total = 1 / (1/U_filled +
# R_ins) where R_ins = (thickness_mm / 1000) / λ. λ defaults to
# 0.04 W/m·K when no documentary evidence is lodged. Cert 0380
# lodges code 6 + 100mm → U_filled (age D)=0.7 + R=2.5 →
# U_total ≈ 0.2545 → rounded to 2 d.p. = 0.25 (worksheet).
#
# The 2-d.p. round matches dr87 / Elmhurst tool behaviour
# (worksheet displays "0.2500" = 2-d.p. value padded to 4 d.p.
# for column alignment). Cascade-internal HLC then uses the
# rounded U so net wall HLC matches `A × U_2dp` exactly.
u_filled = _CAVITY_FILLED_ENG[age_idx]
r_ins = (insulation_thickness_mm / 1000.0) / _WALL_INSULATION_LAMBDA_W_PER_MK
u_unrounded = 1.0 / (1.0 / u_filled + r_ins)
# Half-up 2-d.p. round so 0.2545 → 0.25, matching the dr87
# worksheet's column-display behaviour (used downstream in A×U).
return float(
Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
if wall_type == WALL_CAVITY and (
wall_insulation_type == WALL_INSULATION_FILLED_CAVITY
or _described_as_insulated(description)