mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
parent
8bf7bd116d
commit
1b742f41aa
2 changed files with 131 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue