From a736db3f4ab95b68ca6a86016072f2b34fda8bde Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 26 May 2026 22:37:48 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20101b:=20HP=20cert=200380=20=E2=80=94=20?= =?UTF-8?q?cavity+EWI=20wall=20U=20+=20Table=2011=20cat-4=20secondary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_summary_pdf_mapper_chain.py | 64 +++++++++++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 6 ++ domain/sap10_ml/rdsap_uvalues.py | 35 +++++++++- 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 6388767a..d6b61a64 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9dd24fc3..e0910422 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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, diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 03e9373a..f2c8aa9a 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -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)