mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
2cffa926fb
commit
089e6ac9da
2 changed files with 152 additions and 6 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue