Slice S0380.89: Table 4d responsiveness dispatch + screed-UFH bug fix + strict raise

SAP 10.2 Table 4d (PDF p.170) "Heating type and responsiveness ...
depending on heat emitter":

  Heat emitter                                        R
  -------------------------------------------------   -----
  Systems with radiators                              1.0
  Underfloor (wet) — pipes in insulated timber floor  1.0
  Underfloor (wet) — pipes in screed above insulation 0.75
  Underfloor (wet) — pipes in concrete slab           0.25
  Warm air via fan coil units                         1.0

Pre-S0380.89 the cascade `_responsiveness` had:

    if isinstance(emitter, int) and emitter == 2:
        return 0.25
    return 1.0

But the Elmhurst cert-side enum (`_ELMHURST_HEAT_EMITTER_TO_SAP10` at
`datatypes/epc/domain/mapper.py:3646`) maps:

    1 = Radiators
    2 = Underfloor (in screed)         ← spec R=0.75, NOT 0.25
    3 = Underfloor (timber floor)
    4 = Warm air
    5 = Fan coils

The cascade silently treated screed UFH (Elmhurst code 2) as
concrete-slab UFH (R=0.25 — Table 4d's most thermally massive UFH
variant). The bug halved the actual spec responsiveness — for a screed
UFH cert, off-period temperature reduction was computed with R=0.25
instead of R=0.75, materially under-counting MIT drop and over-counting
SH demand.

The bug is latent on cohort + golden because `_first_main_heating`
picks main[0] and almost all certs lodge radiators (emitter=1) there.
Corpus audit (full JSON sweep): emitter=2 appears on 2 records and
in both cases on a secondary main slot (e.g. golden cert
0240-0200-5706-2365-8010 main[1]) — never on the selected first main.
The fix preempts the next cert that lodges screed UFH on main[0].

Fix:

  - New `_RESPONSIVENESS_BY_EMITTER_CODE` dispatch dict reflecting
    Table 4d per the Elmhurst cert-side enum (1: 1.0, 2: 0.75, 3: 1.0,
    4: 1.0, 5: 1.0). "Concrete slab UFH" (Table 4d R=0.25) has no
    cert-side enum entry — that variant would need a new mapper code
    before the cascade can dispatch it.
  - `_responsiveness` flipped to strict-raise per [[reference-
    unmapped-sap-code]]: absent lodging (None / 0 / "") returns modal
    R=1.0 default; lodging present but unmapped raises
    `UnmappedSapCode("heat_emitter_type", value)`.

Tests (4 new, AAA-structure):

  - `test_heat_emitter_code_2_underfloor_in_screed_routes_to_responsiveness_0p75_per_table_4d`
    pins the bug fix: emitter=2 → R=0.75 (was 0.25)
  - `test_heat_emitter_code_dispatch_table_4d_full_coverage`
    pins all 5 Elmhurst emitter codes to spec-correct R
  - `test_responsiveness_raises_unmapped_sap_code_on_unknown_emitter`
    pins the strict-raise contract (hypothetical code 99 raises)
  - `test_responsiveness_default_1p0_when_emitter_lodging_absent`
    pins the absent-lodging contract (None / 0 / "" → 1.0)

Test baseline: 568 pass (was 564 + 4 new) + 9 expected
`test_sap_result_pin[000565-*]` fails unchanged. Cohort + golden
unaffected (all use emitter=1 on main[0]).

Pyright net-zero per touched file (one `pyright: ignore` added on the
absent-lodging test where `main_heating_control=None` is passed to a
dataclass declaring `Union[int, str]` — runtime data exhibits None
on certs lacking space-heating controls, so the test covers a real
codepath the type system doesn't model).

Per the user-requested "we keep debugging silent fallbacks" mandate,
this is the second slice (after S0380.88) in the strict-raise series.
Next candidates per [[reference-unmapped-sap-code]]: PV pitch +
overshading, meter→tariff, heat-network DLF, secondary-heating
fraction by category.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 09:21:37 +00:00 committed by Jun-te Kim
parent 2cffa926fb
commit 089e6ac9da
2 changed files with 152 additions and 6 deletions

View file

@ -842,14 +842,49 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int:
def _responsiveness(main: Optional[MainHeatingDetail]) -> float:
"""SAP 10.2 Table 9b responsiveness R ∈ [0, 1]. Radiators ≈ 1.0;
underfloor 0.25. Defaults to radiators."""
"""SAP 10.2 Table 4d (PDF p.170) heat-emitter responsiveness R ∈ [0, 1].
Cert-side heat_emitter_type enum (per `_ELMHURST_HEAT_EMITTER_TO_SAP10`
at datatypes/epc/domain/mapper.py:3646):
1 = Radiators R = 1.0
2 = Underfloor (in screed above insulation) R = 0.75
3 = Underfloor (timber floor) R = 1.0
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.
Strict-dispatch per [[reference-unmapped-sap-code]]: absent lodging
(None / 0 / "") returns modal default R=1.0 (radiators); lodging
present but unmapped raises `UnmappedSapCode` so the spec-coverage
gap surfaces at test time.
"""
if main is None:
return 1.0
emitter = main.heat_emitter_type
if isinstance(emitter, int) and emitter == 2:
return 0.25
return 1.0
if not emitter:
return 1.0
if isinstance(emitter, int) and emitter in _RESPONSIVENESS_BY_EMITTER_CODE:
return _RESPONSIVENESS_BY_EMITTER_CODE[emitter]
raise UnmappedSapCode("heat_emitter_type", emitter)
# SAP 10.2 Table 4d (PDF p.170) — heat-emitter responsiveness R.
# Keyed on the Elmhurst-mapper cert-side integer enum (mirrored by the
# API mapper which passes the integer through directly). Pre-S0380.89
# the cascade had `if emitter == 2: return 0.25` — silently mis-treating
# screed UFH (spec R=0.75) as concrete-slab UFH (spec R=0.25). The
# spec R-table is keyed on physical emitter category, not on a single
# "underfloor" lumping.
_RESPONSIVENESS_BY_EMITTER_CODE: Final[dict[int, float]] = {
1: 1.0, # Radiators
2: 0.75, # Underfloor (in screed above insulation)
3: 1.0, # Underfloor (timber floor)
4: 1.0, # Warm air
5: 1.0, # Fan coils
}
def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:

View file

@ -35,6 +35,7 @@ from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
_responsiveness, # pyright: ignore[reportPrivateUsage]
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
UnmappedSapCode,
cert_to_demand_inputs,
@ -903,6 +904,115 @@ def test_cert_to_inputs_raises_unmapped_sap_code_on_unknown_main_heating_control
assert excinfo.value.value == 2998
def test_heat_emitter_code_2_underfloor_in_screed_routes_to_responsiveness_0p75_per_table_4d() -> None:
# Arrange — SAP 10.2 Table 4d (PDF p.170, "Heating type and
# responsiveness ... depending on heat emitter"):
#
# Underfloor heating (wet system):
# pipes in insulated timber floor R = 1.0
# pipes in screed above insulation R = 0.75 ← Elmhurst code 2
# pipes in concrete slab R = 0.25
#
# The Elmhurst mapper at `_ELMHURST_HEAT_EMITTER_TO_SAP10` defines
# the cert-side integer enum: 1=Radiators, 2=Underfloor (in screed),
# 3=Underfloor (timber floor), 4=Warm air, 5=Fan coils. Pre-S0380.89
# `_responsiveness` had `if emitter == 2: return 0.25` — silently
# treating screed UFH as concrete-slab UFH (R=0.25 instead of the
# spec-correct R=0.75). Underfloor in screed has 3× the
# responsiveness the cascade was assigning it.
#
# The bug is mostly latent because (a) `_first_main_heating` picks
# main[0] and most certs lodge radiators there, (b) emitter=2 only
# appears on main[1] in the corpus (e.g. golden cert
# 0240-0200-5706-2365-8010 — but that cert's main[0] is emitter=1
# so the bug doesn't surface). Fixing it preempts the next cert that
# legitimately lodges screed UFH on main[0].
main = MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=2,
emitter_temperature=1, main_heating_control=2106,
main_heating_category=2, sap_main_heating_code=102,
)
# Act
result = _responsiveness(main)
# Assert — SAP 10.2 Table 4d "pipes in screed above insulation" R=0.75
assert abs(result - 0.75) <= 1e-9
def test_heat_emitter_code_dispatch_table_4d_full_coverage() -> None:
# Arrange — SAP 10.2 Table 4d responsiveness by Elmhurst-mapper
# heat_emitter_type code (per `_ELMHURST_HEAT_EMITTER_TO_SAP10` at
# datatypes/epc/domain/mapper.py:3646):
#
# Code Emitter type R
# ---- ---------------------------- ----
# 1 Radiators 1.0
# 2 Underfloor (in screed) 0.75
# 3 Underfloor (timber floor) 1.0
# 4 Warm air 1.0
# 5 Fan coils 1.0
#
# No Elmhurst code maps to "concrete slab UFH" (R=0.25 per Table 4d).
# That category would need a new cert-side enum entry first.
def _main_with(emitter_code: int) -> MainHeatingDetail:
return MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=emitter_code,
emitter_temperature=1, main_heating_control=2106,
main_heating_category=2, sap_main_heating_code=102,
)
# Act / Assert
assert abs(_responsiveness(_main_with(1)) - 1.0) <= 1e-9
assert abs(_responsiveness(_main_with(2)) - 0.75) <= 1e-9
assert abs(_responsiveness(_main_with(3)) - 1.0) <= 1e-9
assert abs(_responsiveness(_main_with(4)) - 1.0) <= 1e-9
assert abs(_responsiveness(_main_with(5)) - 1.0) <= 1e-9
def test_responsiveness_raises_unmapped_sap_code_on_unknown_emitter() -> None:
# Arrange — same strict-raise contract as `_control_type` per
# [[reference-unmapped-sap-code]]: lodging present but unmapped
# raises so the spec-coverage gap surfaces at test time. A code
# outside the dispatch dict (e.g. hypothetical code 9 = "Solid
# fuel stove" or future enum value) must raise, not silently
# return the radiators default.
main = MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=99, # not in dispatch
emitter_temperature=1, main_heating_control=2106,
main_heating_category=2, sap_main_heating_code=102,
)
# Act / Assert
with pytest.raises(UnmappedSapCode) as excinfo:
_responsiveness(main)
assert excinfo.value.field == "heat_emitter_type"
assert excinfo.value.value == 99
def test_responsiveness_default_1p0_when_emitter_lodging_absent() -> None:
# Arrange — emitter lodging absent (None / 0 / "") returns modal
# default R=1.0 (radiators). Corpus has 4 certs lodging emitter=0
# as the "no main heating system present" sentinel. Mirror of the
# control-type absent-lodging contract from S0380.88.
def _main_with(emitter_value: object) -> MainHeatingDetail:
return MainHeatingDetail(
has_fghrs=False, main_fuel_type=26,
heat_emitter_type=emitter_value, # type: ignore[arg-type]
emitter_temperature=1, main_heating_control=2106,
main_heating_category=2, sap_main_heating_code=102,
)
# Act / Assert
assert _responsiveness(None) == 1.0
assert _responsiveness(_main_with(0)) == 1.0
assert _responsiveness(_main_with("")) == 1.0
assert _responsiveness(_main_with(None)) == 1.0
def test_cert_to_inputs_does_not_raise_when_main_heating_control_is_missing() -> None:
# Arrange — distinguish "lodging absent" (cert didn't lodge a control
# code at all → cascade default OK) from "lodging present but unmapped"
@ -921,7 +1031,8 @@ def test_cert_to_inputs_does_not_raise_when_main_heating_control_is_missing() ->
main_heating_details=[
MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=None,
emitter_temperature=1,
main_heating_control=None, # pyright: ignore[reportArgumentType]
main_heating_category=2, sap_main_heating_code=102,
),
],