Slice S0380.126: resolve Elmhurst bare "Underfloor Heating" via RdSAP 10 §10.11

Elmhurst Summary §14.0 Main Heating1 sometimes lodges the bare form
"Heat Emitter: Underfloor Heating" without a subtype qualifier (in
screed / timber floor). The mapper's `_ELMHURST_HEAT_EMITTER_TO_SAP10`
dict only carried the qualified forms, so the bare lodging fell through
to None and was passed as a raw string into `MainHeatingDetail.heat_
emitter_type` — causing `_responsiveness` to strict-raise
`UnmappedSapCode` on every cert with this lodging (2 variants on the
heating-systems corpus: `electric 1` + `oil 6`).

Per RdSAP 10 Specification §10.11 Table 29 page 56 ("Heating and hot
water parameters"):

  > "Underfloor heating: If dwelling has a ground floor, then according
  >  to the floor construction (see Table 19 if unknown):
  >    - solid, main property age band A to E: concrete slab
  >    - solid, main property age band F to M: in screed
  >    - suspended timber: in timber floor
  >    - suspended, not timber: in screed
  >  Otherwise (i.e. upper floor flats), take floor as suspended"

New helper `_resolve_elmhurst_underfloor_subtype` keys off the main BP's
`floor.floor_type` + `construction_age_band` and returns:

  - SAP10.2 Table 4d emitter code 2 (in screed) → R=0.75 — for
    solid + age F-M, suspended-not-timber, and upper-floor-flat cases
  - SAP10.2 Table 4d emitter code 3 (timber floor) → R=1.0 — for
    suspended-timber

The solid + age A-E "concrete slab" branch (R=0.25) has no cert-side
enum entry yet, so the helper strict-raises `UnmappedElmhurstLabel`
when that combination lands — the next variant lodging an A-E solid
underfloor will surface the gap loudly per
[[reference-unmapped-sap-code]].

Property 001431 (the heating-systems corpus dwelling) lodges §9.0
"Type: S Solid" + §3.0 "Date Built: G 1983-1990" (age band G ∈ F-M)
→ "in screed" → code 2 → R=0.75. Both `electric 1` and `oil 6` now
cascade-execute (corpus tally 32 → 34 OK / 41 populated).

Extended handover suite at HEAD post-slice: **830 pass, 0 fail**
(was 829 + 1 new AAA test).

Pyright net-zero on touched files (45 → 45 — pre-existing errors
unrelated).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 00:00:36 +00:00 committed by Jun-te Kim
parent a24a211ee3
commit e628f807b5
2 changed files with 115 additions and 3 deletions

View file

@ -236,6 +236,44 @@ def test_summary_001479_mapper_extensions_count_matches_extension_bps() -> None:
assert len(epc.sap_building_parts) == 3
def test_summary_001431_electric_1_underfloor_heating_resolves_to_in_screed_per_rdsap_10_section_10_11() -> None:
# Arrange — Heating-systems corpus fixture 001431 / "electric 1" lodges
# §14.0 "Heat Emitter: Underfloor Heating" (bare form, no subtype
# qualifier). Per RdSAP 10 Specification §10.11 Table 29 page 56
# ("Heating and hot water parameters"):
#
# "Underfloor heating: If dwelling has a ground floor, then
# according to the floor construction (see Table 19 if unknown):
# - solid, main property age band A to E: concrete slab
# - solid, main property age band F to M: in screed
# - suspended timber: in timber floor
# - suspended, not timber: in screed"
#
# Property 001431 lodges §9.0 Floors as "Type: S Solid" + §3.0 Date
# Built "G 1983-1990" (age band G ∈ F-M), so the spec rule resolves
# to "in screed" → SAP10.2 Table 4d emitter enum 2 (R=0.75).
#
# Pre-slice the Elmhurst mapper passed the raw "Underfloor Heating"
# string through `_elmhurst_heat_emitter_int`'s `dict.get` (which
# returned None for the bare lodging) and then through to the
# MainHeatingDetail's `heat_emitter_type` field, which made the
# cascade strict-raise at `_responsiveness` for any of the 2
# corpus variants lodging this form (`electric 1` + `oil 6`).
summary_pdf = (
Path(__file__).parents[3]
/ "sap worksheets/heating systems examples/electric 1/Summary_001431.pdf"
)
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert
main_1 = epc.sap_heating.main_heating_details[0]
assert main_1.heat_emitter_type == 2
def test_summary_001479_main_party_wall_construction_is_cavity_unfilled() -> None:
# Arrange — cert 001479 Main §7 Walls lodges "Party Wall Type: CU
# Cavity masonry unfilled". The Elmhurst leading-code map previously

View file

@ -3836,8 +3836,78 @@ def _elmhurst_main_fuel_int(fuel_type: str) -> Optional[int]:
return _ELMHURST_MAIN_FUEL_TO_SAP10.get(fuel_type)
def _elmhurst_heat_emitter_int(emitter: str) -> Optional[int]:
return _ELMHURST_HEAT_EMITTER_TO_SAP10.get(emitter)
def _resolve_elmhurst_underfloor_subtype(
main_floor: ElmhurstFloorDetails,
main_age_band: str,
) -> int:
"""RdSAP 10 Specification §10.11 Table 29 page 56 — derive the
underfloor-heating subtype from the main BP's floor construction +
age band when the Elmhurst Summary §14.0 lodges the bare
"Underfloor Heating" lodging form (no subtype qualifier).
Spec rule verbatim:
"Underfloor heating: If dwelling has a ground floor, then according
to the floor construction (see Table 19 if unknown):
- solid, main property age band A to E: concrete slab
- solid, main property age band F to M: in screed
- suspended timber: in timber floor
- suspended, not timber: in screed
Otherwise (i.e. upper floor flats), take floor as suspended"
Returns the SAP10.2 Table 4d emitter int code:
2 = Underfloor (in screed above insulation) R=0.75
3 = Underfloor (timber floor) R=1.0
The "concrete slab" branch (R=0.25 per Table 4d) has no cert-side
enum entry yet strict-raise per [[reference-unmapped-sap-code]]
so a future age A-E + solid + underfloor fixture surfaces the gap
loudly instead of silently routing through `in screed`.
"""
floor_type = main_floor.floor_type
age_letter = main_age_band[0] if main_age_band else ""
is_solid = floor_type.startswith("S Solid") or floor_type == "Solid"
is_suspended_timber = (
floor_type.startswith("T Suspended timber")
or floor_type == "Suspended timber"
)
is_suspended_not_timber = (
floor_type.startswith("N Suspended, not timber")
or floor_type == "Suspended, not timber"
)
if is_solid:
if age_letter in {"A", "B", "C", "D", "E"}:
raise UnmappedElmhurstLabel(
"main_heating.heat_emitter",
(
f"Underfloor heating on solid floor + age band "
f"{main_age_band!r} resolves to 'concrete slab' per "
f"RdSAP 10 §10.11 Table 29 (p.56) — SAP10.2 Table 4d "
f"R=0.25 — but no cert-side enum entry exists yet"
),
)
return 2 # solid + F-M → in screed
if is_suspended_timber:
return 3 # suspended timber → in timber floor
if is_suspended_not_timber:
return 2 # suspended not timber → in screed
# Upper-floor flat (no ground floor) → "take floor as suspended"
# → in screed per spec (defaults to suspended-not-timber side).
return 2
def _elmhurst_heat_emitter_int(
emitter: str,
main_floor: Optional[ElmhurstFloorDetails] = None,
main_age_band: Optional[str] = None,
) -> Optional[int]:
if emitter in _ELMHURST_HEAT_EMITTER_TO_SAP10:
return _ELMHURST_HEAT_EMITTER_TO_SAP10[emitter]
# RdSAP 10 §10.11 Table 29 (p.56): bare "Underfloor heating" lodging
# derives subtype from main floor construction + age band.
if emitter == "Underfloor Heating" and main_floor is not None and main_age_band is not None:
return _resolve_elmhurst_underfloor_subtype(main_floor, main_age_band)
return None
# Elmhurst boiler flue-type strings → SAP10 integer codes. Codes mirror
@ -4294,7 +4364,11 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
and mh.main_heating_sap_code in _ELECTRIC_SAP_MAIN_HEATING_CODES
):
main_fuel_int = _STANDARD_ELECTRICITY_FUEL_CODE
heat_emitter_int = _elmhurst_heat_emitter_int(mh.heat_emitter)
heat_emitter_int = _elmhurst_heat_emitter_int(
mh.heat_emitter,
main_floor=survey.floor,
main_age_band=survey.construction_age_band,
)
sap_control_int = _elmhurst_sap_control_code(sap_control)
main_heating_category = _elmhurst_main_heating_category(mh, pcdb_index)
# Strict-raise mirror of [[unmapped-api-code]] — when Main 1 has