From 276e435e6cbe2cd28645da8b1e49a211fb4b3509 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 17:17:05 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.43:=20SAP=20631=20open-fire=20?= =?UTF-8?q?=E2=86=92=20House=20coal=20spec=20fuel=20=E2=80=94=20closes=20c?= =?UTF-8?q?ert=202102?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_summary_pdf_mapper_chain.py | 64 +++------------- datatypes/epc/domain/mapper.py | 74 +++++++++++++++++-- 2 files changed, 78 insertions(+), 60 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index eeb8bcc3..8258d26a 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1884,9 +1884,15 @@ def test_api_9418_full_chain_sap_within_spec_floor_of_worksheet() -> None: # `domain/sap10_calculator/rdsap/tests/fixtures/golden/.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 # ============================================================================ diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index bd82719f..b37c7f77 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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