Slice 87: implement RdSAP 10 §5 (12) spec rule for suspended timber floor

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-25 20:29:54 +00:00
parent 2d3355ee48
commit aff331ff34
3 changed files with 143 additions and 32 deletions

View file

@ -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 RR vs 15.28 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)
),
)

View file

@ -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,

View file

@ -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(