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