diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index ea1107fc..91185ea6 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -300,6 +300,36 @@ def _parse_thickness_mm(value: Any) -> Optional[int]: return int(digits) if digits else None +def _described_as_retrofit_insulated(description: Optional[str]) -> bool: + """True only when the description asserts insulation KNOWN to have + been added subsequently — i.e. genuine retrofit, not the age-band + as-built assumption. + + RdSAP 10 Table 8/9 footnote routes a wall to the 50 mm "insulation + of unknown thickness" row ONLY when insulation is "known to have been + increased subsequently (otherwise 'as built' applies)". A description + rendered as "as built ... insulated (assumed)" is the EPC's age-band + assumption — it renders only on recent age bands where as-built + construction already includes insulation (an old band renders "no + insulation (assumed)"). For those the spec uses the as-built age-band + U-value, NOT the 50 mm retrofit row. + + Worksheet evidence: simulated case 9 (sandstone, band J, As Built → + U 0.35) and case 10 (solid brick, band J, As Built → U 0.35); both + Elmhurst worksheets return the as-built row, not the 50 mm bucket + (which gives ~0.25). Genuine retrofit is signalled by + `wall_insulation_type` (External/Internal/Filled), checked + independently by the `wall_ins_present` gate — so excluding the + "as built"/"(assumed)" description here loses no real retrofit signal. + """ + if description is None: + return False + if not _described_as_insulated(description): + return False + desc = description.lower() + return "as built" not in desc and "assumed" not in desc + + def _joined_descriptions(elements: list[Any]) -> Optional[str]: if not elements: return None @@ -665,14 +695,21 @@ def heat_transmission_from_cert( wall_construction = _int_or_none(part.wall_construction) wall_ins_type = _int_or_none(part.wall_insulation_type) wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness) - # Per RdSAP 10 Table 6 footnote, a wall with "insulated (assumed)" - # or "partial insulation (assumed)" in its description has retrofit - # insulation the assessor hasn't measured the thickness of — even - # when wall_insulation_type=4 ("as-built / assumed"). Treat as - # present so the 50 mm bucket routes correctly. + # RdSAP 10 Table 8/9 footnote: the 50 mm "insulation of unknown + # thickness" row applies only when insulation is "known to have + # been increased subsequently (otherwise 'as built' applies)". + # Genuine retrofit is signalled by `wall_insulation_type` + # (External/Internal/Filled ≠ NONE). An "as built ... insulated + # (assumed)" description is the EPC age-band assumption (it only + # renders on recent bands where as-built already includes + # insulation) → use the as-built age-band row, NOT 50 mm. + # Worksheet-validated by simulated case 9 (sandstone J → 0.35) + # and case 10 (solid brick J → 0.35), both As Built. So the + # description signal is restricted to genuine (non-assumed) + # retrofit via `_described_as_retrofit_insulated`. wall_ins_present = ( (wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE) - or _described_as_insulated(wall_description) + or _described_as_retrofit_insulated(wall_description) ) party_construction = _int_or_none(part.party_wall_construction) raw_roof_thickness = getattr(part, "roof_insulation_thickness", None) @@ -1172,7 +1209,7 @@ def _alt_wall_w_per_k( alt_thickness = _parse_thickness_mm(alt_wall.wall_insulation_thickness) alt_insulation_present = ( alt_wall.wall_insulation_type != _WALL_INSULATION_NONE - or _described_as_insulated(wall_description) + or _described_as_retrofit_insulated(wall_description) ) alt_u = u_wall( country=country, diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index edc6d9ce..23c36bb3 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -52,14 +52,11 @@ def _described_as_insulated(description: Optional[str]) -> bool: otherwise. Looks for "insulated" or "partial insulation" substrings, with "no insulation" taking precedence as a hard negation. - Two consumers: - - `u_wall` uses this to route cavity walls to the Filled-cavity row - of Table 6 (in lieu of the bucketed cascade). - - `heat_transmission_from_cert` uses this to set `wall_ins_present` - for non-cavity walls so the 50 mm bucket routing fires per the - RdSAP 10 Table 6 footnote ("If a wall is known to have additional - insulation but the insulation thickness is unknown, use the row - in the table for 50 mm insulation"). + Consumer: `u_wall` uses this to route cavity walls to the Filled- + cavity row of Table 6 (in lieu of the bucketed cascade). For the + non-cavity `wall_ins_present` gate, `heat_transmission_from_cert` + further restricts this to genuine (non-assumed) retrofit via its + local `_described_as_retrofit_insulated`. """ if description is None: return False diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index b17cc6f5..6093f71f 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+1.8687, - expected_co2_resid_tonnes_per_yr=+0.0907, + expected_pe_resid_kwh_per_m2=+5.5044, + expected_co2_resid_tonnes_per_yr=+0.2757, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -183,7 +183,26 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "exact. For 0240 this raises HW fuel slightly → PE +1.6893 → " "+1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). The lodged " "73 carries Elmhurst's own residual; case 6 is the spec " - "authority per [[feedback-worksheet-not-api-reference]]." + "authority per [[feedback-worksheet-not-api-reference]]. " + "Slice S0380.209 fixed the API-path wall U: the EPC renders " + "this cert's sandstone (band J, As Built) wall as 'insulated " + "(assumed)', which the cascade wrongly routed to the 50 mm " + "retrofit row (U 0.25). Per RdSAP 10 Table 8/9 footnote the " + "50 mm row is only for insulation 'known to have been " + "increased subsequently'; an 'as built ... (assumed)' " + "description is the age-band assumption (renders only on " + "recent bands) → as-built row U 0.35. Worksheet-validated by " + "simulated case 9 (sandstone J → 0.35) + case 10 (solid brick " + "J → 0.35). walls 24.45 → 34.23 W/K → PE +1.8687 → +5.5044, " + "CO2 +0.0907 → +0.2757 (SAP 72 unchanged). This spec-correct " + "fix REMOVED the wall under-count that was masking the Ext1 " + "vaulted-roof over-count (cascade U 0.68 via the same " + "'insulated (assumed)' description vs case-9 sloping-ceiling " + "0.25) — that roof over-count is the next slice; fixing both " + "lands SAP cont ≈ 72.31 (= Elmhurst case 9). The lodged 73 " + "requires a 2013+ pump (case 7); 0240's API lodges the pump " + "as Unknown (code 0 → 115, proven 0=Unknown across 9 API+" + "Summary pairs), so 73 is unreachable from the lodged inputs." ), ), _GoldenExpectation( diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index b9f54aae..1fdfec3b 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -168,21 +168,26 @@ def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnot assert result.floor_w_per_k == pytest.approx(31.0, abs=2.0) -def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnote() -> None: - # Arrange — 128 corpus certs lodge solid-brick walls with - # wall_insulation_type=4 ("as-built / assumed") AND description - # "Solid brick, as built, insulated (assumed)". The description - # signals retrofit insulation that the assessor hasn't measured the - # thickness of; RdSAP 10 Table 6 footnote routes this to the 50 mm - # row. Without the description signal, type=4 alone would set - # wall_ins_present=False and the cascade would return the as-built - # U=1.7. With it, U = 0.55 at band B. +def test_solid_brick_as_built_insulated_assumed_uses_as_built_row_per_table9_footnote() -> None: + # Arrange — an "as built, insulated (assumed)" description only renders + # on RECENT age bands (where as-built construction already includes + # insulation per Building Regs); an old band renders "no insulation + # (assumed)". RdSAP 10 Table 8/9 footnote routes to the 50 mm row only + # when insulation is "known to have been increased subsequently + # (otherwise 'as built' applies)" — an age-band assumption is NOT + # known retrofit, so the as-built row applies. + # + # Worksheet-validated: simulated case 9 (sandstone, band J, As Built + # → U 0.35) and case 10 (solid brick, band J, As Built → U 0.35) both + # return the as-built row, NOT the 50 mm bucket (which would give + # U=0.25). This was previously asserted at 55 W/K via an IMPOSSIBLE + # band-B + "insulated (assumed)" combination. # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single - # storey → gross_wall = 100 m². walls_w_per_k expected = 0.55 × 100 - # = 55 W/K. + # storey → gross_wall = 100 m². walls_w_per_k expected = 0.35 × 100 + # = 35 W/K. main = make_building_part( identifier="Main Dwelling", - construction_age_band="B", + construction_age_band="J", wall_construction=3, wall_insulation_type=4, party_wall_construction=1, @@ -211,7 +216,7 @@ def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnot result = heat_transmission_from_cert(epc) # Assert - assert result.walls_w_per_k == pytest.approx(55.0, abs=1.0) + assert result.walls_w_per_k == pytest.approx(35.0, abs=1.0) def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row() -> None: