diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index ecb72917c..0f9df93ef 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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) diff --git a/tests/domain/sap10_calculator/rdsap/test_underfloor_responsiveness.py b/tests/domain/sap10_calculator/rdsap/test_underfloor_responsiveness.py new file mode 100644 index 000000000..ba1ea77f6 --- /dev/null +++ b/tests/domain/sap10_calculator/rdsap/test_underfloor_responsiveness.py @@ -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