diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 1763b8f5..1bb3b3c2 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -294,6 +294,13 @@ class ElmhurstSiteNotesExtractor: u_value_known=self._local_bool( lines, f"Alternative Wall {n} U-value Known" ), + # RdSAP10 §5.8 + Table 14: dry-lined uninsulated wall adds + # R = 0.17 m²K/W to base U. Cohort fixture: cert 7700 + # Alt 1 "CavityWallPlasterOnDabs" lodges Dry-lining: Yes → + # U = 1/(1/1.5 + 0.17) ≈ 1.20. + dry_lined=self._local_bool( + lines, f"Alternative Wall {n} Dry-lining" + ), )) return result 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 f8a79eef..b98fe999 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -340,6 +340,38 @@ def test_summary_2102_secondary_heating_routes_house_coal_for_open_fire() -> Non assert epc.sap_heating.secondary_fuel_type == 11 +def test_summary_7700_full_chain_sap_matches_worksheet_pdf_exactly() -> None: + # Arrange — cohort-2 cert 7700-3362-0922-7022-3563 (Summary_000905.pdf + # / dr87-0001-000905.pdf) is the first cohort fixture to exercise + # the alt-wall dry-lining adjustment. End-Terrace house age C, main + # wall filled cavity (CavityWallDensePlasterDenseBlock, U=0.70), + # alt wall 14.44 m² Cavity As-Built, Dry-lining: Yes + # (CavityWallPlasterOnDabsDenseBlock, worksheet U=1.20). + # + # Per RdSAP10 §5.8 + Table 14 page 41: dry-lining adds R = 0.17 + # m²K/W → U = 1/(1/1.5 + 0.17) = 1.19522... → 2 d.p. half-up = 1.20. + # Pre-slice the alt sub-area's `wall_dry_lined="N"` hard-code routed + # to the cavity-as-built default (U=1.50), giving fabric (33) + # 148.72 W/K vs worksheet 144.38 (Δ +4.33 W/K = ~+0.44 SAP). Worksheet + # "SAP value" line lodges unrounded SAP **63.4425**. + cert_dir = Path( + "sap worksheets/additional with api 2/7700-3362-0922-7022-3563" + ) + summary_pdf = next(cert_dir.glob("Summary_*.pdf")) + pages = _summary_pdf_to_textract_style_pages(summary_pdf) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + + # Assert + worksheet_unrounded_sap = 63.4425 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 + + def test_summary_9501_flat_has_no_built_form_in_summary_pdf() -> None: # Arrange — cert 9501 (Summary_000784.pdf) is a flat. The Elmhurst # Summary's §1.0 "Property type" section lodges the built-form diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 194d6d7d..b1122f09 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2875,7 +2875,7 @@ def _map_elmhurst_alternative_wall( case, matching the full-cert-text "TimberWallOneLayer" lodgement).""" return SapAlternativeWall( wall_area=a.area_m2, - wall_dry_lined="N", + wall_dry_lined="Y" if a.dry_lined else "N", wall_construction=_elmhurst_wall_construction_int(a.wall_type) or 0, wall_insulation_type=_elmhurst_wall_insulation_int(a.insulation) or 4, wall_thickness_measured="Y" if not a.thickness_unknown else "N", diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index b955f5c8..fa87f167 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -57,7 +57,12 @@ class AlternativeWall: gross wall that has a different construction (e.g. a small 1.43 m² timber-frame panel on an otherwise cavity-walled extension). Up to two alternative walls per bp; Elmhurst lodges them in §7's "1st/2nd - Extension" subsection under the "Alternative Wall N " prefix.""" + Extension" subsection under the "Alternative Wall N " prefix. + + `dry_lined` carries Summary §7 "Alternative Wall N Dry-lining: Yes/No". + RdSAP10 §5.8 + Table 14: a dry-lined uninsulated wall adds R = 0.17 + m²K/W to the base U-value (cavity-as-built age C: U = 1/(1/1.5 + 0.17) + ≈ 1.20). Cohort fixture: cert 7700 alt-wall (CavityWallPlasterOnDabs).""" area_m2: float wall_type: str # e.g. "TI Timber Frame" @@ -65,6 +70,7 @@ class AlternativeWall: thickness_unknown: bool thickness_mm: Optional[int] u_value_known: bool + dry_lined: bool = False @dataclass diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index a397fea1..0950648a 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -921,5 +921,9 @@ def _alt_wall_w_per_k( insulation_present=alt_insulation_present, description=wall_description, wall_insulation_type=alt_wall.wall_insulation_type, + # RdSAP10 §5.8 + Table 14: dry-lined alt-wall adds R = 0.17 m²K/W + # when no insulation thickness is lodged. Cohort fixture: cert + # 7700 Alt 1 (Cavity, As-Built, Dry-lined) → 1.50 → 1.20. + dry_lined=alt_wall.wall_dry_lined == "Y", ) return alt_u * net_alt_area diff --git a/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py b/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py index dbdeca5d..b26cd227 100644 --- a/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py +++ b/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py @@ -1119,6 +1119,65 @@ def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None: ) +def test_dry_lined_alt_wall_cavity_as_built_age_c_applies_rdsap_5_8_r_0_17_adjustment() -> None: + """RdSAP10 §5.8 + Table 14 page 41: dry-lined uninsulated alt-wall + adds R = 0.17 m²K/W. Cohort fixture: cert 7700-3362-0922-7022-3563 + Alt 1 lodges 14.44 m² Cavity, As-Built, Dry-lining: Yes, age C — + worksheet `CavityWallPlasterOnDabsDenseBlock` row (29a) U=1.20, + A×U = 14.44 × 1.20 = 17.3280 W/K. Without dry-lining the cascade + would route to the cavity-as-built default (U=1.50, A×U=21.66). + Difference: 4.33 W/K → ~+0.44 SAP — the entire cert 7700 residual.""" + from dataclasses import replace + # Arrange — age C single-bp dwelling, main wall filled-cavity (U=0.70, + # so the difference we isolate sits entirely on the alt sub-area). + main_age_c = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="C", + wall_construction=4, + wall_insulation_type=2, # filled cavity → main wall U=0.70 + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=80.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0, + ), + ], + ) + with_dry_lined_alt = replace( + main_age_c, + sap_alternative_wall_1=SapAlternativeWall( + wall_area=14.44, wall_dry_lined="Y", + wall_construction=4, wall_insulation_type=4, + wall_thickness_measured="N", + ), + ) + without_dry_lined_alt = replace( + main_age_c, + sap_alternative_wall_1=SapAlternativeWall( + wall_area=14.44, wall_dry_lined="N", + wall_construction=4, wall_insulation_type=4, + wall_thickness_measured="N", + ), + ) + + # Act + epc_dry_lined = make_minimal_sap10_epc( + total_floor_area_m2=80.0, country_code="ENG", + sap_building_parts=[with_dry_lined_alt], + ) + epc_plain = make_minimal_sap10_epc( + total_floor_area_m2=80.0, country_code="ENG", + sap_building_parts=[without_dry_lined_alt], + ) + result_dry_lined = heat_transmission_from_cert(epc_dry_lined) + result_plain = heat_transmission_from_cert(epc_plain) + + # Assert — the alt-wall A×U delta is exactly 14.44 × (1.50 - 1.20) + # = 4.3320 W/K. Closed form: 1/(1/1.5 + 0.17) = 1.19522... → 2 d.p. = 1.20. + delta = result_plain.walls_w_per_k - result_dry_lined.walls_w_per_k + assert abs(delta - (14.44 * (1.50 - 1.20))) <= 1e-9 + + def test_basement_floor_uses_table_23_u_value_for_whole_floor_when_basement_detected() -> None: """User-confirmed convention: when a part has a basement, the WHOLE floor=0 is the basement floor. Table 23 F-column overrides the diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index c5d630eb..8e6609cf 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -137,6 +137,12 @@ WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7 # (cavity + external/internal insulation). _WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04 +# RdSAP10 §5.8 final note + Table 14 page 41: "For drylining including +# laths and plaster use Rinsulation = 0.17 m²K/W." Applied additively to +# the base U-value of an otherwise-uninsulated wall when the cert lodges +# `wall_dry_lined = "Y"` — see `u_wall(dry_lined=True)`. +_DRY_LINING_RESISTANCE_M2K_PER_W: Final[float] = 0.17 + _AGE_BANDS: Final[tuple[str, ...]] = tuple("ABCDEFGHIJKLM") @@ -329,6 +335,7 @@ def u_wall( insulation_present: bool = False, description: Optional[str] = None, wall_insulation_type: Optional[int] = None, + dry_lined: bool = False, ) -> float: """RdSAP10 wall U-value in W/m^2K, never null. @@ -344,6 +351,16 @@ def u_wall( dedicated "Filled cavity" row is used in preference to the thickness-bucketed cascade — the two encode different things (filled- cavity is a construction state, not an added-insulation thickness). + + `dry_lined` triggers the RdSAP10 §5.8 + Table 14 adjustment: + U_adjusted = 1 / (1/U_base + R_dryline) with R_dryline = 0.17 m²K/W. + The adjustment is applied only when the base U comes from the + uninsulated bucket (no measured insulation thickness, no filled-cavity + branch, no surveyor "described as insulated" override) — for those + branches the dry-lining R is already absorbed into the assumed + insulation stack. Cohort fixture: cert 7700 Alt 1 cavity-as-built + age C with Dry-lining: Yes — base U=1.5 → adjusted U=1.20 (2 d.p., + matching worksheet `CavityWallPlasterOnDabsDenseBlock`). """ measured = _measured_u_from_description(description) if measured is not None: @@ -394,12 +411,21 @@ def u_wall( # Country override first. overrides = _COUNTRY_KLM_OVERRIDES.get(ctry, {}).get((wall_type, bucket), {}) if band in overrides: - return overrides[band] - - base = _ENG_WALL.get((wall_type, bucket)) - if base is None: - return 1.5 - return base[age_idx] + u_base = overrides[band] + else: + base = _ENG_WALL.get((wall_type, bucket)) + u_base = base[age_idx] if base is not None else 1.5 + # RdSAP10 §5.8 + Table 14 page 41 — dry-lining (including lath and + # plaster) adds R = 0.17 m²K/W to an otherwise-uninsulated wall: + # U_adjusted = 1 / (1/U_base + 0.17), rounded to 2 d.p. half-up. + # Only the as-built uninsulated bucket triggers the adjustment; + # insulated buckets already incorporate the dry-lining R via Table 14. + if dry_lined and bucket == 0: + u_unrounded = 1.0 / (1.0 / u_base + _DRY_LINING_RESISTANCE_M2K_PER_W) + return float( + Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) + return u_base # --------------------------------------------------------------------------- diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index b5f7df0b..5c5f5942 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -278,6 +278,73 @@ def test_u_wall_unfilled_cavity_england_age_band_e_unchanged_at_1_5() -> None: assert result == pytest.approx(1.5, abs=0.001) +def test_u_wall_dry_lined_cavity_as_built_age_c_applies_rdsap_5_8_r_0_17_adjustment() -> None: + # Arrange — RdSAP10 §5.8 final note + Table 14 page 41: "For drylining + # including laths and plaster use Rinsulation = 0.17 m²K/W." Applied + # additively to the base U-value of an otherwise-uninsulated wall. + # Cohort fixture: cert 7700-3362-0922-7022-3563 Alt 1 lodges Cavity, + # As-Built, Dry-lining: Yes, age band C → worksheet + # `CavityWallPlasterOnDabsDenseBlock` U-value = 1.20 W/m²K. + # Closed form: 1 / (1/1.5 + 0.17) = 1.19522... → 2 d.p. half-up = 1.20. + + # Act + result = u_wall( + country=Country.ENG, + age_band="C", + construction=WALL_CAVITY, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + dry_lined=True, + ) + + # Assert — adjusted U is rounded to 2 d.p. matching the dr87 worksheet's + # `UValueFinal` column for this construction. + assert abs(result - 1.20) <= 1e-9 + + +def test_u_wall_not_dry_lined_cavity_as_built_age_c_returns_unadjusted_1_5() -> None: + # Arrange — same age + construction as the dry-lined case above but + # without the dry-lining flag. Cascade must return the bare Table 6 + # "Cavity as built" row value (no R = 0.17 added). + + # Act + result = u_wall( + country=Country.ENG, + age_band="C", + construction=WALL_CAVITY, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + dry_lined=False, + ) + + # Assert + assert abs(result - 1.50) <= 1e-9 + + +def test_u_wall_dry_lined_with_measured_insulation_thickness_no_adjustment() -> None: + # Arrange — once a measured insulation thickness is lodged, Table 6's + # insulated buckets already incorporate the dry-lining R via Table 14. + # Applying R = 0.17 on top would double-count. Cavity + 100 mm + # insulation, age band E → Table 6 cavity-100mm row = 0.32 W/m²K + # regardless of the dry-lining flag. + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=WALL_CAVITY, + insulation_thickness_mm=100, + insulation_present=True, + wall_insulation_type=4, + dry_lined=True, + ) + + # Assert + assert abs(result - 0.32) <= 1e-9 + + def test_u_wall_cavity_as_built_england_age_band_g_returns_table6_value() -> None: # Arrange — Table 6, England, Cavity as built, age band G -> 0.60 W/m^2K.