From 3ab09845e7c0f7972d4795c63955e6b7fc8347cc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 21:54:17 +0000 Subject: [PATCH] slice S-B29: parse measured U from full-SAP floor + roof descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel of S-B24 (walls) for the other envelope elements. Full-SAP assessments lodge a measured/calculated U-value directly in the description ("Average thermal transmittance X W/m²K") for floors (~1 391 corpus certs) and roofs (~1 140 certs). Per spec: - §5.11 (roofs) opening clause defers to assessor's value when present - §5.12 (floors): "Unless provided by the assessor the floor U-value is calculated according to BS EN ISO 13370" Both u_floor and u_roof now invoke `_measured_u_from_description` first; if it parses a value, they return it directly and skip the cascade. No range cap (consistent with S-B24 design — calculator mirrors what the assessor lodged). Parity probe at 300 certs, seed=7: headlines unchanged (same parquet sampling gap as S-B24 — full-SAP certs filtered out upstream). Slice correctness proved by: - 1 unit test for u_floor measured-U parse - 1 unit test for u_roof measured-U parse - existing 287 tests passing, no regressions A bulk-zip-based probe to measure the corpus-wide impact remains the needed tooling investment (see S-B24 commit message). Co-Authored-By: Claude Opus 4.7 --- .../domain/src/domain/ml/rdsap_uvalues.py | 15 +++++++ .../src/domain/ml/tests/test_rdsap_uvalues.py | 42 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index 27069827..169c6cdb 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -417,6 +417,12 @@ def u_roof( as uninsulated. 3. Table 18 age-band default. """ + measured = _measured_u_from_description(description) + if measured is not None: + # Full-SAP cert lodges a measured roof U-value in the description + # ("Average thermal transmittance X W/m²K"); spec §5.11 opening + # clause defers to the assessor's value when present. + return measured if insulation_thickness_mm == 0 and _described_as_insulated(description): # Spec §5.11.4 (page 44 footnote): "If retrofit insulation # present of unknown thickness use 50 mm". The cert encodes @@ -478,7 +484,16 @@ def u_floor( age band". The cert encodes "thickness unknown" as either a missing field (`insulation_thickness_mm=None`) or "NI" which `_parse_thickness_mm` returns as 0. + + Full-SAP assessments lodge a measured floor U-value directly in + the description ("Average thermal transmittance X W/m²K"); when + present this supersedes the BS EN ISO 13370 calculation per spec + §5.12 opening clause ("Unless provided by the assessor the floor + U-value is calculated according to BS EN ISO 13370"). """ + measured = _measured_u_from_description(description) + if measured is not None: + return measured if area_m2 is None or perimeter_m is None or perimeter_m <= 0: return 0.7 w = (wall_thickness_mm or 300) / 1000.0 diff --git a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py index afc96334..2bf0804d 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -466,6 +466,25 @@ def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_ # ----- Roofs ----- +def test_u_roof_description_with_measured_transmittance_returns_parsed_value() -> None: + # Arrange — ~1 140 corpus certs lodge a full-SAP measured roof + # U-value in the description, e.g. "Average thermal transmittance + # 0.11 W/m²K". The age-band cascade is bypassed: the assessor's + # measured/calculated value is used directly. Same contract as + # `u_wall` (S-B24) and `u_floor` (S-B29 cycle 1). + + # Act + result = u_roof( + country=Country.ENG, + age_band="C", + insulation_thickness_mm=None, + description="Average thermal transmittance 0.11 W/m²K", + ) + + # Assert + assert result == pytest.approx(0.11, abs=0.001) + + def test_u_roof_ni_thickness_with_insulated_description_applies_50mm_per_section_5_11_4() -> None: # Arrange — 346 corpus certs lodge roof_insulation_thickness="NI" # (Not Indicated, parsed to 0 by _parse_thickness_mm). When the @@ -621,6 +640,29 @@ def test_u_roof_explicit_thickness_beats_description() -> None: # ----- Floors ----- +def test_u_floor_description_with_measured_transmittance_returns_parsed_value() -> None: + # Arrange — ~1 391 corpus certs lodge a full-SAP measured floor + # U-value in the description, e.g. "Average thermal transmittance + # 0.18 W/m²K". The BS EN ISO 13370 calculation is bypassed: the + # assessor's measured/calculated value is used directly. Same + # contract as `u_wall` (S-B24). + + # Act + result = u_floor( + country=Country.ENG, + age_band="B", + construction=None, + insulation_thickness_mm=None, + area_m2=100.0, + perimeter_m=40.0, + wall_thickness_mm=300, + description="Average thermal transmittance 0.18 W/m²K", + ) + + # Assert + assert result == pytest.approx(0.18, abs=0.001) + + def test_u_floor_ni_thickness_with_insulated_description_applies_50mm_per_table19_footnote() -> None: # Arrange — 2 413 corpus certs (~12%) lodge floors with # floor_insulation_thickness="NI" (Not Indicated, which our