mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
2d3355ee48
commit
aff331ff34
3 changed files with 143 additions and 32 deletions
|
|
@ -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)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue