From aff331ff34a9a46d7ed4b78c0a2850a051ba30cf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 25 May 2026 20:29:54 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2087:=20implement=20RdSAP=2010=20=C2=A75?= =?UTF-8?q?=20(12)=20spec=20rule=20for=20suspended=20timber=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the empirical `_elmhurst_has_suspended_timber_floor` heuristic (which keyed on Room-in-Roof < Main ground area) with the mechanical RdSAP 10 Specification §5 rule (page 29): - Age band A-E: U-value < 0.5 → sealed (0.1); retro insulation + no U → sealed (0.1); otherwise unsealed (0.2) - Age band F-M: sealed (0.1) - Park home: unsealed (0.2) - Only applies when Main bp's lowest floor is a "Ground floor" with "Suspended timber" construction The spec rule is derived in `_has_suspended_timber_floor_per_spec` (cert_to_inputs.py) and applied in `ventilation_from_cert` whenever the lodged `epc.sap_ventilation.has_suspended_timber_floor` is None. Explicit lodged values (cohort hand-built fixtures) take precedence. Impact on cert 001479 (the load-bearing API↔Elmhurst parity-test fixture; previously the RR-based heuristic returned False for this no-RR semi-detached, dropping (12) entirely): Mapper → cascade → SAP delta vs worksheet 69.0094: BEFORE: +1.1903 (mapper extracted False; cascade applied (12)=0) AFTER : +0.2290 (mapper extracts None; spec derives True/unsealed; cascade applies (12)=0.2 → matches worksheet) Cohort cascade pins remain GREEN (66 of 66) — cohort hand-built fixtures retain their explicit `has_suspended_timber_floor` values which override the spec derivation. Expected cohort regressions to triage in the next slice: - 4 cohort chain tests RED (000474, 000480, 000487, 000490) — their Elmhurst worksheets enter non-spec (12) values (0.0 or 0.2 when spec predicts the opposite) so the mapper-path cascade now diverges from the worksheet PDF at 1e-4. - 6 cohort diff tests RED — mapper now produces has_suspended_timber_floor=None while the cohort hand-builts retain explicit True/False overrides, producing a 1-field divergence per cohort cert. Pyright net-zero (mapper 35→35; cert_to_inputs 35→35) — dead `_elmhurst_has_suspended_timber_floor` removed. Co-Authored-By: Claude Opus 4.7 --- datatypes/epc/domain/mapper.py | 41 ++---- .../src/domain/sap/rdsap/cert_to_inputs.py | 121 +++++++++++++++++- .../tests/_elmhurst_worksheet_001479.py | 13 +- 3 files changed, 143 insertions(+), 32 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e500c32e..937100fc 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -348,7 +348,13 @@ class EpcPropertyDataMapper: sap_ventilation=_map_elmhurst_ventilation( survey.ventilation, built_form, - has_suspended_timber_floor=_elmhurst_has_suspended_timber_floor(survey), + # `has_suspended_timber_floor` is left None — the cascade + # derives the §2(12) value mechanically from age band + + # floor U-value + insulation per RdSAP 10 spec page 29. + # The previous heuristic (RR < ground area) was an + # empirical fit to the cohort that wrongly returned False + # for non-RR dwellings like cert 001479. + has_suspended_timber_floor=None, ), percent_draughtproofed=survey.draught_proofing_percent, waste_water_heat_recovery=( @@ -2724,36 +2730,10 @@ def _elmhurst_sheltered_sides(built_form: str) -> Optional[int]: return _ELMHURST_SHELTERED_SIDES_BY_BUILT_FORM.get(built_form) -def _elmhurst_has_suspended_timber_floor(survey: ElmhurstSiteNotes) -> bool: - """Apply the Elmhurst §2(12) suspended-wooden-floor flag. Every cert - in the cohort lodges "T Suspended timber" on the §9 ground floor, - yet the worksheet enters 0.2 ACH for only 2 of 6 (000477, 000487) - and 0 ACH for the others (000474, 000480, 000490, 000516). - - The empirical discriminator across the cohort: the dwelling has a - "real" suspended timber floor (counts for §2(12)) only when the - Main bp's Room-in-Roof storey is SMALLER than the Main ground - floor — i.e. the dwelling is a typical 2-storey-plus-loft house - where the RR sits inside the original roof envelope rather than a - structurally-inverted dwelling where the RR is larger than the - storey below it (000480, 19.83 m² RR vs 15.28 m² Main floor) and - Elmhurst treats the floor differently. Falls through to False when - no RR is lodged or the lowest floor isn't a ground floor.""" - if _leading_code(survey.floor.location) != "G": # not a ground floor - return False - rir = survey.room_in_roof - if rir is None or rir.floor_area_m2 <= 0: - return False - main_ground_area = sum( - f.area_m2 for f in survey.dimensions.floors if "lowest" in f.name.lower() - ) - return main_ground_area > 0 and rir.floor_area_m2 < main_ground_area - - def _map_elmhurst_ventilation( v: ElmhurstVentilation, built_form: str, - has_suspended_timber_floor: bool, + has_suspended_timber_floor: Optional[bool], ) -> SapVentilation: return SapVentilation( ventilation_type=None, @@ -2776,5 +2756,8 @@ def _map_elmhurst_ventilation( ventilation_in_pcdf_database=None, sheltered_sides=_elmhurst_sheltered_sides(built_form), has_suspended_timber_floor=has_suspended_timber_floor, - suspended_timber_floor_sealed=False if has_suspended_timber_floor else None, + suspended_timber_floor_sealed=( + None if has_suspended_timber_floor is None + else (False if has_suspended_timber_floor else None) + ), ) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 2648fb12..581a303d 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -62,6 +62,7 @@ from datatypes.epc.domain.epc_property_data import ( ) from domain.ml.demand import predicted_hot_water_kwh +from domain.ml.rdsap_uvalues import Country, u_floor from domain.ml.sap_efficiencies import ( seasonal_efficiency, water_heating_efficiency as _legacy_water_heating_efficiency, @@ -1535,6 +1536,106 @@ def solar_gains_section_from_cert( ) +_AGE_BANDS_F_TO_M: Final[frozenset[str]] = frozenset({"F", "G", "H", "I", "J", "K", "L", "M"}) +_AGE_BANDS_A_TO_E: Final[frozenset[str]] = frozenset({"A", "B", "C", "D", "E"}) +_SUSPENDED_TIMBER_FLOOR_TYPE: Final[str] = "Suspended timber" +_GROUND_FLOOR_TYPE: Final[str] = "Ground floor" +_FLOOR_U_SEALED_THRESHOLD: Final[float] = 0.5 + + +def _main_floor_u_value(epc: EpcPropertyData) -> Optional[float]: + """Compute the Main bp's ground-floor U-value via the same path the + cascade uses in `heat_transmission_section_from_cert`. Returns None + when the Main bp has no usable ground-floor dimension. + + Used by `_has_suspended_timber_floor_per_spec` to apply the RdSAP 10 + §5 (12) rule, which keys on whether the floor U-value < 0.5 W/m²K. + """ + if not epc.sap_building_parts: + return None + main = epc.sap_building_parts[0] + ground_fd = next( + (fd for fd in main.sap_floor_dimensions if fd.floor == 0), + main.sap_floor_dimensions[0] if main.sap_floor_dimensions else None, + ) + if ground_fd is None or ground_fd.is_exposed_floor or main.has_basement: + return None + raw_floor_ins = getattr(main, "floor_insulation_thickness", None) + floor_ins_mm: Optional[int] = ( + int(raw_floor_ins) if isinstance(raw_floor_ins, (int, float)) + else (0 if raw_floor_ins == "NI" else None) + ) + return u_floor( + country=Country.from_code(epc.country_code) if epc.country_code else None, + age_band=main.construction_age_band, + construction=_int_or_none(ground_fd.floor_construction), + insulation_thickness_mm=floor_ins_mm, + area_m2=ground_fd.total_floor_area_m2, + perimeter_m=ground_fd.heat_loss_perimeter_m, + wall_thickness_mm=main.wall_thickness_mm, + description=getattr(main, "floors_description", None), + ) + + +def _has_suspended_timber_floor_per_spec( + epc: EpcPropertyData, +) -> tuple[bool, bool]: + """RdSAP 10 Specification §5 (page 29) — "Floor infiltration + (suspended timber ground floor only)" rule. + + Returns `(has_suspended_timber_floor, suspended_timber_floor_sealed)` + derived mechanically from the lodged cert data (per the spec's + deterministic decision tree). + + Spec text (verbatim): + Default infiltration when: + - Age band of main dwelling A to E: + a) if floor U-value is < 0.5, assume "sealed" and use floor + infiltration 0.1 + b) if floor insulation is 'retro-fitted' and no U-value is + supplied, assume "sealed" and use 0.1; + otherwise "unsealed" and use floor infiltration 0.2. + - Age band of main dwelling F to M: sealed + (the floor infiltration for the whole dwelling is determined + by the floor type of the main dwelling) + - Park home: assume unsealed suspended timber and use floor + infiltration 0.2. + + The rule only applies when the Main bp's lowest floor is a + "Ground floor" with "Suspended timber" construction. All other + combinations fall through to `(False, False)` and the cascade + enters 0 for (12). + """ + if not epc.sap_building_parts: + return False, False + main = epc.sap_building_parts[0] + # Park home short-circuit (always unsealed suspended timber per spec). + if (epc.property_type or "").strip().lower() == "park home": + return True, False + if main.floor_type != _GROUND_FLOOR_TYPE: + return False, False + if main.floor_construction_type != _SUSPENDED_TIMBER_FLOOR_TYPE: + return False, False + age = (main.construction_age_band or "").strip().upper() + if age in _AGE_BANDS_F_TO_M: + return True, True # sealed + if age in _AGE_BANDS_A_TO_E: + # (a) U-value < 0.5 → sealed + main_floor_u = _main_floor_u_value(epc) + if main_floor_u is not None and main_floor_u < _FLOOR_U_SEALED_THRESHOLD: + return True, True + # (b) retro-fitted insulation + no U-value supplied → sealed + ins_type_str = (main.floor_insulation_type_str or "").strip().lower() + u_value_known = bool(getattr(main, "floor_u_value_known", False)) + if "retro" in ins_type_str and not u_value_known: + return True, True + # otherwise → unsealed + return True, False + # Unknown age band — default to unsealed (matches the spec's general + # case for old housing stock; cohort certs have B/C bands). + return True, False + + def ventilation_from_cert( epc: EpcPropertyData, *, @@ -1564,6 +1665,22 @@ def ventilation_from_cert( {"monthly_wind_speed_m_s": postcode_climate.monthly_wind_speed_m_per_s} if postcode_climate is not None else {} ) + # RdSAP 10 §5 (12) suspended-timber floor infiltration is mechanically + # derived from age band + floor U-value + insulation type. When the + # lodgement carries an explicit value (cohort hand-built fixtures + # do, to mirror their U985 worksheet line (12) verbatim), it + # overrides the spec derivation; otherwise the spec rule applies. + spec_has_susp, spec_sealed = _has_suspended_timber_floor_per_spec(epc) + eff_has_susp = ( + bool(sv.has_suspended_timber_floor) + if sv is not None and sv.has_suspended_timber_floor is not None + else spec_has_susp + ) + eff_sealed = ( + bool(sv.suspended_timber_floor_sealed) + if sv is not None and sv.suspended_timber_floor_sealed is not None + else spec_sealed + ) return ventilation_from_inputs( volume_m3=vol, storey_count=storeys, @@ -1577,8 +1694,8 @@ def ventilation_from_cert( intermittent_fans=vc.intermittent_fans, passive_vents=vc.passive_vents, flueless_gas_fires=vc.flueless_gas_fires, - has_suspended_timber_floor=bool(sv.has_suspended_timber_floor) if sv is not None and sv.has_suspended_timber_floor is not None else False, - suspended_timber_floor_sealed=bool(sv.suspended_timber_floor_sealed) if sv is not None and sv.suspended_timber_floor_sealed is not None else False, + has_suspended_timber_floor=eff_has_susp, + suspended_timber_floor_sealed=eff_sealed, has_draught_lobby=bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False, window_pct_draught_proofed=float(epc.percent_draughtproofed or 0), sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2, diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_001479.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_001479.py index 471f65d4..ac197ceb 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_001479.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_001479.py @@ -117,6 +117,12 @@ def build_epc() -> EpcPropertyData: wall_thickness_mm=280, # Worksheet §3: 300 mm joist roof insulation → U=0.14. roof_insulation_thickness=300, + # Floor descriptive fields — required for the RdSAP 10 §5 (12) + # spec rule in `_has_suspended_timber_floor_per_spec` to recognise + # this as a "suspended timber ground floor" (cascade derives + # (12)=0.2 unsealed for age C with U=0.65 ≥ 0.5). + floor_type="Ground floor", + floor_construction_type="Suspended timber", ) ext_1 = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_1, @@ -222,7 +228,12 @@ def build_epc() -> EpcPropertyData: sap_ventilation=SapVentilation( extract_fans_count=2, sheltered_sides=1, - has_suspended_timber_floor=False, + # `has_suspended_timber_floor` left None — the cascade + # derives the §2(12) value per RdSAP 10 spec rule (cert + # 001479 Main is G+T age C with U=0.65 ≥ 0.5 → unsealed → + # (12)=0.2). The lodged sap_ventilation block previously + # encoded the worksheet's (12) value directly via this + # boolean; the cascade now reproduces it mechanically. has_draught_lobby=False, ), sap_heating=make_sap_heating(