Slice S0380.43: SAP 631 open-fire → House coal spec fuel — closes cert 2102

Cert 2102 lodges `secondary_heating_type=631` ("Open fire in grate"
per SAP 10.2 Appendix M Table 4a, BS EN 13229:2001 inset-appliance
class — solid fuel) but `secondary_fuel_type=33` (electricity, Table 32
off-peak 7hr) — physically incompatible (an open fire grate doesn't
run on electricity). The Elmhurst Summary path independently resolves
to Coal (Table 32 code 11) via the §15 "Secondary Fuel: Coal" lodgement
(see `test_summary_2102_secondary_heating_routes_house_coal_for_open_fire`).

API mapper now applies the same spec-derived default via the new
`_api_secondary_fuel_type` helper:

  - When `secondary_heating_type` is in the
    `_API_SECONDARY_HEATING_SPEC_FUEL` dispatch (currently {631: 11}),
    AND the lodged `secondary_fuel_type` is electric (codes 30-40),
    substitute the spec default (House coal).
  - Legitimate non-default solid-fuel lodgement (e.g. SAP 631 with
    lodged fuel_type=15 Wood logs) passes through unchanged.

The override is keyed on the heating-type → spec-fuel dispatch dict
(extend as new fixtures surface analogous inconsistencies), not a
blanket per-code rewrite — keeps the lodged data trusted by default
while spec-correcting the narrow class of inconsistent lodgements.

Applied at all 6 API schema-version mapping sites in `from_api_response`
via replace_all (lines 637/767/922/1080/1278/1544). Worksheet target
for cert 2102: line (242) "Space heating - secondary 3585.24 × 3.6700
= 131.58" confirms 3.67 p/kWh = Table 32 fuel code 11 (House coal).

Test impact:
  - Cohort-2 cert 2102 API path: -6.30 → +4.9e-5 (<1e-4 ✓).
    Moves from `_COHORT_2_API_OPEN` to `_COHORT_2_API_CLOSED`.
  - `_COHORT_2_API_OPEN` is now empty — the residual-pin test
    `test_api_cohort_2_open_cert_residual_matches_current_pin` is
    deleted (cohort fully closed; re-add if future cert surfaces).
  - Cohort-2 API path: **38/38 < 1e-4** matching Summary path 38/38.
    Cross-mapper parity at the cascade is fully established for
    cohort-2 per [[feedback-cross-mapper-parity-via-cascade]].
  - Cohort-1 ASHP 9/9 unchanged.

Test suite: 750 pass + 0 fail. Pyright net-zero on touched files
(mapper.py 32/32 baseline; chain test 0/0).

Spec citations:
  - SAP 10.2 Appendix M Table 4a code 631 "Open fire in grate"
    (Category C, Room heaters, eff 37/32%, solid fuel via BS EN
    13229:2001 inset-appliance class — see spec p.156).
  - SAP 10.2 Table 32 code 11 "House coal" 3.67 p/kWh.
  - Cert 2102 worksheet line (242) reproduces 131.58 = 35.84 × 3.67
    confirming house-coal pricing for the secondary cascade.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 17:17:05 +00:00 committed by Jun-te Kim
parent ae2303b775
commit 276e435e6c
2 changed files with 78 additions and 60 deletions

View file

@ -1884,9 +1884,15 @@ def test_api_9418_full_chain_sap_within_spec_floor_of_worksheet() -> None:
# `domain/sap10_calculator/rdsap/tests/fixtures/golden/<cert>.json` in
# Slice S0380.39). Worksheet SAP is the source of truth.
#
# At HEAD of Slice S0380.40: 34/38 certs hit 1e-4 immediately; the
# remaining 4 are residual-pinned below as forcing functions for the
# next per-cert closure slices (Slice C+).
# Cohort-2 API-path closure history (each slice closed a distinct
# spec-citation gap, then re-pinned the cohort):
# S0380.40 — parametrized over all 38 certs; 34 immediate / 4 open
# S0380.41 — RdSAP 21 → SAP 10.2 glazing-type alias closed 0300/9380
# S0380.42 — Decimal HALF_UP per-window areas closed 1536
# S0380.43 — SAP 631 → spec fuel (House coal) closed 2102
# At HEAD: 38/38 cohort-2 certs hit <1e-4 on the API path, matching
# the Summary-path sweep (also 38/38 <1e-4 at HEAD). Cross-mapper
# parity at the cascade is fully established.
_COHORT_2_API_FIXTURE_DIR: Path = (
Path(__file__).parents[3]
@ -1915,6 +1921,7 @@ _COHORT_2_API_CLOSED: list[tuple[str, float]] = [
("1536-9325-5100-0433-1226", 65.8928), # S0380.42 closure
("2007-3011-9205-8136-3204", 68.3914),
("2031-3007-0205-1296-3204", 64.1734),
("2102-3018-0205-7886-5204", 63.8732), # S0380.43 closure
("2130-3018-4205-4686-5204", 71.3158),
("2336-3124-3600-0517-1292", 83.4955),
("2536-2525-0600-0788-2292", 79.7264),
@ -1938,31 +1945,6 @@ _COHORT_2_API_CLOSED: list[tuple[str, float]] = [
("9836-7525-9500-0575-1202", 75.2223),
]
# (cert_dir, worksheet_unrounded_sap, current_cascade_continuous_sap)
# — 4 cohort-2 certs whose API-path cascade does NOT yet hit the
# worksheet at 1e-4. The third tuple element is the cascade's current
# `sap_score_continuous` at HEAD of Slice S0380.40, pinned at abs <=
# 1e-4 as a forcing function: when a follow-up slice closes the
# residual, the cascade output moves and this assertion fires, forcing
# the cert to migrate from `_COHORT_2_API_OPEN` to `_COHORT_2_API_CLOSED`.
#
# Cluster diagnosis (handover to next agent):
# - 0300/1536/9380: ws Δ = +0.42..+0.44, tight 0.02-band cluster
# — likely a single shared cascade-spec gap (heating/cooling
# dispatch or RdSAP fuel-factor cascade). Summary path hits 1e-4
# on all three, so the gap is API-mapper-specific (a field the
# Summary mapper surfaces and the API mapper drops or mis-routes).
# - 2102: ws Δ = -6.30, two orders of magnitude worse. Summary path
# hits 1e-4 (cohort-2 Summary sweep is 38/38). The Summary test
# `test_summary_2102_secondary_heating_routes_house_coal_for_open_fire`
# covers the cert's open-fire + house-coal secondary heating; the
# API mapper likely lodges the secondary fuel differently. Probe
# the API JSON's `secondary_heating` block first.
_COHORT_2_API_OPEN: list[tuple[str, float, float]] = [
("2102-3018-0205-7886-5204", 63.8732, 57.570156),
]
def _cascade_continuous_sap_from_api(cert_dir_name: str) -> float:
doc = json.loads((_COHORT_2_API_FIXTURE_DIR / f"{cert_dir_name}.json").read_text())
epc = EpcPropertyDataMapper.from_api_response(doc)
@ -1993,32 +1975,6 @@ def test_api_cohort_2_full_chain_sap_matches_worksheet_at_1e_minus_4(
)
@pytest.mark.parametrize(
"cert_dir_name,ws_sap,pinned_continuous_sap", _COHORT_2_API_OPEN
)
def test_api_cohort_2_open_cert_residual_matches_current_pin(
cert_dir_name: str, ws_sap: float, pinned_continuous_sap: float
) -> None:
"""Residual pin for the 4 cohort-2 API-path certs that DON'T yet hit
1e-4 against the worksheet. The pin asserts the cascade's current
`sap_score_continuous` at abs <= 1e-4 a forcing function: when a
follow-up slice closes the underlying mapper or spec gap, the
cascade output moves and this test fires, forcing the cert to
migrate from `_COHORT_2_API_OPEN` to `_COHORT_2_API_CLOSED`. Per
[[project-api-to-sap-residual-test]] this is the established
pattern for tracking residuals as forcing functions, not as
tolerance widening."""
# Arrange
actual = _cascade_continuous_sap_from_api(cert_dir_name)
# Assert — Δ vs PINNED cascade output (worksheet Δ stays surfaced
# in the message for diagnostic context).
assert abs(actual - pinned_continuous_sap) <= 1e-4, (
f"cert {cert_dir_name}: cascade SAP={actual:.6f} moved from pin "
f"{pinned_continuous_sap}; worksheet Δ now {actual - ws_sap:+.6f}"
)
# ============================================================================
# Mapper-vs-hand-built EpcPropertyData diff tests
# ============================================================================

View file

@ -634,7 +634,10 @@ class EpcPropertyDataMapper:
immersion_heating_type=schema.sap_heating.immersion_heating_type,
cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type,
cylinder_thermostat=schema.sap_heating.cylinder_thermostat,
secondary_fuel_type=schema.sap_heating.secondary_fuel_type,
secondary_fuel_type=_api_secondary_fuel_type(
schema.sap_heating.secondary_fuel_type,
schema.sap_heating.secondary_heating_type,
),
secondary_heating_type=schema.sap_heating.secondary_heating_type,
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
),
@ -764,7 +767,10 @@ class EpcPropertyDataMapper:
immersion_heating_type=schema.sap_heating.immersion_heating_type,
cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type,
cylinder_thermostat=schema.sap_heating.cylinder_thermostat,
secondary_fuel_type=schema.sap_heating.secondary_fuel_type,
secondary_fuel_type=_api_secondary_fuel_type(
schema.sap_heating.secondary_fuel_type,
schema.sap_heating.secondary_heating_type,
),
secondary_heating_type=schema.sap_heating.secondary_heating_type,
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
),
@ -919,7 +925,10 @@ class EpcPropertyDataMapper:
immersion_heating_type=schema.sap_heating.immersion_heating_type,
cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type,
cylinder_thermostat=schema.sap_heating.cylinder_thermostat,
secondary_fuel_type=schema.sap_heating.secondary_fuel_type,
secondary_fuel_type=_api_secondary_fuel_type(
schema.sap_heating.secondary_fuel_type,
schema.sap_heating.secondary_heating_type,
),
secondary_heating_type=schema.sap_heating.secondary_heating_type,
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
),
@ -1077,7 +1086,10 @@ class EpcPropertyDataMapper:
immersion_heating_type=schema.sap_heating.immersion_heating_type,
cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type,
cylinder_thermostat=schema.sap_heating.cylinder_thermostat,
secondary_fuel_type=schema.sap_heating.secondary_fuel_type,
secondary_fuel_type=_api_secondary_fuel_type(
schema.sap_heating.secondary_fuel_type,
schema.sap_heating.secondary_heating_type,
),
secondary_heating_type=schema.sap_heating.secondary_heating_type,
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
),
@ -1275,7 +1287,10 @@ class EpcPropertyDataMapper:
shower_outlets=_first_shower_outlet(schema.sap_heating.shower_outlets),
cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type,
cylinder_thermostat=schema.sap_heating.cylinder_thermostat,
secondary_fuel_type=schema.sap_heating.secondary_fuel_type,
secondary_fuel_type=_api_secondary_fuel_type(
schema.sap_heating.secondary_fuel_type,
schema.sap_heating.secondary_heating_type,
),
secondary_heating_type=schema.sap_heating.secondary_heating_type,
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
electric_shower_count=_count_shower_outlets_by_type(
@ -1541,7 +1556,10 @@ class EpcPropertyDataMapper:
shower_outlets=_first_shower_outlet(schema.sap_heating.shower_outlets),
cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type,
cylinder_thermostat=schema.sap_heating.cylinder_thermostat,
secondary_fuel_type=schema.sap_heating.secondary_fuel_type,
secondary_fuel_type=_api_secondary_fuel_type(
schema.sap_heating.secondary_fuel_type,
schema.sap_heating.secondary_heating_type,
),
secondary_heating_type=schema.sap_heating.secondary_heating_type,
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
number_baths=schema.sap_heating.number_baths,
@ -2398,6 +2416,50 @@ def _api_cascade_glazing_type(api_glazing_type: int) -> int:
return _API_TO_SAP10_CASCADE_GLAZING_CODE.get(api_glazing_type, api_glazing_type)
# SAP 10.2 Appendix M Table 4a → Table 32 fuel category dispatch for
# secondary heating types whose lodged `secondary_fuel_type` is
# occasionally inconsistent with the heating system's spec fuel
# category. Cohort-2 cert 2102 lodges SAP code 631 ("Open fire in
# grate", spec p.156 row "Inset appliances ... fired by solid fuels")
# with secondary_fuel_type=33 (electricity off-peak) — physically
# incompatible. The Elmhurst Summary path independently resolves to
# fuel code 11 (House coal) via the §15 "Secondary Fuel: Coal"
# lodgement; the API path needs the same spec-derived default to
# achieve cross-mapper parity at the cascade.
#
# Per SAP 10.2 Appendix M Table 4a + BS EN 13229:2001 inset-appliance
# class, SAP 631 burns solid fuel (default = House coal, Table 32
# code 11). Override only when the lodged fuel is electric — a
# legitimate non-default solid fuel (e.g. Wood logs code 15) lodged
# alongside SAP 631 passes through unchanged.
_API_SECONDARY_HEATING_SPEC_FUEL: Dict[int, int] = {
631: 11, # Open fire in grate — House coal (Table 32 code 11)
}
# Table 32 electric fuel codes — copied here (not imported from
# cert_to_inputs) to keep mapper as a leaf module.
_API_ELECTRIC_FUEL_CODES: frozenset[int] = frozenset({30, 31, 32, 33, 34, 35, 36, 38, 39, 40})
def _api_secondary_fuel_type(
lodged_fuel_type: Optional[int],
secondary_heating_type: Optional[int],
) -> Optional[int]:
"""Resolve the spec-correct SAP 10.2 Table 32 fuel code for an
API-lodged secondary heating system. When the lodged fuel is
physically incompatible with the heating type's spec category
(e.g. electricity for an open-fire grate), substitute the spec
default; otherwise pass the lodged code through unchanged."""
if secondary_heating_type is None or lodged_fuel_type is None:
return lodged_fuel_type
spec_fuel = _API_SECONDARY_HEATING_SPEC_FUEL.get(secondary_heating_type)
if spec_fuel is None:
return lodged_fuel_type
if lodged_fuel_type in _API_ELECTRIC_FUEL_CODES:
return spec_fuel
return lodged_fuel_type
def _api_sap_window(w: Any) -> SapWindow:
"""Build a `SapWindow` from one API schema sap_windows entry,
routing the glazing-type + glazing-gap pair through the spec