From 9cb98344fa52af8fdd8209ceba42e261a1235a64 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 08:57:16 +0000 Subject: [PATCH 01/80] S0380.192: drop placeholder roof surfaces from Simplified room-in-roof (Elmhurst) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Simplified room-in-roof (RdSAP 10 §3.9.1, PDF p.21) does NOT measure its slope / flat-ceiling / stud-wall surfaces — the Elmhurst Summary lodges placeholder Length/Height cells (a 40 m flat-ceiling height, a 32 m slope on a 4.65 m-wide gable). The spec instead derives one timber-framed "remaining area" from the floor area: A_RR = 12.5 × √(A_RR_floor / 1.5) §3.9.1(d) A_RR_final = A_RR − ΣA_RR_gable/other §3.9.1(e) The cascade already computes A_RR_final itself (heat_transmission.py: `12.5 × √(A_RR_floor/1.5) − rr_walls_in_a_rr_area` residual), but only when `detailed_surfaces` carries no roof-going kind (`has_roof_lodgement` gate). `_map_elmhurst_rir_surface` emitted the placeholder slope/ceiling rows as raw L×H for every assessment type, flipping that gate and billing 1024 m² + 160 m² of explicit roof area — a 7.5× fabric-heat-loss explosion (cert 001431 sim case 2: SAP −14.6 vs worksheet 69, space heating 114 378 vs ~15 000 kWh). Fix: for a Simplified assessment, drop the roof-going surfaces in the mapper so the cascade's residual formula fires. This matches how the API path already (correctly) handles the same Simplified RR — scalar gable fields, no roof-going detailed_surfaces (golden cert 6035) — and the gables-only cert 000565. Detailed (§3.10) assessments still measure these surfaces and keep them. With the fix, sim case 2 total external area = 232.94 (worksheet exact), roof 78.33 (was 2725.89), SAP 69.29 → worksheet integer 69. A small residual (~450 kWh main fuel) remains — a separate fabric gap to walk next. 2308 passed (+2), 0 failed; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 19 +++++ .../rdsap/test_cert_to_inputs.py | 74 ++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0ddb4baa..88a0e188 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3416,6 +3416,25 @@ def _map_elmhurst_rir_surface( if prefix is None: return None kind = _RIR_KIND_FROM_NAME_PREFIX[prefix] + # RdSAP 10 §3.9.1 (PDF p.21) Simplified assessment: the roof-going + # surfaces (slope / flat ceiling / stud wall) are NOT measured — the + # Summary lodges placeholder Length/Height cells (e.g. a 40 m ceiling + # height, a 32 m slope on a 4.65 m-wide gable). The spec instead + # derives one timber-framed "remaining area" from the floor area: + # A_RR = 12.5 × √(A_RR_floor / 1.5) §3.9.1(d) + # A_RR_final = A_RR − ΣA_RR_gable/other §3.9.1(e) + # The cascade computes A_RR_final itself (heat_transmission.py — the + # `12.5 × √(A_RR_floor / 1.5) − rr_walls_in_a_rr_area` residual), + # but ONLY when `detailed_surfaces` carries no roof-going kind + # (`has_roof_lodgement` gate). Emitting these placeholder rows flips + # that gate and bills their raw L×H as explicit roof area (a 7.5× + # heat-loss explosion). Drop them for Simplified so the cascade's + # residual formula fires — matching how the API path already handles + # the same Simplified RR (scalar gable fields, no roof-going + # detailed_surfaces; cert 6035) and the gables-only cert 000565. + # Detailed (§3.10) assessments DO measure these surfaces — keep them. + if is_simplified and kind in ("slope", "flat_ceiling", "stud_wall"): + return None u_value_override: Optional[float] = None if kind == "gable_wall" and surface.gable_type == "Sheltered": kind = "gable_wall_external" diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 40ba7aff..fbccd757 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -14,10 +14,18 @@ area fraction); SAP 10.3 specification (13-01-2026) Tables 4a/4e/12. from __future__ import annotations -from typing import Final +from typing import Final, Optional import pytest +from datatypes.epc.domain.mapper import ( + _map_elmhurst_room_in_roof, # pyright: ignore[reportPrivateUsage] +) +from datatypes.epc.surveys.elmhurst_site_notes import ( + RoomInRoof as ElmhurstRoomInRoof, + RoomInRoofSurface as ElmhurstRoomInRoofSurface, +) + from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, MainHeatingDetail, @@ -2120,6 +2128,70 @@ def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> N assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71 +def _placeholder_rir_surfaces() -> "list[ElmhurstRoomInRoofSurface]": + # A §3.9.1 Simplified Room-in-Roof lodges the roof-going Length/Height + # cells as placeholders (a 40 m flat-ceiling height, a 32 m slope on a + # 4.65 m gable) — Elmhurst ignores them and derives the area from the + # floor area. Gables ARE measured (4.65 × 2.45 = 11.39). + def surf( + name: str, length: float, height: float, + gable_type: Optional[str] = None, default_u: Optional[float] = None, + ) -> "ElmhurstRoomInRoofSurface": + return ElmhurstRoomInRoofSurface( + name=name, length_m=length, height_m=height, insulation="", + insulation_type=None, gable_type=gable_type, + default_u_value=default_u, u_value_known=False, u_value=0.0, + ) + + return [ + surf("Flat Ceiling 1", 4.00, 40.00), # placeholder + surf("Slope 1", 32.00, 32.00), # placeholder + surf("Gable Wall 1", 4.65, 2.45, gable_type="Exposed", default_u=0.29), + surf("Gable Wall 2", 4.65, 2.45, gable_type="Party", default_u=0.25), + ] + + +def test_elmhurst_simplified_rir_drops_placeholder_roof_surfaces() -> None: + # Arrange — RdSAP 10 §3.9.1 (PDF p.21): a Simplified RR's slope / + # flat ceiling / stud wall are not measured; emitting their + # placeholder L×H as `detailed_surfaces` makes the cascade bill them + # as explicit roof area (7.5× heat-loss explosion) instead of firing + # the spec's `A_RR = 12.5√(A_floor/1.5) − Σwalls` residual formula. + rir = ElmhurstRoomInRoof( + floor_area_m2=29.75, construction_age_band="A", + assessment="Simplified Type 1", surfaces=_placeholder_rir_surfaces(), + ) + + # Act + mapped = _map_elmhurst_room_in_roof(rir) + + # Assert — roof-going surfaces dropped, both gables retained. + assert mapped is not None + kinds = sorted(s.kind for s in (mapped.detailed_surfaces or [])) + assert "slope" not in kinds + assert "flat_ceiling" not in kinds + assert kinds == ["gable_wall", "gable_wall_external"] + + +def test_elmhurst_detailed_rir_keeps_roof_surfaces() -> None: + # Arrange — a Detailed (§3.10) assessment DOES measure slope / flat + # ceiling, so they must be retained (regression guard so the + # Simplified drop doesn't bleed into Detailed lodgements). + rir = ElmhurstRoomInRoof( + floor_area_m2=29.75, construction_age_band="A", + assessment="Detailed", surfaces=_placeholder_rir_surfaces(), + ) + + # Act + mapped = _map_elmhurst_room_in_roof(rir) + + # Assert — slope + flat ceiling retained under the Detailed path. + assert mapped is not None + kinds = sorted(s.kind for s in (mapped.detailed_surfaces or [])) + assert "slope" in kinds + assert "flat_ceiling" in kinds + + def test_elmhurst_gas_boiler_main_fuel_derives_carrier_from_water_heating() -> None: # Arrange — SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas # boilers (including mains gas, LPG and biogas)". The code identifies From 62fc27a5cc1733b2a60b7eeb310616a6e51ba819 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:16:25 +0000 Subject: [PATCH 02/80] S0380.193: suspended-floor (12) sealed rule fires only on a SUPPLIED U-value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §5 (PDF p.29) "Floor infiltration (suspended timber ground floor only)", age band A-E, splits on whether a floor U-value is supplied: a) [U-value supplied] if floor U-value < 0.5 → "sealed", (12) = 0.1 b) [no U-value supplied] retro-fitted insulation → "sealed" 0.1; otherwise "unsealed", (12) = 0.2 `_has_suspended_timber_floor_per_spec` fed the cascade's COMPUTED default U into rule (a), so an as-built/uninsulated suspended-timber floor whose default U happens to be < 0.5 was marked "sealed" (0.1) where Elmhurst uses "unsealed" (0.2). That dropped (18) infiltration 0.85 → 0.75, (25) effective ACH, HTC, and understated space heating ~450 kWh. Fix: gate rule (a) on `floor_u_value_known` — a computed default U is not a supplied value, so it falls through to (b). Verified against the cert 001431 sim-case-2 worksheet: floor "As built", U=0.43 (matches the worksheet's (28a) 0.4300 exactly), (12)=0.2 unsealed. Golden cert 6035 (also a suspended uninsulated floor) is unaffected — its U=0.63 ≥ 0.5 already routed to unsealed. Promotes sim case 2 to the e2e harness as `001431_rr` (Main + Extension + Simplified room-in-roof — the 6035 archetype). All 11 Block-1 line refs pin at abs=1e-4, locking BOTH this fix and S0380.192 (Simplified-RR remaining area) end-to-end: SAP 69, cost 920.5046, CO2 4566.7090, space 15269.8593, main fuel 18178.4039. 2319 passed (+11), 0 failed; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../tests/fixtures/Summary_001431_rr_ext.pdf | Bin 0 -> 80681 bytes .../sap10_calculator/rdsap/cert_to_inputs.py | 26 +++- ...60-0001-001431 - 2026-06-03T093549.591.pdf | Bin 0 -> 46448 bytes .../simulated case 2/Summary_001431 (1).pdf | Bin 0 -> 80681 bytes .../_elmhurst_worksheet_001431_rr.py | 124 ++++++++++++++++++ .../worksheet/test_e2e_elmhurst_sap_score.py | 17 +++ 6 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_rr_ext.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 2/P960-0001-001431 - 2026-06-03T093549.591.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 2/Summary_001431 (1).pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_rr_ext.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_rr_ext.pdf new file mode 100644 index 0000000000000000000000000000000000000000..34934393bc9fb32ccffb4dc95dfcf1aa7392422b GIT binary patch literal 80681 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 literal 0 HcmV?d00001 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0d99af01..2f4eb570 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4103,13 +4103,27 @@ def _has_suspended_timber_floor_per_spec( if age in _AGE_BANDS_F_TO_M: return True, True # sealed if age in _AGE_BANDS_A_TO_E: - # (a) U-value < 0.5 → sealed - main_floor_u = _main_floor_u_value(epc) - if main_floor_u is not None and main_floor_u < _FLOOR_U_SEALED_THRESHOLD: - return True, True - # (b) retro-fitted insulation + no U-value supplied → sealed - ins_type_str = (main.floor_insulation_type_str or "").strip().lower() u_value_known = bool(getattr(main, "floor_u_value_known", False)) + # (a) a SUPPLIED floor U-value < 0.5 → sealed. RdSAP 10 §5 (PDF + # p.29) splits (a)/(b) on whether a U-value is supplied: (a) is + # the "U-value supplied" branch, (b) the "no U-value is supplied" + # branch. A computed default U (an assumed / as-built uninsulated + # floor) is NOT a supplied value, so it must NOT trigger (a) — it + # falls through to (b). Without this gate the cascade marked an + # as-built suspended-timber floor with default U=0.43 "sealed" + # (0.1) where Elmhurst uses "unsealed" (0.2) — cert 001431 sim + # case 2 worksheet (12)=0.2, dropping (25) effective ACH and + # understating space heating ~450 kWh. + main_floor_u = _main_floor_u_value(epc) + if ( + u_value_known + and main_floor_u is not None + and main_floor_u < _FLOOR_U_SEALED_THRESHOLD + ): + return True, True + # (b) no U-value supplied: retro-fitted insulation → sealed; + # otherwise unsealed. + ins_type_str = (main.floor_insulation_type_str or "").strip().lower() if "retro" in ins_type_str and not u_value_known: return True, True # otherwise → unsealed diff --git a/sap worksheets/golden fixture debugging/simulated case 2/P960-0001-001431 - 2026-06-03T093549.591.pdf b/sap worksheets/golden fixture debugging/simulated case 2/P960-0001-001431 - 2026-06-03T093549.591.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8888b35ae39f1342912566c4eb1f2be6db76b748 GIT binary patch literal 46448 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 literal 0 HcmV?d00001 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr.py new file mode 100644 index 00000000..8fc8ecc7 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr.py @@ -0,0 +1,124 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 2" worksheet — a Main + Extension dwelling with a +Simplified room-in-roof (the 6035 archetype, more complete than sim +case 1). + +Like 000565 / sim case 1, this fixture does NOT hand-build the +EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result +pin grid exercises the WHOLE extractor + mapper + calculator pipeline. + +This cert surfaced two real cascade bugs (both fixed; this fixture pins +them end-to-end at 1e-4): + + S0380.192 — Simplified room-in-roof. The Summary lodges placeholder + slope/ceiling Length/Height cells (a 40 m ceiling height, a 32 m + slope on a 4.65 m gable). RdSAP 10 §3.9.1 derives one timber-framed + "remaining area" from the floor area instead + (A_RR = 12.5√(A_floor/1.5) − Σgables = 32.89 m²). Emitting the + placeholders as detailed_surfaces billed 1024 + 160 m² of explicit + roof area → a 7.5× fabric-heat-loss explosion (SAP −14.6). Fixed by + dropping roof-going surfaces for Simplified assessments so the + cascade's residual formula fires. + + S0380.193 — Suspended-timber-floor "sealed/unsealed" infiltration. + RdSAP 10 §5 (PDF p.29) line (12): rule (a) ("U-value < 0.5 → sealed + 0.1") applies only when a floor U-value is SUPPLIED. This cert's + floor is as-built/uninsulated (default U=0.43, not supplied), so it + falls to rule (b) → unsealed 0.2. The cascade was feeding the + computed default U into rule (a) → sealed 0.1 → (25) effective ACH + dropped → space heating understated ~450 kWh. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 2/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_rr_ext.pdf` +(distinct name — the corpus reuses cert 001431; sim case 1 is the +single-part gas-combi variant) so the test runs without depending on +the unstaged workspace. + +Cert shape: Main + Extension 1, both solid brick WITH internal +insulation (Main) / as-built (Ext1), 3 storeys, Simplified room-in-roof +on the Main (floor 29.75 m², exposed + party gables), suspended +uninsulated ground floors, gas-combi SAP code 104, no PV. + +Worksheet pin targets (P960-0001-001431, Block 1 — energy rating): +- SAP rating 69 (line 258), ECF 2.2395 (line 257) +- Total fuel cost £920.5046 (line 255) +- CO2 4566.7090 kg/year (line 272) +- Space heating 15269.8593 kWh/year (Σ monthly (98)) +- Main 1 fuel 18178.4039 kWh/year (line 211) +- Secondary fuel 0.0 (line 215) +- Hot water fuel 3308.6172 kWh/year (line 219) +- Lighting 282.6414 kWh/year (line 232) +- Pumps/fans 86.0 kWh/year (line 231) + +Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation- +philosophy]]: pins are abs=1e-4 against the worksheet PDF. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_rr_ext.pdf" +) + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label\\nvalue sequences). + + Mirror of the helper in `test_summary_pdf_mapper_chain.py` / + `_elmhurst_worksheet_000565.py`. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-2 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.192 Simplified-RR fix and the + S0380.193 suspended-floor sealed-rule fix. + """ + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 4f4653fd..f2abc332 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -38,6 +38,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_000516 as _w000516, _elmhurst_worksheet_000565 as _w000565, _elmhurst_worksheet_001431 as _w001431, + _elmhurst_worksheet_001431_rr as _w001431_rr, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -167,6 +168,21 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=283.2229, pumps_fans_kwh_per_yr=86.0, ), + # Mapper-driven cohort entry — Summary_001431_rr_ext.pdf → extractor + # → mapper → calculator. Main + Extension, Simplified room-in-roof, + # suspended uninsulated floors (the 6035 archetype). Surfaced + pins + # S0380.192 (Simplified-RR remaining area) and S0380.193 (suspended- + # floor sealed/unsealed rule). Pins are worksheet Block 1 line refs. + "001431_rr": FixtureCascadePins( + sap_score=69, sap_score_continuous=68.7584, ecf=2.2395, + total_fuel_cost_gbp=920.5046, co2_kg_per_yr=4566.7090, + space_heating_kwh_per_yr=15269.8593, + main_heating_fuel_kwh_per_yr=18178.4039, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3308.6172, + lighting_kwh_per_yr=282.6414, + pumps_fans_kwh_per_yr=86.0, + ), } @@ -179,6 +195,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "000516": _w000516, "000565": _w000565, "001431": _w001431, + "001431_rr": _w001431_rr, } From e7a0c9885e34fccc264694c4ba34294debbc3618 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:46:56 +0000 Subject: [PATCH 03/80] S0380.194: pin sim case 3 (near-exact 6035 replica) e2e at 1e-4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user-simulated case-3 worksheet as e2e fixture `001431_rr8` — Main + Extension + Simplified room-in-roof with 8 windows (≈14.15 m², reproducing golden cert 6035's glazing) and Main ground-floor HLP 15.99. All 11 Block-1 line refs pin at abs=1e-4 against the worksheet (SAP 68, cost 951.3425, CO2 4767.4862, space 16086.3557, main fuel 19150.4235, HW 3307.2639, lighting 262.0885). This is the third independent 1e-4 confirmation that the cascade reproduces the spec engine for the 6035 archetype (after S0380.192 Simplified-RR + S0380.193 suspended-floor). It differs from 6035 in one input only — the Main first-floor HLP (15.99 here vs 6035's 8.32) — so 6035's +19 PE vs the lodged register is lodged-register divergence, not a calculator gap. A byte-identical 6035 replica (first-floor HLP 8.32) would let 6035 itself be pinned directly to close that out. 2330 passed (+11), 0 failed; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../tests/fixtures/Summary_001431_rr8w.pdf | Bin 0 -> 80854 bytes ...60-0001-001431 - 2026-06-03T104009.792.pdf | Bin 0 -> 46054 bytes .../simulated case 3/Summary_001431 (1).pdf | Bin 0 -> 80854 bytes .../_elmhurst_worksheet_001431_rr8.py | 112 ++++++++++++++++++ .../worksheet/test_e2e_elmhurst_sap_score.py | 19 +++ 5 files changed, 131 insertions(+) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_rr8w.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 3/P960-0001-001431 - 2026-06-03T104009.792.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 3/Summary_001431 (1).pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr8.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_rr8w.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_rr8w.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d806f5b04ace9b0b1e76c63f693249526b371046 GIT binary patch literal 80854 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 z z>j9$LdFBaQzBY05^lT5*k+9GfiuwYY2`2V&N&|R}MaiarB;4!X!4?gEI)~cdVTIdb z^x3Fj`ci+;pnZ);Z_>k9tN<1FnB#|}Nxa_QgzBZq z9Nl|%E}kz`J(8^6U|2oYdnJ}{;OM@#{otuwz^fn zRU4#cQ!WQHDFDU<%|&_ZhroXL{dWnc_s6H?^?A*>b2bFK9D@cA?Woe z;QH;`8ND4>@C_7@;vawk<5;0sO7{N}uDbYCb%XM8;8)za#)z$TK>4*F7Va5p3CDCy zu>wT2oU8W-(}HpYO>`ulM`W7VQhn<9Hh9g2fEHl6f5#Va{Xl!hovj;9HkaO(y<;8k zAXg>l9ovQlLZ5p}1*MfMoI;@2l5*m*_~cr!RWaZ2UtsSqsgnN$d;h}Ee+PTHL4W>Z zg%X=I4+#2~<$oLh20{PFk-v59|McWPOxkd&~?4bjUFEEi{7T9QRoQn=Y~>V2@3E2LmV<9!!@Iqviir&fFJ7=2Ci&=F8&>0@$pr2AV9CC!l=x6a@ng=jsPWLC z_Nz|6W`(uy(MPkzO0N4RboqJofiul=l@E(S2|-7WSEE&0lqP~Aq$b^nS;e?z7PP0q zo&~I2OMFqQ)x9z*PV&l%grz-X!gh(RCxX3he8RrW810o$*89Gl) z6bSmvPi)NDKK@g$T6D~G8K>)5%`7$$2SP~G^B1{;dSo3nV`UV63ED3n(%?GOu zAfx67oOA++O^6Q(%KU(ByTF7ar=4$aJ!R&4;m2U^?)$cQPkE)UeJjok;+l<&oZr9L zTZ6lUeLbY}7|UMK8@$vLNV*p4kSF!7Fs*3292HZ>-b{gseXyD565JYOqsjeN>7Sg7 zsb+0DZWY)^UZ8M?C3j}e3x@7VP(Y6JXe{811LHj}dN-3FeG5~sWXN1|>B|UpP*13H za{Z&0P!>PDCK%B(*XqG*2I^e7mPdm#4(Lg;k8lVX=q(w$Onopr7xoZIQQEvYV@+Z) zgG%w)EQ-VJYAD5AD|sfoy@`g&;X@#22}zaGbatwun@>yeG6WHe*ILo$%HcME9z&8hw#B^lqDzF4dGxfR?-(JU+YK-~OQqU`s$?L3N<}tO+4hRcxhDP# zcoOx5j|#n6=x$!@=X@O#|GY-*2Y0#rWJQdXU?8aUWqnl$fiq)k8NRp8Qk*Ji*~?0- z#dog|Th>5QrfW2$j)YtE=?`ygG~G@~wW0XR@&EzbQ(_~Wa`k=|alu1cjWDrS%1p?# zrN%yey%FR<=3CS;pg}Y0NfNbXP+C9X+pD2vOrP;Db(}(%)fweqUr*^_&$(%xT`wHb z5MU@;Sdtlq7+G&0%{Fd$7O4O#D(apBUDw`>mLC=Z&-y~{<=aT^4z0CKou9v#diI@0 znSnSj;(J+kZ^arjHBAYi&kJyf67;)8icg;sBI04q0jXi5K!z|4Yg9HVwxR|pJDvijJ z4sg`S-V(d!zR+A;d_G96LExF%Xk~1e=QqM5prG>-MWqkyH_b$ccH=gA7fBf|8vsTk!s=24jb_|2h{A2#n*`xo~hnZ=+d1x8;PvIc`Pn zPyO9Stzf_Q#|4LS{@Rv{8v^;YEf+WZxA}4L{1L4Dc?>Qd&f75N&owv%!u@*<%Khv5 z!6Dp#gt~s3GaSP6`*rL*zugBM28H~(F1ITH|E)g`p4&|BFJs(Z`0EY$3LG$ZU~f{ L8$d%NE-&$4lz;!M literal 0 HcmV?d00001 diff --git a/sap worksheets/golden fixture debugging/simulated case 3/P960-0001-001431 - 2026-06-03T104009.792.pdf b/sap worksheets/golden fixture debugging/simulated case 3/P960-0001-001431 - 2026-06-03T104009.792.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9e79a3469f246050694018c30d809376f203fa4f GIT binary patch literal 46054 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
  • O5aYWFK%uc3!{Ti1HT<_ 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 A&j0`b literal 0 HcmV?d00001 diff --git a/sap worksheets/golden fixture debugging/simulated case 5/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 5/Summary_001431 (1).pdf new file mode 100644 index 0000000000000000000000000000000000000000..335e62428d6a52c924e63541ea6612d5d962752f GIT binary patch literal 80735 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
  • O5aYWFK%uc3!{Ti1HT<_ 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 list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label\\nvalue sequences). + + Mirror of the helper in `test_summary_pdf_mapper_chain.py` / + `_elmhurst_worksheet_000565.py`. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-5 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.196 RR-gable deduction, the + S0380.197 sandstone-wall-label + "400+ mm" roof-thickness fixes. + """ + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 1091c013..1dbee580 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -41,6 +41,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431_rr as _w001431_rr, _elmhurst_worksheet_001431_rr8 as _w001431_rr8, _elmhurst_worksheet_001431_6035 as _w001431_6035, + _elmhurst_worksheet_001431_case5 as _w001431_case5, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -217,6 +218,25 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=262.0885, pumps_fans_kwh_per_yr=86.0, ), + # Mapper-driven cohort entry — Summary_001431_case5.pdf → extractor → + # mapper → calculator. DETACHED, SANDSTONE-walled cousin of cert 0240: + # Main + Extension + room-in-roof (floor 83.2 m², one Exposed + one + # Party gable L=6.40), age J, oil combi (SAP 901), no PV. Validates + # S0380.196 (RR gable deduction) against a real worksheet — the + # worksheet prints Gable 1 (Exposed) at (29a) U=0.35, Gable 2 (Party) + # at (32) U=0.25, remaining area = shell − Σ gables at (30). Also pins + # the S0380.197 sandstone "SS" wall label + "400+ mm" roof-thickness + # extractor fixes (without the latter, roof U fell to 0.16 not 0.11). + "001431_case5": FixtureCascadePins( + sap_score=61, sap_score_continuous=61.3255, ecf=2.7724, + total_fuel_cost_gbp=1586.4549, co2_kg_per_yr=8387.6229, + space_heating_kwh_per_yr=12838.6489, + main_heating_fuel_kwh_per_yr=21397.7480, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=6498.2518, + lighting_kwh_per_yr=381.4601, + pumps_fans_kwh_per_yr=141.0, + ), } @@ -232,6 +252,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "001431_rr": _w001431_rr, "001431_rr8": _w001431_rr8, "001431_6035": _w001431_6035, + "001431_case5": _w001431_case5, } From 999eced9fb145f44ea634b22868628c564bda42c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 12:33:30 +0000 Subject: [PATCH 08/80] =?UTF-8?q?S0380.198:=20API=20window=5Fwall=5Ftype?= =?UTF-8?q?=3D4=20=E2=86=92=20roof=20window=20(SAP=2010.2=20=C2=A73=20(27a?= =?UTF-8?q?)=20+=20Table=206e=20Note=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cert 0240's SAP residual (-1) and a chunk of its PE/CO2 was an API-mapper bug: it flattened ALL windows into sap_windows, so the 6 windows lodged with window_wall_type=4 — the RdSAP code for a roof window ("Roof of Room" rooflight / inclined glazing) — were billed as vertical wall glazing on worksheet (27) at U=2.0, instead of roof windows on (27a) at the Table 6e Note 2 inclination-adjusted U (DG 2002+ vertical 2.0 + 0.30 = 2.30) with 45°-inclined solar gains. window_wall_type=4 is the discriminator, NOT window_type=2 (certs 0390 / 7536 lodge window_type=2 on ordinary main-wall windows). Fix: partition the 21.0.1 API window list into sap_windows (wall_type≠4) + sap_roof_ windows (wall_type=4); `_api_sap_roof_window` mirrors the site-notes `_map_elmhurst_roof_window` (vertical U from the glazing Table-24 lookup + 0.30 inclination; 45° pitch; g/FF from the same lookup). Validated against the simulated-case-6 worksheet, which bills these identical windows on (27a) at U_eff 2.1062 (= 2.30 with the §3.2 R=0.04 curtain transform). The inclined solar gain dominates the higher U-loss, RAISING the SAP: - 0240: SAP cont 72.14 → 72.55 (resid -1 → +0 EXACT), PE +3.91 → +1.95, CO2 +0.22 → +0.12 - 6035: 2 wall_type=4 rooflights — SAP still +0 exact, PE +1.84 → +1.37, CO2 +0.01 → -0.0004 Blast radius is exactly these two certs (only golden fixtures with wall_type=4). Suite: 2354 passed, 1 skipped. New code: 0 pyright errors. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 56 ++++++++++++++- .../rdsap/test_golden_fixtures.py | 68 ++++++++++++++++--- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 143e734b..d779adb5 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1587,9 +1587,17 @@ class EpcPropertyDataMapper: schema.sap_heating.shower_outlets, _API_SHOWER_OUTLET_CODE_MIXER, ), ), - # SAP windows + # SAP windows — split vertical wall windows (27) from roof + # windows (27a) on the RdSAP `window_wall_type=4` signal. sap_windows=[ - _api_sap_window(w) for w in schema.sap_windows + _api_sap_window(w) + for w in schema.sap_windows + if not _api_is_roof_window(w) + ], + sap_roof_windows=[ + _api_sap_roof_window(w) + for w in schema.sap_windows + if _api_is_roof_window(w) ], # SAP energy source sap_energy_source=SapEnergySource( @@ -2631,6 +2639,50 @@ def _api_secondary_fuel_type( return lodged_fuel_type +# RdSAP API `window_wall_type` code 4 = roof window ("Roof of Room" +# rooflight / inclined glazing). Codes 1=main wall, 2=alt wall 1, 3=alt +# wall 2 (see `_window_on_alt_wall`). A roof window is billed on worksheet +# (27a) at the Table 6e Note 2 inclination-adjusted U and draws 45°- +# inclined solar gains, NOT on (27) as vertical glazing. Cert 0240's 6 +# "Roof of Room" windows lodge this code; the simulated-case-6 worksheet +# confirms the (27a) treatment at U_eff 2.1062. +_API_WINDOW_WALL_TYPE_ROOF: Final[int] = 4 + + +def _api_is_roof_window(w: Any) -> bool: + """True when an API sap_windows entry is a roof window (rooflight), + keyed on `window_wall_type == 4`. `window_type` is NOT the signal — + certs 0390 / 7536 lodge `window_type=2` on ordinary main-wall + (wall_type=1) windows.""" + return w.window_wall_type == _API_WINDOW_WALL_TYPE_ROOF + + +def _api_sap_roof_window(w: Any) -> SapRoofWindow: + """Build a `SapRoofWindow` from one API roof-window entry + (`window_wall_type=4`). The lodged glazing type gives the vertical + U / g / frame-factor via the same SAP 10.2 Table 24 lookup the + vertical-window path uses; the U is then raised by the SAP 10.2 + Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to + the inclined-position value the worksheet bills on (27a). Mirror of + the site-notes `_map_elmhurst_roof_window`.""" + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + vertical_u = transmission[0] if transmission is not None else 2.0 + g_perp = transmission[1] if transmission is not None else 0.76 + frame_factor = w.frame_factor + if frame_factor is None: + frame_factor = transmission[2] if transmission is not None else 0.70 + return SapRoofWindow( + area_m2=_measurement_value(w.window_width) * _measurement_value(w.window_height), + u_value_raw=vertical_u + _ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K, + orientation=w.orientation, + pitch_deg=45.0, + g_perpendicular=g_perp, + frame_factor=frame_factor, + glazing_type=_api_cascade_glazing_type(w.glazing_type), + window_location=w.window_location, + ) + + 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 diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 375fe930..03f5fe93 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -82,9 +82,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0240-0200-5706-2365-8010", actual_sap=73, - expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+3.9138, - expected_co2_resid_tonnes_per_yr=+0.2213, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=+1.9459, + expected_co2_resid_tonnes_per_yr=+0.1226, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -127,7 +127,16 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "Party) with no height, previously dropped → roof over-count. " "Routing them through `detailed_surfaces` deducts 2×(6.4×2.45) " "from the A_RR shell → roof drops, tightening PE +5.8007 → " - "+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72." + "+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72. " + "Slice S0380.198 CLOSED the SAP: this cert lodges 6 windows " + "with `window_wall_type=4` = roof windows ('Roof of Room' " + "rooflights). The API mapper had flattened them into " + "`sap_windows` (vertical glazing, (27), U=2.0); they belong on " + "(27a) at the Table 6e Note 2 inclination-adjusted U=2.30 with " + "45°-inclined solar gains. Validated against the simulated-" + "case-6 worksheet ((27a) U_eff 2.1062). The inclined solar " + "gain dominates → SAP cont 72.14 → 72.55 (resid -1 → +0 " + "EXACT), PE +3.9138 → +1.9459, CO2 +0.2213 → +0.1226." ), ), _GoldenExpectation( @@ -201,8 +210,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="6035-7729-2309-0879-2296", actual_sap=70, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+1.8379, - expected_co2_resid_tonnes_per_yr=+0.0103, + expected_pe_resid_kwh_per_m2=+1.3743, + expected_co2_resid_tonnes_per_yr=-0.0004, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "S0380.189 fixed the dominant driver: walls are solid brick " @@ -246,8 +255,14 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "height 2.45 m; Exposed → gable_wall_external, Party → " "gable_wall) so the cascade's Detailed-RR residual fires. " "SAP resid -2 → +0 (exact), PE +19.16 → +1.84, CO2 " - "+0.42 → +0.01. Remaining +1.84 PE is unrelated gains/HW " - "(no worksheet for 6035 itself to pin further)." + "+0.42 → +0.01. " + "Slice S0380.198 (the 0240 roof-window fix) also applies: " + "6035 lodges 2 windows with `window_wall_type=4` (room-in-roof " + "rooflights) which were billed as vertical glazing; routing " + "them to roof windows (27a) at inclined U=2.30 + 45° solar " + "tightened PE +1.84 → +1.37 and CO2 +0.01 → -0.0004 (SAP still " + "exact). Remaining +1.37 PE is unrelated gains/HW (no " + "worksheet for 6035 itself to pin further)." ), ), _GoldenExpectation( @@ -757,6 +772,35 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3 +def test_0240_api_wall_type_4_windows_map_to_roof_windows() -> None: + """Cert 0240 lodges 6 windows with `window_wall_type=4` — the RdSAP + API code for a roof window ("Roof of Room" rooflight / inclined + glazing), distinct from main-wall (1) and alternative-wall (2/3) + windows. They belong on worksheet line (27a) Roof Windows at the + Table 6e Note 2 inclination-adjusted U (DG 2002+ vertical 2.0 + 0.30 + = 2.30 W/m²K), with 45°-inclined solar gains — NOT on (27) as vertical + wall windows at U=2.0. + + Before the fix the API mapper flattened all windows into + `sap_windows`, so these 6 billed as vertical glazing (wrong U *and* + wrong solar). Validated against the simulated-case-6 worksheet, which + bills the identical 6 windows on (27a) at U_eff 2.1062 (= 2.30 with + the §3.2 R=0.04 curtain transform). + """ + # Arrange + doc = _load_cert("0240-0200-5706-2365-8010") + + # Act + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Assert — the 6 wall_type=4 windows route to roof windows; the other + # 5 (wall_type=1, main wall) stay vertical. + assert epc.sap_roof_windows is not None + assert len(epc.sap_roof_windows) == 6 + assert len(epc.sap_windows) == 5 + assert all(abs(rw.u_value_raw - 2.30) <= 1e-9 for rw in epc.sap_roof_windows) + + def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None: """Cert 6035 lodges a Simplified Type 1 room-in-roof (`room_in_roof_ type_1`) with two gable walls (L=4.65 each). Per RdSAP 10 §3.9.1(e) @@ -785,5 +829,9 @@ def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None: # Act roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k - # Assert - assert abs(roof_w_per_k - 78.3336) <= 1e-4 + # Assert — 78.3336 (gable-deducted residual + loft + ext roof) less + # the S0380.198 deduction of 6035's 2 room-in-roof rooflights + # (window_wall_type=4, 2 × 1.2×0.8 = 1.92 m²) from the gross roof at + # U_roof=0.14 → 78.3336 − 0.2688 = 78.0648. The rooflights' own A×U + # moves to roof_windows_w_per_k. + assert abs(roof_w_per_k - 78.0648) <= 1e-4 From 2b1f90a7defd55d641ef46b145b9cb51020b1349 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 12:46:18 +0000 Subject: [PATCH 09/80] =?UTF-8?q?S0380.199:=20site-notes=20"Roof=20of=20Ro?= =?UTF-8?q?om"=20windows=20=E2=86=92=20roof=20windows=20(cross-mapper=20pa?= =?UTF-8?q?rity=20with=20S0380.198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Elmhurst extractor crashed parsing simulated-case-6's room-in-roof window rows: the §11 "Location" cell "Roof of Room in Roof" wraps across the layout prefix/suffix blocks and leaked into the glazing-type phrase ("Double between 2002 Roof of Room and 2021 in Roof" → UnmappedElmhurst- Label). Fix (`_parse_window_from_anchors`): detect the roof-of-room location tokens, strip them from the before/after blocks so the glazing phrase reconstructs cleanly, and set location="Roof of Room". Mapper: `_is_elmhurst_roof_window` gains a "Roof of Room" location branch (highest-confidence rooflight signal, above the BP-roof-type / U>3.0 gates); `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING` gains "Double between 2002 and 2021" → 2.30 (case 6 lodges the already-inclined roof-window U, so the +0.30 inclination adjustment must not double-apply). This is the site-notes mirror of S0380.198 (API window_wall_type=4): both paths now route room-in-roof rooflights to (27a) at the inclined U. Validated against the case-6 P960 worksheet at abs=1e-4: (27) Windows = 22.7408 (cascade 22.7407) (27a) Roof Windows = 13.0375 (cascade 13.0375, EXACT) (31) ext area = 336.13 Case 6 is pinned only on the §3 window line refs (new standalone test, not added to the section-pin `_FIXTURES`) because its DUAL main heating (51% rads + 49% underfloor, oil) makes the §10/§12 per-system lines non-comparable to SapResult's aggregated fields — documented in the fixture module. Summary mirrored to Summary_001431_case6.pdf. Suite: 2355 passed, 1 skipped. New code: 0 pyright errors. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 18 +++ .../tests/fixtures/Summary_001431_case6.pdf | Bin 0 -> 94352 bytes datatypes/epc/domain/mapper.py | 11 ++ ...60-0001-001431 - 2026-06-03T130227.971.pdf | Bin 0 -> 49351 bytes .../simulated case 6/Summary_001431 (1).pdf | Bin 0 -> 94352 bytes .../_elmhurst_worksheet_001431_case6.py | 108 ++++++++++++++++++ .../worksheet/test_section_cascade_pins.py | 30 +++++ 7 files changed, 167 insertions(+) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case6.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 6/P960-0001-001431 - 2026-06-03T130227.971.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 6/Summary_001431 (1).pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 00aaf045..a8b1893f 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -801,6 +801,12 @@ class ElmhurstSiteNotesExtractor: "North", "South", "East", "West", "NE", "NW", "SE", "SW", }) _BP_INLINE_TOKENS = frozenset({"Main"}) # "Extension" only appears as suffix + # A room-in-roof window (rooflight) lodges its §11 "Location" cell as + # "Roof of Room in Roof", which the layout preprocessor wraps onto two + # tokens ("Roof of Room" in the prefix block, "in Roof" in the suffix). + # Detected so the window routes to a roof window (worksheet (27a)) + # and the tokens don't leak into the glazing-type phrase. + _ROOF_OF_ROOM_LOCATION_TOKENS = frozenset({"Roof of Room", "in Roof"}) # The Elmhurst Summary PDF lodges each window's glazing-type as a # capitalised phrase like "Double between 2002" / "Double with unknown" # / "Single" / "Triple" / "Secondary". The first token of that phrase @@ -1020,6 +1026,18 @@ class ElmhurstSiteNotesExtractor: before = [lines[j].strip() for j in range(before_start, data_idx) if lines[j].strip()] after = [lines[j].strip() for j in range(manuf_idx + 4, after_end) if lines[j].strip()] + # Room-in-roof windows lodge their location as "Roof of Room in + # Roof" (wrapped across the prefix/suffix blocks). Detect it, pull + # those tokens out so they don't contaminate the glazing-type + # phrase, and override the wall-keyed `location` with the roof-of- + # room marker the roof-window classifier keys on. + if any( + t in self._ROOF_OF_ROOM_LOCATION_TOKENS for t in (*before, *after) + ): + location = "Roof of Room" + before = [t for t in before if t not in self._ROOF_OF_ROOM_LOCATION_TOKENS] + after = [t for t in after if t not in self._ROOF_OF_ROOM_LOCATION_TOKENS] + glazing_type, building_part, orientation = self._compose_window_descriptors( before=before, after=after, diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case6.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case6.pdf new file mode 100644 index 0000000000000000000000000000000000000000..258f56acc0612cd811962174b17de65d3eb3d030 GIT binary patch literal 94352 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 Jv{EY4{{_uXVqO3M literal 0 HcmV?d00001 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index d779adb5..92373fbe 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3837,6 +3837,11 @@ def _is_elmhurst_roof_window( """ if w.glazing_type.startswith("Single"): return False + # Explicit "Roof of Room" location lodging (simulated case 6): the + # surveyor placed the window on the room-in-roof, so it's a rooflight + # regardless of BP roof type or U-value. + if "roof of room" in (w.location or "").lower(): + return True bp_roof_type = _elmhurst_bp_roof_type(w, survey) if bp_roof_type is not None and bp_roof_type.startswith( _ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS @@ -3852,6 +3857,12 @@ def _is_elmhurst_roof_window( # worksheet's (27a) line. The cohort exercises only "Double pre 2002". _ELMHURST_ROOF_WINDOW_U_BY_GLAZING: Dict[str, float] = { "Double pre 2002": 3.4, + # Simulated case 6 rooflights: the Summary lodges the already-inclined + # roof-window U=2.30 for DG-2002-2021 glazing (vs 2.00 vertical for the + # same glazing on a wall) — the worksheet bills it on (27a) at U_eff + # 2.1062 (= 2.30 with the §3.2 R=0.04 curtain transform). Keyed here so + # the inclination adjustment isn't double-applied. + "Double between 2002 and 2021": 2.30, } diff --git a/sap worksheets/golden fixture debugging/simulated case 6/P960-0001-001431 - 2026-06-03T130227.971.pdf b/sap worksheets/golden fixture debugging/simulated case 6/P960-0001-001431 - 2026-06-03T130227.971.pdf new file mode 100644 index 0000000000000000000000000000000000000000..13e0183aab5c88064b6b35fdd63ac22b61858acc GIT binary patch literal 49351 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 a0ORCr;OP9X)yc-i!NCqgPA;w}@&5sJ&Sxh8 literal 0 HcmV?d00001 diff --git a/sap worksheets/golden fixture debugging/simulated case 6/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 6/Summary_001431 (1).pdf new file mode 100644 index 0000000000000000000000000000000000000000..258f56acc0612cd811962174b17de65d3eb3d030 GIT binary patch literal 94352 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 Jv{EY4{{_uXVqO3M literal 0 HcmV?d00001 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py new file mode 100644 index 00000000..94b6d7e8 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -0,0 +1,108 @@ +"""Mapper-driven cascade fixture for the Elmhurst P960-0001-001431 +"simulated case 6" worksheet — a DETACHED, dual-oil cousin of golden +cert 0240 carrying ROOM-IN-ROOF WINDOWS (rooflights). + +Routes the Summary PDF through ElmhurstSiteNotesExtractor + +from_elmhurst_site_notes (no hand-built EpcPropertyData) so the pin +exercises the whole extractor + mapper + calculator pipeline. + +Purpose: validate S0380.198/199 ROOF-WINDOW handling against a real +worksheet. Case 6 lodges 6 windows on the room-in-roof ("Roof of Room" +location); the worksheet bills them on line (27a) Roof Windows at +U_eff 2.1062 (= inclined 2.30 with the §3.2 R=0.04 curtain transform), +NOT on (27) as vertical glazing. This is the site-notes mirror of +0240's API `window_wall_type=4` roof windows (S0380.198). + +This cert surfaced two site-notes gaps fixed in S0380.199: +- the extractor mangled the "Roof of Room in Roof" window-location cell + into the glazing-type phrase ("Double between 2002 Roof of Room and + 2021 in Roof" → UnmappedElmhurstLabel); `_parse_window_from_anchors` + now detects + strips those tokens and marks the window roof-of-room; +- `_is_elmhurst_roof_window` gained a "Roof of Room" location branch, + and `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING` an entry for the + already-inclined "Double between 2002 and 2021" → 2.30 (so the + inclination adjustment isn't double-applied). + +SCOPE: this fixture pins only the §3 heat-transmission WINDOW line refs +(27)/(27a)/(31) — NOT the full SapResult. Case 6 has a DUAL main heating +system (51% radiators + 49% underfloor, oil), and `SapResult`'s +`main_heating_fuel_kwh_per_yr` / `pumps_fans_kwh_per_yr` aggregate the +two systems differently from the worksheet's per-system (211)/(231) +lines, so a full SapResult pin isn't apples-to-apples. Heating is also +SAP code 127 here vs 0240's code 130 condensing combi — so case 6 pins +to its OWN worksheet, not 0240's register. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 6/`. Summary mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case6.pdf`. + +Worksheet §3 window pin targets (P960-0001-001431, Block 1): +- (27) Windows = 19.3704 (Main) + 3.3704 (Ext1) = 22.7408 W/K +- (27a) Roof Windows = 6.19 m² × 2.1062 = 13.0375 W/K (the 6 rooflights) +- (31) Total external element area = 336.13 m² + +Per [[feedback-zero-error-strict]]: pins are abs=1e-4 against the PDF. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_case6.pdf" +) + +# Worksheet §3 window line refs (Block 1 — energy rating). +LINE_27_WINDOWS_W_PER_K: Final[float] = 22.7408 +LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 13.0375 +LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 336.13 + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (mirror of the case-5 helper).""" + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-6 Summary through extractor + mapper.""" + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index b3480d2a..396247f6 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -42,6 +42,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_000487 as _w000487, _elmhurst_worksheet_000490 as _w000490, _elmhurst_worksheet_000516 as _w000516, + _elmhurst_worksheet_001431_case6 as _w001431_case6, ) @@ -248,6 +249,35 @@ def test_section_3_line_refs_match_pdf( _pin(actual, expected, f"§3 {fixture_attr} {fixture_name}") +def test_section_3_roof_windows_case6_match_pdf() -> None: + """§3 (27a) roof-window pin for simulated case 6 — the 6 room-in-roof + rooflights (window_wall_type=4 on the API side / "Roof of Room" + location on the site-notes side) must bill on (27a) at U_eff 2.1062, + not on (27) as vertical glazing. Validates the S0380.198/199 roof- + window routing against a real worksheet. Case 6 is pinned only on the + §3 window line refs (not added to `_FIXTURES`) because its dual main + heating system makes the §10/§12 per-system lines non-comparable — + see the fixture module docstring.""" + # Arrange + epc = _w001431_case6.build_epc() + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert + _pin(ht.windows_w_per_k, _w001431_case6.LINE_27_WINDOWS_W_PER_K, "§3 (27) case6") + _pin( + ht.roof_windows_w_per_k, + _w001431_case6.LINE_27A_ROOF_WINDOWS_W_PER_K, + "§3 (27a) case6", + ) + _pin( + ht.total_external_element_area_m2, + _w001431_case6.LINE_31_TOTAL_EXTERNAL_AREA_M2, + "§3 (31) case6", + ) + + # ============================================================================ # §4 Water heating — LINE_42..LINE_65 scalar + monthly tuples # ============================================================================ From 8ae978a646187036f4a4d8dac249d3c1542bd896 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:09:43 +0000 Subject: [PATCH 10/80] =?UTF-8?q?S0380.200:=20SAP=2010.2=20=C2=A79a=20two-?= =?UTF-8?q?main-heating=20split=20(203)/(205)/(207)/(213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cascade lumped a dwelling with two main heating systems into one: `space_heating_fuel_monthly_kwh` hard-coded (203)=0 (a documented scope-A placeholder) and the calculator's per-month fuel read only main_1, so the full §8 space-heat demand billed against system 1's efficiency. Simulated case 6 (one oil boiler feeding radiators 51% + underfloor 49%) exposed it: main fuel ≈ demand/eff1 instead of the worksheet's (211)+(213) per-system split. Implements the SAP 10.2 §9a two-main model: (204) = (202) × (1 − (203)) → system 1 share of total heat (205) = (202) × (203) → system 2 share of total heat (211)m = (98c)m × (204) × 100 / (206) (213)m = (98c)m × (205) × 100 / (207) (203) = the second system's lodged `main_heating_fraction`; (207) = its own seasonal efficiency via the new per-detail `_main_heating_detail_ efficiency` (the core of `_main_heating_efficiency`, now reused for system 2). Calculator `_solve_month` aggregates main_1 + main_2 into `main_heating_fuel_kwh`. Cost (§10a 241), CO2 (§12 262) and PE (§13 276) main_2 paths were already wired and now activate. Site-notes gap also fixed: §14.1 Main Heating2 omits the "Fuel Type" cell when the second system shares Main 1's fuel (case 6: one oil boiler, two emitters). `_map_elmhurst_main_heating_2` now inherits Main 1's resolved fuel as a fallback. Blast radius: only dual-main certs. 0240 (2× oil code 130, identical Eq-D1 efficiency) is unchanged — its split collapses to the lumped total. Suite: 2355 passed, 1 skipped. New code: 0 pyright errors. NOTE: case 6 is not yet fully pinnable end-to-end — its two systems have DIFFERENT efficiencies (radiators 55°C → 79%, underfloor 35°C → 84%), a flow-temperature boiler-efficiency adjustment not yet modelled, and its dual-system auxiliary pumps ((230c)+(230d)=356) differ from the cascade. Both are separate follow-on features; this slice is the §9a fuel split. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 22 ++++++++++- domain/sap10_calculator/calculator.py | 10 ++++- .../sap10_calculator/rdsap/cert_to_inputs.py | 36 +++++++++++++++++- .../worksheet/energy_requirements.py | 38 ++++++++++++------- .../worksheet/test_energy_requirements.py | 34 +++++++++++++++++ 5 files changed, 122 insertions(+), 18 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 92373fbe..3d85d19a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4861,6 +4861,8 @@ def _elmhurst_main_heating_category( def _map_elmhurst_main_heating_2( mh2: Optional[ElmhurstMainHeating2], + *, + fallback_fuel_type: Union[int, str, None] = None, ) -> Optional[MainHeatingDetail]: """Build a `MainHeatingDetail` from the Elmhurst §14.1 Main Heating2 block. Returns None when no Main 2 is lodged (extractor convention: @@ -4896,6 +4898,20 @@ def _map_elmhurst_main_heating_2( and mh2.main_heating_sap_code in _ELECTRIC_SAP_MAIN_HEATING_CODES ): main_fuel_int = _STANDARD_ELECTRICITY_FUEL_CODE + # §14.1 Main Heating2 often omits the "Fuel Type" cell when the + # second main system shares Main 1's fuel (simulated case 6: one oil + # boiler feeding radiators + underfloor, so the Summary lodges the + # fuel once on §14.0). Inherit Main 1's resolved fuel so the §9a + # two-main split (213)m can apply system 2's own efficiency. + resolved_fuel: Union[int, str] = ( + main_fuel_int + if main_fuel_int is not None + else ( + fallback_fuel_type + if (not mh2.fuel_type and fallback_fuel_type is not None) + else mh2.fuel_type + ) + ) category: Optional[int] = None if pcdb_index is not None and heat_pump_record(pcdb_index) is not None: category = _ELMHURST_HEATING_CATEGORY_HEAT_PUMP @@ -4906,7 +4922,7 @@ def _map_elmhurst_main_heating_2( # cert-level renewables block is the single source of truth and # is already wired into Main 1. has_fghrs=False, - main_fuel_type=main_fuel_int if main_fuel_int is not None else mh2.fuel_type, + main_fuel_type=resolved_fuel, # §14.1 doesn't lodge a heat emitter (the emitter is Main 1's # radiator/UFH); leave as empty-string sentinel for cascade # consumers that key off Main 1's emitter. @@ -5110,7 +5126,9 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: # services DHW via `Water Heating SapCode 914` ("from second main # system") while Main 1 handles space heat. None when the §14.1 # block is absent or lodges only placeholder zeros. - main_2_detail = _map_elmhurst_main_heating_2(mh.main_heating_2) + main_2_detail = _map_elmhurst_main_heating_2( + mh.main_heating_2, fallback_fuel_type=main_1_detail.main_fuel_type + ) main_heating_details = ( [main_1_detail, main_2_detail] if main_2_detail is not None diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 6b33c3f7..3252b924 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -425,9 +425,15 @@ def _solve_month( # SAP 10.2 §8 — (98c)m precomputed upstream by `space_heating_monthly_kwh` # (includes Table 9c summer clamp Jun..Sep). Calculator indexes directly. q_heat = inputs.space_heating_monthly_kwh[month - 1] - # SAP 10.2 §9a — (211)m/(215)m precomputed upstream by + # SAP 10.2 §9a — (211)m/(213)m/(215)m precomputed upstream by # `space_heating_fuel_monthly_kwh`. Calculator stops doing q/η inline. - fuel_main = inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1] + # `main_heating_fuel_kwh` aggregates both main systems (213)m is zero + # for single-main certs, so this is the per-system sum for dual-main + # dwellings (cert 0240 / simulated case 6) and main-1 alone otherwise. + fuel_main = ( + inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1] + + inputs.energy_requirements.main_2_fuel_monthly_kwh[month - 1] + ) fuel_secondary = inputs.energy_requirements.secondary_fuel_monthly_kwh[month - 1] # SAP 10.2 §8c — (107)m precomputed upstream by `space_cooling_monthly_kwh` diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 2f4eb570..db29fa0a 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1570,7 +1570,16 @@ def _main_heating_efficiency(epc: EpcPropertyData) -> float: seasonal efficiency → heat-network 1/DLF override. Used by §4 (water heating cascade) and §9a (per-system fuel kWh) — both must see the same value, so this single helper is the single source of truth.""" - main = _first_main_heating(epc) + return _main_heating_detail_efficiency(_first_main_heating(epc), epc) + + +def _main_heating_detail_efficiency( + main: Optional[MainHeatingDetail], epc: EpcPropertyData +) -> float: + """SAP 10.2 (206)/(207) efficiency (0..1) for a SPECIFIC main heating + detail — the per-detail core of `_main_heating_efficiency`. Used for + both main system 1 (206) and main system 2 (207) on dual-main certs + (cert 0240 / simulated case 6).""" main_code = main.sap_main_heating_code if main is not None else None main_category = main.main_heating_category if main is not None else None main_fuel = _main_fuel_code(main) @@ -3956,11 +3965,25 @@ def energy_requirements_section_from_cert( if secondary_fraction_value > 0.0 else 0.0 ) eff = _main_heating_efficiency(epc) + # SAP 10.2 §9a two-main split (203)/(207): when a second main heating + # system is lodged, (203) = its `main_heating_fraction` (% of main + # heating it supplies) and (207) = its own seasonal efficiency. Cert + # 0240 (2× oil code 130, 51/49) + simulated case 6 (oil code 127, + # rads 51% + underfloor 49%) exercise this. + details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + main_2 = details[1] if len(details) >= 2 else None + main_2_of_main_fraction = 0.0 + main_2_efficiency_value = 0.0 + if main_2 is not None and main_2.main_heating_fraction is not None: + main_2_of_main_fraction = main_2.main_heating_fraction / 100.0 + main_2_efficiency_value = _main_heating_detail_efficiency(main_2, epc) return space_heating_fuel_monthly_kwh( space_heating_monthly_kwh=sh.total_space_heating_monthly_kwh, secondary_heating_fraction=secondary_fraction_value, main_heating_efficiency_pct=eff * 100.0, secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0, + main_2_of_main_fraction=main_2_of_main_fraction, + main_2_efficiency_pct=main_2_efficiency_value * 100.0, ) @@ -6351,11 +6374,22 @@ def cert_to_inputs( secondary_efficiency_value = _secondary_efficiency( epc.sap_heating, main_code, main_fuel ) + # SAP 10.2 §9a two-main split (203)/(207) — see the section helper + # `energy_requirements_section_from_cert` for the rationale. + _main_details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + _main_2 = _main_details[1] if len(_main_details) >= 2 else None + main_2_of_main_fraction = 0.0 + main_2_efficiency_value = 0.0 + if _main_2 is not None and _main_2.main_heating_fraction is not None: + main_2_of_main_fraction = _main_2.main_heating_fraction / 100.0 + main_2_efficiency_value = _main_heating_detail_efficiency(_main_2, epc) energy_requirements_result = space_heating_fuel_monthly_kwh( space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh, secondary_heating_fraction=secondary_fraction_value, main_heating_efficiency_pct=eff * 100.0, secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0, + main_2_of_main_fraction=main_2_of_main_fraction, + main_2_efficiency_pct=main_2_efficiency_value * 100.0, ) # SAP 10.2 Appendix M1 §3-4 (p.93-94): split monthly PV generation diff --git a/domain/sap10_calculator/worksheet/energy_requirements.py b/domain/sap10_calculator/worksheet/energy_requirements.py index 44a15ed6..ccd8d738 100644 --- a/domain/sap10_calculator/worksheet/energy_requirements.py +++ b/domain/sap10_calculator/worksheet/energy_requirements.py @@ -11,8 +11,10 @@ where (204) = (202) × (1 − (203)) and (202) = 1 − (201). Single-main case ((203) = 0) collapses (204) to (202), so (211)m = (98c)m × (202) × 100 / (206). Same shape for secondary (215)m and main 2 (213)m. -Two-main split ((203) > 0) and cooling-fuel (209)/(221) are zero-branch -placeholders in scope A — populated once first cert exercises them. +Two-main split ((203) > 0) is implemented: (211)m = (98c)m × (204) × +100 / (206) for system 1 and (213)m = (98c)m × (205) × 100 / (207) for +system 2, where (204) = (202) × (1 − (203)) and (205) = (202) × (203). +Cooling-fuel (209)/(221) remains a zero-branch placeholder. Reference: SAP 10.2 specification (14-03-2025) §9a (lines 7909-7953). """ @@ -26,10 +28,9 @@ from dataclasses import dataclass class EnergyRequirementsResult: """SAP 10.2 §9a worksheet line refs (201)..(221). - Scope-A populated lines: (201), (202), (204), (206), (208), (211)m, - (211), (215)m, (215). Two-main and cooling-fuel line refs ((203), - (205), (207), (209), (213)m, (213), (221)) are zero-branch - placeholders until the first multi-main / fixed-AC cert lands. + Populated lines: (201)-(208), (211)m/(211), (213)m/(213) (two-main + split), (215)m/(215). Cooling-fuel line refs ((209), (221)) are + zero-branch placeholders until the first fixed-AC cert lands. """ # Fractions (Table 11) @@ -60,26 +61,37 @@ def space_heating_fuel_monthly_kwh( secondary_heating_fraction: float, main_heating_efficiency_pct: float, secondary_heating_efficiency_pct: float, + main_2_of_main_fraction: float = 0.0, + main_2_efficiency_pct: float = 0.0, ) -> EnergyRequirementsResult: """SAP 10.2 §9a orchestrator — produce (201)..(221) line refs. - Scope A: single-main + secondary only. Two-main ((203) > 0) and - cooling-fuel (Table 10c SEER) populate the zero-branch placeholder - fields with computed values when their respective slices land. + Single-main certs leave `main_2_of_main_fraction` = 0, collapsing + (204) to (202) and zeroing (213)m. Dual-main certs (cert 0240 / + simulated case 6) pass (203) = fraction of main heating from main + system 2 and (207) = main system 2 efficiency; the §8 space-heat + demand then splits (204)=(202)×(1−(203)) to system 1 and + (205)=(202)×(203) to system 2, each at its own efficiency. Cooling- + fuel (Table 10c SEER) remains a zero-branch placeholder. """ fraction_201 = secondary_heating_fraction fraction_202 = 1.0 - fraction_201 - fraction_203 = 0.0 # scope A: no main 2 + fraction_203 = main_2_of_main_fraction fraction_204 = fraction_202 * (1.0 - fraction_203) fraction_205 = fraction_202 * fraction_203 main_1_eff = main_heating_efficiency_pct + main_2_eff = main_2_efficiency_pct secondary_eff = secondary_heating_efficiency_pct main_1_fuel_monthly = tuple( q * fraction_204 * 100.0 / main_1_eff if main_1_eff > 0 else 0.0 for q in space_heating_monthly_kwh ) + main_2_fuel_monthly = tuple( + q * fraction_205 * 100.0 / main_2_eff if main_2_eff > 0 else 0.0 + for q in space_heating_monthly_kwh + ) secondary_fuel_monthly = tuple( q * fraction_201 * 100.0 / secondary_eff if secondary_eff > 0 else 0.0 for q in space_heating_monthly_kwh @@ -92,14 +104,14 @@ def space_heating_fuel_monthly_kwh( main_1_of_total_fraction=fraction_204, main_2_of_total_fraction=fraction_205, main_1_efficiency_pct=main_1_eff, - main_2_efficiency_pct=0.0, + main_2_efficiency_pct=main_2_eff, secondary_efficiency_pct=secondary_eff, cooling_seer=0.0, main_1_fuel_monthly_kwh=main_1_fuel_monthly, - main_2_fuel_monthly_kwh=(0.0,) * 12, + main_2_fuel_monthly_kwh=main_2_fuel_monthly, secondary_fuel_monthly_kwh=secondary_fuel_monthly, main_1_fuel_kwh_per_yr=sum(main_1_fuel_monthly), - main_2_fuel_kwh_per_yr=0.0, + main_2_fuel_kwh_per_yr=sum(main_2_fuel_monthly), secondary_fuel_kwh_per_yr=sum(secondary_fuel_monthly), cooling_fuel_kwh_per_yr=0.0, ) diff --git a/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py b/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py index 0f63ec26..7bcd9400 100644 --- a/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py +++ b/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py @@ -60,6 +60,40 @@ def test_table_11_secondary_fraction_splits_q_heat_between_main_and_secondary() assert result.secondary_fuel_kwh_per_yr == 550.0 +def test_two_main_systems_split_q_heat_by_fraction_203_at_own_efficiencies() -> None: + """Spec §9a (203)/(204)/(205) two-main split: when a second main + system supplies (203) of the main heating, (204)=(202)×(1−(203)) goes + to system 1 at (206) and (205)=(202)×(203) to system 2 at (207). With + no secondary ((202)=1), (203)=0.49, eff1=79%, eff2=84%, Σ(98c)=4400: + Σ(211) = 4400 × 0.51 × 100/79 = 2840.5063 kWh + Σ(213) = 4400 × 0.49 × 100/84 = 2566.6667 kWh + Mirrors simulated case 6 (oil boiler, radiators 51% + underfloor 49%) + and cert 0240 (identical-efficiency systems collapse to the single- + main total).""" + # Arrange + monthly_space_heating = ( + 1000.0, 800.0, 600.0, 400.0, 200.0, 0.0, + 0.0, 0.0, 0.0, 200.0, 400.0, 800.0, + ) + + # Act + result = space_heating_fuel_monthly_kwh( + space_heating_monthly_kwh=monthly_space_heating, + secondary_heating_fraction=0.0, + main_heating_efficiency_pct=79.0, + secondary_heating_efficiency_pct=0.0, + main_2_of_main_fraction=0.49, + main_2_efficiency_pct=84.0, + ) + + # Assert + assert abs(result.main_2_of_main_fraction - 0.49) <= 1e-12 + assert abs(result.main_1_of_total_fraction - 0.51) <= 1e-12 + assert abs(result.main_2_of_total_fraction - 0.49) <= 1e-12 + assert abs(result.main_1_fuel_kwh_per_yr - 4400.0 * 0.51 * 100.0 / 79.0) <= 1e-9 + assert abs(result.main_2_fuel_kwh_per_yr - 4400.0 * 0.49 * 100.0 / 84.0) <= 1e-9 + + def test_per_month_fuel_preserves_summer_clamp_zeros_from_98c() -> None: """The §8 Table 9c summer clamp zeros (98c)m for Jun..Sep. §9a's per- month (211)m / (215)m tuples are linear in (98c)m so they inherit the From 558aaf6d097023d45451e1a56f7c3f9952857428 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:21:49 +0000 Subject: [PATCH 11/80] =?UTF-8?q?docs:=20handover=20post=20S0380.200=20?= =?UTF-8?q?=E2=80=94=206035+0240=20closed;=20boiler-interlock=20=E2=88=925?= =?UTF-8?q?pp=20OPEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the session's window/RR/dual-main work (S0380.196–200) and the open priority: a spec-accurate per-system boiler-interlock −5pp (Table 4c(2)) adjustment. Root cause for case 6's remaining deltas (sys-1 eff 79 not 84 + HW 4824 vs 4902) is the "room thermostat present but no cylinder thermostat → no interlock" path that the current {2101,2102} no-interlock rule misses. 0240 shares the controls + cylinder_thermostat=N so it will re-pin (apply spec uniformly). Secondary: dual-system Table 4f pumps. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_POST_S0380_200.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md diff --git a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md new file mode 100644 index 00000000..f3ad76cc --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md @@ -0,0 +1,154 @@ +# Handover — post S0380.200 (dual-main split done; boiler-interlock −5pp OPEN) + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, +accuracy bar, and pipeline — this records *what this session did* and *what is open*. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `8ae978a6` (S0380.200) +- **Baseline:** `2355 passed, 1 skipped, 0 failed`. Verify with the AGENT_GUIDE §4 suite command. + +--- + +## What this session shipped (S0380.196–200) + +The through-line: **golden certs 6035 and 0240 were both closed to SAP-exact** +by finding real API-mapper bugs (not "lodged divergence"), each confirmed +against a user-generated Elmhurst worksheet ("simulated case 5/6"). + +| Slice | What | Spec | +|---|---|---| +| **.196** | API Simplified Type 1 room-in-roof: `room_in_roof_type_1` gables (length-only, no height) weren't deducted from the A_RR shell → whole shell billed as roof at U_RR=2.30 (+52 W/K). Route them through `detailed_surfaces` (gable area = L × 2.45 default RR storey height). **6035 SAP −2→+0 exact**, PE +19.16→+1.84. | RdSAP 10 §3.9.1(e) p.21; Table 4 p.22 | +| **.197** | Promoted "simulated case 5" (detached sandstone RR) to e2e fixture (`001431_case5`, 11 pins @1e-4). Fixed sandstone wall label `"SS"`→2 (`_ELMHURST_WALL_CODE_TO_SAP10`) + `_parse_thickness_mm` for "400+ mm" roof insulation (trailing `+` was dropped → u_roof fell to age default). | — | +| **.198** | **API `window_wall_type=4` → roof window.** These are roof-of-room rooflights; the mapper flattened them into `sap_windows` (vertical, (27), U=2.0) instead of `sap_roof_windows` ((27a), inclined U=2.30 + 45° solar). The inclined solar dominates → **0240 SAP −1→+0 exact**, PE +3.91→+1.95; 6035 PE +1.84→+1.37. Discriminator is `wall_type==4` NOT `window_type==2` (0390/7536 lodge window_type=2 on main walls). | SAP 10.2 §3 (27a); Table 6e Note 2 | +| **.199** | Site-notes mirror of .198: extractor parses "Roof of Room" window rows (`_parse_window_from_anchors`); `_is_elmhurst_roof_window` location branch; `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING["Double between 2002 and 2021"]=2.30`. Case 6 pinned on §3 windows (`test_section_3_roof_windows_case6_match_pdf`): (27)=22.7408, (27a)=13.0375 exact. | RdSAP 10 §3.7 | +| **.200** | **SAP 10.2 §9a two-main-heating split** (203)/(204)/(205)/(207)/(213). The cascade lumped a 2-main dwelling into one system. Now `space_heating_fuel_monthly_kwh` splits demand (204) to sys1 @ (206) + (205) to sys2 @ (207); `_solve_month` sums main_1+main_2; `_main_heating_detail_efficiency` (new, the per-detail core of `_main_heating_efficiency`) gives each system its own efficiency. Site-notes: `_map_elmhurst_main_heating_2` inherits Main 1's fuel when §14.1 omits Fuel Type. Cost/CO2/PE main_2 paths were already wired. 0240 unchanged (identical Eq-D1 systems). | SAP 10.2 §9a | + +Two new e2e fixtures: `001431_case5` (full SapResult, S0380.197) and +`001431_case6` (§3 windows only, S0380.199 — see why below). Source PDFs +tracked under `sap worksheets/golden fixture debugging/simulated case {5,6}/`; +Summaries mirrored to `backend/documents_parser/tests/fixtures/Summary_001431_case{5,6}.pdf`. + +--- + +## OPEN (the priority) — boiler-interlock −5pp efficiency adjustment, per main system + +**Goal:** a RdSAP-10/SAP-10.2 **spec-accurate** implementation of the boiler +interlock efficiency adjustment, applied **per main heating system**, done in +the established pattern of this domain (per-line walk → cite spec → TDD → +re-pin). This is the last gap blocking full closure of simulated **case 6**, +and it will also re-pin golden **0240**. + +### The evidence (simulated case 6) + +`sap worksheets/golden fixture debugging/simulated case 6/` — detached, dual +**oil** boiler (both SAP code **127**, base seasonal eff **84%**), radiators 51% +(control **2106**) + underfloor 49% (control **2110**). Its P960 worksheet: + +| line | worksheet | meaning | +|---|---|---| +| (206) main sys-1 eff | **79.0** | 84 − **5pp** | +| (207) main sys-2 eff | **84.0** | base, no penalty | +| (216) water-heater eff | **72.0** | also penalised (DHW leg of the −5pp) | +| "Temperature adjustment" | 0.0000 | **flow temp has NO effect** — this is NOT a flow-temperature feature | + +Summary §14 lodges it explicitly: system 1 **"Boiler Interlock: No"**, system 2 +**"Boiler Interlock: Yes"**. The 84→79 is the SAP 10.2 **Table 4c(2)** "no boiler +interlock" −5pp **Space + DHW** adjustment (same mechanism as the AGENT_GUIDE +"oil 6" worked example, S0380.177 — but that one fired off control 2101). + +### Why control 2106 (which HAS a room thermostat) is "no interlock" + +Per RdSAP 10 boiler-interlock rules (find + cite the exact §; the existing +`_NO_INTERLOCK_CONTROLS = {2101, 2102}` block in `cert_to_inputs.py` ~line 1238 +quotes "RdSAP 10 §3 p.57: boiler interlock is assumed present if there is a room +thermostat and [time control], AND — when there is a hot-water cylinder — a +cylinder thermostat; otherwise not interlocked"): system 1 serves the **DHW +cylinder**, the cylinder is present (`Hot Water Cylinder Present: Yes`) but +**`Cylinder Thermostat: No`** → interlock **not** present → −5pp, *despite* the +room thermostat. System 2 (underfloor, separate part, no cylinder interaction) +keeps interlock via its zone control → no penalty. + +So the determination is **not** "control ∈ {2101,2102}". It is, per system: +`interlock present` ⇔ (room thermostat present, from the control code) AND +(time/programmer control) AND (cylinder absent OR cylinder thermostat present). +The current cascade only catches the "no room thermostat" path (2101/2102); it +misses the "room thermostat present but no cylinder thermostat" path that 2106 +hits here. + +### This single root cause explains BOTH remaining case-6 deltas + +- space heating: sys-1 eff 79 not 84 → main fuel cascade 14925 vs ws **14736.96** +- hot water: the −5pp DHW leg → cascade HW 4824 vs ws **4902.86** (lower cascade + fuel ⇒ cascade eff too high ⇒ missing the penalty) + +### 0240 will shift — and that is correct (apply the spec uniformly) + +Golden **0240** has the SAME controls (sys1 2106 / sys2 2110) AND the same +`cylinder_thermostat = "N"` with a cylinder present. So the spec-correct rule +applies the −5pp to 0240's system 1 too. 0240 is currently SAP-exact (continuous +72.55) **without** the penalty — that is an offsetting coincidence (it's API-only, +±0.5 bar, no worksheet). Per [[feedback-software-no-special-handling]] + +[[feedback-spec-floor-skepticism]]: implement the spec rule, let 0240 shift, and +**re-pin** it with a documented note. Expect 0240 continuous SAP to drop ~0.3–0.5 +(may take the integer 73→72; if so the golden `expected_sap_resid` moves −1 and +that is the new truth). Measure precisely and re-pin PE/CO2 too. + +### Where to implement (per-line walk first, then TDD) + +1. **Interlock determination.** Add a per-`MainHeatingDetail` helper, e.g. + `_boiler_interlock_present(main, epc) -> bool`, encoding the RdSAP 10 rule + above (room thermostat from control code + cylinder-thermostat gate when a + cylinder is present). `epc.sap_heating.cylinder_thermostat` ("Y"/"N") and + `cylinder_size`/`hot_water_cylinder_present` are the cylinder signals. The + site-notes path already lodges `cylinder_thermostat` (mapper.py ~5183, string + "Y"/"N"); the API path lodges it on `sap_heating.cylinder_thermostat` (0240 = + "N"). +2. **Apply Table 4c(2) −5pp per system.** The existing −5pp lives near the + `_NO_INTERLOCK_CONTROLS` block — find how it currently adjusts the seasonal + efficiency for 2101/2102 and generalise it to fire on + `not _boiler_interlock_present(main, epc)`, applied inside + `_main_heating_detail_efficiency` so **each** main system gets its own + adjustment (sys1 −5, sys2 0). Confirm the DHW leg (water-heater efficiency + (216)) is penalised too — the §4 water-heating cascade reads + `_main_heating_efficiency`; verify the −5pp flows there (case 6 (216)=72 + is the check). +3. **Verify combi vs regular rows of Table 4c(2).** The "no interlock" −5pp has a + combi row (Space −5 / DHW 0) and a regular-boiler row (Space −5 / DHW −5); + the DHW leg is gated on cylinder presence. Case 6 is a regular oil boiler with + a cylinder → DHW −5 applies (hence (216)=72). Read the table; don't assume. + +### Validation target + +After the fix, **promote case 6 to a full SapResult e2e fixture** (it's currently +§3-windows-only because the lumped efficiency made (211)/(219)/(231) non- +comparable). Case 6 worksheet Block-1 pin grid (P960-0001-001431): +- SAP 72 (258), continuous **71.6597**, ECF **2.0316** (257) +- total fuel cost **1162.5374** (255), CO2 **5953.6679** (272) +- (211) main sys-1 fuel **7741.6458**, (213) main sys-2 fuel **6995.3106** + (SapResult.main_heating_fuel_kwh_per_yr should be the sum **14736.9564**) +- hot water **4902.8601** (219), lighting **357.6571** (232) +- pumps/fans **356.0** (231) — **see the SECOND open item below** + +### SECONDARY open item — dual-system auxiliary pumps (Table 4f) + +Case 6 (231) = **356** = (230c) central-heating pump 156 + (230d) oil-boiler pump +200. Cascade gives **241**. Two boilers → two pump contributions per Table 4f +(note c: "where there are two main heating systems include two figures from this +table" — same note already used for the 0240 oil-pump in S0380.148). Needs the +per-system pump aggregation. Smaller than the interlock fix; do it after, then +case 6's (231) pin closes and the full e2e fixture lands. + +--- + +## Process notes +- One slice = one commit, spec citation (page + line) in the message, + `Co-Authored-By: Claude Opus 4.8 ` trailer. +- AAA tests (`# Arrange/# Act/# Assert`), `abs(x-y) <= tol` (not `pytest.approx`). +- New code passes `pyright` strict, 0 errors. (mapper.py + cert_to_inputs.py each + carry **32 pre-existing** errors — don't add to them; check with a `git stash` + baseline comparison.) +- The Elmhurst worksheet is ground truth at abs=1e-4. 0240 is API-only (±0.5 + fallback) — case 6 is its worksheet-backed proxy for the heating archetype, but + differs from 0240 on the boiler SAP code (127 vs 0240's 130 condensing combi), + so pin case 6 to ITS OWN worksheet, not 0240's register. +- Suite command + section/e2e harness layout: AGENT_GUIDE §2.6 + §4. From 963db2ae230cf861172b89f44b235a1e0f74eb0b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:51:13 +0000 Subject: [PATCH 12/80] S0380.201: SAP 10.2 Table 4f note c) second main-system circulation pump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simulated case 6 (P960-0001-001431, dual oil boiler 51% rads + 49% underfloor) worksheet (231) = 356 = (230c) central-heating pump 156 + (230d) oil boiler pump 200. (230c) decomposes per SAP 10.2 Table 4f note c) (PDF p.175): "Where there are two main heating systems include two figures from this table" — Main 1 41 kWh (pump age "2013 or later") + Main 2 115 kWh (pump age unknown). The cascade summed only Main 1's circulation pump, giving (231) = 241. cert_to_inputs now adds the second main's circulation pump, gated on a lodged main_heating_fraction > 0 (a genuine second SPACE-heating main — the same test §9a uses to split space-heating demand). This excludes DHW-only second mains (cert 000565 Main 2 = gas combi via WHC 914, fraction 0); without the gate 000565's worksheet pins regressed +115 kWh. Re-pin: golden 0240 (dual-main oil combi, API-only, no worksheet) gains its Main 2 pump too (pumps_fans 315 → 430). Spec-correct per note c and validated by the case-6 worksheet; SAP cont 72.55 → 72.18 (integer 73 → 72, resid +0 → -1), PE +1.9459 → +2.8092, CO2 +0.1226 → +0.1385. The lodged 73 carries Elmhurst's own residual; the worksheet- backed case 6 is the spec authority for the archetype. Note: the boiler-interlock −5pp per-main determination the prior handover flagged as the priority is already implemented (S0380.141 cylinder-thermostat path + S0380.177 room-thermostat path) — case 6 already produces (206)=79 / (207)=84 exactly, and 0240 is a combi with no cylinder so correctly unpenalised. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 21 +++++++++++++++ .../rdsap/test_golden_fixtures.py | 26 ++++++++++++++++--- .../_elmhurst_worksheet_001431_case6.py | 8 ++++++ .../worksheet/test_section_cascade_pins.py | 25 ++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index db29fa0a..68c91620 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -5924,6 +5924,27 @@ def cert_to_inputs( has_balanced_mv=_has_balanced_mechanical_ventilation(epc), ) ) + # SAP 10.2 Table 4f note c) (PDF p.175): "Where there are two main + # heating systems include two figures from this table." A genuine + # second SPACE-heating main therefore contributes its own circulation + # pump alongside Main 1's. The "second main heating system" test is the + # same one §9a uses to split space-heating demand: a lodged + # `main_heating_fraction > 0`. This excludes DHW-only second mains + # (e.g. cert 000565 Main 2 = gas combi via WHC 914, fraction 0 — water + # heating only, no space-heating circulation pump). Simulated case 6 + # (dual oil boiler, 51% rads + 49% underfloor) lodges Main 1 "2013 or + # later" (41 kWh) + Main 2 unknown-date (115 kWh) → worksheet (230c) + # central-heating pump = 41 + 115 = 156. The Main 2 oil-boiler aux + # (230d) is already summed in `_table_4f_additive_components`; this + # adds only the circulation pump. + _pumps_main_details = ( + epc.sap_heating.main_heating_details if epc.sap_heating else [] + ) + if len(_pumps_main_details) >= 2: + _pumps_main_2 = _pumps_main_details[1] + _pumps_main_2_fraction = _pumps_main_2.main_heating_fraction + if _pumps_main_2_fraction is not None and _pumps_main_2_fraction > 0: + pumps_fans_kwh += _table_4f_circulation_pump_kwh(_pumps_main_2) pumps_fans_kwh += _table_4f_additive_components(epc) # Track the MEV/MVHR-fan portion separately so the cost cascade can # apply Table 12a Grid 2 `FANS_FOR_MECH_VENT` (0.58 high-frac on diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 03f5fe93..847e7d0d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -82,9 +82,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0240-0200-5706-2365-8010", actual_sap=73, - expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+1.9459, - expected_co2_resid_tonnes_per_yr=+0.1226, + expected_sap_resid=-1, + expected_pe_resid_kwh_per_m2=+2.8092, + expected_co2_resid_tonnes_per_yr=+0.1385, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -136,7 +136,25 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "45°-inclined solar gains. Validated against the simulated-" "case-6 worksheet ((27a) U_eff 2.1062). The inclined solar " "gain dominates → SAP cont 72.14 → 72.55 (resid -1 → +0 " - "EXACT), PE +3.9138 → +1.9459, CO2 +0.2213 → +0.1226." + "EXACT), PE +3.9138 → +1.9459, CO2 +0.2213 → +0.1226. " + "Slice S0380.201 added the SECOND main heating system's " + "circulation pump per SAP 10.2 Table 4f note c) (PDF p.175) " + "\"Where there are two main heating systems include two " + "figures from this table\" — gated on a lodged " + "main_heating_fraction > 0 (a genuine second SPACE-heating " + "main, excluding DHW-only second mains). This cert is dual-" + "main oil combi 51%/49%; Main 2 pump_age unknown (115 kWh) " + "joins Main 1's 115 → cascade pumps_fans 315 → 430 (+115 " + "kWh/yr). The fix was validated against the simulated-case-6 " + "worksheet, whose (231) = 356 decomposes as (230c) central-" + "heating pump 156 (= Main 1 41 + Main 2 115) + (230d) oil " + "boiler pump 200 — proving the two-pump treatment is spec-" + "correct. Cascade SAP cont 72.55 → 72.18 (integer 73 → 72, " + "resid +0 → -1), PE +1.9459 → +2.8092, CO2 +0.1226 → " + "+0.1385. The lodged 73 carries Elmhurst's own rounding/" + "residual (this cert is API-only with no worksheet); the " + "worksheet-backed case 6 is the spec authority for the " + "archetype per [[feedback-worksheet-not-api-reference]]." ), ), _GoldenExpectation( diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py index 94b6d7e8..57a07537 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -69,6 +69,14 @@ LINE_27_WINDOWS_W_PER_K: Final[float] = 22.7408 LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 13.0375 LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 336.13 +# Worksheet (231) "Total electricity for the above, kWh/year" (Block 1). +# Decomposes as (230c) central heating pump 156 + (230d) oil boiler pump +# 200. (230c) = 41 (Main 1 circ pump, "2013 or later") + 115 (Main 2 circ +# pump, unknown date) — the two-main-system circulation-pump pair per +# SAP 10.2 Table 4f note c. (230d) = 2 × 100 oil-boiler aux (already +# wired in `_table_4f_additive_components`). +LINE_231_PUMPS_FANS_KWH: Final[float] = 356.0 + def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: """Convert a Summary PDF into the per-page text format the diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 396247f6..c8db9ec1 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -278,6 +278,31 @@ def test_section_3_roof_windows_case6_match_pdf() -> None: ) +def test_section_4f_pumps_fans_case6_match_pdf() -> None: + """(231) pumps/fans pin for simulated case 6 — a DUAL-oil-boiler + detached dwelling. Worksheet (231) = 356 = (230c) central heating + pump 156 + (230d) oil boiler pump 200. (230c) is itself the two- + main-system circulation-pump pair per SAP 10.2 Table 4f note c + ("Where there are two main heating systems include two figures from + this table"): Main 1 41 kWh (pump age "2013 or later") + Main 2 115 + kWh (pump age unknown). The pre-S0380.201 cascade summed only Main 1's + circulation pump (41) and gave (231) = 241.""" + from domain.sap10_calculator.calculator import calculate_sap_from_inputs + + # Arrange + epc = _w001431_case6.build_epc() + + # Act + result = calculate_sap_from_inputs(cert_to_inputs(epc)) + + # Assert + _pin( + result.pumps_fans_kwh_per_yr, + _w001431_case6.LINE_231_PUMPS_FANS_KWH, + "§4f (231) case6", + ) + + # ============================================================================ # §4 Water heating — LINE_42..LINE_65 scalar + monthly tuples # ============================================================================ From 4ed691603f9f26e728b567fd0b20f3e648cf10f0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:52:13 +0000 Subject: [PATCH 13/80] =?UTF-8?q?docs:=20correct=20S0380.200=20handover=20?= =?UTF-8?q?=E2=80=94=20interlock=20was=20already=20done;=20S0380.201=20clo?= =?UTF-8?q?sed=20pumps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flagged "priority" (per-main boiler interlock −5pp) was already implemented (S0380.141 cylinder-thermostat path + S0380.177 room- thermostat path); case 6 already produces (206)=79/(207)=84 exactly and 0240 is a combi with no cylinder. Records that S0380.201 closed the secondary dual-system pump item and the remaining case-6 gaps (space demand +1.28%, HW −1.6%) for full-SapResult promotion. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_POST_S0380_200.md | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md index f3ad76cc..2ab9570d 100644 --- a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md +++ b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_200.md @@ -30,7 +30,39 @@ Summaries mirrored to `backend/documents_parser/tests/fixtures/Summary_001431_ca --- -## OPEN (the priority) — boiler-interlock −5pp efficiency adjustment, per main system +## ⚠️ CORRECTION (post S0380.201) — the interlock priority was ALREADY DONE + +The "priority" below was **misdiagnosed**. At HEAD the cascade already +produces case 6 (206) sys-1 eff = **79.0** and (207) sys-2 eff = **84.0**, +matching the worksheet exactly. The cylinder-thermostat interlock path +(`no_stored_hw_interlock = has_cylinder and cylinder_thermostat != "Y"`) +has existed since **S0380.141**; the room-thermostat path since S0380.177. +`no_interlock = no_room_thermostat OR no_stored_hw_interlock` — it does NOT +only catch 2101/2102. Toggling case 6 `cylinder_thermostat` N→Y flips eff +0.79→0.84, confirming the −5pp fires. Golden **0240 is a combi** +(`has_hot_water_cylinder=False`) → correctly NOT penalised; its predicted +re-pin from the interlock is void. The misread came from +`energy_requirements_section_from_cert` (a §2.4 debug helper using raw +`_main_heating_efficiency`, which reports 84 — the real `cert_to_inputs` +cascade applies the −5pp at ~line 6071). See [[feedback-verify-handover-claims]]. + +**S0380.201 landed the SECONDARY item** (dual-system aux pumps): SAP 10.2 +Table 4f note c) second main-system circulation pump, gated on a lodged +`main_heating_fraction > 0`. Case 6 (231) 241 → **356** EXACT (= 41 Main-1 +pump + 115 Main-2 pump + 200 oil aux). 0240 re-pinned (pumps 315 → 430, +integer 73 → 72, resid +0 → -1, PE +2.8092, CO2 +0.1385) — anticipated +and authorised below. 000565 protected by the fraction>0 gate (its Main 2 +is a DHW-only combi, fraction 0). + +**Remaining case-6 gaps for full-SapResult promotion** (vs P960-0001-001431): +- (98c) space demand cascade **12145.31** vs ws **11991.96** (+1.28%) — + living-area MIT (87) ~0.3 °C low in winter; multi-causal (gains/heat-loss). +- (219) hot water cascade **4824.74** vs ws **4902.86** (−1.6%) — §4 walk needed. +Once both close, promote case 6 to a full SapResult e2e fixture (pin grid below). + +--- + +## OPEN (was the priority, now DONE) — boiler-interlock −5pp efficiency adjustment, per main system **Goal:** a RdSAP-10/SAP-10.2 **spec-accurate** implementation of the boiler interlock efficiency adjustment, applied **per main heating system**, done in From 3581513b7e90dcee60b28c7e020e00e4ad00fc53 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 14:35:08 +0000 Subject: [PATCH 14/80] S0380.202: SAP 10.2 Table 5a note a) second main-system pump gain (70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §5 (70) internal-gains mirror of S0380.201's Table 4f (230c). SAP 10.2 Table 5a note a) (PDF p.177) verbatim: "Where there are two main heating systems serving different parts of the dwelling, assume each has its own circulation pump and therefore include two figures from this table. ... Where two main systems serve the same space a single pump is assumed." Simulated case 6 (dual oil, 51% radiators + 49% underfloor) lodges Main 1 "2013 or later" (3 W) + Main 2 unknown date (7 W) → worksheet (70) = 10 W in the 8 heating months. The cascade billed a single Main 1 pump (3 W). New `_second_main_central_heating_pump_gain_w` adds the second main's gain (at its own pump-age bucket), gated on a lodged main_heating_fraction > 0 — the same genuine-second-space-heating-main test as S0380.201, so DHW-only second mains (cert 000565 Main 2 combi via WHC 914, fraction 0) keep a single pump (70)=3. Refactored the per-detail pump predicate (`_main_detail_has_central_heating_pump`) and date bucket (`_pump_date_category_for_detail`) out of the orchestrator. Re-pin: golden 0240 (dual-main oil combi, both unknown date) (70) 7 → 14 W; the extra internal gain lowers space-heating demand → SAP cont 72.18 → 72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 +0.1385 → +0.1269 (both closer to zero). Validated against the case-6 worksheet. This closes the (70) leg of case 6's space-demand gap. Remaining for full case-6 closure: roof fabric (37) +1.176 W/K (room-in-roof shell) and HW (216) Eq-D1 water efficiency −1.6%. Co-Authored-By: Claude Opus 4.8 --- .../worksheet/internal_gains.py | 117 +++++++++++++----- .../rdsap/test_golden_fixtures.py | 15 ++- .../_elmhurst_worksheet_001431_case6.py | 9 ++ .../worksheet/test_section_cascade_pins.py | 25 ++++ 4 files changed, 131 insertions(+), 35 deletions(-) diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index 8e6784e1..509ac363 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -27,9 +27,13 @@ from dataclasses import dataclass from decimal import Decimal, ROUND_HALF_UP from enum import Enum from math import cos, exp, pi -from typing import Final +from typing import Final, Optional -from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow +from datatypes.epc.domain.epc_property_data import ( + EpcPropertyData, + MainHeatingDetail, + SapWindow, +) def _decimal_window_area_2dp(width: float, height: float) -> float: @@ -634,15 +638,15 @@ def _daylight_factor_from_cert( return 52.2 * g_l * g_l - 9.94 * g_l + 1.433 -def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory: - """Map first main-heating detail's central_heating_pump_age_str to a +def _pump_date_category_for_detail( + detail: Optional[MainHeatingDetail], +) -> PumpDateCategory: + """Map a `MainHeatingDetail`'s central_heating_pump_age_str to a Table 5a bucket. Elmhurst lodges "Pre 2013" / "Post 2013" / "Unknown" - / None on each `MainHeatingDetail` (nested under `epc.sap_heating`).""" - sap_heating = getattr(epc, "sap_heating", None) - details = getattr(sap_heating, "main_heating_details", None) or [] + / None on each detail.""" age_str = "" - if details: - age_str = (details[0].central_heating_pump_age_str or "").lower() + if detail is not None: + age_str = (detail.central_heating_pump_age_str or "").lower() if "post" in age_str or "2013 or later" in age_str: return PumpDateCategory.NEW_2013_OR_LATER if "pre" in age_str or "2012" in age_str: @@ -650,6 +654,14 @@ def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory: return PumpDateCategory.UNKNOWN +def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory: + """Table 5a date bucket for Main 1 (the dwelling's first circulation + pump). Delegates to `_pump_date_category_for_detail`.""" + sap_heating = getattr(epc, "sap_heating", None) + details = getattr(sap_heating, "main_heating_details", None) or [] + return _pump_date_category_for_detail(details[0] if details else None) + + # SAP 10.2 Table 5a Note a) (PDF p.177): "Not applicable for electric # heat pumps from database." The pump GAIN (worksheet line 70) is # omitted only for HP-category systems. Where the cert lodges a @@ -730,33 +742,69 @@ def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool: details = epc.sap_heating.main_heating_details if not details: return False - for d in details: - if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY: - # PCDB Table 362 record → pump electricity AND gain are - # embedded in COP (Appendix N1.2.1); no separate gain row. - if d.main_heating_index_number is not None: - continue - # Cat 5 warm-air HP (codes 521/523-527) → no water pump. - code = d.sap_main_heating_code - if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES: - continue - # Cat 4 HP, Table 4a default cascade → apply Table 5a - # pump gain per Appendix N3.1. - return True - code = d.sap_main_heating_code - if code is not None and any( - code in r for r in _WET_BOILER_SAP_CODE_RANGES - ): - return True + return any(_main_detail_has_central_heating_pump(d) for d in details) + + +def _main_detail_has_central_heating_pump(d: MainHeatingDetail) -> bool: + """Whether a single `MainHeatingDetail` carries a Table 5a central- + heating-pump gain — the per-detail core of + `_any_main_system_has_central_heating_pump` (see that docstring for + the wet-main identification + HP rules).""" + if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY: + # PCDB Table 362 record → pump electricity AND gain are + # embedded in COP (Appendix N1.2.1); no separate gain row. if d.main_heating_index_number is not None: - return True - if d.main_heating_category in {1, 2}: - return True - if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES: - return True + return False + # Cat 5 warm-air HP (codes 521/523-527) → no water pump. + code = d.sap_main_heating_code + if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES: + return False + # Cat 4 HP, Table 4a default cascade → apply Table 5a + # pump gain per Appendix N3.1. + return True + code = d.sap_main_heating_code + if code is not None and any(code in r for r in _WET_BOILER_SAP_CODE_RANGES): + return True + if d.main_heating_index_number is not None: + return True + if d.main_heating_category in {1, 2}: + return True + if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES: + return True return False +def _second_main_central_heating_pump_gain_w(epc: EpcPropertyData) -> float: + """SAP 10.2 Table 5a note a) (PDF p.177): "Where there are two main + heating systems serving different parts of the dwelling, assume each + has its own circulation pump and therefore include two figures from + this table. ... Where two main systems serve the same space a single + pump is assumed." + + Returns the SECOND main system's central-heating-pump gain (W, + heating-season) when a genuine second SPACE-heating main is lodged — + detected by `main_heating_fraction > 0`, the same gate + `cert_to_inputs` uses to split §9a space-heating demand and to add + the Table 4f note c) second circulation pump (S0380.201). Excludes + DHW-only second mains (fraction 0, e.g. cert 000565 Main 2 combi via + WHC 914). The gain uses the SECOND main's own pump-age bucket — for + simulated case 6 (dual oil, Main 2 unknown date) that is 7 W, giving + worksheet (70) = 3 (Main 1) + 7 (Main 2) = 10. + """ + details = epc.sap_heating.main_heating_details + if len(details) < 2: + return 0.0 + second = details[1] + fraction = second.main_heating_fraction + if fraction is None or fraction <= 0: + return 0.0 + if not _main_detail_has_central_heating_pump(second): + return 0.0 + return central_heating_pump_w( + date_category=_pump_date_category_for_detail(second) + ) + + # SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. The # Table 5a "Warm air heating system fans" gain (and Table 4f # electricity row) fire for these mains: @@ -881,6 +929,11 @@ def internal_gains_from_cert( pump_w = central_heating_pump_w( date_category=_pump_date_category_from_cert(epc) ) + # SAP 10.2 Table 5a note a) — a second main heating system serving + # a different part of the dwelling has its own circulation pump + # (two figures from the table). Simulated case 6 (dual oil, rads + + # underfloor) → Main 1 3 W + Main 2 7 W = worksheet (70) 10 W. + pump_w += _second_main_central_heating_pump_gain_w(epc) else: pump_w = 0.0 # SAP 10.2 Table 5a row "Warm air heating system fans a) c)" (PDF diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 847e7d0d..d656440d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+2.8092, - expected_co2_resid_tonnes_per_yr=+0.1385, + expected_pe_resid_kwh_per_m2=+2.5812, + expected_co2_resid_tonnes_per_yr=+0.1269, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -154,7 +154,16 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "+0.1385. The lodged 73 carries Elmhurst's own rounding/" "residual (this cert is API-only with no worksheet); the " "worksheet-backed case 6 is the spec authority for the " - "archetype per [[feedback-worksheet-not-api-reference]]." + "archetype per [[feedback-worksheet-not-api-reference]]. " + "Slice S0380.202 added the SECOND main's central-heating-pump " + "GAIN per SAP 10.2 Table 5a note a) (PDF p.177) \"two main " + "heating systems serving different parts ... include two " + "figures\" — the §5 (70) mirror of S0380.201's Table 4f (230c). " + "Both Main 1 + Main 2 unknown-date → (70) 7 → 14 W. The extra " + "internal gain lowers space-heating demand → SAP cont 72.18 → " + "72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 " + "+0.1385 → +0.1269 (both closer to zero). Validated against " + "case 6 worksheet (70) = 10 (= 3 Main 1 + 7 Main 2)." ), ), _GoldenExpectation( diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py index 57a07537..56b91e60 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -77,6 +77,15 @@ LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 336.13 # wired in `_table_4f_additive_components`). LINE_231_PUMPS_FANS_KWH: Final[float] = 356.0 +# Worksheet (70) "Pumps, fans" internal-gain (W), heating-season only +# (Jun-Sep = 0). = 10 W = the two-main-system central-heating-pump pair +# per SAP 10.2 Table 5a note a): Main 1 ("2013 or later" → 3 W) + Main 2 +# (unknown date → 7 W). The pre-S0380.202 cascade billed a single Main 1 +# pump (3 W). +LINE_70_PUMPS_FANS_GAINS_W: Final[tuple[float, ...]] = ( + 10.0, 10.0, 10.0, 10.0, 10.0, 0.0, 0.0, 0.0, 0.0, 10.0, 10.0, 10.0, +) + def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: """Convert a Summary PDF into the per-page text format the diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index c8db9ec1..e00194c6 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -303,6 +303,31 @@ def test_section_4f_pumps_fans_case6_match_pdf() -> None: ) +def test_section_5_pumps_fans_gains_case6_match_pdf() -> None: + """(70) pumps/fans internal-gain pin for simulated case 6. The dual oil + boiler serves different parts (51% radiators + 49% underfloor), so SAP + 10.2 Table 5a note a) ("Where there are two main heating systems serving + different parts of the dwelling, assume each has its own circulation + pump and therefore include two figures from this table") bills TWO + central-heating-pump gains: Main 1 "2013 or later" (3 W) + Main 2 + unknown date (7 W) = 10 W in the 8 heating months. The pre-S0380.202 + cascade billed a single Main 1 pump (3 W).""" + # Arrange + epc = _w001431_case6.build_epc() + + # Act + ig = internal_gains_section_from_cert(epc) + + # Assert + assert ig is not None + for m in range(12): + _pin( + ig.pumps_fans_monthly_w[m], + _w001431_case6.LINE_70_PUMPS_FANS_GAINS_W[m], + f"§5 (70) case6 month {m + 1}", + ) + + # ============================================================================ # §4 Water heating — LINE_42..LINE_65 scalar + monthly tuples # ============================================================================ From a42e03529c2fa7dad628a86eda4e5bc144db5159 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 15:19:37 +0000 Subject: [PATCH 15/80] =?UTF-8?q?S0380.203:=20RdSAP=2010=20=C2=A73.7=20?= =?UTF-8?q?=E2=80=94=20"Roof=20of=20Room"=20rooflights=20deduct=20from=20t?= =?UTF-8?q?he=20RR=20residual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rooflight deducts from the gross area of the roof element it pierces (RdSAP 10 §3.7, PDF p.19). A "Roof of Room" rooflight (window_wall_type=4 / site-notes "Roof of Room") sits on the room-in-roof sloped ceiling, so its area must deduct from the §3.10.1 RR residual roof — not the flat / loft external roof. The cascade deducted every rooflight from the regular roof (heat_ transmission line 814). Simulated case 6's worksheet is the first worksheet evidence for "Roof of Room" rooflight billing: "Roof room Main remaining area" net 55.54 = gross 61.73 − 6.19 rooflights (U_RR=0.30), while "External roof Main" 14.52 carries no opening. New `_bp_rr_roof_absorbs_rooflight` routes the rooflight area to the RR roof (simplified A_RR_final or detailed §3.10.1 residual) ONLY when the BP's RR contributes such a shell AND lodges no explicit roof surface (slope / flat_ceiling / stud_wall). Case 6 roof (30) 20.2284 → 19.0523 EXACT; demand gap +153 → +61 kWh/yr. Preserved: certs 000565 (Ext2 stud walls) and 000516 (slopes) lodge explicit roof surfaces → rooflight keeps deducting from the regular roof (their 1e-4 worksheet pins hold). Simplified Type 1 RR is excluded too. Re-pin (uniform spec application per [[feedback-software-no-special- handling]] + worksheet-is-truth): API certs 6035 and 0240 are detailed-RR gables-only like case 6 (no worksheet of their own for rooflights), so their "Roof of Room" rooflights now deduct from the RR residual too. This SUPERSEDES the unvalidated S0380.198 "deduct from loft" assumption. - 6035: roof 78.0648 → 73.9176; the previously-"unexplained" +1.37 PE residual COLLAPSES to -0.14 (CO2 -0.0004 → -0.0362; SAP exact 70) — strong corroboration the rooflight-on-RR treatment is correct. - 0240: PE +2.5812 → +2.1519, CO2 +0.1269 → +0.1051 (SAP 72 unchanged). Co-Authored-By: Claude Opus 4.8 --- .../worksheet/heat_transmission.py | 69 ++++++++++++++++++- .../rdsap/test_golden_fixtures.py | 48 +++++++++---- .../_elmhurst_worksheet_001431_case6.py | 7 ++ .../worksheet/test_section_cascade_pins.py | 5 ++ 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 1c0a6f0c..ea1107fc 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -450,6 +450,45 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: } +_RR_ROOF_LODGEMENT_KINDS: Final[frozenset[str]] = frozenset( + {"slope", "flat_ceiling", "stud_wall"} +) + + +def _bp_rr_roof_absorbs_rooflight( + part: SapBuildingPart, geom: dict[str, Any] +) -> bool: + """Whether a rooflight on this building part pierces the room-in-roof + sloped ceiling (so it deducts from the RR roof contribution) rather + than a flat external roof. + + True ONLY for a Detailed RR (§3.10) lodging wall surfaces but no roof + surfaces (gable / common / connected, no slope / flat_ceiling / + stud_wall): the §3.10.1 residual roof fires and the rooflight deducts + from it (simulated case 6: the 6 "Roof of Room" rooflights deduct from + "Roof room Main remaining" net 55.54 = gross 61.73 − 6.19). + + False otherwise: + - Simplified Type 1/2 RR (geom A_RR > 0, certs 6035 / 0240): the + rooflight pierces the regular loft roof at U_roof, NOT the A_RR + shell — its area deducts from `roof_area` (the test + `test_6035_api_room_in_roof_gables_deduct_from_roof` pins this). + - Detailed RR lodging explicit roof surfaces (cert 000565 Ext2 stud + walls / 000516 slopes): the rooflight pierces the regular roof. + Both keep the pre-S0380.203 §3.7 "deduct from the host roof" behaviour. + """ + if geom["rr_simplified_a_rr_m2"] > 0: + return False + rir = part.sap_room_in_roof + if rir is None or not rir.detailed_surfaces: + return False + if float(rir.floor_area) <= 0.0: + return False + return not any( + s.kind in _RR_ROOF_LODGEMENT_KINDS for s in rir.detailed_surfaces + ) + + def heat_transmission_from_cert( epc: EpcPropertyData, *, @@ -811,7 +850,23 @@ def heat_transmission_from_cert( if "sloping ceiling" in roof_type: top_floor_area = top_floor_area / _COS_30_DEG gross_roof_area = _round_half_up(top_floor_area, _AREA_ROUND_DP) - roof_area = max(0.0, gross_roof_area - rw_area_part) + # RdSAP 10 §3.7 — a rooflight deducts from the gross roof of the + # element it physically pierces. A "Roof of Room" rooflight sits on + # the room-in-roof sloped ceiling (the §3.9/§3.10 A_RR shell), not a + # flat external roof, so its area deducts from the RR roof + # contribution (simplified A_RR_final or the §3.10.1 detailed + # residual) rather than `roof_area` — but ONLY when the BP's RR + # actually contributes such a shell/residual. Where the BP lodges + # explicit roof surfaces (cert 000565 Ext2 stud walls / 000516 + # slopes), the rooflight pierces those (the regular roof) and + # deducts there per §3.7 (current behaviour). Simulated case 6 + # worksheet: "Roof room Main remaining area" net 55.54 = gross 61.73 + # − 6.19 rooflights, while "External roof Main" 14.52 carries no + # opening. + rw_area_on_rr = ( + rw_area_part if _bp_rr_roof_absorbs_rooflight(part, geom) else 0.0 + ) + roof_area = max(0.0, gross_roof_area - (rw_area_part - rw_area_on_rr)) floor_area_total = _round_half_up( geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0, _AREA_ROUND_DP, @@ -868,7 +923,9 @@ def heat_transmission_from_cert( rir = part.sap_room_in_roof assert rir is not None # rr_a_rr > 0 ⇒ rir present per _part_geometry walls += uw * (rr_common + rr_gable) - a_rr_final = max(0.0, rr_a_rr - rr_common - rr_gable) + # Deduct any "Roof of Room" rooflights piercing the RR shell + # (see `rw_area_on_rr` rationale at the gross-roof block). + a_rr_final = max(0.0, rr_a_rr - rr_common - rr_gable - rw_area_on_rr) u_rr = u_rr_default_all_elements( country=country, age_band=rir.construction_age_band, ) @@ -1003,7 +1060,13 @@ def heat_transmission_from_cert( a_rr_shell = _round_half_up( 12.5 * sqrt(rr_floor_for_a_rr / 1.5), _AREA_ROUND_DP, ) - residual_area = max(0.0, a_rr_shell - rr_walls_in_a_rr_area) + # Deduct any "Roof of Room" rooflights piercing the RR + # residual (see `rw_area_on_rr` rationale at the gross-roof + # block) — case 6: 93.09 shell − 31.36 gables − 6.19 + # rooflights = 55.54 net = worksheet "Roof room remaining". + residual_area = max( + 0.0, a_rr_shell - rr_walls_in_a_rr_area - rw_area_on_rr + ) if residual_area > 0.0: rr_detailed_area += residual_area roof += residual_area * u_rr_default_all_elements( diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index d656440d..c1507af0 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+2.5812, - expected_co2_resid_tonnes_per_yr=+0.1269, + expected_pe_resid_kwh_per_m2=+2.1519, + expected_co2_resid_tonnes_per_yr=+0.1051, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -163,7 +163,13 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "internal gain lowers space-heating demand → SAP cont 72.18 → " "72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 " "+0.1385 → +0.1269 (both closer to zero). Validated against " - "case 6 worksheet (70) = 10 (= 3 Main 1 + 7 Main 2)." + "case 6 worksheet (70) = 10 (= 3 Main 1 + 7 Main 2). " + "Slice S0380.203 routed this cert's 6 'Roof of Room' rooflights " + "(window_wall_type=4) to deduct from the §3.10.1 RR residual " + "instead of the regular roof (the case-6 worksheet rule). 0240 " + "is detailed-RR gables-only like case 6 → roof drops → space-" + "heating demand falls → PE +2.5812 → +2.1519, CO2 +0.1269 → " + "+0.1051 (both closer to zero; SAP integer 72 unchanged)." ), ), _GoldenExpectation( @@ -237,8 +243,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="6035-7729-2309-0879-2296", actual_sap=70, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+1.3743, - expected_co2_resid_tonnes_per_yr=-0.0004, + expected_pe_resid_kwh_per_m2=-0.1357, + expected_co2_resid_tonnes_per_yr=-0.0362, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "S0380.189 fixed the dominant driver: walls are solid brick " @@ -288,8 +294,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "rooflights) which were billed as vertical glazing; routing " "them to roof windows (27a) at inclined U=2.30 + 45° solar " "tightened PE +1.84 → +1.37 and CO2 +0.01 → -0.0004 (SAP still " - "exact). Remaining +1.37 PE is unrelated gains/HW (no " - "worksheet for 6035 itself to pin further)." + "exact). " + "Slice S0380.203 CLOSED the remaining +1.37 PE (it was NOT " + "'unrelated gains/HW'): the 2 'Roof of Room' rooflights pierce " + "the room-in-roof sloped ceiling, so their 1.92 m² deducts from " + "the §3.10.1 RR residual (uninsulated U_RR=2.30) — not the " + "insulated loft (U=0.14) the S0380.198 assumption used. Roof " + "78.0648 → 73.9176 (−4.42 W/K); space-heating demand drops → " + "PE +1.37 → -0.14, CO2 -0.0004 → -0.0362 (SAP still exact 70). " + "Validated against the simulated-case-6 worksheet, the only " + "worksheet evidence for 'Roof of Room' rooflight deduction " + "(6035's site-notes case-4 replica lodges no rooflights)." ), ), _GoldenExpectation( @@ -856,9 +871,16 @@ def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None: # Act roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k - # Assert — 78.3336 (gable-deducted residual + loft + ext roof) less - # the S0380.198 deduction of 6035's 2 room-in-roof rooflights - # (window_wall_type=4, 2 × 1.2×0.8 = 1.92 m²) from the gross roof at - # U_roof=0.14 → 78.3336 − 0.2688 = 78.0648. The rooflights' own A×U - # moves to roof_windows_w_per_k. - assert abs(roof_w_per_k - 78.0648) <= 1e-4 + # Assert — 78.3336 (gable-deducted residual + loft + ext roof). The 2 + # room-in-roof rooflights (window_wall_type=4 = "Roof of Room", 1.92 m²) + # pierce the RR sloped ceiling, so per S0380.203 their area deducts from + # the §3.10.1 residual (at the uninsulated U_RR=2.30) — NOT the insulated + # loft at U_roof=0.14 as the unvalidated S0380.198 assumption had it. + # 78.3336 − 1.92 × 2.30 = 78.3336 − 4.416 = 73.9176. The rooflights' own + # A×U stays on roof_windows_w_per_k. This matches the simulated-case-6 + # worksheet, where the only worksheet evidence for "Roof of Room" + # rooflight deduction shows them billed against "Roof room remaining" + # (the RR residual), not the flat/loft roof. Cert 6035 is API-only and + # its site-notes case-4 worksheet replica lodges no rooflights, so the + # case-6 worksheet is the spec authority for this archetype. + assert abs(roof_w_per_k - 73.9176) <= 1e-4 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py index 56b91e60..c9f1d820 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -69,6 +69,13 @@ LINE_27_WINDOWS_W_PER_K: Final[float] = 22.7408 LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 13.0375 LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 336.13 +# Worksheet (30) Roof total W/K = RR remaining (net of the 6.19 m² roof +# windows) 55.54 × 0.30 = 16.6620 + External roof Main 14.52 × 0.11 = +# 1.5972 + External roof Ext1 7.21 × 0.11 = 0.7931 → 19.0523. The 6 "Roof +# of Room" rooflights pierce the room-in-roof sloped ceiling, so their +# area deducts from the RR residual area, NOT the external flat roof. +LINE_30_ROOF_W_PER_K: Final[float] = 19.0523 + # Worksheet (231) "Total electricity for the above, kWh/year" (Block 1). # Decomposes as (230c) central heating pump 156 + (230d) oil boiler pump # 200. (230c) = 41 (Main 1 circ pump, "2013 or later") + 115 (Main 2 circ diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index e00194c6..a6952cb4 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -276,6 +276,11 @@ def test_section_3_roof_windows_case6_match_pdf() -> None: _w001431_case6.LINE_31_TOTAL_EXTERNAL_AREA_M2, "§3 (31) case6", ) + _pin( + ht.roof_w_per_k, + _w001431_case6.LINE_30_ROOF_W_PER_K, + "§3 (30) case6", + ) def test_section_4f_pumps_fans_case6_match_pdf() -> None: From 2b1afa733934130e6ef2c6a90760f91877ea9374 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 15:53:32 +0000 Subject: [PATCH 16/80] =?UTF-8?q?S0380.204:=20extract=20Main=20Heating2's?= =?UTF-8?q?=20own=20emitter=20+=20control=20(=C2=A714.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prerequisite for the SAP 10.2 p.186 two-systems-different-parts MIT. When two main systems heat different parts of a dwelling, §14.1 Main Heating2 lodges its OWN "Heat Emitter" + "Main Heating Controls Sap" (simulated case 6: Main 1 radiators / control 2106 serving the living area, Main 2 underfloor / control 2110 serving elsewhere). The extractor + mapper dropped both — `MainHeatingDetail.heat_emitter_type` and `main_heating_control` came through as empty-string sentinels, so the cascade saw system 2 as having no responsiveness (defaulted R=1.0) and no control type. - `MainHeating2` datatype gains `heat_emitter` + `heating_controls_sap`. - The extractor reads them from the §14.1 block. - `_map_elmhurst_main_heating_2` maps them via the same helpers as Main 1 (`_elmhurst_heat_emitter_int` → underfloor-in-screed = emitter 2, Table 4d R=0.75; `_elmhurst_sap_control_code` → 2110, Table 4e type 3), threading the dwelling floor + age band for the underfloor subtype. Empty-string fallback preserved for the legacy DHW-only Main 2 (cert 000565 §14.1 omits emitter/control). No cascade output changes yet — the MIT consumer lands in S0380.205. Full suite 2358 pass + 0 fail. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 2 ++ datatypes/epc/domain/mapper.py | 29 +++++++++++++++---- datatypes/epc/surveys/elmhurst_site_notes.py | 9 ++++++ .../worksheet/test_section_cascade_pins.py | 18 ++++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index a8b1893f..2523acb0 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1346,6 +1346,8 @@ class ElmhurstSiteNotesExtractor: fan_assisted_flue=self._local_bool(lines, "Fan Assisted Flue"), percentage_of_heat=pct, main_heating_sap_code=main_heating_sap_code, + heat_emitter=self._local_str(lines, "Heat Emitter"), + heating_controls_sap=self._local_str(lines, "Main Heating Controls Sap"), ) def _extract_community_heating(self) -> Optional[CommunityHeating]: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 3d85d19a..d7cb95b2 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4863,6 +4863,8 @@ def _map_elmhurst_main_heating_2( mh2: Optional[ElmhurstMainHeating2], *, fallback_fuel_type: Union[int, str, None] = None, + main_floor: Optional[ElmhurstFloorDetails] = None, + main_age_band: Optional[str] = None, ) -> Optional[MainHeatingDetail]: """Build a `MainHeatingDetail` from the Elmhurst §14.1 Main Heating2 block. Returns None when no Main 2 is lodged (extractor convention: @@ -4917,20 +4919,32 @@ def _map_elmhurst_main_heating_2( category = _ELMHURST_HEATING_CATEGORY_HEAT_PUMP elif pcdb_index is not None and mh2.fuel_type in _ELMHURST_GAS_BOILER_FUEL_TYPES: category = _ELMHURST_HEATING_CATEGORY_GAS_BOILER + # §14.1 lodges Main 2's own "Heat Emitter" + "Main Heating Controls + # Sap" when the two systems heat different parts of the dwelling + # (simulated case 6: Main 1 radiators / 2106, Main 2 underfloor / + # 2110). Map them through the same helpers as Main 1 so the SAP 10.2 + # p.186 two-systems-different-parts MIT can read system 2's + # responsiveness (underfloor → emitter 2 → R=0.75) + control type. + # Empty-string sentinels preserved for the legacy DHW-only Main 2 + # (cert 000565: §14.1 omits emitter/control → consumers key off + # Main 1). + emitter_int = _elmhurst_heat_emitter_int( + mh2.heat_emitter, main_floor=main_floor, main_age_band=main_age_band + ) + control_int = _elmhurst_sap_control_code(mh2.heating_controls_sap) return MainHeatingDetail( # Main 2 doesn't carry its own FGHRS lodgement in §14.1; the # cert-level renewables block is the single source of truth and # is already wired into Main 1. has_fghrs=False, main_fuel_type=resolved_fuel, - # §14.1 doesn't lodge a heat emitter (the emitter is Main 1's - # radiator/UFH); leave as empty-string sentinel for cascade - # consumers that key off Main 1's emitter. - heat_emitter_type="", + heat_emitter_type=( + emitter_int if emitter_int is not None else mh2.heat_emitter + ), emitter_temperature="", fan_flue_present=mh2.fan_assisted_flue, boiler_flue_type=_elmhurst_flue_type_int(mh2.flue_type), - main_heating_control="", + main_heating_control=control_int if control_int is not None else "", main_heating_category=category, main_heating_number=2, main_heating_fraction=mh2.percentage_of_heat, @@ -5127,7 +5141,10 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: # system") while Main 1 handles space heat. None when the §14.1 # block is absent or lodges only placeholder zeros. main_2_detail = _map_elmhurst_main_heating_2( - mh.main_heating_2, fallback_fuel_type=main_1_detail.main_fuel_type + mh.main_heating_2, + fallback_fuel_type=main_1_detail.main_fuel_type, + main_floor=survey.floor, + main_age_band=survey.construction_age_band, ) main_heating_details = ( [main_1_detail, main_2_detail] diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index eb2b8885..f524ac79 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -244,6 +244,15 @@ class MainHeating2: fan_assisted_flue: bool = False percentage_of_heat: int = 0 main_heating_sap_code: Optional[int] = None + # §14.1 "Heat Emitter" (e.g. "Underfloor Heating") + "Main Heating + # Controls Sap" (e.g. "SAP code 2110, ..."). Lodged when the two main + # systems serve different parts of the dwelling with their own + # emitter + control (simulated case 6: Main 1 radiators / control + # 2106, Main 2 underfloor / control 2110). Needed for the SAP 10.2 + # p.186 two-systems-different-parts MIT (weighted responsiveness + + # elsewhere two-control blend). + heat_emitter: str = "" + heating_controls_sap: str = "" @dataclass diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index a6952cb4..57937a58 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -283,6 +283,24 @@ def test_section_3_roof_windows_case6_match_pdf() -> None: ) +def test_case6_main_2_emitter_and_control_extracted() -> None: + """Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter + ("Underfloor Heating") and control ("SAP code 2110, ...") — the two + main systems heat different parts (Main 1 radiators/2106 living, Main + 2 underfloor/2110 elsewhere). Pre-S0380.204 the extractor + mapper + dropped both (emitter='' / control=''), so the SAP 10.2 p.186 two- + systems-different-parts MIT could not read system 2's responsiveness + (underfloor → emitter 2 → R=0.75) or control type (2110 → type 3).""" + # Arrange / Act + epc = _w001431_case6.build_epc() + main_2 = epc.sap_heating.main_heating_details[1] + + # Assert — emitter 2 (underfloor in screed → Table 4d R=0.75) + + # control 2110 (Table 4e type 3 zone control). + assert main_2.heat_emitter_type == 2 + assert main_2.main_heating_control == 2110 + + def test_section_4f_pumps_fans_case6_match_pdf() -> None: """(231) pumps/fans pin for simulated case 6 — a DUAL-oil-boiler detached dwelling. Worksheet (231) = 356 = (230c) central heating From e440e2df2eee21bcf933621f3c46fe2d309844b7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 16:02:56 +0000 Subject: [PATCH 17/80] S0380.205: SAP 10.2 p.186 two-systems-different-parts MIT (weighted R + elsewhere blend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two main heating systems heat different parts of a dwelling, SAP 10.2 §7 (PDF p.186) adapts the mean-internal-temperature calculation: - Table 9b weighted responsiveness: R = (1−(203))·R_sys1 + (203)·R_sys2. - Rest-of-dwelling temperature (90)m = weighted average of T2 computed under EACH system's control schedule, weights (203)/[1−(91)] for sys2 and [1−(203)−(91)]/[1−(91)] for sys1 (or sys2's control alone when (203) ≥ 1−(91)). The cascade used Main 1's control + R=1.0 for the whole dwelling, over-stating MIT by +0.037 °C on simulated case 6 (Main 1 radiators/2106 type 2 living + Main 2 underfloor/2110 type 3 elsewhere, R 1.0/0.75). That inflated (97) heat loss by ~11 W → demand +61 kWh/yr. `mean_internal_temperature_monthly` gains `main_2_control_type`, `main_2_fraction`, `main_2_responsiveness`; cert_to_inputs derives them from the second main detail (gated on main_heating_fraction > 0, so single-main / DHW-only second mains pass the defaults → unchanged). Case 6: (87) living, (90) elsewhere, (98c) demand 11991.96 and per-system fuel (211)=7741.6458 / (213)=6995.3106 all match the worksheet to 1e-4. Re-pin: golden 0240 (same 2106/2110 archetype, API-only) — PE +2.1519 → +1.6893, CO2 +0.1051 → +0.0815 (both closer to zero; SAP 72 unchanged). Single-main certs unchanged (2360 pass + 0 fail). Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 30 +++++++++- .../worksheet/mean_internal_temperature.py | 56 ++++++++++++++++++- .../rdsap/test_golden_fixtures.py | 13 ++++- .../_elmhurst_worksheet_001431_case6.py | 8 +++ .../worksheet/test_section_cascade_pins.py | 27 +++++++++ 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 68c91620..6cec1313 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -6250,12 +6250,33 @@ def cert_to_inputs( # = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0 # for the Elmhurst corpus (cert-side mapping is a future slice). control_type_value = _control_type(main) - responsiveness_value = _responsiveness( - main, tariff=tariff_from_meter_type(epc.sap_energy_source.meter_type), - ) + _mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type) + responsiveness_value = _responsiveness(main, tariff=_mit_tariff) living_area_fraction_value = _living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ) + # SAP 10.2 Table 9b weighted R + p.186 two-systems-different-parts MIT. + # A genuine second main (main_heating_fraction > 0 = (203)) contributes + # its own responsiveness (Table 9b weighted average) and, when it + # carries a different control type, its own rest-of-dwelling control + # schedule. `_first_main_heating` is system 1 (living area); the second + # detail is system 2. Single-main / DHW-only second mains (frac 0) pass + # the None/0 defaults → unchanged single-system MIT. + _mit_details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + _mit_main_2 = _mit_details[1] if len(_mit_details) >= 2 else None + main_2_control_type_value: Optional[int] = None + main_2_fraction_value = 0.0 + main_2_responsiveness_value = 1.0 + if ( + _mit_main_2 is not None + and _mit_main_2.main_heating_fraction is not None + and _mit_main_2.main_heating_fraction > 0 + ): + main_2_control_type_value = _control_type(_mit_main_2) + main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0 + main_2_responsiveness_value = _responsiveness( + _mit_main_2, tariff=_mit_tariff + ) monthly_total_gains_w = tuple( internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) ) @@ -6281,6 +6302,9 @@ def cert_to_inputs( responsiveness=responsiveness_value, living_area_fraction=living_area_fraction_value, control_temperature_adjustment_c=_control_temperature_adjustment_c(main), + main_2_control_type=main_2_control_type_value, + main_2_fraction=main_2_fraction_value, + main_2_responsiveness=main_2_responsiveness_value, extended_heating_days_per_month=extended_heating_days, ) diff --git a/domain/sap10_calculator/worksheet/mean_internal_temperature.py b/domain/sap10_calculator/worksheet/mean_internal_temperature.py index 8e562ed4..7cfa637e 100644 --- a/domain/sap10_calculator/worksheet/mean_internal_temperature.py +++ b/domain/sap10_calculator/worksheet/mean_internal_temperature.py @@ -321,6 +321,9 @@ def mean_internal_temperature_monthly( control_temperature_adjustment_c: float = 0.0, secondary_fraction: float = 0.0, secondary_responsiveness: float = 1.0, + main_2_control_type: Optional[int] = None, + main_2_fraction: float = 0.0, + main_2_responsiveness: float = 1.0, extended_heating_days_per_month: Optional[tuple[tuple[int, int], ...]] = None, ) -> MeanInternalTemperatureResult: """SAP 10.2 §7 orchestrator — chain Table 9c steps 1–9 for all 12 months. @@ -354,13 +357,36 @@ def mean_internal_temperature_monthly( standard SAP heating schedule applies: T_zone = T_bimodal directly. """ + # SAP 10.2 Table 9b (PDF p.183) — "where there are two main systems R + # is a weighted average ... R = (203)·R_system2 + [1 − (203)]·R_system1". + # (203) = `main_2_fraction`. Applied before the secondary-heating blend. + main_responsiveness = responsiveness + if main_2_control_type is not None and main_2_fraction > 0.0: + main_responsiveness = ( + (1.0 - main_2_fraction) * responsiveness + + main_2_fraction * main_2_responsiveness + ) effective_responsiveness = ( - (1.0 - secondary_fraction) * responsiveness + (1.0 - secondary_fraction) * main_responsiveness + secondary_fraction * secondary_responsiveness ) elsewhere_off_hours = ( _ELSEWHERE_OFF_HOURS_TYPE_3 if control_type == 3 else _ELSEWHERE_OFF_HOURS_TYPE_12 ) + # SAP 10.2 p.186 "two systems heat different parts of the house": when + # the two mains carry different controls, the rest-of-dwelling (90)m is + # the weighted average of T2 computed under EACH system's control. The + # elsewhere off-hours for main system 2's control: + two_main_different_parts = ( + main_2_control_type is not None + and main_2_fraction > 0.0 + and main_2_control_type != control_type + ) + elsewhere_off_hours_main_2 = ( + _ELSEWHERE_OFF_HOURS_TYPE_3 + if main_2_control_type == 3 + else _ELSEWHERE_OFF_HOURS_TYPE_12 + ) eta_living: list[float] = [] t_1: list[float] = [] @@ -408,6 +434,34 @@ def mean_internal_temperature_monthly( ) eta_elsewhere.append(eta_e) + # SAP 10.2 p.186 part 2 — two systems heat different parts: blend + # the rest-of-dwelling temperature computed under each system's + # control. Th2 + η are identical for control types 2/3 (Table 9 + # uses the same Th2 formula); only the off-hours differ, so the + # second computation reuses t_h2_m and shares η. Weights: + # sys2 control: (203) / [1 − (91)] + # sys1 control: [1 − (203) − (91)] / [1 − (91)] + # If (203) ≥ rest-of-house area [1 − (91)], use sys2's control + # alone for elsewhere (per the spec's threshold clause). + if two_main_different_parts: + rest_of_house = 1.0 - living_area_fraction + _, t_e_main_2 = _zone_mean_temp_with_per_zone_eta( + heating_temperature_c=t_h2_m, + off_hours_first=elsewhere_off_hours_main_2[0], + off_hours_second=elsewhere_off_hours_main_2[1], + external_temp_c=ext, responsiveness=effective_responsiveness, + total_gains_w=gains, heat_transfer_coefficient_w_per_k=h, + time_constant_h=tau, + ) + if rest_of_house <= 0.0 or main_2_fraction >= rest_of_house: + t_e_bimodal = t_e_main_2 + else: + w_main_2 = main_2_fraction / rest_of_house + w_main_1 = ( + rest_of_house - main_2_fraction + ) / rest_of_house + t_e_bimodal = w_main_1 * t_e_bimodal + w_main_2 * t_e_main_2 + # SAP 10.2 Appendix N3.5 Equation N5 — when the caller provides # per-month (N24,9, N16,9) day allocations, blend Th / T_unimodal # / T_bimodal for each zone. T_unimodal applies one 8-hour off diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index c1507af0..381895b5 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+2.1519, - expected_co2_resid_tonnes_per_yr=+0.1051, + expected_pe_resid_kwh_per_m2=+1.6893, + expected_co2_resid_tonnes_per_yr=+0.0815, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -169,7 +169,14 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "instead of the regular roof (the case-6 worksheet rule). 0240 " "is detailed-RR gables-only like case 6 → roof drops → space-" "heating demand falls → PE +2.5812 → +2.1519, CO2 +0.1269 → " - "+0.1051 (both closer to zero; SAP integer 72 unchanged)." + "+0.1051 (both closer to zero; SAP integer 72 unchanged). " + "Slice S0380.205 applied the SAP 10.2 p.186 two-systems-" + "different-parts MIT (Main 1 2106 type 2 / Main 2 2110 type 3, " + "emitter 2 R=0.75): weighted responsiveness 0.8775 + elsewhere " + "two-control blend. Lowers MIT ~0.037 °C → space-heating demand " + "falls → PE +2.1519 → +1.6893, CO2 +0.1051 → +0.0815 (both " + "closer to zero; SAP integer 72 unchanged). Verified 1e-4 " + "against the case-6 worksheet (87)/(90)/(98c)." ), ), _GoldenExpectation( diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py index c9f1d820..95e4824f 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -84,6 +84,14 @@ LINE_30_ROOF_W_PER_K: Final[float] = 19.0523 # wired in `_table_4f_additive_components`). LINE_231_PUMPS_FANS_KWH: Final[float] = 356.0 +# Worksheet (211)/(213) per-system space-heating fuel (kWh/yr). The dual +# oil boiler heats different parts (Main 1 radiators/2106 living 51%, Main +# 2 underfloor/2110 elsewhere 49%) — the SAP 10.2 p.186 two-systems- +# different-parts MIT (weighted R 0.8775 + elsewhere two-control blend) +# lands (98c) demand 11991.96 exact, so the per-system fuels pin. +LINE_211_MAIN_1_FUEL_KWH: Final[float] = 7741.6458 +LINE_213_MAIN_2_FUEL_KWH: Final[float] = 6995.3106 + # Worksheet (70) "Pumps, fans" internal-gain (W), heating-season only # (Jun-Sep = 0). = 10 W = the two-main-system central-heating-pump pair # per SAP 10.2 Table 5a note a): Main 1 ("2013 or later" → 3 W) + Main 2 diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 57937a58..9ec6975f 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -301,6 +301,33 @@ def test_case6_main_2_emitter_and_control_extracted() -> None: assert main_2.main_heating_control == 2110 +def test_section_9a_per_system_fuel_case6_match_pdf() -> None: + """(211)/(213) per-system space-heating fuel for simulated case 6. The + dual oil boiler heats different parts (Main 1 radiators/2106 living, + Main 2 underfloor/2110 elsewhere), so SAP 10.2 p.186 applies the + two-systems-different-parts MIT: weighted responsiveness R = 0.51·1.0 + + 0.49·0.75 = 0.8775 (Table 9b) and a rest-of-dwelling temperature + blended from each system's control schedule. That lands (98c) demand + 11991.96 exact, so the per-system fuels pin. Pre-S0380.205 the cascade + used Main 1's control + R=1.0 for the whole dwelling → MIT +0.037 °C → + demand +61 kWh → both legs ~+1.3 % high.""" + # Arrange / Act — pin the REAL cascade (the §2.4 section helper skips + # the interlock penalty + two-system MIT params, so use cert_to_inputs). + er = cert_to_inputs(_w001431_case6.build_epc()).energy_requirements + + # Assert + _pin( + er.main_1_fuel_kwh_per_yr, + _w001431_case6.LINE_211_MAIN_1_FUEL_KWH, + "§9a (211) case6", + ) + _pin( + er.main_2_fuel_kwh_per_yr, + _w001431_case6.LINE_213_MAIN_2_FUEL_KWH, + "§9a (213) case6", + ) + + def test_section_4f_pumps_fans_case6_match_pdf() -> None: """(231) pumps/fans pin for simulated case 6 — a DUAL-oil-boiler detached dwelling. Worksheet (231) = 356 = (230c) central heating From d1ae87c7e9a711be1cf9a0a9cd32c0a2f2eba55b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 16:10:42 +0000 Subject: [PATCH 18/80] S0380.206: Eq D1 Q_space uses the DHW boiler's own (204) share, not (202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix D §D2.1(2) Equation D1 blends the monthly water-heater efficiency by the ratio of the boiler's space-heating load to its water load. On a dual-main cert the DHW boiler does only its OWN share of space heating ((204) for Main 1, (205) for Main 2), but the cascade fed Eq D1 the dwelling total ((202) = 1 − secondary). That over-weighted η_winter and under-stated HW fuel — simulated case 6 (Main 1 serves DHW + 51% of space heat) was HW −78 kWh vs the worksheet. New `_water_heating_main_space_fraction` returns the DHW main's total- space share via `_water_heating_main` (WHC-901 → Main 1 (204); WHC-914 → Main 2 (205)); single-main / WHC-901 single systems get (202) = 1 − (201), so they are unchanged. Case 6 (219) HW now 4902.8601 EXACT. With S0380.205 (demand exact), case 6 now closes to 1e-4 on EVERY metric: SAP cont 71.6597, ECF 2.0316, cost 1162.5374, (211)+(213) 14736.9564, (219) 4902.8601, (231) 356, (232) 357.6571, CO2 5953.6679 (rating) / 4895.2137 (demand). Re-pin: 0240 (dual combi, WHC 901, Main 1 51%) HW rises slightly → PE +1.6893 → +1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). Single-main certs unchanged (2360 pass + 0 fail). Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 37 ++++++++++++++++++- .../rdsap/test_golden_fixtures.py | 13 +++++-- .../_elmhurst_worksheet_001431_case6.py | 6 +++ .../worksheet/test_section_cascade_pins.py | 17 +++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 6cec1313..13a7cf62 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1479,6 +1479,35 @@ def _water_heating_main( return details[0] +def _water_heating_main_space_fraction( + epc: EpcPropertyData, secondary_fraction: float +) -> float: + """Fraction of TOTAL space heating provided by the DHW boiler — the + SAP 10.2 Appendix D §D2.1(2) Equation D1 Q_space weight. + + Eq D1's monthly water-heater efficiency blends η_winter / η_summer by + the ratio of the boiler's space-heating load to its water load. On a + single-main / WHC-901 cert that load is the whole main share, + (202) = 1 − (201). On a dual-main cert the DHW boiler does ONLY its + own share — (204) for Main 1, (205) for Main 2 — so feeding it the + dwelling total over-weights η_winter and under-states HW fuel + (simulated case 6: Main 1 serves DHW + 51% of space heat; using 100% + of demand gave HW −78 kWh vs the worksheet).""" + details = epc.sap_heating.main_heating_details if epc.sap_heating else [] + main_fraction = 1.0 - secondary_fraction # (202) + if len(details) < 2: + return main_fraction + main_2 = details[1] + main_2_of_main = ( + main_2.main_heating_fraction / 100.0 + if main_2.main_heating_fraction is not None + else 0.0 + ) + if _water_heating_main(epc) is details[1]: + return main_fraction * main_2_of_main # (205) — DHW from Main 2 + return main_fraction * (1.0 - main_2_of_main) # (204) — DHW from Main 1 + + def _rdsap_tariff(epc: EpcPropertyData) -> Tariff: """Resolve the cert's Table 12a tariff column via RdSAP 10 §12 Rules 1-4 (page 62). Consults BOTH main heating systems — §12 @@ -6329,8 +6358,14 @@ def cert_to_inputs( # Q_space (kWh/month) per spec = (98c)m × (204) = (98c)m × (1 − # sec_frac) for single-main fixtures. if wh_result is not None: + # Eq D1 Q_space is the DHW boiler's OWN space-heating load — its + # (204)/(205) share of total — not the dwelling total (202). See + # `_water_heating_main_space_fraction`. + water_main_space_fraction = _water_heating_main_space_fraction( + epc, secondary_fraction_value + ) space_heating_monthly_useful_kwh = tuple( - q * (1.0 - secondary_fraction_value) + q * water_main_space_fraction for q in space_heating_result.total_space_heating_monthly_kwh ) hw_kwh = _apply_water_efficiency( diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 381895b5..b17cc6f5 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+1.6893, - expected_co2_resid_tonnes_per_yr=+0.0815, + expected_pe_resid_kwh_per_m2=+1.8687, + expected_co2_resid_tonnes_per_yr=+0.0907, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -176,7 +176,14 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "two-control blend. Lowers MIT ~0.037 °C → space-heating demand " "falls → PE +2.1519 → +1.6893, CO2 +0.1051 → +0.0815 (both " "closer to zero; SAP integer 72 unchanged). Verified 1e-4 " - "against the case-6 worksheet (87)/(90)/(98c)." + "against the case-6 worksheet (87)/(90)/(98c). " + "Slice S0380.206 fed Eq D1 the DHW boiler's OWN (204) space " + "share (Main 1 = 51%) instead of the dwelling total (202) — " + "the worksheet-validated case-6 fix that lands its (219) HW " + "exact. For 0240 this raises HW fuel slightly → PE +1.6893 → " + "+1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). The lodged " + "73 carries Elmhurst's own residual; case 6 is the spec " + "authority per [[feedback-worksheet-not-api-reference]]." ), ), _GoldenExpectation( diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py index 95e4824f..0ef2714e 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -92,6 +92,12 @@ LINE_231_PUMPS_FANS_KWH: Final[float] = 356.0 LINE_211_MAIN_1_FUEL_KWH: Final[float] = 7741.6458 LINE_213_MAIN_2_FUEL_KWH: Final[float] = 6995.3106 +# Worksheet (219) water-heating fuel (kWh/yr). The DHW boiler is Main 1 +# (WHC 901), which provides only 51% of space heating, so SAP 10.2 +# Appendix D Eq D1 weights η_winter by Main 1's (204) share — not the +# dwelling total — when blending the monthly water-heater efficiency. +LINE_219_HOT_WATER_FUEL_KWH: Final[float] = 4902.8601 + # Worksheet (70) "Pumps, fans" internal-gain (W), heating-season only # (Jun-Sep = 0). = 10 W = the two-main-system central-heating-pump pair # per SAP 10.2 Table 5a note a): Main 1 ("2013 or later" → 3 W) + Main 2 diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 9ec6975f..2835eb16 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -301,6 +301,23 @@ def test_case6_main_2_emitter_and_control_extracted() -> None: assert main_2.main_heating_control == 2110 +def test_section_4_hot_water_fuel_case6_match_pdf() -> None: + """(219) water-heating fuel for simulated case 6. The DHW boiler (Main + 1, WHC 901) provides only 51% of space heating, so SAP 10.2 Appendix D + §D2.1(2) Equation D1 must weight η_winter by Main 1's (204) share, not + the dwelling total (202). Pre-S0380.206 the cascade fed Eq D1 the full + dwelling space load → over-weighted η_winter → HW −78 kWh.""" + # Arrange / Act — real cascade (the §2.4 helper skips the cylinder gate). + ci = cert_to_inputs(_w001431_case6.build_epc()) + + # Assert + _pin( + ci.hot_water_kwh_per_yr, + _w001431_case6.LINE_219_HOT_WATER_FUEL_KWH, + "§4 (219) case6", + ) + + def test_section_9a_per_system_fuel_case6_match_pdf() -> None: """(211)/(213) per-system space-heating fuel for simulated case 6. The dual oil boiler heats different parts (Main 1 radiators/2106 living, From 7344f600e652441d07468cd1edf156857b81ce35 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 16:14:48 +0000 Subject: [PATCH 19/80] S0380.207: promote simulated case 6 to a full SapResult e2e fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With S0380.201-206 closing every line ref, the detached dual-oil case 6 (Main 1 radiators 51% / Main 2 underfloor 49%, different parts, no boiler interlock, 6 roof-of-room rooflights) now matches its P960-0001-001431 worksheet to 1e-4 on the whole SapResult. Registered in `test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS` (11 pins): SAP 72 / cont 71.6597, ECF 2.0316, cost 1162.5374, CO2 5953.6679, space heat (98c) 11991.9611, main fuel (211)+(213) 14736.9564, HW (219) 4902.8601, lighting (232) 357.6571, pumps (231) 356.0. This was the validation target the S0380.200 handover set. Updated the fixture docstring's stale "§3-windows-only" scope note. Co-Authored-By: Claude Opus 4.8 --- .../_elmhurst_worksheet_001431_case6.py | 26 +++++++++++++------ .../worksheet/test_e2e_elmhurst_sap_score.py | 23 ++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py index 0ef2714e..71f19cbf 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py @@ -23,14 +23,24 @@ This cert surfaced two site-notes gaps fixed in S0380.199: already-inclined "Double between 2002 and 2021" → 2.30 (so the inclination adjustment isn't double-applied). -SCOPE: this fixture pins only the §3 heat-transmission WINDOW line refs -(27)/(27a)/(31) — NOT the full SapResult. Case 6 has a DUAL main heating -system (51% radiators + 49% underfloor, oil), and `SapResult`'s -`main_heating_fuel_kwh_per_yr` / `pumps_fans_kwh_per_yr` aggregate the -two systems differently from the worksheet's per-system (211)/(231) -lines, so a full SapResult pin isn't apples-to-apples. Heating is also -SAP code 127 here vs 0240's code 130 condensing combi — so case 6 pins -to its OWN worksheet, not 0240's register. +SCOPE: promoted to a FULL SapResult e2e fixture (S0380.207) once the dual +main heating system was fully modelled. Case 6 has Main 1 radiators (51%, +control 2106) + Main 2 underfloor (49%, control 2110) heating different +parts. Closing every line ref took: Table 4f note c) two circulation +pumps (231) S0380.201; Table 5a note a) two pump gains (70) S0380.202; +RdSAP §3.7 "Roof of Room" rooflights → §3.10.1 RR residual (30) S0380.203; +SAP 10.2 p.186 two-systems-different-parts MIT — weighted responsiveness +0.8775 + elsewhere two-control blend — (87)/(90)/(98c) S0380.205 (with +the Main 2 emitter/control extractor fix S0380.204); and Eq D1 per-boiler +(204) space share (219) S0380.206. SapResult pins (Block 1 energy rating) +live in `test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case6"]`; +`main_heating_fuel_kwh_per_yr` is the (211)+(213) two-system sum. Heating +is SAP code 127 (vs 0240's 130 condensing combi) — case 6 pins to its OWN +worksheet, the spec authority for this dual-oil archetype. + +The §3 window line refs (27)/(27a)/(31), the roof (30), the pumps (231), +the pump gains (70), the per-system fuel (211)/(213), and HW (219) also +have dedicated section pins in `test_section_cascade_pins.py`. Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/simulated case 6/`. Summary mirrored into the tracked diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 1dbee580..93df288e 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -42,6 +42,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431_rr8 as _w001431_rr8, _elmhurst_worksheet_001431_6035 as _w001431_6035, _elmhurst_worksheet_001431_case5 as _w001431_case5, + _elmhurst_worksheet_001431_case6 as _w001431_case6, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -237,6 +238,27 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=381.4601, pumps_fans_kwh_per_yr=141.0, ), + # Mapper-driven cohort entry — Summary_001431_case6.pdf → extractor → + # mapper → calculator. DETACHED dual-oil cousin of case 5: Main 1 + # radiators (control 2106) + Main 2 underfloor (control 2110) heating + # DIFFERENT parts (51% / 49%), 6 "Roof of Room" rooflights, no boiler + # interlock (cyl stat No → −5pp on Main 1). Promoted to a full + # SapResult fixture once S0380.201-206 closed every line ref: Table 4f + # note c) two-pump electricity (231), Table 5a note a) two-pump gain + # (70), §3.7 rooflight→RR-residual (30), SAP 10.2 p.186 two-systems- + # different-parts MIT (87)/(90)/(98c), and Eq D1 per-boiler (204) + # space share (219). Pins are worksheet Block 1 (energy rating) line + # refs; main_heating_fuel_kwh_per_yr is the (211)+(213) two-system sum. + "001431_case6": FixtureCascadePins( + sap_score=72, sap_score_continuous=71.6597, ecf=2.0316, + total_fuel_cost_gbp=1162.5374, co2_kg_per_yr=5953.6679, + space_heating_kwh_per_yr=11991.9611, + main_heating_fuel_kwh_per_yr=14736.9564, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=4902.8601, + lighting_kwh_per_yr=357.6571, + pumps_fans_kwh_per_yr=356.0, + ), } @@ -253,6 +275,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "001431_rr8": _w001431_rr8, "001431_6035": _w001431_6035, "001431_case5": _w001431_case5, + "001431_case6": _w001431_case6, } From d4817ccdc74ed144a6d7ed0ff2869c43f61b77cb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:05:58 +0000 Subject: [PATCH 20/80] docs: handover for closing golden cert 0240 to 1e-4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records why case 6 (worksheet-validated dual-oil archetype) did not close 0240's residual: 0240 is API-only with an INTEGER-rounded register target (PE 122, CO2 6.0), so 0 residual at 1e-4 is not well-posed without a worksheet. 0240's unvalidated path vs case 6 is the condensing-combi (code 130) + no-cylinder HW (Table 3a keep-hot 600 kWh) — case 6 used a regular boiler + cylinder. Recommends generating an exact-0240 worksheet (or a 'case 7' = case 6 with the combi swapped in) to get a 1e-4 target. Notes the lodged RHI water_heating 2842.82 already matches the cascade HW output exactly (HW demand is right; any residual is in efficiency). Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_0240_CLOSURE.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md diff --git a/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md b/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md new file mode 100644 index 00000000..190f9593 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md @@ -0,0 +1,129 @@ +# Handover — closing golden cert 0240-0200-5706-2365-8010 to 1e-4 + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, +accuracy bar, and pipeline. This records the state of cert **0240** and the +concrete path to driving its residual to zero. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `7344f600` (S0380.207). Confirm with `git rev-parse HEAD`. +- **Baseline:** `2372 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). + +--- + +## What the last session shipped (S0380.201–207) + +Closed **simulated case 6** (`001431_case6`) to 1e-4 on the full SapResult and +promoted it to an e2e fixture. It is the worksheet-backed proxy for 0240's +**dual-oil, different-parts** archetype (Main 1 rads/2106 + Main 2 UFH/2110, +51/49, 6 "Roof of Room" rooflights, no boiler interlock). Slices: + +| slice | spec | what | +|---|---|---| +| S0380.201 | Table 4f note c) | 2nd-main circulation pump → (231) | +| S0380.202 | Table 5a note a) | 2nd-main pump **gain** → (70) | +| S0380.203 | RdSAP §3.7 | "Roof of Room" rooflights deduct from the §3.10.1 RR residual → (30) | +| S0380.204 | extractor/mapper | capture Main 2's §14.1 emitter + control | +| S0380.205 | SAP 10.2 **p.186** | two-systems-different-parts MIT: weighted R + elsewhere two-control blend → (87)/(90)/(98c) | +| S0380.206 | Appendix D Eq D1 | Q_space = DHW boiler's own (204) share, not (202) → (219) | +| S0380.207 | test | promote case 6 to a full e2e fixture | + +**0240 was re-pinned at each step** (it shares the archetype) and its residual +improved on PE/CO2 but its SAP integer dropped 73→72. The boiler-interlock −5pp +the *previous* handover called the priority was already implemented — see +[[project_case6_interlock_already_done]]. + +--- + +## The 0240 problem — and why case 6 did NOT close it + +### ⚠️ Critical: 0240 is API-only and its register target is INTEGER-rounded + +0240 has **no worksheet**. The golden test pins the cascade against the lodged +EPC register: +- `energy_consumption_current = 122` — **an integer** (1 kWh/m² resolution). +- `co2_emissions_current = 6.0` — **1 d.p.** (tonnes). +- `current_energy_efficiency = None` — the SAP isn't even in the JSON + (`actual_sap=73` in the test was carried from the original lodgement). + +**You cannot drive 0240 to "0 residual" at 1e-4 against these.** The register +rounds PE to the nearest whole kWh/m², so any cascade value in `[121.5, 122.5)` +*is* 122, and the true (unrounded) Elmhurst value could sit anywhere in that band +— or itself carry residual vs the rounded lodgement. Matching a rounded integer +to 1e-4 is not a well-posed target. **The only 1e-4 ground truth is a worksheet** +(the per-line `(1)..(286)` Elmhurst output), which is exactly why case 5/6 were +generated. + +Current 0240 cascade vs lodged: **PE 123.8687 vs 122 (resid +1.8687)**, **CO2 +6.0907 vs 6.0 (+0.0907)**, SAP cont 72.39 (integer 72 vs lodged 73, resid −1). + +### Why case 6 didn't close 0240 + +Case 6 validated the dual-main **structure** (MIT p.186, pumps, rooflights, +Eq-D1 fraction). But 0240 differs in cert-specific features case 6 does **not** +exercise: + +| feature | case 6 (worksheet-validated) | **0240** (unvalidated) | +|---|---|---| +| SAP code | 127 regular oil boiler | **130 condensing oil combi** (Table 4b 82/73) | +| DHW path | regular boiler **+ 110 L cylinder** → primary/storage loss | **combi, NO cylinder** → Table 3a keep-hot **600 kWh** (`combi_loss`), primary_loss 0 | +| TFA | (case-6 dwelling) | **201.53 m²** (different fabric/dimensions) | +| PV | none | **none** (the golden note's "+ PV" is STALE — `solar_water_heating=N`, no PV field) | + +So 0240's *remaining* residual lives in the parts case 6 never touched — +**the condensing-combi (130) + no-cylinder HW path** and the cert's own fabric. +The combi Eq-D1 / Table 3a keep-hot path has never been pinned against a +worksheet in the dual-main context. + +### Partial ground truth already in the 0240 JSON + +The lodged `renewable_heat_incentive` block gives two deemed-demand figures: +- `water_heating = 2842.82` — **exactly equals** the cascade's §4 HW output + `(64)` (2842.82). So the **HW demand is right**; any HW residual is in the + *efficiency* (Eq D1 combi blend), not the demand. +- `space_heating_existing_dwelling = 13254.52` vs cascade `(98c)` 12760.9 — + differ ~494 kWh (~3.7%). RHI uses its own deemed methodology so this is **not** + a clean 1e-4 check, but it's a hint the space-heat demand or the combi figures + are worth scrutinising. + +--- + +## What to do next — generate the right example + +To close 0240 properly you need a **worksheet** that exercises its +combi-HW path. Two options, best first: + +1. **Exact 0240 replica worksheet (gold standard).** Re-enter 0240's lodged data + into Elmhurst and export the worksheet PDF. Then build a mapper-driven fixture + (mirror `_elmhurst_worksheet_001431_case6.py`) and pin every line `(1)..(286)` + at 1e-4. The first diverging line localises the residual exactly. This is the + only way to get a true 1e-4 target for 0240. + +2. **"Case 7" — case 6 with 0240's combi swapped in.** If generating an exact + 0240 replica is hard, generate a `001431` variant that changes case 6's + heating to **0240's**: + - **Condensing oil combi, SAP code 130** (not 127 regular boiler). + - **NO hot water cylinder** — combi instantaneous DHW → WHC 901, Table 3a/3b + combi keep-hot loss, no primary/storage loss. + - Keep the validated dual-main rads(2106)+UFH(2110) 51/49 + RR rooflights. + This pins the **combi Eq-D1 + Table 3 keep-hot** path (the biggest unvalidated + piece) against a worksheet. Whatever it reveals applies directly to 0240. + +**The single most important differentiator to change vs case 6: regular +boiler + cylinder → condensing combi (130) with no cylinder.** That is the one +HW path 0240 uses that has never seen a worksheet in this archetype. + +### Reframing the goal +If a worksheet is genuinely unavailable, "0 residual vs the lodged register" is +not achievable at 1e-4 (integer rounding). The realistic target then is the +**±0.5 SAP fallback** (AGENT_GUIDE §1) — and 0240's continuous SAP 72.39 vs +lodged 73 is ~0.6 off, just outside it. Closing that last 0.6 still requires +knowing the true (worksheet) value, so the worksheet is the unblocker either way. + +--- + +## Pointers +- Golden pin + full slice history: `tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py` (cert `0240-0200-5706-2365-8010`, line ~83). +- Case-6 fixture to mirror: `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py` + its e2e pins in `test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case6"]`. +- Memories: [[project_case6_mit_two_system_rootcause]] (the p.186 MIT, now CLOSED), [[project_case6_interlock_already_done]], [[feedback-worksheet-not-api-reference]] (API matches the worksheet, not the lodged register), [[feedback-software-no-special-handling]]. +- Repro 0240: `EpcPropertyDataMapper.from_api_response(json.load(...0240.json))` → `cert_to_inputs` / `cert_to_demand_inputs` → `calculate_sap_from_inputs`. The §2.4 section helpers are UNFAITHFUL (skip the interlock penalty + two-system MIT params) — diagnose against the real `cert_to_inputs` cascade. +- Process: one slice = one commit, spec citation (page+line), `Co-Authored-By: Claude Opus 4.8 `. SAP 10.2 only. No tolerance widening. mapper.py + cert_to_inputs.py each carry 32 pre-existing pyright errors (baseline-compare with `git stash`). From 6ac67a4c6fecc1e562df6cf272f671d3506e5cb2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:13:46 +0000 Subject: [PATCH 21/80] docs: add full 0240 worksheet input spec to the closure handover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "build THIS in Elmhurst" specification — dwelling, dual condensing oil-combi (code 130) heating, combi/no-cylinder DHW (Table 3a keep-hot 600), per-element fabric W/K targets, room-in-roof gables, the 5 vertical + 6 roof-of-room windows, lighting (8 LED), no PV — so a generated worksheet reproduces cert 0240 as closely as possible. Flags the three load-bearing differences vs case 6 (combi code 130, no cylinder, boiler interlock PRESENT → no -5pp) that the new worksheet must capture. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_0240_CLOSURE.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md b/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md index 190f9593..91507e27 100644 --- a/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md +++ b/domain/sap10_calculator/docs/HANDOVER_0240_CLOSURE.md @@ -121,6 +121,89 @@ knowing the true (worksheet) value, so the worksheet is the unblocker either way --- +## 0240 worksheet input specification (build THIS in Elmhurst) + +Reproduce cert **0240-0200-5706-2365-8010** as closely as possible so the +worksheet is a valid 1e-4 ground truth for the cascade. All values are from the +fixture JSON (`tests/.../fixtures/golden/0240-0200-5706-2365-8010.json`). Match +the **U-values / W-per-K targets** below — those are what the cascade actually +consumes, so hitting them matters more than the exact construction wording. + +**Dwelling** +- Detached house, **construction age band J**, England. Postcode **LE15 9LB** + (drives the demand/EPC climate). 1 extension. 7 habitable rooms. +- Storey height **2.28 m**. Total floor area **≈202 m²** = + Main ground floor **97.72** + Extension 1 ground floor **20.61** + the + room-in-roof floor **83.2**. + +**Heating — the load-bearing difference vs case 6.** TWO main systems, both a +**condensing oil combi, SAP main heating code 130** (Table 4b winter **82** / +summer **73**), oil fuel, **balanced flue** (not fan-assisted), efficiency from +the **SAP table** (no PCDB boiler index), central-heating pump age **unknown**. +They heat **different parts** (so the p.186 MIT applies, already implemented): +- **Main 1** — **radiators**, control **2106** (programmer + room thermostat + + TRVs), **51%**, serves the living area. +- **Main 2** — **underfloor heating in screed** (R=0.75), control **2110** (time + + temperature zone control), **49%**, serves elsewhere. + +**Domestic hot water — the other key difference vs case 6.** Heated **from the +main system** (WHC 901), oil. It is a **COMBI with NO hot-water cylinder** — +instantaneous DHW → SAP 10.2 **Table 3a keep-hot loss 600 kWh/yr** (`combi_loss` +600, `primary_loss` 0). 3 **mixer** showers + 1 bath; no effective WWHRS. +NB the lodged RHI `water_heating = 2842.82` already equals the cascade HW output +exactly, so get the DHW *demand* inputs right and any residual is in the combi +*efficiency* (Eq D1 winter/summer blend). +- **Boiler interlock: YES** for 0240 (combi + room thermostat 2106, no cylinder) + → **no −5pp penalty**, both systems run at base eff 82/73. (This is the + OPPOSITE of case 6, which had a regular boiler + cylinder with no cylinder stat + → −5pp. Get this right or the efficiencies — hence everything — will be off.) + +**Fabric — target W/K (cascade values to reproduce; total external area 328.97 m²):** +| element | W/K | notes | +|---|---|---| +| Walls (29a) | 24.45 | age J, **uninsulated** (NI), not dry-lined, not measured | +| Roof (30) | 32.331 | Main = pitched, **access to loft**, insulation at ceiling, **400 mm+** ; Ext1 = pitched **vaulted ceiling**, **no insulation (NI)** | +| Floor (28) | 29.4297 | **solid**; Main heat-loss perimeter 36.45, Ext1 13.45 | +| Party/gable (32) | 7.84 | RR gables billed as party at U=0.25 | +| Windows (27) | 22.7407 | see below | +| Roof windows (27a) | 12.6374 | see below | +| Doors (26) | 11.1 | **2 doors, uninsulated** | +| Thermal bridging (36) | 36.1867 | = 0.11 × 328.97 | +| **(33) fabric total** | **140.5288** | | +| **(37)+vent feeds (39)** | total transmission **176.7155** | | + +**Room-in-roof** (Main only): floor area **83.2 m²**, **two gables L = 6.40 m** +— one **Exposed**, one **Party** (per the case-5/6 sandstone replica convention), +age J. This is the same Simplified/detailed-gable RR structure case 6 validated. + +**Windows** (all **double glazed, PVC frame**, glazing "DG 2002+", U≈2.0, g=0.72): +- 5 **vertical** wall windows: 1.4×1.3, 1.2×1.3 (orient N), 1.6×1.3, 2.5×2.0 + (orient E), 1.4×1.3 (orient S, on Extension 1). +- 6 **"Roof of Room" rooflights** (window_wall_type 4): all **1.0×1.0**, at 45°, + 3 orient N + 3 orient S. These bill on (27a) and deduct from the RR residual + (S0380.203) — keep them as roof-of-room, not vertical glazing. + +**Ventilation / lighting / other** +- Natural ventilation; **no** mechanical ventilation, **no** extract fans, **no** + chimneys/flues. 85% draught-proofed. +- Lighting: **8 LED bulbs, 100% low-energy** (no CFL/incandescent). +- **No PV**, no solar thermal, **no secondary heating**, no air-conditioning. +- Electricity meter type 3 (standard), smart meter present, not export-capable. + +### The three things that MUST differ from case 6 (or you've just rebuilt case 6) +1. **Condensing oil combi, SAP code 130** (case 6 = regular oil boiler 127). +2. **Combi, NO cylinder** → Table 3a keep-hot 600 kWh (case 6 = boiler + 110 L + cylinder → primary/storage loss). +3. **Boiler interlock PRESENT → no −5pp** (case 6 = no interlock → −5pp). Driven + automatically by "combi + room thermostat, no cylinder", but verify the + worksheet shows base eff 82/73, not 77/68. + +Everything else (dual-main different-parts MIT, two pumps, rooflight→RR, Eq-D1 +(204) share) is already implemented and validated by case 6 — the new worksheet +just confirms the combi-HW path on top of that closed structure. + +--- + ## Pointers - Golden pin + full slice history: `tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py` (cert `0240-0200-5706-2365-8010`, line ~83). - Case-6 fixture to mirror: `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py` + its e2e pins in `test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case6"]`. From fe59c4d8a2ee7f03ca53411deb7e1a4963442160 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:57:22 +0000 Subject: [PATCH 22/80] =?UTF-8?q?S0380.208:=20case=207=20combi=20e2e=20fix?= =?UTF-8?q?ture=20=E2=80=94=20condensing-oil-combi=20path=20validated=20ex?= =?UTF-8?q?act?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds simulated case 7: case 6 (P960-0001-001431) with the heating swapped to a CONDENSING OIL COMBI (SAP code 130, Table 4b 82/73) and the cylinder removed — combi instantaneous DHW (WHC 901), Table 3a keep-hot combi loss (61) = 600 kWh/yr, no primary/storage loss, boiler interlock PRESENT (no −5pp). This is the heating archetype golden cert 0240-0200-5706-2365-8010 uses, which case 6 (SAP code 127, a *regular* condensing oil boiler + cylinder) never exercised. The cascade reproduces the case-7 worksheet EXACTLY at abs=1e-4 on every top-level SapResult output with ZERO calculator changes: (211) 7865.4304 (213) 7556.9821 (219) 3496.8121 (98c) 12646.3783 (255) 1123.3372 (257) 1.9631 (272) 5738.9315 (258) 73 This validates the SAP 10.2 Appendix D Eq D1 combi efficiency blend + Table 3a keep-hot combi loss + Table 4b code 130 (82/73) path, and exonerates the combi mechanism as the source of 0240's API-path residual — which therefore lives in 0240's fabric/demand or the API mapper. Test-only slice (no impl change). New fixture file: 0 pyright errors. Co-Authored-By: Claude Opus 4.8 --- .../tests/fixtures/Summary_001431_case7.pdf | Bin 0 -> 93369 bytes .../_elmhurst_worksheet_001431_case7.py | 134 ++++++++++++++++++ .../worksheet/test_e2e_elmhurst_sap_score.py | 20 +++ 3 files changed, 154 insertions(+) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case7.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8c42d24a7ec7231d29a66c7152f7edbbaa825720 GIT binary patch literal 93369 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�zIBcu5uj-lCFwFix3P;?6jIYu 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 100 L every month so the "no keep-hot" + fu-scaling collapses to 1.0). +- (59)m primary loss = 0 and storage loss = 0 (combi, no cylinder). +- (211) space-heating fuel main 1 = 7865.4304. +- (213) space-heating fuel main 2 = 7556.9821. +- (219) water-heating fuel = 3496.8121. +- (64) HW demand total = 2712.0619 (smaller dwelling than 0240's + 2842.82 — case 7 validates the combi *mechanism*, not 0240's absolute + demand). + +Per [[feedback-zero-error-strict]]: e2e pins are abs=1e-4 against the PDF +(see test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case7"]). + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 7/`. Summary mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf`. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_case7.pdf" +) + +# Worksheet (211)/(213) per-system space-heating fuel (kWh/yr). Both mains +# are condensing oil combis (SAP code 130, Table 4b 82/73) at base +# efficiency — interlock present (combi + room thermostat, no cylinder), +# so NO −5pp penalty (the case-6 boiler+cylinder had no cylinder stat → a +# −5pp penalty; the combi removes it). +LINE_211_MAIN_1_FUEL_KWH: Final[float] = 7865.4304 +LINE_213_MAIN_2_FUEL_KWH: Final[float] = 7556.9821 + +# Worksheet (219) water-heating fuel (kWh/yr). Combi instantaneous DHW +# (WHC 901) — SAP 10.2 Appendix D Eq D1 blends the monthly water-heater +# efficiency (217)m by the DHW boiler's (204) space share; Table 3a +# keep-hot combi loss (61) = 600 kWh/yr; no primary/storage loss. +LINE_219_HOT_WATER_FUEL_KWH: Final[float] = 3496.8121 + +# Worksheet (206)/(207) main space-heating efficiency — base 82, no +# −5pp (interlock present). Watch these if the pin ever regresses: a +# silent interlock flip drops them to 77/68. +LINE_206_MAIN_1_EFFICIENCY_PCT: Final[float] = 82.0 +LINE_207_MAIN_2_EFFICIENCY_PCT: Final[float] = 82.0 + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (mirror of the case-6 helper).""" + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-7 Summary through extractor + mapper.""" + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 93df288e..1d36e443 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -43,6 +43,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431_6035 as _w001431_6035, _elmhurst_worksheet_001431_case5 as _w001431_case5, _elmhurst_worksheet_001431_case6 as _w001431_case6, + _elmhurst_worksheet_001431_case7 as _w001431_case7, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -259,6 +260,24 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=357.6571, pumps_fans_kwh_per_yr=356.0, ), + # Mapper-driven cohort entry — Summary_001431_case7.pdf → extractor → + # mapper → calculator. Case 6 with the heating swapped to a CONDENSING + # OIL COMBI (SAP code 130, Table 4b 82/73) with NO cylinder — combi + # instantaneous DHW (WHC 901), Table 3a keep-hot combi loss (61), no + # primary/storage loss, boiler interlock PRESENT (no −5pp). Validates + # the combi HW + space efficiency path that golden cert 0240 uses; + # reproduces every line ref EXACTLY with no calculator change. + # main_heating_fuel_kwh_per_yr is the (211)+(213) two-system sum. + "001431_case7": FixtureCascadePins( + sap_score=73, sap_score_continuous=72.6153, ecf=1.9631, + total_fuel_cost_gbp=1123.3372, co2_kg_per_yr=5738.9315, + space_heating_kwh_per_yr=12646.3783, + main_heating_fuel_kwh_per_yr=15422.4125, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3496.8121, + lighting_kwh_per_yr=357.6571, + pumps_fans_kwh_per_yr=356.0, + ), } @@ -276,6 +295,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "001431_6035": _w001431_6035, "001431_case5": _w001431_case5, "001431_case6": _w001431_case6, + "001431_case7": _w001431_case7, } From 844fc22f67c4606d72a0ac3c681bde17dcdedbbe Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 20:42:18 +0000 Subject: [PATCH 23/80] =?UTF-8?q?S0380.209:=20API-path=20wall=20U=20?= =?UTF-8?q?=E2=80=94=20as-built=20"insulated=20(assumed)"=20uses=20age-ban?= =?UTF-8?q?d=20row,=20not=2050mm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EPC renders a recent-band as-built wall as ", as built, insulated (assumed)". The API mapper populates epc.walls with that string, and heat_transmission's wall_ins_present gate keyed off the "insulated" substring → routed the wall to the RdSAP 50 mm "insulation of unknown thickness" bucket (e.g. sandstone band J U=0.25) instead of the as-built age-band row (U=0.35). Per RdSAP 10 Table 8/9 footnote the 50 mm row applies ONLY when insulation is "known to have been increased subsequently (otherwise 'as built' applies)". An "as built ... (assumed)" description is the EPC's age-band assumption — it only renders on RECENT bands (an old band renders "no insulation (assumed)"), so the as-built row applies. Genuine retrofit is signalled by wall_insulation_type (External/Internal/Filled), which the gate still checks independently. Worksheet-validated by two new Elmhurst worksheets, both As Built band J: - simulated case 9: sandstone → (29a) U 0.35 - simulated case 10: solid brick → (29a) U 0.35 both the as-built row, NOT 50 mm (0.25). Fix: restrict the description-based gate to genuine retrofit via the new local `_described_as_retrofit_insulated` (excludes "as built"/"(assumed)"). The cavity filled-row routing inside `u_wall` (which uses `_described_as_insulated` directly) is untouched — the 3 cavity API certs (0390/0535/7536) are unaffected. test_heat_transmission: the old `..._uses_50mm_row` test asserted 50 mm via an IMPOSSIBLE band-B + "insulated (assumed)" combination; corrected to a valid recent-band (J) scenario asserting the as-built row (35 W/K). Golden 0240: walls 24.45 → 34.23 W/K (U 0.25 → 0.35). SAP integer 72 unchanged; PE residual re-pinned +1.8687 → +5.5044, CO2 +0.0907 → +0.2757. This spec-correct fix REMOVED the wall under-count that was masking the Ext1 vaulted-roof over-count (cascade U 0.68 via the same "insulated (assumed)" description vs case-9 sloping-ceiling 0.25) — that roof over-count is the next slice; fixing both lands SAP cont ≈ 72.31 (= Elmhurst case 9). Co-Authored-By: Claude Opus 4.8 --- .../worksheet/heat_transmission.py | 51 ++++++++++++++++--- domain/sap10_ml/rdsap_uvalues.py | 13 ++--- .../rdsap/test_golden_fixtures.py | 25 +++++++-- .../worksheet/test_heat_transmission.py | 31 ++++++----- 4 files changed, 89 insertions(+), 31 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index ea1107fc..91185ea6 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -300,6 +300,36 @@ def _parse_thickness_mm(value: Any) -> Optional[int]: return int(digits) if digits else None +def _described_as_retrofit_insulated(description: Optional[str]) -> bool: + """True only when the description asserts insulation KNOWN to have + been added subsequently — i.e. genuine retrofit, not the age-band + as-built assumption. + + RdSAP 10 Table 8/9 footnote routes a wall to the 50 mm "insulation + of unknown thickness" row ONLY when insulation is "known to have been + increased subsequently (otherwise 'as built' applies)". A description + rendered as "as built ... insulated (assumed)" is the EPC's age-band + assumption — it renders only on recent age bands where as-built + construction already includes insulation (an old band renders "no + insulation (assumed)"). For those the spec uses the as-built age-band + U-value, NOT the 50 mm retrofit row. + + Worksheet evidence: simulated case 9 (sandstone, band J, As Built → + U 0.35) and case 10 (solid brick, band J, As Built → U 0.35); both + Elmhurst worksheets return the as-built row, not the 50 mm bucket + (which gives ~0.25). Genuine retrofit is signalled by + `wall_insulation_type` (External/Internal/Filled), checked + independently by the `wall_ins_present` gate — so excluding the + "as built"/"(assumed)" description here loses no real retrofit signal. + """ + if description is None: + return False + if not _described_as_insulated(description): + return False + desc = description.lower() + return "as built" not in desc and "assumed" not in desc + + def _joined_descriptions(elements: list[Any]) -> Optional[str]: if not elements: return None @@ -665,14 +695,21 @@ def heat_transmission_from_cert( wall_construction = _int_or_none(part.wall_construction) wall_ins_type = _int_or_none(part.wall_insulation_type) wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness) - # Per RdSAP 10 Table 6 footnote, a wall with "insulated (assumed)" - # or "partial insulation (assumed)" in its description has retrofit - # insulation the assessor hasn't measured the thickness of — even - # when wall_insulation_type=4 ("as-built / assumed"). Treat as - # present so the 50 mm bucket routes correctly. + # RdSAP 10 Table 8/9 footnote: the 50 mm "insulation of unknown + # thickness" row applies only when insulation is "known to have + # been increased subsequently (otherwise 'as built' applies)". + # Genuine retrofit is signalled by `wall_insulation_type` + # (External/Internal/Filled ≠ NONE). An "as built ... insulated + # (assumed)" description is the EPC age-band assumption (it only + # renders on recent bands where as-built already includes + # insulation) → use the as-built age-band row, NOT 50 mm. + # Worksheet-validated by simulated case 9 (sandstone J → 0.35) + # and case 10 (solid brick J → 0.35), both As Built. So the + # description signal is restricted to genuine (non-assumed) + # retrofit via `_described_as_retrofit_insulated`. wall_ins_present = ( (wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE) - or _described_as_insulated(wall_description) + or _described_as_retrofit_insulated(wall_description) ) party_construction = _int_or_none(part.party_wall_construction) raw_roof_thickness = getattr(part, "roof_insulation_thickness", None) @@ -1172,7 +1209,7 @@ def _alt_wall_w_per_k( alt_thickness = _parse_thickness_mm(alt_wall.wall_insulation_thickness) alt_insulation_present = ( alt_wall.wall_insulation_type != _WALL_INSULATION_NONE - or _described_as_insulated(wall_description) + or _described_as_retrofit_insulated(wall_description) ) alt_u = u_wall( country=country, diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index edc6d9ce..23c36bb3 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -52,14 +52,11 @@ def _described_as_insulated(description: Optional[str]) -> bool: otherwise. Looks for "insulated" or "partial insulation" substrings, with "no insulation" taking precedence as a hard negation. - Two consumers: - - `u_wall` uses this to route cavity walls to the Filled-cavity row - of Table 6 (in lieu of the bucketed cascade). - - `heat_transmission_from_cert` uses this to set `wall_ins_present` - for non-cavity walls so the 50 mm bucket routing fires per the - RdSAP 10 Table 6 footnote ("If a wall is known to have additional - insulation but the insulation thickness is unknown, use the row - in the table for 50 mm insulation"). + Consumer: `u_wall` uses this to route cavity walls to the Filled- + cavity row of Table 6 (in lieu of the bucketed cascade). For the + non-cavity `wall_ins_present` gate, `heat_transmission_from_cert` + further restricts this to genuine (non-assumed) retrofit via its + local `_described_as_retrofit_insulated`. """ if description is None: return False diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index b17cc6f5..6093f71f 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+1.8687, - expected_co2_resid_tonnes_per_yr=+0.0907, + expected_pe_resid_kwh_per_m2=+5.5044, + expected_co2_resid_tonnes_per_yr=+0.2757, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -183,7 +183,26 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "exact. For 0240 this raises HW fuel slightly → PE +1.6893 → " "+1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). The lodged " "73 carries Elmhurst's own residual; case 6 is the spec " - "authority per [[feedback-worksheet-not-api-reference]]." + "authority per [[feedback-worksheet-not-api-reference]]. " + "Slice S0380.209 fixed the API-path wall U: the EPC renders " + "this cert's sandstone (band J, As Built) wall as 'insulated " + "(assumed)', which the cascade wrongly routed to the 50 mm " + "retrofit row (U 0.25). Per RdSAP 10 Table 8/9 footnote the " + "50 mm row is only for insulation 'known to have been " + "increased subsequently'; an 'as built ... (assumed)' " + "description is the age-band assumption (renders only on " + "recent bands) → as-built row U 0.35. Worksheet-validated by " + "simulated case 9 (sandstone J → 0.35) + case 10 (solid brick " + "J → 0.35). walls 24.45 → 34.23 W/K → PE +1.8687 → +5.5044, " + "CO2 +0.0907 → +0.2757 (SAP 72 unchanged). This spec-correct " + "fix REMOVED the wall under-count that was masking the Ext1 " + "vaulted-roof over-count (cascade U 0.68 via the same " + "'insulated (assumed)' description vs case-9 sloping-ceiling " + "0.25) — that roof over-count is the next slice; fixing both " + "lands SAP cont ≈ 72.31 (= Elmhurst case 9). The lodged 73 " + "requires a 2013+ pump (case 7); 0240's API lodges the pump " + "as Unknown (code 0 → 115, proven 0=Unknown across 9 API+" + "Summary pairs), so 73 is unreachable from the lodged inputs." ), ), _GoldenExpectation( diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index b9f54aae..1fdfec3b 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -168,21 +168,26 @@ def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnot assert result.floor_w_per_k == pytest.approx(31.0, abs=2.0) -def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnote() -> None: - # Arrange — 128 corpus certs lodge solid-brick walls with - # wall_insulation_type=4 ("as-built / assumed") AND description - # "Solid brick, as built, insulated (assumed)". The description - # signals retrofit insulation that the assessor hasn't measured the - # thickness of; RdSAP 10 Table 6 footnote routes this to the 50 mm - # row. Without the description signal, type=4 alone would set - # wall_ins_present=False and the cascade would return the as-built - # U=1.7. With it, U = 0.55 at band B. +def test_solid_brick_as_built_insulated_assumed_uses_as_built_row_per_table9_footnote() -> None: + # Arrange — an "as built, insulated (assumed)" description only renders + # on RECENT age bands (where as-built construction already includes + # insulation per Building Regs); an old band renders "no insulation + # (assumed)". RdSAP 10 Table 8/9 footnote routes to the 50 mm row only + # when insulation is "known to have been increased subsequently + # (otherwise 'as built' applies)" — an age-band assumption is NOT + # known retrofit, so the as-built row applies. + # + # Worksheet-validated: simulated case 9 (sandstone, band J, As Built + # → U 0.35) and case 10 (solid brick, band J, As Built → U 0.35) both + # return the as-built row, NOT the 50 mm bucket (which would give + # U=0.25). This was previously asserted at 55 W/K via an IMPOSSIBLE + # band-B + "insulated (assumed)" combination. # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single - # storey → gross_wall = 100 m². walls_w_per_k expected = 0.55 × 100 - # = 55 W/K. + # storey → gross_wall = 100 m². walls_w_per_k expected = 0.35 × 100 + # = 35 W/K. main = make_building_part( identifier="Main Dwelling", - construction_age_band="B", + construction_age_band="J", wall_construction=3, wall_insulation_type=4, party_wall_construction=1, @@ -211,7 +216,7 @@ def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnot result = heat_transmission_from_cert(epc) # Assert - assert result.walls_w_per_k == pytest.approx(55.0, abs=1.0) + assert result.walls_w_per_k == pytest.approx(35.0, abs=1.0) def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row() -> None: From 58ff7d88810a4bbafd74c541f7e1992b3756cb7e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 21:22:04 +0000 Subject: [PATCH 24/80] docs: handover for golden-cert mapper/cascade bugs (roof S0380.210 + community fuel collision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records post-S0380.209 state: 0240 verdict (true SAP 72, register 73 = unpreserved 2013+ pump, proven 0=Unknown via 13 pairs), and three open threads — roof Ext1 "insulated (assumed)" U over-count (needs case 11 worksheet), community fuel-code collision (API 18-25 vs Table-12 biomass 18-25; cert 9390 CO2 6x low; needs 9390 worksheet), and 0390 +7 demand-side gap. Plus the audit table of all 5 non-zero-SAP golden certs. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_MAPPER_BUGS.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md diff --git a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md new file mode 100644 index 00000000..0ac86d11 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md @@ -0,0 +1,180 @@ +# Handover — golden-cert mapper/cascade bugs (post-0240 wall fix) + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, +the 1e-4 bar, the per-line debugging loop, and the suite command. This records the +state after closing the 0240 investigation and fixing the first of several +API-mapper/cascade bugs the audit surfaced. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `844fc22f` (S0380.209). Confirm with `git rev-parse HEAD`. +- **Baseline:** `2383 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). +- **Next slice number:** **S0380.210**. + +--- + +## What this session shipped + +| slice | what | +|---|---| +| **S0380.208** | Promoted **simulated case 7** (combi swap of case 6) to an e2e fixture. PROVED the condensing-oil-combi (SAP code 130, no cylinder, combi instantaneous DHW, Eq D1, Table 3a keep-hot) path is **exact at 1e-4** with zero calculator changes → exonerated the heating as the source of 0240's residual. | +| **S0380.209** | Fixed the **API-path wall U** "as built, insulated (assumed)" bug — routes to the as-built age-band row, not the 50 mm retrofit bucket. New `_described_as_retrofit_insulated` in `heat_transmission.py`. Worksheet-validated by case 9 (sandstone J → 0.35) + case 10 (solid brick J → 0.35). Re-pinned 0240 PE +1.8687 → +5.5044, CO2 +0.0907 → +0.2757 (SAP integer 72 unchanged). | + +Both also carry a memory: [[project_case7_combi_exonerated]], [[project_as_built_insulated_assumed_bug]]. + +--- + +## The 0240 verdict — RESOLVED, not closable to 73 + +**0240's true SAP is 72**, proven three independent ways: +- Elmhurst **case 8** (pump=Unknown, 0240's actual lodged value) → worksheet SAP **72**. +- Elmhurst **case 9** (correct sandstone wall 0.35 + sloping roof 0.25) → SAP **72**. +- Our cascade with **both** the wall (done) and roof (pending) bugs fixed → SAP cont **72.31**. + +The register's **73 requires a "2013 or later" circulation pump (41 kWh)** — Elmhurst +**case 7** with that pump = 73. But 0240's API lodges `central_heating_pump_age=0` += **Unknown** → 115 kWh. The encoding was **proven from 13 API+Summary pairs**: +`0`="Unknown" (9 pairs), `2`="2013 or later" (4 pairs). The open-data export did not +preserve the pump age that produced the lodged 73. **It is genuinely unreachable from +the JSON.** Do not chase 0240 to 73; re-pin to its correct 72 once the roof lands. + +`pump_age` enum (verified): `0`=Unknown→115, `1`=Pre-2013→165, `2`=2013+→41 +(`_TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE` in `cert_to_inputs.py`). + +--- + +## THE METHODOLOGY LESSON FROM THIS SESSION (read this) + +The 0240 baseline (cont 72.39) was **two offsetting bugs**: a wall U **under**-count +(0.25 vs 0.35, less loss) masking an Ext1 roof U **over**-count (0.68 vs ~0.25, more +loss). Fixing one alone moves the residual the "wrong" way — fix both +([[feedback_software_no_special_handling]]). And: **Elmhurst is the arbiter, not the +spec text** — twice this session a confident spec/first-principles read was wrong and +a generated worksheet settled it (`NI`=not-indicated not "none"; pump `0`=Unknown). +**Generate a worksheet rather than guess a U-value or factor.** The user generates +Elmhurst worksheets readily (cases 7–10 done) — ask for one. + +--- + +## OPEN THREAD 1 — Roof fix (S0380.210), needs **case 11** + +Same root cause as the wall (`project_as_built_insulated_assumed_bug`): the API mapper +populates `epc.roofs` with "Pitched, insulated (assumed)"; `u_roof` +([`rdsap_uvalues.py:708`](../../sap10_ml/rdsap_uvalues.py)) routes +`insulation_thickness_mm==0 and _described_as_insulated(description)` → **0.68** +(retrofit 50 mm). 0240's Ext1 is `roof_construction=5` "vaulted ceiling", `NI` thickness, +band J → cascade **0.68**, should be a Table 18 sloping-ceiling value. + +**Why it's harder than the wall:** +- Just swapping to `_described_as_retrofit_insulated` makes thickness-0 fall to the + Table 16 ladder → **2.30** (uninsulated). `NI`/unknown must route to the age-band + default, not 0 mm. +- `u_roof` receives the **joined all-roofs description** (can't tell which call is the + vaulted Ext1) and **no construction type** — needs the per-BP sloping/vaulted signal + threaded through (heat_transmission already computes `is_flat_roof`; add an + `is_sloping_ceiling`/vaulted flag similarly). +- **Code-5 "vaulted" is not recognised as a sloping ceiling** — only code-8 + "sloping ceiling" is (`_api_resolve_sloping_ceiling_thickness` gates on `==8`; + `heat_transmission` keys the cos(30°) area factor on the "sloping ceiling" substring). +- **Value ambiguity (needs case 11):** Table 18 (RdSAP 10 p.45) band J gives + col (1) joists/unknown **0.16**, col (2) **at rafters 0.20**, col (3) flat / + "sloping ceiling + unheated space above" (footnote b) **0.25**. Case 9 (code-8 + sloping, Unknown) → Elmhurst **0.25**, but that may not equal code-5 vaulted (could + be 0.20). All three give 0240 integer 72 — it's a PE-pin precision question. + +**Generate case 11:** a **vaulted Ext1 roof (`roof_construction` vaulted), insulation +Unknown/NI, band J**; report worksheet **`(30)` U** (0.20 or 0.25?). Then implement the +roof fix to match, re-pin 0240 (both bugs fixed → cont ≈ 72.31, integer 72). + +Simulated worksheet `(30)` decompositions seen so far: case 9 Ext1 sloping-Unknown = +0.25; case 7/8/10 roofs are all 400 mm loft (col 1, 0.11) so they do NOT exercise the +sloping path. + +--- + +## OPEN THREAD 2 — Community fuel-code collision (cert 9390), needs a **9390 worksheet** + +Cert **9390-2722-3520**: SAP +4 (calc 71 / lodged 67), PE ≈ matches (204 vs 205), but +**CO2 0.44 t vs lodged 2.8 t**. Community scheme (`sap_main_heating_code=301`, +`main_fuel_type=20`). + +**Root cause:** `co2_factor_kg_per_kwh` / `unit_price_p_per_kwh` / +`primary_energy_factor` in `tables/table_12.py` + `table_32.py` use an +"accept-either-API-or-Table-12-code" lookup that checks the **Table-12 code first**: + +```python +if fuel_code in CO2_KG_PER_KWH: # API 20 IS ALSO a T12 code (wood logs, 0.028) + return CO2_KG_PER_KWH[fuel_code] # ← returns 0.028, never translates +translated = API_FUEL_TO_TABLE_12.get(fuel_code) # 20 → 51 (community gas, 0.21) — unreached +``` + +API community fuels **18–25** (`API_FUEL_TO_TABLE_12`: 18→75,19→76,20→51,21→52,22→53, +23→55,24→54,25→41) all **collide** with Table-12 codes 18–25 (biomass), so they silently +get the biomass factor. The heat-network CO2 path +(`cert_to_inputs.py` ~L2801-2819: `_co2_factor_kg_per_kwh(main) * scaling`) thus emits +`0.028 × 1.25 (1/0.80 heat-source-eff) = 0.035` instead of `0.21 × 1.25`. + +**Why the fix needs care (don't just translate):** a naive translate of the heat-network +fuel via `API_FUEL_TO_TABLE_32` corrects CO2 (0.44 → 3.03 t, ≈ lodged 2.8) but +**over-corrects PE** (204 → 219.9 vs lodged 205) and **doesn't move the SAP +4** (cost +is a *separate* community issue — price/standing-charge/heat-source-eff scaling). The +community scheme's declared PE/CO2/cost factors must be pinned against a **9390 Elmhurst +worksheet** before committing. This touches the community-heating corpus (the "touchy" +S0380.180-184 area — see [[project_heating_systems_corpus]]); run the heating-systems +corpus test after any change. + +The right long-term shape: translate API→Table-12 **explicitly** at the known-API-code +boundary instead of the ambiguous T12-first "accept-either" — but verify it against the +whole cohort. + +--- + +## OPEN THREAD 3 — Cert 0390 +7 (demand-side), no worksheet needed to diagnose + +**0390-2954-3640**: SAP +7 (calc 67 / lodged 60), PE 137 vs **165** (−28/m²), CO2 12.3 vs +15. Large **360 m² age-F** detached. The boiler is **correctly** resolved (PCDB index +**9005** = "Firebird S", 86.4% winter — a real modern oil-boiler retrofit; not a bug). +The gap is a **demand-side under-count** (~−28 PE/m² = thousands of kWh). Suspects: +cavity wall "as built, **partial** insulation (assumed)" routing; age-F roof "insulated +(assumed)"; floor (solid, no insulation) 81 W/K; ventilation/TB. Walk the demand +cascade (§2.4 helpers + the lodged register subsystem ratings) to localise. This is a +known long-standing gap (see the cert's `notes:` in `test_golden_fixtures.py`). + +--- + +## Full audit — all golden certs with non-zero SAP residual + +| cert | SAP resid | diagnosis | +|---|---|---| +| 0390-2954-3640 | +7 | demand-side under-count (Thread 3); boiler eff correct | +| 9390-2722-3520 | +4 | community fuel-code collision → CO2 6× low (Thread 2) | +| 0240-0200-5706 | −1 | NOT a bug — unpreserved 2013+ pump; true SAP 72 | +| 2130-1033-4050 | +1 | minor fabric precision (multi-part solid-brick wall); low value | +| 7536-3827-0600 | +1 | minor fabric precision (multi-bp D/L/F cavity); low value | + +All others pin at residual 0. + +--- + +## Worksheets + +- **Available** (user-generated, `sap worksheets/golden fixture debugging/simulated case N/`): + case 7 (combi), case 8 (unknown pump), case 9 (sandstone wall + sloping roof), + case 10 (solid-brick wall). Case 7's Summary is the only one mirrored into tracked + fixtures (`backend/.../Summary_001431_case7.pdf`, used by the e2e pin). +- **Needed:** **case 11** (vaulted Ext1 roof, NI, band J — Thread 1) and a **9390** + worksheet (community PE/CO2/cost — Thread 2). + +## Pointers +- Golden pins + slice history: `tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py`. +- Wall fix: `domain/sap10_calculator/worksheet/heat_transmission.py` + (`_described_as_retrofit_insulated`, the `wall_ins_present` gate) + + `tests/.../worksheet/test_heat_transmission.py`. +- Roof code: `domain/sap10_ml/rdsap_uvalues.py` `u_roof` (L708 + Table 18 dicts L637-668). +- Community/heat-network CO2/PE/cost: `cert_to_inputs.py` ~L2749-2880, L1837 + (`_main_fuel_code`), `tables/table_12.py` + `table_32.py` (`co2_factor_kg_per_kwh`, + `API_FUEL_TO_TABLE_12/32`). +- Process: one slice = one commit, spec citation (page+line), AAA tests, `abs(x-y)<=tol` + not `pytest.approx`, `Co-Authored-By: Claude Opus 4.8 `. + SAP 10.2 only. No tolerance widening. mapper.py + cert_to_inputs.py each carry 32 + pre-existing pyright errors; heat_transmission.py + rdsap_uvalues.py carry their own — + baseline-compare with `git stash` for net-zero NEW. From c75ef6417fb5bd877e0de0507338d7921c0506f3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 21:57:00 +0000 Subject: [PATCH 25/80] =?UTF-8?q?S0380.210:=20cert=200390=20cavity=20"part?= =?UTF-8?q?ial=20insulation=20(assumed)"=20=E2=86=92=20as-built=20row,=20n?= =?UTF-8?q?ot=20filled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden cert 0390-2954-3640 (detached, TFA 360, age F) carried a +7 SAP / -28 kWh/m² PE residual the audit attributed to a demand-side fabric gap. Walking the §3 cascade localised it to the Main wall: lodged wall_construction=4 (cavity), wall_insulation_type=4 (as-built / assumed), description "Cavity wall, as built, partial insulation (assumed)". The cascade mis-routed it to the Table 6 "Filled cavity" row (band F = 0.40) because `_described_as_insulated` matches the "partial insulation" substring. RdSAP 10 Specification (10-06-2025) Table 6 — Wall U-values, England distinguishes two cavity rows: "Cavity as built" A-E 1.5, F 1.0, G 0.60, H 0.60, I 0.45, J 0.35, ... "Filled cavity" A-E 0.7, F 0.40, G 0.35, H 0.35, I 0.45†, J 0.35†, ... An "as built ... partial insulation (assumed)" cavity is the as-built partial fill of the age band, NOT a retrofit cavity fill (a genuine fill lodges the distinct "Cavity wall, filled cavity", wall_insulation_type=2). It therefore routes to "Cavity as built" (band F = 1.0), mirroring the worksheet-validated solid-brick rule in S0380.209 (cases 9/10: "as built, insulated (assumed)" → as-built age-band row, not retrofit). New `_cavity_described_as_filled` predicate is used only in u_wall's cavity filled-row branch; it excludes the "partial insulation" substring while keeping "insulated (assumed)" → filled (the unrelated, separately asserted test_cavity_as_built_insulated_assumed_uses_filled_cavity_row is unchanged). The shared `_described_as_insulated` (also consumed by the roof/floor paths) is left untouched. Wall HLC +53.6 W/K (U 0.40 → 1.0 over ~268 m²) lifts all four metrics together — the signature of a real fabric bug, not a tuned offset: SAP +7 → +0 PE -27.9745 → +0.5281 kWh/m² CO2 -2.7134 → -0.1189 t/yr Bands I-M are unaffected (the two rows coincide per the † footnote), so golden certs 0535 (band M) / 7536 (band L) with "insulated (assumed)" cavities continue to pin at 0. Full suite 2384 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 --- domain/sap10_ml/rdsap_uvalues.py | 37 ++++++++++++- .../rdsap/test_golden_fixtures.py | 19 +++++-- .../worksheet/test_heat_transmission.py | 52 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 23c36bb3..6a089aa3 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -66,6 +66,41 @@ def _described_as_insulated(description: Optional[str]) -> bool: return "insulated" in desc or "partial insulation" in desc +def _cavity_described_as_filled(description: Optional[str]) -> bool: + """True when an as-built cavity wall's description asserts the cavity is + insulated/filled, routing it to the Table 6 "Filled cavity" row. + + Distinguishes the three as-built cavity states the EPC renders by age + band when wall_insulation_type=4 ("as-built / assumed"): + + - "...insulated (assumed)" → Filled cavity (assessor judges + the cavity filled but lodges no + thickness) + - "...partial insulation (assumed)" → "Cavity as built" row (the + as-built partial fill of the age + band, NOT a retrofit cavity fill) + - "...no insulation (assumed)" → "Cavity as built" row + + Narrower than `_described_as_insulated`: it excludes the "partial + insulation" substring so a "partial insulation (assumed)" cavity stays on + the as-built row. RdSAP 10 Table 6 (England) "Cavity as built" band F = + 1.0 vs "Filled cavity" band F = 0.40 — for an as-built band-F cavity the + filled row understates heat loss by 2.5x. A genuine retrofit fill is + lodged distinctly as "Cavity wall, filled cavity" + (wall_insulation_type=2), handled by the explicit-code branch. + + Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, cavity + type 4, "partial insulation (assumed)") closes all four SAP metrics on + the as-built 1.0 row; the filled 0.40 row under-counts PE by ~28 kWh/m². + """ + if description is None: + return False + desc = description.lower() + if "no insulation" in desc: + return False + return "insulated" in desc + + # --------------------------------------------------------------------------- # Country # --------------------------------------------------------------------------- @@ -597,7 +632,7 @@ def u_wall( ) if wall_type == WALL_CAVITY and ( wall_insulation_type == WALL_INSULATION_FILLED_CAVITY - or _described_as_insulated(description) + or _cavity_described_as_filled(description) ): return _CAVITY_FILLED_ENG[age_idx] bucket = _insulation_bucket(insulation_thickness_mm, insulation_present) diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 6093f71f..38f03079 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -236,9 +236,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0390-2954-3640-2196-4175", actual_sap=60, - expected_sap_resid=+7, - expected_pe_resid_kwh_per_m2=-27.9745, - expected_co2_resid_tonnes_per_yr=-2.7134, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=+0.5281, + expected_co2_resid_tonnes_per_yr=-0.1189, notes=( "Detached, TFA 360, age F, Firebird oil combi PCDF 9005 " "(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + " @@ -269,7 +269,18 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "Slice S0380.151 wired RdSAP 10 §4.1 Table 5 (PDF p.28) " "extract-fans default (age F → 1 fan). Cascade ventilation " "HLC rises ~0.03 ACH × volume; PE -28.0830 → -27.9745 " - "(closer to zero), CO2 -2.7342 → -2.7134." + "(closer to zero), CO2 -2.7342 → -2.7134. " + "Slice S0380.210 CLOSED the residual: the Main cavity wall lodges " + "wall_insulation_type=4 (as-built/assumed) + description " + "'Cavity wall, as built, partial insulation (assumed)'. The " + "cascade mis-routed it to the Table 6 'Filled cavity' row " + "(band F = 0.40) via the 'partial insulation' substring; " + "RdSAP 10 Table 6 (England) routes an as-built partial-fill " + "cavity to the 'Cavity as built' row (band F = 1.0). New " + "`_cavity_described_as_filled` excludes 'partial insulation' " + "(keeping 'insulated (assumed)' → filled). Wall HLC +53.6 W/K " + "(0.40 → 1.0 over 268 m²) lifted all four metrics together: " + "SAP +7 → +0, PE -27.9745 → +0.5281, CO2 -2.7134 → -0.1189." ), ), _GoldenExpectation( diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 1fdfec3b..76edac33 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -307,6 +307,58 @@ def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None: assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0) +def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None: + # Arrange — the EPC renders a cavity wall lodged wall_insulation_type=4 + # (as-built / assumed) with description "Cavity wall, as built, partial + # insulation (assumed)" for age bands where the as-built construction + # carries only partial cavity fill. "Partial insulation" is the as-built + # thermal state of the age band, NOT a retrofit cavity fill — the spec + # routes it to the "Cavity as built" row, not "Filled cavity": + # RdSAP 10 Table 6 (England) "Cavity as built" band F = 1.0 vs + # "Filled cavity" band F = 0.40. A genuine fill renders the distinct + # "Cavity wall, filled cavity" description (wall_insulation_type=2), + # caught separately. Contrast the "insulated (assumed)" variant above, + # which the assessor judges as filled. + # + # Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, + # cavity type 4, "partial insulation (assumed)") closes all four SAP + # metrics (PE/SAP/CO2/cost) on the as-built 1.0 row — at the filled + # 0.40 row its PE under-counts by ~28 kWh/m². + # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single storey + # → gross_wall = 100 m². walls_w_per_k expected = 1.0 × 100 = 100 W/K. + main = make_building_part( + construction_age_band="F", + wall_construction=4, + wall_insulation_type=4, + party_wall_construction=1, + roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=100.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + epc.walls = [ + EnergyElement( + description="Cavity wall, as built, partial insulation (assumed)", + energy_efficiency_rating=3, + environmental_efficiency_rating=3, + ), + ] + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — U=1.0 × 100 m² gross wall = 100 W/K (as-built, not filled 70). + assert abs(result.walls_w_per_k - 100.0) <= 1.0 + + def test_walls_description_measured_transmittance_overrides_construction_cascade() -> None: # Arrange — a full-SAP (not RdSAP) cert lodges the wall U-value # directly in walls[i].description ("Average thermal transmittance From 0add6b6a59b8c62623e1758e2f1f21c41b8a43dc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 21:58:37 +0000 Subject: [PATCH 26/80] docs: mark Thread 3 (cert 0390) CLOSED by S0380.210 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the mapper-bugs handover: Thread 3 closed via the cavity "partial insulation (assumed)" → "Cavity as built" routing fix; record the latent open question about the unvalidated "insulated (assumed)" → filled-cavity test (slice S-B25). Bump HEAD/baseline/next-slice. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_MAPPER_BUGS.md | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md index 0ac86d11..5087fb1f 100644 --- a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md +++ b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md @@ -6,9 +6,11 @@ state after closing the 0240 investigation and fixing the first of several API-mapper/cascade bugs the audit surfaced. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `844fc22f` (S0380.209). Confirm with `git rev-parse HEAD`. -- **Baseline:** `2383 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). -- **Next slice number:** **S0380.210**. +- **HEAD:** `c75ef641` (S0380.210). Confirm with `git rev-parse HEAD`. +- **Baseline:** `2384 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). +- **Next slice number:** **S0380.211**. +- **Open:** Thread 1 (roof, needs case 11) + Thread 2 (community 9390, needs 9390 ws). + Thread 3 (0390) **CLOSED** below. --- @@ -18,6 +20,7 @@ API-mapper/cascade bugs the audit surfaced. |---|---| | **S0380.208** | Promoted **simulated case 7** (combi swap of case 6) to an e2e fixture. PROVED the condensing-oil-combi (SAP code 130, no cylinder, combi instantaneous DHW, Eq D1, Table 3a keep-hot) path is **exact at 1e-4** with zero calculator changes → exonerated the heating as the source of 0240's residual. | | **S0380.209** | Fixed the **API-path wall U** "as built, insulated (assumed)" bug — routes to the as-built age-band row, not the 50 mm retrofit bucket. New `_described_as_retrofit_insulated` in `heat_transmission.py`. Worksheet-validated by case 9 (sandstone J → 0.35) + case 10 (solid brick J → 0.35). Re-pinned 0240 PE +1.8687 → +5.5044, CO2 +0.0907 → +0.2757 (SAP integer 72 unchanged). | +| **S0380.210** | **CLOSED cert 0390** (Thread 3). Cavity wall "as built, **partial** insulation (assumed)" (type 4) was mis-routed to the Table 6 "Filled cavity" row (band F 0.40) → should be "Cavity as built" (band F 1.0). New `_cavity_described_as_filled` in `rdsap_uvalues.py` excludes "partial insulation" from the filled trigger (keeps "insulated (assumed)" → filled). SAP +7 → +0, PE −27.97 → +0.53, CO2 −2.71 → −0.12. Mirrors S0380.209 on the cavity path. | Both also carry a memory: [[project_case7_combi_exonerated]], [[project_as_built_insulated_assumed_bug]]. @@ -128,16 +131,36 @@ whole cohort. --- -## OPEN THREAD 3 — Cert 0390 +7 (demand-side), no worksheet needed to diagnose +## THREAD 3 — Cert 0390 +7 — **CLOSED (S0380.210)** -**0390-2954-3640**: SAP +7 (calc 67 / lodged 60), PE 137 vs **165** (−28/m²), CO2 12.3 vs -15. Large **360 m² age-F** detached. The boiler is **correctly** resolved (PCDB index -**9005** = "Firebird S", 86.4% winter — a real modern oil-boiler retrofit; not a bug). -The gap is a **demand-side under-count** (~−28 PE/m² = thousands of kWh). Suspects: -cavity wall "as built, **partial** insulation (assumed)" routing; age-F roof "insulated -(assumed)"; floor (solid, no insulation) 81 W/K; ventilation/TB. Walk the demand -cascade (§2.4 helpers + the lodged register subsystem ratings) to localise. This is a -known long-standing gap (see the cert's `notes:` in `test_golden_fixtures.py`). +**0390-2954-3640** (detached, TFA 360, age F). The boiler was correctly resolved +(PCDB 9005 Firebird S, 86.4% winter); the gap was a single fabric mis-route. Walking +the §3 cascade localised it to the Main **cavity wall**: lodged `wall_construction=4`, +`wall_insulation_type=4` (as-built/assumed), description "Cavity wall, as built, +**partial insulation** (assumed)". The cascade routed it to the Table 6 **"Filled +cavity"** row (band F = 0.40) because `_described_as_insulated` matches the "partial +insulation" substring. Per **RdSAP 10 Table 6 (England)** an as-built partial-fill +cavity uses **"Cavity as built"** (band F = **1.0**), not filled — a genuine fill +lodges the distinct "Cavity wall, filled cavity" (`wall_insulation_type=2`). This +mirrors the worksheet-validated solid-brick rule from S0380.209 (cases 9/10). + +Fix: new `_cavity_described_as_filled` predicate, used **only** in u_wall's cavity +filled-row branch, excludes "partial insulation" while keeping "insulated (assumed)" +→ filled. Wall HLC +53.6 W/K lifted all four metrics together: **SAP +7 → +0**, +PE −27.9745 → +0.5281, CO2 −2.7134 → −0.1189. Bands I-M coincide († footnote) so +0535(M)/7536(L) are unaffected. Re-pinned in `test_golden_fixtures.py`. + +**Diagnosis lesson / latent follow-up:** the fix collided with an existing test, +`test_cavity_as_built_insulated_assumed_uses_filled_cavity_row` (heat_transmission +tests). That test (from early "slice S-B25") asserts a cavity **"insulated (assumed)"** +→ filled row, citing only an *assumption* ("the assessor has determined the cavity is +filled"), **never worksheet-validated** — and it is the OPPOSITE conclusion from the +worksheet-backed solid-brick sibling. The narrow S0380.210 fix leaves it untouched +(no current cert exercises it at a band where as-built ≠ filled). **Open question for a +future worksheet:** does a cavity lodged "as built, insulated (assumed)" (type 4) +belong on the filled row (0.7 at E) or the as-built row (1.5 at E)? If a worksheet +says as-built, fold "insulated (assumed)" into the as-built routing too and retire +that test. --- @@ -145,7 +168,7 @@ known long-standing gap (see the cert's `notes:` in `test_golden_fixtures.py`). | cert | SAP resid | diagnosis | |---|---|---| -| 0390-2954-3640 | +7 | demand-side under-count (Thread 3); boiler eff correct | +| 0390-2954-3640 | ~~+7~~ **+0** | **CLOSED S0380.210** — cavity partial-insulation → as-built row | | 9390-2722-3520 | +4 | community fuel-code collision → CO2 6× low (Thread 2) | | 0240-0200-5706 | −1 | NOT a bug — unpreserved 2013+ pump; true SAP 72 | | 2130-1033-4050 | +1 | minor fabric precision (multi-part solid-brick wall); low value | From 90f6720cae63d301603cbba05e42a60c75143824 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 22:57:22 +0000 Subject: [PATCH 27/80] =?UTF-8?q?S0380.211:=20vaulted/sloping=20roof=20NI?= =?UTF-8?q?=20insulation=20=E2=86=92=20Table=2018=20col=20(1),=20not=2050?= =?UTF-8?q?=20mm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the Ext1 vaulted-roof over-count that S0380.209 exposed on golden cert 0240-0200-5706. BP2 lodges roof_construction=5 (vaulted ceiling), roof_insulation_thickness="NI" (parsed to 0), description "Pitched, insulated (assumed)", band J. The cascade returned U=0.68 — the RdSAP 10 §5.11.4 (p.44) retrofit-50 mm "insulation at joists" row. A vaulted / sloping ceiling has no ceiling-joist void, so that row does not apply; per RdSAP 10 §5.11 Table 18 (p.45) it takes the column (1) age-band default (band J = 0.16). The arbiter is the cohort, not the spec text alone: 33 cohort-2 certs lodge "ND" (thickness None) vaulted roofs (roof_construction=5, band D) that already pin to their dr87 worksheets at U=0.40 = Table 18 col (1) by falling through the age-band default. 0240's only difference is the "NI" sentinel (insulation present, unknown thickness) which uniquely hit the 0.68 override. (The S0380.209 note's predicted "cont ≈ 72.31" assumed a col-3 0.25 value; the cohort's ND vaulted roofs disprove that — they use col (1), so 0240 lands at cont 72.4617.) Implementation: new `u_roof(is_sloping_ceiling=...)` flag, threaded from heat_transmission for roof_construction_type containing "sloping ceiling" (code 8) or "vaulted" (code 5). It fires only for the NI case (thickness 0 + "insulated (assumed)"), routing to the col (1) age-band default; the "ND"/None path is untouched (already col 1) and a NORMAL pitched-with-loft roof still takes the §5.11.4 50 mm row (flag defaults False). roof 76.93 → ~68 W/K → 0240 PE +5.5044 → +1.5181, CO2 +0.2757 → +0.0728 (SAP integer 72 unchanged — the true value; lodged 73 needs the unpreserved 2013+ pump). Also corrects test_u_wall_cavity_as_built_partial_insulation_routes_to_ filled_cavity_row → ..._routes_to_as_built_row: a missed S0380.210 follow-up. That test (in domain/sap10_ml/tests/, which the AGENT_GUIDE §4 suite command does not run) asserted the pre-S0380.210 "partial insulation → filled" behavior on legacy-map parity, not worksheet evidence; S0380.210 corrected it to the as-built row per RdSAP 10 Table 6 + golden cert 0390's four-metric closure. Suite: 2614 passed, 1 skipped; the 2 remaining failures in test_rdsap_uvalues.py (stone §5.6 thin-wall formula vs Table-6 1.7 cap) are pre-existing (fail at HEAD 58ff7d88, before this branch's work). Co-Authored-By: Claude Opus 4.8 --- .../worksheet/heat_transmission.py | 10 ++- domain/sap10_ml/rdsap_uvalues.py | 26 +++++++ domain/sap10_ml/tests/test_rdsap_uvalues.py | 78 ++++++++++++++++--- .../rdsap/test_golden_fixtures.py | 21 ++++- 4 files changed, 121 insertions(+), 14 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 91185ea6..9ddce6c6 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -783,7 +783,15 @@ def heat_transmission_from_cert( # spec value is 2.30 (A-D) / 1.50 (E) / 0.68 (F) / 0.40 (G). roof_type_lower = (part.roof_construction_type or "").lower() is_flat_roof = "flat" in roof_type_lower - ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof) + # RdSAP 10 §5.11 Table 18 — a pitched roof whose ceiling follows the + # slope ("Pitched, sloping ceiling" code 8 / "Pitched (vaulted + # ceiling)" code 5) has no loft void, so an unknown-thickness + # lodgement takes the column (3) "Flat roof / sloping ceiling" + # age-band default rather than the §5.11.4 retrofit-50 mm joist row. + is_sloping_ceiling = ( + "sloping ceiling" in roof_type_lower or "vaulted" in roof_type_lower + ) + ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling) # Floor U-value routing (in priority order): # 1. Basement floor — Table 23 F-column override (whole floor=0). # 2. Exposed/semi-exposed upper floor — Table 20 lookup; no diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 6a089aa3..0e39f8a7 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -720,6 +720,7 @@ def u_roof( insulation_thickness_mm: Optional[int], description: Optional[str] = None, is_flat_roof: bool = False, + is_sloping_ceiling: bool = False, ) -> float: """RdSAP10 roof U-value in W/m^2K, never null. @@ -733,6 +734,17 @@ def u_roof( 3. Table 18 age-band default — column (1) "Pitched, insulation between joists" by default; column (3) "Flat roof" when `is_flat_roof=True`. Spec §5.11 Table 18 page 45. + + `is_sloping_ceiling` flags a pitched roof whose ceiling follows the + slope (a "Pitched, sloping ceiling" or "Pitched (vaulted ceiling)" + construction — RdSAP roof_construction codes 8 and 5). Such a roof has + no loft / ceiling-joist void, so an "NI" lodgement (parsed to 0) + + "insulated (assumed)" description means unknown-thickness-with-insulation, + NOT the §5.11.4 retrofit-50 mm joist row (0.68) a normal pitched-with- + loft roof would take. It instead takes the Table 18 column (1) age-band + default (band J = 0.16) — the same value a vaulted roof lodged "ND" + (thickness None) already reaches by falling through. The 33 cohort-2 + "ND" vaulted certs (code 5, band D → 0.40 = col 1) are the evidence. """ measured = _measured_u_from_description(description) if measured is not None: @@ -740,6 +752,20 @@ def u_roof( # ("Average thermal transmittance X W/m²K"); spec §5.11 opening # clause defers to the assessor's value when present. return measured + if ( + is_sloping_ceiling + and age_band is not None + and insulation_thickness_mm == 0 + and _described_as_insulated(description) + ): + # RdSAP 10 §5.11 Table 18 page 45 — a vaulted/sloping ceiling has no + # ceiling-joist void, so the "NI" sentinel (parsed to 0) + + # "insulated (assumed)" is unknown-thickness-with-insulation, not + # 0 mm uninsulated. It must NOT fall to the §5.11.4 retrofit-50 mm + # joist row (0.68) below; it takes the column (1) age-band default + # (band J = 0.16), matching the cohort's "ND" (thickness None) + # vaulted roofs which already reach col (1) by falling through. + return _ROOF_BY_AGE.get(age_band.upper(), 0.4) if insulation_thickness_mm == 0 and _described_as_insulated(description): # Spec §5.11.4 (page 44 footnote): "If retrofit insulation # present of unknown thickness use 50 mm". The cert encodes diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 906ce3b8..a864600f 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -165,18 +165,28 @@ def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_ro assert result == pytest.approx(1.5, abs=0.001) -def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() -> None: - # Arrange — 147 corpus certs lodge "Cavity wall, as built, partial - # insulation (assumed)" with wall_insulation_type=4. The legacy - # production map (recommendations/rdsap_tables.py:753) routes these - # to "Filled cavity" — same destination as the "insulated (assumed)" - # case. We match that interpretation for parity with the cert - # assessor and the production recommendation engine. +def test_u_wall_cavity_as_built_partial_insulation_routes_to_as_built_row() -> None: + # Arrange — a cavity lodged "Cavity wall, as built, partial insulation + # (assumed)" with wall_insulation_type=4 is in its AS-BUILT state (the + # partial fill of the age band), NOT a retrofit cavity fill. Per + # RdSAP 10 Table 6 (England) it uses the "Cavity as built" row, not + # "Filled cavity": band D = 1.5 (as built) vs 0.7 (filled). A genuine + # fill lodges the distinct "Cavity wall, filled cavity" + # (wall_insulation_type=2), caught by the explicit-code branch. + # + # Slice S0380.210 corrected this: the prior routing to "Filled cavity" + # mirrored a legacy production map, but golden cert 0390-2954-3640 + # (band F, cavity type 4, "partial insulation (assumed)") closes all + # four SAP metrics on the as-built row (band F = 1.0) and under-counts + # PE by ~28 kWh/m² on the filled row — the legacy parity was a latent + # bug at bands A-H (bands I-M coincide per the Table 6 † footnote). + # The "insulated (assumed)" variant still routes to filled (see the + # heat_transmission `_cavity_described_as_filled` sibling test). # Act result = u_wall( country=Country.ENG, - age_band="D", # 1950-1966 — typical partial-fill retrofit cohort + age_band="D", # 1950-1966 — as-built ≠ filled at this band construction=WALL_CAVITY, insulation_thickness_mm=None, insulation_present=False, @@ -184,8 +194,8 @@ def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() description="Cavity wall, as built, partial insulation (assumed)", ) - # Assert — Filled-cavity row at band D = 0.7 W/m²K. - assert result == pytest.approx(0.7, abs=0.001) + # Assert — Cavity-as-built row at band D = 1.5 W/m²K (not filled 0.7). + assert abs(result - 1.5) <= 0.001 def test_u_wall_description_without_transmittance_phrase_routes_through_cascade() -> None: @@ -841,6 +851,54 @@ def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None: assert result == pytest.approx(0.4, abs=0.001) +def test_u_roof_vaulted_ni_unknown_band_j_uses_col1_age_band_not_50mm() -> None: + # Arrange — a pitched roof with a vaulted/sloping ceiling (no joist + # void) lodged with insulation thickness "NI" (Not Indicated, parsed + # to 0) + an "insulated (assumed)" description. For a NORMAL pitched + # roof this hits the §5.11.4 "retrofit 50 mm" override (U=0.68, the + # Table 16 joist row) — but a vaulted/sloping ceiling has no joist + # void, so RdSAP 10 Table 18 routes it to the column (1) age-band + # default: band J = 0.16 W/m²K (NOT 0.68). This is the same value a + # vaulted roof lodged "ND" (thickness None) already reaches by falling + # through to the age-band default. + # + # Cohort-validated: 33 cohort-2 certs lodge "ND" vaulted roofs + # (roof_construction=5, band D) that pin to worksheet U=0.40 = col (1). + # Closes golden cert 0240's Ext1 vaulted roof (code 5, NI, band J) + # which the cascade returned at 0.68 (offsetting the wall under-count + # fixed in S0380.209). + + # Act + result = u_roof( + country=Country.ENG, + age_band="J", + insulation_thickness_mm=0, # parsed from "NI" + description="Pitched, insulated (assumed)", + is_sloping_ceiling=True, + ) + + # Assert + assert abs(result - 0.16) <= 1e-4 + + +def test_u_roof_normal_pitched_ni_insulated_still_returns_50mm_per_5_11_4() -> None: + # Arrange — regression guard: the is_sloping_ceiling flag defaults + # False, so a NORMAL pitched roof (with loft) lodged NI + "insulated + # (assumed)" must STILL hit the §5.11.4 retrofit-50 mm row (U=0.68). + # Same inputs as the sloping test above minus is_sloping_ceiling. + + # Act + result = u_roof( + country=Country.ENG, + age_band="J", + insulation_thickness_mm=0, + description="Pitched, insulated (assumed)", + ) + + # Assert + assert abs(result - 0.68) <= 1e-4 + + def test_u_roof_flat_age_band_d_returns_table18_col3_value() -> None: # Arrange — RdSAP 10 §5.11 Table 18 page 45 column (3) "Flat roof": # age band D, thickness unknown → U = 2.30 W/m²K. Column (1) diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 38f03079..e9918716 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+5.5044, - expected_co2_resid_tonnes_per_yr=+0.2757, + expected_pe_resid_kwh_per_m2=+1.5181, + expected_co2_resid_tonnes_per_yr=+0.0728, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -202,7 +202,22 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "lands SAP cont ≈ 72.31 (= Elmhurst case 9). The lodged 73 " "requires a 2013+ pump (case 7); 0240's API lodges the pump " "as Unknown (code 0 → 115, proven 0=Unknown across 9 API+" - "Summary pairs), so 73 is unreachable from the lodged inputs." + "Summary pairs), so 73 is unreachable from the lodged inputs. " + "Slice S0380.211 fixed the Ext1 vaulted-roof over-count S0380.209 " + "exposed: BP2 lodges roof_construction=5 (vaulted), NI thickness, " + "'Pitched, insulated (assumed)', band J → the cascade returned " + "U 0.68 (the §5.11.4 retrofit-50 mm joist row). A vaulted ceiling " + "has no joist void, so per RdSAP 10 Table 18 it takes the column " + "(1) age-band default (band J = 0.16) — the SAME value the 33 " + "cohort-2 'ND' vaulted roofs (code 5, band D → 0.40 = col 1) " + "reach by falling through. New u_roof `is_sloping_ceiling` flag " + "(threaded from heat_transmission for codes 5/8) routes the 'NI' " + "variant to col (1) too. roof 76.93 → ~68 W/K → PE +5.5044 → " + "+1.5181, CO2 +0.2757 → +0.0728 (SAP integer 72 unchanged — the " + "true value; the lodged 73 needs the unpreserved 2013+ pump). " + "NB the S0380.209 note's predicted 'cont ≈ 72.31 (case 9, U 0.25 " + "col 3)' was an unconfirmed guess; the cohort's 'ND' vaulted " + "roofs are the arbiter and use col (1) 0.16 → cont 72.4617." ), ), _GoldenExpectation( From 55af4ee2d7dba4064959fc3c024a5e267f2193b1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 22:59:35 +0000 Subject: [PATCH 28/80] docs: Thread 1 (roof) CLOSED by S0380.211; Thread 2 needs code-301 gas ws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the roof closure (vaulted NI → Table 18 col 1 0.16, cohort-arbitrated not the guessed 0.25), the AGENT_GUIDE suite-command gap (sap10_ml/tests/ not run) + pre-existing stone failures, cases 11/12/13 now available, and the fuel-20 = community-gas (Table 12 code 51) note. Thread 2 still needs a code-301 community-boiler + mains-gas worksheet (case 13 is code-302 CHP). Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_MAPPER_BUGS.md | 90 +++++++++++-------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md index 5087fb1f..f735dfc1 100644 --- a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md +++ b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md @@ -6,11 +6,13 @@ state after closing the 0240 investigation and fixing the first of several API-mapper/cascade bugs the audit surfaced. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `c75ef641` (S0380.210). Confirm with `git rev-parse HEAD`. +- **HEAD:** `90f6720c` (S0380.211). Confirm with `git rev-parse HEAD`. - **Baseline:** `2384 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). -- **Next slice number:** **S0380.211**. -- **Open:** Thread 1 (roof, needs case 11) + Thread 2 (community 9390, needs 9390 ws). - Thread 3 (0390) **CLOSED** below. + ALSO run `domain/sap10_ml/tests/` when touching `rdsap_uvalues.py` — 2 PRE-EXISTING + stone-formula failures there, see Thread 1. +- **Next slice number:** **S0380.212**. +- **Open:** Thread 2 (community 9390) — needs a **code-301 boiler + mains-gas** worksheet + (case 13 is code-302 CHP-gas, close but not 9390's scheme). Threads 1 + 3 **CLOSED**. --- @@ -21,6 +23,7 @@ API-mapper/cascade bugs the audit surfaced. | **S0380.208** | Promoted **simulated case 7** (combi swap of case 6) to an e2e fixture. PROVED the condensing-oil-combi (SAP code 130, no cylinder, combi instantaneous DHW, Eq D1, Table 3a keep-hot) path is **exact at 1e-4** with zero calculator changes → exonerated the heating as the source of 0240's residual. | | **S0380.209** | Fixed the **API-path wall U** "as built, insulated (assumed)" bug — routes to the as-built age-band row, not the 50 mm retrofit bucket. New `_described_as_retrofit_insulated` in `heat_transmission.py`. Worksheet-validated by case 9 (sandstone J → 0.35) + case 10 (solid brick J → 0.35). Re-pinned 0240 PE +1.8687 → +5.5044, CO2 +0.0907 → +0.2757 (SAP integer 72 unchanged). | | **S0380.210** | **CLOSED cert 0390** (Thread 3). Cavity wall "as built, **partial** insulation (assumed)" (type 4) was mis-routed to the Table 6 "Filled cavity" row (band F 0.40) → should be "Cavity as built" (band F 1.0). New `_cavity_described_as_filled` in `rdsap_uvalues.py` excludes "partial insulation" from the filled trigger (keeps "insulated (assumed)" → filled). SAP +7 → +0, PE −27.97 → +0.53, CO2 −2.71 → −0.12. Mirrors S0380.209 on the cavity path. | +| **S0380.211** | **CLOSED Thread 1 (roof).** 0240 Ext1 vaulted (code 5) NI roof returned 0.68 (§5.11.4 50 mm) → should be Table 18 col (1) age-band (band J 0.16), matching 33 cohort-2 `ND` vaulted roofs. New `u_roof(is_sloping_ceiling=...)` flag threaded from heat_transmission (codes 5/8). 0240 PE +5.50 → +1.52, CO2 +0.28 → +0.07 (SAP 72). Also corrected the S0380.210 cavity unit test in `domain/sap10_ml/tests/` (suite-command gap — see Thread 1). | Both also carry a memory: [[project_case7_combi_exonerated]], [[project_as_built_insulated_assumed_bug]]. @@ -58,39 +61,42 @@ Elmhurst worksheets readily (cases 7–10 done) — ask for one. --- -## OPEN THREAD 1 — Roof fix (S0380.210), needs **case 11** +## THREAD 1 — Roof fix — **CLOSED (S0380.211)** -Same root cause as the wall (`project_as_built_insulated_assumed_bug`): the API mapper -populates `epc.roofs` with "Pitched, insulated (assumed)"; `u_roof` -([`rdsap_uvalues.py:708`](../../sap10_ml/rdsap_uvalues.py)) routes -`insulation_thickness_mm==0 and _described_as_insulated(description)` → **0.68** -(retrofit 50 mm). 0240's Ext1 is `roof_construction=5` "vaulted ceiling", `NI` thickness, -band J → cascade **0.68**, should be a Table 18 sloping-ceiling value. +0240's Ext1 (BP2) lodges `roof_construction=5` (vaulted), `NI` thickness, "Pitched, +insulated (assumed)", band J → the cascade hit `u_roof`'s +`insulation_thickness_mm==0 and _described_as_insulated` override → **0.68** (the +§5.11.4 retrofit-50 mm joist row). A vaulted/sloping ceiling has no joist void, so per +RdSAP 10 §5.11 Table 18 (p.45) it takes the **column (1) age-band default (band J = +0.16)**, NOT 0.68. -**Why it's harder than the wall:** -- Just swapping to `_described_as_retrofit_insulated` makes thickness-0 fall to the - Table 16 ladder → **2.30** (uninsulated). `NI`/unknown must route to the age-band - default, not 0 mm. -- `u_roof` receives the **joined all-roofs description** (can't tell which call is the - vaulted Ext1) and **no construction type** — needs the per-BP sloping/vaulted signal - threaded through (heat_transmission already computes `is_flat_roof`; add an - `is_sloping_ceiling`/vaulted flag similarly). -- **Code-5 "vaulted" is not recognised as a sloping ceiling** — only code-8 - "sloping ceiling" is (`_api_resolve_sloping_ceiling_thickness` gates on `==8`; - `heat_transmission` keys the cos(30°) area factor on the "sloping ceiling" substring). -- **Value ambiguity (needs case 11):** Table 18 (RdSAP 10 p.45) band J gives - col (1) joists/unknown **0.16**, col (2) **at rafters 0.20**, col (3) flat / - "sloping ceiling + unheated space above" (footnote b) **0.25**. Case 9 (code-8 - sloping, Unknown) → Elmhurst **0.25**, but that may not equal code-5 vaulted (could - be 0.20). All three give 0240 integer 72 — it's a PE-pin precision question. +**The arbiter was the cohort, not case 11 — a methodology trap avoided.** The handover +above guessed the value was col-3 **0.25** (→ cont 72.31), citing case 9. That was +WRONG. The decisive evidence: **33 cohort-2 certs lodge `ND` (thickness None) vaulted +roofs** (`roof_construction=5`, band D) that already pin to their dr87 worksheets at +**0.40 = Table 18 col (1)**. 0240's only difference is the `NI` sentinel (insulation +present, unknown thickness), which uniquely hit the 0.68 override. So the spec-correct +value is **col (1) 0.16**, and 0240 lands at **cont 72.4617**, integer 72 — NOT 72.31. +A first broad attempt (route sloping → col-3 `_FLAT_ROOF_BY_AGE`) broke all 33 cohort +certs (band D col-3 = 2.30 vs worksheet 0.40) — that failure is what revealed the +col-1 answer. Lesson: when a U-value change moves worksheet-pinned cohort certs off +their pins, the change is wrong; the cohort worksheets are ground truth. -**Generate case 11:** a **vaulted Ext1 roof (`roof_construction` vaulted), insulation -Unknown/NI, band J**; report worksheet **`(30)` U** (0.20 or 0.25?). Then implement the -roof fix to match, re-pin 0240 (both bugs fixed → cont ≈ 72.31, integer 72). +**Implementation:** new `u_roof(is_sloping_ceiling=...)` flag, threaded from +`heat_transmission` for `roof_construction_type` containing "sloping ceiling" (code 8) +or "vaulted" (code 5). Fires only on the `NI` case (thickness 0 + "insulated +(assumed)") → col (1); the `ND`/None path is untouched (already col 1) and a normal +pitched-with-loft roof still takes the §5.11.4 50 mm row (flag defaults False). 0240 +PE +5.5044 → +1.5181, CO2 +0.2757 → +0.0728 (SAP 72 unchanged). Re-pinned in +`test_golden_fixtures.py`. -Simulated worksheet `(30)` decompositions seen so far: case 9 Ext1 sloping-Unknown = -0.25; case 7/8/10 roofs are all 400 mm loft (col 1, 0.11) so they do NOT exercise the -sloping path. +**⚠ Suite-command gap discovered:** the AGENT_GUIDE §4 suite command does NOT run +`domain/sap10_ml/tests/`, where `u_roof`/`u_wall` unit tests live. S0380.210 shipped a +broken `test_u_wall_cavity_..._filled_cavity_row` there unnoticed; S0380.211 corrected +it (→ `..._as_built_row`). **When touching `rdsap_uvalues.py`, also run +`domain/sap10_ml/tests/`.** Two PRE-EXISTING failures live there (stone §5.6 thin-wall +formula 3.7408 vs Table-6 1.7 cap, granite + sandstone band A) — they fail at HEAD +`58ff7d88` too, unrelated to this branch. --- @@ -169,8 +175,8 @@ that test. | cert | SAP resid | diagnosis | |---|---|---| | 0390-2954-3640 | ~~+7~~ **+0** | **CLOSED S0380.210** — cavity partial-insulation → as-built row | -| 9390-2722-3520 | +4 | community fuel-code collision → CO2 6× low (Thread 2) | -| 0240-0200-5706 | −1 | NOT a bug — unpreserved 2013+ pump; true SAP 72 | +| 9390-2722-3520 | +4 | community fuel-code collision → CO2 6× low (Thread 2, OPEN) | +| 0240-0200-5706 | −1 | NOT a bug — unpreserved 2013+ pump; true SAP 72. Roof PE-pin tightened by **S0380.211** (PE +5.50 → +1.52) | | 2130-1033-4050 | +1 | minor fabric precision (multi-part solid-brick wall); low value | | 7536-3827-0600 | +1 | minor fabric precision (multi-bp D/L/F cavity); low value | @@ -182,10 +188,16 @@ All others pin at residual 0. - **Available** (user-generated, `sap worksheets/golden fixture debugging/simulated case N/`): case 7 (combi), case 8 (unknown pump), case 9 (sandstone wall + sloping roof), - case 10 (solid-brick wall). Case 7's Summary is the only one mirrored into tracked - fixtures (`backend/.../Summary_001431_case7.pdf`, used by the e2e pin). -- **Needed:** **case 11** (vaulted Ext1 roof, NI, band J — Thread 1) and a **9390** - worksheet (community PE/CO2/cost — Thread 2). + case 10 (solid-brick wall), **case 11** (001431 sloping-ceiling Unknown roof — used to + scope Thread 1; the cohort `ND` certs were the real arbiter), **case 12** (community + CHP **coal**, code 302), **case 13** (community CHP **mains gas**, code 302). Case 7's + Summary is the only one mirrored into tracked fixtures + (`backend/.../Summary_001431_case7.pdf`, used by the e2e pin). +- **Still needed (Thread 2):** a **code-301** (community boilers, NOT CHP) + **mains gas** + worksheet to pin 9390's exact PE/CO2/cost. case 13 is code-302 CHP-gas: it confirms the + community-gas direction (heat-network `(386)` CO2 0.2456) but the CHP heat-power split + differs from 9390's boiler scheme. **API `main_fuel_type=20` = community/district + heating from mains gas → SAP Table 12 code 51** (CO2 0.210, PEF 1.130). ## Pointers - Golden pins + slice history: `tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py`. From c3c44fa3d07c8fd021f7ffdca0a641ab87b8f07f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 23:07:05 +0000 Subject: [PATCH 29/80] =?UTF-8?q?docs:=20Thread=202=20unblocked=20?= =?UTF-8?q?=E2=80=94=20case=2014=20(code-301=20boiler+gas)=20arbitrates=20?= =?UTF-8?q?9390?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the community mains-gas BOILER worksheet (case 14): target (386) heat-network CO2 factor 0.2640, distribution loss 1.49, code 301. 9390 decomposition: PE matches (204 vs 205), CO2 6.5x low (collision), SAP +4 separate cost gap. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_MAPPER_BUGS.md | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md index f735dfc1..eb8a96a1 100644 --- a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md +++ b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md @@ -11,8 +11,8 @@ API-mapper/cascade bugs the audit surfaced. ALSO run `domain/sap10_ml/tests/` when touching `rdsap_uvalues.py` — 2 PRE-EXISTING stone-formula failures there, see Thread 1. - **Next slice number:** **S0380.212**. -- **Open:** Thread 2 (community 9390) — needs a **code-301 boiler + mains-gas** worksheet - (case 13 is code-302 CHP-gas, close but not 9390's scheme). Threads 1 + 3 **CLOSED**. +- **Open:** Thread 2 (community 9390) — UNBLOCKED: **case 14** (code-301 boiler + mains + gas) is on disk, target `(386)` heat-network CO2 = 0.2640. Threads 1 + 3 **CLOSED**. --- @@ -100,11 +100,22 @@ formula 3.7408 vs Table-6 1.7 cap, granite + sandstone band A) — they fail at --- -## OPEN THREAD 2 — Community fuel-code collision (cert 9390), needs a **9390 worksheet** +## OPEN THREAD 2 — Community fuel-code collision (cert 9390) — UNBLOCKED, **case 14 in hand** -Cert **9390-2722-3520**: SAP +4 (calc 71 / lodged 67), PE ≈ matches (204 vs 205), but -**CO2 0.44 t vs lodged 2.8 t**. Community scheme (`sap_main_heating_code=301`, -`main_fuel_type=20`). +Cert **9390-2722-3520** (TFA 75.17): SAP +4 (calc 71 / lodged 67), PE ≈ matches +(204 vs 205), but **CO2 5.86 kg/m² vs lodged 38** (0.44 t vs 2.8 t — 6.5× low). +Community scheme (`sap_main_heating_code=301`, `main_fuel_type=20`). + +**Arbitrating worksheet (case 14, `simulated case 14/`):** community **Boilers** +(`(303a)`=1.0, no CHP) + **Mains Gas** + **SAP code 301** — 9390's exact scheme. +Key targets: `(386)` **Overall heat-network CO2 factor = 0.2640**, `(306)` +distribution loss factor (Table 12c) = **1.49**, `(385)` EI rating 69. So a +community mains-gas BOILER scheme should drive CO2 at the heat-network factor +**0.2640** (≈ base mains-gas 0.210 × 1.257 source/loss scaling), NOT the biomass +0.028 the collision currently returns. **API `main_fuel_type=20` = community mains +gas → Table 12 code 51** (base CO2 0.210, PEF 1.130). Cases 12 (CHP coal, 302) + +13 (CHP gas, 302) also on disk — useful for the CHP `(303a/303b)` split but NOT +9390's boiler scheme. **Root cause:** `co2_factor_kg_per_kwh` / `unit_price_p_per_kwh` / `primary_energy_factor` in `tables/table_12.py` + `table_32.py` use an From 08dd0b4c73b63348ccb59c0d8f0589c6ed8b9da1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 23:25:53 +0000 Subject: [PATCH 30/80] S0380.212: fix community fuel-code collision in heat-network CO2/PE/cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cert 9390-2722-3520 (community mains-gas boiler scheme, sap_main_heating_ code=301, main_fuel_type=20) emitted CO2 0.44 t vs lodged 2.8 t — 6.4x low. Root cause: the EPC `main_fuel_type` enum and the SAP Table 12 / Table 32 fuel-code numbering COLLIDE in the 18-25 range. Per `datatypes/epc/domain/epc_codes.csv` (RdSAP-Schema-17.0) EPC fuel 20 = "mains gas (community)", but Table 12/32 code 20 is a solid biomass fuel (CO2 0.028, PE 1.046, wood-logs price). The factor lookups (`co2_factor_kg_per_kwh` / `primary_energy_factor` / `unit_price_p_per_kwh`) check the Table-12/32 dict FIRST, so the EPC community fuel 20 silently returned the biomass factor instead of translating 20 -> Table 12 code 51 (community mains gas: CO2 0.210, PE 1.130, mains-gas price). Fix: new `_heat_network_factor_fuel_code(main)` translates the EPC community fuel to its Table-12 code via `API_FUEL_TO_TABLE_12`, but ONLY for heat-network mains (`_is_heat_network_main`) — a genuine biomass boiler (non-community) keeps its raw Table-12 factor. Applied at the five heat-network factor sites: space-heating CO2 / PE / unit-price and water-heating (WHC 901) CO2 / PE. The Summary path is unaffected (it maps "Mains gas - community" to code 1, no collision), so the community-heating corpus (CH1-6) is untouched. Worksheet-validated against simulated case 14 (community boilers + mains gas, SAP code 301): worksheet (367) CO2 factor 0.2100, (467) PE factor 1.1300 — exactly the Table-12 code-51 values the translator now reaches. 9390 CO2 0.44 -> 3.03 t (lodged 2.8; spec-correct factors over the API-only register residual per [[feedback-worksheet-not-api-reference]]), PE 204 -> 220 (the spec-correct 1.13 factor; the prior 204≈205 match was the collision coinciding with the register residual). 9390 is unpinned (retired at P2.2 per ADR-0010 §10); the translator is locked by two unit tests. REMAINING (separate follow-up): 9390 SAP +4 is a cost-side gap — the heat-network cost path does not apply the 1/heat_source_eff (1/0.80) scaling that the CO2/PE paths do, so community fuel cost under-counts. Suite: 2616 passed, 1 skipped (community corpus green); the 2 test_rdsap_uvalues stone-formula failures are pre-existing (HEAD 58ff7d88). Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 44 ++++++++++++-- .../rdsap/test_cert_to_inputs.py | 57 +++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 13a7cf62..4fa6fde3 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1855,6 +1855,37 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]: raise MissingMainFuelType(fuel, main.sap_main_heating_code) +def _heat_network_factor_fuel_code( + main: Optional[MainHeatingDetail], +) -> Optional[int]: + """Fuel code to feed the Table 12 / Table 32 factor lookups, with the + EPC→Table-12 translation applied for heat-network (community) mains. + + The EPC `main_fuel_type` enum and the SAP Table 12 / Table 32 fuel-code + numbering COLLIDE in the 18-25 range: `epc_codes.csv` lists + 20='mains gas (community)', 21='LPG (community)', 22='oil (community)', + ..., whereas Table 12/32 code 20-25 are solid biomass fuels. The factor + lookups (`co2_factor_kg_per_kwh` / `primary_energy_factor` / + `unit_price_p_per_kwh`) check the Table-12/32 dict FIRST, so an EPC + community fuel 20 silently returns the biomass factor (CO2 0.028, PE + 1.046, wood-logs price) instead of community mains gas (CO2 0.210, PE + 1.130, mains-gas price + £120 standing charge). + + Resolution: for a heat-network main, translate the EPC community fuel to + its Table-12 code via `API_FUEL_TO_TABLE_12` (20->51) so the lookups hit + the heat-network row. NON-heat-network mains are returned unchanged so a + genuine biomass boiler (EPC 6 wood logs / 12 biomass, etc.) keeps its raw + Table-12 factor. The Summary path is unaffected — it maps + "Mains gas - community" to code 1 (no collision). Worksheet-validated: + simulated case 14 (community boilers + mains gas, SAP code 301) → + (367) CO2 factor 0.2100, (467) PE factor 1.1300. + """ + fuel = _main_fuel_code(main) + if fuel is None or not _is_heat_network_main(main): + return fuel + return API_FUEL_TO_TABLE_12.get(fuel, fuel) + + def _fuel_cost_gbp_per_kwh( main: Optional[MainHeatingDetail], prices: PriceTable ) -> float: @@ -1882,7 +1913,10 @@ def _fuel_cost_gbp_per_kwh( ) blended_p = chp_frac * chp_price + (1.0 - chp_frac) * boiler_price return blended_p * _PENCE_TO_GBP - return prices.unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP + return ( + prices.unit_price_p_per_kwh(_heat_network_factor_fuel_code(main)) + * _PENCE_TO_GBP + ) # RdSAP energy_tariff enum (per datatypes/epc/domain/epc_codes.csv): @@ -2816,7 +2850,7 @@ def _main_heating_co2_factor_kg_per_kwh( ) if monthly is not None: return monthly * scaling - return _co2_factor_kg_per_kwh(main) * scaling + return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling if tariff is Tariff.STANDARD: monthly = _effective_monthly_co2_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, @@ -2895,7 +2929,7 @@ def _main_heating_primary_factor( ) if monthly is not None: return monthly * scaling - return primary_energy_factor(fuel) * scaling + return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling if tariff is Tariff.STANDARD: monthly = _effective_monthly_pe_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, @@ -3171,7 +3205,7 @@ def _hot_water_co2_factor_kg_per_kwh( ) if monthly is not None: return monthly * scaling - return _co2_factor_kg_per_kwh(main) * scaling + return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_CO2_KG_PER_KWH @@ -3235,7 +3269,7 @@ def _hot_water_primary_factor( ) if monthly is not None: return monthly * scaling - return primary_energy_factor(_main_fuel_code(main)) * scaling + return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_PEF diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index fbccd757..932552d2 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -52,6 +52,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] + _heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] @@ -5816,3 +5817,59 @@ def test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_table_4b_boi f"want {expected_hw_fuel!r} per SAP 10.2 Appendix D §D2.1 (2) " f"Equation D1 with Table 4b code 127 (winter 84%, summer 72%)" ) + + +def test_heat_network_community_gas_fuel_translates_epc_20_to_table12_51() -> None: + # Arrange — a community mains-gas BOILER main (SAP code 301) lodges + # main_fuel_type=20. Per epc_codes.csv (RdSAP-Schema-17.0) EPC fuel 20 + # is "mains gas (community)", but the SAP Table 12 / Table 32 numbering + # uses 20 for a solid biomass fuel — a collision. The factor lookups + # check the Table-12 dict first, so co2_factor_kg_per_kwh(20) returns + # the biomass 0.028 instead of community mains gas 0.210. The + # heat-network fuel-code translator must route EPC 20 → Table 12 51. + from domain.sap10_calculator.tables.table_12 import ( + co2_factor_kg_per_kwh, + primary_energy_factor, + ) + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # EPC "mains gas (community)" + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, # heat network + sap_main_heating_code=301, # community boilers + ) + + # Act + code = _heat_network_factor_fuel_code(main) + + # Assert — translates to Table 12 code 51 (community mains gas), and the + # factor lookups then return the worksheet-validated case-14 values + # ((367) CO2 0.2100, (467) PE 1.1300), NOT the collided biomass factors. + assert code == 51 + assert abs(co2_factor_kg_per_kwh(code) - 0.210) <= 1e-9 + assert abs(primary_energy_factor(code) - 1.130) <= 1e-9 + assert abs(co2_factor_kg_per_kwh(20) - 0.028) <= 1e-9 # the collided value + + +def test_non_heat_network_biomass_fuel_not_translated() -> None: + # Arrange — a NON-heat-network main lodging the same integer fuel code + # must NOT be translated: a genuine biomass boiler keeps its raw + # Table-12 factor. The translator only fires for heat-network mains. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, # ordinary boiler, NOT heat network + sap_main_heating_code=102, + ) + + # Act + code = _heat_network_factor_fuel_code(main) + + # Assert — unchanged (raw code, biomass factor preserved). + assert code == 20 From f658f7ce7154ef28a36c9ffd8b492e58b34fd11f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 23:27:45 +0000 Subject: [PATCH 31/80] docs: Thread 2 CO2/PE collision fixed by S0380.212; cost +4 tail open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the heat-network fuel-code collision fix (EPC 20 'mains gas (community)' → Table-12 51), case-14 validation, and the remaining cost-scaling gap (heat-network cost path missing 1/heat_source_eff). Bump HEAD/next-slice; update shipped + audit tables. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_MAPPER_BUGS.md | 78 ++++++++----------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md index eb8a96a1..24d8a4d5 100644 --- a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md +++ b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md @@ -6,13 +6,13 @@ state after closing the 0240 investigation and fixing the first of several API-mapper/cascade bugs the audit surfaced. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `90f6720c` (S0380.211). Confirm with `git rev-parse HEAD`. +- **HEAD:** `08dd0b4c` (S0380.212). Confirm with `git rev-parse HEAD`. - **Baseline:** `2384 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). ALSO run `domain/sap10_ml/tests/` when touching `rdsap_uvalues.py` — 2 PRE-EXISTING stone-formula failures there, see Thread 1. -- **Next slice number:** **S0380.212**. -- **Open:** Thread 2 (community 9390) — UNBLOCKED: **case 14** (code-301 boiler + mains - gas) is on disk, target `(386)` heat-network CO2 = 0.2640. Threads 1 + 3 **CLOSED**. +- **Next slice number:** **S0380.213**. +- **Open:** Thread 2 *cost* tail only — 9390 SAP +4 (heat-network cost missing + 1/heat_source_eff scaling). CO2/PE collision FIXED (S0380.212). Threads 1 + 3 **CLOSED**. --- @@ -24,6 +24,7 @@ API-mapper/cascade bugs the audit surfaced. | **S0380.209** | Fixed the **API-path wall U** "as built, insulated (assumed)" bug — routes to the as-built age-band row, not the 50 mm retrofit bucket. New `_described_as_retrofit_insulated` in `heat_transmission.py`. Worksheet-validated by case 9 (sandstone J → 0.35) + case 10 (solid brick J → 0.35). Re-pinned 0240 PE +1.8687 → +5.5044, CO2 +0.0907 → +0.2757 (SAP integer 72 unchanged). | | **S0380.210** | **CLOSED cert 0390** (Thread 3). Cavity wall "as built, **partial** insulation (assumed)" (type 4) was mis-routed to the Table 6 "Filled cavity" row (band F 0.40) → should be "Cavity as built" (band F 1.0). New `_cavity_described_as_filled` in `rdsap_uvalues.py` excludes "partial insulation" from the filled trigger (keeps "insulated (assumed)" → filled). SAP +7 → +0, PE −27.97 → +0.53, CO2 −2.71 → −0.12. Mirrors S0380.209 on the cavity path. | | **S0380.211** | **CLOSED Thread 1 (roof).** 0240 Ext1 vaulted (code 5) NI roof returned 0.68 (§5.11.4 50 mm) → should be Table 18 col (1) age-band (band J 0.16), matching 33 cohort-2 `ND` vaulted roofs. New `u_roof(is_sloping_ceiling=...)` flag threaded from heat_transmission (codes 5/8). 0240 PE +5.50 → +1.52, CO2 +0.28 → +0.07 (SAP 72). Also corrected the S0380.210 cavity unit test in `domain/sap10_ml/tests/` (suite-command gap — see Thread 1). | +| **S0380.212** | **Thread 2 CO2/PE collision FIXED.** EPC fuel 20 = "mains gas (community)" collided with Table-12 biomass code 20 → community CO2 6.4× low. New `_heat_network_factor_fuel_code` translates 20→51 for heat-network mains only (5 sites: SH+HW CO2/PE/price). 9390 CO2 0.44→3.03 t (lodged 2.8), PE 204→220. Case-14-validated ((367) 0.2100 / (467) 1.1300). Cost +4 tail open. | Both also carry a memory: [[project_case7_combi_exonerated]], [[project_as_built_insulated_assumed_bug]]. @@ -100,51 +101,36 @@ formula 3.7408 vs Table-6 1.7 cap, granite + sandstone band A) — they fail at --- -## OPEN THREAD 2 — Community fuel-code collision (cert 9390) — UNBLOCKED, **case 14 in hand** +## THREAD 2 — Community fuel-code collision (cert 9390) — **CO2/PE FIXED (S0380.212); cost +4 open** -Cert **9390-2722-3520** (TFA 75.17): SAP +4 (calc 71 / lodged 67), PE ≈ matches -(204 vs 205), but **CO2 5.86 kg/m² vs lodged 38** (0.44 t vs 2.8 t — 6.5× low). -Community scheme (`sap_main_heating_code=301`, `main_fuel_type=20`). +Cert **9390-2722-3520** (community mains-gas boiler, `sap_main_heating_code=301`, +`main_fuel_type=20`). Authoritative: `datatypes/epc/domain/epc_codes.csv` +(RdSAP-Schema-17.0) `main_fuel,20,mains gas (community)`. -**Arbitrating worksheet (case 14, `simulated case 14/`):** community **Boilers** -(`(303a)`=1.0, no CHP) + **Mains Gas** + **SAP code 301** — 9390's exact scheme. -Key targets: `(386)` **Overall heat-network CO2 factor = 0.2640**, `(306)` -distribution loss factor (Table 12c) = **1.49**, `(385)` EI rating 69. So a -community mains-gas BOILER scheme should drive CO2 at the heat-network factor -**0.2640** (≈ base mains-gas 0.210 × 1.257 source/loss scaling), NOT the biomass -0.028 the collision currently returns. **API `main_fuel_type=20` = community mains -gas → Table 12 code 51** (base CO2 0.210, PEF 1.130). Cases 12 (CHP coal, 302) + -13 (CHP gas, 302) also on disk — useful for the CHP `(303a/303b)` split but NOT -9390's boiler scheme. +**Root cause (the collision):** the EPC `main_fuel_type` enum and the SAP Table 12 / +Table 32 numbering overlap in **18–25** — EPC 20='mains gas (community)' but Table-12 +code 20 is solid biomass (CO2 0.028). `co2_factor_kg_per_kwh`/`primary_energy_factor`/ +`unit_price_p_per_kwh` check the Table-12 dict FIRST, so the EPC community fuel got the +biomass factor instead of translating 20→51 (community mains gas: CO2 0.210, PE 1.130). -**Root cause:** `co2_factor_kg_per_kwh` / `unit_price_p_per_kwh` / -`primary_energy_factor` in `tables/table_12.py` + `table_32.py` use an -"accept-either-API-or-Table-12-code" lookup that checks the **Table-12 code first**: +**S0380.212 fix:** new `_heat_network_factor_fuel_code(main)` translates the EPC community +fuel → Table-12 code via `API_FUEL_TO_TABLE_12`, but ONLY for heat-network mains +(`_is_heat_network_main`) so a genuine biomass boiler keeps its raw factor. Applied at +**five** sites — space-heating CO2/PE/unit-price + water-heating (WHC 901) CO2/PE (9390's +HW is ALSO community gas, so both paths needed it). Worksheet-validated by **case 14** +(community boilers + mains gas, code 301): `(367)` CO2 0.2100, `(467)` PE 1.1300 = the +Table-12 code-51 values. 9390 CO2 **0.44 → 3.03 t** (lodged 2.8 — spec-correct factor over +the API-only register residual; 9390 is unpinned, retired P2.2 per ADR-0010 §10), PE +**204 → 220** (the prior 204≈205 was the collision coinciding with the register residual). +Summary path uses code 1 (no collision) → CH1-6 corpus untouched. Locked by 2 unit tests +in `test_cert_to_inputs.py`. -```python -if fuel_code in CO2_KG_PER_KWH: # API 20 IS ALSO a T12 code (wood logs, 0.028) - return CO2_KG_PER_KWH[fuel_code] # ← returns 0.028, never translates -translated = API_FUEL_TO_TABLE_12.get(fuel_code) # 20 → 51 (community gas, 0.21) — unreached -``` - -API community fuels **18–25** (`API_FUEL_TO_TABLE_12`: 18→75,19→76,20→51,21→52,22→53, -23→55,24→54,25→41) all **collide** with Table-12 codes 18–25 (biomass), so they silently -get the biomass factor. The heat-network CO2 path -(`cert_to_inputs.py` ~L2801-2819: `_co2_factor_kg_per_kwh(main) * scaling`) thus emits -`0.028 × 1.25 (1/0.80 heat-source-eff) = 0.035` instead of `0.21 × 1.25`. - -**Why the fix needs care (don't just translate):** a naive translate of the heat-network -fuel via `API_FUEL_TO_TABLE_32` corrects CO2 (0.44 → 3.03 t, ≈ lodged 2.8) but -**over-corrects PE** (204 → 219.9 vs lodged 205) and **doesn't move the SAP +4** (cost -is a *separate* community issue — price/standing-charge/heat-source-eff scaling). The -community scheme's declared PE/CO2/cost factors must be pinned against a **9390 Elmhurst -worksheet** before committing. This touches the community-heating corpus (the "touchy" -S0380.180-184 area — see [[project_heating_systems_corpus]]); run the heating-systems -corpus test after any change. - -The right long-term shape: translate API→Table-12 **explicitly** at the known-API-code -boundary instead of the ambiguous T12-first "accept-either" — but verify it against the -whole cohort. +**STILL OPEN — 9390 SAP +4 (separate cost-scaling gap):** the heat-network cost path +(`_fuel_cost_gbp_per_kwh`, cert_to_inputs.py ~L1888) does NOT apply the +`1/heat_source_eff` (1/0.80) scaling the CO2/PE paths do, so community fuel cost +under-counts → SAP over-reads. Validate the fix against case 14's **10b fuel-cost block** +(+ Table 32 note (l) £120 community standing charge). Run the heating-systems corpus after +(touchy area, [[project_heating_systems_corpus]]). --- @@ -186,7 +172,7 @@ that test. | cert | SAP resid | diagnosis | |---|---|---| | 0390-2954-3640 | ~~+7~~ **+0** | **CLOSED S0380.210** — cavity partial-insulation → as-built row | -| 9390-2722-3520 | +4 | community fuel-code collision → CO2 6× low (Thread 2, OPEN) | +| 9390-2722-3520 | +4 (unpinned) | **CO2/PE collision FIXED S0380.212** (CO2 0.44→3.03 t); SAP +4 cost-scaling tail open | | 0240-0200-5706 | −1 | NOT a bug — unpreserved 2013+ pump; true SAP 72. Roof PE-pin tightened by **S0380.211** (PE +5.50 → +1.52) | | 2130-1033-4050 | +1 | minor fabric precision (multi-part solid-brick wall); low value | | 7536-3827-0600 | +1 | minor fabric precision (multi-bp D/L/F cavity); low value | From ee484d9f4aab076bd5f23a2791896ecc7fe31fc5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 23:45:24 +0000 Subject: [PATCH 32/80] =?UTF-8?q?S0380.213:=20heat-network=20standing=20ch?= =?UTF-8?q?arge=20(=C2=A3120)=20=E2=80=94=20fixes=209390=20cost=20under-co?= =?UTF-8?q?unt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cert 9390 (community mains-gas boiler, API main_fuel_type=20) drew £0 standing charge → fuel cost under-counted → SAP read +4 high (71 vs 67). Root cause: the standing-charge logic (`additional_standing_charges_gbp`) only knows the GAS branch (`_is_gas_code`) and the off-peak-electric branch. A heat-network community fuel is not a Table-32 gas code — EPC 20 = "mains gas (community)" normalises to Table-32 code 20 (biomass), so `_is_gas_code(20)` is False and the standing came out £0. The Summary path masks this because it lodges community gas as Table-32 code 1 (ordinary mains gas), which IS gas-recognised and already draws the £120 gas standing — so the CH1-6 corpus was unaffected while the API path lost the charge. Spec basis (verified against SAP 10.2 spec PDF): - Table 12 (p.191) "Heat networks" row standing charge = £120/yr, note (k). - Note (l): "Include half this value if only DHW is provided by a heat network." - §C3.2 (p.58): the full charge applies when the space heating is also a heat network. Worksheet-validated: simulated case 14 (community boilers + mains gas, space + water) → worksheet (351) Additional standing charges = £120. Fix: new `_heat_network_standing_charge_gbp(epc, main)` returns the heat-network standing (£120 full when the space main is a heat network; £60 when only DHW is on the network) or None otherwise. Applied at both fuel-cost call sites, REPLACING the fuel-based `additional_standing_charges _gbp` for heat-network mains (NOT additive) so a Summary-path community-gas main — already £120 via the gas branch — is not double-counted to £240. The CH1-6 community corpus stays exactly £120 (59 corpus tests pass). 9390 SAP +4 → -2 (cont 65.39 vs lodged 67): the spec-correct £120 standing EXPOSES a separate ~7% demand over-count (also visible as PE 220 vs lodged 205) — a heat-source-efficiency-default / fabric residual, follow-up scope. 9390 is unpinned (retired P2.2 per ADR-0010 §10); helper locked by 2 unit tests. Full suite 2386 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 61 ++++++++++++++++--- .../rdsap/test_cert_to_inputs.py | 47 ++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 4fa6fde3..9e189860 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -883,6 +883,12 @@ _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]] = { 304: 3.00, } +# SAP 10.2 Table 12 (PDF p.191) "Heat networks" standing charge row = +# £120/yr (note (k)). Note (l): "Include half this value if only DHW is +# provided by a heat network." §C3.2 (PDF p.58): the full charge applies +# when the space heating is also on the heat network. +_HEAT_NETWORK_STANDING_CHARGE_GBP: Final[float] = 120.0 + def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool: """True when the cert's main heating is a heat network — either by @@ -1592,6 +1598,35 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool: ) +def _heat_network_standing_charge_gbp( + epc: EpcPropertyData, main: Optional[MainHeatingDetail] +) -> Optional[float]: + """SAP 10.2 Table 12 note (l) + §C3.2 heat-network standing charge, or + None when the dwelling is not on a heat network (caller then falls back + to the fuel-based `additional_standing_charges_gbp`). + + A heat network carries the Table 12 £120/yr standing charge regardless + of the network fuel — full when the SPACE heating is on the network + (§C3.2 "the total standing charge is the normal heat network standing + charge"), halved to £60 when ONLY DHW is provided by the heat network + (note (l)). This REPLACES the fuel-based gas/off-peak standing for a + heat-network main, so it must not be added on top of + `additional_standing_charges_gbp` (which would double-count: a + Summary-path community-gas main lodges Table-32 code 1 and already + draws the £120 gas standing). Worksheet-validated: simulated case 14 + (community boilers + mains gas, space + water) → (351) = £120. + + The API path under-counted this: an EPC community fuel (e.g. 20 = mains + gas community) is not a Table-32 gas code, so `_is_gas_code` returned + False and the standing came out £0 — cert 9390 lost the whole £120. + """ + if _is_heat_network_main(main): + return _HEAT_NETWORK_STANDING_CHARGE_GBP + if _is_community_heating_hw_from_main(epc): + return _HEAT_NETWORK_STANDING_CHARGE_GBP / 2.0 + return None + + def _main_heating_efficiency(epc: EpcPropertyData) -> float: """SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction. @@ -5885,10 +5920,15 @@ def _fuel_cost( table_32_unit_price_p_per_kwh(60) * _PENCE_TO_GBP ) - standing = additional_standing_charges_gbp( - main_fuel_code=main_fuel_code, - water_heating_fuel_code=water_heating_fuel_code, - tariff=tariff, + heat_network_standing = _heat_network_standing_charge_gbp(epc, main) + standing = ( + heat_network_standing + if heat_network_standing is not None + else additional_standing_charges_gbp( + main_fuel_code=main_fuel_code, + water_heating_fuel_code=water_heating_fuel_code, + tariff=tariff, + ) ) # Worksheet display convention: when a row's kWh is zero (no main 2, no @@ -6610,10 +6650,15 @@ def cert_to_inputs( epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc), ) _hw_extra_standing = 0.0 - standing_charges_total = additional_standing_charges_gbp( - main_fuel_code=_main_fuel_code(main), - water_heating_fuel_code=_water_heating_fuel_code(epc), - tariff=_rdsap_tariff(epc), + _heat_network_standing = _heat_network_standing_charge_gbp(epc, main) + standing_charges_total = ( + _heat_network_standing + if _heat_network_standing is not None + else additional_standing_charges_gbp( + main_fuel_code=_main_fuel_code(main), + water_heating_fuel_code=_water_heating_fuel_code(epc), + tariff=_rdsap_tariff(epc), + ) ) + _hw_extra_standing # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 932552d2..e58f9713 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -53,6 +53,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage] + _heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] @@ -5873,3 +5874,49 @@ def test_non_heat_network_biomass_fuel_not_translated() -> None: # Assert — unchanged (raw code, biomass factor preserved). assert code == 20 + + +def test_heat_network_space_and_water_standing_charge_is_full_120() -> None: + # Arrange — a heat-network SPACE main (SAP code 301) carries the full + # Table 12 (PDF p.191) heat-network standing charge of £120/yr per + # §C3.2 ("the total standing charge is the normal heat network standing + # charge" when space heating is on the network). Worksheet-validated: + # case 14 (community boilers + mains gas, space + water) → (351) £120. + # The epc is not consulted on this branch (heat-network space main wins + # first), so a minimal epc suffices. + epc = _typical_semi_detached_epc() + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # EPC mains gas (community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=301, + ) + + # Act + standing = _heat_network_standing_charge_gbp(epc, main) + + # Assert + assert standing is not None + assert abs(standing - 120.0) <= 1e-9 + + +def test_non_heat_network_main_returns_none_so_caller_uses_fuel_standing() -> None: + # Arrange — a non-heat-network gas-boiler main must NOT draw the + # heat-network standing branch; the helper returns None so the caller + # falls back to the fuel-based `additional_standing_charges_gbp`. + epc = _typical_semi_detached_epc() + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, # mains gas (not community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=102, + ) + + # Act / Assert + assert _heat_network_standing_charge_gbp(epc, main) is None From b9bbcecb42b78ff7d9eb2b4ede36c05086abbe42 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 23:46:32 +0000 Subject: [PATCH 33/80] docs: Thread 2 cost +4 closed by S0380.213 heat-network standing charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the £120 standing-charge fix (Table 12 note (l) + §C3.2, case 14 (351)), the corrected diagnosis (standing charge, not cost scaling — the 4.24 p/kWh heat price was already right), the double-count avoidance, and the remaining ~7% demand over-count (SAP -2). Bump HEAD/baseline/next-slice. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_MAPPER_BUGS.md | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md index 24d8a4d5..7237497e 100644 --- a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md +++ b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md @@ -6,13 +6,14 @@ state after closing the 0240 investigation and fixing the first of several API-mapper/cascade bugs the audit surfaced. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `08dd0b4c` (S0380.212). Confirm with `git rev-parse HEAD`. -- **Baseline:** `2384 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). +- **HEAD:** `ee484d9f` (S0380.213). Confirm with `git rev-parse HEAD`. +- **Baseline:** `2386 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). ALSO run `domain/sap10_ml/tests/` when touching `rdsap_uvalues.py` — 2 PRE-EXISTING stone-formula failures there, see Thread 1. -- **Next slice number:** **S0380.213**. -- **Open:** Thread 2 *cost* tail only — 9390 SAP +4 (heat-network cost missing - 1/heat_source_eff scaling). CO2/PE collision FIXED (S0380.212). Threads 1 + 3 **CLOSED**. +- **Next slice number:** **S0380.214**. +- **Open:** Thread 2 *demand* tail only — 9390 ~7% demand over-count (SAP -2, PE 220 vs + 205) = heat-source-efficiency-default / fabric residual. CO2/PE collision (S0380.212) + + standing charge (S0380.213) FIXED. Threads 1 + 3 **CLOSED**. --- @@ -25,6 +26,7 @@ API-mapper/cascade bugs the audit surfaced. | **S0380.210** | **CLOSED cert 0390** (Thread 3). Cavity wall "as built, **partial** insulation (assumed)" (type 4) was mis-routed to the Table 6 "Filled cavity" row (band F 0.40) → should be "Cavity as built" (band F 1.0). New `_cavity_described_as_filled` in `rdsap_uvalues.py` excludes "partial insulation" from the filled trigger (keeps "insulated (assumed)" → filled). SAP +7 → +0, PE −27.97 → +0.53, CO2 −2.71 → −0.12. Mirrors S0380.209 on the cavity path. | | **S0380.211** | **CLOSED Thread 1 (roof).** 0240 Ext1 vaulted (code 5) NI roof returned 0.68 (§5.11.4 50 mm) → should be Table 18 col (1) age-band (band J 0.16), matching 33 cohort-2 `ND` vaulted roofs. New `u_roof(is_sloping_ceiling=...)` flag threaded from heat_transmission (codes 5/8). 0240 PE +5.50 → +1.52, CO2 +0.28 → +0.07 (SAP 72). Also corrected the S0380.210 cavity unit test in `domain/sap10_ml/tests/` (suite-command gap — see Thread 1). | | **S0380.212** | **Thread 2 CO2/PE collision FIXED.** EPC fuel 20 = "mains gas (community)" collided with Table-12 biomass code 20 → community CO2 6.4× low. New `_heat_network_factor_fuel_code` translates 20→51 for heat-network mains only (5 sites: SH+HW CO2/PE/price). 9390 CO2 0.44→3.03 t (lodged 2.8), PE 204→220. Case-14-validated ((367) 0.2100 / (467) 1.1300). Cost +4 tail open. | +| **S0380.213** | **Thread 2 cost +4 FIXED** via the heat-network standing charge. API community fuel 20 isn't a Table-32 gas code → `_is_gas_code` False → £0 standing (vs SAP 10.2 Table 12 note (l) £120; case 14 `(351)`=£120). New `_heat_network_standing_charge_gbp` (£120 full / £60 DHW-only, §C3.2) REPLACES the fuel standing for heat-network mains (no double-count; CH corpus stays £120). 9390 SAP +4 → -2 (exposes a ~7% demand over-count — follow-up). | Both also carry a memory: [[project_case7_combi_exonerated]], [[project_as_built_insulated_assumed_bug]]. @@ -125,12 +127,22 @@ the API-only register residual; 9390 is unpinned, retired P2.2 per ADR-0010 §10 Summary path uses code 1 (no collision) → CH1-6 corpus untouched. Locked by 2 unit tests in `test_cert_to_inputs.py`. -**STILL OPEN — 9390 SAP +4 (separate cost-scaling gap):** the heat-network cost path -(`_fuel_cost_gbp_per_kwh`, cert_to_inputs.py ~L1888) does NOT apply the -`1/heat_source_eff` (1/0.80) scaling the CO2/PE paths do, so community fuel cost -under-counts → SAP over-reads. Validate the fix against case 14's **10b fuel-cost block** -(+ Table 32 note (l) £120 community standing charge). Run the heating-systems corpus after -(touchy area, [[project_heating_systems_corpus]]). +**Cost +4 — FIXED (S0380.213), via the standing charge (NOT cost scaling).** The earlier +"missing 1/heat_source_eff cost scaling" hypothesis was WRONG: case 14's 10b block shows +the heat price (`(340)`/`(307)` = 4.24 p/kWh) is applied to delivered heat, NOT scaled — +and Table-32 code 51 already = 4.24 p/kWh (the price collision was harmless, 4.23≈4.24). +The real gap was the **£120 heat-network standing charge** (SAP 10.2 Table 12 note (l) + +§C3.2; case 14 `(351)` = £120): the API community fuel (20) isn't a Table-32 gas code so +`_is_gas_code` returned False → £0 standing (the Summary path masks it via code 1). New +`_heat_network_standing_charge_gbp` REPLACES the fuel standing for heat-network mains +(£120 full / £60 DHW-only) — not additive, so the CH corpus (already £120 via the gas +branch) isn't double-counted to £240. 9390 SAP +4 → **-2**. + +**STILL OPEN — 9390 ~7% demand over-count (SAP -2):** the standing fix EXPOSED it — PE 220 +vs lodged 205, CO2 3.03 vs 2.8 all run ~7% high. Likely the heat-source-efficiency default +(`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY[301]=0.80`) being too low for 9390's actual scheme, +or a fabric/demand difference. 9390 is API-only (no worksheet) + unpinned, so this is a +low-priority residual; needs a 9390-specific efficiency/fabric investigation. --- @@ -172,7 +184,7 @@ that test. | cert | SAP resid | diagnosis | |---|---|---| | 0390-2954-3640 | ~~+7~~ **+0** | **CLOSED S0380.210** — cavity partial-insulation → as-built row | -| 9390-2722-3520 | +4 (unpinned) | **CO2/PE collision FIXED S0380.212** (CO2 0.44→3.03 t); SAP +4 cost-scaling tail open | +| 9390-2722-3520 | −2 (unpinned) | **CO2/PE collision FIXED S0380.212** + **standing charge S0380.213** (SAP +4→−2); remaining ~7% demand over-count (heat-source-eff default?) | | 0240-0200-5706 | −1 | NOT a bug — unpreserved 2013+ pump; true SAP 72. Roof PE-pin tightened by **S0380.211** (PE +5.50 → +1.52) | | 2130-1033-4050 | +1 | minor fabric precision (multi-part solid-brick wall); low value | | 7536-3827-0600 | +1 | minor fabric precision (multi-bp D/L/F cavity); low value | From f50195ac54ffc885c120db9f8474d776a1cbe25e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 00:00:23 +0000 Subject: [PATCH 34/80] =?UTF-8?q?docs:=20handover=20=E2=80=94=20golden=20S?= =?UTF-8?q?AP=20drive-to-zero=20priority=20(2130/7536);=200240=20architect?= =?UTF-8?q?ural?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CURRENT PRIORITY section: of 53 pinned golden certs, 3 have non-zero SAP residual. 2130 (+0.85 cont) + 7536 (+0.57 cont) are real multi-part-wall fabric over-predictions (the drive-to-zero targets, possibly one shared cause). 0240 (-1) is architectural — the lodged 73 needs an unpreserved 2013+ pump; document the cause, do NOT re-pin (user decision). Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_MAPPER_BUGS.md | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md index 7237497e..b720c2c1 100644 --- a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md +++ b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md @@ -6,14 +6,47 @@ state after closing the 0240 investigation and fixing the first of several API-mapper/cascade bugs the audit surfaced. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `ee484d9f` (S0380.213). Confirm with `git rev-parse HEAD`. +- **HEAD:** `b9bbcecb` (docs after S0380.213). Confirm with `git rev-parse HEAD`. - **Baseline:** `2386 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command). ALSO run `domain/sap10_ml/tests/` when touching `rdsap_uvalues.py` — 2 PRE-EXISTING stone-formula failures there, see Thread 1. - **Next slice number:** **S0380.214**. -- **Open:** Thread 2 *demand* tail only — 9390 ~7% demand over-count (SAP -2, PE 220 vs - 205) = heat-source-efficiency-default / fabric residual. CO2/PE collision (S0380.212) + - standing charge (S0380.213) FIXED. Threads 1 + 3 **CLOSED**. + +--- + +## ★ CURRENT PRIORITY — drive golden-fixture SAP residuals to ZERO + +`_EXPECTATIONS` in `test_golden_fixtures.py` holds **53 pinned golden certs**. The suite +is GREEN. **Only 3 have a non-zero SAP integer residual**, and they split into two kinds: + +| cert | lodged | cont SAP | resid | nature | +|---|---|---|---|---| +| **2130-1033-4050** | 82 | **83.349** | **+1** | REAL +0.85 over-prediction — multi-part **solid-brick** wall fabric | +| **7536-3827-0600** | 68 | **69.071** | **+1** | REAL +0.57 over-prediction — multi-bp **D/L/F cavity** wall fabric | +| **0240-0200-5706** | 73 | **72.462** | **−1** | **ARCHITECTURAL — do NOT chase, do NOT re-pin** (see below) | + +**The two real targets are 2130 + 7536.** Both are fabric *over*-predictions on **multi-part +wall** geometry (a wall with alternative-construction sub-areas / multiple building parts), +so they may share a single root cause in the §3 per-part wall area/U handling — a fix to +one could close both. Method: walk the §3 heat-transmission cascade per-part vs the lodged +register subsystem ratings (the certs are API-only — no worksheet — so the bar is ±0.5 SAP +vs lodged, but the goal is the integer flip 83→82 / 69→68, i.e. shave ~0.85 / ~0.57 off the +continuous SAP). If a multi-part-wall U or area is over-counted, that is the lever. The user +generates Elmhurst worksheets on request — ASK for a simulated case mirroring 2130's wall +(multi-part solid brick) rather than guess a U-value (the session methodology lesson). + +**0240 (−1) is NOT driveable from the JSON and the user has decided NOT to re-pin it — +document the cause only.** Continuous SAP is 72.462; the true SAP is **72**. The lodged +**73 requires a "2013 or later" circulation pump (41 kWh)**; 0240's open-data API lodges +`central_heating_pump_age=0` = **Unknown → 115 kWh**. The encoding was proven across 13 +API+Summary pairs (`0`=Unknown, `2`=2013+). The export did not preserve the pump age that +produced the lodged 73, so 73 is unreachable without inventing data. Both fabric bugs that +masked it are now fixed (wall S0380.209 + roof S0380.211 → cont 72.462). **Leave the pin at +`actual_sap=73, expected_sap_resid=-1`; the notes already record this.** Driving it to zero +would mean fudging the pump age — don't. + +Latent (lower priority): **9390** (community, −2, **unpinned**/retired) ~7% demand +over-count — see Thread 2. --- From ec64c39d742c4d30027aa7a71af866b0937ec7c0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 00:14:10 +0000 Subject: [PATCH 35/80] docs: correct 2130/7536 drive-to-zero diagnosis (distinct causes, not shared) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per each cert's notes: 7536 is a glazing-U gap (S0380.97 glazing_type=2 Table 24 default vs the cert's higher lodged U on multi-age bps) — the tractable target; 2130's SAP +1 is a PV-β cohort cascade interaction, not a fabric line. The earlier "multi-part wall, shared cause" label was wrong. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_MAPPER_BUGS.md | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md index b720c2c1..aad9253b 100644 --- a/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md +++ b/domain/sap10_calculator/docs/HANDOVER_MAPPER_BUGS.md @@ -19,21 +19,28 @@ API-mapper/cascade bugs the audit surfaced. `_EXPECTATIONS` in `test_golden_fixtures.py` holds **53 pinned golden certs**. The suite is GREEN. **Only 3 have a non-zero SAP integer residual**, and they split into two kinds: -| cert | lodged | cont SAP | resid | nature | +| cert | lodged | cont SAP | resid | nature (from the cert's `notes:`) | |---|---|---|---|---| -| **2130-1033-4050** | 82 | **83.349** | **+1** | REAL +0.85 over-prediction — multi-part **solid-brick** wall fabric | -| **7536-3827-0600** | 68 | **69.071** | **+1** | REAL +0.57 over-prediction — multi-bp **D/L/F cavity** wall fabric | -| **0240-0200-5706** | 73 | **72.462** | **−1** | **ARCHITECTURAL — do NOT chase, do NOT re-pin** (see below) | +| **7536-3827-0600** | 68 | **69.071** | **+1** | +0.57 over — multi-age bps (Main D / Ext1 L / Ext2 F); **glazing U** (S0380.97 set glazing_type=2 → Table 24 spec U=2.0, but the cert's lodged U "appears higher than the spec default") | +| **2130-1033-4050** | 82 | **83.349** | **+1** | +0.85 over — end-terrace + 1 ext, gas combi PCDB 17505, **2× PV arrays**; SAP +1 came from the **cohort PV-β cascade interaction** (S0380.45/.49), not a pinpointed fabric line; PE residual −8.22 sits in gas-combi PE + secondary credit | -**The two real targets are 2130 + 7536.** Both are fabric *over*-predictions on **multi-part -wall** geometry (a wall with alternative-construction sub-areas / multiple building parts), -so they may share a single root cause in the §3 per-part wall area/U handling — a fix to -one could close both. Method: walk the §3 heat-transmission cascade per-part vs the lodged -register subsystem ratings (the certs are API-only — no worksheet — so the bar is ±0.5 SAP -vs lodged, but the goal is the integer flip 83→82 / 69→68, i.e. shave ~0.85 / ~0.57 off the -continuous SAP). If a multi-part-wall U or area is over-counted, that is the lever. The user -generates Elmhurst worksheets on request — ASK for a simulated case mirroring 2130's wall -(multi-part solid brick) rather than guess a U-value (the session methodology lesson). +**These are TWO DIFFERENT root causes — not a shared one** (an earlier audit label calling +both "multi-part wall" was wrong; trust the `notes:` above). + +- **7536** is the more tractable: a clear **glazing-U** hypothesis. S0380.97 forced + `glazing_type=2` to the Table 24 default U=2.0; the note suspects the cert's true per-bp + glazing U is higher (multi-age D/L/F geometry). Walk §3 window `(27)` per-bp vs the lodged + register window rating; the lever is likely the glazing U for one of the extensions. ASK + the user for a simulated Elmhurst worksheet mirroring 7536's glazing (double-glazed, + multi-age bps) to pin the true `(27)` U rather than guess. +- **2130** is harder: the SAP +1 is a PV-β / cohort *cascade interaction*, not a single + fabric line. Its PE residual (−8.22) is a documented gas-combi-PE + secondary-credit + deferral. Decompose which metric drives the integer flip (cost/EI vs PE) before touching + anything; this one may need the PV/secondary path, not fabric. + +Both certs are API-only (no worksheet) → bar is ±0.5 SAP vs lodged; the goal is the integer +flip (69→68 / 83→82), i.e. shave ~0.57 / ~0.85 off the continuous SAP. Per the session +methodology lesson, ASK for a worksheet rather than guess a U-value or factor. **0240 (−1) is NOT driveable from the JSON and the user has decided NOT to re-pin it — document the cause only.** Continuous SAP is 72.462; the true SAP is **72**. The lodged From ac7f510ccb2f1bc4c891835d9e63f526cb49612e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 08:58:37 +0000 Subject: [PATCH 36/80] =?UTF-8?q?S0380.214:=20as-built=20sloping-ceiling?= =?UTF-8?q?=20roof=20=E2=86=92=20Table=2018=20col=20(3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "Pitched, sloping ceiling" roof (roof_construction code 8) lodged with "As Built" insulation (no measured thickness → None) was wrongly routed to RdSAP 10 Table 18 column (1) "insulation between joists or unknown". A sloping ceiling has no joist void, so per RdSAP 10 §5.11 roof-input item 5-5 ("Sloping ceiling insulation … unknown / as built → Table 18") and Table 18 note (b) ("Applies also to roof with sloping ceiling") it takes column (3) — band F = 0.68, band L = 0.18 (vs col 1 0.40 / 0.16). Discriminator is the code-8 "sloping ceiling" string only: code-5 vaulted ceilings stay on column (1) per the 33 cohort-2 "ND" vaulted certs (S0380.211), and the "NI"/"ND" unknown case is untouched. New `is_pitched_sloping_ceiling` flag threaded from heat_transmission to `u_roof`; pre-1950 bands already reach the same col (3) value (2.30) via the mapper's thickness=0 → Table 16 row-0 override, so the new branch carries the post-1950 bands where col 1 ≠ col 3. Worksheet-validated by simulated case 15 (the 7536 replica): our cascade on its Summary matches the P960 worksheet exactly — roof HLC 29.17 W/K, cont SAP 65.04 vs 65. Re-pins golden cert 7536: roof 26.77 → 29.17, cont SAP 69.071 → 68.924, PE -7.0776 → -6.1952, CO2 -0.1875 → -0.1639 (SAP integer 68, resid +1 unchanged — the remaining +0.92 is a diffuse demand under-count needing a fully-faithful worksheet). Blast radius: 7536 only. Suite: 2388 passed, 1 skipped (main); sap10_ml 233 passed + 2 pre-existing stone-formula failures (out of scope). Zero new pyright errors. Co-Authored-By: Claude Opus 4.8 --- .../worksheet/heat_transmission.py | 8 ++- domain/sap10_ml/rdsap_uvalues.py | 22 +++++++ domain/sap10_ml/tests/test_rdsap_uvalues.py | 60 +++++++++++++++++++ .../rdsap/test_golden_fixtures.py | 25 ++++++-- 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 9ddce6c6..2a4d7088 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -791,7 +791,13 @@ def heat_transmission_from_cert( is_sloping_ceiling = ( "sloping ceiling" in roof_type_lower or "vaulted" in roof_type_lower ) - ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling) + # RdSAP 10 Table 18 col (3) routing for an AS-BUILT "Pitched, + # sloping ceiling" (code 8). Narrower than `is_sloping_ceiling` + # (which also covers code-5 vaulted): vaulted ceilings stay on + # col (1) per the cohort, so only the literal "sloping ceiling" + # string triggers the col (3) age-band default in `u_roof`. + is_pitched_sloping_ceiling = "sloping ceiling" in roof_type_lower + ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling, is_pitched_sloping_ceiling=is_pitched_sloping_ceiling) # Floor U-value routing (in priority order): # 1. Basement floor — Table 23 F-column override (whole floor=0). # 2. Exposed/semi-exposed upper floor — Table 20 lookup; no diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 0e39f8a7..529b2fcb 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -721,6 +721,7 @@ def u_roof( description: Optional[str] = None, is_flat_roof: bool = False, is_sloping_ceiling: bool = False, + is_pitched_sloping_ceiling: bool = False, ) -> float: """RdSAP10 roof U-value in W/m^2K, never null. @@ -745,6 +746,18 @@ def u_roof( default (band J = 0.16) — the same value a vaulted roof lodged "ND" (thickness None) already reaches by falling through. The 33 cohort-2 "ND" vaulted certs (code 5, band D → 0.40 = col 1) are the evidence. + + `is_pitched_sloping_ceiling` is the narrower code-8 ("Pitched, sloping + ceiling") signal for the AS-BUILT case (insulation lodged "As Built", + parsed to thickness None — distinct from the "NI"/"ND" unknown case + above). Per RdSAP 10 roof-input item 5-5 ("Sloping ceiling insulation + ... as built → Table 18") and Table 18 note (b) ("applies also to roof + with sloping ceiling"), an as-built sloping ceiling takes the column + (3) age-band default (band F = 0.68, band L = 0.18), NOT the column (1) + loft-joist default (band F = 0.40, band L = 0.16). Vaulted ceilings + (code 5) are deliberately excluded — they stay on column (1) per the + cohort evidence above. Worksheet-validated by simulated case 15 (the + 7536 replica): Ext1 band L → 0.18, Ext2 band F → 0.68. """ measured = _measured_u_from_description(description) if measured is not None: @@ -789,6 +802,15 @@ def u_roof( return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row) if age_band is None: return 0.4 + if is_pitched_sloping_ceiling: + # RdSAP 10 §5.11 Table 18 page 45 column (3) + roof-input item 5-5: + # an as-built "Pitched, sloping ceiling" (code 8) with no measured + # thickness takes the column (3) age-band default, not the column + # (1) loft-joist default. Note (b): column (3) "applies also to + # roof with sloping ceiling". (Pre-1950 bands reach the same value + # via the mapper's thickness=0 → Table 16 row-0 2.30 override, so + # this branch carries the post-1950 bands where col 1 ≠ col 3.) + return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) if is_flat_roof: return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) return _ROOF_BY_AGE.get(age_band.upper(), 0.4) diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index a864600f..85dbd489 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -945,6 +945,66 @@ def test_u_roof_flat_age_band_l_returns_table18_col3_value() -> None: assert abs(result - 0.18) <= 1e-4 +def test_u_roof_pitched_sloping_ceiling_as_built_band_f_uses_col3() -> None: + # Arrange — RdSAP 10 §5.11 Table 18 page 45 + roof-input item 5-5 + # ("Sloping ceiling insulation ... unknown / as built → Table 18"). + # A "Pitched, sloping ceiling" roof (roof_construction code 8) with an + # "As Built" insulation lodgement (no measured thickness → None) takes + # the Table 18 column (3) age-band default, NOT the column (1) + # "insulation between joists" default. Note (b) on column (3) states it + # "applies also to roof with sloping ceiling". For age band F the + # column (3) value is 0.68 W/m²K (vs column (1) 0.40 — the loft-joist + # assumption that is wrong for a sloping ceiling with no joist void). + # + # Worksheet-validated: simulated case 15 (7536 replica) lodges Ext2 as + # band F "PS Pitched, sloping ceiling, As Built"; its P960 worksheet + # pins `External roof Ext2 … 0.68`, and the full-cascade roof HLC and + # SAP match Elmhurst exactly only with column (3). + + # Act + result = u_roof( + country=Country.ENG, age_band="F", insulation_thickness_mm=None, + is_pitched_sloping_ceiling=True, + ) + + # Assert + assert abs(result - 0.68) <= 1e-4 + + +def test_u_roof_pitched_sloping_ceiling_as_built_band_l_uses_col3() -> None: + # Arrange — same rule at band L (2012-2022): Table 18 column (3) gives + # 0.18 W/m²K, where columns (2)/(3) coincide. Simulated case 15's Ext1 + # (band L PS sloping ceiling, As Built) pins worksheet U=0.18 (vs the + # column (1) value 0.16 the cascade returned pre-fix). + + # Act + result = u_roof( + country=Country.ENG, age_band="L", insulation_thickness_mm=None, + is_pitched_sloping_ceiling=True, + ) + + # Assert + assert abs(result - 0.18) <= 1e-4 + + +def test_u_roof_vaulted_nd_unknown_band_d_still_col1_not_col3() -> None: + # Arrange — regression guard for the discriminator: a code-5 "vaulted" + # roof lodged "ND" (thickness None) is the UNKNOWN-insulation case and + # must stay on Table 18 column (1) — band D = 0.40 — per the 33 + # cohort-2 vaulted certs (S0380.211). The col (3) routing fires only + # for code-8 "Pitched, sloping ceiling" (is_pitched_sloping_ceiling), + # NOT for vaulted ceilings, so this defaults False here and resolves + # to column (1) 0.40, NOT column (3) 2.30. + + # Act + result = u_roof( + country=Country.ENG, age_band="D", insulation_thickness_mm=None, + ) + + # Assert + assert abs(result - 0.40) <= 1e-4 + + def test_u_roof_description_no_insulation_overrides_age_band_default() -> None: # Arrange — surveyor description on a Victorian roof says uninsulated; # Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index e9918716..49f66e24 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -370,8 +370,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="7536-3827-0600-0600-0276", actual_sap=68, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-7.0776, - expected_co2_resid_tonnes_per_yr=-0.1875, + expected_pe_resid_kwh_per_m2=-6.1952, + expected_co2_resid_tonnes_per_yr=-0.1639, notes=( "Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, " "Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and " @@ -379,10 +379,23 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "age band, not per-bp) jointly tightened: SAP +4 → +3, PE " "-27.17 → -22.53, CO2 -0.72 → -0.60. Slice 97 added " "glazing_type=2 (Table 24 spec U=2.0): SAP 0 → +1, PE/CO2 " - "widened. The cert's actual lodged U for glazing_type=2 " - "appears higher than the spec's table default — multi-age " - "geometry probably surfaces a per-bp U-value the spec table " - "doesn't capture exactly." + "widened. Slice S0380.214 fixed the as-built sloping-ceiling " + "roof U: Ext1 (band L) and Ext2 (band F) lodge " + "roof_construction=8 'Pitched, sloping ceiling' + 'As Built', " + "which take RdSAP 10 Table 18 col (3) (L=0.18, F=0.68) not " + "col (1) (0.16/0.40) — per item 5-5 + note (b). Roof HLC " + "26.77 → 29.17 W/K; cont SAP 69.071 → 68.924, PE -7.0776 → " + "-6.1952, CO2 -0.1875 → -0.1639 (SAP integer still 68 vs " + "lodged → resid +1). Worksheet-validated by simulated case 15 " + "(the 7536 replica): our cascade on its Summary matches the " + "P960 worksheet exactly (roof 29.17, SAP 65.04 vs 65). The " + "glazing hypothesis from the prior handover was wrong — maxing " + "the glazing U past spec can't flip 69→68, and every per-bp " + "fabric U is spec-plausible. The residual +0.92 cont SAP is a " + "diffuse demand under-count (window split + Main suspended-" + "timber floor) not capturable from the API-only JSON; needs a " + "fully-faithful 7536 worksheet (in progress) to localise or " + "conclude it is 0240-like." ), ), _GoldenExpectation( From e097ce2cef5a72a5723d74fe6465ee29044a1b8d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 09:41:48 +0000 Subject: [PATCH 37/80] docs: finalise 7536 as 0240-like (resid +1, do not chase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After three faithful-worksheet iterations (simulated cases 15/16/17), the 7536 +1 SAP residual is confirmed 0240-like — an Elmhurst register-rounding residual not reproducible from the API-only JSON, not a calculator bug. Case 17 is faithful on windows (Main 16.98 / Ext1 13.59 / Ext2 1.89) and ground floors; every per-element value matches our cascade: walls 0.70/0.28/0.40, roofs 0.40/0.18/0.68 (S0380.214), window U-eff 2.4368/1.8519, ground floors 0.97/0.26/1.12. The only worksheet divergences were manual-entry artifacts: case-16 inverted the floor order (put the 50.98 m² upper floor as ground), and case-17 auto-derived spurious "to external air" exposed floors from the small-ground/big-upper geometry — real 7536 lodges floor_heat_loss 2/7/3 (unheated-space / ground), none is code 1 (exposed). Our spec-correct cont SAP is 68.924; lodged 68 carries Elmhurst's own residual. Notes-only; pin unchanged (resid +1, PE -6.1952, CO2 -0.1639). Suite green. Co-Authored-By: Claude Opus 4.8 --- .../rdsap/test_golden_fixtures.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 49f66e24..8ac38714 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -391,11 +391,19 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "P960 worksheet exactly (roof 29.17, SAP 65.04 vs 65). The " "glazing hypothesis from the prior handover was wrong — maxing " "the glazing U past spec can't flip 69→68, and every per-bp " - "fabric U is spec-plausible. The residual +0.92 cont SAP is a " - "diffuse demand under-count (window split + Main suspended-" - "timber floor) not capturable from the API-only JSON; needs a " - "fully-faithful 7536 worksheet (in progress) to localise or " - "conclude it is 0240-like." + "fabric U is spec-plausible. CONCLUSION (cases 15/16/17, the " + "last faithful on windows 16.98/13.59/1.89 + ground floors): " + "every per-element value matches Elmhurst — walls 0.70/0.28/" + "0.40, roofs 0.40/0.18/0.68, window U-eff 2.4368/1.8519, " + "ground floors Main 0.97 / Ext1 0.26 / Ext2 1.12. The only " + "worksheet divergences were manual-entry artifacts (case 16 " + "floor-order inversion; case 17 spurious 'to external air' " + "exposed floors auto-derived from the small-ground/big-upper " + "geometry — real 7536 lodges floor_heat_loss 2/7/3 = unheated-" + "space/ground, NOT code 1 exposed). The residual +0.92 cont " + "SAP is therefore 0240-like: an Elmhurst register-rounding " + "residual not reproducible from the API-only JSON. DO NOT " + "chase further — leave at resid +1." ), ), _GoldenExpectation( From 2f5ca85854385e1c2a47ef447ae4a7f74ae541ef Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 10:30:49 +0000 Subject: [PATCH 38/80] S0380.215: capture dropped measured wall insulation thickness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema-21 SapBuildingPart never declared `wall_insulation_thickness_ measured`, so `from_dict` silently discarded it. When a cert lodges `wall_insulation_thickness == "measured"` the actual value (mm) lives in that dropped field, so the cascade fell back to the 50 mm "insulation present, unknown thickness" default instead of the lodged measurement. Cert 2130 Ext1 lodges solid brick band B + INTERNAL insulation "measured"/100 mm. Per RdSAP 10 §5.7 Table 8 (insulated-wall U by age band + insulation thickness) the 100 mm row gives U=0.32; the unknown-thickness fallback gave 0.55. New `_api_resolve_wall_insulation_thickness` substitutes the measured value for the "measured" sentinel; the existing `_insulation_bucket`/Table-8 path then computes the correct U. Field added to schema 21.0.0/21.0.1 SapBuildingPart; domain field widened to Union[str, int] to match `roof_insulation_thickness`. Isolated: 2130 Ext1 is the only bp lodging "measured" across all 47 fixtures. This spec-correct fix EXPOSED an offsetting under-count it had been masking (per the repo's no-special-handling rule — the pre-fix +1 was two bugs cancelling): 2130 cont SAP 83.35 → 83.78 (resid +1 → +2), PE -7.56 → -11.72, CO2 -0.045 → -0.095. The exposed -11.72 PE (~-746 kWh/yr) is the deferred gas-combi-PE + PV-β-credit under-count from S0380.45/.49, now un-masked — the next slice. Re-pinned 2130 with the cause documented. Suite: 2391 passed, 1 skipped. Zero new pyright errors (mapper 32=32). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/epc_property_data.py | 6 +- datatypes/epc/domain/mapper.py | 60 ++++++++++++++++--- .../domain/tests/test_from_rdsap_schema.py | 51 ++++++++++++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 5 ++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 5 ++ .../rdsap/test_golden_fixtures.py | 24 ++++++-- 6 files changed, 137 insertions(+), 14 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 1048bed2..39386781 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -472,7 +472,11 @@ class SapBuildingPart: ) wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes wall_thickness_mm: Optional[int] = None - wall_insulation_thickness: Optional[str] = None + # Union[str, int]: a numeric mm value when the API lodges + # `wall_insulation_thickness == "measured"` (resolved from the + # separate measured field), else the lodged string ("NI", a numeric + # string, etc.). Mirrors `roof_insulation_thickness`. + wall_insulation_thickness: Optional[Union[str, int]] = None sap_alternative_wall_1: Optional[SapAlternativeWall] = None sap_alternative_wall_2: Optional[SapAlternativeWall] = None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index d7cb95b2..032a881e 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -560,7 +560,10 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), floor_heat_loss=bp.floor_heat_loss, floor_insulation_thickness=None, roof_construction=bp.roof_construction, @@ -693,7 +696,10 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), floor_heat_loss=bp.floor_heat_loss, floor_insulation_thickness=None, roof_construction=bp.roof_construction, @@ -826,7 +832,10 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -985,7 +994,10 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1161,7 +1173,10 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1378,7 +1393,10 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1640,7 +1658,10 @@ class EpcPropertyDataMapper: building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, - wall_insulation_thickness=bp.wall_insulation_thickness, + wall_insulation_thickness=_api_resolve_wall_insulation_thickness( + bp.wall_insulation_thickness, + getattr(bp, "wall_insulation_thickness_measured", None), + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -2928,6 +2949,31 @@ def _parse_rir_insulation_thickness_mm(value: Any) -> Optional[int]: return int(m.group(1)) if m else None +def _api_resolve_wall_insulation_thickness( + wall_insulation_thickness: Union[str, int, None], + wall_insulation_thickness_measured: Union[str, int, None], +) -> Union[str, int, None]: + """Resolve the wall insulation thickness for the cascade. + + When the cert lodges `wall_insulation_thickness == "measured"` the + actual value sits in the separate `wall_insulation_thickness_measured` + field (mm). RdSAP 10 §5.7/Table 8 use the measured thickness to pick + the insulated-wall U-value row; without it the cascade falls back to + the 50 mm "insulation present, unknown thickness" default (e.g. cert + 2130 Ext1: solid brick band B + internal insulation lodged 100 mm → + Table 8 U=0.32, not the 50 mm default 0.55). + + Any other lodgement (numeric string, "NI", None) passes through + unchanged.""" + if ( + isinstance(wall_insulation_thickness, str) + and wall_insulation_thickness.strip().lower() == "measured" + and wall_insulation_thickness_measured is not None + ): + return wall_insulation_thickness_measured + return wall_insulation_thickness + + def _api_resolve_sloping_ceiling_thickness( roof_construction: Optional[int], roof_insulation_thickness: Union[str, int, None], diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index e91ca73a..694726f1 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -697,3 +697,54 @@ class TestFromRdSapSchema21_0_1: assert rhi.impact_of_cavity_insulation_kwh == -122.0 assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0 + + +# --------------------------------------------------------------------------- +# Measured wall insulation thickness (`wall_insulation_thickness == "measured"`) +# --------------------------------------------------------------------------- + + +class TestApiResolveWallInsulationThickness: + """`wall_insulation_thickness == "measured"` resolves to the separate + `wall_insulation_thickness_measured` field (previously dropped by + `from_dict`, leaving the cascade on the 50 mm unknown-thickness + default). Cert 2130 Ext1 lodges solid brick band B + internal + insulation "measured"/100 mm → RdSAP 10 Table 8 U=0.32, not 0.55.""" + + def test_measured_string_resolves_to_measured_value(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_wall_insulation_thickness, + ) + + # Act + resolved = _api_resolve_wall_insulation_thickness("measured", 100) + + # Assert + assert resolved == 100 + + def test_non_measured_lodgement_passes_through_unchanged(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_wall_insulation_thickness, + ) + + # Act + ni: object = _api_resolve_wall_insulation_thickness("NI", 100) + none_thk: object = _api_resolve_wall_insulation_thickness(None, None) + + # Assert + assert ni == "NI" + assert none_thk is None + + def test_measured_without_value_passes_through(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_wall_insulation_thickness, + ) + + # Act + resolved: object = _api_resolve_wall_insulation_thickness("measured", None) + + # Assert + assert resolved == "measured" diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index c8cc6e23..71d2cbf8 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -241,6 +241,11 @@ class SapBuildingPart: sap_alternative_wall_2: Optional[SapAlternativeWall] = None wall_thickness: Optional[int] = None wall_insulation_thickness: Optional[str] = None + # Lodged measured insulation thickness (mm) backing a + # `wall_insulation_thickness == "measured"` lodgement. Previously + # undeclared, so `from_dict` silently dropped it and the cascade fell + # back to the 50 mm "insulation present, unknown thickness" default. + wall_insulation_thickness_measured: Optional[Union[str, int]] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 242d30b2..59ff41c9 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -279,6 +279,11 @@ class SapBuildingPart: sap_alternative_wall_2: Optional[SapAlternativeWall] = None wall_thickness: Optional[int] = None wall_insulation_thickness: Optional[str] = None + # Lodged measured insulation thickness (mm) backing a + # `wall_insulation_thickness == "measured"` lodgement. Previously + # undeclared, so `from_dict` silently dropped it and the cascade fell + # back to the 50 mm "insulation present, unknown thickness" default. + wall_insulation_thickness_measured: Optional[Union[str, int]] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = None diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 8ac38714..274c6f93 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -434,9 +434,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="2130-1033-4050-5007-8395", actual_sap=82, - expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-7.5579, - expected_co2_resid_tonnes_per_yr=-0.0454, + expected_sap_resid=+2, + expected_pe_resid_kwh_per_m2=-11.7236, + expected_co2_resid_tonnes_per_yr=-0.0947, notes=( "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " "postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays " @@ -445,9 +445,21 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "from -38.63 to -9.70. Slice S0380.49 wired effective monthly " "Table 12e PE factor (vs annual 1.501/0.501) into the PV " "split: residual closed -9.70 → -8.22. SAP integer shifted " - "+1 (82 → 83) via the cohort cascade interaction. Remaining " - "-8.22 residual sits in gas combi PE under-count + secondary " - "heating credit (deferred)." + "+1 (82 → 83) via the cohort cascade interaction. " + "Slice S0380.215 fixed the dropped measured wall insulation: " + "Ext1 lodges solid-brick band B + INTERNAL insulation " + "`wall_insulation_thickness='measured'` with the actual 100 mm " + "in the separate `wall_insulation_thickness_measured` field " + "that the schema didn't declare, so `from_dict` discarded it " + "and the cascade fell back to the 50 mm unknown-thickness " + "default (U=0.55). Wiring it through → RdSAP 10 Table 8 U=0.32 " + "(less wall loss). This SPEC-CORRECT fix EXPOSED the offsetting " + "PV-β / gas-combi-PE under-count it had been masking: cont SAP " + "83.35 → 83.78 (resid +1 → +2), PE -7.56 → -11.72, CO2 -0.045 " + "→ -0.095. The exposed -11.72 PE (~-746 kWh/yr) is the same " + "deferred gas-combi-PE + PV-β-credit under-count from S0380.45/" + ".49 — now un-masked. Closing it is the next slice (needs the " + "deferred PV/combi-PE work + ideally a 2130 worksheet)." ), ), _GoldenExpectation( From 712cc6f3f85c9bf846c5e76239cfff9869643b48 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:03:32 +0000 Subject: [PATCH 39/80] =?UTF-8?q?S0380.216:=20extractor=20=E2=80=94=20hand?= =?UTF-8?q?le=20wrapped=20glazing-gap=20column=20in=20=C2=A711=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pdftotext dumps of hand-entered Elmhurst worksheets wrap the §11 glazing- GAP column ("16 mm or more") onto the glazing-TYPE token, yielding labels like "Double between 2002 and 2021 16 mm or [1st]" that `_elmhurst_glazing_type_code` didn't recognise → UnmappedElmhurstLabel, blocking the whole Summary from parsing. Added a fallback: when the lightly-cleaned label isn't a known key, strip a trailing wrapped gap descriptor (`\s+\d+\s*mm\b.*$`) and retry. Applied AFTER the direct lookup so explicitly-mapped interleaved variants (e.g. "Double with unknown 16 mm or install date more", where the gap splits into the middle) are unaffected. The gap drives the API-path U-value lookup, not the site-notes glazing-type enum, so dropping it is loss-free for the cascade. Unblocks running our cascade on hand-entered worksheet Summaries — used to validate the PV β-split against simulated case 18 (our split matches the P960 worksheet exactly: gen 2684.17, onsite 970.77, export 1713.40). Suite: 2391 passed, 1 skipped. Zero new pyright errors (mapper 32=32). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 24 ++++++++-- .../domain/tests/test_from_rdsap_schema.py | 47 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 032a881e..41c07a6d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4858,6 +4858,18 @@ _ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE: Final[re.Pattern[str]] = re.compile( _ELMHURST_GLAZING_LABEL_NOISE_SUFFIX_RE: Final[re.Pattern[str]] = re.compile( r"\s+Summary Information$|\s+Alternative wall.*$" ) +# Fallback only: pdftotext wraps the §11 glazing-GAP column ("6 mm" / +# "12 mm" / "16 mm or more") onto the glazing-TYPE token on hand-entered +# worksheets, e.g. "Double between 2002 and 2021 16 mm or [1st]". When the +# lightly-cleaned label isn't a known key, strip the trailing gap +# descriptor (and any building-part fragment after it) and retry. Applied +# AFTER the direct lookup so explicitly-mapped interleaved variants (e.g. +# "Double with unknown 16 mm or install date more") are unaffected. The +# gap drives the API-path U-value lookup, not the site-notes glazing-type +# enum, so dropping it here is loss-free for the cascade. +_ELMHURST_GLAZING_LABEL_TRAILING_GAP_RE: Final[re.Pattern[str]] = re.compile( + r"\s+\d+\s*mm\b.*$" +) def _elmhurst_glazing_type_code(label: Optional[str]) -> int: @@ -4874,9 +4886,15 @@ def _elmhurst_glazing_type_code(label: Optional[str]) -> int: cleaned = _ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE.sub("", label) cleaned = _ELMHURST_GLAZING_LABEL_NOISE_SUFFIX_RE.sub("", cleaned).strip() code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(cleaned) - if code is None: - raise UnmappedElmhurstLabel("glazing_type", label) - return code + if code is not None: + return code + # Fallback: strip a trailing wrapped glazing-gap descriptor and retry. + degapped = _ELMHURST_GLAZING_LABEL_TRAILING_GAP_RE.sub("", cleaned).strip() + if degapped != cleaned: + code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(degapped) + if code is not None: + return code + raise UnmappedElmhurstLabel("glazing_type", label) def _elmhurst_main_heating_category( diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 694726f1..58b9ed1a 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -748,3 +748,50 @@ class TestApiResolveWallInsulationThickness: # Assert assert resolved == "measured" + + +# --------------------------------------------------------------------------- +# Glazing-type label cleaning — pdftotext gap-column wrap +# --------------------------------------------------------------------------- + + +class TestElmhurstGlazingTypeWrappedGap: + """When a hand-entered Elmhurst worksheet is dumped via pdftotext, the + glazing-GAP column ("16 mm or more") wraps onto the glazing-TYPE token, + yielding labels like "Double between 2002 and 2021 16 mm or" (plus a + trailing building-part fragment). The extractor must strip the trailing + gap descriptor and map the clean type, not raise UnmappedElmhurstLabel.""" + + def test_trailing_gap_descriptor_stripped(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code + + # Act + code = _elmhurst_glazing_type_code( + "Double between 2002 and 2021 16 mm or" + ) + + # Assert — clean "Double between 2002 and 2021" → SAP10 code 3 + assert code == 3 + + def test_trailing_gap_plus_building_part_fragment_stripped(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code + + # Act + code = _elmhurst_glazing_type_code( + "Double between 2002 and 2021 16 mm or 1st" + ) + + # Assert + assert code == 3 + + def test_clean_label_still_maps(self) -> None: + # Arrange — regression guard: an un-wrapped label is unaffected. + from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code + + # Act + code = _elmhurst_glazing_type_code("Double pre 2002") + + # Assert + assert code == 2 From 6b4f1aec44b5993578566b5a835cafef9df70507 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:04:47 +0000 Subject: [PATCH 40/80] =?UTF-8?q?docs:=20finalise=202130=20as=200240-like?= =?UTF-8?q?=20=E2=80=94=20PV=20=CE=B2-split=20proven=20exact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated 2130's pin notes with the case-18 finding: our cascade reproduces the worksheet PV split to the decimal (gen 2684.17 / onsite 970.77 / export 1713.40), so the Appendix M1 β-split is exact, not the suspected bug. With the gas PE factor also exact (1.13) and the wall measurement now wired (S0380.215), 2130's +2/-11.72 is the irreducible API-only lodged residual (0240-like), not a closable calculator bug. Notes-only; pin unchanged. Co-Authored-By: Claude Opus 4.8 --- .../rdsap/test_golden_fixtures.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 274c6f93..04ec0814 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -456,10 +456,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "(less wall loss). This SPEC-CORRECT fix EXPOSED the offsetting " "PV-β / gas-combi-PE under-count it had been masking: cont SAP " "83.35 → 83.78 (resid +1 → +2), PE -7.56 → -11.72, CO2 -0.045 " - "→ -0.095. The exposed -11.72 PE (~-746 kWh/yr) is the same " - "deferred gas-combi-PE + PV-β-credit under-count from S0380.45/" - ".49 — now un-masked. Closing it is the next slice (needs the " - "deferred PV/combi-PE work + ideally a 2130 worksheet)." + "→ -0.095. INVESTIGATED the exposed -11.72 PE (~-746 kWh/yr) " + "against simulated case 18 (a TFA-64 base + 2130's exact PV: 2× " + "2.04 kWp SE/NW, overshading 1/2): our cascade reproduces the " + "P960 worksheet's PV split EXACTLY — gen 2684.17, (233a) onsite " + "970.77, (233b) export 1713.40 to the decimal. So the Appendix " + "M1 β-split is NOT the bug; the gas PE factor is also exact " + "(Table 12 mains gas 1.13). 2130's residual is therefore the " + "irreducible API-only lodged gap (Elmhurst's own residual), " + "0240-like — NOT a closable calculator bug. The +2/-11.72 is " + "the spec-correct state once the masking wall bug is removed. " + "Leave it; do not chase." ), ), _GoldenExpectation( From f895dd3ab7f1c155030068138203a99d41f272c9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 11:57:00 +0000 Subject: [PATCH 41/80] S0380.217: capture wall_insulation_thermal_conductivity (was dropped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second silently-dropped field from the 2130 audit: the schema-21 SapBuildingPart never declared `wall_insulation_thermal_conductivity`, so `from_dict` discarded it. Captured it through schema 21.0.0/21.0.1 → domain SapBuildingPart → API mapper, and wired it into u_wall's RdSAP 10 §5.8 documentary-evidence R-value calc (both the solid-brick §5.7/§5.8 path and the cavity-composite path), replacing the bare 0.04 λ constant with a resolved λ. Resolver: absent / "Unknown" → the §5.8 default 0.04 W/m·K (mineral wool / EPS); a mapped code → its λ; an unmapped integer code RAISES so the enum is confirmed against a worksheet rather than silently mis-factored (same incremental-coverage discipline as the glazing-type map). Only code 1 (= the default 0.04) is mapped — the sole observed value (cert 2130 Ext1). Zero cascade effect today: the λ path fires only for solid-brick/cavity walls with a *measured* wall thickness, and 2130 Ext1 lodges no wall thickness, so its conductivity is captured-but-unused; all existing §5.8 certs lodge no conductivity → 0.04 default unchanged. The point is to stop dropping lodged data and make λ correct when a future cert exercises it. Suite: 2523 passed (1 pre-existing TFA fail); sap10_ml 237 passed (2 pre-existing stone-formula fails). Zero new pyright errors (46=46). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/epc_property_data.py | 4 ++ datatypes/epc/domain/mapper.py | 21 +++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 5 ++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 5 ++ .../worksheet/heat_transmission.py | 4 ++ domain/sap10_ml/rdsap_uvalues.py | 47 +++++++++++++++- domain/sap10_ml/tests/test_rdsap_uvalues.py | 56 +++++++++++++++++++ 7 files changed, 140 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 39386781..63ef5f97 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -477,6 +477,10 @@ class SapBuildingPart: # separate measured field), else the lodged string ("NI", a numeric # string, etc.). Mirrors `roof_insulation_thickness`. wall_insulation_thickness: Optional[Union[str, int]] = None + # RdSAP 10 §5.8 thermal-conductivity code for measured wall insulation + # (λ = 0.04 / 0.03 / 0.025 W/m·K). Used by the documentary-evidence + # R-value path when a measured wall thickness is lodged alongside it. + wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None sap_alternative_wall_1: Optional[SapAlternativeWall] = None sap_alternative_wall_2: Optional[SapAlternativeWall] = None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 41c07a6d..4f6abc04 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -564,6 +564,9 @@ class EpcPropertyDataMapper: bp.wall_insulation_thickness, getattr(bp, "wall_insulation_thickness_measured", None), ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, floor_insulation_thickness=None, roof_construction=bp.roof_construction, @@ -700,6 +703,9 @@ class EpcPropertyDataMapper: bp.wall_insulation_thickness, getattr(bp, "wall_insulation_thickness_measured", None), ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, floor_insulation_thickness=None, roof_construction=bp.roof_construction, @@ -836,6 +842,9 @@ class EpcPropertyDataMapper: bp.wall_insulation_thickness, getattr(bp, "wall_insulation_thickness_measured", None), ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -998,6 +1007,9 @@ class EpcPropertyDataMapper: bp.wall_insulation_thickness, getattr(bp, "wall_insulation_thickness_measured", None), ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1177,6 +1189,9 @@ class EpcPropertyDataMapper: bp.wall_insulation_thickness, getattr(bp, "wall_insulation_thickness_measured", None), ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1397,6 +1412,9 @@ class EpcPropertyDataMapper: bp.wall_insulation_thickness, getattr(bp, "wall_insulation_thickness_measured", None), ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually @@ -1662,6 +1680,9 @@ class EpcPropertyDataMapper: bp.wall_insulation_thickness, getattr(bp, "wall_insulation_thickness_measured", None), ), + wall_insulation_thermal_conductivity=getattr( + bp, "wall_insulation_thermal_conductivity", None + ), floor_heat_loss=bp.floor_heat_loss, # API certs commonly lodge "NI" (no measured # thickness) on floors that aren't actually diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 71d2cbf8..dee7002d 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -246,6 +246,11 @@ class SapBuildingPart: # undeclared, so `from_dict` silently dropped it and the cascade fell # back to the 50 mm "insulation present, unknown thickness" default. wall_insulation_thickness_measured: Optional[Union[str, int]] = None + # Lodged thermal-conductivity code for measured wall insulation + # (RdSAP 10 §5.8: λ = 0.04 / 0.03 / 0.025 W/m·K). Previously undeclared + # → dropped by `from_dict`. Consumed by `u_wall`'s documentary-evidence + # R-value path when a measured wall thickness is also lodged. + wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 59ff41c9..87cbf91e 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -284,6 +284,11 @@ class SapBuildingPart: # undeclared, so `from_dict` silently dropped it and the cascade fell # back to the 50 mm "insulation present, unknown thickness" default. wall_insulation_thickness_measured: Optional[Union[str, int]] = None + # Lodged thermal-conductivity code for measured wall insulation + # (RdSAP 10 §5.8: λ = 0.04 / 0.03 / 0.025 W/m·K). Previously undeclared + # → dropped by `from_dict`. Consumed by `u_wall`'s documentary-evidence + # R-value path when a measured wall thickness is also lodged. + wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = None diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 2a4d7088..d21b2eb1 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -750,6 +750,10 @@ def heat_transmission_from_cert( # insulation_type combination doesn't match the formula # path's preconditions. wall_thickness_mm=part.wall_thickness_mm, + # RdSAP 10 §5.8 — lodged insulation thermal-conductivity + # code feeds the documentary-evidence R-value calc when a + # measured wall thickness is also present (else ignored). + wall_insulation_thermal_conductivity=part.wall_insulation_thermal_conductivity, ) # When the per-bp `roof_insulation_thickness` is explicitly lodged # as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 529b2fcb..8013e3d7 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -177,6 +177,43 @@ WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7 # (cavity + external/internal insulation). _WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04 +# RdSAP 10 §5.8 (page 41) — when documentary evidence lodges the insulation +# thermal conductivity, the R-value calc uses it instead of the 0.04 default. +# The spec offers three λ: 0.04 (mineral wool / EPS, the default), 0.03 (XPS), +# 0.025 (PUR / PIR / phenolic). The GOV.UK API surfaces a coded value +# (`wall_insulation_thermal_conductivity`); code 1 = the default 0.04 (the +# only code observed — cert 2130 Ext1, whose documentary-evidence path does +# not fire as no wall thickness is lodged, so the value is captured but +# unused there). Other codes raise until a worksheet-backed fixture confirms +# their λ — the same incremental-coverage discipline as the glazing-type map. +_WALL_INSULATION_CONDUCTIVITY_CODE_TO_LAMBDA: Final[dict[int, float]] = { + 1: 0.04, +} + + +def _resolve_wall_insulation_lambda_w_per_mk( + conductivity: "str | int | None", +) -> float: + """Resolve the insulation λ (W/m·K) for the §5.8 documentary-evidence + R-value calc. Absent / "Unknown" → the 0.04 default; a mapped integer + code → its λ; an unmapped integer code raises so the enum is confirmed + against a worksheet rather than silently mis-factored.""" + if conductivity is None: + return _WALL_INSULATION_LAMBDA_W_PER_MK + if isinstance(conductivity, str): + text = conductivity.strip() + if not text or text.lower() == "unknown" or not text.isdigit(): + return _WALL_INSULATION_LAMBDA_W_PER_MK + conductivity = int(text) + lam = _WALL_INSULATION_CONDUCTIVITY_CODE_TO_LAMBDA.get(conductivity) + if lam is None: + raise ValueError( + "unmapped wall_insulation_thermal_conductivity code " + f"{conductivity!r}; add its RdSAP 10 §5.8 λ " + "(0.04 / 0.03 / 0.025 W/m·K) once a worksheet confirms it" + ) + return lam + # RdSAP10 §5.8 final note + Table 14 page 41: "For drylining including # laths and plaster use Rinsulation = 0.17 m²K/W." Applied additively to # the base U-value of an otherwise-uninsulated wall when the cert lodges @@ -489,6 +526,7 @@ def u_wall( dry_lined: bool = False, curtain_wall_age: Optional[str] = None, wall_thickness_mm: Optional[int] = None, + wall_insulation_thermal_conductivity: "str | int | None" = None, ) -> float: """RdSAP10 wall U-value in W/m^2K, never null. @@ -601,7 +639,10 @@ def u_wall( ): u0 = _u_brick_thin_wall_age_a_to_e(wall_thickness_mm) r_ins = _r_insulation_table_14( - insulation_thickness_mm, _WALL_INSULATION_LAMBDA_W_PER_MK, + insulation_thickness_mm, + _resolve_wall_insulation_lambda_w_per_mk( + wall_insulation_thermal_conductivity + ), ) u_unrounded = 1.0 / (1.0 / u0 + r_ins) return float( @@ -623,7 +664,9 @@ def u_wall( # for column alignment). Cascade-internal HLC then uses the # rounded U so net wall HLC matches `A × U_2dp` exactly. u_filled = _CAVITY_FILLED_ENG[age_idx] - r_ins = (insulation_thickness_mm / 1000.0) / _WALL_INSULATION_LAMBDA_W_PER_MK + r_ins = (insulation_thickness_mm / 1000.0) / _resolve_wall_insulation_lambda_w_per_mk( + wall_insulation_thermal_conductivity + ) u_unrounded = 1.0 / (1.0 / u_filled + r_ins) # Half-up 2-d.p. round so 0.2545 → 0.25, matching the dr87 # worksheet's column-display behaviour (used downstream in A×U). diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 85dbd489..00cf7164 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -1870,3 +1870,59 @@ def test_u_floor_matches_section_5_12_formula_for_cohort_geometry( # Assert assert abs(u - expected_u) < 1e-4 + + +def test_resolve_wall_insulation_lambda_absent_uses_default() -> None: + # Arrange — no lodged conductivity → RdSAP 10 §5.8 default 0.04 W/m·K. + from domain.sap10_ml.rdsap_uvalues import ( + _resolve_wall_insulation_lambda_w_per_mk, + ) + + # Act + lam = _resolve_wall_insulation_lambda_w_per_mk(None) + + # Assert + assert abs(lam - 0.04) <= 1e-9 + + +def test_resolve_wall_insulation_lambda_unknown_string_uses_default() -> None: + # Arrange — a non-numeric "Unknown" lodgement defers to the default. + from domain.sap10_ml.rdsap_uvalues import ( + _resolve_wall_insulation_lambda_w_per_mk, + ) + + # Act + lam = _resolve_wall_insulation_lambda_w_per_mk("Unknown") + + # Assert + assert abs(lam - 0.04) <= 1e-9 + + +def test_resolve_wall_insulation_lambda_code_1_is_default_mineral_wool() -> None: + # Arrange — code 1 = the §5.8 default λ=0.04 (mineral wool / EPS); + # cert 2130 Ext1 lodges this. Numeric-string form resolves identically. + from domain.sap10_ml.rdsap_uvalues import ( + _resolve_wall_insulation_lambda_w_per_mk, + ) + + # Act + lam_int = _resolve_wall_insulation_lambda_w_per_mk(1) + lam_str = _resolve_wall_insulation_lambda_w_per_mk("1") + + # Assert + assert abs(lam_int - 0.04) <= 1e-9 + assert abs(lam_str - 0.04) <= 1e-9 + + +def test_resolve_wall_insulation_lambda_unmapped_code_raises() -> None: + # Arrange — an unmapped code must raise (incremental-coverage gate) + # rather than silently mis-factor the R-value. + import pytest as _pytest + + from domain.sap10_ml.rdsap_uvalues import ( + _resolve_wall_insulation_lambda_w_per_mk, + ) + + # Act / Assert + with _pytest.raises(ValueError): + _resolve_wall_insulation_lambda_w_per_mk(2) From 8bd8ff8e5c399f323f751535388b0f586380f4fb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 12:08:28 +0000 Subject: [PATCH 42/80] =?UTF-8?q?docs:=20handover=20=E2=80=94=20fresh-API?= =?UTF-8?q?=20cross-comparison=20+=20flagged-cert=20debugging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next-agent brief: fetch certs fresh from the EPC API (two new API+Summary+ worksheet triples for cross-mapper parity, plus six dashboard-flagged certs). Flags the critical reconciliation: the user's flagged numbers don't match the golden-fixtures cascade (0390-2954-3640 pinned +0 but flagged -6.85; 7536/2130 flags are pre-this-session), so fresh-raw-JSON-vs-curated-fixture or a different engine must be reconciled before debugging. Documents the EPC API fetch mechanism, the dropped-field audit method, this session's 4 fixes, and the conventions. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_FRESH_API_DEBUG.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md diff --git a/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md b/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md new file mode 100644 index 00000000..d6859499 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md @@ -0,0 +1,171 @@ +# Handover — fresh-API cross-comparison + flagged-cert debugging + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, the +1e-4 bar, the per-line debugging loop, the section helpers, and the suite command. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `f895dd3a` (S0380.217). Confirm with `git rev-parse HEAD`. +- **Baseline (AGENT_GUIDE §4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/` + → green (2388 passed, 1 skipped at HEAD; the golden + worksheet pins all pass). +- **Next slice number:** **S0380.218**. +- **Pre-existing failures (NOT yours, out of scope):** + - `domain/sap10_ml/tests/test_rdsap_uvalues.py` — 2 stone-§5.6 thin-wall failures + (granite + sandstone band A, 3.7408 vs Table-6 1.7 cap). Run this suite when you touch + `rdsap_uvalues.py`. + - `datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestFromRdSapSchema21_0_1::test_total_floor_area` + (145.82 vs 45.82) — fails at original HEAD `ec64c39d` too. This file is NOT in the §4 + suite command. + +--- + +## ★ THE TASK — fetch fresh from the EPC API and debug, with worksheet cross-comparison + +The previous session drove the **golden-fixtures cascade** (`cert_to_inputs` → +`calculate_sap_from_inputs`) and concluded that the three then-flagged certs (7536, 2130, +0240) are "0240-like" — API-only residuals not reproducible from the register JSON. The +user pushed back ("going around in circles"), and the right next move is **fresh raw-API +data + worksheet triples**, not more simulated worksheets. + +### Part 1 — two NEW certs with API + Summary + worksheet (cross-comparison) + +The user has **two certs that have all three artifacts**: the GOV.UK API JSON, the Elmhurst +**Summary** PDF (site notes / input), and the Elmhurst **worksheet** PDF (the `(1)..(286)` +ground truth). These are gold — they let you run BOTH front-ends (`from_api_response` and +`from_elmhurst_site_notes`) through the same cascade and pin **both** against the worksheet +at 1e-4. The user will provide the cert numbers + drop the PDFs. For each: + +1. Fetch the API JSON (see **Fetching** below). +2. Run API path → cascade; run Summary path → cascade; pin **both** vs the worksheet line + refs (`pdftotext -layout` the worksheet; compare `(27)/(28a)/(29a)/(30)/(33)/(36)/(45)m/ + (62)/(233a)/(233b)/(258)…`). Cross-mapper parity: the two paths must agree to 1e-4 AND + match the worksheet (memory `feedback_cross_mapper_parity_via_cascade`). +3. The **first diverging line ref localises the bug** (AGENT_GUIDE §3): value present in + worksheet but cascade 0/wrong → calculator; input field absent in `epc` → mapper or + extractor. Fix one cause = one slice. + +### Part 2 — six flagged certs to fetch fresh and debug + +The user's dashboard flags these (their numbers, **sign = lodged − our**): + +| cert | lodged | their "our" | their Δ | +|---|---|---|---| +| 0240-0200-5706-2365-8010 | 73 | 71.73 | +1.27 | +| 0390-2954-3640-2196-4175 | 60 | 66.85 | −6.85 | +| 2130-1033-4050-5007-8395 | 82 | 83.35 | −1.35 | +| 6035-7729-2309-0879-2296 | 70 | 67.81 | +2.19 | +| 7536-3827-0600-0600-0276 | 68 | 69.07 | −1.07 | +| 9390-2722-3520-2105-8715 | 67 | 71.24 | −4.24 | + +### ⚠ CRITICAL — reconcile the numbers FIRST, before debugging + +**The user's flagged numbers DO NOT match the golden-fixtures cascade.** All six certs are +already golden fixtures (`tests/domain/sap10_calculator/rdsap/fixtures/golden/.json`), +and the cascade gives different values: + +- **0390-2954-3640 is pinned at resid +0** (our cascade = 60, EXACTLY lodged) — but the user + flags it at **66.85 (−6.85)**. A 6.85 SAP gap can't be staleness. +- 7536 (their 69.07) and 2130 (their 83.35) are **pre-this-session** values — the S0380.214 + roof fix moved 7536 → 68.924, and the S0380.215 wall fix moved 2130 → 83.78. + +So the user's numbers come from a **different computation** than the golden cascade. Two +hypotheses, test both before assuming the cascade is wrong: + +1. **Fresh API JSON ≠ curated fixture.** The golden fixtures were bulk-fetched once + (`scripts/fetch_cohort2_api_jsons.py`, which *skips certs whose JSON already exists`) and + some may have been hand-corrected since. **Fetch each cert fresh and `diff` the raw JSON + against the committed fixture.** If they differ, the fixture was curated and the fresh raw + data is what the user's pipeline sees — debug the FRESH data. This is the most likely cause + and exactly why the user wants a fresh fetch. +2. **A different SAP engine.** The production stack (`backend/SearchEpc.py` → + `etl/epc_clean/epc_attributes/*` → `backend/engine/engine.py`) is a SEPARATE mapping + + scorer from `cert_to_inputs`. If the user's dashboard is produced there, that's a different + code path than the golden cascade. Ask the user which pipeline the table came from. + +Do NOT start "fixing" the cascade to hit the user's numbers until you know which pipeline +produced them. The golden cascade is worksheet-validated for 47 certs; chasing a dashboard +number from a different stack would regress it. + +--- + +## Fetching from the EPC API + +Token lives in `backend/.env` as `OPEN_EPC_API_TOKEN` (also `EPC_AUTH_TOKEN`). The exact +mechanism (from `scripts/fetch_cohort2_api_jsons.py`): + +```python +import httpx, os +from dotenv import load_dotenv +from infrastructure.epc_client.epc_client_service import EpcClientService +load_dotenv("backend/.env") +token = os.environ["OPEN_EPC_API_TOKEN"] +resp = httpx.get( + f"{EpcClientService.BASE_URL}/api/certificate", + params={"certificate_number": ""}, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + timeout=EpcClientService.REQUEST_TIMEOUT, +) +payload = resp.json()["data"] # <- this is the schema-21 JSON the mapper consumes +``` + +`EpcPropertyDataMapper.from_api_response(payload)` only supports `schema_type` +`RdSAP-Schema-21.0.0` / `21.0.1`; it raises for others. The persisted golden fixture IS this +`data` payload. So `diff <(fresh)` vs the committed fixture is apples-to-apples. + +--- + +## Per-cert notes carried from the previous session (verify against FRESH data) + +- **7536 (+1)** — roof bug fixed (S0380.214: as-built sloping ceiling → Table 18 col 3). + Every per-element U matches Elmhurst (cases 15-17 worksheets). Concluded 0240-like; cont + 68.924. +- **2130 (+2)** — dropped measured wall insulation captured (S0380.215 → Table 8 U=0.32), + which **exposed** the true residual (the +1 was two offsetting bugs). PV β-split **proven + exact** vs simulated case 18 worksheet (onsite 970.77 / export 1713.40 to the decimal). + Gas PE factor exact (1.13). Concluded 0240-like; cont 83.78. +- **0240 (−1)** — export-dropped 2013+ circulation-pump age (115 vs 41 kWh); WWHRS confirmed + inert (`shower_wwhrs=1` is the universal default across all 47 certs). User previously + decided NOT to re-pin. Concluded 0240-like. +- **0390-2954-3640** — pinned +0 (oil combi, Table 3a row 1). The user's −6.85 flag is the + reconciliation mystery above — START HERE; it's the clearest signal of a fresh-vs-fixture + or different-engine gap. +- **6035** — see memory `project_golden_coverage_state`: a user-simulated 6035 worksheet + closed to 1e-4, but "6035 remaining +19 PE needs its own worksheet"; flagged +2.19 SAP. +- **9390** — community heat-network (S0380.212/.213 fixed the fuel-code collision + standing + charge); left at SAP −2 with a documented ~7% demand over-count (heat-source-eff default?). + Unpinned/retired. The user's −4.24 may be the same demand over-count on fresh data. + +--- + +## What this session shipped (commits `ec64c39d..f895dd3a`) + +| slice | what | +|---|---| +| **S0380.214** | As-built "Pitched, sloping ceiling" (code 8) roof → RdSAP 10 Table 18 col (3) (band F 0.40→0.68, L 0.16→0.18) per §5.11 item 5-5 + note (b). Code-5 vaulted stays col (1) (cohort). Worksheet-validated (sim case 15). Re-pinned 7536. | +| **S0380.215** | Captured dropped `wall_insulation_thickness_measured` (schema 21 didn't declare it → `from_dict` dropped it). 2130 Ext1 "measured"/100 mm → RdSAP Table 8 U=0.32 (was 0.55 default). Exposed 2130's true +2 residual. | +| **S0380.216** | Extractor: handle pdftotext wrapping the §11 glazing-GAP column onto the glazing-TYPE token ("…16 mm or [1st]"). Fallback strip AFTER the direct lookup (preserves explicit interleaved keys). Unblocked running the cascade on hand-entered worksheet Summaries. | +| **S0380.217** | Captured dropped `wall_insulation_thermal_conductivity` (schema → domain → mapper) and wired it into `u_wall`'s §5.8 λ resolver. Code 1 = default 0.04; unmapped codes raise. Zero cascade effect today (2130's §5.8 path doesn't fire). | +| 3× docs | finalised 7536 / 2130 as 0240-like; corrected diagnoses. | + +**Audit method that found the dropped fields** (reuse it on the fresh certs): recursively +compare raw JSON keys against the parsed schema dataclass fields — anything in the JSON but +not a declared field is silently dropped by `from_dict`. The two real drops (2130's measured +wall insulation + thermal conductivity) came from this. Re-run it on the fresh fetches; new +certs may surface new dropped fields. + +--- + +## Conventions (unchanged) + +One cause = one slice = one commit; spec citation (page + line) in the message; AAA tests +(`# Arrange / # Act / # Assert`); assert with `abs(x - y) <= tol` (not `pytest.approx`); +SAP 10.2 only; no tolerance widening / xfail / rel-tol. New code passes pyright strict with +ZERO NEW errors — baseline-compare with `git stash` + `PYRIGHT_PYTHON_FORCE_VERSION=latest` +(mapper.py / cert_to_inputs.py / heat_transmission.py / rdsap_uvalues.py carry pre-existing +errors; compare counts). Stage files by name — the working tree has pre-existing unrelated +changes to `pytest.ini` / `scripts/` that must NOT be staged. +`Co-Authored-By: Claude Opus 4.8 `. + +When you re-pin a golden cert, update `expected_sap_resid` (±0), `expected_pe_resid_kwh_per_m2` +(±0.01) and `expected_co2_resid_tonnes_per_yr` (±0.001) to the exact post-fix values and +append a slice note to the cert's `notes:` explaining the cause + spec/worksheet citation. +Run the full §4 suite as the blast-radius check after any fabric/factor change. From 1085842395e3f128dda6e084f970ff4d0faf1f7d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 12:11:12 +0000 Subject: [PATCH 43/80] =?UTF-8?q?docs:=20handover=20=E2=80=94=20flagged=20?= =?UTF-8?q?numbers=20were=20stale=20(different=20branch),=20Part=201=20is?= =?UTF-8?q?=20the=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_FRESH_API_DEBUG.md | 49 +++++-------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md b/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md index d6859499..9dd46134 100644 --- a/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md +++ b/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md @@ -43,47 +43,20 @@ at 1e-4. The user will provide the cert numbers + drop the PDFs. For each: worksheet but cascade 0/wrong → calculator; input field absent in `epc` → mapper or extractor. Fix one cause = one slice. -### Part 2 — six flagged certs to fetch fresh and debug +### Part 2 — (secondary) re-check the previously-flagged certs on THIS branch -The user's dashboard flags these (their numbers, **sign = lodged − our**): +A dashboard once flagged six certs (0240, 0390-2954-3640, 2130, 6035, 7536, 9390). **Those +numbers are STALE — they came from a branch WITHOUT this branch's fixes** (the user confirmed +this). Do not chase them. On THIS branch the picture is different and mostly settled: -| cert | lodged | their "our" | their Δ | -|---|---|---|---| -| 0240-0200-5706-2365-8010 | 73 | 71.73 | +1.27 | -| 0390-2954-3640-2196-4175 | 60 | 66.85 | −6.85 | -| 2130-1033-4050-5007-8395 | 82 | 83.35 | −1.35 | -| 6035-7729-2309-0879-2296 | 70 | 67.81 | +2.19 | -| 7536-3827-0600-0600-0276 | 68 | 69.07 | −1.07 | -| 9390-2722-3520-2105-8715 | 67 | 71.24 | −4.24 | +- 7536 (68.924, +1), 2130 (83.78, +2), 0240 (−1) — concluded **0240-like** (API-only + residuals; see per-cert notes below). 0390-2954-3640 pins at **+0** (exact). +- 6035 (+2.19) and 9390 (community, −2) carry documented open residuals (see notes) but are + lower-priority and not worksheet-backed. -### ⚠ CRITICAL — reconcile the numbers FIRST, before debugging - -**The user's flagged numbers DO NOT match the golden-fixtures cascade.** All six certs are -already golden fixtures (`tests/domain/sap10_calculator/rdsap/fixtures/golden/.json`), -and the cascade gives different values: - -- **0390-2954-3640 is pinned at resid +0** (our cascade = 60, EXACTLY lodged) — but the user - flags it at **66.85 (−6.85)**. A 6.85 SAP gap can't be staleness. -- 7536 (their 69.07) and 2130 (their 83.35) are **pre-this-session** values — the S0380.214 - roof fix moved 7536 → 68.924, and the S0380.215 wall fix moved 2130 → 83.78. - -So the user's numbers come from a **different computation** than the golden cascade. Two -hypotheses, test both before assuming the cascade is wrong: - -1. **Fresh API JSON ≠ curated fixture.** The golden fixtures were bulk-fetched once - (`scripts/fetch_cohort2_api_jsons.py`, which *skips certs whose JSON already exists`) and - some may have been hand-corrected since. **Fetch each cert fresh and `diff` the raw JSON - against the committed fixture.** If they differ, the fixture was curated and the fresh raw - data is what the user's pipeline sees — debug the FRESH data. This is the most likely cause - and exactly why the user wants a fresh fetch. -2. **A different SAP engine.** The production stack (`backend/SearchEpc.py` → - `etl/epc_clean/epc_attributes/*` → `backend/engine/engine.py`) is a SEPARATE mapping + - scorer from `cert_to_inputs`. If the user's dashboard is produced there, that's a different - code path than the golden cascade. Ask the user which pipeline the table came from. - -Do NOT start "fixing" the cascade to hit the user's numbers until you know which pipeline -produced them. The golden cascade is worksheet-validated for 47 certs; chasing a dashboard -number from a different stack would regress it. +So Part 2 is only worth touching if a **fresh fetch differs from the committed fixture** +(curated/hand-corrected fixtures can mask raw-API mapper behaviour) — `diff` fresh vs fixture +and debug the delta. Otherwise these are done; the real new work is **Part 1**. --- From 6d9ef1143c44472b752c01341b26b3e00628f8cb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 12:35:50 +0000 Subject: [PATCH 44/80] S0380.218: golden fixtures for 2 fresh API+Summary+worksheet triples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two certs fetched fresh from the GOV.UK EPB register, each with an Elmhurst Summary PDF (input) and a dr87 worksheet PDF (the (1)..(286) ground truth): 0340-2467-9260-2006-6521 (Summary_000922 / dr87-0001-000922) 5500-5070-0822-0201-3663 (Summary_000920 / dr87-0001-000920) Both run through BOTH front-ends — from_api_response and from_elmhurst_site_notes — and through the rating + demand cascades. Cross-mapper parity holds: the two paths agree to <1e-4 on continuous SAP, fuel cost, CO2 and PE. Both paths reproduce the worksheet exactly: 0340: (255) cost 776.4295, (272) CO2 2875.0498, (286) PE 16474.5616; fabric (33) 171.6188, (37) 205.9358; SAP int 70 = lodged. 5500: (255) cost 751.8295, (272) CO2 2423.4547, (286) PE 14397.0118; fabric (33) 141.1226, (37) 167.3696; SAP int 66 = lodged. Pinned in two tables of test_golden_fixtures.py: - _EXPECTATIONS / test_golden_cert_residual_matches_pin — SAP/PE/CO2 residual vs the integer-rounded lodged register (SAP resid +0 both). - _WORKSHEET_PE_CO2 / test_golden_cert_pe_co2_matches_worksheet — PE (286)/(4) and CO2 (272) vs the worksheet at +0.0000 (the load-bearing 1e-4 check; lodged register is integer-rounded). Dropped-field audit (raw JSON keys vs the schema-21.0.1 dataclass fields consumed by from_dict) re-run on both fresh JSONs: no new silently-dropped fields — only created_at metadata and the shower_outlet_type/shower_wwhrs keys already handled by _normalize_shower_outlets (mapper.py:2047). No calculator or mapper change required; this is pure validation + regression-pinning. Full §4 suite: 2392 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 --- .../golden/0340-2467-9260-2006-6521.json | 516 +++++++++++++++++ .../golden/5500-5070-0822-0201-3663.json | 523 ++++++++++++++++++ .../rdsap/test_golden_fixtures.py | 57 +- 3 files changed, 1095 insertions(+), 1 deletion(-) create mode 100644 tests/domain/sap10_calculator/rdsap/fixtures/golden/0340-2467-9260-2006-6521.json create mode 100644 tests/domain/sap10_calculator/rdsap/fixtures/golden/5500-5070-0822-0201-3663.json diff --git a/tests/domain/sap10_calculator/rdsap/fixtures/golden/0340-2467-9260-2006-6521.json b/tests/domain/sap10_calculator/rdsap/fixtures/golden/0340-2467-9260-2006-6521.json new file mode 100644 index 00000000..f402740f --- /dev/null +++ b/tests/domain/sap10_calculator/rdsap/fixtures/golden/0340-2467-9260-2006-6521.json @@ -0,0 +1,516 @@ +{ + "uprn": 77048251, + "roofs": [ + { + "description": "Pitched, 300 mm loft insulation", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "addendum": { + "addendum_numbers": [ + 15 + ] + }, + "lighting": { + "description": "Good lighting efficiency", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "M22 1UR", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "MANCHESTER", + "built_form": 2, + "created_at": "2026-06-03 14:45:36", + "door_count": 2, + "region_code": 19, + "report_type": 2, + "sap_heating": { + "number_baths": 0, + "cylinder_size": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 15709 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 691, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.48, + "window_height": 0.92, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.57, + "window_height": 1.12, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.44, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.54, + "window_height": 1.11, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 8, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.48, + "window_height": 0.92, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.61, + "window_height": 1.05, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 1, + "window_height": 1.24, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.83, + "window_height": 1.07, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 7, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.56, + "window_height": 1.13, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.02, + "window_height": 1.17, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.93, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.81, + "window_height": 1.29, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 4, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.54, + "window_height": 1.32, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.93, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.93, + "window_height": 1.13, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.99, + "window_height": 1.05, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": 2, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Semi-detached house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "49 Cotefield Road", + "assessment_type": "RdSAP", + "completion_date": "2026-06-03", + "inspection_date": "2026-06-03", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 107, + "transaction_type": 5, + "conservatory_type": 1, + "heated_room_count": 6, + "registration_date": "2026-06-03", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "Room heaters, electric", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "extract_fans_count": 2, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 53.63, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 5.63, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 26.13, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.45, + "quantity": "metres" + }, + "total_floor_area": { + "value": 53.63, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 5.63, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 21.78, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "D", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "300mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 6, + "heating_cost_current": { + "value": 1131, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.9, + "energy_rating_average": 60, + "energy_rating_current": 70, + "lighting_cost_current": { + "value": 63, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 1031, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 218, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 100, + "currency": "GBP" + }, + "indicative_cost": "\u00a35,000 - \u00a310,000", + "improvement_type": "W2", + "improvement_details": { + "improvement_number": 58 + }, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 75 + }, + { + "sequence": 2, + "typical_saving": { + "value": 223, + "currency": "GBP" + }, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 76, + "environmental_impact_rating": 76 + } + ], + "co2_emissions_potential": 2.5, + "energy_rating_potential": 76, + "lighting_cost_potential": { + "value": 63, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 218, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2512.47, + "space_heating_existing_dwelling": 9580.15 + }, + "draughtproofed_door_count": 2, + "energy_consumption_current": 154, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 130, + "environmental_impact_current": 73, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 76, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 27, + "low_energy_fixed_lighting_bulbs_count": 14, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/tests/domain/sap10_calculator/rdsap/fixtures/golden/5500-5070-0822-0201-3663.json b/tests/domain/sap10_calculator/rdsap/fixtures/golden/5500-5070-0822-0201-3663.json new file mode 100644 index 00000000..421d0d6f --- /dev/null +++ b/tests/domain/sap10_calculator/rdsap/fixtures/golden/5500-5070-0822-0201-3663.json @@ -0,0 +1,523 @@ +{ + "uprn": 77079925, + "roofs": [ + { + "description": "Pitched, 100 mm loft insulation", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + { + "description": "Pitched, insulated (assumed)", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Suspended, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": "Good lighting efficiency", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "M22 4FA", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "MANCHESTER", + "built_form": 2, + "created_at": "2026-06-03 10:48:51", + "door_count": 2, + "region_code": 19, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 2 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17741 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 691, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.98, + "window_height": 0.93, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.4, + "window_height": 0.74, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.49, + "window_height": 1.99, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.13, + "window_height": 1.06, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.17, + "window_height": 0.75, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 6, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.92, + "window_height": 0.94, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 8, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.01, + "window_height": 1.21, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 8, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.41, + "window_height": 0.85, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.03, + "window_height": 1.26, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.12, + "window_height": 0.92, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.63, + "window_height": 0.98, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 2.64, + "window_height": 1.2, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.55, + "window_height": 1.22, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Semi-detached house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "75 Kenworthy Lane", + "assessment_type": "RdSAP", + "completion_date": "2026-06-03", + "inspection_date": "2026-06-03", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 83, + "transaction_type": 5, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2026-06-03", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "Room heaters, electric", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "extract_fans_count": 2, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.43, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 35.07, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 4.29, + "quantity": "metres" + }, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 11.12, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.45, + "quantity": "metres" + }, + "total_floor_area": { + "value": 35.67, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 4.84, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 12.21, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "C", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + }, + { + "identifier": "Extension 1", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 5, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.43, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 6.07, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 1.2, + "quantity": "metres" + }, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 6.26, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.45, + "quantity": "metres" + }, + "total_floor_area": { + "value": 6.07, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 1.2, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 6.26, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "C", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 4, + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": { + "value": 1013, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 66, + "lighting_cost_current": { + "value": 53, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 926, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 292, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 87, + "currency": "GBP" + }, + "indicative_cost": "\u00a35,000 - \u00a310,000", + "improvement_type": "W1", + "improvement_details": { + "improvement_number": 57 + }, + "improvement_category": 5, + "energy_performance_rating": 68, + "environmental_impact_rating": 75 + }, + { + "sequence": 2, + "typical_saving": { + "value": 224, + "currency": "GBP" + }, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 73, + "environmental_impact_rating": 76 + } + ], + "co2_emissions_potential": 2.1, + "energy_rating_potential": 73, + "lighting_cost_potential": { + "value": 53, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 292, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2105.06, + "space_heating_existing_dwelling": 8424.88 + }, + "draughtproofed_door_count": 2, + "energy_consumption_current": 174, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 146, + "environmental_impact_current": 73, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 76, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 29, + "low_energy_fixed_lighting_bulbs_count": 9, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 04ec0814..4bea5f15 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -666,6 +666,58 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3541, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3533, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1132, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."), + + # ------------------------------------------------------------------ + # "with api 3" — 2 fresh API+Summary+worksheet triples cross-validated + # by S0380.218. Both fetched fresh from the GOV.UK EPB register, run + # through BOTH front-ends (`from_api_response` + `from_elmhurst_site_ + # notes`), and pinned against the dr87 worksheet. The two paths agree + # to <1e-4 on all four metrics (cross-mapper parity per + # [[feedback-cross-mapper-parity-via-cascade]]) AND reproduce the + # worksheet's (255) cost / (272) CO2 / (286) PE exactly — see the + # +0.0000 worksheet pins in `_WORKSHEET_PE_CO2`. The dropped-field + # audit on both fresh JSONs surfaced no new silently-dropped schema + # fields (only `created_at` metadata + the shower keys handled by + # `_normalize_shower_outlets`). The PE/CO2 residuals below are vs the + # integer-rounded lodged register (`energy_consumption_current` / + # `co2_emissions_current`); the worksheet pins are the load-bearing + # 1e-4 check. + # ------------------------------------------------------------------ + _GoldenExpectation( + cert_number="0340-2467-9260-2006-6521", + actual_sap=70, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=-0.4054, + expected_co2_resid_tonnes_per_yr=-0.0250, + notes=( + "Semi-detached house, TFA 107.26, mains-gas PCDB-listed boiler " + "(index 15709, no Table 4b code), no PV, 1 building part, 17 " + "windows. S0380.218 cross-validation: API path ≡ Summary path " + "to <1e-4 on SAP/cost/CO2/PE; cascade reproduces the dr87 " + "worksheet (255) cost 776.4295 / (272) CO2 2875.0498 / (286) PE " + "16474.5616 exactly (see `_WORKSHEET_PE_CO2`). Cascade SAP " + "integer 70 = lodged (resid +0); cont 70.1228. PE/CO2 resids " + "below are vs the integer-rounded lodged register." + ), + ), + _GoldenExpectation( + cert_number="5500-5070-0822-0201-3663", + actual_sap=66, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=-0.2909, + expected_co2_resid_tonnes_per_yr=+0.0235, + notes=( + "Semi-detached house + 1 extension, TFA 82.88, mains-gas " + "PCDB-listed boiler (index 17741, no Table 4b code), no PV, 2 " + "building parts, 13 windows. S0380.218 cross-validation: API " + "path ≡ Summary path to <1e-4 on SAP/cost/CO2/PE; cascade " + "reproduces the dr87 worksheet (255) cost 751.8295 / (272) CO2 " + "2423.4547 / (286) PE 14397.0118 exactly (see " + "`_WORKSHEET_PE_CO2`). Cascade SAP integer 66 = lodged (resid " + "+0); cont 65.5539. PE/CO2 resids below are vs the integer-" + "rounded lodged register." + ), + ), ) @@ -697,7 +749,8 @@ class _WorksheetPin: expected_co2_resid_kg: float -# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). calc ≡ +# The 49 worksheet-validated certs (9 ASHP + 38 cohort-2 + 2 "with api +# 3", the last pair added by S0380.218). calc ≡ # worksheet on BOTH PE and CO2 at <1e-4 across the ENTIRE cohort # (every expected_*_resid below is 0.0000) — the SAP 10.2 1e-4 # convergence target, met. Closed over two slices: @@ -722,6 +775,7 @@ _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( _WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0340-2467-9260-2006-6521", ws_pe_kwh_per_m2=153.5946, ws_co2_kg_per_yr=2875.0498, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), @@ -747,6 +801,7 @@ _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( _WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="4536-8325-3100-0409-1222", ws_pe_kwh_per_m2=181.7206, ws_co2_kg_per_yr=2109.2633, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="4800-3992-0422-0599-3563", ws_pe_kwh_per_m2=66.4814, ws_co2_kg_per_yr=259.3652, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="5500-5070-0822-0201-3663", ws_pe_kwh_per_m2=173.7091, ws_co2_kg_per_yr=2423.4547, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), From d3def1e25476f9b8e2b8eeaf1575ebed25db8c23 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 12:36:23 +0000 Subject: [PATCH 45/80] =?UTF-8?q?docs:=20handover=20=E2=80=94=20S0380.218?= =?UTF-8?q?=20closed=20the=20"with=20api=203"=20pair=20(both=20clean)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record S0380.218 shipped, bump HEAD/next-slice, note both certs are 0-residual cross-validated golden fixtures and flag the optional Summary-path regression guard as the cheap follow-up. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_FRESH_API_DEBUG.md | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md b/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md index 9dd46134..2a982f38 100644 --- a/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md +++ b/domain/sap10_calculator/docs/HANDOVER_FRESH_API_DEBUG.md @@ -4,10 +4,26 @@ Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodolog 1e-4 bar, the per-line debugging loop, the section helpers, and the suite command. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `f895dd3a` (S0380.217). Confirm with `git rev-parse HEAD`. +- **HEAD:** `6d9ef114` (S0380.218). Confirm with `git rev-parse HEAD`. - **Baseline (AGENT_GUIDE §4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/` - → green (2388 passed, 1 skipped at HEAD; the golden + worksheet pins all pass). -- **Next slice number:** **S0380.218**. + → green (2392 passed, 1 skipped at HEAD; the golden + worksheet pins all pass). +- **Next slice number:** **S0380.219**. + +> **S0380.218 (DONE) — Part 1 closed for the "with api 3" pair.** The two +> certs the user dropped under `sap worksheets/with api 3/` — +> `0340-2467-9260-2006-6521` (Summary_000922 / dr87 000922) and +> `5500-5070-0822-0201-3663` (Summary_000920 / dr87 000920) — are **clean**. +> Fetched fresh, run through BOTH front-ends, both paths agree to <1e-4 on +> SAP/cost/CO2/PE AND reproduce the worksheet (255)/(272)/(286)/(33)/(37) +> exactly. SAP integer = lodged (resid +0) on both. **No mapper/calculator +> bug surfaced.** Dropped-field audit clean (only `created_at` + +> `_normalize_shower_outlets`-handled shower keys). Locked in as golden +> fixtures: 2 JSONs under `fixtures/golden/` + entries in `_EXPECTATIONS` +> and `_WORKSHEET_PE_CO2` (test_golden_fixtures.py). The Summary path was +> validated manually but is NOT pinned in a committed test (would need the +> Summary PDFs copied into `backend/documents_parser/tests/fixtures/` + a +> textract-preprocessed chain test) — a cheap follow-up if cross-mapper +> parity wants a standing regression guard beyond the API-path golden pin. - **Pre-existing failures (NOT yours, out of scope):** - `domain/sap10_ml/tests/test_rdsap_uvalues.py` — 2 stone-§5.6 thin-wall failures (granite + sandstone band A, 3.7408 vs Table-6 1.7 cap). Run this suite when you touch From c89bec42cb9485a665b4290debe8c57349331d69 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 14:02:43 +0000 Subject: [PATCH 46/80] =?UTF-8?q?S0380.219:=20map=20API=20floor=5Fconstruc?= =?UTF-8?q?tion=20code=203=20=E2=86=92=20"Suspended,=20not=20timber"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A random 1000-cert Jan–May 2026 EPB-register sample surfaced 53 certs lodging sap_floor_dimensions.floor_construction=3, which raised UnmappedApiCode and blocked the whole cert from computing (~44 of the sample's mapper raises). RdSAP 10 field 3-1 "Floor construction" enumerates the lowest-floor construction as solid / suspended timber / suspended, not timber, and the spec's "Suspended not timber (structural infiltration 0)" makes the split load-bearing. Map code 3 to the canonical "Suspended, not timber" string (the same value the site-notes mapper already emits — cross-mapper parity): - u_floor takes the suspended BS EN ISO 13370 branch via the "Suspended" prefix (_floor_is_suspended_from_description), and - _has_suspended_timber_floor_per_spec's exact-match `!= "Suspended timber"` gate correctly does NOT fire, so the §5 (12) 0.1/0.2 floor-infiltration adjustment is skipped (structural infiltration 0) — exactly the spec rule for not-timber suspended. Validated: all 5 sampled code-3 certs now compute (e.g. 0340-2877-5570-2606-5965 floor_construction_type="Suspended, not timber", SAP cont 60.12 vs lodged 60). Confirmed against the cert's own global floor descriptions ("Suspended, …", floor_heat_loss=7). Code semantics established from the RdSAP 10 spec + the lodged certs' human-readable floor descriptions (the EPB /api/codes endpoint carries no floor_construction enum). §4 suite + schema-mapper tests green (the pre-existing test_total_floor_area failure is unrelated). mapper.py pyright unchanged at 32; new test suppresses reportPrivateUsage to keep net-zero new errors. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 13 +++++++ .../domain/tests/test_from_rdsap_schema.py | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 4f6abc04..c79bb76b 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2373,9 +2373,22 @@ def _api_party_wall_construction_int(value: Union[int, str, None]) -> Optional[i # distinguishes "Suspended" vs everything-else (the solid-branch is # the default fall-through), so the additional code maps to the same # "Solid" string as code 1. +# Code 3 = "suspended, not timber" (e.g. beam-and-block / suspended +# concrete). RdSAP 10 field 3-1 "Floor construction" enumerates the +# lowest-floor construction as solid / suspended timber / suspended, +# not timber. The timber/not-timber split is load-bearing: the spec's +# "Suspended not timber (structural infiltration 0)" means only +# "Suspended timber" triggers the §5 (12) 0.1/0.2 floor-infiltration +# adjustment (see `_has_suspended_timber_floor_per_spec`), while a +# not-timber suspended floor is infiltration 0. Mapping to the canonical +# "Suspended, not timber" string (also used by the site-notes mapper) +# takes the suspended U-value branch via the "Suspended" prefix yet +# correctly fails the exact-match timber gate. Observed on 53/1000 of a +# random 2026 API sample (was raising UnmappedApiCode, blocking the cert). _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, str] = { 1: "Solid", 2: "Suspended timber", + 3: "Suspended, not timber", 4: "Solid", } diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 58b9ed1a..c3d59891 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -795,3 +795,38 @@ class TestElmhurstGlazingTypeWrappedGap: # Assert assert code == 2 + + +class TestApiFloorConstructionCode: + """`_api_floor_construction_str` maps the GOV.UK API integer + floor_construction code to the description string the cascade's + `u_floor` + the §5 (12) infiltration rule read. RdSAP 10 field 3-1 + "Floor construction" enumerates the lowest-floor construction as one + of: solid / suspended timber / suspended, not timber. The spec's + "Suspended not timber (structural infiltration 0)" makes the + timber/not-timber split load-bearing: only "Suspended timber" + triggers the §5 (12) 0.1/0.2 floor-infiltration adjustment; + "suspended, not timber" is structural-infiltration 0.""" + + def test_code_3_maps_to_suspended_not_timber(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_floor_construction_str(3) + + # Assert — suspended U-value branch fires (starts "Suspended"), + # but the exact-match "Suspended timber" (12) rule does NOT — + # per RdSAP 10 "suspended not timber (structural infiltration 0)". + # Same canonical string the site-notes mapper already uses. + assert result == "Suspended, not timber" + + def test_code_2_still_maps_to_suspended_timber(self) -> None: + # Arrange — regression guard: the timber code is unchanged. + from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_floor_construction_str(2) + + # Assert + assert result == "Suspended timber" From d164850dd38ab3e6496a2f7331a43f5a25836a46 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 14:37:50 +0000 Subject: [PATCH 47/80] =?UTF-8?q?S0380.220:=20map=20API=20floor=5Fconstruc?= =?UTF-8?q?tion=20code=200=20=E2=86=92=20None=20(unknown/N-A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026 sample's second-largest mapper raise: 37 certs lodge sap_floor_dimensions.floor_construction=0, which raised UnmappedApiCode and blocked the cert. Code 0 is the "not recorded / not applicable" sentinel — 33/37 pair it with floor_heat_loss=6 ("another dwelling below", an upper-floor flat with no ground floor to describe); the rest carry mixed Solid / unheated-space descriptions. There is no single construction to assert. Map code 0 → None, which defers to RdSAP 10 Table 19 ("where floor construction is unknown" → age-band default) — identical to how an unlodged floor_construction (the 993 None certs) is already handled, and honest about the absence (cf. the no-misleading-insulation_type rule). Empirically inert and validated: across all 37 code-0 certs the cascade floor W/K is byte-identical whether code 0 maps to None or to an explicit "Solid" string — the another-dwelling-below floors compute to 0.0 W/K (handled via floor_heat_loss + property_type=Flat + floors[].description, per the _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE code-6 note), and the few genuine ground/unheated floors hit the same age-band default either way. All 37 now compute (were raising). Dict value type widened to Optional[str] for the None entry; helper already returns Optional[str]. §4 suite + schema-mapper tests green (pre-existing test_total_floor_area failure unrelated); mapper.py pyright unchanged at 32; new test suppresses reportPrivateUsage (net-zero). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 13 ++++++++++++- .../domain/tests/test_from_rdsap_schema.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index c79bb76b..f8a31ae4 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2385,7 +2385,18 @@ def _api_party_wall_construction_int(value: Union[int, str, None]) -> Optional[i # takes the suspended U-value branch via the "Suspended" prefix yet # correctly fails the exact-match timber gate. Observed on 53/1000 of a # random 2026 API sample (was raising UnmappedApiCode, blocking the cert). -_API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, str] = { +# +# Code 0 = "not recorded / not applicable" → None. It pairs +# overwhelmingly with floor_heat_loss=6 ("another dwelling below" — an +# upper-floor flat with no ground floor to describe) but also appears +# with mixed Solid / unheated-space descriptions, so there is no single +# construction to assert. None defers to RdSAP 10 Table 19 ("where floor +# construction is unknown" → age-band default), exactly as an unlodged +# floor_construction does. Empirically inert: floor W/K is identical to +# any explicit construction across all 37 code-0 certs in the 2026 +# sample (the heat loss is governed by floor_heat_loss, not this field). +_API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { + 0: None, 1: "Solid", 2: "Suspended timber", 3: "Suspended, not timber", diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index c3d59891..4f0b80ee 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -830,3 +830,22 @@ class TestApiFloorConstructionCode: # Assert assert result == "Suspended timber" + + def test_code_0_maps_to_none_unknown_construction(self) -> None: + # Arrange — code 0 is the "not recorded / not applicable" + # sentinel: it pairs overwhelmingly with floor_heat_loss=6 + # ("another dwelling below", an upper-floor flat with no ground + # floor to describe), but also appears with mixed Solid / unheated + # descriptions. There is no single construction to assert, so it + # maps to None — RdSAP 10 Table 19 ("where floor construction is + # unknown" → age-band default), the same treatment as an unlodged + # floor_construction. Empirically inert: floor W/K is identical to + # any explicit construction string across all observed code-0 + # certs (the heat loss is governed by floor_heat_loss, not this). + from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_floor_construction_str(0) + + # Assert — no raise; None defers to the cascade's Table 19 default. + assert result is None From aac3f0690a785777be8952a293674997902a0740 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 14:59:37 +0000 Subject: [PATCH 48/80] S0380.221: default a missing API post_town so the cert stays mappable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 2026-register cert (4519-9056-4002-0222-4802) omits the top-level post_town entirely (its town sits only in address_line_3 "BARNSTAPLE"). RdSapSchema21_0_x declares post_town as a required no-default field, so from_dict raised "missing required field 'post_town'" and blocked the whole cert from computing. post_town is address metadata the SAP cascade never reads (no consumer in domain/sap10_calculator/), so default an absent post_town to "" in a from_api_response pre-processor (mirroring _normalize_shower_outlets) — inert for the calculation, keeps the cert mappable. The schema dataclass can't simply give post_town a default: it is a plain (non-kw_only) dataclass with 57 required fields after post_town, so a mid-list default would break field ordering. Validated: cert 4519 now maps (post_town="") and computes SAP cont 74.68 vs lodged 75. §4 suite 2392 passed; mapper.py pyright unchanged at 32; new tests suppress reportPrivateUsage (net-zero). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 21 +++++++++++++ .../domain/tests/test_from_rdsap_schema.py | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index f8a31ae4..2a61bebb 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1911,6 +1911,7 @@ class EpcPropertyDataMapper: """ data = _normalize_shower_outlets(data) + data = _default_missing_post_town(data) schema = data.get("schema_type", "") if schema == "RdSAP-Schema-21.0.1": from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 @@ -2081,6 +2082,26 @@ def _normalize_shower_outlets(data: Dict[str, Any]) -> Dict[str, Any]: return {**data, "sap_heating": new_sap_heating} +def _default_missing_post_town(data: Dict[str, Any]) -> Dict[str, Any]: + """Default an absent top-level `post_town` to "" before `from_dict`. + + `RdSapSchema21_0_x.post_town` is a required (no-default) field, so a + real-API cert that omits it (observed on a 2026-register cert whose + town sits only in `address_line_3`) makes `from_dict` raise + "missing required field 'post_town'", blocking the whole cert. + `post_town` is address metadata that the SAP cascade never reads, so + defaulting it to "" is inert for the calculation while keeping the + cert mappable. The schema dataclass can't simply give the field a + default — it is a plain (non-kw_only) dataclass with 57 required + fields after `post_town`, so a mid-list default would break field + ordering; pre-processing here mirrors `_normalize_shower_outlets`. + + Mutates a shallow copy so the caller's dict is untouched.""" + if "post_town" in data: + return data + return {**data, "post_town": ""} + + def _count_shower_outlets_by_type( schema_shower_outlets: Any, target_type: int, ) -> Optional[int]: diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 4f0b80ee..b0711d9f 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -849,3 +849,33 @@ class TestApiFloorConstructionCode: # Assert — no raise; None defers to the cascade's Table 19 default. assert result is None + + +class TestDefaultMissingPostTown: + """`_default_missing_post_town` keeps a cert mappable when the + register omits the required `post_town` field (observed on a 2026 + cert whose town sits only in address_line_3). post_town is address + metadata the SAP cascade never reads, so defaulting it to "" before + `from_dict` is inert for the calculation.""" + + def test_absent_post_town_is_defaulted_to_empty_string(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"postcode": "EX31 2LE"} + + # Act + result = _default_missing_post_town(doc) + + # Assert + assert result["post_town"] == "" + + def test_present_post_town_is_untouched(self) -> None: + # Arrange — regression guard: a lodged town passes through. + from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"post_town": "BARNSTAPLE"} + + # Act + result = _default_missing_post_town(doc) + + # Assert + assert result["post_town"] == "BARNSTAPLE" From 28634e8ae5f82f6506b3d8ed912db30114fcc023 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 15:16:49 +0000 Subject: [PATCH 49/80] =?UTF-8?q?S0380.222:=20map=20API=20roof=5Fconstruct?= =?UTF-8?q?ion=20codes=206=20(thatched)=20+=207=20(dwelling=20above)=20?= =?UTF-8?q?=E2=86=92=20None?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026 sample lodges roof_construction=6 (1 cert, "Thatched, with additional insulation") and =7 (6 certs, "(same dwelling above)" / "(another dwelling above)"), both raising UnmappedApiCode and blocking the cert. roof_construction_type is read ONLY for the §3 "sloping ceiling" cos(30°) inclined-surface factor (Slice 89); the base roof U-value comes from the global roofs[].description. Neither code is a sloping ceiling: - 6 = thatched — U set by the description, not this field; - 7 = same/another dwelling above — an internal ceiling with no roof heat loss (the roof-side analogue of floor_construction code 0, governed by the roof_heat_loss / description path). Map both to None: carries no information the cascade consumes here and correctly avoids the cos(30°) false-trigger. Empirically inert and validated — roof W/K is byte-identical whether 6/7 map to None or to an explicit pitched string across all code-6/7 certs in the sample. 5 of the 7 now compute (e.g. thatched cert 2276 SAP 62.8 vs lodged 63); the other 2 also carry a gable_wall_type 2/3 raise (separate, worksheet- backed slice). Dict value type widened to Optional[str]. §4 suite 2392 passed; mapper.py pyright unchanged at 32; new tests suppress reportPrivateUsage (net-zero). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 19 +++++++- .../domain/tests/test_from_rdsap_schema.py | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2a61bebb..caf09b6f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2433,11 +2433,28 @@ _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { # are observed on cert 001479; the wider RdSAP10 roof-construction # enum (1=Flat, 3=Pitched no-access, 5=Vaulted, etc.) is mapped as # best-effort against SAP10 nomenclature. -_API_ROOF_CONSTRUCTION_TO_STR: Dict[int, str] = { +# +# Codes 6 and 7 → None. This field is read ONLY for the sloping-ceiling +# inclined factor; the base roof U-value comes from the global +# roofs[].description, so a non-sloping code carries no information the +# cascade consumes here, and None correctly avoids the cos(30°) false- +# trigger: +# 6 = "Thatched, with additional insulation" — its U is set by the +# global description; not a sloping ceiling. +# 7 = "(same dwelling above)" / "(another dwelling above)" — an +# internal ceiling with no roof heat loss (the roof-side analogue +# of floor_construction code 0). Heat loss is governed by the +# roof_heat_loss / description path, not this field. +# Empirically inert: roof W/K is identical whether 6/7 map to None or to +# an explicit pitched string across all code-6/7 certs in the 2026 +# sample (were raising UnmappedApiCode, blocking the cert). +_API_ROOF_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { 1: "Flat", 3: "Pitched (slates/tiles), no access to loft", 4: "Pitched (slates/tiles), access to loft", 5: "Pitched (vaulted ceiling)", + 6: None, + 7: None, 8: "Pitched, sloping ceiling", } diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index b0711d9f..5a4796a5 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -879,3 +879,49 @@ class TestDefaultMissingPostTown: # Assert assert result["post_town"] == "BARNSTAPLE" + + +class TestApiRoofConstructionCode: + """`_api_roof_construction_str` maps the GOV.UK API integer + roof_construction code to the string the cascade reads ONLY for the + "sloping ceiling" cos(30°) inclined-surface factor (Slice 89). Codes + 6 and 7 are neither sloping ceilings nor base-U drivers (the roof + U-value comes from the global roofs[].description), so both map to + None: code 6 = "Thatched" (its U is set by the description, not this + field) and code 7 = "(same/another dwelling above)" — an internal + ceiling with no roof heat loss, the roof-side analogue of + floor_construction code 0. Empirically inert: roof W/K is identical + whether 6/7 map to None or to an explicit pitched string across all + code-6/7 certs in the 2026 sample.""" + + def test_code_7_same_dwelling_above_maps_to_none(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_roof_construction_str(7) + + # Assert — None: no sloping-ceiling signal (avoids the cos(30°) + # false-trigger); the internal ceiling has no roof heat loss. + assert result is None + + def test_code_6_thatched_maps_to_none(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_roof_construction_str(6) + + # Assert — None: thatched is not a sloping ceiling; its U-value is + # carried by the global roof description, not roof_construction_type. + assert result is None + + def test_code_8_still_maps_to_sloping_ceiling(self) -> None: + # Arrange — regression guard: the sloping-ceiling code is unchanged. + from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_roof_construction_str(8) + + # Assert + assert result == "Pitched, sloping ceiling" From 69fdbf9f1dcc1e8a3be3fdfde9824dd3f5872003 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 15:33:52 +0000 Subject: [PATCH 50/80] S0380.223: complete _part_geometry early-return key contract (RR KeyError) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 certs in a 2026 API sample raised `KeyError: 'rr_common_wall_area_m2'` and were blocked from computing. Root cause: `_part_geometry`'s early return (taken when a building part lodges no sap_floor_dimensions — e.g. a party-wall-only or RR-only extension as bp[0]) returned only 6 of the 9 keys the full return exposes, omitting rr_common_wall_area_m2, rr_gable_area_m2 and cantilever_floor_area_m2. The §3.9 RR contribution block reads geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] for EVERY part, so the floorless part's truncated dict raised KeyError at heat_transmission.py:974. Fix: the early return now exposes all 9 keys, the three RR/cantilever geometry values defaulting to 0.0 — correct, since a part with no floor dimensions has no derivable RR shell or cantilever (no floor area). Pure contract-completion bug; no spec/U-value change. Regression test pins the invariant directly: a floorless part's _part_geometry keys must equal a with-floors part's keys. Validated: all 5 certs now compute (4 within ~2 SAP of lodged; the 5th, 8536, has a separate residual). §4 suite 2393 passed; heat_transmission.py pyright unchanged at 12, test file at 71. Co-Authored-By: Claude Opus 4.8 --- .../worksheet/heat_transmission.py | 10 ++++++++ .../worksheet/test_heat_transmission.py | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index d21b2eb1..dff61a08 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -341,6 +341,13 @@ def _joined_descriptions(elements: list[Any]) -> Optional[str]: def _part_geometry(part: SapBuildingPart) -> dict[str, float]: if not part.sap_floor_dimensions: + # A part with no floor dimensions has no derivable RR shell or + # cantilever geometry, but the early return must still expose the + # SAME keys as the full return below: the §3.9 RR block reads + # geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] / + # ["cantilever_floor_area_m2"] for every part, so omitting them + # here raised KeyError on multi-part certs whose first bp lodges + # no sap_floor_dimensions (5 certs in a 2026 API sample). return { "ground_floor_area_m2": 0.0, "top_floor_area_m2": 0.0, @@ -348,6 +355,9 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: "party_wall_area_m2": 0.0, "rr_floor_area_m2": 0.0, "rr_simplified_a_rr_m2": 0.0, + "rr_common_wall_area_m2": 0.0, + "rr_gable_area_m2": 0.0, + "cantilever_floor_area_m2": 0.0, } fds = list(part.sap_floor_dimensions) ground = next((fd for fd in fds if fd.floor == 0), fds[0]) diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 76edac33..600bf7d9 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -36,11 +36,36 @@ from domain.sap10_calculator.worksheet.heat_transmission import ( heat_transmission_from_cert, ) from domain.sap10_calculator.worksheet.heat_transmission import ( + _part_geometry, # pyright: ignore[reportPrivateUsage] _round_half_up, # pyright: ignore[reportPrivateUsage] _window_bp_index, # pyright: ignore[reportPrivateUsage] ) +def test_part_geometry_floorless_part_honours_full_key_contract() -> None: + # Arrange — a building part lodged with NO sap_floor_dimensions (e.g. + # a party-wall-only or RR-only extension; observed on 5 certs in a + # 2026 API sample). `_part_geometry`'s early return must expose the + # same dict keys as its full return: the §3.9 RR contribution block + # reads geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] for + # EVERY part, so a missing key raises KeyError and blocks the cert. + floorless = make_building_part(floor_dimensions=[]) + with_floors = make_building_part( + floor_dimensions=[make_floor_dimension(total_floor_area_m2=50.0)] + ) + + # Act + early = _part_geometry(floorless) + full = _part_geometry(with_floors) + + # Assert — identical key contract; the RR/cantilever geometry is 0.0 + # for a floorless part (no floor area ⇒ no RR shell or cantilever). + assert set(early.keys()) == set(full.keys()) + assert early["rr_common_wall_area_m2"] == 0.0 + assert early["rr_gable_area_m2"] == 0.0 + assert early["cantilever_floor_area_m2"] == 0.0 + + def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() -> None: # Arrange — 346 corpus certs lodge roof_insulation_thickness="NI" # with descriptions like "Pitched, insulated (assumed)". The From 2e351be9575272922e71a00742ac90435c588c70 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 16:19:35 +0000 Subject: [PATCH 51/80] S0380.224: compute storage loss for loose-jacket cylinders (Table 2 Note 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_cylinder_storage_loss_override` returned None for any cylinder whose cylinder_insulation_type wasn't 1 (factory), so a loose-jacket cylinder (code 2, RdSAP 10 field 7-11) fell to the cascade's zero-storage-loss combi/instantaneous default — its real storage loss vanished. SAP 10.2 Table 2 Note 1 gives loose jacket a SEPARATE, ~2× higher loss factor (L = 0.005 + 1.76/(t+12.8) vs factory 0.005 + 0.55/(t+4)); the cylinder_storage_loss_factor_table_2 helper already implements it — only the dispatch was missing. Fix: a `_cylinder_storage_loss_insulation_label` resolver maps the lodged code to the Table 2 branch (1 → factory_insulated, 2 → loose_jacket; None/0/unknown → None, keeping the conservative no-loss default). The override and the HW storage call now route through it instead of hardcoding "factory_insulated". Evidence + validation: a random 2026 register sample has 22 loose-jacket certs that over-predicted SAP by +2.29 mean (18/22 too high, 1/22 within 0.5) — the exact signature of under-counted HW storage loss. After the fix their mean error collapses to +0.45 and 11/22 land within 0.5, with ZERO regression across the worksheet-validated cohort (§4 + golden suite 2394 passed — no validated cert lodges loose jacket, so none shifts). Also unblocks the §10.7 A-F no-water-heating default (next slice) which needs the loose-jacket branch. cert_to_inputs.py pyright unchanged at 32. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 28 +++++++- .../rdsap/test_cert_to_inputs.py | 72 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9e189860..5ab3334d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4582,6 +4582,27 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = { # from the ASHP cohort (all 7 certs lodge code 1, worksheet shows # "Foam" → factory-applied per SAP 10.2 Table 2 Note 2). _CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1 +# RdSAP 10 field 7-11 (cylinder insulation type) — code 2 = loose jacket, +# which SAP 10.2 Table 2 Note 1 gives a SEPARATE (higher) loss factor +# L = 0.005 + 1.76 / (t + 12.8) vs the factory L = 0.005 + 0.55 / (t+4). +_CYLINDER_INSULATION_TYPE_LOOSE_JACKET: Final[int] = 2 + + +def _cylinder_storage_loss_insulation_label( + insulation_type: "int | str | None", +) -> Optional[Literal["factory_insulated", "loose_jacket"]]: + """Map the lodged cylinder_insulation_type code to the SAP 10.2 + Table 2 loss-factor branch. Code 1 → factory-insulated, code 2 → + loose jacket. Any other value (None / 0 / unknown) → None so the + caller keeps the conservative no-storage-loss default rather than + guessing a loss branch. Accepts the int / digit-string / None shapes + `cylinder_insulation_type` arrives in across the two front-ends.""" + code = _int_or_none(insulation_type) + if code == _CYLINDER_INSULATION_TYPE_FACTORY: + return "factory_insulated" + if code == _CYLINDER_INSULATION_TYPE_LOOSE_JACKET: + return "loose_jacket" + return None # RdSAP 10 §10.7 (PDF p.55) "No water heating system": SAP water-heating # code 999 (Elmhurst §15.0 "NON") signals that no DHW system was @@ -5556,14 +5577,17 @@ def _cylinder_storage_loss_override( volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code) if volume_l is None: return None - if sh.cylinder_insulation_type != _CYLINDER_INSULATION_TYPE_FACTORY: + insulation_label = _cylinder_storage_loss_insulation_label( + sh.cylinder_insulation_type + ) + if insulation_label is None: return None thickness_mm = sh.cylinder_insulation_thickness_mm if thickness_mm is None: return None storage_56m = cylinder_storage_loss_monthly_kwh( volume_l=volume_l, - insulation_type="factory_insulated", + insulation_type=insulation_label, thickness_mm=float(thickness_mm), has_cylinder_thermostat=sh.cylinder_thermostat == "Y", # SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index e58f9713..899a99f5 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -3960,6 +3960,78 @@ def test_air_source_heat_pump_pcdb_104568_derives_apm_efficiencies_per_sap_app_n ) +def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_branch() -> None: + """SAP 10.2 Table 2 (PDF p.158) Note 1 gives a SEPARATE storage loss + factor for a loose-jacket cylinder: L = 0.005 + 1.76 / (t + 12.8), + ~2× the factory-insulated L = 0.005 + 0.55 / (t + 4.0) at the same + thickness. The EPB API lodges cylinder_insulation_type=2 = loose + jacket (1 = factory-applied). Before this fix + `_cylinder_storage_loss_override` returned None for every non-factory + type, so a loose-jacket cylinder fell to the zero-storage-loss combi + default — a systematic HW under-count (a 2026 register sample of 22 + such certs over-predicted SAP by +2.29 mean). The override must route + insulation_type=2 to the Table 2 loose-jacket branch. + """ + # Arrange — identical to the factory storage-loss test but + # cylinder_insulation_type=2 (loose jacket) instead of 1. + from domain.sap10_calculator.worksheet.water_heating import ( + cylinder_storage_loss_factor_table_2, + cylinder_temperature_factor_table_2b, + cylinder_volume_factor_table_2a, + ) + + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, + sap_main_heating_code=None, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, # Medium → 160 L + cylinder_insulation_type=2, # loose jacket + cylinder_insulation_thickness_mm=50, + cylinder_thermostat="Y", + ), + ) + # Expected (56)m Jan from the Table 2 loose-jacket branch (same V / + # VF / TF as the factory test — only the loss factor L differs). + loss_factor = cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=50.0 + ) + vol_factor = cylinder_volume_factor_table_2a(160.0) + temp_factor = cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=True, separately_timed_dhw=True + ) + expected_jan_kwh = 160.0 * loss_factor * vol_factor * temp_factor * 31 + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — non-None (was the zero-loss default) and equal to the + # loose-jacket branch, distinctly larger than the factory 36.9530. + assert wh_result is not None + got_jan_kwh = wh_result.solar_storage_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4 + assert got_jan_kwh > 36.9530 # loose jacket loses more than factory + + def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3() -> None: """SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary circuit loss for an indirect cylinder: From 9c0a373f7d79f62ee82ad0390775f43846f3c8ee Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 16:28:25 +0000 Subject: [PATCH 52/80] =?UTF-8?q?S0380.225:=20=C2=A710.7=20no-water-heatin?= =?UTF-8?q?g=20default=20=E2=80=94=20A-F=20=E2=86=92=2012mm=20loose=20jack?= =?UTF-8?q?et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §10.7 no-water-heating default cylinder raised UnmappedSapCode for age bands A-F (2 certs in a 2026 sample, bands B + C) because Table 29's "A to F: 12 mm loose jacket" row wasn't plumbed — the loose-jacket storage-loss branch didn't exist. S0380.224 added it, so this slice completes the Table 29 lookup. Restructure _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE to carry (cylinder_insulation_type, thickness_mm) per band — A-F → (loose jacket, 12), G/H → (factory, 25), I-M → (factory, 38) per RdSAP 10 Table 29 (PDF p.56) — and have the default read both, setting the loose-jacket type for A-F instead of hardcoding factory. The strict-raise is retained only for an absent / out-of-A-M age band (no Table 29 row). Validated: certs 2211 (band B, SAP 49.8 vs lodged 52) and 3420 (band C, 11.2 vs 11) now compute. §4 + golden suite 2395 passed — the corpus "no system" cert (age G, 25 mm factory) is unchanged. cert_to_inputs.py pyright unchanged at 32; new test suppresses reportPrivateUsage. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 36 +++++++++++++------ .../rdsap/test_cert_to_inputs.py | 32 +++++++++++++++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5ab3334d..21619ed4 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4616,11 +4616,25 @@ _WHC_NO_WATER_HEATING_SYSTEM: Final[int] = 999 # "Immersion Heater Type: Single" so the single-immersion path is used. _CYLINDER_SIZE_CODE_NORMAL_110L: Final[int] = 2 # RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not -# accessible" — the §10.7 default cylinder uses the age-band insulation, -# same rule as the inaccessible-cylinder path: A-F → 12 mm loose jacket -# (not yet plumbed — strict-raise), G/H → 25 mm foam, I-M → 38 mm foam. -_TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE: Final[dict[str, int]] = { - "G": 25, "H": 25, "I": 38, "J": 38, "K": 38, "L": 38, "M": 38, +# accessible" — the §10.7 default cylinder uses the age-band insulation: +# "Age band of main property A to F: 12 mm loose jacket", G/H → 25 mm +# foam, I-M → 38 mm foam. Each entry is (cylinder_insulation_type, +# thickness_mm); the loose-jacket branch is now plumbed (S0380.224) so +# A-F resolves instead of raising. +_TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE: Final[dict[str, tuple[int, int]]] = { + "A": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "B": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "C": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "D": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "E": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "F": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12), + "G": (_CYLINDER_INSULATION_TYPE_FACTORY, 25), + "H": (_CYLINDER_INSULATION_TYPE_FACTORY, 25), + "I": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), + "J": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), + "K": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), + "L": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), + "M": (_CYLINDER_INSULATION_TYPE_FACTORY, 38), } @@ -4643,9 +4657,8 @@ def _apply_rdsap_no_water_heating_system_default( Elmhurst engine's worksheet header for the corpus "no system" cert (WHS 903, Single immersion, 110 L cylinder, 25 mm foam at age G). - Raises `UnmappedSapCode` for age bands A-F (12 mm loose jacket) — - no corpus member exercises that combination and the SAP 10.2 Table 2 - loss-factor dispatch only has the factory-foam path plumbed. + Raises `UnmappedSapCode` only when the main dwelling's age band is + absent / outside A-M (no Table 29 row to apply). """ if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM: return epc @@ -4654,17 +4667,18 @@ def _apply_rdsap_no_water_heating_system_default( if epc.sap_building_parts else None ) band = (age_band or "")[:1].upper() - thickness_mm = _TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE.get(band) - if thickness_mm is None: + default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band) + if default is None: raise UnmappedSapCode( "rdsap_10_7_default_cylinder_insulation_age_band", age_band ) + insulation_type_code, thickness_mm = default sap_heating = replace( epc.sap_heating, water_heating_code=_WHC_ELECTRIC_IMMERSION, water_heating_fuel=_STANDARD_ELECTRICITY_FUEL_CODE, cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L, - cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY, + cylinder_insulation_type=insulation_type_code, cylinder_insulation_thickness_mm=thickness_mm, cylinder_thermostat="Y", ) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 899a99f5..3be0ea4f 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -4032,6 +4032,38 @@ def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_bra assert got_jan_kwh > 36.9530 # loose jacket loses more than factory +def test_no_water_heating_default_age_a_to_f_uses_12mm_loose_jacket_per_table_29() -> None: + """RdSAP 10 §10.7 + Table 29 (PDF p.55-56): when no water heating + system is lodged, the default cylinder takes the age-band insulation, + and "Age band of main property A to F: 12 mm loose jacket". Bands + A-F previously raised UnmappedSapCode because the loose-jacket storage + loss branch wasn't plumbed (now it is, S0380.224). A band-B cert must + resolve to a 12 mm loose-jacket cylinder; band G stays 25 mm factory. + """ + from domain.sap10_calculator.rdsap.cert_to_inputs import _apply_rdsap_no_water_heating_system_default # pyright: ignore[reportPrivateUsage] + + def _no_dhw_epc(age_band: str) -> EpcPropertyData: + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part(construction_age_band=age_band)], + sap_heating=make_sap_heating(water_heating_code=999), + ) + + # Act — band B (A-F band) + band G (factory band, regression guard). + band_b = _apply_rdsap_no_water_heating_system_default(_no_dhw_epc("B")) + band_g = _apply_rdsap_no_water_heating_system_default(_no_dhw_epc("G")) + + # Assert — band B → 12 mm loose jacket (type 2); band G → 25 mm + # factory (type 1). Both gain the immersion + 110 L cylinder default. + assert band_b.has_hot_water_cylinder is True + assert band_b.sap_heating.cylinder_insulation_type == 2 # loose jacket + assert band_b.sap_heating.cylinder_insulation_thickness_mm == 12 + assert band_g.sap_heating.cylinder_insulation_type == 1 # factory + assert band_g.sap_heating.cylinder_insulation_thickness_mm == 25 + + def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3() -> None: """SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary circuit loss for an indirect cylinder: From 19ed29e13cebb365d788f7d3bb1f3f62d441f2d8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 16:37:03 +0000 Subject: [PATCH 53/80] =?UTF-8?q?docs:=20handover=20=E2=80=94=201000-cert?= =?UTF-8?q?=20API=20accuracy=20study=20+=20next-steps=20+=20worksheet=20as?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the wide-scale 2026-register study (41.8% <0.5, heating-driven cluster table), the 7 slices shipped (S0380.219-225), the prioritised remaining work (electric-heating clusters + worksheet-backed raises), and the single highest-ROI worksheet to generate: an electric-storage-heater house with a loose-jacket cylinder + a room-in-roof with Sheltered/ Adjacent gables + an extension — one document that validates the #1 accuracy cluster, pins the S0380.224 loose-jacket fix at 1e-4, closes the gable_wall_type Table 4 raise, and exercises multi-bp fabric. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_API_SAMPLE_ACCURACY.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md diff --git a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md new file mode 100644 index 00000000..7a7094c3 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md @@ -0,0 +1,152 @@ +# Handover — wide-scale API accuracy study + next steps + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, the +1e-4 bar, the per-line debugging loop, the section helpers, and the suite command. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `9c0a373f` (S0380.225). Next slice: **S0380.226**. +- **Baseline (§4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/` + → green (2395 passed, 1 skipped). Pre-existing out-of-scope failures unchanged + (stone-§5.6 in `domain/sap10_ml/tests/`; `test_from_rdsap_schema.py::...test_total_floor_area`). + +--- + +## What this study did + +Fetched a **random 1,000-cert sample of domestic EPCs lodged Jan–May 2026** from the +GOV.UK EPB register (the `/api/domestic/search` date-windowed endpoint to enumerate cert +numbers across random pages → `/api/certificate` per cert for the full schema-21 JSON), ran +each through the **API path** (`from_api_response → cert_to_inputs → continuous SAP`), and +compared to the lodged rounded `energy_rating_current`. + +**This is the first measurement of raw-API behaviour on an unbiased population** — the curated +golden cohort (~exact) masked it. + +### Reproduce +- Sampler/fetcher: `/tmp/sample_fetch_2026.py` → caches JSONs to `/tmp/epc_2026_sample/`. +- Evaluator: `/tmp/eval_sap_accuracy.py` → per-cert CSV + summary (`% <0.5`, buckets, worst-40, + raise breakdown). Cluster analysis: `/tmp/analyze2.py`. (Token in `backend/.env` + `OPEN_EPC_API_TOKEN`; `date_end` must be < today.) +- **These scripts are uncommitted (in /tmp).** Worth promoting to `scripts/` if this becomes + a recurring measurement. + +--- + +## Headline (at HEAD `9c0a373f`) + +| metric | value | +|---|---| +| computed | **882 / 1000** (100 unsupported pre-21 schema; 18 still raise) | +| **% \|err\| < 0.5** (of computed) | **41.8%** | +| % < 1.0 / < 2.0 / < 5.0 | 54.9% / 71.9% / 87.8% | +| median / mean \|err\| | 0.79 / ~2.4 | +| mean signed err | +0.2 (slight over-rate) | + +**Accuracy is dominated by heating type** (the load-bearing cut): + +| main_heating_category | n | mean \|err\| | %<0.5 | status | +|---|---|---|---|---| +| 2 = gas boiler (PCDB-indexed) | 579 | 1.30 | 48% | the well-trodden path | +| **7 = electric storage heaters** | 39 | **7.33** | **3%** | **broken — #1 lever** | +| **10 = electric room heaters** | 43 | **10.26** | **9%** | **broken — #2 lever** | +| 6 = community scheme | 38 | 2.28 | 34% | known-hard | +| Flats (any heating) | 242 | 3.19 | 29% | geometry + communal | + +--- + +## Work shipped this session (S0380.219–225) + +Coverage unblocked **788 → 882 computed (+94)**; one real accuracy bug fixed (+22 certs). + +| slice | fix | certs | +|---|---|---| +| S0380.219 | floor_construction 3 → "Suspended, not timber" (RdSAP 10 field 3-1) | ~44 | +| S0380.220 | floor_construction 0 → None (Table 19 unknown; proven inert) | 37 | +| S0380.221 | default missing `post_town` (unused metadata) | 1 | +| S0380.222 | roof_construction 6 (thatched) + 7 (dwelling above) → None (inert) | 5 | +| S0380.223 | `_part_geometry` early-return key contract (RR KeyError) | 5 | +| **S0380.224** | **loose-jacket cylinder storage loss (Table 2 Note 1)** — was None'd out → zero loss | **22** (mean err +2.29 → +0.45) | +| S0380.225 | §10.7 no-water-heating default A-F → 12mm loose jacket | 2 | + +**S0380.224 is only DIRECTION-validated** (the 22 certs moved toward lodged + §4/golden stayed +green) — it has **no worksheet pin on the loose-jacket magnitude**. A worksheet with a +loose-jacket cylinder would close that (see "What to generate" below). + +--- + +## Remaining work, prioritised + +### A. Accuracy clusters (highest value — 80+ certs, mean err 7–10) +1. **Electric storage heaters (cat 7, 39 certs).** Distinct cascade — off-peak tariff split, + charge control (2401/2402), 7-hr/24-hr charge, Table 4a efficiency, responsiveness. **No + worksheet currently validates this path.** Errs both directions (−27..+16). +2. **Electric room heaters (cat 10, 43 certs).** Likewise (controls 2601/2602/2603). Worst + cluster by mean (10.26). +3. **Flats (242, 29% <0.5)** and **PV (40, 28%)** — secondary. + +### B. Remaining raises (18 certs — all U-value / heat-loss-sensitive, NOT enum guesses) +- **`gable_wall_type` 2 & 3 (14 certs).** RdSAP 10 **Table 4** RR walls: 0=Party (U=0.25), + 1=Exposed (U=common wall), 2/3 = **Sheltered (U=external×R0.5)** + **Adjacent-to-heated + (U=0)**, code↔type order unconfirmed (schema says "not yet seen"). Needs (i) a worksheet to + pin which code is which + the U-values, and (ii) **calculator support** — the cascade only + has `gable_wall`/`gable_wall_external` kinds; Sheltered (R=0.5) and Adjacent (U=0) are new. + Best real example: `2818-3053-3203-2655-9204` lodges BOTH gable 2 and 3. +- **`main_heating_category: 9` = warm air, mains gas (1 cert).** Needs §9 warm-air dispatch. +- **`wall_insulation_thermal_conductivity` 3 (1 cert).** Verified it shifts wall U + (53.96→51.61 across λ) → worksheet-backed (the resolver's own discipline). +- **`floor_heat_loss` 8 (2 certs).** Semantically unconfirmed; inert for the 2 observed + (non-Main bp) but potentially "heated space below" (→ should exclude the floor, a calculator + change). Don't guess. + +The clean mapper-enum raises are **exhausted** — every remaining raise changes the answer, which +is what the strict-raise guard exists to prevent. + +--- + +## ★ What to generate — the single most productive worksheet + +Heating is one-per-property, so one worksheet can't cover all four broken heating types. But +**fabric is independent of heating**, so the highest-ROI single artifact bundles the #1 +accuracy cluster with the fabric that closes the gable raises and pins the loose-jacket fix. + +**Build (in Elmhurst, a simulated case is fine — same as the existing `simulated case N` +worksheets) ONE property:** + +> **A house heated by ELECTRIC STORAGE HEATERS, with a room-in-roof and a hot-water cylinder:** +> - **Heating:** electric storage heaters (off-peak / Economy-7 tariff), with a clear control +> type. *This is the load-bearing choice — it validates the 39-cert cat-7 cluster.* +> - **Hot water:** a cylinder with a **loose-jacket** insulation (not factory foam), a stated +> jacket thickness, and a cylinder thermostat. *Pins S0380.224's loose-jacket storage loss +> (56)m at 1e-4 — currently only direction-validated.* +> - **Room-in-roof** with **two gable walls of different types** — ideally one **"Sheltered"** +> and one **"Adjacent to another heated space"** (plus, if the tool allows, a Party and an +> Exposed gable). *Gives the Table 4 U-values for gable_wall_type 2 & 3 and disambiguates the +> code order — closes the 14-cert raise.* +> - **An extension (2nd building part)** with a different floor exposure (e.g. over unheated +> space or "to external air"). *Exercises multi-bp geometry + floor-exposure handling.* + +From that single worksheet I can pin, at 1e-4: the electric-storage space-heating lines +((210)/(211)/space-heat), the loose-jacket storage loss (56)m, the RR gable U-values (30)/(32), +and the multi-bp fabric (27)–(37). That's **one cluster + one fix-validation + the biggest +raise + fabric**, all in one document. + +**If you'd rather do two:** add a second worksheet that is identical but with **electric room +heaters** instead of storage heaters — together they cover cat 7 + cat 10 (≈ 82 certs, the +two worst clusters). A third for a **community-heating flat** would cover cat 6 + the flat +geometry cluster. + +### Then send me, per worksheet +The **Summary PDF** (the Elmhurst input/site-notes) + the **worksheet PDF** (the `(1)..(286)` +ground truth). With those I run both front-ends through the cascade and pin each line ref at +1e-4, exactly as for the `with api 3` pair (S0380.218). + +--- + +## Conventions (unchanged) +One cause = one slice = one commit; spec citation (page+line) in the message; AAA tests +(`# Arrange / # Act / # Assert`); `abs(x - y) <= tol` (not `pytest.approx`); SAP 10.2 only; no +tolerance widening / xfail / rel-tol. New code passes pyright strict with ZERO NEW errors +(baseline-compare with `git stash`; mapper.py / cert_to_inputs.py / heat_transmission.py carry +pre-existing errors — compare counts). Stage files by name (the tree has unrelated +`pytest.ini`/`scripts/` changes that must NOT be staged). +`Co-Authored-By: Claude Opus 4.8 `. From 3b442f96067326ae2ccea80ac67d29ecbd80f9f0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 16:52:09 +0000 Subject: [PATCH 54/80] scripts: promote the API SAP-accuracy toolkit from /tmp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reusable scripts (each with a purpose/usage docstring) for wide-scale testing of the calculator's API front-end against the GOV.UK EPB register — the toolkit behind the 1000-cert study (docs/HANDOVER_API_SAMPLE_ACCURACY.md): fetch_2026_epc_sample.py — sample cert numbers across a date window (random pages) + download full schema-21 JSON to a cache; resumable, 429/5xx backoff. eval_api_sap_accuracy.py — % within 0.5 SAP, error histogram, worst-40, and the mapper/calculator raise breakdown. analyse_api_sap_clusters.py — error grouped by property + heating type to locate clusters (electric heating, flats, PV). Cache dir defaults to /tmp/epc_2026_sample, overridable via EPC_SAMPLE_CACHE. Co-Authored-By: Claude Opus 4.8 --- scripts/analyse_api_sap_clusters.py | 102 +++++++++++++++++ scripts/eval_api_sap_accuracy.py | 169 ++++++++++++++++++++++++++++ scripts/fetch_2026_epc_sample.py | 145 ++++++++++++++++++++++++ 3 files changed, 416 insertions(+) create mode 100644 scripts/analyse_api_sap_clusters.py create mode 100644 scripts/eval_api_sap_accuracy.py create mode 100644 scripts/fetch_2026_epc_sample.py diff --git a/scripts/analyse_api_sap_clusters.py b/scripts/analyse_api_sap_clusters.py new file mode 100644 index 00000000..ab8f0516 --- /dev/null +++ b/scripts/analyse_api_sap_clusters.py @@ -0,0 +1,102 @@ +"""Group API-path SAP error by property + heating type to find clusters. + +WHAT THIS IS FOR +---------------- +The headline number from `eval_api_sap_accuracy.py` tells you HOW accurate the +API path is; this tells you WHERE the error lives so you can prioritise. It +buckets the cached sample's per-cert SAP error (continuous vs lodged) by: + - property type (house / flat / bungalow / maisonette / park home), + - real PV presence, + - heating identity (main_heating_category + whether a PCDB index is lodged), +and prints n / mean|err| / %<0.5 per group, plus red flags (negative or +extreme-low SAP). The load-bearing cut is heating: e.g. electric storage +heaters (cat 7) and room heaters (cat 10) are the worst clusters, which points +the next worksheet-backed fix at those systems. + +USAGE +----- + PYTHONPATH=/workspaces/model python scripts/analyse_api_sap_clusters.py + +Reads the cache written by `fetch_2026_epc_sample.py` (default +`/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`). +""" +import os +import json +import math +from collections import defaultdict +from pathlib import Path + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs + +CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample")) +PROP = {"0": "House", "1": "Bungalow", "2": "Flat", "3": "Maisonette", "4": "Park home"} + + +def real_pv(doc): + """True only for a genuine PV array — `none_or_no_details` / 0% is not PV.""" + es = doc.get("sap_energy_source", {}) or {} + pv = es.get("photovoltaic_supply") + if not isinstance(pv, dict): + return False + if set(pv.keys()) <= {"none_or_no_details"}: + nod = pv.get("none_or_no_details") or {} + return bool(nod.get("percent_roof_area")) + return True + + +def heat_identity(doc): + h = doc.get("sap_heating", {}) or {} + mh = (h.get("main_heating_details") or [{}]) + m0 = mh[0] if mh else {} + return m0.get("main_heating_index_number"), m0.get("main_heating_category") + + +def main(): + rows = [] + for f in sorted(CACHE.glob("????-????-????-????-????.json")): + doc = json.loads(f.read_text()) + lodged = doc.get("energy_rating_current") + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + cont = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ).sap_score_continuous + except Exception: + continue + if lodged is None or not math.isfinite(cont): + continue + idx, cat = heat_identity(doc) + rows.append(dict( + cert=f.stem, ae=abs(cont - lodged), cont=cont, lodged=lodged, + prop=PROP.get(str(doc.get("property_type")), str(doc.get("property_type"))), + pv=real_pv(doc), idx=idx, cat=cat, + neg=(cont < 0), low_lodged=(lodged <= 20), + )) + n = len(rows) + + def grp(keyfn, label): + g = defaultdict(list) + for r in rows: + g[keyfn(r)].append(r["ae"]) + print(f"\n-- mean|err| by {label} (n, mean|err|, %<0.5) --") + for k, v in sorted(g.items(), key=lambda kv: -sum(kv[1]) / len(kv[1])): + if len(v) < 5: + continue + p = 100 * sum(1 for x in v if x < 0.5) / len(v) + print(f" {str(k):28s} n={len(v):4d} mean={sum(v) / len(v):6.2f} <0.5={p:4.1f}%") + + print(f"computed n={n}") + grp(lambda r: r["prop"], "property type") + grp(lambda r: "PV" if r["pv"] else "no-PV", "real PV presence") + grp(lambda r: f"cat={r['cat']},idx={'Y' if r['idx'] else '-'}", "heating identity") + + neg = [r for r in rows if r["neg"]] + loww = [r for r in rows if r["low_lodged"]] + print(f"\nRED FLAGS: negative continuous SAP: {len(neg)} | lodged<=20 (extreme): {len(loww)}") + print(" negative-SAP certs:", [r["cert"] for r in neg][:15]) + + +if __name__ == "__main__": + main() diff --git a/scripts/eval_api_sap_accuracy.py b/scripts/eval_api_sap_accuracy.py new file mode 100644 index 00000000..7f1dd86d --- /dev/null +++ b/scripts/eval_api_sap_accuracy.py @@ -0,0 +1,169 @@ +"""Score the SAP10 calculator's API path against a cached EPC sample. + +WHAT THIS IS FOR +---------------- +Measures how well the API front-end (`from_api_response` → `cert_to_inputs` +→ continuous SAP) reproduces each cert's lodged rounded SAP +(`energy_rating_current`) across the sample built by +`fetch_2026_epc_sample.py`. This is the headline accuracy gauge for raw-API +behaviour on an unbiased population. + +Each cert lands in one bucket: + - computed — ran end-to-end; SAP error recorded. + - unsupported_schema — pre-21 schema the mapper doesn't support (skip). + - raise: — mapper raised (UnmappedApiCode etc.) — a gap to fix. + - calc_raise: — calculator raised (UnmappedSapCode etc.) — a gap. + +OUTPUT +------ + - Category counts + the raise breakdown with example certs (what to fix). + - For computed certs: % within 0.5 / 1 / 2 / 5 SAP, median/mean/p90/p99/max + |err|, the signed mean (over- vs under-rating), abs-err histogram. + - The 40 worst offenders with diagnostic columns (to prioritise). + - A full per-cert CSV at /_results.csv for ad-hoc slicing. + +USAGE +----- + PYTHONPATH=/workspaces/model python scripts/eval_api_sap_accuracy.py + +Reads the cache written by `fetch_2026_epc_sample.py` (default +`/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`). +""" +import os +import json +import csv +import math +from collections import Counter, defaultdict +from pathlib import Path + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs + +CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample")) + + +def diag(doc): + """A few raw-JSON fields that help explain a cert's error at a glance.""" + es = doc.get("sap_energy_source", {}) or {} + h = doc.get("sap_heating", {}) or {} + mh = (h.get("main_heating_details") or [{}]) + mh0 = mh[0] if mh else {} + pv = es.get("photovoltaic_supply") + return { + "schema": doc.get("schema_type"), + "prop_type": doc.get("property_type"), + "built_form": doc.get("built_form"), + "age_band": doc.get("construction_age_band"), + "mains_gas": es.get("mains_gas"), + "main_heat_cat": mh0.get("main_heating_category"), + "main_heat_idx": mh0.get("main_heating_index_number"), + "n_bps": len(doc.get("sap_building_parts") or []), + "lodged_band": doc.get("current_energy_efficiency_band"), + } + + +def main(): + files = sorted(CACHE.glob("????-????-????-????-????.json")) + rows = [] + cat = Counter() + exc_examples = defaultdict(list) + for f in files: + cert = f.stem + try: + doc = json.loads(f.read_text()) + except Exception: + cat["bad_json"] += 1 + continue + lodged = doc.get("energy_rating_current") + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + except ValueError as e: + if "Unsupported EPC schema" in str(e): + cat["unsupported_schema"] += 1 + else: + cat["raise:ValueError"] += 1 + exc_examples["ValueError:" + str(e)[:60]].append(cert) + continue + except Exception as e: + ename = type(e).__name__ + cat[f"raise:{ename}"] += 1 + exc_examples[f"{ename}:{str(e)[:60]}"].append(cert) + continue + if lodged is None: + cat["no_lodged_sap"] += 1 + continue + try: + cont = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ).sap_score_continuous + except Exception as e: + ename = type(e).__name__ + cat[f"calc_raise:{ename}"] += 1 + exc_examples[f"calc:{ename}:{str(e)[:50]}"].append(cert) + continue + if not math.isfinite(cont): + cat["non_finite"] += 1 + continue + err = cont - lodged + cat["computed"] += 1 + rows.append({ + "cert": cert, "our_cont": round(cont, 4), "lodged": lodged, + "err": round(err, 4), "abs_err": round(abs(err), 4), **diag(doc), + }) + + if rows: + keys = list(rows[0].keys()) + with open(CACHE / "_results.csv", "w", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=keys) + w.writeheader() + w.writerows(rows) + + n = len(rows) + print("=" * 70) + print(f"SAMPLE: {len(files)} cached certs | categories:") + for k, v in cat.most_common(): + print(f" {k:28s} {v}") + if n == 0: + return + abs_errs = sorted(r["abs_err"] for r in rows) + + def pct(thr): + return 100.0 * sum(1 for r in rows if r["abs_err"] < thr) / n + + print("=" * 70) + print(f"COMPUTED: {n} certs (continuous SAP vs lodged rounded)") + print(f" % |err| < 0.5 : {pct(0.5):.1f}% <-- headline") + print(f" % |err| < 1.0 : {pct(1.0):.1f}%") + print(f" % |err| < 2.0 : {pct(2.0):.1f}%") + print(f" % |err| < 5.0 : {pct(5.0):.1f}%") + print(f" median |err| : {abs_errs[n // 2]:.3f}") + print(f" mean |err| : {sum(abs_errs) / n:.3f}") + print(f" p90 |err| : {abs_errs[int(n * 0.90)]:.3f}") + print(f" p99 |err| : {abs_errs[int(n * 0.99)]:.3f}") + print(f" max |err| : {abs_errs[-1]:.3f}") + signed = [r["err"] for r in rows] + print(f" mean signed err: {sum(signed) / n:+.3f} (we - lodged; +ve = we over-rate)") + print(" abs-err buckets:") + for lo, hi in [(0, 0.5), (0.5, 1), (1, 2), (2, 5), (5, 10), (10, 1e9)]: + c = sum(1 for r in rows if lo <= r["abs_err"] < hi) + print(f" [{lo:>4}, {hi:>4}) : {c:4d} ({100 * c / n:4.1f}%)") + print("=" * 70) + print("TOP 40 LARGEST |err| (prioritise these):") + worst = sorted(rows, key=lambda r: -r["abs_err"])[:40] + print(f" {'cert':22s} {'err':>7s} {'our':>6s} {'lodg':>4s} prop bf age gas cat/idx bps") + for r in worst: + print(f" {r['cert']:22s} {r['err']:+7.2f} {r['our_cont']:6.1f} {r['lodged']:4d} " + f"{str(r['prop_type']):>4s} {str(r['built_form']):>2s} {str(r['age_band'])[:3]:>3s} " + f"{str(r['mains_gas']):>3s} {str(r['main_heat_cat']):>3s}/{str(r['main_heat_idx']):>6s} " + f"{r['n_bps']}") + if exc_examples: + print("=" * 70) + print("RAISE/ERROR EXAMPLES (mapper/calculator gaps — also prioritise):") + for k, v in sorted(exc_examples.items(), key=lambda kv: -len(kv[1]))[:20]: + print(f" [{len(v):3d}] {k} e.g. {v[0]}") + print(f"\nFull per-cert CSV -> {CACHE / '_results.csv'}") + + +if __name__ == "__main__": + main() diff --git a/scripts/fetch_2026_epc_sample.py b/scripts/fetch_2026_epc_sample.py new file mode 100644 index 00000000..7c16ae3e --- /dev/null +++ b/scripts/fetch_2026_epc_sample.py @@ -0,0 +1,145 @@ +"""Fetch a random sample of domestic EPC JSONs from the GOV.UK EPB register. + +WHAT THIS IS FOR +---------------- +Wide-scale accuracy testing of the SAP10 calculator's API front-end against +real-world certificates (not the curated golden cohort, which masks raw-API +behaviour). This script builds the *input corpus*: it samples certificate +numbers uniformly at random across a date window, then downloads each cert's +full schema-21 ``data`` payload (the exact shape +``EpcPropertyDataMapper.from_api_response`` consumes) into a local cache. + +Pair it with: + - ``eval_api_sap_accuracy.py`` — % within 0.5 SAP, worst offenders, raises. + - ``analyse_api_sap_clusters.py`` — error grouped by heating type / property. + +HOW THE SAMPLE IS DRAWN +----------------------- +The register's ``/api/domestic/search`` endpoint is date-windowed and paged +(``date_start``/``date_end``/``current_page``/``page_size``); results are +ordered by registration date, so picking random PAGES across the whole window +gives an unbiased spread over dates, regions and property types. Each chosen +cert number is then resolved to its full JSON via ``/api/certificate``. + +USAGE +----- + PYTHONPATH=/workspaces/model python scripts/fetch_2026_epc_sample.py + +Resumable — re-running skips certs already cached, so it's safe to interrupt. +Token is read from ``backend/.env`` (``OPEN_EPC_API_TOKEN``). NB the register +rejects a ``date_end`` that includes today, so keep the window in the past. + +Tune the constants below (window, page count, target size, seed). The cache +dir defaults to ``/tmp/epc_2026_sample`` and can be overridden with the +``EPC_SAMPLE_CACHE`` env var. +""" +import os +import json +import time +import random +import threading +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed + +import httpx +from dotenv import load_dotenv + +load_dotenv("backend/.env") +TOKEN = os.environ["OPEN_EPC_API_TOKEN"] +BASE = "https://api.get-energy-performance-data.communities.gov.uk" +H = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"} +CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample")) +CACHE.mkdir(parents=True, exist_ok=True) + +# Sampling window + size. `date_end` must be strictly before today (the +# register rejects "the date cannot include today"). TOTAL_PAGES is the +# `totalPages` the search returns for this window at page_size=100 — re-probe +# it if you change the window (it only needs to be an upper bound for the +# random page draw; out-of-range pages just return fewer rows). +WINDOW = {"date_start": "2026-01-01", "date_end": "2026-05-31"} +TOTAL_PAGES = 7402 +N_PAGES = 14 # random pages to pull → N_PAGES * 100 candidate certs +TARGET = 1200 # cap on how many full JSONs to fetch +random.seed(2026) # reproducible page draw + + +def _get(url, params, timeout=20.0, tries=5): + """GET with retry/backoff on 429 + 5xx (honours Retry-After).""" + r = None + for i in range(tries): + try: + r = httpx.get(url, params=params, headers=H, timeout=timeout) + except httpx.HTTPError: + time.sleep(1.5 * (i + 1)) + continue + if r.status_code == 429 or r.status_code >= 500: + ra = r.headers.get("Retry-After") + time.sleep(float(ra) if ra else 1.5 * (i + 1)) + continue + return r + return r + + +def sample_cert_numbers(): + pages = sorted(random.sample(range(1, TOTAL_PAGES + 1), N_PAGES)) + certs = {} + for p in pages: + r = _get(f"{BASE}/api/domestic/search", {**WINDOW, "current_page": p, "page_size": 100}) + if r is None or not r.is_success: + print(f" search page {p} -> {getattr(r, 'status_code', 'ERR')}") + continue + for row in r.json().get("data", []): + certs[row["certificateNumber"]] = row.get("registrationDate") + print(f" page {p}: cumulative {len(certs)} certs") + return certs + + +_lock = threading.Lock() +_done = {"ok": 0, "404": 0, "err": 0} + + +def fetch_one(cert): + out = CACHE / f"{cert}.json" + if out.exists(): + with _lock: + _done["ok"] += 1 + return + r = _get(f"{BASE}/api/certificate", {"certificate_number": cert}) + if r is not None and r.status_code == 404: + with _lock: + _done["404"] += 1 + return + if r is None or not r.is_success: + with _lock: + _done["err"] += 1 + return + try: + payload = r.json()["data"] + except Exception: + with _lock: + _done["err"] += 1 + return + out.write_text(json.dumps(payload)) + with _lock: + _done["ok"] += 1 + if _done["ok"] % 100 == 0: + print(f" fetched {_done['ok']} (404={_done['404']} err={_done['err']})") + + +def main(): + print("sampling cert numbers...") + certs = sample_cert_numbers() + cert_list = list(certs)[:TARGET] + (CACHE / "_manifest.json").write_text( + json.dumps({"certs": cert_list, "window": WINDOW}, indent=2) + ) + print(f"fetching {len(cert_list)} cert JSONs into {CACHE} ...") + t0 = time.time() + with ThreadPoolExecutor(max_workers=8) as ex: + list(as_completed([ex.submit(fetch_one, c) for c in cert_list])) + print(f"DONE in {time.time() - t0:.0f}s: ok={_done['ok']} 404={_done['404']} err={_done['err']}") + print(f"cached JSON files: {len(list(CACHE.glob('????-????-????-????-????.json')))}") + + +if __name__ == "__main__": + main() From c236aa5836b583b8198a7ace03ce79f7a42f02b1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 17:07:37 +0000 Subject: [PATCH 55/80] =?UTF-8?q?S0380.226:=20map=20Elmhurst=20"Jacket"=20?= =?UTF-8?q?cylinder=20insulation=20=E2=86=92=20loose-jacket=20(code=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Summary-path mapper raised UnmappedElmhurstLabel for a §15.1 "Cylinder Insulation Type: Jacket" lodging — only "Foam" (→1, factory) was mapped. SAP10 cylinder_insulation_type uses 2 for loose jacket (matching the GOV.UK API codes), and SAP 10.2 Table 2 Note 1 gives it a separate ~2× storage-loss factor that the cascade now handles (S0380.224). Add "Jacket" → 2 for cross-mapper parity with the API path and so the loose-jacket storage-loss branch fires on the Summary path. Surfaced by simulated case 19 (a 210 L jacket cylinder + electric storage heaters), which previously couldn't extract at all. §4 suite 2397 passed; mapper.py pyright unchanged at 32. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_summary_pdf_mapper_chain.py | 29 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 6 ++++ 2 files changed, 35 insertions(+) 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 94a3b927..c3a1f4df 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -4330,3 +4330,32 @@ def test_from_elmhurst_site_notes_matches_hand_built_000516() -> None: f"hand-built EpcPropertyData for cohort cert 000516:\n " + "\n ".join(diffs) ) + + +def test_elmhurst_jacket_cylinder_insulation_maps_to_loose_jacket_code_2() -> None: + # Arrange — an Elmhurst §15.1 "Cylinder Insulation Type: Jacket" + # lodging is a loose jacket, which SAP 10.2 Table 2 Note 1 gives a + # separate (higher) storage-loss factor than factory foam. The SAP10 + # `cylinder_insulation_type` enum uses 2 for loose jacket (1 = factory + # foam), matching the GOV.UK API path — so the Summary "Jacket" label + # must resolve to 2 for cross-mapper parity, and so the + # loose-jacket storage-loss branch (S0380.224) fires. Observed on the + # simulated-case-19 worksheet (210 L jacket cylinder + storage heaters). + from datatypes.epc.domain.mapper import _elmhurst_cylinder_insulation_code # pyright: ignore[reportPrivateUsage] + + # Act + code = _elmhurst_cylinder_insulation_code("Jacket", cylinder_present=True) + + # Assert + assert code == 2 + + +def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> None: + # Arrange — regression guard: the factory-foam label is unchanged. + from datatypes.epc.domain.mapper import _elmhurst_cylinder_insulation_code # pyright: ignore[reportPrivateUsage] + + # Act + code = _elmhurst_cylinder_insulation_code("Foam", cylinder_present=True) + + # Assert + assert code == 1 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index caf09b6f..98a341b9 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4710,8 +4710,14 @@ _ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10: Dict[str, int] = { # which SAP 10.2 Table 2 Note 2 treats as factory-applied PU foam). # Other labels (Loose Jacket, None) raise `UnmappedElmhurstLabel` # until a fixture exercises them. +# SAP10 cylinder_insulation_type enum: 1 = factory-applied foam, +# 2 = loose jacket (matching the GOV.UK API codes). SAP 10.2 Table 2 +# Note 1 gives loose jacket a separate, ~2× higher storage-loss factor; +# the cascade's loose-jacket branch is wired (S0380.224), so "Jacket" +# resolves to 2 for cross-mapper parity with the API path. _ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10: Dict[str, int] = { "Foam": 1, + "Jacket": 2, } From 796dce9d69faaac8a15f455e9ea39c3fc80bd5bf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 17:14:05 +0000 Subject: [PATCH 56/80] =?UTF-8?q?docs:=20handover=20=E2=80=94=20fold=20in?= =?UTF-8?q?=20S0380.224-226=20+=20simulated=20case=2019=20debug=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump HEAD/next-slice/baseline, note the committed scripts toolkit, and add the active "simulated case 19" section: the electric-storage-heater + loose-jacket worksheet the user generated, what S0380.226 unblocked, and the prioritised cluster bugs it exposed (cost (255) -334 = the +9 SAP driver; Table 2b TF x0.9; WHS-911 storage-vs-combi routing; fabric +1.0). Updated the "what to generate" ask to the two highest-value follow-ups (electric room heaters; Sheltered/Adjacent RR gables). Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_API_SAMPLE_ACCURACY.md | 65 +++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md index 7a7094c3..afbc4b6f 100644 --- a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md +++ b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md @@ -4,10 +4,15 @@ Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodolog 1e-4 bar, the per-line debugging loop, the section helpers, and the suite command. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `9c0a373f` (S0380.225). Next slice: **S0380.226**. +- **HEAD:** `c236aa58` (S0380.226). Next slice: **S0380.227**. - **Baseline (§4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/` - → green (2395 passed, 1 skipped). Pre-existing out-of-scope failures unchanged + → green (2397 passed, 1 skipped). Pre-existing out-of-scope failures unchanged (stone-§5.6 in `domain/sap10_ml/tests/`; `test_from_rdsap_schema.py::...test_total_floor_area`). +- **Toolkit (committed):** `scripts/fetch_2026_epc_sample.py`, + `scripts/eval_api_sap_accuracy.py`, `scripts/analyse_api_sap_clusters.py`. The 1,000 cached + JSONs live in `/tmp/epc_2026_sample/` (gitignored scratch — re-fetch with the sampler; + `EPC_SAMPLE_CACHE` overrides the dir). Re-run the eval after any mapper/calculator change to + watch the headline move. --- @@ -67,10 +72,45 @@ Coverage unblocked **788 → 882 computed (+94)**; one real accuracy bug fixed ( | S0380.223 | `_part_geometry` early-return key contract (RR KeyError) | 5 | | **S0380.224** | **loose-jacket cylinder storage loss (Table 2 Note 1)** — was None'd out → zero loss | **22** (mean err +2.29 → +0.45) | | S0380.225 | §10.7 no-water-heating default A-F → 12mm loose jacket | 2 | +| S0380.226 | Elmhurst "Jacket" cylinder insulation → loose-jacket code 2 (Summary path) | (unblocked case 19) | -**S0380.224 is only DIRECTION-validated** (the 22 certs moved toward lodged + §4/golden stayed -green) — it has **no worksheet pin on the loose-jacket magnitude**. A worksheet with a -loose-jacket cylinder would close that (see "What to generate" below). +Headline at HEAD: **882 / 1000 computed, 41.8% < 0.5** (re-run the eval to refresh). + +--- + +## ★ Active worksheet: simulated case 19 — the electric-storage-heater debug + +The user generated `sap worksheets/golden fixture debugging/simulated case 19/` +(`Summary_001431 (2).pdf` + `P960-0001-001431 - 2026-06-04T174437.228.pdf`), purpose-built to +hit the #1 cluster. It exercises **electric storage heaters** (SAP code 402, control 2402 +auto-charge, 7-hr off-peak tariff) + a **loose-jacket 210 L cylinder** + **WHS 911** (gas +boiler for water only) + **room-in-roof gables (Party + Exposed) + an alternative wall + +exposed floor + electric secondary**. + +**S0380.226 unblocked extraction** (the "Jacket" label was raising). Running the Summary path +through the cascade vs the worksheet (rating block) then exposes the cat-7 cluster bugs — our +**SAP cont 60.2 vs worksheet ~51.2 (+9, the cluster signature)**: + +| line ref | ours | worksheet | gap / cause | +|---|---|---|---| +| **cost (255)** | 1482.12 | **1816.58** | **−334 → the primary +9 SAP driver.** Likely the Economy-7 off-peak tariff cost split (SAP 10.2 Table 12a / §10c high-rate vs low-rate). START HERE. | +| Table 2b TF (53) | 0.54 | **0.60** | we apply the ×0.9 separately-timed multiplier, but the Summary lodges "Separate Time Control: No" → should be 0.60. Check `_table_2b_note_b_multiplier_applies` / the override's `separately_timed_dhw` for a WHS-911 + storage-heater dwelling. | +| HW fuel (219) | ~3642 | 3188.17 | +454 HW over. Tied to the TF bug + how WHS-911 routes storage vs combi loss (the WaterHeating result showed a spurious `combi_loss` and `solar_storage=0` via the section helper — verify against the FULL cascade, the section helper may not mirror it). | +| fabric (33) | 305.04 | 304.04 | +1.0 W/K — minor; gable / alternative-wall rounding. Low priority. | +| CO2 (272) | 3124.67 | 3125.85 | ≈ exact. | + +**Debug recipe** (reuse the `/tmp/case19*.py` throwaways or rebuild): +```python +pages → ElmhurstSiteNotesExtractor(...).extract() → from_elmhurst_site_notes +→ cert_to_inputs / cert_to_demand_inputs → calculate_sap_from_inputs +# section helpers: water_heating_section_from_cert / heat_transmission_section_from_cert +# CI._cylinder_storage_loss_override(epc, main) returns (57)m directly — useful to bisect. +``` +The worksheet's rating block is block 1 (UK-avg, region 0); the demand block (postcode) is +block 2. Pin the rating block for SAP/cost, the demand block for PE/CO2. + +S0380.224's loose-jacket magnitude can be **worksheet-pinned at 1e-4 here** once the TF bug is +fixed (worksheet (51)=0.0330, (52)=0.8298, (53)=0.6000, (55)=3.4531, (56)Jan=107.0456). --- @@ -103,7 +143,20 @@ is what the strict-raise guard exists to prevent. --- -## ★ What to generate — the single most productive worksheet +## ★ Additional worksheets that would help most + +Case 19 (above) already covers electric storage heaters + loose-jacket cylinder + RR. The two +that would add the most NEW coverage: +1. **An electric ROOM-heater dwelling** (SAP code ~691, control 2601/2602) — the **cat-10 + cluster (43 certs, worst by mean error 10.26)**, which case 19 does not touch. +2. **A room-in-roof with a SHELTERED gable and an ADJACENT-TO-HEATED gable** (Table 4 types + beyond Party/Exposed) — closes the `gable_wall_type` 2/3 raise (14 certs) and pins the + Sheltered (U=ext×R0.5) / Adjacent (U=0) U-values the calculator must add. + +The original "design one property" guidance (kept below for reference) is what case 19 was +built from. + +## What to generate — the single most productive worksheet (reference) Heating is one-per-property, so one worksheet can't cover all four broken heating types. But **fabric is independent of heating**, so the highest-ROI single artifact bundles the #1 From 5d4b55d7f99304f0b533a1a07b8a44f29560c3af Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 17:44:11 +0000 Subject: [PATCH 57/80] S0380.227: dedicated DHW-only system is not separately timed (Table 2b note b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 2b note b (PDF p.159) applies the ×0.9 temperature-factor reduction only when DHW is "separately timed" relative to space heating on a SHARED heat generator ("boiler systems, warm air systems and heat pump systems"). Per RdSAP 10 §10.5.1 (PDF p.55) a separate boiler/ circulator providing DHW only (water-heating code 911 = "Gas boiler/ circulator for water heating only") is NOT the main space-heating system — so there is no shared timer to apply the ×0.9 against. `_separately_ timed_dhw` now returns False when water_heating_code is not "from main / 2nd-main system" ({901,902,914}), mirroring the existing WHC 903 electric- immersion carve-out. Simulated case 19 (electric storage main SAP 402 + WHS 911 + 210 L loose-jacket cylinder) is the worksheet case. The single flag drives both: - (53) Temperature factor: 0.54 → 0.6000 (worksheet base, no ×0.9) - (55) storage loss/day: → 3.4531; (56)/(57)m Jan → 107.0456 (1e-4) - (59)m primary loss: h=3 (43.31) → h=5 (Jan 64.5792), worksheet-exact This also worksheet-pins S0380.224's loose-jacket storage loss magnitude at 1e-4, previously only direction-validated. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 14 ++++++ .../rdsap/test_cert_to_inputs.py | 48 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 21619ed4..417a9e28 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4764,6 +4764,20 @@ def _separately_timed_dhw( return False if main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES: return False + # SAP 10.2 Table 2b note b + RdSAP 10 §10.5.1 (PDF p.55): the ×0.9 + # reduction reflects DHW timed separately from space heating on a + # SHARED heat generator. When DHW is from a separate dedicated + # water-heating-only system (water-heating code not "from main / + # 2nd-main system" — e.g. 911 "Gas boiler/circulator for water + # heating only") there is no shared timer to apply the ×0.9 against, + # so the multiplier must not fire — the same principle as the WHC + # 903 electric-immersion carve-out above. Simulated case 19 (electric + # storage main + WHS 911 + 210 L loose-jacket cylinder) is the + # worksheet case: (53) Temperature factor 0.6000 (not 0.54) and + # (59)m primary loss h=5 (Jan 64.5792, not 43.31) both confirm the + # DHW is not separately timed. + if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: + return False return bool(epc.has_hot_water_cylinder) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 3be0ea4f..c0e8df0a 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1864,6 +1864,54 @@ def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b() assert sep_immersion is False +def test_separately_timed_dhw_excludes_dedicated_water_heater_per_table_2b_note_b() -> None: + # Arrange — SAP 10.2 Table 2b note b) (PDF p.159) applies the ×0.9 + # temperature-factor reduction only when DHW "is separately timed" + # relative to space heating on a SHARED heat generator ("boiler + # systems, warm air systems and heat pump systems"). Per RdSAP 10 + # §10.5.1 (PDF p.55) a separate boiler/circulator providing DHW only + # (water-heating code 911 = "Gas boiler/circulator for water heating + # only") is NOT the main space-heating system — here space is by + # electric storage heaters (SAP code 402). With no shared generator + # there is no separate DHW timer to apply the ×0.9 against, so the + # multiplier must not fire — the same principle as the WHC 903 + # electric-immersion carve-out above. Simulated case 19's worksheet + # confirms it: cylinder thermostat present + "Separate Time Control: + # No" → (53) Temperature factor 0.6000 (base, not 0.54 = 0.6 × 0.9) + # AND (59)m primary loss h=5 (winter Jan 64.5792), not h=3 (43.31). + storage_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # electricity + heat_emitter_type="", + emitter_temperature=1, + main_heating_control=2402, # storage-heater auto-charge control + main_heating_category=None, + sap_main_heating_code=402, # electric storage heaters + ) + dedicated_gas_water_heater_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_heating=make_sap_heating( + main_heating_details=[storage_heater_main], + water_heating_fuel=26, # mains gas (dedicated WHS boiler) + water_heating_code=911, # gas boiler/circulator, water only + cylinder_size=4, + cylinder_insulation_type=2, # loose jacket + cylinder_insulation_thickness_mm=50, + ), + ) + + # Act + separately_timed = _separately_timed_dhw( + dedicated_gas_water_heater_epc, storage_heater_main, + ) + + # Assert — dedicated water-heating-only system → not separately timed. + assert separately_timed is False + + def test_water_efficiency_uses_table_4a_water_column_for_heat_pumps_per_sap_10_2() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.163-164) gives heat pumps two # efficiency columns: "space" and "water". For low-temperature From 4911c56200ab6f73bd635e4190d869bcf54699bc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 18:00:38 +0000 Subject: [PATCH 58/80] S0380.228: electric secondary on off-peak bills at Table 12a direct-acting high rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating is a direct- acting electric room heater (RdSAP 10 §A.2.2 default), on the "Other systems including direct-acting electric" row — 7-hour high-rate fraction 1.00, 10-hour 0.50. A room heater runs on demand, mostly at the high rate; it does NOT earn the 100%-low-rate of overnight storage charging. `_secondary_fuel_cost_gbp_per_kwh` previously returned the flat off-peak LOW rate (5.50 p, £0.0550) for every off-peak electric secondary, under- charging by 9.79 p/kWh. New `_secondary_off_peak_rate_gbp_per_kwh` mirrors `_space_heating_fuel_cost_gbp_per_kwh`: it blends the Table 12a high-rate fraction (OTHER_DIRECT_ACTING_ELECTRIC) against the Table 32 high/low rates, with the 18-/24-hour fallback to the low rate. Simulated case 19 (electric storage main + electric secondary, Dual/7-hour meter) is the worksheet case (242): "Space heating - secondary (1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. This was the primary cat-7-cluster cost driver: total cost 1485.68 → 1835.53 (worksheet 1816.58), SAP cont 60.11 → 50.67 (worksheet ~51.22). Remaining +19 cost is HW/space-heating kWh (next slices). Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 33 +++++++++++++- .../rdsap/test_cert_to_inputs.py | 44 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 417a9e28..92158613 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2262,6 +2262,35 @@ def _secondary_efficiency( return seasonal_efficiency(code, None, None) +def _secondary_off_peak_rate_gbp_per_kwh(meter_type: object) -> float: + """SAP 10.2 Table 12a Grid 1 (PDF p.191) blended rate for an electric + secondary heater on an off-peak tariff. The secondary is a direct- + acting electric room heater (RdSAP 10 §A.2.2 default), so it sits on + the "Other systems including direct-acting electric" row — high-rate + fraction 1.00 for 7-hour, 0.50 for 10-hour. NOT the 100%-low-rate of + storage-charging: a room heater runs on demand, mostly at the high + rate. Worksheet evidence — simulated case 19 (242): "Space heating - + secondary (1.00*15.29 + 0.00*5.50)" → all at the 7-hour HIGH rate. + + Mirrors `_space_heating_fuel_cost_gbp_per_kwh`: the meter resolves to + a tariff (the `_is_off_peak_meter` Unknown-code-3 heuristic falls + through to 7-hour, as in `_off_peak_low_rate_gbp_per_kwh_via_meter_ + heuristic`); 18-/24-hour tariffs (absent from the Grid 1 direct-acting + row) fall back to the tariff's Table 32 low rate.""" + tariff = tariff_from_meter_type(meter_type) + if tariff is Tariff.STANDARD: + tariff = Tariff.SEVEN_HOUR + try: + high_frac = space_heating_high_rate_fraction( + Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, tariff, + ) + except NotImplementedError: + return _off_peak_low_rate_gbp_per_kwh(tariff) + high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff) + blended = high_frac * high_rate + (1.0 - high_frac) * low_rate + return blended * _PENCE_TO_GBP + + def _secondary_fuel_cost_gbp_per_kwh( sap_heating, main: Optional[MainHeatingDetail], @@ -2277,13 +2306,13 @@ def _secondary_fuel_cost_gbp_per_kwh( # Default to electricity since the default secondary system is # portable electric heaters (code 693). if _is_off_peak_meter(meter_type, fuel_is_electric=True): - return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type) + return _secondary_off_peak_rate_gbp_per_kwh(meter_type) return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP # When secondary_fuel_type is electricity, apply off-peak if applicable. if _is_electric_water(sec_fuel) and _is_off_peak_meter( meter_type, fuel_is_electric=True ): - return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type) + return _secondary_off_peak_rate_gbp_per_kwh(meter_type) return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index c0e8df0a..69afd8dc 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -64,6 +64,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] _responsiveness, # pyright: ignore[reportPrivateUsage] + _secondary_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage] _section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage] _separately_timed_dhw, # pyright: ignore[reportPrivateUsage] @@ -1912,6 +1913,49 @@ def test_separately_timed_dhw_excludes_dedicated_water_heater_per_table_2b_note_ assert separately_timed is False +def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate() -> None: + # Arrange — SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating + # is a direct-acting electric room heater (RdSAP 10 §A.2.2 default), + # which sits on the "Other systems including direct-acting electric" + # row. For a 7-hour (Economy-7) tariff that row's high-rate fraction + # is 1.00 — ALL secondary consumption bills at the high rate, NOT the + # off-peak low rate that storage-heater charging earns. Simulated + # case 19's worksheet (242) is the evidence: "Space heating - + # secondary (1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. Pre- + # slice `_secondary_fuel_cost_gbp_per_kwh` returned the 7-hour low + # rate 5.50 p (£0.0550) for every off-peak electric secondary, + # under-charging by 9.79 p/kWh × the secondary kWh (~£340 on case 19). + storage_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # electricity + heat_emitter_type="", + emitter_temperature=1, + main_heating_control=2402, + main_heating_category=None, + sap_main_heating_code=402, # electric storage heaters + ) + dual_meter_off_peak_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_heating=make_sap_heating( + main_heating_details=[storage_heater_main], + # secondary_fuel_type omitted → §A.2.2 portable electric default + ), + ) + + # Act + secondary_rate_gbp_per_kwh = _secondary_fuel_cost_gbp_per_kwh( + dual_meter_off_peak_epc.sap_heating, + storage_heater_main, + 1, # Dual meter → 7-hour off-peak tariff + SAP_10_2_SPEC_PRICES, + ) + + # Assert — 1.00 × 15.29 p + 0.00 × 5.50 p = 15.29 p/kWh = £0.1529. + assert abs(secondary_rate_gbp_per_kwh - 0.1529) <= 1e-6 + + def test_water_efficiency_uses_table_4a_water_column_for_heat_pumps_per_sap_10_2() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.163-164) gives heat pumps two # efficiency columns: "space" and "water". For low-temperature From 0f6b4023457e18fc711ca07e4bc5d2811cf8f45c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 18:35:12 +0000 Subject: [PATCH 59/80] S0380.229: primary loss applies for a dedicated water-heating boiler/circulator (WHS 911-931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 3 (PDF p.160) row 1: primary circuit loss applies when "hot water is heated by a heat generator (e.g. boiler) connected to a hot water storage vessel via insulated or uninsulated pipes". The Table 4a hot-water-only codes (PDF p.166) 911 gas / 912 liquid / 913 solid boiler-circulator + 921-931 range cooker with boiler are each a heat generator feeding the cylinder through a primary loop. `_primary_loss_applies` keyed only off the resolved DHW `main` — but for these certs `_water_heating_main` returns the SPACE main (e.g. electric storage heaters, SAP code 402, which has no primary loop), so every boiler branch missed the gas water-boiler's primary circuit and (59)m went to zero. New branch keys off `water_heating_code` ∈ `_WATER_HEATING_BOILER_CIRCULATOR_CODES`. 941 (electric HP for water only) is excluded — HP DHW vessels follow the Table 3 integral-vessel rules. Simulated case 19 (electric storage main + WHS 911 + 210 L cylinder): (62)m total HW demand 2493.30 → 3169.98 kWh/yr, matching the worksheet (the missing 676.68 kWh/yr = the worksheet's (59) primary-loss annual sum, h=5/p=0). The remaining (64)/(219) gap is the PV diverter (63b), deferred to its own slice. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 25 +++++++++++ .../rdsap/test_cert_to_inputs.py | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 92158613..58a08efd 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -659,6 +659,19 @@ _INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909}) # zero-loss list, so primary loss is zero whenever this code is lodged. _WHC_ELECTRIC_IMMERSION: Final[int] = 903 +# Water-heating codes for a dedicated "boiler/circulator for water +# heating only" — SAP 10.2 Table 4a hot-water section (PDF p.166): +# 911 gas, 912 liquid fuel, 913 solid fuel boiler/circulator; 921-931 +# range cooker with boiler for water heating only. Each is a heat +# generator feeding the cylinder through a primary loop, so SAP 10.2 +# Table 3 (PDF p.160) row 1 primary circuit loss applies — independent +# of the space-heating system (which for these certs is a separate main, +# e.g. electric storage heaters). 941 (electric HP for water only) is +# excluded: HP DHW vessels follow the Table 3 integral-vessel rules. +_WATER_HEATING_BOILER_CIRCULATOR_CODES: Final[frozenset[int]] = frozenset( + {911, 912, 913} | set(range(921, 932)) +) + # SAP 10.2 Appendix M equation (M1): EPV = 0.8 × kWp × S × ZPV, summed # per array. The module efficiency constant (0.8), orientation-dependent @@ -5110,6 +5123,18 @@ def _primary_loss_applies( # kWh/yr primary loss to a system with no primary circuit at all. if water_heating_code == _WHC_ELECTRIC_IMMERSION: return False + # SAP 10.2 Table 3 (PDF p.160) row 1 — a dedicated "boiler/circulator + # for water heating only" (WHC 911 gas / 912 liquid / 913 solid / + # 921-931 range cooker with boiler) is a heat generator feeding the + # cylinder through a primary loop, so the loss applies regardless of + # the space-heating main. Checked off `water_heating_code` (not + # `main`) because for these certs the resolved DHW `main` is the + # SPACE main (e.g. an electric storage heater, SAP code 402) — the + # gas/oil water boiler isn't a `main_heating_detail`. Simulated case + # 19 (storage main + WHS 911 + 210 L cylinder): worksheet (59) = 676.68 + # kWh/yr — zero before this branch. + if water_heating_code in _WATER_HEATING_BOILER_CIRCULATOR_CODES: + return True if main.main_heating_category == 4: if hp_record is None: # No PCDB record → assume separate-vessel (conservative; the diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 69afd8dc..fc95112c 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -60,6 +60,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _is_off_peak_meter, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] _other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + _primary_loss_applies, # pyright: ignore[reportPrivateUsage] _rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage] _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] @@ -1956,6 +1957,46 @@ def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate( assert abs(secondary_rate_gbp_per_kwh - 0.1529) <= 1e-6 +def test_sap_table_3_primary_loss_applies_to_dedicated_water_heating_boiler_circulator() -> None: + # Arrange — SAP 10.2 Table 3 (PDF p.160) row 1: primary circuit loss + # applies when "hot water is heated by a heat generator (e.g. boiler) + # connected to a hot water storage vessel via insulated or + # uninsulated pipes". The dedicated "boiler/circulator for water + # heating only" water-heating codes (Table 4a hot-water section, PDF + # p.166): 911 gas, 912 liquid fuel, 913 solid fuel, 921-931 range + # cooker with boiler — each is a boiler feeding the cylinder through a + # primary loop, so the loss applies regardless of what the SPACE + # heating system is. Simulated case 19 pairs electric storage heaters + # (SAP code 402) for space with a WHS 911 gas boiler/circulator for + # water: `_water_heating_main` resolves to the code-402 storage main + # (electric, no primary loop), so before this slice every dedicated- + # boiler branch missed the cylinder's primary circuit and (59)m went + # to zero — dropping the worksheet's 676.68 kWh/yr (59) and inflating + # HW fuel (219). + storage_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # electricity + heat_emitter_type="", + emitter_temperature=1, + main_heating_control=2402, + main_heating_category=None, + sap_main_heating_code=402, # electric storage heaters (space) + ) + + # Act + applies_with_cylinder = _primary_loss_applies( + storage_heater_main, True, None, water_heating_code=911, + ) + applies_without_cylinder = _primary_loss_applies( + storage_heater_main, False, None, water_heating_code=911, + ) + + # Assert — WHS 911 + cylinder → primary loss applies; no cylinder → + # no primary circuit, no loss. + assert applies_with_cylinder is True + assert applies_without_cylinder is False + + def test_water_efficiency_uses_table_4a_water_column_for_heat_pumps_per_sap_10_2() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.163-164) gives heat pumps two # efficiency columns: "space" and "water". For low-temperature From 9a483b871145883c2c64a7137c37fe042a96bd2d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 18:36:13 +0000 Subject: [PATCH 60/80] =?UTF-8?q?docs:=20handover=20=E2=80=94=20fold=20in?= =?UTF-8?q?=20S0380.227-229=20+=20PV=20diverter=20(G4)=20as=20the=20case-1?= =?UTF-8?q?9=20next=20slice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_API_SAMPLE_ACCURACY.md | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md index afbc4b6f..2aa3ff68 100644 --- a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md +++ b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md @@ -4,7 +4,7 @@ Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodolog 1e-4 bar, the per-line debugging loop, the section helpers, and the suite command. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `c236aa58` (S0380.226). Next slice: **S0380.227**. +- **HEAD:** `0f6b4023` (S0380.229). Next slice: **S0380.230**. - **Baseline (§4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/` → green (2397 passed, 1 skipped). Pre-existing out-of-scope failures unchanged (stone-§5.6 in `domain/sap10_ml/tests/`; `test_from_rdsap_schema.py::...test_total_floor_area`). @@ -87,30 +87,48 @@ auto-charge, 7-hr off-peak tariff) + a **loose-jacket 210 L cylinder** + **WHS 9 boiler for water only) + **room-in-roof gables (Party + Exposed) + an alternative wall + exposed floor + electric secondary**. -**S0380.226 unblocked extraction** (the "Jacket" label was raising). Running the Summary path -through the cascade vs the worksheet (rating block) then exposes the cat-7 cluster bugs — our -**SAP cont 60.2 vs worksheet ~51.2 (+9, the cluster signature)**: +**S0380.226 unblocked extraction** (the "Jacket" label was raising). The worksheet has FOUR +blocks: **block 1 = rating** (UK-avg region 0; cost (255)=1816.58, SAP (258)=51, TF (53)=0.60, +(51)=0.0330), **block 2 = demand** (postcode; CO2 (272)=3125.85, PE (286)=30271.76), blocks 3/4 += the potential/improved variants. Pin the rating block for SAP/cost, the demand block for +PE/CO2. Worksheet header line 116 lodges **"Separate Time Control: No"** (NOT in the Summary §15 +PDF — only in the P960 header). -| line ref | ours | worksheet | gap / cause | +**Three slices shipped (S0380.227–229)** — closed the +9 cluster signature; SAP cont +60.2 → **50.33** (worksheet ~51.22): + +| slice | line ref | fix | SAP cont | |---|---|---|---| -| **cost (255)** | 1482.12 | **1816.58** | **−334 → the primary +9 SAP driver.** Likely the Economy-7 off-peak tariff cost split (SAP 10.2 Table 12a / §10c high-rate vs low-rate). START HERE. | -| Table 2b TF (53) | 0.54 | **0.60** | we apply the ×0.9 separately-timed multiplier, but the Summary lodges "Separate Time Control: No" → should be 0.60. Check `_table_2b_note_b_multiplier_applies` / the override's `separately_timed_dhw` for a WHS-911 + storage-heater dwelling. | -| HW fuel (219) | ~3642 | 3188.17 | +454 HW over. Tied to the TF bug + how WHS-911 routes storage vs combi loss (the WaterHeating result showed a spurious `combi_loss` and `solar_storage=0` via the section helper — verify against the FULL cascade, the section helper may not mirror it). | -| fabric (33) | 305.04 | 304.04 | +1.0 W/K — minor; gable / alternative-wall rounding. Low priority. | -| CO2 (272) | 3124.67 | 3125.85 | ≈ exact. | +| **S0380.227** | TF (53) 0.54→**0.60**; (59) h=3→**h=5** | dedicated DHW-only system (WHS 911) is NOT separately timed → no Table 2b ×0.9 (RdSAP 10 §10.5.1). `_separately_timed_dhw` gated on WHC ∈ {901,902,914}. Worksheet-pins S0380.224's loose-jacket (51)=0.0330/(53)=0.60/(55)=3.4531/(56-57)Jan=107.0456 at 1e-4. | 60.2→60.1 | +| **S0380.228** | cost (255) | electric SECONDARY on off-peak bills at Table 12a `OTHER_DIRECT_ACTING_ELECTRIC` (7-hr high-frac **1.00** = £0.1529), not the flat off-peak low (£0.0550). Worksheet (242): "1.00*15.29 + 0.00*5.50". THE primary cost driver (−340). | 60.1→**50.67** | +| **S0380.229** | (62) 2493.30→**3169.98** | dedicated water-heating boiler/circulator (WHC 911-931) feeds the cylinder via a primary loop → Table 3 row 1 primary loss applies (keyed off `water_heating_code`, since `_water_heating_main` returns the electric SPACE main). Restored the missing (59)=676.68 kWh/yr. | 50.67→50.33 | + +**The ONE remaining case-19 cause — the PV diverter (63b) — is S0380.230 (next).** Worksheet +header line 124 "Diverter = Yes"; Summary §19 "Diverter present: Yes". Per **SAP 10.2 Appendix +G4 (PDF p.72-73)** surplus PV is diverted to the cylinder immersion: +`S_PV,diverter,m = EPV,m × (1 − βm) × 0.8 × 0.9`, clamped to ≤ (62)m + (63a)m, entered as a +NEGATIVE (63b)m. (64)m = (62)m + (63a)m + (63b)m + … → (219)m = (64)m / eff. All four G4 +inclusion conditions are met (PV connected to dwelling; cylinder 210 L > (43)=74.24; no solar +HW; no battery). Worksheet (63b) annual ≈ −1097.67 kWh → (64) drops 3169.98 → 2072.31, (219) +4876.9 → 3188.17. It ALSO changes the PV β-split (export drops: worksheet dwelling 1280.39 / +exported 184.16 vs our 1496.20 / 1187.98 with no diverter). This is a 3-layer feature +(extractor `Diverter present` → mapper flag → calculator (63b) + β-split interaction) — +implement as one focused slice. Spec note p.5485: for the β calc, (219)m must EXCLUDE the +diverter saving. + +Smaller residuals after the diverter lands: main fuel (211) ours 20250.22 vs ws 19910.30 +(+340), secondary (215) 3573.57 vs 3513.58 (+60), fabric (33) +1.0 (gable/alt-wall). Current +demand block: CO2 (272) 3331.04 vs 3125.85, PE (286) 31653.23 vs 30271.76 — both will drop with +the diverter (less grid import). **Debug recipe** (reuse the `/tmp/case19*.py` throwaways or rebuild): ```python pages → ElmhurstSiteNotesExtractor(...).extract() → from_elmhurst_site_notes → cert_to_inputs / cert_to_demand_inputs → calculate_sap_from_inputs -# section helpers: water_heating_section_from_cert / heat_transmission_section_from_cert -# CI._cylinder_storage_loss_override(epc, main) returns (57)m directly — useful to bisect. +# CI._cylinder_storage_loss_override(epc, main) → (57)m; CI._primary_loss_override(epc, age) → (59)m +# CI._water_heating_worksheet_and_gains(epc=…, water_efficiency_pct=0.65, is_instantaneous=False, +# primary_age=, pcdb_record=None) → wh_result with (45)/(46)/(57)/(59)/(62)/(64) ``` -The worksheet's rating block is block 1 (UK-avg, region 0); the demand block (postcode) is -block 2. Pin the rating block for SAP/cost, the demand block for PE/CO2. - -S0380.224's loose-jacket magnitude can be **worksheet-pinned at 1e-4 here** once the TF bug is -fixed (worksheet (51)=0.0330, (52)=0.8298, (53)=0.6000, (55)=3.4531, (56)Jan=107.0456). --- From 193ae27124f01ac109680389d33720db49103405 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 19:05:18 +0000 Subject: [PATCH 61/80] mapper: disambiguate SY system-built from B basement wall (both share code 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP10 `wall_construction == 6` is canonically WALL_SYSTEM_BUILT, but the gov-EPC basement heuristic hijacked it: Elmhurst lodges both "SY System build" and "B Basement wall" as code 6, so a system-built wall was mis-flagged `main_wall_is_basement` and routed to the RdSAP §5.17 `u_basement_wall` override instead of the system-built U-value table. System-built stays on its canonical code 6; the basement signal moves to an explicit `is_basement` (SapAlternativeWall) / `wall_is_basement` (SapBuildingPart) Optional[bool] flag, set by the Elmhurst mapper from the distinct "SY"/"B" codes via `_elmhurst_wall_is_basement` (True for B, False for SY, None otherwise). The `main_wall_is_basement` / `is_basement_wall` properties honour the flag when set and fall back to the gov-EPC API code-6 heuristic when None — so the API path (basement lodged as integer 6, no flag) and the cert 000565 "B" cohort are unchanged. Acceptance (a recommendation-summary generator depends on it): a system-built MAIN wall reports wall_construction == 6 AND main_wall_is_basement is False; a genuine basement main wall still reports main_wall_is_basement is True. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_summary_pdf_mapper_chain.py | 21 ++++++++ datatypes/epc/domain/epc_property_data.py | 32 ++++++++++-- datatypes/epc/domain/mapper.py | 48 +++++++++++++---- .../worksheet/test_heat_transmission.py | 52 +++++++++++++++++++ 4 files changed, 138 insertions(+), 15 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 c3a1f4df..216af22c 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -4359,3 +4359,24 @@ def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> Non # Assert assert code == 1 + + +def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() -> None: + # Arrange — "SY System build" and "B Basement wall" both map to SAP10 + # wall_construction=6 (canonical WALL_SYSTEM_BUILT). The explicit + # basement flag separates them: only "B" is a basement wall (drives + # RdSAP §5.17 u_basement_wall); "SY" is False so it routes through the + # normal system-built U-value table; any other code → None (the + # gov-EPC API code-6 heuristic still applies). + from datatypes.epc.domain.mapper import _elmhurst_wall_construction_int # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import _elmhurst_wall_is_basement # pyright: ignore[reportPrivateUsage] + + # Act / Assert — system-built keeps code 6 but is NOT basement. + assert _elmhurst_wall_construction_int("SY System build") == 6 + assert _elmhurst_wall_is_basement("SY System build") is False + # Genuine basement: code 6 AND flagged basement. + assert _elmhurst_wall_construction_int("B Basement wall") == 6 + assert _elmhurst_wall_is_basement("B Basement wall") is True + # Other constructions defer to the API code-6 heuristic. + assert _elmhurst_wall_is_basement("CA Cavity") is None + assert _elmhurst_wall_is_basement("") is None diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 63ef5f97..0eb3cdee 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -435,13 +435,24 @@ class SapAlternativeWall: # Mirrors `SapBuildingPart.wall_thickness_mm` per the # [[feedback-no-misleading-insulation-type]] convention. wall_thickness_mm: Optional[int] = None + # Explicit basement determination. RdSAP10 `wall_construction == 6` is + # canonically SYSTEM-BUILT (`WALL_SYSTEM_BUILT`) — the basement + # heuristic hijacked it because Elmhurst lodges both "SY System build" + # and "B Basement wall" as code 6. When the source can tell them apart + # (the Elmhurst mapper, from the distinct "SY"/"B" codes) it sets this + # flag; None falls back to the gov-EPC API code-6 heuristic so the API + # path (basement lodged as integer 6, no flag) is unchanged. + is_basement: Optional[bool] = None @property def is_basement_wall(self) -> bool: - """True iff this alt sub-area is the dwelling's basement wall — - identified by RdSAP10 wall_construction code = 6 (see module - constant `BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 - applies a special U-value lookup to basement walls.""" + """True iff this alt sub-area is the dwelling's basement wall. + Honours the explicit `is_basement` flag when set; otherwise falls + back to the gov-EPC API basement sentinel `wall_construction == 6` + (`BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 applies + a special U-value lookup to basement walls.""" + if self.is_basement is not None: + return self.is_basement return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE @@ -514,12 +525,23 @@ class SapBuildingPart: # The dwelling-wide `construction_age_band` does NOT govern curtain # walls; this field decouples them per spec. curtain_wall_age: Optional[str] = None + # Explicit basement determination for the primary wall. See + # `SapAlternativeWall.is_basement` — RdSAP10 code 6 is canonically + # SYSTEM-BUILT, so the Elmhurst mapper sets this flag from the distinct + # "SY"/"B" codes (False for system-built, True for basement); None + # preserves the gov-EPC API code-6 heuristic. + wall_is_basement: Optional[bool] = None @property def main_wall_is_basement(self) -> bool: """True iff this part's primary wall (not an alt sub-area) is the basement wall — happens when the whole part sits below grade. - Empirically 54 of 67k parts in the 2026 sweep; rare but real.""" + Empirically 54 of 67k parts in the 2026 sweep; rare but real. + Honours the explicit `wall_is_basement` flag when set (so a + SYSTEM-BUILT wall, which shares code 6, is not mis-flagged); + otherwise falls back to the gov-EPC API code-6 heuristic.""" + if self.wall_is_basement is not None: + return self.wall_is_basement return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE @property diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 98a341b9..ee551080 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2172,16 +2172,16 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = { "CA": 4, # Cavity "TF": 5, # Timber frame "TI": 5, # Timber frame (Elmhurst's alt-wall code; same SAP10 mapping) - "SY": 6, # System build - "B": 6, # Basement wall (cert 000565 Ext3+Ext4) — routes to the - # `BASEMENT_WALL_CONSTRUCTION_CODE=6` canonical signal so - # the cascade's `part.main_wall_is_basement` triggers the - # RdSAP 10 §5.17 / Table 23 `u_basement_wall` override - # (heat_transmission.py:640). Collides numerically with - # "SY" System build — the cascade's basement check - # precedes `u_wall(construction=6)` so SY would be - # silently mis-routed to u_basement_wall today; no cohort - # fixture exercises SY yet so the conflict is dormant. + "SY": 6, # System build — canonical RdSAP10 WALL_SYSTEM_BUILT=6. + "B": 6, # Basement wall (cert 000565 Ext3+Ext4). Numerically + # collides with "SY" System build on code 6, so the + # basement vs system-built distinction is carried by the + # explicit `is_basement` / `wall_is_basement` flag (set + # via `_elmhurst_wall_is_basement`) rather than the code: + # only "B" triggers the cascade's `main_wall_is_basement` + # → RdSAP 10 §5.17 / Table 23 `u_basement_wall` override. + # "SY" sets the flag False so it routes through the normal + # `u_wall(construction=6)` system-built table instead. "CO": 7, # Cob "PH": 8, # Park home "CW": 9, # Curtain wall @@ -2263,6 +2263,32 @@ def _elmhurst_wall_construction_int(coded: str) -> Optional[int]: return _ELMHURST_WALL_CODE_TO_SAP10[code] +# Elmhurst wall codes that both resolve to SAP10 wall_construction=6 but +# carry opposite basement meaning: "B" Basement wall vs "SY" System build +# (see `_ELMHURST_WALL_CODE_TO_SAP10`). RdSAP10 code 6 is canonically +# WALL_SYSTEM_BUILT; the explicit basement flag lets the cascade route a +# genuine basement to RdSAP §5.17 `u_basement_wall` without mis-flagging +# a system-built wall. +_ELMHURST_BASEMENT_WALL_CODE: Final[str] = "B" +_ELMHURST_SYSTEM_BUILT_WALL_CODE: Final[str] = "SY" + + +def _elmhurst_wall_is_basement(coded: str) -> Optional[bool]: + """Disambiguate the SAP10 code-6 collision from the Elmhurst wall_type + string. Returns True for "B Basement wall", False for "SY System + build", and None for every other code (so the SapBuildingPart / + SapAlternativeWall properties fall back to the gov-EPC API code-6 + heuristic — unchanged for the API path). Empty lodging → None.""" + code = _leading_code(coded) + if not code: + return None + if code == _ELMHURST_BASEMENT_WALL_CODE: + return True + if code == _ELMHURST_SYSTEM_BUILT_WALL_CODE: + return False + return None + + # Elmhurst Party Wall Type codes — distinct category-set from the Wall # Type field; the codes describe construction class for `u_party_wall` # (Table 4 / RdSAP §S.3.2) rather than a specific SAP10 wall-type. Maps @@ -3385,6 +3411,7 @@ def _map_elmhurst_building_part( identifier=identifier, construction_age_band=age_code, wall_construction=_elmhurst_wall_construction_int(walls.wall_type), + wall_is_basement=_elmhurst_wall_is_basement(walls.wall_type), wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation), wall_thickness_measured=not walls.thickness_unknown, party_wall_construction=_elmhurst_party_wall_construction_int(walls.party_wall_type), @@ -3463,6 +3490,7 @@ def _map_elmhurst_alternative_wall( wall_thickness_measured="Y" if not a.thickness_unknown else "N", wall_insulation_thickness=None, wall_thickness_mm=measured_thickness_mm, + is_basement=_elmhurst_wall_is_basement(a.wall_type), ) diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 600bf7d9..1757bdd6 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -1222,6 +1222,58 @@ def test_sap_building_part_has_basement_detects_main_wall_and_alt_wall_codes() - assert alt_is_basement.main_wall_is_basement is False # main is still wc=4 +def test_explicit_wall_is_basement_flag_disambiguates_system_built_from_basement() -> None: + """RdSAP10 `wall_construction == 6` is canonically SYSTEM-BUILT + (`WALL_SYSTEM_BUILT`), but the gov-EPC basement heuristic hijacked it + (Elmhurst lodges both "SY System build" and "B Basement wall" as + code 6). The explicit `wall_is_basement` flag — set by the Elmhurst + mapper from the distinct "SY"/"B" codes — disambiguates: + - flag True → basement (drives §5.17 u_basement_wall) + - flag False → system-built (drives the u_wall code-6 table) + - flag None → fall back to the gov-EPC API code-6 heuristic + so the API path (which lodges basement as integer 6 with no flag) is + unchanged.""" + from dataclasses import replace + # Arrange — three parts, all wall_construction=6, differing only in flag. + plain = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=6, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=80.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0, + ), + ], + ) + system_built = replace(plain, wall_is_basement=False) + basement = replace(plain, wall_is_basement=True) + api_code_6 = replace(plain, wall_is_basement=None) + + # Act / Assert + assert system_built.main_wall_is_basement is False + assert basement.main_wall_is_basement is True + assert api_code_6.main_wall_is_basement is True # API heuristic preserved + + # Alt-wall mirror — same Optional disambiguation on SapAlternativeWall. + alt_system_built = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", is_basement=False, + ) + alt_basement = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", is_basement=True, + ) + alt_api_code_6 = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", + ) + assert alt_system_built.is_basement_wall is False + assert alt_basement.is_basement_wall is True + assert alt_api_code_6.is_basement_wall is True + + def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None: """RdSAP §5.17 / Table 23 governs basement-wall U-values: 0.7 for age A-F, 0.6 for G-H, 0.45 for I, 0.35 for J, ..., 0.26 for M. The From bd25a3c774791348a774677684fb24a4071b9377 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 19:05:18 +0000 Subject: [PATCH 62/80] mapper: disambiguate SY system-built from B basement wall (both share code 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP10 `wall_construction == 6` is canonically WALL_SYSTEM_BUILT — a WALL TYPE — but the gov-EPC basement heuristic hijacked it: Elmhurst lodges both "SY System build" and "B Basement wall" as code 6, and the API lodges basements as code 6 too, so a system-built wall was mis-flagged `main_wall_is_basement` → wrong RdSAP §5.17 / Table 23 u_basement_wall/u_basement_floor overrides, and downstream the solid-wall Recommendation Generator couldn't offer EWI/IWI on system-built walls. System-built stays the wall type on its canonical code 6; the basement signal moves OFF code 6 to a dedicated `is_basement` (SapAlternativeWall) / `wall_is_basement` (SapBuildingPart) Optional[bool] flag: - Elmhurst: `_elmhurst_wall_is_basement` sets it from the distinct "SY"/"B" labels (False for SY, True for B, None otherwise). - gov-EPC API: per-wall code 6 can't be told apart at lodging time, so `from_api_response` post-processes via `_clear_basement_flag_when_ system_built` — when the cert addendum marks the dwelling system-built, the code-6 basement heuristic is cleared. A genuine basement (no addendum signal) keeps the code-6 fallback. - `main_wall_is_basement` / `is_basement_wall` honour the flag when set, else fall back to the code-6 heuristic — so untouched API basements and the cert 000565 "B" cohort are unchanged. `EpcPropertyData.system_build` is a derived property over the wall type: the MAIN wall is system-built iff `wall_construction == 6` and it is not flagged basement. System-built lives on `wall_construction`; the basement attribute is separate. Acceptance: a system-built main wall (Elmhurst SY, or API addendum system_build) → wall_construction == 6, main_wall_is_basement is False, system_build is True; a genuine basement main wall → main_wall_is_basement is True, system_build is False. Full §4 suite green (2404 passed). Co-Authored-By: Claude Opus 4.8 --- .../tests/test_summary_pdf_mapper_chain.py | 21 +++ datatypes/epc/domain/epc_property_data.py | 54 ++++++- datatypes/epc/domain/mapper.py | 124 ++++++++++++++-- .../worksheet/test_heat_transmission.py | 132 ++++++++++++++++++ 4 files changed, 312 insertions(+), 19 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 c3a1f4df..216af22c 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -4359,3 +4359,24 @@ def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> Non # Assert assert code == 1 + + +def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() -> None: + # Arrange — "SY System build" and "B Basement wall" both map to SAP10 + # wall_construction=6 (canonical WALL_SYSTEM_BUILT). The explicit + # basement flag separates them: only "B" is a basement wall (drives + # RdSAP §5.17 u_basement_wall); "SY" is False so it routes through the + # normal system-built U-value table; any other code → None (the + # gov-EPC API code-6 heuristic still applies). + from datatypes.epc.domain.mapper import _elmhurst_wall_construction_int # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import _elmhurst_wall_is_basement # pyright: ignore[reportPrivateUsage] + + # Act / Assert — system-built keeps code 6 but is NOT basement. + assert _elmhurst_wall_construction_int("SY System build") == 6 + assert _elmhurst_wall_is_basement("SY System build") is False + # Genuine basement: code 6 AND flagged basement. + assert _elmhurst_wall_construction_int("B Basement wall") == 6 + assert _elmhurst_wall_is_basement("B Basement wall") is True + # Other constructions defer to the API code-6 heuristic. + assert _elmhurst_wall_is_basement("CA Cavity") is None + assert _elmhurst_wall_is_basement("") is None diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 63ef5f97..030d5345 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -435,13 +435,24 @@ class SapAlternativeWall: # Mirrors `SapBuildingPart.wall_thickness_mm` per the # [[feedback-no-misleading-insulation-type]] convention. wall_thickness_mm: Optional[int] = None + # Explicit basement determination. RdSAP10 `wall_construction == 6` is + # canonically SYSTEM-BUILT (`WALL_SYSTEM_BUILT`) — the basement + # heuristic hijacked it because Elmhurst lodges both "SY System build" + # and "B Basement wall" as code 6. When the source can tell them apart + # (the Elmhurst mapper, from the distinct "SY"/"B" codes) it sets this + # flag; None falls back to the gov-EPC API code-6 heuristic so the API + # path (basement lodged as integer 6, no flag) is unchanged. + is_basement: Optional[bool] = None @property def is_basement_wall(self) -> bool: - """True iff this alt sub-area is the dwelling's basement wall — - identified by RdSAP10 wall_construction code = 6 (see module - constant `BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 - applies a special U-value lookup to basement walls.""" + """True iff this alt sub-area is the dwelling's basement wall. + Honours the explicit `is_basement` flag when set; otherwise falls + back to the gov-EPC API basement sentinel `wall_construction == 6` + (`BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 applies + a special U-value lookup to basement walls.""" + if self.is_basement is not None: + return self.is_basement return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE @@ -514,12 +525,23 @@ class SapBuildingPart: # The dwelling-wide `construction_age_band` does NOT govern curtain # walls; this field decouples them per spec. curtain_wall_age: Optional[str] = None + # Explicit basement determination for the primary wall. See + # `SapAlternativeWall.is_basement` — RdSAP10 code 6 is canonically + # SYSTEM-BUILT, so the Elmhurst mapper sets this flag from the distinct + # "SY"/"B" codes (False for system-built, True for basement); None + # preserves the gov-EPC API code-6 heuristic. + wall_is_basement: Optional[bool] = None @property def main_wall_is_basement(self) -> bool: """True iff this part's primary wall (not an alt sub-area) is the basement wall — happens when the whole part sits below grade. - Empirically 54 of 67k parts in the 2026 sweep; rare but real.""" + Empirically 54 of 67k parts in the 2026 sweep; rare but real. + Honours the explicit `wall_is_basement` flag when set (so a + SYSTEM-BUILT wall, which shares code 6, is not mis-flagged); + otherwise falls back to the gov-EPC API code-6 heuristic.""" + if self.wall_is_basement is not None: + return self.wall_is_basement return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE @property @@ -722,3 +744,25 @@ class EpcPropertyData: solar_hw_collector_orientation: Optional[str] = None solar_hw_collector_pitch_deg: Optional[int] = None solar_hw_overshading: Optional[str] = None + + @property + def system_build(self) -> Optional[bool]: + """Whether the dwelling's MAIN wall is system-built. + + System-built is a WALL TYPE: RdSAP10 `WALL_SYSTEM_BUILT == 6` on + the main wall (the U-value cascade table is keyed on that code). + It happens to share the integer with basement walls — so a code-6 + main wall is system-built only when it is NOT flagged as a + basement (`main_wall_is_basement`, the dedicated basement signal + the mapper sets from the distinct "SY"/"B" labels or the cert + addendum). Reading the wall type keeps the two concerns separate: + `wall_construction` carries the construction, the basement flag + carries the below-grade attribute. Returns None when there is no + MAIN building part (unknown).""" + for part in self.sap_building_parts: + if part.identifier is BuildingPartIdentifier.MAIN: + return ( + part.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE + and not part.main_wall_is_basement + ) + return None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 98a341b9..57edae7a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,10 +1,12 @@ import re +from dataclasses import replace from datetime import date from decimal import ROUND_HALF_UP, Decimal from typing import Any, Dict, Final, List, Optional, Sequence, Union, cast from datatypes.epc.schema.helpers import from_dict from datatypes.epc.domain.epc_property_data import ( + BASEMENT_WALL_CONSTRUCTION_CODE, Addendum, BuildingPartIdentifier, EnergyElement, @@ -1916,14 +1918,18 @@ class EpcPropertyDataMapper: if schema == "RdSAP-Schema-21.0.1": from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 - return EpcPropertyDataMapper.from_rdsap_schema_21_0_1( - from_dict(RdSapSchema21_0_1, data) + return _clear_basement_flag_when_system_built( + EpcPropertyDataMapper.from_rdsap_schema_21_0_1( + from_dict(RdSapSchema21_0_1, data) + ) ) if schema == "RdSAP-Schema-21.0.0": from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0 - return EpcPropertyDataMapper.from_rdsap_schema_21_0_0( - from_dict(RdSapSchema21_0_0, data) + return _clear_basement_flag_when_system_built( + EpcPropertyDataMapper.from_rdsap_schema_21_0_0( + from_dict(RdSapSchema21_0_0, data) + ) ) raise ValueError(f"Unsupported EPC schema: {schema!r}") @@ -1933,6 +1939,68 @@ class EpcPropertyDataMapper: # --------------------------------------------------------------------------- +def _clear_basement_flag_when_system_built( + epc: EpcPropertyData, +) -> EpcPropertyData: + """When the dwelling is system-built, a `wall_construction == 6` wall + is WALL_SYSTEM_BUILT, not a basement — so the gov-EPC API code-6 + basement heuristic must not fire for it. The API path can't tell the + two apart at the per-wall level (both lodge code 6), so once the + cert-level `system_build` flag is known we clear the basement signal + on every code-6 wall that hasn't been explicitly determined + (`wall_is_basement` / `is_basement` still None). No-op unless the + dwelling is system-built, so genuine basements (system_build absent / + False) keep the code-6 heuristic. Returns the same object when + nothing changes. + + The Elmhurst path sets the per-wall flag directly from the distinct + "SY"/"B" labels, so it never reaches here (it routes through + `from_elmhurst_site_notes`, not `from_api_response`). + + Keyed on the RAW cert `addendum.system_build` signal rather than the + derived `epc.system_build` property — the property reads the wall + type AFTER this clears the basement flag, so using it here would be + circular.""" + if epc.addendum is None or epc.addendum.system_build is not True: + return epc + + def _clear_alt(alt: Optional[SapAlternativeWall]) -> Optional[SapAlternativeWall]: + if ( + alt is not None + and alt.is_basement is None + and alt.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE + ): + return replace(alt, is_basement=False) + return alt + + new_parts: List[SapBuildingPart] = [] + changed = False + for part in epc.sap_building_parts: + new_alt_1 = _clear_alt(part.sap_alternative_wall_1) + new_alt_2 = _clear_alt(part.sap_alternative_wall_2) + clear_main = ( + part.wall_is_basement is None + and part.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE + ) + if clear_main or new_alt_1 is not part.sap_alternative_wall_1 or ( + new_alt_2 is not part.sap_alternative_wall_2 + ): + changed = True + new_parts.append( + replace( + part, + wall_is_basement=False if clear_main else part.wall_is_basement, + sap_alternative_wall_1=new_alt_1, + sap_alternative_wall_2=new_alt_2, + ) + ) + else: + new_parts.append(part) + if not changed: + return epc + return replace(epc, sap_building_parts=new_parts) + + def _measurement_value(field: Any) -> float: """SAP measurements arrive as a `Measurement` (with `.value`), a raw dict {'value': N, 'quantity': '...'} when `from_dict` didn't coerce, or a plain @@ -2172,16 +2240,16 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = { "CA": 4, # Cavity "TF": 5, # Timber frame "TI": 5, # Timber frame (Elmhurst's alt-wall code; same SAP10 mapping) - "SY": 6, # System build - "B": 6, # Basement wall (cert 000565 Ext3+Ext4) — routes to the - # `BASEMENT_WALL_CONSTRUCTION_CODE=6` canonical signal so - # the cascade's `part.main_wall_is_basement` triggers the - # RdSAP 10 §5.17 / Table 23 `u_basement_wall` override - # (heat_transmission.py:640). Collides numerically with - # "SY" System build — the cascade's basement check - # precedes `u_wall(construction=6)` so SY would be - # silently mis-routed to u_basement_wall today; no cohort - # fixture exercises SY yet so the conflict is dormant. + "SY": 6, # System build — canonical RdSAP10 WALL_SYSTEM_BUILT=6. + "B": 6, # Basement wall (cert 000565 Ext3+Ext4). Numerically + # collides with "SY" System build on code 6, so the + # basement vs system-built distinction is carried by the + # explicit `is_basement` / `wall_is_basement` flag (set + # via `_elmhurst_wall_is_basement`) rather than the code: + # only "B" triggers the cascade's `main_wall_is_basement` + # → RdSAP 10 §5.17 / Table 23 `u_basement_wall` override. + # "SY" sets the flag False so it routes through the normal + # `u_wall(construction=6)` system-built table instead. "CO": 7, # Cob "PH": 8, # Park home "CW": 9, # Curtain wall @@ -2263,6 +2331,32 @@ def _elmhurst_wall_construction_int(coded: str) -> Optional[int]: return _ELMHURST_WALL_CODE_TO_SAP10[code] +# Elmhurst wall codes that both resolve to SAP10 wall_construction=6 but +# carry opposite basement meaning: "B" Basement wall vs "SY" System build +# (see `_ELMHURST_WALL_CODE_TO_SAP10`). RdSAP10 code 6 is canonically +# WALL_SYSTEM_BUILT; the explicit basement flag lets the cascade route a +# genuine basement to RdSAP §5.17 `u_basement_wall` without mis-flagging +# a system-built wall. +_ELMHURST_BASEMENT_WALL_CODE: Final[str] = "B" +_ELMHURST_SYSTEM_BUILT_WALL_CODE: Final[str] = "SY" + + +def _elmhurst_wall_is_basement(coded: str) -> Optional[bool]: + """Disambiguate the SAP10 code-6 collision from the Elmhurst wall_type + string. Returns True for "B Basement wall", False for "SY System + build", and None for every other code (so the SapBuildingPart / + SapAlternativeWall properties fall back to the gov-EPC API code-6 + heuristic — unchanged for the API path). Empty lodging → None.""" + code = _leading_code(coded) + if not code: + return None + if code == _ELMHURST_BASEMENT_WALL_CODE: + return True + if code == _ELMHURST_SYSTEM_BUILT_WALL_CODE: + return False + return None + + # Elmhurst Party Wall Type codes — distinct category-set from the Wall # Type field; the codes describe construction class for `u_party_wall` # (Table 4 / RdSAP §S.3.2) rather than a specific SAP10 wall-type. Maps @@ -3385,6 +3479,7 @@ def _map_elmhurst_building_part( identifier=identifier, construction_age_band=age_code, wall_construction=_elmhurst_wall_construction_int(walls.wall_type), + wall_is_basement=_elmhurst_wall_is_basement(walls.wall_type), wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation), wall_thickness_measured=not walls.thickness_unknown, party_wall_construction=_elmhurst_party_wall_construction_int(walls.party_wall_type), @@ -3463,6 +3558,7 @@ def _map_elmhurst_alternative_wall( wall_thickness_measured="Y" if not a.thickness_unknown else "N", wall_insulation_thickness=None, wall_thickness_mm=measured_thickness_mm, + is_basement=_elmhurst_wall_is_basement(a.wall_type), ) diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 600bf7d9..ad7ad30d 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -1222,6 +1222,138 @@ def test_sap_building_part_has_basement_detects_main_wall_and_alt_wall_codes() - assert alt_is_basement.main_wall_is_basement is False # main is still wc=4 +def test_explicit_wall_is_basement_flag_disambiguates_system_built_from_basement() -> None: + """RdSAP10 `wall_construction == 6` is canonically SYSTEM-BUILT + (`WALL_SYSTEM_BUILT`), but the gov-EPC basement heuristic hijacked it + (Elmhurst lodges both "SY System build" and "B Basement wall" as + code 6). The explicit `wall_is_basement` flag — set by the Elmhurst + mapper from the distinct "SY"/"B" codes — disambiguates: + - flag True → basement (drives §5.17 u_basement_wall) + - flag False → system-built (drives the u_wall code-6 table) + - flag None → fall back to the gov-EPC API code-6 heuristic + so the API path (which lodges basement as integer 6 with no flag) is + unchanged.""" + from dataclasses import replace + # Arrange — three parts, all wall_construction=6, differing only in flag. + plain = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=6, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=80.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0, + ), + ], + ) + system_built = replace(plain, wall_is_basement=False) + basement = replace(plain, wall_is_basement=True) + api_code_6 = replace(plain, wall_is_basement=None) + + # Act / Assert + assert system_built.main_wall_is_basement is False + assert basement.main_wall_is_basement is True + assert api_code_6.main_wall_is_basement is True # API heuristic preserved + + # Alt-wall mirror — same Optional disambiguation on SapAlternativeWall. + alt_system_built = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", is_basement=False, + ) + alt_basement = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", is_basement=True, + ) + alt_api_code_6 = SapAlternativeWall( + wall_area=14.24, wall_dry_lined="N", wall_construction=6, + wall_insulation_type=4, wall_thickness_measured="Y", + ) + assert alt_system_built.is_basement_wall is False + assert alt_basement.is_basement_wall is True + assert alt_api_code_6.is_basement_wall is True + + +def test_system_build_property_derives_from_main_wall_construction_type() -> None: + # Arrange — system-built is a WALL TYPE: RdSAP10 WALL_SYSTEM_BUILT=6 + # on the MAIN wall. It shares the integer with basement, so a code-6 + # main wall is system-built only when it is NOT flagged basement. The + # `system_build` property reads the wall type (wall_construction) + the + # dedicated basement flag — it does not need a separate dwelling-level + # field. + from dataclasses import replace + base_main = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=6, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=80.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0, + ), + ], + ) + system_built = make_minimal_sap10_epc( + sap_building_parts=[replace(base_main, wall_is_basement=False)], + ) + basement = make_minimal_sap10_epc( + sap_building_parts=[replace(base_main, wall_is_basement=True)], + ) + cavity = make_minimal_sap10_epc( + sap_building_parts=[replace(base_main, wall_construction=4)], + ) + no_main = make_minimal_sap10_epc(sap_building_parts=[]) + + # Act / Assert — code 6 + not basement → system-built; code 6 + basement + # → not system-built; a non-6 wall type → not system-built; no main → None. + assert system_built.system_build is True + assert basement.system_build is False + assert cavity.system_build is False + assert no_main.system_build is None + + +def test_system_built_addendum_clears_basement_on_code_6_walls_api_path() -> None: + # Arrange — gov-EPC API system-built cert: the per-wall code 6 can't be + # told from a basement at lodging time, so once the cert addendum marks + # the dwelling system-built, `from_api_response` clears the code-6 + # basement heuristic. A genuine basement (no addendum signal) keeps it. + from dataclasses import replace + + from datatypes.epc.domain.epc_property_data import Addendum + from datatypes.epc.domain.mapper import _clear_basement_flag_when_system_built # pyright: ignore[reportPrivateUsage] + + code_6_main = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=6, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=80.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0, + ), + ], + ) + system_built_cert = replace( + make_minimal_sap10_epc(sap_building_parts=[code_6_main]), + addendum=Addendum(system_build=True), + ) + genuine_basement_cert = make_minimal_sap10_epc(sap_building_parts=[code_6_main]) + + # Act + cleared = _clear_basement_flag_when_system_built(system_built_cert) + untouched = _clear_basement_flag_when_system_built(genuine_basement_cert) + + # Assert — system-built cert: code-6 main wall is no longer basement, + # and the wall-type-derived system_build reads True. Genuine basement + # (no addendum) is unchanged → still basement. + assert cleared.sap_building_parts[0].main_wall_is_basement is False + assert cleared.system_build is True + assert untouched.sap_building_parts[0].main_wall_is_basement is True + assert untouched.system_build is False + + def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None: """RdSAP §5.17 / Table 23 governs basement-wall U-values: 0.7 for age A-F, 0.6 for G-H, 0.45 for I, 0.35 for J, ..., 0.26 for M. The From 86a725224b0aaabda1bef3663d4fa99985c1f481 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 19:51:29 +0000 Subject: [PATCH 63/80] =?UTF-8?q?docs:=20PR=20note=20for=20#1177=20?= =?UTF-8?q?=E2=80=94=20system=5Fbuild=20property=20vs=20field=20merge=20co?= =?UTF-8?q?llision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flags the SY/B disambiguation change and the field-vs-property merge landmine (raises AttributeError at first EpcPropertyData instantiation, not at import; git merges silently) for the feature/bill-derivation reviewer, with the recommended reconciliation and the strict-xfail tripwire they own. Co-Authored-By: Claude Opus 4.8 --- docs/PR_NOTE_system_built_basement_1177.md | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/PR_NOTE_system_built_basement_1177.md diff --git a/docs/PR_NOTE_system_built_basement_1177.md b/docs/PR_NOTE_system_built_basement_1177.md new file mode 100644 index 00000000..6315010f --- /dev/null +++ b/docs/PR_NOTE_system_built_basement_1177.md @@ -0,0 +1,62 @@ +# PR note — SY system-built vs B basement wall (issue #1177) + +For the reviewer / the `feature/bill-derivation` session. Fold the relevant +parts into the PR description; delete this file before/at merge. + +## What this branch changed (commit `bd25a3c7`) + +`wall_construction == 6` (`WALL_SYSTEM_BUILT`) is the canonical **wall type** +for system-built — and it stays there. The basement signal, which had hijacked +code 6, is moved onto a dedicated flag: + +- `SapBuildingPart.wall_is_basement: Optional[bool]` and + `SapAlternativeWall.is_basement: Optional[bool]`. +- `main_wall_is_basement` / `is_basement_wall` honour the flag when set, else + fall back to the legacy `wall_construction == 6` heuristic (so untouched API + basements and the cert 000565 "B" cohort are unchanged). +- Elmhurst: `_elmhurst_wall_is_basement` sets the flag from the distinct + "SY"/"B" labels (SY→False, B→True, other→None). +- gov-EPC API: `from_api_response` post-processes via + `_clear_basement_flag_when_system_built` — when the cert `addendum.system_build` + is True, the code-6 basement heuristic is cleared. +- `EpcPropertyData.system_build` is a **derived `@property`** (not a stored + field): the MAIN wall is system-built iff `wall_construction == 6` and it is + not flagged basement. (Per the call: "system build is the wall type and + should be on `wall_construction`.") + +Acceptance verified: system-built main wall → `wall_construction == 6`, +`main_wall_is_basement is False`, `system_build is True`; genuine basement main +wall → `main_wall_is_basement is True`, `system_build is False`. Full §4 suite +green (2404 passed), zero new pyright errors. + +## ⚠️ Merge collision: `system_build` field (yours) vs property (this branch) + +The `#1177` prompt referenced `EpcPropertyData.system_build` as an existing +**field** on `feature/bill-derivation`. This branch adds `system_build` as a +**`@property`**. They share the name but live in different regions of the class, +so: + +- **Git will likely merge them silently** (no textual conflict). +- **Python will NOT raise at import** — the class defines fine. +- It raises `AttributeError: property 'system_build' ... has no setter` at the + **first `EpcPropertyData(...)` instantiation** — i.e. the first mapper call, + so the test suite fails immediately. (Reproduced.) + +So the collision is caught fast but is a landmine, not a clean signal. **Resolve +it deliberately at merge** — pick ONE representation: + +- **Recommended (matches the agreed model):** drop the stored field; keep the + derived property (system-built is the wall type). If your code currently + *assigns* `epc.system_build = …`, replace those writes with setting the + underlying wall type / basement flag, or add a setter. +- **Or** keep your stored field and delete this branch's property — but then + populate the field consistently with the wall type on BOTH paths (API addendum + *and* Elmhurst "SY"), so `system_build` and `wall_construction`/the basement + flag never disagree. + +## Tripwire you own + +`tests/domain/modelling/test_elmhurst_cascade_pins.py::test_system_built_generator_offers_ewi_and_iwi_each_pinning_its_after` +is a strict-xfail on your branch (fixtures `system_built_{ewi,iwi}_001431_{before,after}.pdf` +are committed there, not here). With this fix the behaviour it guards is +satisfied, so it should flip xfail→xpass — delete the marker when it does. From 0476b4b235cb649134ebdac195967ca3434ce6f9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 20:39:15 +0000 Subject: [PATCH 64/80] S0380.230: electric room heaters (cat 10) on off-peak bill at Table 12a direct-acting high rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 12a Grid 1 (PDF p.191): an electric room heater (RdSAP main_heating_category 10, e.g. SAP code 691) is direct-acting electric, so it sits on the "Other systems including direct-acting electric" row — 7-hour high-rate fraction 1.00, 10-hour 0.50. It runs on demand, mostly at the HIGH rate; it does NOT earn the 100%-low-rate of overnight storage charging (which is category 7). `_table_12a_system_for_main` only mapped ASHP, so an electric room heater fell through to the "100% low-rate" fallback (5.50 p, £0.0550), under- charging space heating by ~9.79 p/kWh and systematically OVER-rating the cluster. Now maps electric cat-10 mains to OTHER_DIRECT_ACTING_ELECTRIC (gated on `_is_electric_main`, so gas/solid-fuel cat-10 room heaters are excluded). The same Table 12a fraction flows through cost, CO2 (Table 12d) and PE (Table 12e) — all three callers already pre-gate on electric. Mirror of S0380.228 (same fallback bug for electric SECONDARY heating). 1,000-cert 2026 API sample (no worksheet for this cluster — ±0.5-vs-lodged fallback bar): cat-10 mean |err| 9.49 → 7.11, %<0.5 10.4% → 16.7%; headline %<0.5 42.5% → 42.9%, overall mean |err| 2.29 → 2.16. cat-7 (storage) and cat-2 (gas) unchanged. Full §4 suite green (2405 passed). Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 10 ++++ .../rdsap/test_cert_to_inputs.py | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 58a08efd..93af44e6 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2138,6 +2138,16 @@ def _table_12a_system_for_main( main.main_heating_index_number is not None and heat_pump_record(main.main_heating_index_number) is not None ) + # Electric room heaters (RdSAP main_heating_category 10) are direct- + # acting electric → SAP 10.2 Table 12a Grid 1 (PDF p.191) "Other + # systems including direct-acting electric" row (7-hour high-rate + # fraction 1.00, 10-hour 0.50). Distinct from electric STORAGE + # heaters (category 7), which charge off-peak and correctly fall + # through to None here (→ 100% low rate). Gated on `_is_electric_main` + # so a non-electric room heater (gas / solid-fuel cat 10) is excluded; + # all callers already pre-gate on electric, this is belt-and-braces. + if main.main_heating_category == 10 and _is_electric_main(main): + return Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC # ASHP — Table 4a rows 211-217 (earlier generations) + 221-227 # (2013+) cover the air-source space. Warm-air ASHPs are 521-524. if code is not None and ( diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index fc95112c..c0d9d685 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -2938,6 +2938,55 @@ def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> assert abs(cost_eighteen_hour - 0.0741) <= 1e-6 +def test_space_heating_electric_room_heater_off_peak_bills_at_direct_acting_high_rate() -> None: + # Arrange — an ELECTRIC room heater (RdSAP main_heating_category 10, + # e.g. SAP code 691) is direct-acting electric, so SAP 10.2 Table 12a + # Grid 1 (PDF p.191) puts it on the "Other systems including direct- + # acting electric" row: 7-hour high-rate fraction 1.00, 10-hour 0.50. + # Unlike STORAGE heaters (category 7), which charge off-peak and so + # correctly bill 100% at the low rate, a room heater runs on demand — + # mostly at the HIGH rate. `_table_12a_system_for_main` only mapped + # ASHP, so a room heater fell through to the "100% low-rate" fallback + # (5.50 p, £0.0550), under-charging space heating by ~9.79 p/kWh and + # systematically OVER-rating the cat-10 cluster (1,000-cert API sample: + # 48 certs, mean |err| 9.49, signed +5.08). The fix maps electric + # cat-10 mains to OTHER_DIRECT_ACTING_ELECTRIC. Mirror of S0380.228 + # (which fixed the same fallback for electric SECONDARY heating). + from domain.sap10_calculator.tables.table_12a import Tariff + electric_room_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # standard electricity + heat_emitter_type=2, + emitter_temperature=1, + main_heating_control=2602, + main_heating_category=10, # electric room heaters + sap_main_heating_code=691, + ) + gas_room_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=1, # mains gas — NOT electric + heat_emitter_type=2, + emitter_temperature=1, + main_heating_control=2602, + main_heating_category=10, # gas room heater (also cat 10) + sap_main_heating_code=631, + ) + + # Act — 7-hour off-peak tariff. + electric_rate = _space_heating_fuel_cost_gbp_per_kwh( + electric_room_heater_main, Tariff.SEVEN_HOUR, prices=SAP_10_2_SPEC_PRICES, + ) + gas_rate = _space_heating_fuel_cost_gbp_per_kwh( + gas_room_heater_main, Tariff.SEVEN_HOUR, prices=SAP_10_2_SPEC_PRICES, + ) + + # Assert — electric room heater: 1.00 × 15.29 p = £0.1529 (high rate). + # Gas room heater is unaffected (non-electric → single Table 32 rate, + # not the off-peak electric split). + assert abs(electric_rate - 0.1529) <= 1e-6 + assert abs(gas_rate - 0.0550) > 1e-6 + + def test_heat_network_dlf_full_table_12c_age_band_coverage() -> None: # Arrange — SAP 10.2 Table 12c (page 193) heat-network Distribution # Loss Factor by dwelling age band A..M. None → K-or-newer From 3684a142ac71ba9cb5ecf0f8774ae876bda14cd5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 20:51:09 +0000 Subject: [PATCH 65/80] =?UTF-8?q?S0380.231:=20Dual-meter=20electric=20room?= =?UTF-8?q?=20heaters=20resolve=20to=2010-hour=20tariff=20(RdSAP=2010=20?= =?UTF-8?q?=C2=A712=20Rule=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §12 (PDF p.62) Dual-meter dispatch: "the choice between 7-hour and 10-hour is made by the main heating type ... if the main system is a direct-acting electric boiler (191), or electric room heaters ... it is 10-hour tariff." The electric room-heater codes — Table 4a 691 (panel/ convector/radiant), 692 (fan), 693 (portable), 694 (water-/oil-filled), 699 (assumed) — were missing from `_RULE_3_TEN_HOUR_CODES` (the long- standing TODO there), so a Dual-meter room-heater cert fell through to Rule 4 (7-hour default). Compounded with S0380.230 (which routes room heaters to Table 12a OTHER_DIRECT_ACTING_ELECTRIC): at 7-hour the high-rate fraction is 1.00 (all at 15.29 p), but at the correct 10-hour it is 0.50 split over the 10-hour rates (14.68 / 7.50 p) → blended ~11 p. Without this fix .230 over-charged and flipped the cluster from over- to under-rating. 1,000-cert 2026 API sample: cat-10 mean |err| 7.11 → 5.26, signed mean +5.08 → -0.86 (now balanced, 22 over / 26 under — the systematic directional bias is gone). Overall mean |err| 2.16 → 2.04. Full §4 suite green (2406 passed). Co-Authored-By: Claude Opus 4.8 --- domain/sap10_calculator/tables/table_12a.py | 9 +++++--- .../domain/sap10_calculator/test_table_12a.py | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/domain/sap10_calculator/tables/table_12a.py b/domain/sap10_calculator/tables/table_12a.py index f98e1c27..2ee7c331 100644 --- a/domain/sap10_calculator/tables/table_12a.py +++ b/domain/sap10_calculator/tables/table_12a.py @@ -250,13 +250,16 @@ _RULE_2_STORAGE_CODES: Final[frozenset[int]] = frozenset( # Rule 3: direct-acting electric + heat pumps + electric room heaters # → 10-hour. §12 lists "heat pump (211 to 224, 521 to 524, or # database)" — the "database" branch fires when the cert lodges a -# PCDB Table 362 heat-pump index regardless of SAP code. +# PCDB Table 362 heat-pump index regardless of SAP code. §12 also +# names "electric room heaters" verbatim (RdSAP 10 PDF p.62) — Table 4a +# electric room-heater codes 691 (panel/convector/radiant), 692 (fan), +# 693 (portable), 694 (water-/oil-filled), 699 (assumed). Without these +# a Dual-meter room-heater cert fell through to Rule 4 (7-hour default). _RULE_3_TEN_HOUR_CODES: Final[frozenset[int]] = frozenset( [191] # direct-acting electric boiler + list(range(211, 225)) # heat pumps 211-224 + list(range(521, 525)) # warm-air heat pumps 521-524 - # TODO: electric room heater codes (SAP Table 4a row 6xx for - # electric panel / radiant heaters) when a fixture surfaces them. + + [691, 692, 693, 694, 699] # electric room heaters (Table 4a) ) diff --git a/tests/domain/sap10_calculator/test_table_12a.py b/tests/domain/sap10_calculator/test_table_12a.py index 2b294ca2..a15cb2ed 100644 --- a/tests/domain/sap10_calculator/test_table_12a.py +++ b/tests/domain/sap10_calculator/test_table_12a.py @@ -17,12 +17,35 @@ from domain.sap10_calculator.tables.table_12a import ( Table12aSystem, Tariff, other_use_high_rate_fraction, + rdsap_tariff_for_cert, space_heating_high_rate_fraction, tariff_from_meter_type, water_heating_high_rate_fraction, ) +def test_dual_meter_electric_room_heater_resolves_to_ten_hour_tariff() -> None: + # Arrange — RdSAP 10 §12 (PDF p.62) Dual-meter tariff dispatch: for a + # Dual meter the choice between 7-hour and 10-hour is made by the main + # heating type. Rule 3 verbatim: "if the main system ... is a direct- + # acting electric boiler (191), or electric room heaters ... it is + # 10-hour tariff." The electric room-heater codes are Table 4a 691 + # (panel/convector/radiant), 692 (fan), 693 (portable), 694 (water-/ + # oil-filled), 699 (assumed). Pre-slice these fell through to Rule 4 + # (7-hour default), so a Dual-meter room-heater cert was billed at the + # 7-hour rates with Table 12a high-rate fraction 1.00 instead of the + # 10-hour rates with fraction 0.50 — over-charging direct-acting heat + # once S0380.230 routed it to OTHER_DIRECT_ACTING_ELECTRIC. + + # Act / Assert — every electric room-heater code on a Dual meter → 10-hour. + for code in (691, 692, 693, 694, 699): + assert rdsap_tariff_for_cert(1, main_1_sap_code=code) is Tariff.TEN_HOUR + # Storage heaters (Rule 2) stay 7-hour; a gas room heater (non-Rule-3 + # code) keeps the Rule 4 Dual default of 7-hour. + assert rdsap_tariff_for_cert(1, main_1_sap_code=401) is Tariff.SEVEN_HOUR + assert rdsap_tariff_for_cert(1, main_1_sap_code=601) is Tariff.SEVEN_HOUR + + def test_tariff_enum_has_five_members() -> None: """Table 12a columns: standard (no off-peak split), 7-hour, 10-hour, 18-hour, 24-hour. Worksheet-shape fidelity: TEN_HOUR is included for From f326e4eb530e90ba072c8fed9007bfea611a9312 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 21:16:20 +0000 Subject: [PATCH 66/80] mapper: Elmhurst path populates roof_construction (int) for cross-mapper parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov-EPC API mapper sets BOTH roof_construction (int) and roof_construction_type (str, derived via _API_ROOF_CONSTRUCTION_TO_STR), but the Elmhurst mapper set only the string — leaving roof_construction None on every site-notes cert. The SAP cascade reads the STRING (so SAP cross-mapper parity always held), but consumers of the int (e.g. domain/sap10_ml/transform.py ML aggregates `main_dwelling_roof_ construction`) silently saw None on the Elmhurst path. New `_elmhurst_roof_construction_int` maps the Elmhurst roof-type code to the same SAP10 int the API lodges (F→1, PN→3, PA→4, PS→8, S/A→7), harvested from the committed Summary fixtures. Unlike the wall map it returns None (not a strict-raise) for unmapped codes: the int is not cascade-load-bearing, so an unknown roof must not block the cert (vaulted 5 / thatched 6 / NR omitted until a fixture surfaces them). The 6 hand-built U985 reference fixtures gain the matching roof_construction int (4/4/3 etc.) so test_from_elmhurst_site_notes_ matches_hand_built_* still asserts structural parity. SAP output is unchanged (cascade reads the string). §4 suite green (2407 passed); the two pre-existing stone-§5.6 sap10_ml failures are unrelated/out of scope. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_summary_pdf_mapper_chain.py | 26 ++++++++++++++ datatypes/epc/domain/mapper.py | 35 +++++++++++++++++++ .../worksheet/_elmhurst_worksheet_000474.py | 6 ++++ .../worksheet/_elmhurst_worksheet_000477.py | 2 ++ .../worksheet/_elmhurst_worksheet_000480.py | 4 +++ .../worksheet/_elmhurst_worksheet_000487.py | 4 +++ .../worksheet/_elmhurst_worksheet_000490.py | 4 +++ .../worksheet/_elmhurst_worksheet_000516.py | 2 ++ 8 files changed, 83 insertions(+) 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 216af22c..88ab7f14 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -4361,6 +4361,32 @@ def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> Non assert code == 1 +def test_elmhurst_roof_construction_int_matches_api_codes() -> None: + # Arrange — cross-mapper structural parity: the gov-EPC API mapper + # populates BOTH roof_construction (int) and roof_construction_type + # (str derived via `_API_ROOF_CONSTRUCTION_TO_STR`), but the Elmhurst + # mapper set only the string, leaving the int None. The SAP cascade + # reads the string (so SAP parity held), but consumers of the int + # (e.g. domain/sap10_ml ML aggregates) saw None on every site-notes + # cert. `_elmhurst_roof_construction_int` closes the gap, mapping the + # Elmhurst roof code to the same SAP10 int the API lodges. Unmapped + # codes return None (not a raise) — the int is not cascade-load- + # bearing, so an unknown roof must not block the cert. + from datatypes.epc.domain.mapper import _elmhurst_roof_construction_int # pyright: ignore[reportPrivateUsage] + + # Act / Assert — each Elmhurst roof code → the gov-EPC API int. + assert _elmhurst_roof_construction_int("F Flat") == 1 + assert _elmhurst_roof_construction_int("PN Pitched (slates/tiles), no access") == 3 + assert _elmhurst_roof_construction_int("PA Pitched (slates/tiles), access to loft") == 4 + assert _elmhurst_roof_construction_int("PS Pitched, sloping ceiling") == 8 + assert _elmhurst_roof_construction_int("S Same dwelling above") == 7 + assert _elmhurst_roof_construction_int("A Another dwelling above") == 7 + # Absent / unmapped → None (no raise; not cascade-load-bearing). + assert _elmhurst_roof_construction_int(None) is None + assert _elmhurst_roof_construction_int("") is None + assert _elmhurst_roof_construction_int("NR Non-residential space above") is None + + def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() -> None: # Arrange — "SY System build" and "B Basement wall" both map to SAP10 # wall_construction=6 (canonical WALL_SYSTEM_BUILT). The explicit diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 57edae7a..2c272975 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2314,6 +2314,40 @@ def _elmhurst_dwelling_type( return f"{position} flat" +# Elmhurst roof-type codes → SAP10 roof_construction integer, matching the +# gov-EPC API codes in `_API_ROOF_CONSTRUCTION_TO_STR` so the two +# front-ends populate the same field. Harvested from the committed +# Elmhurst Summary fixtures (corpus + cohort): F/PN/PA/PS/S/A. Vaulted (5) +# and thatched (6) are omitted until a fixture surfaces their Elmhurst +# codes. NR ("Non-residential space above") is intentionally left +# unmapped — the gov enum's code 7 is specifically "dwelling above". +_ELMHURST_ROOF_CODE_TO_SAP10: Dict[str, int] = { + "F": 1, # Flat + "PN": 3, # Pitched (slates/tiles), no access (to loft) + "PA": 4, # Pitched (slates/tiles), access to loft + "PS": 8, # Pitched, sloping ceiling + "S": 7, # Same dwelling above + "A": 7, # Another dwelling above +} + + +def _elmhurst_roof_construction_int(coded: Optional[str]) -> Optional[int]: + """Map an Elmhurst roof_type string ('PA Pitched (slates/tiles), + access to loft') to the SAP10 `roof_construction` integer the gov-EPC + API lodges (4), so the site-notes and API front-ends populate the + same field (cross-mapper structural parity). + + Returns None for an absent or unmapped code — and, unlike + `_elmhurst_wall_construction_int`, does NOT raise. `roof_construction` + (int) is not read by the SAP cascade (which reads the string + `roof_construction_type`, populated on both paths), so an unmapped + roof code stays None — the pre-existing Elmhurst behaviour — rather + than blocking the cert.""" + if not coded: + return None + return _ELMHURST_ROOF_CODE_TO_SAP10.get(_leading_code(coded)) + + def _elmhurst_wall_construction_int(coded: str) -> Optional[int]: """Map an Elmhurst wall_type string ('CA Cavity') to the SAP10 integer code (4). Returns None when the lodging is absent (empty @@ -3494,6 +3528,7 @@ def _map_elmhurst_building_part( if walls.insulation_thickness_mm is not None else None ), + roof_construction=_elmhurst_roof_construction_int(roof.roof_type), roof_construction_type=_strip_code(roof.roof_type), roof_insulation_location=_strip_code(roof.insulation), roof_insulation_thickness=_resolve_sloping_ceiling_thickness( diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py index 1c7f4787..531381b9 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000474.py @@ -65,6 +65,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, @@ -98,6 +100,8 @@ def build_epc() -> EpcPropertyData: ) extension_1 = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_1, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, @@ -130,6 +134,8 @@ def build_epc() -> EpcPropertyData: ) extension_2 = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_2, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=3, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py index a73f047b..f87730d9 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000477.py @@ -63,6 +63,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py index 145ef3d0..490091da 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000480.py @@ -64,6 +64,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, @@ -133,6 +135,8 @@ def build_epc() -> EpcPropertyData: ) extension = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_1, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py index a5cbe9b4..4c82aa7f 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000487.py @@ -60,6 +60,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, # "A As Built" @@ -130,6 +132,8 @@ def build_epc() -> EpcPropertyData: ) extension = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_1, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py index f06194a4..a9e26137 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000490.py @@ -65,6 +65,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, @@ -97,6 +99,8 @@ def build_epc() -> EpcPropertyData: ) extension = SapBuildingPart( identifier=BuildingPartIdentifier.EXTENSION_1, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=4, construction_age_band="B", wall_construction=_WC_CAVITY, wall_insulation_type=4, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py index c561b353..2745b20a 100644 --- a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_000516.py @@ -69,6 +69,8 @@ def build_epc() -> EpcPropertyData: """ main = SapBuildingPart( identifier=BuildingPartIdentifier.MAIN, + # API parity: roof_construction int mirrors the gov-EPC mapper + roof_construction=3, construction_age_band="A", wall_construction=_WC_CAVITY, wall_insulation_type=4, From 2a29b29aa5485ef70379ecdb4ccb0a7a46e02333 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 21:23:07 +0000 Subject: [PATCH 67/80] =?UTF-8?q?docs:=20handover=20=E2=80=94=20fold=20in?= =?UTF-8?q?=20S0380.227-231=20+=20mapper=20parity=20work;=20refresh=20erro?= =?UTF-8?q?r=20landscape=20+=20worksheet=20asks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_API_SAMPLE_ACCURACY.md | 89 +++++++++++++++---- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md index 2aa3ff68..b47ff6ed 100644 --- a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md +++ b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md @@ -4,10 +4,50 @@ Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodolog 1e-4 bar, the per-line debugging loop, the section helpers, and the suite command. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `0f6b4023` (S0380.229). Next slice: **S0380.230**. +- **HEAD:** `f326e4eb`. Next SAP slice: **S0380.232**. - **Baseline (§4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/` - → green (2397 passed, 1 skipped). Pre-existing out-of-scope failures unchanged + → green (2407 passed, 1 skipped). Pre-existing out-of-scope failures unchanged (stone-§5.6 in `domain/sap10_ml/tests/`; `test_from_rdsap_schema.py::...test_total_floor_area`). + +## Headline now (1,000-cert 2026 API sample, HEAD `f326e4eb`) + +| metric | value | was (handover baseline `9c0a373f`) | +|---|---|---| +| computed | 882 / 1000 | 882 | +| **% \|err\| < 0.5** | **42.9%** | 41.8% | +| % < 1 / < 2 / < 5 | 56.7% / 74.6% / 90.1% | 54.9 / 71.9 / 87.8 | +| median / mean \|err\| | 0.73 / **2.04** | 0.79 / ~2.4 | +| mean signed | −0.41 | +0.2 | + +**Error by heating cluster** (the load-bearing cut — re-run `analyse_api_sap_clusters.py`): + +| cluster | n | mean \|err\| | %<0.5 | note | +|---|---|---|---|---| +| cat 2 gas boiler + PCDB | 639 | 1.27 | 49.6% | well-trodden | +| cat 2 gas, NO PCDB idx | 91 | 3.18 | 35.2% | non-PCDB Table-4b boilers | +| cat 6 community | 45 | 2.59 | 31.1% | known-hard | +| cat 7 electric storage | 40 | **5.25** | 10.0% | was 7.33 → S0380.227-229 | +| cat 10 electric room heaters | 48 | **5.26** | 16.7% | was 9.49 → S0380.230-231 (bias gone) | +| cat 4 HP + PCDB | 8 | 6.11 | 12.5% | small n, APM | +| Flats (any) | 282 | 2.57 | 30.5% | geometry / communal | +| real PV | 45 | 3.90 | 26.7% | Appendix M | + +**Worst individual offenders** (the long tail — `eval` TOP 40): `2100-5421-0922-1622-3463` +(−60.8, our SAP **negative** −24.8 vs lodged 36 — a flat, 2 bps, cat-2; the single worst, likely +a geometry/communal blow-up — START a per-cert dig here), `2958-8008` (+32, age 6=tiny), +`9836-5829` (−29.5, cat-10 tail), several cat-7/cat-10 in the −20s. + +## Work shipped (this session — S0380.227-231 + 3 mapper commits) + +| commit | what | +|---|---| +| **S0380.227** | dedicated DHW-only system (WHS 911) is NOT separately timed → no Table 2b ×0.9; TF (53) 0.54→0.60, (59) h=3→5 | +| **S0380.228** | electric SECONDARY on off-peak bills at Table 12a `OTHER_DIRECT_ACTING_ELECTRIC` (1.00 high-frac), not 100% low | +| **S0380.229** | dedicated water boiler/circulator (WHC 911-931) feeds cylinder via primary loop → Table 3 primary loss applies | +| **S0380.230** | electric room heaters (cat 10) on off-peak → `OTHER_DIRECT_ACTING_ELECTRIC` (mirror of .228 for the MAIN). cat-10 9.49→7.11 | +| **S0380.231** | Dual-meter electric room heaters → 10-hour tariff (RdSAP §12 Rule 3; codes 691-694,699). cat-10 7.11→5.26, bias +5.08→−0.86 | +| `bd25a3c7` | SY system-built vs B basement: code 6 stays system-built; basement → explicit `wall_is_basement`/`is_basement` flag. `system_build` is a derived property (wall type). API path post-processes via addendum. (issue #1177 — see `docs/PR_NOTE_system_built_basement_1177.md`: field-vs-property merge landmine) | +| `f326e4eb` | Elmhurst path now populates `roof_construction` (int) via `_elmhurst_roof_construction_int` for cross-mapper parity (API set it, Elmhurst didn't) | - **Toolkit (committed):** `scripts/fetch_2026_epc_sample.py`, `scripts/eval_api_sap_accuracy.py`, `scripts/analyse_api_sap_clusters.py`. The 1,000 cached JSONs live in `/tmp/epc_2026_sample/` (gitignored scratch — re-fetch with the sampler; @@ -103,7 +143,7 @@ PDF — only in the P960 header). | **S0380.228** | cost (255) | electric SECONDARY on off-peak bills at Table 12a `OTHER_DIRECT_ACTING_ELECTRIC` (7-hr high-frac **1.00** = £0.1529), not the flat off-peak low (£0.0550). Worksheet (242): "1.00*15.29 + 0.00*5.50". THE primary cost driver (−340). | 60.1→**50.67** | | **S0380.229** | (62) 2493.30→**3169.98** | dedicated water-heating boiler/circulator (WHC 911-931) feeds the cylinder via a primary loop → Table 3 row 1 primary loss applies (keyed off `water_heating_code`, since `_water_heating_main` returns the electric SPACE main). Restored the missing (59)=676.68 kWh/yr. | 50.67→50.33 | -**The ONE remaining case-19 cause — the PV diverter (63b) — is S0380.230 (next).** Worksheet +**The ONE remaining case-19 cause — the PV diverter (63b) — is S0380.232.** Worksheet header line 124 "Diverter = Yes"; Summary §19 "Diverter present: Yes". Per **SAP 10.2 Appendix G4 (PDF p.72-73)** surplus PV is diverted to the cylinder immersion: `S_PV,diverter,m = EPV,m × (1 − βm) × 0.8 × 0.9`, clamped to ≤ (62)m + (63a)m, entered as a @@ -134,15 +174,22 @@ pages → ElmhurstSiteNotesExtractor(...).extract() → from_elmhurst_site_notes ## Remaining work, prioritised -### A. Accuracy clusters (highest value — 80+ certs, mean err 7–10) -1. **Electric storage heaters (cat 7, 39 certs).** Distinct cascade — off-peak tariff split, - charge control (2401/2402), 7-hr/24-hr charge, Table 4a efficiency, responsiveness. **No - worksheet currently validates this path.** Errs both directions (−27..+16). -2. **Electric room heaters (cat 10, 43 certs).** Likewise (controls 2601/2602/2603). Worst - cluster by mean (10.26). -3. **Flats (242, 29% <0.5)** and **PV (40, 28%)** — secondary. +### A. Accuracy clusters (highest value) +1. **PV diverter (S0380.232)** — closes case 19 to 1e-4 AND helps the real-PV cluster (45 certs, + mean 3.90). Fully spec'd in the case-19 section above (Appendix G4). **Has a worksheet** → + 1e-4 bar. Do this first: it's the one open cause on a validated worksheet. +2. **Electric storage heaters (cat 7, 40 certs, mean 5.25).** S0380.227-229 took it 7.33→5.25; + the case-19 PV diverter will help further. Beyond that the tail is per-cert — a **dedicated + cat-7 worksheet** (no PV, no diverter) would let you pin charge-control / responsiveness at + 1e-4 instead of the ±0.5 lodged fallback. +3. **Electric room heaters (cat 10, 48 certs, mean 5.26).** S0380.230-231 fixed the systematic + tariff bias (mean 9.49→5.26, signed +5.08→−0.86); the residual is now scattered per-cert + (e.g. `9836-5829` −29.5, an under-rater). A **cat-10 worksheet** pins the tail at 1e-4. +4. **Non-PCDB gas boilers (cat 2, no idx, 91 certs, mean 3.18)** and **Flats (282, mean 2.57)** — + the next volume levers once the electric clusters are worksheet-pinned. Flats = geometry / + communal; start with the worst (`2100-5421` negative SAP). -### B. Remaining raises (18 certs — all U-value / heat-loss-sensitive, NOT enum guesses) +### B. Remaining raises (16 certs — all U-value / heat-loss-sensitive, NOT enum guesses) - **`gable_wall_type` 2 & 3 (14 certs).** RdSAP 10 **Table 4** RR walls: 0=Party (U=0.25), 1=Exposed (U=common wall), 2/3 = **Sheltered (U=external×R0.5)** + **Adjacent-to-heated (U=0)**, code↔type order unconfirmed (schema says "not yet seen"). Needs (i) a worksheet to @@ -161,16 +208,24 @@ is what the strict-raise guard exists to prevent. --- -## ★ Additional worksheets that would help most +## ★ Additional worksheets that would help most (the user will generate these on request) -Case 19 (above) already covers electric storage heaters + loose-jacket cylinder + RR. The two -that would add the most NEW coverage: -1. **An electric ROOM-heater dwelling** (SAP code ~691, control 2601/2602) — the **cat-10 - cluster (43 certs, worst by mean error 10.26)**, which case 19 does not touch. -2. **A room-in-roof with a SHELTERED gable and an ADJACENT-TO-HEATED gable** (Table 4 types +The two electric clusters are now systematic-bias-free (S0380.227-231) but their TAILS sit at +the ±0.5-vs-lodged fallback bar because **no worksheet validates them at 1e-4**. The three +highest-value worksheets to ask the user for: +1. **An electric ROOM-heater dwelling** (SAP code ~691, control 2601/2602/2603, Dual meter) — + pins the cat-10 tail (48 certs, mean 5.26) at 1e-4. Make it PV-free + cylinder-free to + isolate the space-heat path from the diverter/HW. +2. **An electric STORAGE-heater dwelling distinct from case 19** (no PV, no WHS-911) — pins the + cat-7 tail (40 certs, mean 5.25): charge control (2401/2402), 7-hr vs 24-hr, responsiveness. +3. **A room-in-roof with a SHELTERED gable and an ADJACENT-TO-HEATED gable** (Table 4 types beyond Party/Exposed) — closes the `gable_wall_type` 2/3 raise (14 certs) and pins the Sheltered (U=ext×R0.5) / Adjacent (U=0) U-values the calculator must add. +Per worksheet send BOTH the **Summary PDF** (input) and the **P960/dr87 worksheet PDF** (the +`(1)..(286)` ground truth). Drop them in `sap worksheets/golden fixture debugging//` and +run the case-19 debug recipe. + The original "design one property" guidance (kept below for reference) is what case 19 was built from. From 212b0c92ab5b6f938c050c7c43fb5fe7b5d29c34 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 22:07:04 +0000 Subject: [PATCH 68/80] S0380.232: D_PV excludes low-rate portion of off-peak electric main heating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix M1 §3a (PDF p.93, lines 5470-5476): "E_space,m = (211)m + (213)m + (215)m, where (211), (213) and/or (215) should be included only where the fuel code applied to them in Section 10a of the SAP worksheet is 30, 32, 34, 35 or 38 (i.e. electricity not at the low-rate)." The PV-eligible demand D_PV,m was adding 100% of the main space-heating fuel (211)m whenever the main's Table-12 code was in the eligible set (30, …), ignoring the off-peak high/low split that §10a already bills via `_space_heating_fuel_cost_gbp_per_kwh`. Electric STORAGE heaters on a 7-hour tariff are charged wholly at the low rate (Table 12a Grid 1 SH fraction 0.00; worksheet (240) high-rate cost = 0), so none of (211) may enter D_PV — but the cascade counted it all, inflating R_PV,m = E_PV,m / D_PV,m and therefore the β onsite-PV split in the heating months. Fix mirrors the cost-side rate split: `_main_space_heating_high_rate_ fraction(main, tariff)` returns the high-rate portion (1.0 for non-electric / STANDARD, the published Grid 1 SH fraction otherwise, 0.0 when the Grid 1 SH row is unwired → 100% low rate), and `_pv_eligible_demand_monthly_kwh` scales the (211)m contribution by it. Backward-compatible: STANDARD-tariff electric mains and the gas-main / electric-secondary PV cohort are unchanged (fraction 1.0). On simulated case 19 (electric storage heaters, 7-hour, PV) this takes β_Jan 0.894 → 0.792, matching the worksheet 0.791, and the summer months (no main heating) already pinned exactly. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 56 +++++++++++- .../rdsap/test_cert_to_inputs.py | 86 +++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 93af44e6..a2b988c9 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2183,6 +2183,41 @@ def _space_heating_fuel_cost_gbp_per_kwh( return blended * _PENCE_TO_GBP +def _main_space_heating_high_rate_fraction( + main: Optional[MainHeatingDetail], + tariff: Tariff, +) -> float: + """SAP 10.2 Appendix M1 §3a (PDF p.93) — the fraction of the main + space-heating fuel that is billed at the HIGH rate in Section 10a, + i.e. carries an "electricity not at the low-rate" fuel code (30, 32, + 34, 35 or 38). Only this high-rate portion of E_space,m may enter the + PV-eligible demand D_PV,m; the low-rate portion (code 31/33/36/37/39) + is excluded. + + Mirrors `_space_heating_fuel_cost_gbp_per_kwh`'s rate split exactly so + the D_PV inclusion and the §10a billing stay consistent: + - non-electric main, or STANDARD tariff → 1.0 (no off-peak split; + the eligible-code gate in `_pv_eligible_demand_monthly_kwh` + already excludes non-electric fuels, and a STANDARD-tariff + electric main bills 100% at code 30). + - electric main on an off-peak tariff whose Table 12a Grid 1 SH row + is wired → the published high-rate fraction. Electric STORAGE + heaters (Table 12a `_table_12a_system_for_main` → None, charged + wholly off-peak) and any system whose Grid 1 SH row is not yet + wired bill 100% at the low rate → fraction 0.0, so E_space,m is + excluded from D_PV entirely (worksheet (240) high-rate cost = 0). + """ + if not _is_electric_main(main) or tariff is Tariff.STANDARD: + return 1.0 + system = _table_12a_system_for_main(main) + if system is None: + return 0.0 + try: + return space_heating_high_rate_fraction(system, tariff) + except NotImplementedError: + return 0.0 + + def _hot_water_fuel_cost_gbp_per_kwh( water_heating_fuel: Optional[int], main: Optional[MainHeatingDetail], @@ -2474,6 +2509,7 @@ def _pv_eligible_demand_monthly_kwh( main_fuel_code_table_12: Optional[int], secondary_fuel_code_table_12: Optional[int], water_heating_fuel_code_table_12: Optional[int], + main_space_high_rate_fraction: float = 1.0, ) -> tuple[float, ...]: """SAP 10.2 Appendix M1 §3a (p.93) — monthly PV-eligible demand D_PV,m. Always includes lighting + appliances + cooking + electric @@ -2482,6 +2518,18 @@ def _pv_eligible_demand_monthly_kwh( (codes 30, 32, 34, 35, 38 per spec). Includes E_water,m only when the water heating fuel code is 30 (standard electricity) per spec. + `main_space_high_rate_fraction` scales the main-heating contribution + by the portion billed at the HIGH rate (code 30) in Section 10a. + Per the §3a inclusion rule "(211) should be included only where the + fuel code applied to it in Section 10a is 30, 32, 34, 35 or 38 (i.e. + electricity not at the low-rate)", off-peak electric mains (e.g. + storage heaters charged wholly at the low rate, fraction 0.0) must + NOT add their (211) to D_PV. Defaults to 1.0 → unchanged for + STANDARD-tariff electric mains and the gas-main / electric-secondary + cohort. Without this, off-peak storage-heater dwellings over-counted + D_PV by the full (211) in winter, inflating R_PV,m → β → the onsite + PV split (case 19: β_Jan 0.894 → 0.792, matching worksheet 0.791). + Secondary space heating is included on the same footing as main: Appendix M1 §3a counts E_space,m as the dwelling's total electric space-heating demand, which for a gas-main / electric-secondary @@ -2516,7 +2564,7 @@ def _pv_eligible_demand_monthly_kwh( + pumps_fans_monthly_kwh[m] ) if include_main_space: - d += main_1_fuel_monthly_kwh[m] + d += main_space_high_rate_fraction * main_1_fuel_monthly_kwh[m] if include_secondary_space: d += secondary_fuel_monthly_kwh[m] if include_water: @@ -6721,6 +6769,12 @@ def cert_to_inputs( ) if epc.sap_heating.water_heating_fuel is not None else None ), + # SAP 10.2 Appendix M1 §3a — exclude the low-rate portion of an + # off-peak electric main from D_PV (the §10a high/low split that + # `_space_heating_fuel_cost_gbp_per_kwh` already bills). + main_space_high_rate_fraction=_main_space_heating_high_rate_fraction( + main, _rdsap_tariff(epc), + ), ) pv_split = pv_split_monthly( epv_monthly_kwh=pv_monthly_kwh, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index c0d9d685..bde4bb44 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -59,7 +59,9 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _is_electric_water, # pyright: ignore[reportPrivateUsage] _is_off_peak_meter, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] + _main_space_heating_high_rate_fraction, # pyright: ignore[reportPrivateUsage] _other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + _pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage] _primary_loss_applies, # pyright: ignore[reportPrivateUsage] _rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage] _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] @@ -1766,6 +1768,90 @@ def test_other_fuel_cost_for_18_hour_tariff_uses_18_hour_high_rate() -> None: ) +def test_main_space_high_rate_fraction_zero_for_off_peak_storage_heaters() -> None: + # Arrange — SAP 10.2 Appendix M1 §3a (PDF p.93): E_space,m (211) is + # included in D_PV "only where the fuel code applied to it in Section + # 10a is 30, 32, 34, 35 or 38 (i.e. electricity not at the low-rate)". + # Electric STORAGE heaters (code 402) on a 7-hour off-peak tariff are + # charged wholly at the low rate (Table 12a Grid 1 SH fraction 0.00 / + # `_table_12a_system_for_main` → None) — worksheet (240) high-rate + # cost = 0 — so none of (211) may enter D_PV. + from domain.sap10_calculator.tables.table_12a import Tariff + + storage_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2402, + main_heating_category=7, + sap_main_heating_code=402, + ) + + # Act + off_peak = _main_space_heating_high_rate_fraction( + storage_main, Tariff.SEVEN_HOUR + ) + standard = _main_space_heating_high_rate_fraction( + storage_main, Tariff.STANDARD + ) + gas = _main_space_heating_high_rate_fraction( + _gas_boiler_detail(sap_main_heating_code=102), Tariff.SEVEN_HOUR + ) + + # Assert + assert abs(off_peak - 0.0) <= 1e-9 + # STANDARD tariff has no high/low split → 100% high rate. + assert abs(standard - 1.0) <= 1e-9 + # Non-electric main never carries an off-peak split. + assert abs(gas - 1.0) <= 1e-9 + + +def test_pv_eligible_demand_excludes_low_rate_main_space_heating() -> None: + # Arrange — SAP 10.2 Appendix M1 §3a (PDF p.93). A main billed wholly + # at the low rate (high-rate fraction 0.0) must contribute zero to + # D_PV even though its Table-12 code (30) is in the eligible set; the + # secondary (also code 30) at its full high-rate fraction stays in. + main_1 = tuple(float(100 + m) for m in range(12)) + secondary = tuple(float(10 + m) for m in range(12)) + base = tuple(float(5) for _ in range(12)) # lighting et al. + + # Act + excluded = _pv_eligible_demand_monthly_kwh( + lighting_monthly_kwh=base, + appliances_monthly_kwh=(0.0,) * 12, + cooking_monthly_kwh=(0.0,) * 12, + electric_shower_monthly_kwh=(0.0,) * 12, + pumps_fans_monthly_kwh=(0.0,) * 12, + main_1_fuel_monthly_kwh=main_1, + secondary_fuel_monthly_kwh=secondary, + hot_water_monthly_kwh=(0.0,) * 12, + main_fuel_code_table_12=30, + secondary_fuel_code_table_12=30, + water_heating_fuel_code_table_12=26, # gas → no E_water + main_space_high_rate_fraction=0.0, + ) + included = _pv_eligible_demand_monthly_kwh( + lighting_monthly_kwh=base, + appliances_monthly_kwh=(0.0,) * 12, + cooking_monthly_kwh=(0.0,) * 12, + electric_shower_monthly_kwh=(0.0,) * 12, + pumps_fans_monthly_kwh=(0.0,) * 12, + main_1_fuel_monthly_kwh=main_1, + secondary_fuel_monthly_kwh=secondary, + hot_water_monthly_kwh=(0.0,) * 12, + main_fuel_code_table_12=30, + secondary_fuel_code_table_12=30, + water_heating_fuel_code_table_12=26, + main_space_high_rate_fraction=1.0, + ) + + # Assert — excluded drops the full (211); secondary stays in both. + for m in range(12): + assert abs(excluded[m] - (base[m] + secondary[m])) <= 1e-9 + assert abs(included[m] - (base[m] + secondary[m] + main_1[m])) <= 1e-9 + + def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None: # Arrange — RdSAP 10 §17 page 85 row 10-2 lodges 18-hour meter # as the bare "18-hour" or "18 Hour" form (Elmhurst Summary §14.2 From d4a8c02b543e649b609ee9d9a5a277bb7016679e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 22:57:40 +0000 Subject: [PATCH 69/80] S0380.233: PV self-consumption credited at Table 12a weighted import rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): "apply the normal import electricity price to PV energy used within the dwelling and the 'electricity sold to grid, PV' price from Table 12 to the energy exported. In the case of the former, use a weighted average of high and low rates (Table 12a)." `_pv_dwelling_import_price_gbp_per_kwh` was returning the bare off-peak LOW rate (5.50 p/kWh on a 7-hour tariff) for the PV-used-in-dwelling credit. PV self-consumption displaces the dwelling's "all other uses" electricity (lighting / appliances / pumps), which on an off-peak tariff bills at the Table 12a Grid 2 ALL_OTHER_USES weighted blend, not the low rate. On simulated case 19 the worksheet (252)/(269) credits PV-used-in-dwelling at 14.3110 p/kWh = 0.90 × 15.29 + 0.10 × 5.50; we credited it at 5.50, under-crediting onsite PV by ~£0.088/kWh on every off-peak PV cert. Fix delegates to `_other_fuel_cost_gbp_per_kwh(tariff, prices)` (the same ALL_OTHER_USES rate): STANDARD tariff still returns the flat Table 32 code 30 13.19 p/kWh (golden cohort unchanged — all 2412 tests pass); off-peak returns the weighted high/low blend. Call sites now pass the resolved `_rdsap_tariff(epc)`. The now-unused `_off_peak_low_rate_gbp_per_kwh_via_meter_heuristic` (its only caller) is removed. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 62 ++++++------------- .../rdsap/test_cert_to_inputs.py | 23 +++++++ 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index a2b988c9..6a4bfbba 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2082,23 +2082,6 @@ def _off_peak_low_rate_gbp_per_kwh(tariff: Tariff) -> float: return low * _PENCE_TO_GBP -def _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type: object) -> float: - """Off-peak low-rate £/kWh for callsites that detect off-peak via the - `_is_off_peak_meter` heuristic (RdSAP meter code 3 = Unknown is - treated as off-peak for electric end-uses; see _is_off_peak_meter - docstring). When the meter resolves to a known off-peak tariff - (codes 1/4/5), bills at that tariff's Table 32 low rate; when the - meter resolves to STANDARD (codes 2 = Single, 3 = Unknown), falls - back to the SEVEN_HOUR rate (5.50, Table 32 code 31). Codifies the - heuristic that pre-S0380.138 was baked into the literal - `prices.e7_low_rate_p_per_kwh` constant.""" - tariff = tariff_from_meter_type(meter_type) - if tariff is Tariff.STANDARD: - _high, low = _tariff_high_low_rates_p_per_kwh(Tariff.SEVEN_HOUR) - return low * _PENCE_TO_GBP - return _off_peak_low_rate_gbp_per_kwh(tariff) - - # Tariff → (high_rate_fuel_code, low_rate_fuel_code) for the SAP 10.2 # Table 12d (CO2) / Table 12e (PE) monthly factors. Mirror of the # Table 32 cost-rates dict above: 7-hour and 10-hour tariffs split into @@ -2660,30 +2643,25 @@ def _pv_export_credit_gbp_per_kwh() -> float: def _pv_dwelling_import_price_gbp_per_kwh( - meter_type: object, prices: PriceTable + tariff: Tariff, prices: PriceTable ) -> float: """PV dwelling-consumption price per kWh per SAP 10.2 Appendix M1 §6 - (p.94): "apply the normal import electricity price to PV energy used - within the dwelling". Onsite-consumed PV displaces grid IMPORTS, so - it bills at the standard electricity import tariff (Table 32 code 30 - under the RdSAP10 amendment per ADR-0010 §10 = 13.19 p/kWh — the - same rate `_fuel_cost`'s `other_uses_p_per_kwh` already pays for - lighting/pumps/fans, and crucially the same rate Table 32 code 60 - pays for the EXPORT credit. In Table 32 these collapse to a single - 13.19 p value, so the IMPORT/EXPORT split is mathematically - equivalent to the legacy single-rate-EXPORT credit — but the - distinction matters when an off-peak tariff lands: §6 then directs - a weighted Table 12a high/low rate, deferred until the first off- - peak cost cert ships.""" - if _is_off_peak_meter(meter_type, fuel_is_electric=True): - # Off-peak weighted Table 12a rate (deferred — `_fuel_cost` - # short-circuits Tariff != STANDARD before reaching this path). - # Routes through the meter-heuristic helper so an Unknown-meter - # cert (code 3 = "treat as off-peak for electric end-uses" per - # _is_off_peak_meter) falls back to the SEVEN_HOUR low rate - # rather than raising on STANDARD. - return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type) - return table_32_unit_price_p_per_kwh(30) * _PENCE_TO_GBP + (PDF p.94, lines 5510-5513): "apply the normal import electricity + price to PV energy used within the dwelling … In the case of the + former, use a weighted average of high and low rates (Table 12a)." + + Onsite-consumed PV displaces the dwelling's "all other uses" + electricity (lighting / appliances / pumps), so it bills at the same + Table 12a Grid 2 ALL_OTHER_USES rate `_other_fuel_cost_gbp_per_kwh` + derives — a STANDARD-tariff dwelling pays the flat Table 32 code 30 + 13.19 p/kWh (unchanged from the legacy single-rate path), while an + off-peak dwelling pays the weighted high/low blend (7-hour: + 0.90 × 15.29 + 0.10 × 5.50 = 14.311 p/kWh, matching worksheet + (252)/(269) "PV used in dwelling" on case 19). + + Pre-S0380.233 the off-peak branch returned the bare low rate + (5.50 p/kWh), under-crediting onsite PV on every off-peak cert.""" + return _other_fuel_cost_gbp_per_kwh(tariff, prices) def _other_fuel_cost_gbp_per_kwh( @@ -6137,9 +6115,7 @@ def _fuel_cost( pv_dwelling_kwh_per_yr=pv_dwelling_kwh_per_yr, pv_exported_kwh_per_yr=pv_exported_kwh_per_yr, pv_dwelling_import_price_gbp_per_kwh=( - _pv_dwelling_import_price_gbp_per_kwh( - epc.sap_energy_source.meter_type, prices - ) + _pv_dwelling_import_price_gbp_per_kwh(_rdsap_tariff(epc), prices) ), ) @@ -6976,7 +6952,7 @@ def cert_to_inputs( pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate), pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(), pv_dwelling_import_price_gbp_per_kwh=_pv_dwelling_import_price_gbp_per_kwh( - epc.sap_energy_source.meter_type, prices + _rdsap_tariff(epc), prices ), # SAP 10.2 Appendix M1 §3-4 PV split — the cascade applies # IMPORT PEF (Table 12) to the onsite portion and EXPORT PEF diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index bde4bb44..ad5b3553 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -61,6 +61,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _main_floor_u_value, # pyright: ignore[reportPrivateUsage] _main_space_heating_high_rate_fraction, # pyright: ignore[reportPrivateUsage] _other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + _pv_dwelling_import_price_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage] _primary_loss_applies, # pyright: ignore[reportPrivateUsage] _rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage] @@ -1852,6 +1853,28 @@ def test_pv_eligible_demand_excludes_low_rate_main_space_heating() -> None: assert abs(included[m] - (base[m] + secondary[m] + main_1[m])) <= 1e-9 +def test_pv_dwelling_import_price_blends_high_low_on_off_peak() -> None: + # Arrange — SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): PV + # used in the dwelling is credited at "a weighted average of high and + # low rates (Table 12a)". On a 7-hour tariff the ALL_OTHER_USES blend + # is 0.90 × 15.29 + 0.10 × 5.50 = 14.311 p/kWh (worksheet case 19 + # (252) "PV used in dwelling" = 14.3110). STANDARD tariff has no + # split → flat Table 32 code 30 = 13.19 p/kWh (unchanged). + from domain.sap10_calculator.tables.table_12a import Tariff + + # Act + off_peak = _pv_dwelling_import_price_gbp_per_kwh( + Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES + ) + standard = _pv_dwelling_import_price_gbp_per_kwh( + Tariff.STANDARD, SAP_10_2_SPEC_PRICES + ) + + # Assert + assert abs(off_peak - 0.14311) <= 1e-6 + assert abs(standard - 0.1319) <= 1e-6 + + def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None: # Arrange — RdSAP 10 §17 page 85 row 10-2 lodges 18-hour meter # as the bare "18-hour" or "18 Hour" form (Elmhurst Summary §14.2 From 9521d5240343f7039f8346b32fcb9fd07d4e04ce Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 22:59:12 +0000 Subject: [PATCH 70/80] =?UTF-8?q?S0380.234:=20PV=20diverter=20(Appendix=20?= =?UTF-8?q?G4)=20=E2=80=94=20diverts=20surplus=20PV=20to=20the=20cylinder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV generation (the would-be export EPV,m × (1 − βm)) to an immersion heater in the hot-water cylinder. Per G4 step 4: SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss (0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as the negative worksheet (63b)m (step 5). The β factor is computed on the PRE-diverter (219) per the §3a note (lines 5485-5486). Effects: - (64)m = (62)m + (63b)m → less main-system water-heating fuel (219); - export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94 line 5501); the onsite dwelling portion EPV,m × βm is unchanged. Inclusion (G4 step 1) requires ALL of: a PV system connected to the dwelling; a cylinder larger than (43) average daily HW use; no solar water heating; no battery — else the diverter is disregarded. Three layers: - extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1 SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`); - `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`, set in both the Elmhurst and API mapper paths; - `_pv_diverter_monthly_kwh` applies the G4 math after the β split; `cert_to_inputs` recomputes (219) and the PV export. On simulated case 19 (electric storage heaters, 7-hour, PV + diverter): SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 → 3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual +0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap + fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 1 + datatypes/epc/domain/epc_property_data.py | 5 + datatypes/epc/domain/mapper.py | 3 + datatypes/epc/schema/rdsap_schema_21_0_0.py | 1 + datatypes/epc/schema/rdsap_schema_21_0_1.py | 1 + datatypes/epc/surveys/elmhurst_site_notes.py | 5 + .../sap10_calculator/rdsap/cert_to_inputs.py | 143 ++++++++++++++++-- .../rdsap/test_cert_to_inputs.py | 98 ++++++++++++ 8 files changed, 248 insertions(+), 9 deletions(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 2523acb0..7bd1dba6 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1538,6 +1538,7 @@ class ElmhurstSiteNotesExtractor: wind_turbines_terrain_type=terrain, hydro_electricity_generated_kwh=hydro, pv_arrays=self._extract_pv_arrays(), + pv_diverter_present=self._bool_val("Diverter present"), pv_percent_roof_area=pv_pct if pv_pct > 0 else None, solar_hw_collector_orientation=solar_orientation, solar_hw_collector_pitch_deg=solar_pitch, diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 030d5345..4b45e598 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -324,6 +324,11 @@ class SapEnergySource: photovoltaic_arrays: Optional[List[PhotovoltaicArray]] = None wind_turbine_details: Optional[WindTurbineDetails] = None pv_batteries: Optional[PvBatteries] = None + # SAP 10.2 Appendix G4 — a PV diverter present on the dwelling routes + # surplus PV to a hot-water cylinder immersion. Drives worksheet + # (63b)m. Set from the API `sap_energy_source.pv_diverter` flag or the + # Elmhurst Summary §19 "Diverter present" row. + pv_diverter_present: bool = False @dataclass diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2c272975..9bc5e8e3 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -343,6 +343,7 @@ class EpcPropertyDataMapper: wind_turbines_terrain_type=survey.renewables.wind_turbines_terrain_type, electricity_smart_meter_present=survey.meters.electricity_smart_meter, photovoltaic_arrays=_elmhurst_pv_arrays(survey.renewables), + pv_diverter_present=survey.renewables.pv_diverter_present, # RdSAP 10 §11.1 b): when the cert lodges only a "% of # roof area" PV figure (no detailed kWp / orientation), # surface it through `photovoltaic_supply` so the @@ -1393,6 +1394,7 @@ class EpcPropertyDataMapper: else None ), pv_batteries=_first_pv_battery(es.pv_batteries), + pv_diverter_present=es.pv_diverter == "true", ), sap_building_parts=[ SapBuildingPart( @@ -1660,6 +1662,7 @@ class EpcPropertyDataMapper: else None ), pv_batteries=_first_pv_battery(es.pv_batteries), + pv_diverter_present=es.pv_diverter == "true", ), # SAP building parts sap_building_parts=[ diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index dee7002d..6db6fa50 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -133,6 +133,7 @@ class SapEnergySource: wind_turbines_terrain_type: int electricity_smart_meter_present: str pv_batteries: Optional[PvBatteries] = None + pv_diverter: Optional[str] = None @dataclass diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 87cbf91e..e508c161 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -161,6 +161,7 @@ class SapEnergySource: wind_turbines_terrain_type: int electricity_smart_meter_present: str pv_battery_count: Optional[int] = None + pv_diverter: Optional[str] = None wind_turbine_details: Optional[WindTurbineDetails] = None pv_batteries: Optional[Union[PvBatteries, List[PvBatteries]]] = None diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index f524ac79..2fa55acc 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -418,6 +418,11 @@ class Renewables: solar_hw_collector_orientation: Optional[str] = None solar_hw_collector_pitch_deg: Optional[int] = None solar_hw_overshading: Optional[str] = None + # Summary §19.0 "Diverter present" — a PV diverter routes surplus PV + # generation to an immersion heater in the hot-water cylinder + # (SAP 10.2 Appendix G4). Drives worksheet (63b)m. Defaults False + # when the cert lodges no PV or "Diverter present = No". + pv_diverter_present: bool = False @dataclass diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 6a4bfbba..584ae6f7 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -164,7 +164,10 @@ from domain.sap10_calculator.worksheet.energy_requirements import ( from domain.sap10_calculator.worksheet.fabric_energy_efficiency import ( fabric_energy_efficiency_kwh_per_m2_yr, ) -from domain.sap10_calculator.worksheet.photovoltaic import pv_split_monthly +from domain.sap10_calculator.worksheet.photovoltaic import ( + PhotovoltaicSplit, + pv_split_monthly, +) from domain.sap10_calculator.worksheet.space_cooling import ( SpaceCoolingResult, space_cooling_monthly_kwh, @@ -2522,9 +2525,13 @@ def _pv_eligible_demand_monthly_kwh( worksheet (233a) gap localised on the cohort-2 gas+PV certs: cert 3136 onsite 726.9 → 790.3 vs worksheet 792.1). - The off-peak immersion × (243) Ewater branch and the Appendix G4 - PV diverter adjustment are deferred — current cohort fixtures - don't exercise them.""" + The off-peak immersion × (243) Ewater branch is deferred. The + Appendix G4 PV-diverter saving is intentionally NOT reflected here: + per the §3a note (PDF p.93, lines 5485-5486) "If there is a PV + diverter, then for the purposes of this β factor calculation (219)m + should not include the diverter savings" — so D_PV uses the + pre-diverter (219), and the diverter (63b)m is applied afterwards in + `_pv_diverter_monthly_kwh`.""" include_main_space = ( main_fuel_code_table_12 is not None and main_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES @@ -2556,6 +2563,70 @@ def _pv_eligible_demand_monthly_kwh( return tuple(monthly) +# SAP 10.2 Appendix G4 step 4 (PDF p.73) — correction factors applied to +# the surplus PV available to the diverter: 0.8 for the cylinder's +# ability to accept the heat, and fPV,diverter,storageloss = 0.9 for the +# increased cylinder losses from storing water at a higher temperature. +_PV_DIVERTER_CYLINDER_ACCEPTANCE_FACTOR: Final[float] = 0.8 +_PV_DIVERTER_STORAGE_LOSS_FACTOR: Final[float] = 0.9 + + +def _pv_diverter_monthly_kwh( + *, + epc: EpcPropertyData, + pv_export_monthly_kwh: tuple[float, ...], + water_demand_monthly_kwh: tuple[float, ...], + avg_daily_hot_water_l: float, + battery_capacity_kwh: float, + pv_generation_kwh: float, +) -> Optional[tuple[float, ...]]: + """SAP 10.2 Appendix G4 (PDF p.72-73) — monthly PV-diverter water- + heating input SPV,diverter,m (positive kWh), entered as the negative + worksheet (63b)m. + + `pv_export_monthly_kwh` is the pre-diverter surplus EPV,m × (1 − βm) + — the portion of PV generation not consumed by the dwelling's + instantaneous demand, which would otherwise be exported. Per G4 step + 4: + + SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss + + clamped to ≤ (62)m + (63a)m (`water_demand_monthly_kwh`; (63a) the + WWHRS reduction, 0 here) so the diverter never supplies more than the + water-heating demand. + + Returns None — diverter disregarded by software (G4 step 1) — unless + ALL four inclusion conditions hold: + a. a PV system connected to the dwelling supply (EPV > 0); + b. a cylinder whose volume exceeds (43) the average daily hot-water + use; + c. no solar water heating present; + d. no battery storage present. + `pv_diverter_present` (Summary §19 / API `pv_diverter`) gates the + whole calculation: an absent diverter returns None immediately. + """ + if not epc.sap_energy_source.pv_diverter_present: + return None + # a. PV connected to the dwelling (case "a" Appendix M1 step 2). + if pv_generation_kwh <= 0.0: + return None + # b. Cylinder volume (litres) must exceed (43) average daily HW use. + cylinder_volume_l = _hot_water_cylinder_volume_l(epc) + if cylinder_volume_l is None or cylinder_volume_l <= avg_daily_hot_water_l: + return None + # c. No solar water heating. d. No battery storage. + if epc.solar_water_heating or battery_capacity_kwh > 0.0: + return None + correction = ( + _PV_DIVERTER_CYLINDER_ACCEPTANCE_FACTOR + * _PV_DIVERTER_STORAGE_LOSS_FACTOR + ) + return tuple( + min(pv_export_monthly_kwh[m] * correction, water_demand_monthly_kwh[m]) + for m in range(12) + ) + + # RdSAP 10 §11.1 b): when the kWp is not lodged but the cert lodges a # "% of roof area" PV figure, derive the PV peak power as # `0.12 × PV area`, with PV area being the dwelling's roof area for @@ -6571,6 +6642,7 @@ def cert_to_inputs( # the scalar `water_eff` (Table 4a/4b boilers, legacy fallback). # Q_space (kWh/month) per spec = (98c)m × (204) = (98c)m × (1 − # sec_frac) for single-main fixtures. + space_heating_monthly_useful_kwh: tuple[float, ...] = (0.0,) * 12 if wh_result is not None: # Eq D1 Q_space is the DHW boiler's OWN space-heating load — its # (204)/(205) share of total — not the dwelling total (202). See @@ -6758,17 +6830,70 @@ def cert_to_inputs( battery_capacity_kwh=_pv_battery_capacity_kwh(epc), ) + # SAP 10.2 Appendix G4 (PDF p.72-73) — PV diverter. The β factor above + # is computed on the PRE-diverter (219) per the §3a note; now apply + # the diverter saving. SPV,diverter,m diverts the surplus PV (the + # would-be export EPV,m × (1 − βm)) into the cylinder immersion: + # - (63b)m = −SPV,diverter,m reduces the §4 output (64)m → less main- + # system water-heating fuel (219); + # - the export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (the + # diverted energy is no longer exported); the onsite dwelling + # portion EPV,dw,m = EPV,m × βm is unchanged (the β is fixed). + hw_output_monthly_for_factors = ( + wh_result.output_monthly_kwh if wh_result is not None else (0.0,) * 12 + ) + pv_diverter_monthly_kwh = _pv_diverter_monthly_kwh( + epc=epc, + pv_export_monthly_kwh=pv_split.epv_exported_monthly_kwh, + water_demand_monthly_kwh=( + wh_result.total_demand_monthly_kwh if wh_result is not None + else (0.0,) * 12 + ), + avg_daily_hot_water_l=( + wh_result.annual_avg_hot_water_l_per_day if wh_result is not None + else 0.0 + ), + battery_capacity_kwh=_pv_battery_capacity_kwh(epc), + pv_generation_kwh=sum(pv_monthly_kwh), + ) + if pv_diverter_monthly_kwh is not None and wh_result is not None: + pv63b_monthly_kwh = tuple(-s for s in pv_diverter_monthly_kwh) + # (64)m = (62)m + (63a)m + (63b)m — reduce the §4 output by the + # diverter input, then recompute (219) from the reduced output. + hw_output_monthly_for_factors = tuple( + max(0.0, wh_result.output_monthly_kwh[m] + pv63b_monthly_kwh[m]) + for m in range(12) + ) + if section_12_4_4_blend is None: + hw_kwh = _apply_water_efficiency( + wh_output_monthly_kwh=hw_output_monthly_for_factors, + wh_output_annual_kwh=sum(hw_output_monthly_for_factors), + water_efficiency_pct=water_eff, + eq_d1_winter_summer_pct=eq_d1_winter_summer_pct, + space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh, + interlock_penalty_pp=eq_d1_interlock_penalty_pp, + ) + # EPV,ex,m = EPV,m(1 − βm) + (63b)m / fPV,diverter,storageloss. + adjusted_export_monthly_kwh = tuple( + pv_split.epv_exported_monthly_kwh[m] + + pv63b_monthly_kwh[m] / _PV_DIVERTER_STORAGE_LOSS_FACTOR + for m in range(12) + ) + pv_split = PhotovoltaicSplit( + beta_monthly=pv_split.beta_monthly, + epv_dwelling_monthly_kwh=pv_split.epv_dwelling_monthly_kwh, + epv_exported_monthly_kwh=adjusted_export_monthly_kwh, + ) + # SAP 10.2 §12.4.4 overrides — when summer immersion applies (back- # boiler combo + cylinder + WHC from main heating), the HW cost / # CO2 / PE factors are kWh-weighted blends of the winter boiler fuel # + summer electric immersion. The standing-charges line adds the # off-peak electric standing because the cylinder is heated by an # off-peak immersion Jun-Sep. When the rule does NOT apply, the - # locals fall back to the existing single-fuel HW helpers. - hw_monthly_kwh_for_factors = ( - wh_result.output_monthly_kwh if wh_result is not None - else (0.0,) * 12 - ) + # locals fall back to the existing single-fuel HW helpers. The HW + # factors weight by the diverter-adjusted (64)m output. + hw_monthly_kwh_for_factors = hw_output_monthly_for_factors if section_12_4_4_blend is not None: ( _hw_total_unused, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index ad5b3553..f99afce9 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -61,6 +61,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _main_floor_u_value, # pyright: ignore[reportPrivateUsage] _main_space_heating_high_rate_fraction, # pyright: ignore[reportPrivateUsage] _other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + _pv_diverter_monthly_kwh, # pyright: ignore[reportPrivateUsage] _pv_dwelling_import_price_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage] _primary_loss_applies, # pyright: ignore[reportPrivateUsage] @@ -1875,6 +1876,103 @@ def test_pv_dwelling_import_price_blends_high_low_on_off_peak() -> None: assert abs(standard - 0.1319) <= 1e-6 +def _pv_diverter_epc(): + """A minimal dwelling that satisfies every Appendix G4 inclusion + condition: a 210 L cylinder (code 4), no solar HW, no battery, with + `pv_diverter_present` set on the energy source.""" + from dataclasses import replace + + epc = make_minimal_sap10_epc( + total_floor_area_m2=90.0, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + solar_water_heating=False, + sap_heating=make_sap_heating( + main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)], + cylinder_size=4, # RdSAP Table 28 code 4 → 210 L + ), + ) + return replace( + epc, + sap_energy_source=replace( + epc.sap_energy_source, pv_diverter_present=True + ), + ) + + +def test_pv_diverter_monthly_applies_g4_correction_and_clamp() -> None: + # Arrange — SAP 10.2 Appendix G4 step 4 (PDF p.73): SPV,diverter,m = + # EPV,m(1 − βm) × 0.8 × 0.9, clamped to ≤ (62)m + (63a)m. With a + # 100-kWh monthly surplus the uncapped diverter input is 72 kWh; a + # month whose water demand is only 50 kWh clamps it to 50. + epc = _pv_diverter_epc() + export = tuple(100.0 for _ in range(12)) + demand = tuple(50.0 if m < 6 else 1000.0 for m in range(12)) + + # Act + out = _pv_diverter_monthly_kwh( + epc=epc, + pv_export_monthly_kwh=export, + water_demand_monthly_kwh=demand, + avg_daily_hot_water_l=120.0, # < 210 L cylinder + battery_capacity_kwh=0.0, + pv_generation_kwh=1200.0, + ) + + # Assert + assert out is not None + for m in range(12): + expected = min(100.0 * 0.8 * 0.9, demand[m]) + assert abs(out[m] - expected) <= 1e-9 + + +def test_pv_diverter_disregarded_when_any_g4_condition_fails() -> None: + # Arrange — SAP 10.2 Appendix G4 step 1: if a PV system / large-enough + # cylinder / no-solar-HW / no-battery condition is not met, software + # disregards the diverter (returns None). + from dataclasses import replace + + epc = _pv_diverter_epc() + export: tuple[float, ...] = (100.0,) * 12 + demand: tuple[float, ...] = (1000.0,) * 12 + + def divert( + e: object, avg_l: float = 120.0, battery: float = 0.0, pv_gen: float = 1200.0 + ) -> Optional[tuple[float, ...]]: + return _pv_diverter_monthly_kwh( + epc=e, # pyright: ignore[reportArgumentType] + pv_export_monthly_kwh=export, + water_demand_monthly_kwh=demand, + avg_daily_hot_water_l=avg_l, + battery_capacity_kwh=battery, + pv_generation_kwh=pv_gen, + ) + + # Act / Assert — sanity: all conditions met → not None. + assert divert(epc) is not None + # Diverter not present. + assert ( + divert( + replace( + epc, + sap_energy_source=replace( + epc.sap_energy_source, pv_diverter_present=False + ), + ) + ) + is None + ) + # No PV generation (condition a). + assert divert(epc, pv_gen=0.0) is None + # Cylinder not larger than (43) average daily HW use (condition b). + assert divert(epc, avg_l=9999.0) is None + # Battery present (condition d). + assert divert(epc, battery=5.0) is None + # Solar water heating present (condition c). + assert divert(replace(epc, solar_water_heating=True)) is None + + def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None: # Arrange — RdSAP 10 §17 page 85 row 10-2 lodges 18-hour meter # as the bare "18-hour" or "18 Hour" form (Elmhurst Summary §14.2 From 6d4fa5dd3b6acb4033029f81aba27e6f4ed7fa31 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 23:06:20 +0000 Subject: [PATCH 71/80] =?UTF-8?q?docs:=20handover=20=E2=80=94=20fold=20in?= =?UTF-8?q?=20S0380.232-234=20(case-19=20PV=20closure)=20+=20open=20causes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the three PV slices shipped (D_PV off-peak exclusion, weighted dwelling import price, Appendix G4 diverter), the resulting case-19 state (SAP 50.33→51.34, rounds to lodged 51), and the two remaining case-19 causes (winter Appendix-M EPV monthly shape; fabric (33) +1.0). Adds the `2100-5421` worst-offender diagnosis (a 352 m² uninsulated solid-wall dwelling on the as-built-insulated-assumed roof-U front, not a flats bug). Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_API_SAMPLE_ACCURACY.md | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md index b47ff6ed..ffd15274 100644 --- a/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md +++ b/domain/sap10_calculator/docs/HANDOVER_API_SAMPLE_ACCURACY.md @@ -4,11 +4,43 @@ Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodolog 1e-4 bar, the per-line debugging loop, the section helpers, and the suite command. - **Branch:** `feature/per-cert-mapper-validation` -- **HEAD:** `f326e4eb`. Next SAP slice: **S0380.232**. +- **HEAD:** `9521d524`. Next SAP slice: **S0380.235**. - **Baseline (§4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/` - → green (2407 passed, 1 skipped). Pre-existing out-of-scope failures unchanged + → green (2412 passed, 1 skipped). Pre-existing out-of-scope failures unchanged (stone-§5.6 in `domain/sap10_ml/tests/`; `test_from_rdsap_schema.py::...test_total_floor_area`). +## Shipped this session (S0380.232-234 — the case-19 PV closure) + +The PV diverter (the prior handover's S0380.232 ask) needed two prerequisite +spec bugs fixed first; all three landed: + +| slice | commit | spec | what | +|---|---|---|---| +| **S0380.232** | `212b0c92` | App M1 §3a (p.93, l.5470-5476) | D_PV excludes the LOW-rate portion of an off-peak electric main: `(211)` is only PV-eligible where its §10a code ∈ {30,32,34,35,38}. Storage heaters on 7-hr charge wholly at low rate → fraction 0.0 → excluded. β_Jan 0.894→0.792 (ws 0.791). New `_main_space_heating_high_rate_fraction`. | +| **S0380.233** | `d4a8c02b` | App M1 §6 (p.94, l.5510-5513) | PV-used-in-dwelling credited at the Table 12a ALL_OTHER_USES **weighted** rate (7-hr 14.311 p/kWh), not the bare low rate (5.50). Was under-crediting onsite PV on every off-peak PV cert. Delegates to `_other_fuel_cost_gbp_per_kwh`; STANDARD unchanged. | +| **S0380.234** | `9521d524` | Appendix G4 (p.72-73) | The PV diverter. 3 layers: extractor `Diverter present` + schema `pv_diverter` → `pv_diverter_present` flag (Elmhurst + API mappers) → `_pv_diverter_monthly_kwh` (SPV = export×0.8×0.9, clamp ≤ (62)+(63a), → (63b)m); `cert_to_inputs` recomputes (219) + PV export, β fixed pre-diverter. | + +**Case 19 now: SAP cont 50.33 → 51.34** (ws 51.2221; both round to lodged **51**), +cost (255) 1847.5→1812.3 (ws 1816.6), CO2 3331→3120 (ws 3126), (233a) dwelling +1280.6 (ws **1280.4** — the β fix pins it). The diverter formula is **exact in +summer** (Jun SPV 186.07 = export×0.72, matches ws (63b)). + +**The remaining +0.11 SAP on case 19 = two separate, still-open causes:** +1. **Winter Appendix-M monthly EPV shape.** Our annual EPV (2684.17) matches the + worksheet exactly and Jun-Sep match per-month exactly, but Jan-May/Oct-Dec our + EPV is ~9-11% LOW (worksheet Jan 68.2 vs ours 62.5). Back-solve: ws EPV_m = + |(233a)_m| + |(63b)_m|/0.72. This under-diverts in winter → export (233b) 280.7 + vs ws 184.2, and (219) 3322 vs ws 3188. **A two-array PV apportionment issue + (case 19 has SE + NW arrays with different overshading) — chase in §M / Appendix U + monthly radiation, NOT the diverter (which is validated).** +2. **Fabric (33) +1.0 W/K** (ours 305.04 vs ws 304.04) — a single element off by + exactly 1.0; floor=25.000 is suspiciously round. Walk the per-element §3 breakdown. + +The **eval headline is flat** (42.9→43.0% <0.5; cat-7 5.25→4.93) — expected: the +diverter is rare and the β/price effects are small on the rounded SAP. The value +was pinning the worksheet-validated case 19 + fixing three real spec bugs that the +curated cohort masked. + ## Headline now (1,000-cert 2026 API sample, HEAD `f326e4eb`) | metric | value | was (handover baseline `9c0a373f`) | @@ -188,6 +220,14 @@ pages → ElmhurstSiteNotesExtractor(...).extract() → from_elmhurst_site_notes 4. **Non-PCDB gas boilers (cat 2, no idx, 91 certs, mean 3.18)** and **Flats (282, mean 2.57)** — the next volume levers once the electric clusters are worksheet-pinned. Flats = geometry / communal; start with the worst (`2100-5421` negative SAP). + - **`2100-5421-0922-1622-3463` diagnosed (S0380.234 session):** NOT a flat — `property_type 0`, + a **352 m² 2-storey uninsulated solid-wall** dwelling (wall_constr 3 / wall_ins 4 as-built; + roof_type 4, no roof insulation). Our space-heating demand is **71,084 kWh/yr** → (37)=995.93 + W/K → SAP −24.8 (lodged 36), cost £14,045. This is the **`as-built insulated-assumed`** + U-value front ([[project_as_built_insulated_assumed_bug]]; S0380.209 fixed walls, "roof next"): + the uninsulated-roof / as-built U over-estimates demand on big old dwellings. API-only (no + worksheet → ±0.5 lodged fallback only); needs a generated worksheet or a roof-U spec audit to + pin. It is one outlier, not a cluster-wide flats bug. ### B. Remaining raises (16 certs — all U-value / heat-loss-sensitive, NOT enum guesses) - **`gable_wall_type` 2 & 3 (14 certs).** RdSAP 10 **Table 4** RR walls: 0=Party (U=0.25), From 3e45b7fa3b5b3eeac0ab72bb2b901278ab102660 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 08:15:11 +0000 Subject: [PATCH 72/80] =?UTF-8?q?S0380.235:=20map=20the=20remaining=20Elmh?= =?UTF-8?q?urst=20=C2=A711=20glazing=20labels=20to=20SAP=2010.2=20Table=20?= =?UTF-8?q?6b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The double_glazing recommendation fixture (Summary_001431) exercises every RdSAP-21 §11 glazing lodging in one cert; five labels were missing from `_ELMHURST_GLAZING_LABEL_TO_SAP10` and strict-raised `UnmappedElmhurstLabel`: "Secondary glazing" -> 7 (Table 6b "secondary glazing", g_L 0.80) "Secondary glazing - Normal emissivity" -> 11 (RdSAP-21 secondary normal-E, g_L 0.80) "Triple pre 2002" -> 10 (triple pre-2002, g_L 0.70) "Triple with unknown install date" -> 6 (generic triple glazed, g_L 0.70) "Single glazing, known data" -> 15 (single known-data, g_L 0.90) The glazing code's only cascade effect is the §5 (66)..(67) daylight factor g_L in `_G_LIGHT_BY_GLAZING_CODE` (single 0.90 / double+secondary 0.80 / triple 0.70); the lodged manufacturer U-value and solar_transmittance drive §3 / §6 directly (`_g_perpendicular` prefers the lodged value). Codes are the semantically-exact RdSAP-21 rows within the correct g_L bucket, kept distinct for the strict-raise audit trail. Adds a full-coverage test over all 13 distinct labels. Suite 2413 pass. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_summary_pdf_mapper_chain.py | 31 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 24 ++++++++++++++ 2 files changed, 55 insertions(+) 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 88ab7f14..b1966d40 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -42,6 +42,7 @@ from datatypes.epc.domain.mapper import ( EpcPropertyDataMapper, UnmappedApiCode, UnmappedElmhurstLabel, + _elmhurst_glazing_type_code, # pyright: ignore[reportPrivateUsage] ) from domain.sap10_calculator.calculator import calculate_sap_from_inputs from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs @@ -1471,6 +1472,36 @@ def test_summary_000474_double_glazed_windows_route_to_code_3() -> None: ) +def test_elmhurst_glazing_label_full_coverage_per_sap10_table_6b() -> None: + # Arrange — the double_glazing recommendation fixture (Summary_001431) + # exercises every RdSAP-21 §11 glazing-type lodging in one cert. Each + # label must resolve to the SAP 10.2 Table 6b cascade code whose + # `_G_LIGHT_BY_GLAZING_CODE` daylight factor g_L is correct for the + # glazing family: single 0.90, double / secondary 0.80, triple 0.70 + # (the lodged manufacturer U/g drive §3/§6; the code only sets g_L). + expected: dict[str, int] = { + "Single glazing": 1, + "Single glazing, known data": 15, + "Double pre 2002": 2, + "Double between 2002 and 2021": 3, + "Double with unknown install date": 3, + "Double glazing, known data": 3, + "Double post or during 2022": 5, + "Secondary glazing": 7, + "Secondary glazing - Normal emissivity": 11, + "Triple pre 2002": 10, + "Triple between 2002 and 2021": 9, + "Triple post or during 2022": 6, + "Triple with unknown install date": 6, + } + + # Act / Assert + for label, code in expected.items(): + assert _elmhurst_glazing_type_code(label) == code, ( + f"{label!r} should map to SAP 10.2 Table 6b code {code}" + ) + + def test_summary_mapper_raises_on_unmapped_glazing_type_label() -> None: # Arrange — same strict-coverage gate as the cylinder-size helper # (Slice S0380.15 + S0380.16): silently routing an unknown glazing diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 9bc5e8e3..71df85c0 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -5073,6 +5073,30 @@ _ELMHURST_GLAZING_LABEL_TO_SAP10: Dict[str, int] = { # solar_transmittance=0.72 overrides per worksheet-pinned value. "Triple between 2002 and 2021": 9, "Secondary": 7, + # Elmhurst §11 lodges the full "Secondary glazing" phrase as well as + # the bare "Secondary" form — both are SAP 10.2 Table 6b "window with + # secondary glazing" (cascade code 7, g_L=0.80, g⊥=0.76). The + # RdSAP-21 schema splits secondary glazing by pane emissivity; the + # "Normal emissivity" variant is its own code 11 (g_L=0.80, identical + # cascade output to 7, kept distinct for the strict-raise audit + # trail). Surfaced on the double_glazing/before recommendation + # fixture (Summary_001431 §11 Window 9). + "Secondary glazing": 7, + "Secondary glazing - Normal emissivity": 11, + # RdSAP-21 row "triple glazing, installed pre-2002" (cascade code 10, + # g_L=0.70, g⊥=0.68 — same triple-glazed daylight/solar bucket as the + # other triple variants {6, 8, 9, 14}). The lodged manufacturer + # U-value / solar_transmittance drive §6; the code only sets g_L. + "Triple pre 2002": 10, + # Triple glazing of unknown install date → the generic SAP 10.2 + # Table 6b "triple glazed" row (cascade code 6, g_L=0.70). No + # dedicated "triple, unknown date" enum exists; 6 is the correct + # triple-glazed bucket. + "Triple with unknown install date": 6, + # RdSAP-21 row "single glazing, known data" (cascade code 15, + # g_L=0.90, g⊥=0.85 — same as plain single glazing). Manufacturer + # U/g lodged on WindowTransmissionDetails drive §6. + "Single glazing, known data": 15, } _ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE: Final[re.Pattern[str]] = re.compile( From ea35bed24c37f66956e7e3a3c9feee14fb48d1ec Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 09:19:43 +0000 Subject: [PATCH 73/80] S0380.236: extension party-wall type read independently of "As Main Wall" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §3.3: "As Main Wall: Yes" makes an extension inherit the main dwelling's external wall CONSTRUCTION only — the party wall type is lodged separately per building part in the Summary §7 block and may differ. `_extract_extensions` was copying `main_walls.party_wall_type` into the inherited WallDetails, so every extension reused the main's party wall U. On the double_glazing fixture (Summary_001431) the Main lodges party "CU Cavity masonry unfilled" (SAP10 wall_construction 4 → u_party_wall 0.5) but the 1st Extension lodges "U Unable to determine" (→ 0 → RdSAP default 0.25). Pre-fix both building parts used 0.5, inflating worksheet (32) party-wall heat loss by 6.56 W/K (Ext1 26.25 m² × 0.25). After the fix worksheet (32) is exact: ours 32.573 vs worksheet 32.5725. Now reads the extension's own "Party Wall Type" from its §7 chunk, falling back to the main's only when the extension lodges none. Adds a fixture + test asserting Main=4 / Ext=0 with distinct u_party_wall. Suite 2413 pass; no cohort regression. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 14 +++++++- .../Summary_001431_double_glazing.pdf | Bin 0 -> 80939 bytes .../tests/test_summary_pdf_mapper_chain.py | 31 ++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_double_glazing.pdf diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 7bd1dba6..0f440f4a 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -670,12 +670,24 @@ class ElmhurstSiteNotesExtractor: # even when the main wall fields are inherited; merge # them into the inherited WallDetails so the bp carries # them through to its SapBuildingPart. + # + # "As Main Wall: Yes" inherits the EXTERNAL wall + # construction only — the PARTY WALL TYPE is lodged + # separately in the extension's §7 block and may differ + # (cert 001431: Main "CU Cavity masonry unfilled" U=0.5, + # 1st Extension "U Unable to determine" → RdSAP default + # U=0.25). Read the extension's own party wall type when + # present; fall back to the main's only when absent. + ext_party_wall_type = ( + self._local_str(wall_lines, "Party Wall Type") + or main_walls.party_wall_type + ) walls = WallDetails( wall_type=main_walls.wall_type, insulation=main_walls.insulation, thickness_unknown=main_walls.thickness_unknown, u_value_known=main_walls.u_value_known, - party_wall_type=main_walls.party_wall_type, + party_wall_type=ext_party_wall_type, thickness_mm=main_walls.thickness_mm, insulation_thickness_mm=main_walls.insulation_thickness_mm, alternative_walls=self._alternative_walls_from_lines(wall_lines), diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_double_glazing.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_double_glazing.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f58a021786b8b34f699522f6ad54f427d254a719 GIT binary patch literal 80939 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 literal 0 HcmV?d00001 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 b1966d40..d3ed87d6 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1502,6 +1502,37 @@ def test_elmhurst_glazing_label_full_coverage_per_sap10_table_6b() -> None: ) +def test_extension_party_wall_type_read_independently_of_as_main_wall() -> None: + # Arrange — RdSAP 10 §3.3: "As Main Wall: Yes" inherits only the + # external wall CONSTRUCTION; the party wall type is lodged + # separately per building part and may differ. The double_glazing + # fixture (Summary_001431) lodges Main party "CU Cavity masonry + # unfilled" (SAP10 wall_construction 4 → u_party_wall 0.5) but the + # 1st Extension party "U Unable to determine" (→ wall_construction 0 + # → RdSAP default u_party_wall 0.25), even though the extension is + # "As Main Wall: Yes". Pre-fix the extension inherited the Main's + # party type (both 0.5), inflating worksheet (32) party heat loss. + pages = _summary_pdf_to_textract_style_pages( + _FIXTURES / "Summary_001431_double_glazing.pdf" + ) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert — Main BP keeps cavity-unfilled (4); the extension BP gets + # the "Unable to determine" sentinel (0), a distinct party wall U. + party_codes = [ + bp.party_wall_construction for bp in epc.sap_building_parts + ] + assert party_codes == [4, 0], ( + f"expected Main=4 (CU, U=0.5) + Ext=0 (Unable, U=0.25), got {party_codes}" + ) + # The two map to different SAP party-wall U-values. + assert abs(u_party_wall(4) - 0.5) <= 1e-9 + assert abs(u_party_wall(0) - 0.25) <= 1e-9 + + def test_summary_mapper_raises_on_unmapped_glazing_type_label() -> None: # Arrange — same strict-coverage gate as the cylinder-size helper # (Slice S0380.15 + S0380.16): silently routing an unknown glazing From 8133521c43340a1385322688917eadf1970eb3c4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 09:35:35 +0000 Subject: [PATCH 74/80] =?UTF-8?q?S0380.237:=20map=20"Secondary=20glazing?= =?UTF-8?q?=20-=20Low=20emissivity"=20=E2=86=92=20SAP=2010.2=20code=2012?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the secondary-glazing family. S0380.235 mapped the unknown-data (7) and normal-emissivity (11) secondary variants; the RdSAP-21.0.1 `glazed_type` enum also defines code 12 "secondary glazing, low emissivity", whose Elmhurst §11 label "Secondary glazing - Low emissivity" was unmapped and would strict-raise. Cascade code 12 carries the same daylight/solar bucket as 7/11 (g_L=0.80, g⊥=0.76); the lodged manufacturer U/g drive §3/§6. With this the double family (codes 1/2/3/ 7/13 via their Elmhurst phrasings) and the secondary family (4/11/12) are fully covered. Coverage test extended. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/tests/test_summary_pdf_mapper_chain.py | 1 + datatypes/epc/domain/mapper.py | 6 ++++++ 2 files changed, 7 insertions(+) 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 d3ed87d6..66f0172e 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1489,6 +1489,7 @@ def test_elmhurst_glazing_label_full_coverage_per_sap10_table_6b() -> None: "Double post or during 2022": 5, "Secondary glazing": 7, "Secondary glazing - Normal emissivity": 11, + "Secondary glazing - Low emissivity": 12, "Triple pre 2002": 10, "Triple between 2002 and 2021": 9, "Triple post or during 2022": 6, diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 71df85c0..32765df1 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -5083,6 +5083,12 @@ _ELMHURST_GLAZING_LABEL_TO_SAP10: Dict[str, int] = { # fixture (Summary_001431 §11 Window 9). "Secondary glazing": 7, "Secondary glazing - Normal emissivity": 11, + # RdSAP-21 glazed_type 12 "secondary glazing, low emissivity" — the + # low-E sibling of code 11. Cascade code 12 carries g_L=0.80 / g⊥=0.76 + # (identical daylight/solar bucket to 7 and 11; the lodged U/g drive + # §3/§6). Mapped symmetrically with the "Normal emissivity" variant so + # the whole secondary-glazing family is covered. + "Secondary glazing - Low emissivity": 12, # RdSAP-21 row "triple glazing, installed pre-2002" (cascade code 10, # g_L=0.70, g⊥=0.68 — same triple-glazed daylight/solar bucket as the # other triple variants {6, 8, 9, 14}). The lodged manufacturer From 218840db987f65b14e776f2cd4edd08c06845149 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 09:47:29 +0000 Subject: [PATCH 75/80] docs: handover for the open window-extraction work on the double_glazing fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the diagnosis so the next agent doesn't re-derive it: what's done (S0380.235-237), what's confirmed correct (calculator U-adjustment, party wall, glazing labels), the worksheet pin targets, and the two open causes — crucially the 000516 trap (byte-identical Summary data classified as a roof window there but a wall window here, so flipping the U>3 rule regresses 000516). Includes a rebuildable tracer recipe. Co-Authored-By: Claude Opus 4.8 --- .../HANDOVER_GLAZING_WINDOW_EXTRACTION.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_GLAZING_WINDOW_EXTRACTION.md diff --git a/domain/sap10_calculator/docs/HANDOVER_GLAZING_WINDOW_EXTRACTION.md b/domain/sap10_calculator/docs/HANDOVER_GLAZING_WINDOW_EXTRACTION.md new file mode 100644 index 00000000..8f652381 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_GLAZING_WINDOW_EXTRACTION.md @@ -0,0 +1,94 @@ +# Handover — double_glazing fixture: glazing done, window-extraction open + +Point-in-time note for the agent owning the Elmhurst window extractor / +mapper. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for the 1e-4 +worksheet-pin methodology and the cascade pipeline. + +- **Branch:** `feature/per-cert-mapper-validation`. HEAD `8133521c`. +- **Fixture:** the double_glazing "before" recommendation pair — + `sap worksheets/Recommendations Elmhurst Files/double_glazing/before/` + (`Summary_001431 (1).pdf` + `P960-0001-001431 - 2026-06-02T115533.961.pdf`). + The Summary is also committed as a test fixture: + `backend/documents_parser/tests/fixtures/Summary_001431_double_glazing.pdf`. +- **Worksheet block to pin** (rating = block 1, region 0; demand = block 2, + postcode): SAP cont **57.2415**, cost (255) **1423.0955**, fabric (33) + **158.4548** / (37) **197.8463**; demand CO2 (272) **3486.0799**, PE (286) + **16796.5617**. (Blocks 3+ add PV/diverter — ignore for the "before" pin.) + +## Done this session (S0380.235-237) — DON'T redo + +| slice | what | +|---|---| +| **S0380.235** `3e45b7fa` | 5 missing Elmhurst §11 glazing labels → SAP10 Table 6b (`Secondary glazing`→7, `…- Normal emissivity`→11, `Triple pre 2002`→10, `Triple with unknown install date`→6, `Single glazing, known data`→15). | +| **S0380.236** `ea35bed2` | Extension party-wall type read independently of "As Main Wall" (`_extract_extensions`): Main `CU`→0.5, Ext `U Unable to determine`→0.25. Worksheet **(32) party heat loss now exact** (32.573 vs 32.5725). | +| **S0380.237** `8133521c` | `Secondary glazing - Low emissivity`→12. Double {1,2,3,7,13} + secondary {4,11,12} families now fully mapped. | + +**Confirmed already correct — do not touch:** +- The calculator's window **U-adjustment `1/(1/U + 0.04)`** (SAP §3.2 curtain + correction) is exact: lodged 4.80→4.0268, 3.10→2.7580, 1.40→1.3258, all + match the worksheet to 1e-4. Our 14 extracted windows sum to **exactly + 56.090**. The 1e-4 gap is NOT in the calculator. +- Glazing label→code mapping (g_L is the only cascade effect; lodged U/g + drive §3/§6 via `_g_perpendicular` preferring the lodged value). + +## Open — current residual **SAP +1.13** (ours 58.37 vs ws 57.24), all in WINDOWS + +The Summary §11 lodges **17 physical window rows**; we end up with **14** +`sap_windows`. Three windows are lost, in two distinct ways: + +### Cause 1 (HARD — read before touching `_is_elmhurst_roof_window`) +The mapper routes the two `Double pre 2002` windows (lodged U 3.1 / 3.4) to +**roof** windows via the `U > 3.0` backstop in +`_is_elmhurst_roof_window` (`datatypes/epc/domain/mapper.py`, the final +`return w.u_value > _ELMHURST_ROOF_WINDOW_U_THRESHOLD`). This fixture's +worksheet bills them as **wall** windows (27). + +**The trap:** cohort cert **000516** has a window that is *byte-identical* +in every extractable Summary field — `Double pre 2002`, U=3.1, +`location="External wall"`, bp `Main`, orient `North-East` — and *its* +worksheet bills it as a **roof** window (27a). Verified: gating the U>3 +rule on `location == "External wall"` makes this fixture pass but +**breaks both 000516 pins** (`test_summary_000516_full_chain_…` and +`test_from_elmhurst_site_notes_matches_hand_built_000516`). + +So identical Summary inputs are classified oppositely by the two +worksheets. **No rule keyed on the fields we currently extract can satisfy +both.** Resolving this needs a NEW disambiguating signal — likely a +roof/wall or rooflight field Elmhurst lodges in §11 (or the BP roof +structure) that the extractor doesn't yet capture. Do NOT flip the U>3 +heuristic to fix this fixture; it silently regresses 000516. + +### Cause 2 (tractable — a plain parsing miss) +The extractor produces **16 windows from 17 §11 rows** — it drops the +`Double glazing, known data` row (BFRC, lodged U=1.00 → adjusted 0.9615, +1st Extension, area 1.00; worksheet "Windows 12" on Ext1). The label maps +fine (→3); the physical row just isn't extracted. Fixing this alone won't +pin the fixture (Cause 1 still blocks) but it's a real, isolatable +extractor bug. + +## Tracer recipe (rebuild — the throwaway lived in /tmp) +```python +# from repo root, PYTHONPATH=/workspaces/model +import re, subprocess; from pathlib import Path +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, cert_to_inputs, cert_to_demand_inputs, + heat_transmission_section_from_cert) +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +def pages(pdf): + n=int(re.search(r"Pages:\s+(\d+)",subprocess.run(["pdfinfo",str(pdf)], + capture_output=True,text=True).stdout).group(1)); out=[] + for i in range(1,n+1): + L=subprocess.run(["pdftotext","-layout","-f",str(i),"-l",str(i),str(pdf),"-"], + capture_output=True,text=True).stdout + out.append("\n".join(tok for ln in L.splitlines() + for tok in re.split(r"\s{2,}",ln.strip()) if tok)) + return out +D=Path("sap worksheets/Recommendations Elmhurst Files/double_glazing/before") +sn=ElmhurstSiteNotesExtractor(pages(D/"Summary_001431 (1).pdf")).extract() +epc=EpcPropertyDataMapper.from_elmhurst_site_notes(sn) +# len(sn.windows)==16 (should be 17); len(epc.sap_windows)==14 (2 → roof, 1 dropped) +``` +Per-window A×U on the worksheet uses the ADJUSTED U `1/(1/U_lodged+0.04)`; +sum the §3 `(27)` lines to 60.5577 (we get 56.090 from 14 windows). From c882cb2c95fd579988d831ec2095a29f74336eb6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 09:56:06 +0000 Subject: [PATCH 76/80] review: typehint Optional locals around _parse_thickness_mm call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback (dancafc): `_parse_thickness_mm` handles a None input and returns Optional[int], so its call-return locals — and the Optional[str] raws they read from `_local_val` — read clearer when annotated. Annotates `thickness_raw`/`ins_thickness_raw: Optional[str]` and `thickness_mm`/`insulation_thickness_mm: Optional[int]` at all four call sites (_wall_details_from_lines, _alternative_walls_from_lines, _roof_details_from_lines, _floor_details_from_lines), plus the adjacent `u_val_raw`/`default_u` Optional pair in _floor_details_from_lines for consistency. Matches the project convention of typehinting call-return locals. No behaviour change; pyright clean, 569 parser tests pass. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 0f440f4a..16f32e07 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -269,8 +269,8 @@ class ElmhurstSiteNotesExtractor: ) def _wall_details_from_lines(self, lines: List[str]) -> WallDetails: - thickness_raw = self._local_val(lines, "Wall Thickness") - thickness_mm = ( + thickness_raw: Optional[str] = self._local_val(lines, "Wall Thickness") + thickness_mm: Optional[int] = ( int(thickness_raw.split()[0]) if thickness_raw else None ) # Composite / retrofit insulation thickness — Summary §7.0 @@ -280,8 +280,8 @@ class ElmhurstSiteNotesExtractor: # is local-scoped inside the §7 block so it does not collide # with the §8 Roofs / §9 Floors blocks. None when the PDF # omits the line (no retrofit lodged). - ins_thickness_raw = self._local_val(lines, "Insulation Thickness") - insulation_thickness_mm = self._parse_thickness_mm(ins_thickness_raw) + ins_thickness_raw: Optional[str] = self._local_val(lines, "Insulation Thickness") + insulation_thickness_mm: Optional[int] = self._parse_thickness_mm(ins_thickness_raw) return WallDetails( wall_type=self._local_str(lines, "Type"), insulation=self._local_str(lines, "Insulation"), @@ -318,8 +318,10 @@ class ElmhurstSiteNotesExtractor: continue if area <= 0: continue - thickness_raw = self._local_val(lines, f"Alternative Wall {n} Thickness") - thickness_mm = self._parse_thickness_mm(thickness_raw) + thickness_raw: Optional[str] = self._local_val( + lines, f"Alternative Wall {n} Thickness" + ) + thickness_mm: Optional[int] = self._parse_thickness_mm(thickness_raw) result.append(AlternativeWall( area_m2=area, wall_type=self._local_str(lines, f"Alternative Wall {n} Type"), @@ -365,8 +367,8 @@ class ElmhurstSiteNotesExtractor: return int(match.group()) if match else None def _roof_details_from_lines(self, lines: List[str]) -> RoofDetails: - thickness_raw = self._local_val(lines, "Insulation Thickness") - thickness_mm = self._parse_thickness_mm(thickness_raw) + thickness_raw: Optional[str] = self._local_val(lines, "Insulation Thickness") + thickness_mm: Optional[int] = self._parse_thickness_mm(thickness_raw) insulation = self._local_str(lines, "Insulation") # The Summary PDF omits the "Insulation Thickness" line entirely # when no retrofit insulation is lodged (e.g. "Insulation: N None" @@ -390,14 +392,14 @@ class ElmhurstSiteNotesExtractor: return self._roof_details_from_lines(lines) def _floor_details_from_lines(self, lines: List[str]) -> FloorDetails: - u_val_raw = self._local_val(lines, "Default U-value") - default_u = float(u_val_raw) if u_val_raw else None + u_val_raw: Optional[str] = self._local_val(lines, "Default U-value") + default_u: Optional[float] = float(u_val_raw) if u_val_raw else None # RdSAP 10 §5.13 Table 20 — retro-fitted upper floors lodge an # "Insulation Thickness: NNN mm" cell so the cascade can route # via the per-thickness column. Mirror of the §8 roof extractor # at `_roof_details_from_lines`. - thickness_raw = self._local_val(lines, "Insulation Thickness") - thickness_mm = self._parse_thickness_mm(thickness_raw) + thickness_raw: Optional[str] = self._local_val(lines, "Insulation Thickness") + thickness_mm: Optional[int] = self._parse_thickness_mm(thickness_raw) return FloorDetails( location=self._local_str(lines, "Location"), floor_type=self._local_str(lines, "Type"), From 77f90e144edd01ca0b04183aab514541bc8f7667 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 10:07:24 +0000 Subject: [PATCH 77/80] review: store epc_building_part.wall_insulation_thickness as JSONB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback (dancafc): the SQLModel column was Optional[str], but the domain `SapBuildingPart.wall_insulation_thickness` is Optional[Union[str, int]] — `_api_resolve_wall_insulation_thickness` returns an int mm when the API lodges `wall_insulation_thickness == "measured"` (SAP 10.2 §5.7 / Table 8). The plain str column round-trips that int back as the string "100", corrupting the Table 8 insulated-wall U-value lookup. This column was missed in the round-trip-fidelity §1 JSONB sweep (#1129) — its `Union[str, int]` sibling `roof_insulation_thickness` was converted, but `wall_insulation_thickness` was not, and no 21.0.0/21.0.1 fixture lodges "measured" so the gap stayed latent. Convert to JSONB (matching `roof_insulation_thickness` / `flat_roof_insulation_thickness`), align the column type to Optional[Union[str, int]] (also removes a pyright type-mismatch), record it in the migration doc §1, and add a round-trip guard test asserting an int survives as an int (fails as '100' == 100 on the old str column). Co-Authored-By: Claude Opus 4.8 --- .../epc-property-round-trip-fidelity.md | 7 ++-- infrastructure/postgres/epc_property_table.py | 10 +++++- tests/repositories/epc/test_epc_round_trip.py | 34 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/migrations/epc-property-round-trip-fidelity.md b/docs/migrations/epc-property-round-trip-fidelity.md index d9ed6557..53590a2f 100644 --- a/docs/migrations/epc-property-round-trip-fidelity.md +++ b/docs/migrations/epc-property-round-trip-fidelity.md @@ -32,8 +32,9 @@ and **still require the matching DB migration** wherever the physical tables liv `heating_secondary_heating_type`, `heating_shower_outlet_type`, `energy_pv_connection`; `epc_main_heating_detail`: `main_fuel_type`, `heat_emitter_type`, `emitter_temperature`, `main_heating_control`; `epc_building_part`: `wall_construction`, `wall_insulation_type`, - `party_wall_construction`, `flat_roof_insulation_thickness`, `roof_insulation_location`, - `roof_insulation_thickness`; `epc_window`: `glazing_gap`, `orientation`, `window_type`, + `party_wall_construction`, `wall_insulation_thickness`, `flat_roof_insulation_thickness`, + `roof_insulation_location`, `roof_insulation_thickness`; `epc_window`: `glazing_gap`, + `orientation`, `window_type`, `glazing_type`, `window_location`, `window_wall_type`, `draught_proofed`, `permanent_shutters_present`, `transmission_data_source`). - **New scalar columns** — `epc_property`: `heating_number_baths`, `heating_number_baths_wwhrs`, @@ -68,7 +69,7 @@ in the forward mapper so the Python type round-trips exactly (JSON scalars prese |---|---| | `epc_property` | `heating_cylinder_size`, `heating_immersion_heating_type`, `heating_cylinder_insulation_type`, `heating_secondary_heating_type`, `heating_shower_outlet_type`, `energy_pv_connection` | | `epc_main_heating_detail` | `main_fuel_type`, `heat_emitter_type`, `emitter_temperature`, `main_heating_control` | -| `epc_building_part` | `wall_construction`, `wall_insulation_type`, `party_wall_construction`, `flat_roof_insulation_thickness`, `roof_insulation_location`, `roof_insulation_thickness` | +| `epc_building_part` | `wall_construction`, `wall_insulation_type`, `party_wall_construction`, `wall_insulation_thickness`, `flat_roof_insulation_thickness`, `roof_insulation_location`, `roof_insulation_thickness` | | `epc_window` | `glazing_gap`, `orientation`, `window_type`, `glazing_type`, `window_location`, `window_wall_type`, `draught_proofed`, `permanent_shutters_present` | (`energy_meter_type` and `energy_wind_turbines_terrain_type` are `str` in the domain — leave as diff --git a/infrastructure/postgres/epc_property_table.py b/infrastructure/postgres/epc_property_table.py index 539628bd..4b235d34 100644 --- a/infrastructure/postgres/epc_property_table.py +++ b/infrastructure/postgres/epc_property_table.py @@ -549,7 +549,15 @@ class EpcBuildingPartModel(SQLModel, table=True): building_part_number: Optional[int] = Field(default=None) wall_dry_lined: Optional[bool] = Field(default=None) wall_thickness_mm: Optional[int] = Field(default=None) - wall_insulation_thickness: Optional[str] = Field(default=None) + # Union[str, int] — int mm when the API lodges + # `wall_insulation_thickness == "measured"` (resolved by + # `_api_resolve_wall_insulation_thickness`), else the lodged string + # ("NI", a numeric string, ...). JSONB to preserve int vs str on + # round-trip, exactly like the sibling `roof_insulation_thickness` / + # `flat_roof_insulation_thickness`. + wall_insulation_thickness: Optional[Union[str, int]] = Field( + default=None, sa_column=Column(JSONB, nullable=True) + ) floor_heat_loss: Optional[int] = Field(default=None) floor_insulation_thickness: Optional[str] = Field(default=None) flat_roof_insulation_thickness: Optional[Union[str, int]] = Field( diff --git a/tests/repositories/epc/test_epc_round_trip.py b/tests/repositories/epc/test_epc_round_trip.py index 192027f7..8233bda3 100644 --- a/tests/repositories/epc/test_epc_round_trip.py +++ b/tests/repositories/epc/test_epc_round_trip.py @@ -48,3 +48,37 @@ def test_epc_property_data_round_trips(schema_dir: str, db_engine: Engine) -> No # Assert assert reloaded == original + + +def test_building_part_wall_insulation_thickness_preserves_int( + db_engine: Engine, +) -> None: + # SAP 10.2 §5.7/Table 8: when the API lodges + # `wall_insulation_thickness == "measured"`, the mapper resolves the + # value to an int mm. The `epc_building_part.wall_insulation_thickness` + # column must therefore preserve int vs str on round-trip (JSONB), like + # its `roof_insulation_thickness` sibling — a plain str column would + # round-trip the int 100 back as "100" and corrupt the Table 8 lookup. + from dataclasses import replace + + # Arrange — take a green fixture and force the measured-int case. + original = _load_epc("RdSAP-Schema-21.0.0") + assert original.sap_building_parts, "fixture must have a building part" + bp0 = replace(original.sap_building_parts[0], wall_insulation_thickness=100) + original = replace( + original, + sap_building_parts=[bp0, *original.sap_building_parts[1:]], + ) + + # Act + with Session(db_engine) as session: + epc_property_id = EpcPostgresRepository(session).save(original) + session.commit() + with Session(db_engine) as session: + reloaded = EpcPostgresRepository(session).get(epc_property_id) + + # Assert — the int survives as an int, not the string "100". + assert reloaded is not None + value = reloaded.sap_building_parts[0].wall_insulation_thickness + assert value == 100 + assert isinstance(value, int) From 86b875af35fdb433acdc4571e67f5b9bd0e489a5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 10:18:58 +0000 Subject: [PATCH 78/80] review: clearer room-in-roof area variable names in heat_transmission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback (dancafc): the simplified room-in-roof branch used cryptic locals. Rename for clarity (behaviour-unchanged; the geom dict keys and the builder-function locals are untouched): rr_a_rr -> rr_roof_area (the worksheet's simplified A_RR) rr_common -> rr_common_wall_area rr_gable -> rr_gable_area a_rr_final -> rr_residual_roof_area (leftover roof-going area after deducting perimeter walls/gables /rooflights — takes the roof U) Names now mirror the `rr_*_area_m2` geom keys they read from and say "area of what". Added a one-line note that `rr_roof_area` is the RdSAP 10 §3.10.1 A_RR. Pyright unchanged; 1087 heat-transmission/cascade-pin tests pass. Co-Authored-By: Claude Opus 4.8 --- .../worksheet/heat_transmission.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index dff61a08..a961d60f 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -980,21 +980,28 @@ def heat_transmission_from_cert( # U_main_wall per spec page 23 ("Common wall U-value is inferred # from the U-value of the main wall in the building part below"; # gables fall under the same Table 4 rule). - rr_a_rr = geom["rr_simplified_a_rr_m2"] - rr_common = geom["rr_common_wall_area_m2"] - rr_gable = geom["rr_gable_area_m2"] + # `rr_roof_area` is the worksheet's simplified A_RR (the notional + # room-in-roof roof area, RdSAP 10 §3.10.1). Its perimeter common + # walls + gables are billed to walls; the leftover residual is the + # roof-going area that takes the roof U-value. + rr_roof_area = geom["rr_simplified_a_rr_m2"] + rr_common_wall_area = geom["rr_common_wall_area_m2"] + rr_gable_area = geom["rr_gable_area_m2"] rr_detailed_area = 0.0 - if rr_a_rr > 0: + if rr_roof_area > 0: rir = part.sap_room_in_roof - assert rir is not None # rr_a_rr > 0 ⇒ rir present per _part_geometry - walls += uw * (rr_common + rr_gable) + assert rir is not None # rr_roof_area > 0 ⇒ rir present per _part_geometry + walls += uw * (rr_common_wall_area + rr_gable_area) # Deduct any "Roof of Room" rooflights piercing the RR shell # (see `rw_area_on_rr` rationale at the gross-roof block). - a_rr_final = max(0.0, rr_a_rr - rr_common - rr_gable - rw_area_on_rr) + rr_residual_roof_area = max( + 0.0, + rr_roof_area - rr_common_wall_area - rr_gable_area - rw_area_on_rr, + ) u_rr = u_rr_default_all_elements( country=country, age_band=rir.construction_age_band, ) - roof += u_rr * a_rr_final + roof += u_rr * rr_residual_roof_area elif part.sap_room_in_roof is not None and part.sap_room_in_roof.detailed_surfaces: # RdSAP10 §3.10 Detailed RR — iterate per-surface lodgement. # Slope / flat_ceiling / stud_wall route to roof (worksheet @@ -1007,7 +1014,8 @@ def heat_transmission_from_cert( # band". Wall-going RIR surfaces (gable_wall, gable_wall_ # external, common_wall) deduct from the simplified A_RR # to leave the residual area, mirroring the Simplified - # branch's `a_rr_final = rr_a_rr - rr_common - rr_gable`. + # branch's `rr_residual_roof_area = rr_roof_area - + # rr_common_wall_area - rr_gable_area`. # Roof-going surfaces (slope / flat_ceiling / stud_wall) # do NOT deduct — they sit inside the RR shell rather than # forming its perimeter walls. @@ -1179,7 +1187,7 @@ def heat_transmission_from_cert( main_wall_area + (alt_walls_total_area - alt_window_area) + roof_area + floor_area_total - + w_area + d_area + rw_area_part + rr_a_rr + rr_detailed_area + + w_area + d_area + rw_area_part + rr_roof_area + rr_detailed_area + cantilever_area ) total_external_area += part_external_area From 5597a8b87eb6ce36eef3aa4045b7948ce36f377c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 10:26:17 +0000 Subject: [PATCH 79/80] review: bind test inputs in Arrange for the wall-insulation-thickness tests PR feedback (dancafc): the `_api_resolve_wall_insulation_thickness` tests passed literals straight into the Act call. Bind them as named variables in Arrange (`lodged_thickness`, `measured_value_mm`, `ni_lodgement`) and have the asserts reference those names, so the Act line reads declaratively and the inputs/expectations are stated once. Applied to all three tests in the class. No behaviour change; tests pass. Co-Authored-By: Claude Opus 4.8 --- .../domain/tests/test_from_rdsap_schema.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 5a4796a5..c1ef3ad3 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -717,11 +717,16 @@ class TestApiResolveWallInsulationThickness: _api_resolve_wall_insulation_thickness, ) + lodged_thickness = "measured" + measured_value_mm = 100 + # Act - resolved = _api_resolve_wall_insulation_thickness("measured", 100) + resolved = _api_resolve_wall_insulation_thickness( + lodged_thickness, measured_value_mm + ) # Assert - assert resolved == 100 + assert resolved == measured_value_mm def test_non_measured_lodgement_passes_through_unchanged(self) -> None: # Arrange @@ -729,12 +734,17 @@ class TestApiResolveWallInsulationThickness: _api_resolve_wall_insulation_thickness, ) + ni_lodgement = "NI" + measured_value_mm = 100 + # Act - ni: object = _api_resolve_wall_insulation_thickness("NI", 100) + ni: object = _api_resolve_wall_insulation_thickness( + ni_lodgement, measured_value_mm + ) none_thk: object = _api_resolve_wall_insulation_thickness(None, None) # Assert - assert ni == "NI" + assert ni == ni_lodgement assert none_thk is None def test_measured_without_value_passes_through(self) -> None: @@ -743,11 +753,16 @@ class TestApiResolveWallInsulationThickness: _api_resolve_wall_insulation_thickness, ) + lodged_thickness = "measured" + measured_value_mm = None + # Act - resolved: object = _api_resolve_wall_insulation_thickness("measured", None) + resolved: object = _api_resolve_wall_insulation_thickness( + lodged_thickness, measured_value_mm + ) # Assert - assert resolved == "measured" + assert resolved == lodged_thickness # --------------------------------------------------------------------------- From a6b798218fb3dd6821318f4a815a39474276673e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 10:45:44 +0000 Subject: [PATCH 80/80] fix: normalize empty API sap_roof_windows to None for round-trip fidelity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_epc_property_data_round_trips[RdSAP-Schema-21.0.1]` failed with `sap_roof_windows: None != []` — a normalization mismatch, not lost data. The 21.0.1 fixture has no roof windows, but the 21.0.1 API mapper emitted an empty list `[]` while the domain field defaults to None (`Optional[List[SapRoofWindow]] = None`), the 21.0.0 path yields None, and the persistence reload yields None (roof windows aren't stored yet — doc §2.4). Append `or None` so "no roof windows" has one canonical representation across mapper paths and the round-trip. No data-loss change: a cert WITH roof windows still produces the populated list (test_golden_fixtures pins a 6-roof-window cert), and the §2.4 roof-window persistence gap remains separately tracked. Full sap10_calculator + documents_parser + epc-repository suites pass (2420); pyright unchanged. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 32765df1..83a0a9eb 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1634,11 +1634,17 @@ class EpcPropertyDataMapper: for w in schema.sap_windows if not _api_is_roof_window(w) ], + # Empty → None (not []) so "no roof windows" has the single + # canonical representation the domain field defaults to + # (`Optional[List] = None`), matching the 21.0.0 path and the + # persistence round-trip (roof windows aren't yet stored — doc + # §2.4 — so a reloaded cert always reads None here). sap_roof_windows=[ _api_sap_roof_window(w) for w in schema.sap_windows if _api_is_roof_window(w) - ], + ] + or None, # SAP energy source sap_energy_source=SapEnergySource( mains_gas=es.mains_gas == "Y",

  • CK7@$@Ah8Bo^>z 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�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 z z>j9$LdFBaQzBY05^lT5*k+9GfiuwYY2`2V&N&|R}MaiarB;4!X!4?gEI)~cdVTIdb z^x3Fj`ci+;pnZ);Z_>k9tN<1FnB#|}Nxa_QgzBZq z9Nl|%E}kz`J(8^6U|2oYdnJ}{;OM@#{otuwz^fn zRU4#cQ!WQHDFDU<%|&_ZhroXL{dWnc_s6H?^?A*>b2bFK9D@cA?Woe z;QH;`8ND4>@C_7@;vawk<5;0sO7{N}uDbYCb%XM8;8)za#)z$TK>4*F7Va5p3CDCy zu>wT2oU8W-(}HpYO>`ulM`W7VQhn<9Hh9g2fEHl6f5#Va{Xl!hovj;9HkaO(y<;8k zAXg>l9ovQlLZ5p}1*MfMoI;@2l5*m*_~cr!RWaZ2UtsSqsgnN$d;h}Ee+PTHL4W>Z zg%X=I4+#2~<$oLh20{PFk-v59|McWPOxkd&~?4bjUFEEi{7T9QRoQn=Y~>V2@3E2LmV<9!!@Iqviir&fFJ7=2Ci&=F8&>0@$pr2AV9CC!l=x6a@ng=jsPWLC z_Nz|6W`(uy(MPkzO0N4RboqJofiul=l@E(S2|-7WSEE&0lqP~Aq$b^nS;e?z7PP0q zo&~I2OMFqQ)x9z*PV&l%grz-X!gh(RCxX3he8RrW810o$*89Gl) z6bSmvPi)NDKK@g$T6D~G8K>)5%`7$$2SP~G^B1{;dSo3nV`UV63ED3n(%?GOu zAfx67oOA++O^6Q(%KU(ByTF7ar=4$aJ!R&4;m2U^?)$cQPkE)UeJjok;+l<&oZr9L zTZ6lUeLbY}7|UMK8@$vLNV*p4kSF!7Fs*3292HZ>-b{gseXyD565JYOqsjeN>7Sg7 zsb+0DZWY)^UZ8M?C3j}e3x@7VP(Y6JXe{811LHj}dN-3FeG5~sWXN1|>B|UpP*13H za{Z&0P!>PDCK%B(*XqG*2I^e7mPdm#4(Lg;k8lVX=q(w$Onopr7xoZIQQEvYV@+Z) zgG%w)EQ-VJYAD5AD|sfoy@`g&;X@#22}zaGbatwun@>yeG6WHe*ILo$%HcME9z&8hw#B^lqDzF4dGxfR?-(JU+YK-~OQqU`s$?L3N<}tO+4hRcxhDP# zcoOx5j|#n6=x$!@=X@O#|GY-*2Y0#rWJQdXU?8aUWqnl$fiq)k8NRp8Qk*Ji*~?0- z#dog|Th>5QrfW2$j)YtE=?`ygG~G@~wW0XR@&EzbQ(_~Wa`k=|alu1cjWDrS%1p?# zrN%yey%FR<=3CS;pg}Y0NfNbXP+C9X+pD2vOrP;Db(}(%)fweqUr*^_&$(%xT`wHb z5MU@;Sdtlq7+G&0%{Fd$7O4O#D(apBUDw`>mLC=Z&-y~{<=aT^4z0CKou9v#diI@0 znSnSj;(J+kZ^arjHBAYi&kJyf67;)8icg;sBI04q0jXi5K!z|4Yg9HVwxR|pJDvijJ z4sg`S-V(d!zR+A;d_G96LExF%Xk~1e=QqM5prG>-MWqkyH_b$ccH=gA7fBf|8vsTk!s=24jb_|2h{A2#n*`xo~hnZ=+d1x8;PvIc`Pn zPyO9Stzf_Q#|4LS{@Rv{8v^;YEf+WZxA}4L{1L4Dc?>Qd&f75N&owv%!u@*<%Khv5 z!6Dp#gt~s3GaSP6`*rL*zugBM28H~(F1ITH|E)g`p4&|BFJs(Z`0EY$3LG$ZU~f{ L8$d%NE-&$4lz;!M literal 0 HcmV?d00001 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr8.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr8.py new file mode 100644 index 00000000..8011704e --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_rr8.py @@ -0,0 +1,112 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 3" worksheet — a near-exact replica of golden cert +6035 (Main + Extension + Simplified room-in-roof, 8 windows). + +Like 000565 / sim case 1 / sim case 2, this fixture does NOT hand-build +the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result +pin grid exercises the WHOLE extractor + mapper + calculator pipeline. + +Purpose: prove the calculator is spec-correct for the 6035 archetype +(after S0380.192 Simplified-RR + S0380.193 suspended-floor fixes). This +cert reproduces 6035's 8 windows (≈14.15 m²) and Main ground-floor +heat-loss perimeter (15.99 m). It still differs from 6035 in ONE input: +the Main FIRST-floor HLP is 15.99 here vs 6035's 8.32 (6035's upper +storey has less exposed perimeter), so it is not yet byte-identical to +6035. All 11 Block-1 line refs nonetheless pin at abs=1e-4 against this +cert's OWN worksheet, confirming the cascade reproduces the spec engine +exactly for this Main+Ext+RR+suspended-floor+gas-combi shape — so 6035's +residual +19 PE vs the lodged register is lodged-register divergence, +not a cascade gap. + +Cert shape: Main + Extension 1, both solid brick WITH internal +insulation (Main) / as-built (Ext1), 3 storeys, Simplified room-in-roof +on the Main (floor 29.75 m², exposed + party gables), suspended +uninsulated ground floors, gas-combi SAP code 104, 8 windows, no PV. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 3/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_rr8w.pdf` +(distinct name — the corpus reuses cert 001431). + +Worksheet pin targets (P960-0001-001431, Block 1 — energy rating): +- SAP rating 68 (line 258), ECF 2.3146 (line 257) +- Total fuel cost £951.3425 (line 255) +- CO2 4767.4862 kg/year (line 272) +- Space heating 16086.3557 kWh/year (Σ monthly (98)) +- Main 1 fuel 19150.4235 kWh/year (line 211) +- Secondary fuel 0.0 (line 215) +- Hot water fuel 3307.2639 kWh/year (line 219) +- Lighting 262.0885 kWh/year (line 232) +- Pumps/fans 86.0 kWh/year (line 231) + +Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation- +philosophy]]: pins are abs=1e-4 against the worksheet PDF. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_rr8w.pdf" +) + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label\\nvalue sequences). + + Mirror of the helper in `test_summary_pdf_mapper_chain.py` / + `_elmhurst_worksheet_000565.py`. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-2 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.192 Simplified-RR fix and the + S0380.193 suspended-floor sealed-rule fix. + """ + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index f2abc332..f121b0e6 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -39,6 +39,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_000565 as _w000565, _elmhurst_worksheet_001431 as _w001431, _elmhurst_worksheet_001431_rr as _w001431_rr, + _elmhurst_worksheet_001431_rr8 as _w001431_rr8, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -183,6 +184,23 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=282.6414, pumps_fans_kwh_per_yr=86.0, ), + # Mapper-driven cohort entry — Summary_001431_rr8w.pdf → extractor → + # mapper → calculator. Near-exact 6035 replica: Main + Extension + + # Simplified room-in-roof, 8 windows (≈14.15 m², matching 6035), + # suspended uninsulated floors. Differs from 6035 only in the Main + # first-floor HLP (15.99 here vs 6035's 8.32). Pins at 1e-4 confirm + # the cascade is spec-correct for the archetype → 6035's +19 PE vs + # the lodged register is lodged-register divergence, not a calc gap. + "001431_rr8": FixtureCascadePins( + sap_score=68, sap_score_continuous=67.7118, ecf=2.3146, + total_fuel_cost_gbp=951.3425, co2_kg_per_yr=4767.4862, + space_heating_kwh_per_yr=16086.3557, + main_heating_fuel_kwh_per_yr=19150.4235, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3307.2639, + lighting_kwh_per_yr=262.0885, + pumps_fans_kwh_per_yr=86.0, + ), } @@ -196,6 +214,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "000565": _w000565, "001431": _w001431, "001431_rr": _w001431_rr, + "001431_rr8": _w001431_rr8, } From 4a21717de605f826a725ba47878e9bf6f6f42ae2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 09:56:39 +0000 Subject: [PATCH 04/80] =?UTF-8?q?S0380.195:=20pin=20sim=20case=204=20(6035?= =?UTF-8?q?=20floor=20geometry)=20e2e=20at=201e-4=20=E2=80=94=206035=20+19?= =?UTF-8?q?=20PE=20is=20lodged=20divergence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user-simulated case-4 worksheet as e2e fixture `001431_6035` — reproduces golden cert 6035's full floor geometry (Main ground-floor HLP 15.99 + first-floor HLP 8.32, the asymmetric upper storey) and 8 windows. All 11 Block-1 line refs pin at abs=1e-4 against the worksheet (SAP 68, ECF 2.2802, cost 937.2341, CO2 4682.3494, space 15745.3260, main fuel 18744.4357). This is the 4th independent 1e-4 confirmation across the 6035 archetype (sim cases 1-4). Case 4 matches 6035 on floors + window areas; the residual ~50 kWh / £11 cascade delta vs 6035 is two lodged inputs only (largest window orientation N vs S; meter type "Dual" vs API 2), not calculator behaviour. Conclusion: the cascade reproduces the spec engine exactly for 6035's geometry, so 6035's +19 PE vs the lodged register is lodged-register divergence (the gov.uk register's rounded value vs the spec-exact worksheet), NOT a calculator gap. 6035 is a "pin-forever" lodged-only cert. Bugs surfaced + fixed along the way: S0380.192 (Simplified-RR remaining area) and S0380.193 (suspended-floor sealed rule). 2341 passed (+11), 0 failed; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../tests/fixtures/Summary_001431_6035.pdf | Bin 0 -> 80860 bytes ...60-0001-001431 - 2026-06-03T105104.313.pdf | Bin 0 -> 46015 bytes .../simulated case 4/Summary_001431 (1).pdf | Bin 0 -> 80860 bytes .../_elmhurst_worksheet_001431_6035.py | 114 ++++++++++++++++++ .../worksheet/test_e2e_elmhurst_sap_score.py | 17 +++ 5 files changed, 131 insertions(+) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_6035.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 4/P960-0001-001431 - 2026-06-03T105104.313.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 4/Summary_001431 (1).pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_6035.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_6035.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_6035.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6c31c0d8dabff1eba5a9895e3fb31d3fc1d398d3 GIT binary patch literal 80860 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Ü>4~sZ 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�up&^cRk63JMP_z%zL4fu<<{q5}A|993x;cq% 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 HRp$QysFu+I literal 0 HcmV?d00001 diff --git a/sap worksheets/golden fixture debugging/simulated case 4/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 4/Summary_001431 (1).pdf new file mode 100644 index 0000000000000000000000000000000000000000..6c31c0d8dabff1eba5a9895e3fb31d3fc1d398d3 GIT binary patch literal 80860 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Ü>4~sZ ztgIj^|o7eah4UH8o9w)b8tV#;cD?sm}+)?-kj}?2WE>PXGM*PWI_{dNpR! z!szc+Wh#E8jCcBF{3_W;2Z4?3e4LUy!C6o`KF;u&+>f#q{pxFwEX++|&`YYAvGq(9 zJUhSuVWJvb#&w{Az3uvqsMWfnzCzjO2Z>(-*EPW>CK*{qBpL9KvR4r{OFiS5kq z!?Lf1o*E5bBgD6ES|4#9%d_BX?d@5PQ}F_9bz1t@XUEvv2t!3hZTQ%74{J>+QV;Po zDZEMD3!dvNEj=5d(kk40!2f!He7wBeyg+l{pRwEW^VZ|yZ)9l6wLq6c3k{CzwRHD8v_1y|892x_Uqc9Jhw^zI>&9pU)KSJ^6~tmFAVn2 z@%V3({&PHMM`KHCGe>}s5PtZ>Nl7Jh=>53oQ)lwe}5BUU list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label\\nvalue sequences). + + Mirror of the helper in `test_summary_pdf_mapper_chain.py` / + `_elmhurst_worksheet_000565.py`. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-2 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.192 Simplified-RR fix and the + S0380.193 suspended-floor sealed-rule fix. + """ + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index f121b0e6..1091c013 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -40,6 +40,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431 as _w001431, _elmhurst_worksheet_001431_rr as _w001431_rr, _elmhurst_worksheet_001431_rr8 as _w001431_rr8, + _elmhurst_worksheet_001431_6035 as _w001431_6035, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -201,6 +202,21 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=262.0885, pumps_fans_kwh_per_yr=86.0, ), + # Mapper-driven cohort entry — Summary_001431_6035.pdf → extractor → + # mapper → calculator. Reproduces 6035's full floor geometry (Main + # ground HLP 15.99 + first 8.32, asymmetric) and 8 windows. Residual + # vs 6035 is two lodged inputs only (largest window orientation, + # meter type). Pins at 1e-4 → 6035's +19 PE is lodged divergence. + "001431_6035": FixtureCascadePins( + sap_score=68, sap_score_continuous=68.1906, ecf=2.2802, + total_fuel_cost_gbp=937.2341, co2_kg_per_yr=4682.3494, + space_heating_kwh_per_yr=15745.3260, + main_heating_fuel_kwh_per_yr=18744.4357, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3307.8383, + lighting_kwh_per_yr=262.0885, + pumps_fans_kwh_per_yr=86.0, + ), } @@ -215,6 +231,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "001431": _w001431, "001431_rr": _w001431_rr, "001431_rr8": _w001431_rr8, + "001431_6035": _w001431_6035, } From af477678c248b5631343f8ae17eaea394123201b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 10:14:17 +0000 Subject: [PATCH 05/80] =?UTF-8?q?docs:=20handover=20post=20S0380.195=20?= =?UTF-8?q?=E2=80=94=206035=20OPEN,=20API-mapper=20roof/RR=20over-count=20?= =?UTF-8?q?lead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retracts the premature "6035 = lodged divergence" claim (S0380.195 commit msg + fixture docstring). The golden residual SAP -2 / PE +19.16 / CO2 +0.42t is REAL and exceeds the fallback bar. Section-level diff of 6035 (API) vs sim case 4 (site-notes, pins @1e-4) localised it to a cross-mapper parity break: roof W/K 78.33 (site-notes) vs 130.73 (API), a +52 over-count from the API RR scalar path + roof_construction=4. Next agent starts there. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_POST_S0380_195.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_POST_S0380_195.md diff --git a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_195.md b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_195.md new file mode 100644 index 00000000..a5f6945c --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_195.md @@ -0,0 +1,147 @@ +# Handover — post S0380.195 (gas-combi site-notes + RR/floor bugs; 6035 OPEN) + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, +accuracy bar, and pipeline — this records *what this session did* and *what is open*. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `4a21717d` (S0380.195) +- **Baseline:** `2341 passed, 1 skipped, 0 failed`. Verify with the §4 suite command. + +--- + +## What this session shipped (S0380.190–195 + 1 extractor fix) + +| Slice | What | Spec | +|---|---|---| +| **.190** | Gas-combi site-notes `main_fuel_type` derivation. Newer Elmhurst export lodges §14.0 Fuel Type EMPTY + SAP code 104 → `MissingMainFuelType` blocked ALL gas-combi Summary certs. Derive carrier from §15.0 Water Heating Fuel Type for Table 4b gas-boiler codes 101–119 (NOT "104→mains gas" — Table 4b gas codes span mains gas/LPG/biogas; §15.0 disambiguates). `_elmhurst_gas_boiler_main_fuel`. | SAP 10.2 Table 4b p.168 | +| extractor fix | Windows-table header remnant ("value value Proofed Shutters") leaked into the FIRST window's `glazing_type` (layout `before_start=0` reaches the wrapped header). Trim prefix to the glazing-start word. | — | +| **.191** | Promoted sim case 1 (single-part gas combi) to e2e harness `001431`, 11 pins @1e-4. Resolved the handover's "+0.0007 SAP" as a display-rounded-ECF target artifact. | — | +| **.192** | **Simplified room-in-roof bug.** A Simplified RR (assessment "Simplified") lodges PLACEHOLDER slope/ceiling L×H (40 m ceiling height, 32 m slope). Spec derives one timber-framed remaining area `A_RR = 12.5√(A_floor/1.5) − Σgables`. The cascade already computes this (`has_roof_lodgement` gate in heat_transmission.py) but `_map_elmhurst_rir_surface` emitted the placeholder slope/ceiling → 1024+160 m² roof → **7.5× heat-loss explosion (SAP −14.6)**. Fix: drop roof-going surfaces for Simplified. API path (6035) already correct via scalar gable fields. | RdSAP 10 §3.9.1 p.21 | +| **.193** | **Suspended-floor (12) sealed rule.** Rule (a) "floor U<0.5 → sealed 0.1" applies only when a U-value is SUPPLIED; an as-built/default U falls to (b)→unsealed 0.2. Cascade fed the computed default U into (a) → wrongly sealed → ~450 kWh space-heat understatement. Fix: gate (a) on `floor_u_value_known`. | RdSAP 10 §5 p.29 | +| **.194** | Sim case 3 (8 windows, symmetric HLP) → e2e `001431_rr8`, 11 pins @1e-4. | — | +| **.195** | Sim case 4 (6035 floor geometry: Main ground HLP 15.99 + first 8.32) → e2e `001431_6035`, 11 pins @1e-4. | — | + +Net: 4 new Elmhurst-only e2e fixtures (cases 1–4 of cert 001431), all @1e-4. The +worksheet Summaries are mirrored into `backend/documents_parser/tests/fixtures/` +(`Summary_001431_gas_combi.pdf`, `_rr_ext`, `_rr8w`, `_6035`); source Summary + +P960 worksheet tracked under `sap worksheets/golden fixture debugging/simulated +case {1..4}/`. + +**The .195 commit message and the `_elmhurst_worksheet_001431_6035.py` docstring +claim 6035's +19 PE is "lodged divergence." THAT CLAIM IS RETRACTED — see below.** + +--- + +## OPEN (the priority) — golden cert 6035 residual is REAL, not divergence + +`tests/.../test_golden_fixtures.py` pins cert `6035-7729-2309-0879-2296`: +`actual_sap=70, expected_sap_resid=-2, expected_pe_resid=+19.16, +expected_co2_resid=+0.42 t`. **All three exceed the ±0.5 SAP / small-CO2 fallback +bar.** A −2 SAP is not rounding. 6035 was lodged **2025-11-11** under +RdSAP-Schema-21.0.1 / SAP 10.2 (software `5.02r0328`) — the SAME methodology we +target, so it is NOT a version artifact. + +### What we know +- **The cascade reproduces Elmhurst's WORKSHEET engine** for this archetype: sim + cases 1–4 (Main+Ext+RR+suspended-floor+gas-combi-104) all pin @1e-4 on all 11 + Block-1 line refs. +- **Case 4 ≈ 6035.** Identical: 2 BPs, age A, solid-brick walls (Main ins, Ext + as-built), RR floor 29.75, floor areas, **Main floors HLP 15.99/8.32**, doors, + heating (104/control 2106), 8 windows by **area + BP + 7/8 orientations**. + Remaining input diffs case4-vs-6035: + 1. the **3.82 m² window**: North in case 4, **South** in 6035 (only window diff); + 2. **lighting bulbs**: case 4 cascade lighting 262 vs 6035's **364** (6035 lodges + 9 low-energy + 2 incandescent; case 4's Summary lighting parsed as None); + 3. **meter type** "Dual" (case4) vs API **2** (6035); + 4. 6035 lodges `cylinder_size=1` (case 4 none) — appears immaterial (HW matches). +- **Controlled test:** flipping case 4's 3.82 window N→S raises SAP only **+0.25** + (68.19→68.44). Nowhere near +2. So orientation does NOT explain the gap. +- **The energy/demand model looks ~right per-end-use.** Cascade DEMAND + (postcode) costs ≈ 6035's lodged costs: heating £1278 vs lodged £1285, HW £225 + vs £217, lighting £103 vs £103. So the −2 SAP lives in the **RATING block** + (UK-avg): cascade rating cost 948.59 → ECF 2.31 → SAP 67.81; register implies + cost ~£886 / ECF 2.15 / SAP 70. **Plus the CO2 (+0.42 t) is unexplained.** +- Neither bug fixed this session touches 6035 (its RR uses the API scalar-field + path, already correct; its floor U=0.63 ≥ 0.5 was already "unsealed"). + +### The contradiction to resolve +Elmhurst-worksheet-for-case4-inputs = **68**, 6035-register = **70**, same +methodology, inputs nearly identical, and the known diffs explain only ~+0.25. +Either (a) 6035's register was produced from inputs materially different from the +golden JSON in a rating-relevant way we can't see, or (b) there's a real cascade +bug only 6035's exact combination triggers (the simulated cases didn't hit it). + +### ★ BREAKTHROUGH LEAD (end of session) — API-mapper roof/RR over-count +The user's hypothesis ("something missing from the API mapper") is CONFIRMED. +Diffing **6035 (API path) vs case 4 (site-notes path)** at the SECTION level +(`heat_transmission_section_from_cert`) — with near-identical fabric — exposes a +cross-mapper parity break that should not exist: + +| §3 line | case4 (site-notes) | 6035 (API) | Δ | +|---|---|---|---| +| **roof W/K** | **78.33** | **130.73** | **+52.39** | +| party W/K | 36.86 | 0.00 | −36.86 | +| (33) fabric heat loss | 290.72 | 304.66 | +13.94 | +| (31) total ext area | 231.02 | 242.74 | +11.72 | +| walls / floor / windows / doors | — | — | ≈0 | + +**The roof +52 W/K is the prime suspect for the whole 6035 residual** (52 W/K of +spurious heat loss ≈ the −2 SAP / +19 PE / +0.42 t CO2). Root cause is the RR/roof +representation feeding two DIFFERENT cascade paths: +- **case 4 (site-notes):** `sap_room_in_roof.detailed_surfaces=[gable_wall_external, + gable_wall]`, scalar gable lengths = None, `roof_construction=None` → cascade's + Detailed-loop residual path (`12.5√(A_floor/1.5) − Σwalls`) → roof 78.33. ✓ + (pins to case-4 worksheet @1e-4). +- **6035 (API):** `detailed_surfaces=None`, scalar `gable_1/2_length_m=4.65`, + **`roof_construction=4`** → cascade's SCALAR RR path (heat_transmission.py + ~363-460 + ~853-875) AND a separate `roof_construction=4` main-roof element → + roof 130.73. Likely DOUBLE-COUNTS the main roof over the full footprint with the + RR, or the scalar A_RR path over-states the area. + +Hand-check: for 6035 the correct roof ≈ RR remaining (12.5√(29.75/1.5) − 2×11.39 += 32.88 × 2.30 = 75.6) + main-loft residual (41.73−29.75=11.98 × 0.14 = 1.68) + +ext roof (7.21 × 0.14 = 1.01) ≈ **78.3** (matches case 4). The API path's 130.73 is +~52 too high. + +**START HERE:** instrument the API RR/roof path for 6035. Compare +`_api_build_room_in_roof` (mapper.py ~2713) output + `roof_construction=4` +handling vs the site-notes detailed_surfaces path. Find where the extra ~52 W/K +roof comes from (main-roof-area double count with RR, or scalar A_RR over-state). +Fix so the API path matches the site-notes path (cross-mapper parity), then re-pin +6035's golden residual (should collapse toward 0). The party=0 (party_wall_ +construction=3) is secondary — verify 3=solid U=0 is correct first. + +This is a CALCULATOR/MAPPER bug, not lodged divergence — the byte-exact-worksheet +plan below is now a fallback only. + +### Fallback — byte-exact 6035 worksheet ("simulated case 5") +Ask the user to generate case 5 = case 4 with EVERY remaining input matched to +6035: **3.82 m² window → South**, **lighting = 9 low-energy + 2 incandescent**, +**meter type matched**, **cylinder matched**. Then: +- If the worksheet SAP = **70** → real cascade bug. Diff cascade vs worksheet + line-by-line (start §6 solar gains (74)–(83) for the south window, §8 lighting + (232)/Appendix L, then §10a/§12 rating cost/ECF and §12/§13 CO2). +- If the worksheet SAP = **68** → the register's 70 is the anomaly (lodged from + different inputs); 6035 becomes a documented register-vs-worksheet divergence. + +Parallel angle worth a look NOW (no new worksheet needed): the **lighting energy** +(cascade 364 for 9 LE + 2 inc, TFA 128) — verify against SAP 10.2 Appendix L; and +the **CO2 (+0.42 t)** decomposition by carrier (the demand-cost match suggests the +energy is right, so a CO2-FACTOR or rating-block issue is implicated). + +--- + +## Carry-over (lower priority, from the prior handover) +- `transform.py:973` treats `wall_construction in (5,6)` as timber-frame for the + ventilation structural-ACH split, but 6 = system-built (masonry); only 5/7/8 are + timber/cob/park. Possible latent ventilation-ACH bug — verify before touching. +- Summary-path `main_fuel_type` for non-gas/non-104 boilers (only 101–119 + the + existing liquid/solid/electric/community branches are covered). + +## Process notes +- One slice = one commit, spec citation in the message, `Co-Authored-By: Claude + Opus 4.8` trailer. AAA tests, `abs(x-y) <= tol` (not `pytest.approx`). +- The 4 sim-case e2e fixtures pin Block 1 (UK-avg rating) via + `Sap10Calculator().calculate(epc)` — NOT the postcode demand block. +- Window ORIENTATION does NOT change the SAP rating much (+0.25 for 3.82 m²) — do + not over-attribute the 6035 gap to it. From 8861dac694b3afc4b65943cc5192f36f0d4a993d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 10:37:26 +0000 Subject: [PATCH 06/80] =?UTF-8?q?S0380.196:=20API=20Simplified=20Type=201?= =?UTF-8?q?=20RR=20gables=20deduct=20from=20A=5FRR=20shell=20(RdSAP=2010?= =?UTF-8?q?=20=C2=A73.9.1(e)=20p.21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden cert 6035's residual (SAP -2 / PE +19.16 / CO2 +0.42t) was a real API-mapper bug, NOT lodged divergence (prior claim retracted). The API `room_in_roof_type_1` block lodges gable walls by length only (no height). The mapper carried just the scalar `gable_*_length_m`, and the cascade's `_part_geometry` gable formula silently drops height-less gables (needs a height) -> the whole A_RR shell `12.5√(A_RR_floor/1.5)` billed as roof at U_RR=2.30 instead of the §3.9.1(e) residual `A_RR − Σ gables`. On 6035 that over-counted roof by 22.78 m² × 2.30 = +52.4 W/K (roof 130.73 -> 78.33, matching the site-notes case-4 replica at 1e-4 — cross-mapper parity). RdSAP 10 §3.9.1(e) (PDF p.21): "the area of the room-in-roof gable walls ... is deducted from A_RR to give the residual roof area." Fix: route the Type 1 gables through `detailed_surfaces` (gable area = L × the §3.9.1 default RR storey height 2.45 m; gable_wall_type 0=Party->gable_wall U=0.25, 1=Exposed->gable_wall_external "as common wall" per Table 4 p.22) so the cascade's Detailed-RR residual fires — the same path the site-notes mapper already uses. Re-pinned golden residuals: - 6035: SAP -2 -> +0 (exact), PE +19.16 -> +1.84, CO2 +0.42 -> +0.01 - 0240: same fix applies (2 Party gables L=6.4); PE +5.80 -> +3.91, CO2 +0.32 -> +0.22, SAP integer unchanged Also corrected the stale "gable_wall_type 0 = external" schema comment (6035's Summary proves 0=Party, 1=Exposed) and added a strict UnmappedApiCode raise for unknown gable_wall_type codes. Suite: 2342 passed, 1 skipped. New code: 0 pyright errors. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 90 +++++++++++++++++-- datatypes/epc/schema/rdsap_schema_21_0_0.py | 9 +- datatypes/epc/schema/rdsap_schema_21_0_1.py | 9 +- .../rdsap/test_golden_fixtures.py | 70 +++++++++++++-- 4 files changed, 155 insertions(+), 23 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 88a0e188..1f205786 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2710,20 +2710,84 @@ def _api_build_sap_floor_dimensions( return out +# RdSAP 10 §3.9.1 (PDF p.21): a Simplified Type 1 RR gable has no measured +# height — the worksheet applies the default RR storey height of 2.45 m, so +# the gable area is L × 2.45 (cert 6035 Summary lodges H=2.45 explicitly, +# matching this default; gable area 4.65 × 2.45 = 11.39 m²). +_RIR_TYPE_1_GABLE_HEIGHT_M: Final[float] = 2.45 + +# GOV.UK API `room_in_roof_type_1.gable_wall_type_*` integer → the +# `SapRoomInRoofSurface.kind` the cascade's Detailed-RR branch routes by +# U-value. Established from cert 6035's Summary (gable_wall_type_1=1 ↔ +# "Exposed" U=0.29; gable_wall_type_2=0 ↔ "Party" U=0.25): +# 0 = Party → `gable_wall` (Table 4 p.22 row 2, U=0.25) +# 1 = Exposed → `gable_wall_external` (Table 4 p.22 row 1, "as common wall") +_API_TYPE_1_GABLE_TYPE_TO_KIND: Dict[int, str] = { + 0: "gable_wall", + 1: "gable_wall_external", +} + + +def _api_type_1_gable_kind(gable_type: Optional[int]) -> str: + """Map a `gable_wall_type_*` code to the cascade's RR surface kind. + + `None` (type unlodged) defaults to `gable_wall` (party) — the modal + RR gable and the conservative choice (party billing at U=0.25 vs the + main-wall U). A lodged integer outside the known set raises + `UnmappedApiCode` so a new gable variant forces an explicit mapping + rather than silently mis-routing its U-value (mirror of the strict- + raise pattern on the other API helpers).""" + if gable_type is None: + return "gable_wall" + if gable_type not in _API_TYPE_1_GABLE_TYPE_TO_KIND: + raise UnmappedApiCode("gable_wall_type", gable_type) + return _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type] + + +def _api_type_1_gable_surfaces( + type_1: Any, +) -> Optional[List[SapRoomInRoofSurface]]: + """Translate the Simplified Type 1 scalar gable fields into the + per-surface list the cascade's Detailed-RR branch consumes. + + Gable area = L × the §3.9.1 default RR storey height (2.45 m); the + `gable_wall_type_*` code routes the kind (Exposed vs Party). U-values + are left to the cascade (Exposed falls through to the main-wall U; + Party uses the fixed 0.25). Returns None when no gable length is + lodged so the cascade keeps its existing residual-only behaviour.""" + surfaces: List[SapRoomInRoofSurface] = [] + for gable_type, length in ( + (type_1.gable_wall_type_1, type_1.gable_wall_length_1), + (type_1.gable_wall_type_2, type_1.gable_wall_length_2), + ): + if length is None or length <= 0: + continue + surfaces.append( + SapRoomInRoofSurface( + kind=_api_type_1_gable_kind(gable_type), + area_m2=_round_half_up_2dp( + float(length), _RIR_TYPE_1_GABLE_HEIGHT_M + ), + ) + ) + return surfaces or None + + def _api_build_room_in_roof( bp_rir: Any, *, is_flat: bool = False, ) -> Optional[SapRoomInRoof]: """Build `SapRoomInRoof` from the API schema's per-bp RR block. Two real-API shapes coexist: - `room_in_roof_type_1` (cohort certs 6035, 0240): RdSAP §3.9.1 - Simplified Type 1 — gable lengths only, cascade applies the - 2.45 m default storey height. + Simplified Type 1 — gable lengths only (no heights). Built into + `detailed_surfaces` using the 2.45 m default RR storey height so + the cascade's residual deducts the gables from the A_RR shell. - `room_in_roof_details` (cert 9501): RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling detail. - When the Detailed block is present, build `detailed_surfaces` so the - cascade's per-surface RR branch (heat_transmission.py:629) picks - up exact gable + flat-ceiling areas instead of falling through to - the Table 18 col(4) "all elements" default U. + For BOTH shapes we build `detailed_surfaces` so the cascade's + per-surface RR branch picks up exact gable + flat-ceiling areas and + the §3.9.1(e) residual roof (A_RR shell − Σ gables), instead of + billing the whole shell at the Table 18 col(4) "all elements" U. """ if bp_rir is None: return None @@ -2733,10 +2797,20 @@ def _api_build_room_in_roof( ) type_1 = getattr(bp_rir, "room_in_roof_type_1", None) if type_1 is not None: - # RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights — - # the cascade applies the 2.45 m default storey height). + # RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights). rir.gable_1_length_m = type_1.gable_wall_length_1 rir.gable_2_length_m = type_1.gable_wall_length_2 + # Route the gables through `detailed_surfaces` so the cascade's + # Detailed-RR residual deducts each gable from the A_RR shell + # (RdSAP 10 §3.9.1(e) p.21: A_RR_final = 12.5√(A_RR_floor/1.5) − + # Σ gables) — the same path the site-notes mapper builds. The + # scalar `gable_*_length_m` fields alone can't trigger this: the + # cascade's `_part_geometry` gable formula needs a height and + # silently drops height-less gables, billing the WHOLE shell as + # roof (a ~52 W/K over-count on cert 6035). Gable area = L × the + # §3.9.1 default RR storey height (2.45 m); the type code routes + # the U-value (Exposed → main-wall U, Party → 0.25). + rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1) details = getattr(bp_rir, "room_in_roof_details", None) if details is not None: rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat) diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 383a4a6e..c8cc6e23 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -175,10 +175,11 @@ class SapFloorDimension: class RoomInRoofType1: """RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only. - `gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.; - full enum not yet mapped). `gable_wall_length_*` is the run of the - external gable in metres. Heights are NOT lodged here — the cascade - applies the §3.9.1 default storey height (2.45 m).""" + `gable_wall_type_*` is the Table 4 gable variant (0 = Party, + 1 = Exposed — established from cert 6035's Summary; other variants + not yet seen). `gable_wall_length_*` is the run of the gable in + metres. Heights are NOT lodged here — the mapper applies the §3.9.1 + default RR storey height (2.45 m).""" gable_wall_type_1: Optional[int] = None gable_wall_type_2: Optional[int] = None gable_wall_length_1: Optional[float] = None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index e8925863..242d30b2 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -207,10 +207,11 @@ class SapFloorDimension: class RoomInRoofType1: """RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only. - `gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.; - full enum not yet mapped). `gable_wall_length_*` is the run of the - external gable in metres. Heights are NOT lodged here — the cascade - applies the §3.9.1 default storey height (2.45 m).""" + `gable_wall_type_*` is the Table 4 gable variant (0 = Party, + 1 = Exposed — established from cert 6035's Summary; other variants + not yet seen). `gable_wall_length_*` is the run of the gable in + metres. Heights are NOT lodged here — the mapper applies the §3.9.1 + default RR storey height (2.45 m).""" gable_wall_type_1: Optional[int] = None gable_wall_type_2: Optional[int] = None gable_wall_length_1: Optional[float] = None diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index dc000956..375fe930 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -41,6 +41,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, cert_to_demand_inputs, cert_to_inputs, + heat_transmission_section_from_cert, ) _FIXTURES_DIR = Path(__file__).parent / "fixtures" / "golden" @@ -82,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+5.8007, - expected_co2_resid_tonnes_per_yr=+0.3173, + expected_pe_resid_kwh_per_m2=+3.9138, + expected_co2_resid_tonnes_per_yr=+0.2213, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -120,7 +121,13 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "extract-fans default (age J, 4 hab rooms → 2 fans). " "Cascade ventilation HLC rises ~0.07 ACH × volume → SH " "demand rises proportionally; PE +2.5225 → +5.8007, CO2 " - "+0.1395 → +0.3173. SAP integer unchanged at 72." + "+0.1395 → +0.3173. SAP integer unchanged at 72. " + "Slice S0380.196 (the 6035 RR fix) also applies here: this " + "cert's `room_in_roof_type_1` lodges two gables (L=6.4, both " + "Party) with no height, previously dropped → roof over-count. " + "Routing them through `detailed_surfaces` deducts 2×(6.4×2.45) " + "from the A_RR shell → roof drops, tightening PE +5.8007 → " + "+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72." ), ), _GoldenExpectation( @@ -193,9 +200,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, - expected_sap_resid=-2, - expected_pe_resid_kwh_per_m2=+19.1566, - expected_co2_resid_tonnes_per_yr=+0.4211, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=+1.8379, + expected_co2_resid_tonnes_per_yr=+0.0103, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "S0380.189 fixed the dominant driver: walls are solid brick " @@ -223,7 +230,24 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "WHC=901 + main code 104). Eq D1 monthly blend (mean ~80%) " "produces ~150 kWh/yr more HW fuel than the pre-slice flat-" "winter calc → PE residual +46.0952 → +47.2928, CO2 +1.0495 " - "→ +1.0779." + "→ +1.0779. " + "Slice S0380.196 CLOSED the residual (the prior 'lodged " + "divergence' claim is RETRACTED — it was a real API-mapper " + "bug). The API `room_in_roof_type_1` block lodges two gable " + "walls (L=4.65 each) but no heights; the mapper carried only " + "the scalar lengths, and the cascade's `_part_geometry` gable " + "formula silently drops height-less gables → the whole " + "55.67 m² A_RR shell billed as roof at U_RR=2.30 instead of " + "the §3.9.1(e) residual `12.5√(29.75/1.5) − 2×11.39 = 32.89`. " + "That over-counted roof by 22.78 m² × 2.30 = +52.4 W/K (roof " + "130.73 → 78.33, matching the site-notes case-4 replica at " + "1e-4). Fix: route the Type 1 gables through `detailed_" + "surfaces` (gable area = L × the §3.9.1 default RR storey " + "height 2.45 m; Exposed → gable_wall_external, Party → " + "gable_wall) so the cascade's Detailed-RR residual fires. " + "SAP resid -2 → +0 (exact), PE +19.16 → +1.84, CO2 " + "+0.42 → +0.01. Remaining +1.84 PE is unrelated gains/HW " + "(no worksheet for 6035 itself to pin further)." ), ), _GoldenExpectation( @@ -731,3 +755,35 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( assert main.main_heating_index_number == expected_pcdb_id if expected_winter_eff is not None: assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3 + + +def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None: + """Cert 6035 lodges a Simplified Type 1 room-in-roof (`room_in_roof_ + type_1`) with two gable walls (L=4.65 each). Per RdSAP 10 §3.9.1(e) + (PDF p.21) the gable areas deduct from the A_RR shell — the residual + roof area is `12.5√(A_RR_floor/1.5) − Σ gables`, NOT the full shell. + + The API mapper must route these scalar gables through + `detailed_surfaces` (gable area = L × the §3.9.1 default RR storey + height 2.45 m) so the cascade's Detailed-RR residual fires, exactly + as the site-notes path does. Before the fix the gables (no lodged + height) were silently dropped → the whole 55.67 m² shell billed at + U_RR=2.30, a +52 W/K roof over-count and the entire 6035 residual. + + Cross-mapper parity: this is the value the site-notes case-4 replica + (`worksheet/_elmhurst_worksheet_001431_6035.py`) pins to its + worksheet at 1e-4: + loft (41.73−29.75=11.98) × U_roof(300 mm) = 1.6772 + ext (7.21) × U_roof(300 mm) = 1.0094 + RR residual (55.67 − 2×11.39 = 32.89) × U_RR(age A=2.30) = 75.647 + → 78.3336 W/K + """ + # Arrange + doc = _load_cert("6035-7729-2309-0879-2296") + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act + roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k + + # Assert + assert abs(roof_w_per_k - 78.3336) <= 1e-4 From 570df834590d90b6f7de9bf5d1fab2c43b09573a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 11:41:16 +0000 Subject: [PATCH 07/80] =?UTF-8?q?S0380.197:=20simulated=20case=205=20e2e?= =?UTF-8?q?=20fixture=20=E2=80=94=20detached=20sandstone=20RR=20validates?= =?UTF-8?q?=20S0380.196=20(RdSAP=2010=20=C2=A73.9.1=20+=20Table=204=20p.22?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes user-simulated "case 5" (detached, sandstone-walled, room-in-roof cousin of golden cert 0240) to an e2e worksheet fixture pinning the WHOLE extractor → mapper → calculator pipeline at abs=1e-4 on all 11 Block-1 line refs. Its worksheet prints the exact RR-gable routing S0380.196 implements, validating that fix against ground truth: Roof room Main Gable Wall 1 15.68 U=0.35 (29a) Exposed → walls @ main-wall U Roof room Main remaining area 61.73 U=0.30 (30) A_RR shell − Σ gables External roof Main 14.52 U=0.11 (30) loft residual Roof room Main Gable Wall 2 15.68 U=0.25 (32) Party → party @ 0.25 gable area = 6.40 × 2.45 (§3.9.1 default RR storey height); A_RR remaining = 12.5√(83.2/1.5) − 2×15.68 = 93.09 − 31.36 = 61.73 (RdSAP 10 §3.9.1(e)). Confirms a DETACHED dwelling can lodge a Party RR gable (Table 4 p.22 row 2) — so my S0380.196 mapping (gable_wall_type 0=Party, 1=Exposed) is correct; do not flip it. Two extractor/mapper gaps surfaced and fixed (case 5 is the forcing test): - Sandstone wall label "SS Stone: sandstone or limestone" had no `_ELMHURST_WALL_CODE_TO_SAP10` entry (raised UnmappedElmhurstLabel). Added "SS" → 2 (WALL_STONE_SANDSTONE), matching 0240's API wall_construction=2 (cross-mapper parity). - Roof "Insulation Thickness 400+ mm" was silently dropped: the four thickness parsers used `.split()[0].isdigit()`, which rejects the trailing "+" → None → u_roof fell back to the age-J default 0.16 instead of 0.11 (+1.09 W/K roof, the whole 0.12 SAP gap). Added `_parse_thickness_mm` (strips to leading digits) and applied it at all four sites (walls / alt-wall / roof / floor). The only existing fixture with "400+ mm" (000565 Stud Wall) routes via the RIR regex, unaffected. Result: case 5 cascade ≡ worksheet at 1e-4 on SAP/ECF/cost/CO2 + every energy stream. Neither gap affects 0240 (its API path captures both the sandstone code and "400mm+"); 0240's residual is therefore non-fabric. Suite: 2353 passed, 1 skipped. New code: 0 pyright errors. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 38 +++--- .../tests/fixtures/Summary_001431_case5.pdf | Bin 0 -> 80735 bytes datatypes/epc/domain/mapper.py | 4 + ...60-0001-001431 - 2026-06-03T115608.865.pdf | Bin 0 -> 46528 bytes .../simulated case 5/Summary_001431 (1).pdf | Bin 0 -> 80735 bytes .../_elmhurst_worksheet_001431_case5.py | 122 ++++++++++++++++++ .../worksheet/test_e2e_elmhurst_sap_score.py | 21 +++ 7 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case5.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 5/P960-0001-001431 - 2026-06-03T115608.865.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 5/Summary_001431 (1).pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case5.py diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index b3fde06b..00aaf045 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -281,11 +281,7 @@ class ElmhurstSiteNotesExtractor: # with the §8 Roofs / §9 Floors blocks. None when the PDF # omits the line (no retrofit lodged). ins_thickness_raw = self._local_val(lines, "Insulation Thickness") - insulation_thickness_mm = ( - int(ins_thickness_raw.split()[0]) - if ins_thickness_raw and ins_thickness_raw.split()[0].isdigit() - else None - ) + insulation_thickness_mm = self._parse_thickness_mm(ins_thickness_raw) return WallDetails( wall_type=self._local_str(lines, "Type"), insulation=self._local_str(lines, "Insulation"), @@ -323,11 +319,7 @@ class ElmhurstSiteNotesExtractor: if area <= 0: continue thickness_raw = self._local_val(lines, f"Alternative Wall {n} Thickness") - thickness_mm = ( - int(thickness_raw.split()[0]) - if thickness_raw and thickness_raw.split()[0].isdigit() - else None - ) + thickness_mm = self._parse_thickness_mm(thickness_raw) result.append(AlternativeWall( area_m2=area, wall_type=self._local_str(lines, f"Alternative Wall {n} Type"), @@ -356,11 +348,25 @@ class ElmhurstSiteNotesExtractor: lines = [l.strip() for l in main_body.splitlines() if l.strip()] return self._wall_details_from_lines(lines) + @staticmethod + def _parse_thickness_mm(raw: Optional[str]) -> Optional[int]: + """Parse an Elmhurst "Insulation Thickness" cell ("100 mm", + "400+ mm") to integer mm. The bucket-cap "400+ mm" (Table 17/18 + max tabulated row) carries a trailing "+" that a bare + `.split()[0].isdigit()` test rejects — strip to the leading + digits so the cap parses through to the cascade with its numeric + value (simulated case 5: roof "400+ mm" was silently dropped → + u_roof fell back to the age-J default 0.16 instead of the + 300mm+ value 0.11). Returns None when the cell is absent or + carries no leading number ("As Built", "N None").""" + if not raw: + return None + match = re.match(r"\d+", raw.strip()) + return int(match.group()) if match else None + def _roof_details_from_lines(self, lines: List[str]) -> RoofDetails: thickness_raw = self._local_val(lines, "Insulation Thickness") - thickness_mm = ( - int(thickness_raw.split()[0]) if thickness_raw and thickness_raw.split()[0].isdigit() else None - ) + thickness_mm = self._parse_thickness_mm(thickness_raw) insulation = self._local_str(lines, "Insulation") # The Summary PDF omits the "Insulation Thickness" line entirely # when no retrofit insulation is lodged (e.g. "Insulation: N None" @@ -391,11 +397,7 @@ class ElmhurstSiteNotesExtractor: # via the per-thickness column. Mirror of the §8 roof extractor # at `_roof_details_from_lines`. thickness_raw = self._local_val(lines, "Insulation Thickness") - thickness_mm = ( - int(thickness_raw.split()[0]) - if thickness_raw and thickness_raw.split()[0].isdigit() - else None - ) + thickness_mm = self._parse_thickness_mm(thickness_raw) return FloorDetails( location=self._local_str(lines, "Location"), floor_type=self._local_str(lines, "Type"), diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case5.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case5.pdf new file mode 100644 index 0000000000000000000000000000000000000000..335e62428d6a52c924e63541ea6612d5d962752f GIT binary patch literal 80735 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