diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 2ef935ce..845a72e2 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -66,39 +66,26 @@ def _described_as_insulated(description: Optional[str]) -> bool: return "insulated" in desc or "partial insulation" in desc -def _cavity_described_as_filled(description: Optional[str]) -> bool: - """True when an as-built cavity wall's description asserts the cavity is - insulated/filled, routing it to the Table 6 "Filled cavity" row. - - Distinguishes the three as-built cavity states the EPC renders by age - band when wall_insulation_type=4 ("as-built / assumed"): - - - "...insulated (assumed)" → Filled cavity (assessor judges - the cavity filled but lodges no - thickness) - - "...partial insulation (assumed)" → "Cavity as built" row (the - as-built partial fill of the age - band, NOT a retrofit cavity fill) - - "...no insulation (assumed)" → "Cavity as built" row - - Narrower than `_described_as_insulated`: it excludes the "partial - insulation" substring so a "partial insulation (assumed)" cavity stays on - the as-built row. RdSAP 10 Table 6 (England) "Cavity as built" band F = - 1.0 vs "Filled cavity" band F = 0.40 — for an as-built band-F cavity the - filled row understates heat loss by 2.5x. A genuine retrofit fill is - lodged distinctly as "Cavity wall, filled cavity" - (wall_insulation_type=2), handled by the explicit-code branch. - - Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, cavity - type 4, "partial insulation (assumed)") closes all four SAP metrics on - the as-built 1.0 row; the filled 0.40 row under-counts PE by ~28 kWh/m². - """ - if description is None: - return False - desc = description.lower() - if "no insulation" in desc: - return False - return "insulated" in desc +# An AS-BUILT cavity wall (wall_insulation_type=4 / "as-built / assumed", +# however the EPC renders the insulation adjective — "insulated", "partial +# insulation" or "no insulation" "(assumed)") routes to Table 6's "Cavity +# as built" row via the bucketed cascade, NOT the "Filled cavity" row. Per +# RdSAP 10 Table 6 (England) the "Filled cavity" row's † footnote ("assumed +# as built") applies only at age bands I-M, where the two rows are +# numerically identical — so at bands A-H the Filled cavity row represents a +# GENUINE fill, not the as-built assumption. A genuine retrofit fill is +# lodged distinctly as "Cavity wall, filled cavity" (wall_insulation_type=2), +# caught by the explicit-code branch in `u_wall`. +# +# Slice S0380.210 first corrected this for "partial insulation (assumed)" +# (golden 0390-2954-3640, band F → as-built 1.0); the "insulated (assumed)" +# variant was left on the filled row by a legacy production convention. That +# was the SAME latent A-H bug: the API SAP-accuracy cohort over-rated +# "Cavity wall, as built, insulated (assumed)" certs at bands G/H by a clean +# +1.4 / +1.6 SAP median (filled 0.35 vs as-built 0.60), while bands I-M +# were unaffected (rows coincide). The `_cavity_described_as_filled` +# description sniffer is therefore retired — as-built cavities always use the +# as-built row regardless of the rendered insulation adjective. # --------------------------------------------------------------------------- @@ -689,7 +676,6 @@ def u_wall( ) if wall_type == WALL_CAVITY and ( wall_insulation_type == WALL_INSULATION_FILLED_CAVITY - or _cavity_described_as_filled(description) ): return _CAVITY_FILLED_ENG[age_idx] bucket = _insulation_bucket(insulation_thickness_mm, insulation_present) diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index f7e31c70..2d563dd1 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -114,20 +114,26 @@ def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote( assert result == pytest.approx(0.55, abs=0.001) -def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() -> None: - # Arrange — 1 171 corpus certs (~4% of scanned bulk) lodge - # wall_insulation_type=4 ("as-built / assumed") together with the - # description "Cavity wall, as built, insulated (assumed)". The - # assessor is saying: this cavity is filled, but I haven't measured - # the thickness. Spec footnote on Table 6 covers this: "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" — but - # legacy convention (used by the production recommendation engine) - # is to route this to the Filled-cavity row, U = 0.7 at A-E. We - # follow the legacy convention here for parity with the cert assessor. +def test_u_wall_cavity_as_built_insulated_assumed_routes_to_as_built_row() -> None: + # Arrange — a cavity lodged "Cavity wall, as built, insulated (assumed)" + # with wall_insulation_type=4 is in its AS-BUILT state, NOT a retrofit + # cavity fill. Per RdSAP 10 Table 6 (England) the "Filled cavity" row's + # † footnote ("assumed as built") applies only at bands I-M, where it + # coincides with "Cavity as built"; at bands A-H the filled row is for a + # GENUINE fill. So an as-built cavity uses the "Cavity as built" row: + # band E = 1.5, NOT the filled 0.7. + # + # Slice S0380.210 corrected this for the "partial insulation (assumed)" + # variant but left "insulated (assumed)" on the filled row by a legacy + # production convention — the SAME latent A-H bug. The API SAP-accuracy + # cohort over-rated band-G/H "insulated (assumed)" cavities by a clean + # +1.4 / +1.6 SAP median (filled 0.35 vs as-built 0.60); bands I-M were + # unaffected (rows coincide). A genuine fill lodges the distinct "Cavity + # wall, filled cavity" (wall_insulation_type=2), caught by the + # explicit-code branch. # Act - result = u_wall( + result_e = u_wall( country=Country.ENG, age_band="E", construction=WALL_CAVITY, @@ -136,19 +142,30 @@ def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() wall_insulation_type=4, description="Cavity wall, as built, insulated (assumed)", ) + # Band I: "Cavity as built" and "Filled cavity" rows coincide (0.45), + # so the routing change is a no-op there — the corpus-confirmed pivot. + result_i = u_wall( + country=Country.ENG, + age_band="I", + construction=WALL_CAVITY, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + description="Cavity wall, as built, insulated (assumed)", + ) - # Assert - assert result == pytest.approx(0.7, abs=0.001) + # Assert — band E → as-built 1.5 (not filled 0.7); band I → 0.45 (rows coincide). + assert abs(result_e - 1.5) <= 0.001 + assert abs(result_i - 0.45) <= 0.001 def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_row() -> None: # Arrange — the same wall_insulation_type=4 ("as-built / assumed") # cert population also contains 686 "Cavity wall, as built, no - # insulation (assumed)" entries which must continue to route to the - # Cavity-as-built row of Table 6 (U=1.5 at band E). The "no - # insulation" substring marker takes precedence over the - # "insulated"-substring filled-cavity rule, so this case is - # disambiguated from "Cavity wall, as built, insulated (assumed)". + # insulation (assumed)" entries which route to the Cavity-as-built row + # of Table 6 (U=1.5 at band E) — as do ALL as-built cavity variants + # ("insulated" / "partial insulation" / "no insulation") now that the + # as-built path no longer special-cases the insulation adjective. # Act result = u_wall( @@ -180,8 +197,8 @@ def test_u_wall_cavity_as_built_partial_insulation_routes_to_as_built_row() -> N # four SAP metrics on the as-built row (band F = 1.0) and under-counts # PE by ~28 kWh/m² on the filled row — the legacy parity was a latent # bug at bands A-H (bands I-M coincide per the Table 6 † footnote). - # The "insulated (assumed)" variant still routes to filled (see the - # heat_transmission `_cavity_described_as_filled` sibling test). + # A later slice extended the same fix to the "insulated (assumed)" + # variant (see the as-built-insulated sibling test above). # Act result = u_wall( diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 24fbe082..92967423 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -327,16 +327,22 @@ def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row assert result.walls_w_per_k == pytest.approx(170.0, abs=1.0) -def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None: - # Arrange — the modal RdSAP encoding for a retrofitted-cavity dwelling: - # wall_construction=4 (cavity), wall_insulation_type=4 (as-built / - # assumed), and walls[0].description = "Cavity wall, as built, - # insulated (assumed)". The assessor has determined the cavity is - # filled but hasn't lodged a thickness. Without the description-based - # dispatcher, the cascade would return U=1.5; with it, the Filled- - # cavity row of Table 6 applies: U=0.7 at band E. +def test_cavity_as_built_insulated_assumed_uses_as_built_row() -> None: + # Arrange — wall_construction=4 (cavity), wall_insulation_type=4 + # (as-built / assumed), walls[0].description = "Cavity wall, as built, + # insulated (assumed)". This is the AS-BUILT state, not a retrofit fill: + # per RdSAP 10 Table 6 (England) the "Filled cavity" row's † footnote + # ("assumed as built") applies only at bands I-M, where it coincides + # with "Cavity as built"; at bands A-H the filled row is for a genuine + # fill. So band E uses the "Cavity as built" row U=1.5, NOT filled 0.7. + # + # Prior code special-cased the "insulated" adjective to the filled row + # (legacy convention); the API SAP-accuracy cohort over-rated band-G/H + # "insulated (assumed)" cavities by +1.4 / +1.6 SAP median (filled 0.35 + # vs as-built 0.60). A genuine fill renders the distinct "Cavity wall, + # filled cavity" (wall_insulation_type=2), caught separately. # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single storey - # → gross_wall = 100 m². walls_w_per_k expected = 0.7 × 100 = 70 W/K. + # → gross_wall = 100 m². walls_w_per_k expected = 1.5 × 100 = 150 W/K. main = make_building_part( identifier="Main Dwelling", construction_age_band="E", @@ -367,8 +373,8 @@ def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None: # Act result = heat_transmission_from_cert(epc) - # Assert - assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0) + # Assert — Cavity-as-built row at band E = 1.5 W/m²K (not filled 0.7). + assert result.walls_w_per_k == pytest.approx(150.0, abs=1.0) def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None: @@ -381,8 +387,8 @@ def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None: # RdSAP 10 Table 6 (England) "Cavity as built" band F = 1.0 vs # "Filled cavity" band F = 0.40. A genuine fill renders the distinct # "Cavity wall, filled cavity" description (wall_insulation_type=2), - # caught separately. Contrast the "insulated (assumed)" variant above, - # which the assessor judges as filled. + # caught separately. The "insulated (assumed)" variant above now routes + # to the same as-built row (all as-built adjectives coincide at A-H). # # Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, # cavity type 4, "partial insulation (assumed)") closes all four SAP