Derive wet-underfloor responsiveness from RdSAP Table 29 subtype 🟩

Wet underfloor responsiveness was pinned at R=0.75 for the lumped gov-EPC emitter
code 2, regardless of floor construction. SAP 10.2 Table 4d splits it three ways
and RdSAP 10 Table 29 (p.56-57) selects from floor construction + age band:
timber floor 1.0, screed above insulation 0.75, concrete slab 0.25. A
concrete-slab system (solid floor, age A-E) was billed as screed, over-stating
responsiveness (higher MIT) and over-rating the dwelling; R=0.25 was unreachable.

_responsiveness now takes the cert and, for the lumped underfloor code, derives
the Table 29 subtype via _underfloor_responsiveness from the main part's floor
construction + age band: suspended TIMBER -> 1.0; solid A-E -> 0.25 (concrete
slab); solid F-M / suspended-not-timber / unknown / no ground floor -> 0.75
(screed, the prior default, so ambiguous no-ground-floor certs do not move). The
mapper stays a pass-through.

Spec: SAP 10.2 Table 4d (p.170), RdSAP 10 Table 29 (p.56-57). Corpus: MAE
0.570 -> 0.565 (net toward lodged), within-0.5 81.2%. Cert 100100137438 (solid,
band B) 72 -> 69, exactly lodged. Closes #1668 on the API path.

NOTE: the issue's worksheet adjudication (run 100100137438 through accredited
Elmhurst to confirm concrete-slab R=0.25) is still outstanding — it corrects to
lodged 69 exactly, but is not yet Elmhurst-worksheet-confirmed. The Elmhurst
site-notes path (_resolve_elmhurst_underfloor_subtype still raises for solid A-E)
is untouched — no corpus/fixture exercises it; separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-22 13:29:10 +00:00
parent 8bf7bd116d
commit 1b742f41aa
2 changed files with 131 additions and 6 deletions

View file

@ -2141,9 +2141,52 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int:
raise UnmappedSapCode("main_heating_control", code)
# RdSAP 10 Table 29 (PDF p.56-57): a solid floor in age bands A-E carries no
# insulation layer, so wet underfloor pipes sit in a bare concrete slab (SAP 10.2
# Table 4d R=0.25). F-M solid floors carry insulation → screed above (0.75). #1668
_CONCRETE_SLAB_UNDERFLOOR_BANDS: Final[frozenset[str]] = frozenset("ABCDE")
# Lumped gov-EPC "underfloor" heat-emitter code — carries no screed/timber/slab
# subtype; the subtype is derived from floor construction + age band per Table 29.
_UNDERFLOOR_EMITTER_CODE: Final[int] = 2
def _underfloor_responsiveness(
floor_description: Optional[str], age_band: Optional[str]
) -> float:
"""SAP 10.2 Table 4d underfloor responsiveness selected via RdSAP 10 Table 29
from the main part's floor construction + age band (#1668):
- suspended TIMBER floor 1.0 (fully responsive timber-floor UFH)
- solid floor, age A-E 0.25 (bare concrete slab, no insulation)
- solid F-M / suspended-not-timber / unknown / no ground floor
0.75 (screed above insulation the prior
default, so ambiguous certs do not move).
"""
desc = (floor_description or "").lower()
is_timber = "timber" in desc and "not timber" not in desc
if is_timber:
return 1.0
band = (age_band or "").strip().upper()[:1]
if "solid" in desc and band in _CONCRETE_SLAB_UNDERFLOOR_BANDS:
return 0.25
return 0.75
def _main_underfloor_floor_context(
epc: Optional[EpcPropertyData],
) -> tuple[Optional[str], Optional[str]]:
"""(floor construction description, age band) of the main building part —
the Table 29 discriminator for the underfloor subtype in `_responsiveness`."""
if epc is None or not epc.sap_building_parts:
return (None, None)
main_bp = epc.sap_building_parts[0]
return (_effective_floor_description(epc, main_bp), main_bp.construction_age_band)
def _responsiveness(
main: Optional[MainHeatingDetail],
tariff: Optional[Tariff] = None,
epc: Optional[EpcPropertyData] = None,
) -> float:
"""SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271:
@ -2183,9 +2226,10 @@ def _responsiveness(
4 = Warm air R = 1.0
5 = Fan coils R = 1.0
"Concrete slab" UFH (Table 4d R=0.25) has no cert-side enum entry
yet that variant would need a new mapper code before the cascade
can dispatch it.
The lumped underfloor code 2 carries no screed/timber/concrete-slab
subtype; its Table 4d responsiveness (0.25 / 0.75 / 1.0) is derived
from the main part's floor construction + age band per RdSAP 10
Table 29 via `_underfloor_responsiveness` (needs `epc`; #1668).
Strict-dispatch per [[reference-unmapped-sap-code]]: absent lodging
(None / 0 / "") returns modal default R=1.0 (radiators); lodging
@ -2210,6 +2254,9 @@ def _responsiveness(
emitter = main.heat_emitter_type
if not emitter:
return 1.0
if emitter == _UNDERFLOOR_EMITTER_CODE:
floor_description, age_band = _main_underfloor_floor_context(epc)
return _underfloor_responsiveness(floor_description, age_band)
if isinstance(emitter, int) and emitter in _RESPONSIVENESS_BY_EMITTER_CODE:
return _RESPONSIVENESS_BY_EMITTER_CODE[emitter]
raise UnmappedSapCode("heat_emitter_type", emitter)
@ -4958,7 +5005,7 @@ def mean_internal_temperature_section_from_cert(
thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc),
total_floor_area_m2=dim.total_floor_area_m2,
control_type=_control_type(main),
responsiveness=_responsiveness(main, tariff=tariff),
responsiveness=_responsiveness(main, tariff=tariff, epc=epc),
living_area_fraction=_living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2
),
@ -8255,7 +8302,7 @@ def cert_to_inputs(
# for the Elmhurst corpus (cert-side mapping is a future slice).
control_type_value = _control_type(main)
_mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type)
responsiveness_value = _responsiveness(main, tariff=_mit_tariff)
responsiveness_value = _responsiveness(main, tariff=_mit_tariff, epc=epc)
living_area_fraction_value = _living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2
)
@ -8279,7 +8326,7 @@ def cert_to_inputs(
main_2_control_type_value = _control_type(_mit_main_2)
main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0
main_2_responsiveness_value = _responsiveness(
_mit_main_2, tariff=_mit_tariff
_mit_main_2, tariff=_mit_tariff, epc=epc
)
monthly_total_gains_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)

View file

@ -0,0 +1,78 @@
"""#1668 — wet underfloor responsiveness was pinned at R=0.75 for the lumped
gov-EPC emitter code 2, regardless of floor construction. SAP 10.2 Table 4d
splits it three ways and RdSAP 10 Table 29 (p.56-57) selects which from floor
construction + age band: insulated timber floor R=1.0, screed above insulation
R=0.75, concrete slab R=0.25. A concrete-slab system (solid floor, age A-E) was
billed as screed (0.75), over-stating responsiveness and over-rating the
dwelling. The calculator must derive the Table 29 subtype from the main part's
floor construction + age band.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import Sap10Calculator
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_underfloor_responsiveness, # pyright: ignore[reportPrivateUsage]
)
_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
def _corpus_cert(uprn: str) -> dict[str, Any]:
for line in _CORPUS.read_text().splitlines():
if uprn in line:
cert: dict[str, Any] = json.loads(line)
return cert
raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus")
def test_solid_floor_age_a_to_e_is_a_concrete_slab_r_0p25() -> None:
# Arrange / Act / Assert — RdSAP 10 Table 29: a solid floor in age bands A-E
# has no insulation layer, so the underfloor pipes sit in a concrete slab.
assert _underfloor_responsiveness("Solid", "B") == 0.25
assert _underfloor_responsiveness("Solid", "E") == 0.25
def test_solid_floor_age_f_onwards_is_screed_r_0p75() -> None:
# Arrange / Act / Assert — newer solid floors carry insulation, so the pipes
# are in screed above it (0.75), not a bare slab.
assert _underfloor_responsiveness("Solid", "F") == 0.75
assert _underfloor_responsiveness("Solid", "M") == 0.75
def test_suspended_timber_floor_is_r_1p0() -> None:
# Arrange / Act / Assert — a timber-floor underfloor system is fully
# responsive (Table 4d timber floor = 1.0).
assert _underfloor_responsiveness("Suspended timber", "D") == 1.0
def test_suspended_not_timber_is_screed_r_0p75_not_timber() -> None:
# Arrange / Act / Assert — "Suspended, not timber" must NOT be read as timber
# (the substring trap); it falls to the screed default 0.75.
assert _underfloor_responsiveness("Suspended, not timber", "C") == 0.75
def test_unknown_or_absent_floor_defaults_to_screed_r_0p75() -> None:
# Arrange / Act / Assert — a main part with no ground floor / unlodged
# construction keeps the prior 0.75, so ambiguous certs do not move.
assert _underfloor_responsiveness(None, "A") == 0.75
assert _underfloor_responsiveness("", None) == 0.75
def test_concrete_slab_cert_corrects_to_lodged() -> None:
# Arrange — cert 100100137438 (solid floor, band B, wet underfloor) over-rated
# at 72 on HEAD via the pinned 0.75; lodged 69.
payload = _corpus_cert("100100137438")
epc = EpcPropertyDataMapper.from_api_response(payload)
assert epc is not None
# Act
sap = Sap10Calculator().calculate(epc).sap_score
# Assert — Table 29 concrete-slab R=0.25 corrects it to exactly lodged 69.
assert sap == 69