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
1b3bbbf783
commit
6d02d205c5
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:
|
def _responsiveness(main: Optional[MainHeatingDetail]) -> float:
|
||||||
"""SAP 10.2 Table 9b responsiveness R ∈ [0, 1]. Radiators ≈ 1.0;
|
"""SAP 10.2 Table 4d (PDF p.170) heat-emitter responsiveness R ∈ [0, 1].
|
||||||
underfloor ≈ 0.25. Defaults to radiators."""
|
|
||||||
|
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:
|
if main is None:
|
||||||
return 1.0
|
return 1.0
|
||||||
emitter = main.heat_emitter_type
|
emitter = main.heat_emitter_type
|
||||||
if isinstance(emitter, int) and emitter == 2:
|
if not emitter:
|
||||||
return 0.25
|
return 1.0
|
||||||
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]:
|
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 (
|
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||||
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
|
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
|
||||||
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
|
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
|
||||||
|
_responsiveness, # pyright: ignore[reportPrivateUsage]
|
||||||
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
|
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
|
||||||
UnmappedSapCode,
|
UnmappedSapCode,
|
||||||
cert_to_demand_inputs,
|
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
|
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:
|
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
|
# Arrange — distinguish "lodging absent" (cert didn't lodge a control
|
||||||
# code at all → cascade default OK) from "lodging present but unmapped"
|
# 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=[
|
main_heating_details=[
|
||||||
MainHeatingDetail(
|
MainHeatingDetail(
|
||||||
has_fghrs=False, main_fuel_type=26, heat_emitter_type=1,
|
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,
|
main_heating_category=2, sap_main_heating_code=102,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue