diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index 353cf267..a38d05b7 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -455,11 +455,21 @@ def u_floor( area_m2: Optional[float], perimeter_m: Optional[float], wall_thickness_mm: Optional[int], + description: Optional[str] = None, ) -> float: """RdSAP10 ground-floor U-value via BS EN ISO 13370 solid-floor branch. Suspended-floor branch is approximated as solid since the difference at the feature-engineering granularity is < 0.1 W/m^2K for typical UK floors. + + `description` is the joined surveyor text from `floors[i].description`. + When it asserts retrofit insulation ("Solid, insulated (assumed)" / + "Suspended, insulated (assumed)" / similar) and the assessor hasn't + lodged a numeric thickness, RdSAP 10 §5.12 Table 19 footnote (2) + applies: "use the greater of 50 mm and the thickness according to the + 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. """ if area_m2 is None or perimeter_m is None or perimeter_m <= 0: return 0.7 @@ -468,8 +478,17 @@ def u_floor( r_si = 0.17 r_se = 0.04 ins_mm = insulation_thickness_mm - if ins_mm is None and age_band is not None: - ins_mm = _FLOOR_INSULATION_DEFAULT_MM.get(age_band.upper(), 0) + age_default_mm = ( + _FLOOR_INSULATION_DEFAULT_MM.get(age_band.upper(), 0) + if age_band is not None + else 0 + ) + if ins_mm is None: + ins_mm = age_default_mm + if (ins_mm is None or ins_mm == 0) and _described_as_insulated(description): + # Table 19 footnote (2): "use the greater of 50 mm and the + # thickness according to the age band". + ins_mm = max(50, age_default_mm) r_f = ((ins_mm or 0) / 1000.0) / 0.035 d_t = w + soil_g * (r_si + r_f + r_se) b = 2.0 * area_m2 / perimeter_m 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 0ff57f22..90c13cce 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -578,6 +578,60 @@ def test_u_roof_explicit_thickness_beats_description() -> None: # ----- Floors ----- +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 + # _parse_thickness_mm returns as 0) AND a description "Solid, + # insulated (assumed)" or "Suspended, insulated (assumed)". The + # assessor sees insulation but hasn't measured the thickness. + # RdSAP 10 §5.12 Table 19 footnote (2): + # "For floors which have retrofitted insulation, use the greater + # of 50 mm and the thickness according to the age band." + # Band B's age-band default is 0 mm, so max(50, 0) = 50 mm applies. + # Geometry: 100 m² × 40 m perimeter, w=0.3, gives B=5, d_t=2.758 + # (with R_f from 50 mm/0.035 = 1.429); U = 2 × 1.5 × ln(π×5/2.758 + 1) + # / (π×5 + 2.758) ≈ 0.31 W/m²K. + + # Act + result = u_floor( + country=Country.ENG, + age_band="B", + construction=None, + insulation_thickness_mm=0, # parsed from "NI" + area_m2=100.0, + perimeter_m=40.0, + wall_thickness_mm=300, + description="Solid, insulated (assumed)", + ) + + # Assert + assert result == pytest.approx(0.31, abs=0.02) + + +def test_u_floor_ni_thickness_with_no_insulation_description_stays_uninsulated() -> None: + # Arrange — 8 221 corpus certs lodge "Solid, no insulation + # (assumed)" with thickness="NI". The Table 19 footnote (2) override + # must not fire on these: the "no insulation" substring takes + # precedence over the "insulated" substring per + # `_described_as_insulated`. Same geometry as the cycle-1 test; + # uninsulated U should be ~0.60 W/m²K (B=5, d_t=0.615 with R_f=0). + + # Act + result = u_floor( + country=Country.ENG, + age_band="B", + construction=None, + insulation_thickness_mm=0, # parsed from "NI" + area_m2=100.0, + perimeter_m=40.0, + wall_thickness_mm=300, + description="Solid, no insulation (assumed)", + ) + + # Assert + assert result == pytest.approx(0.60, abs=0.02) + + def test_u_floor_solid_uninsulated_typical_geometry_returns_iso_13370_value() -> None: # Arrange — solid floor, age C, England. # BS EN ISO 13370 with A=80, P=36, w=0.22m, soil g=1.5, Rsi=0.17, Rse=0.04, Rf=0 diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index f3b75431..64a3928b 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -170,6 +170,7 @@ def heat_transmission_from_cert( country = Country.from_code(epc.country_code) roof_description = _joined_descriptions(epc.roofs) wall_description = _joined_descriptions(epc.walls) + floor_description = _joined_descriptions(epc.floors) door_area = max(0, door_count) * _DEFAULT_DOOR_AREA_M2 window_u = window_avg_u_value if (window_avg_u_value or 0) > 0 else u_window( @@ -233,6 +234,7 @@ def heat_transmission_from_cert( insulation_thickness_mm=floor_ins_thickness, area_m2=floor_area, perimeter_m=floor_perimeter, wall_thickness_mm=part.wall_thickness_mm, + description=floor_description, ) upw = u_party_wall(party_wall_construction=party_construction) y = thermal_bridging_y(age_band=age_band) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py index d216ecd0..cf5950f6 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py @@ -31,6 +31,49 @@ from domain.sap.worksheet.heat_transmission import ( ) +def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnote() -> None: + # Arrange — 2 413 corpus certs lodge floors with thickness="NI" and + # description "Solid, insulated (assumed)". The retrofit-insulation + # signal in the description must flow from epc.floors[0].description + # through heat_transmission_from_cert into u_floor so the 50 mm + # assumption fires per RdSAP 10 §5.12 Table 19 footnote (2). + # Geometry: 100 m² floor, 40 m perimeter, w=0.3 → B=5, U ≈ 0.31. + # floor_w_per_k expected = 0.31 × 100 ≈ 31 W/K (cf. uninsulated + # case which would give 0.60 × 100 = 60 W/K). + main = make_building_part( + identifier="Main Dwelling", + construction_age_band="B", + wall_construction=3, + wall_insulation_type=4, + party_wall_construction=1, + roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=100.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + epc.floors = [ + EnergyElement( + description="Solid, insulated (assumed)", + energy_efficiency_rating=3, + environmental_efficiency_rating=3, + ), + ] + + # Act + result = heat_transmission_from_cert(epc) + + # Assert + 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