From b2b6f8e95488660a22635ddd995f9a2fe394dcd7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 15 Jun 2026 23:57:25 +0000 Subject: [PATCH 01/10] fix(mapper): map Elmhurst "Value known" cylinder to measured volume (code 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Elmhurst Summary §15.1 lodges "Cylinder Size: Value known" with the measured volume in the "Cylinder Volume (l)" line — the Summary-path equivalent of the gov-API "Exact" descriptor. The mapper had no entry for "Value known" so `_elmhurst_cylinder_size_code` raised UnmappedElmhurstLabel, and even once mapped the measured volume was never threaded through, so the cascade dropped the cylinder storage loss (~468 kWh/yr) from (219) water heating on every measured-volume-cylinder Summary. Per RdSAP 10 §10.5 Table 28 (p.55) a measured cylinder volume is used directly. Map "Value known" → cascade code 6 (Exact) and thread the §15.1 "Cylinder Volume (l)" value into SapHeating.cylinder_volume_measured_l, which `_cylinder_volume_l_from_code` (cert_to_inputs.py:5281) already reads for code 6 — mirroring the gov-API path (mapper.py:1575/1885). Pins simulated case 39 (P960-0001-001431): an age-A mid-terrace on direct- acting electric room heaters (SAP code 691, cat 10, control 2602) with electric-immersion DHW off a 117 L "Value known" cylinder. The full extractor→mapper→calculator cascade now reproduces the worksheet's SAP-rating block EXACTLY — SAP value 36.6365 (band F) and (272) CO2 2056.0731 kg/yr, with (219) water heating 2637.5049 and (255) total energy cost 1802.0039. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 13 ++ .../tests/fixtures/Summary_001431_case39.pdf | Bin 0 -> 79548 bytes datatypes/epc/domain/mapper.py | 15 +++ datatypes/epc/surveys/elmhurst_site_notes.py | 5 + .../epc/domain/test_mapper_cylinder_size.py | 60 +++++++++ .../_elmhurst_worksheet_001431_case39.py | 121 ++++++++++++++++++ 6 files changed, 214 insertions(+) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf create mode 100644 tests/datatypes/epc/domain/test_mapper_cylinder_size.py create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 55dd04d6..11e94fba 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1528,6 +1528,18 @@ class ElmhurstSiteNotesExtractor: first = cylinder_ins_thickness_raw.split()[0] if first.isdigit(): cylinder_insulation_thickness_mm = int(first) + # §15.1 "Cylinder Volume (l)" — the measured volume lodged alongside + # a "Value known" Cylinder Size. The value is written as a decimal + # ("117.00"); take the integer part for the cascade's measured-volume + # field (gov-API "Exact" descriptor, code 6). + cylinder_volume_raw = self._local_val(cylinder_lines, "Cylinder Volume (l)") + cylinder_volume_measured_l: Optional[int] = None + if cylinder_volume_raw: + first = cylinder_volume_raw.split()[0] + try: + cylinder_volume_measured_l = int(float(first)) + except ValueError: + cylinder_volume_measured_l = None cylinder_thermostat_raw = self._local_val( cylinder_lines, "Cylinder Thermostat", ) @@ -1560,6 +1572,7 @@ class ElmhurstSiteNotesExtractor: cylinder_size_label=cylinder_size_label, cylinder_insulation_label=cylinder_insulation_label, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, + cylinder_volume_measured_l=cylinder_volume_measured_l, cylinder_thermostat=cylinder_thermostat, immersion_type=immersion_type, ) diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf new file mode 100644 index 0000000000000000000000000000000000000000..137985f25d8ccbc179b01134beb2aed99f0ffa2b GIT binary patch literal 79548 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 SapHeating: ), cylinder_insulation_type=cylinder_insulation_type_field, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm_field, + # §15.1 "Cylinder Volume (l)" — measured volume for a "Value known" + # cylinder (cascade code 6 / Exact). None unless a cylinder is + # present; the cascade reads it only when `cylinder_size == 6`. + cylinder_volume_measured_l=( + survey.water_heating.cylinder_volume_measured_l + if survey.water_heating.hot_water_cylinder_present + else None + ), # Cascade reads `cylinder_thermostat == "Y"` (string compare) per # `cert_to_inputs.py:2252` / `:2218`. Map the bool to the Y/N # string the cascade expects; None when no cylinder is present. diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 3d5b2b21..eded346f 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -369,6 +369,11 @@ class WaterHeating: cylinder_insulation_label: Optional[str] = None # §15.1 "Insulation Thickness" lodging in mm (an integer or None). cylinder_insulation_thickness_mm: Optional[int] = None + # §15.1 "Cylinder Volume (l)" lodging — the measured cylinder volume in + # litres, present when "Cylinder Size" is lodged as "Value known" + # (the Summary-path equivalent of the gov-API "Exact" descriptor, + # cascade code 6). None when no cylinder is present or the line is absent. + cylinder_volume_measured_l: Optional[int] = None # §15.1 "Cylinder Thermostat" lodging (Yes / No). False or absent # keeps the cascade's no-thermostat Table 2b temperature factor. cylinder_thermostat: Optional[bool] = None diff --git a/tests/datatypes/epc/domain/test_mapper_cylinder_size.py b/tests/datatypes/epc/domain/test_mapper_cylinder_size.py new file mode 100644 index 00000000..b8d03a9a --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_cylinder_size.py @@ -0,0 +1,60 @@ +"""Mapper boundary: the Elmhurst §15.1 "Cylinder Size" label. + +A cylinder lodged "Value known" carries a measured volume in the §15.1 +"Cylinder Volume (l)" line — the Summary-path equivalent of the gov-API +"Exact" descriptor. Per RdSAP 10 §10.5 Table 28 (p.55) the measured volume +is used directly; cascade code 6 routes `_cylinder_volume_l_from_code` to +the lodged `cylinder_volume_measured_l`. Before this was mapped the label +raised `UnmappedElmhurstLabel`, blocking every measured-volume-cylinder +Summary. +""" + +from datatypes.epc.domain.mapper import ( + UnmappedElmhurstLabel, + _elmhurst_cylinder_size_code, # pyright: ignore[reportPrivateUsage] +) + + +def test_value_known_label_maps_to_exact_code_6() -> None: + # Arrange + label = "Value known" + + # Act + code = _elmhurst_cylinder_size_code(label, cylinder_present=True) + + # Assert + assert code == 6 + + +def test_value_known_label_with_no_cylinder_maps_to_none() -> None: + # Arrange + label = "Value known" + + # Act + code = _elmhurst_cylinder_size_code(label, cylinder_present=False) + + # Assert + assert code is None + + +def test_normal_label_still_maps_to_code_2() -> None: + # Arrange + label = "Normal" + + # Act + code = _elmhurst_cylinder_size_code(label, cylinder_present=True) + + # Assert + assert code == 2 + + +def test_unknown_label_still_raises() -> None: + # Arrange + label = "Spray-on unicorn cylinder" + + # Act / Assert + try: + _elmhurst_cylinder_size_code(label, cylinder_present=True) + except UnmappedElmhurstLabel: + return + raise AssertionError("expected UnmappedElmhurstLabel for an unknown label") diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py new file mode 100644 index 00000000..88547fbc --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py @@ -0,0 +1,121 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 39" worksheet — an age-A (pre-1900) mid-terrace heated by +**direct-acting electric room heaters** (SAP code 691, category 10, control +2602 appliance thermostats), with an electric room-heater secondary (also +691) and electric-immersion DHW (WHC 903) off a **measured-volume hot-water +cylinder** ("Cylinder Size: Value known", 117 L, foam 38 mm), on a single +(standard) electricity meter. + +This case was generated to probe the API-corpus's worst-served cohort +(category-10 direct-acting electric, 46% within-0.5). It exposed a real +Summary-path gap: the §15.1 "Cylinder Size: Value known" lodging (the +Summary equivalent of the gov-API "Exact" descriptor) was unmapped, so the +extractor/mapper raised `UnmappedElmhurstLabel` and — once that was mapped — +the measured "Cylinder Volume (l)" was not threaded through, dropping the +cylinder storage loss (~468 kWh/yr) from (219) water heating. Wiring the +measured volume (cascade code 6 → `_cylinder_volume_l_from_code`) closes the +whole cascade EXACTLY. + +Like 000565 / the _rr cases / case 20 / 21 / 38, this fixture does NOT hand- +build the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises +the WHOLE extractor + mapper + calculator pipeline. + +Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/ +simulated case 39/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf` so the +test runs without depending on the unstaged workspace. + +Worksheet pin targets (P960-0001-001431, "11a. SAP rating" / "12a. CO2 +emissions" block — the UK-average-climate rating block our cascade +reproduces; the P960's separate postcode-climate EPC block (272)=1803.19 is +a known regional-climate gap, not a SAP-rating divergence): +- SAP value (un-rounded, before (258) integer rounding) = 36.6365 (band F) +- (272) Total CO2, kg/year = 2056.0731 + +Per [[feedback-zero-error-strict]] + [[feedback-continuous-sap-tolerance]]: +pins are abs <= 1e-3 against the worksheet PDF (printed to 4 dp). +""" + +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 +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs + +# 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_case39.pdf" +) + +LINE_258_SAP_VALUE_CONTINUOUS: Final[float] = 36.6365 +LINE_272_TOTAL_CO2_KG_PER_YR: Final[float] = 2056.0731 +_PIN_ABS: Final[float] = 1e-3 + + +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/value token sequences). + Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures. + """ + 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-39 Summary through extractor + mapper. + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target.""" + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + +def test_case39_measured_volume_cylinder_reproduces_the_worksheet_sap_and_co2() -> None: + # Arrange — the full extractor -> mapper -> calculator pipeline on the + # simulated case-39 Summary (direct-electric room heaters + electric + # immersion DHW off a "Value known" 117 L measured-volume cylinder). + epc = build_epc() + + # Act + result = calculate_sap_from_inputs(cert_to_inputs(epc)) + + # Assert — the SAP-rating block reproduces the worksheet exactly. + assert ( + abs(result.sap_score_continuous - LINE_258_SAP_VALUE_CONTINUOUS) + <= _PIN_ABS + ) + assert abs(result.co2_kg_per_yr - LINE_272_TOTAL_CO2_KG_PER_YR) <= _PIN_ABS From 5d556faf86436683d6ad3fceca087f2d18418b03 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 04:42:44 +0000 Subject: [PATCH 02/10] fix(roof): bill at-rafters insulation on RdSAP 10 Table 16/18 column (2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `u_roof` only implemented the joist column, so roofs lodged insulated at rafters (`roof_insulation_location == 1`) were mis-billed at the joist U on both the API and Summary paths — under-stating loss, over-rating SAP. RdSAP 10 §5.11.2 Table 16 (spec p.42-43) gives a distinct "insulation at rafters" column (2): the rafter cavity is shallower than a loft void, so the same depth yields a higher U (200 mm: rafters 0.29 vs joists 0.21). §5.11 Table 18 (p.45) likewise carries a rafters column (2) for unknown / as-built thickness (footnote (1): "The value from the table applies for unknown and as built") — band A-D = 2.30, E = 1.50, F = 0.68, diverging from the joist column's 100 mm-equivalent 0.40 default (footnote (4)). - add `_ROOF_RAFTERS_BY_THICKNESS` (Table 16 col 2) + `_ROOF_RAFTERS_BY_AGE` (Table 18 col 2) to rdsap_uvalues; `u_roof` selects them via a new `insulation_at_rafters` flag (ignored for flat / sloping-ceiling roofs). - `heat_transmission` derives the flag PER BUILDING PART from `roof_insulation_location` (gov-API int 1 / Summary "R Rafters"), which also fixes the multi-part dedup-roof-join problem: each part's own location now drives its U, replacing the unattributable joined `epc.roofs[]` description. Worksheet-validated to 1e-4: simulated case 41 (4-bp — Ext1 rafters 200mm → 0.29, Ext3 rafters As-Built band F → 0.68; roof total 24.8350) and case 42 (6 variants — rafters 50mm → 0.88, rafters unknown band C → 2.30, joists/none unchanged). Case 40 stays exact (roof 35.340, total 441.1606); worksheet harness 47/47. Corpus within-0.5 66.9% → 66.5% (gates 0.65/1.08 hold) — a spec-correct shift, NOT a regression: all 15 corpus rafter certs carry redacted (None) thickness yet lodge roof EER 2-4 (insulated), so the open API blanked a specified thickness and the spec's unknown-rafter 2.30 default correctly over-states them. Recovery needs a roof-EER→thickness inference on the API path (follow-up), not a change to the U-table. Co-Authored-By: Claude Opus 4.8 --- .../Summary_001431_case41_rafters.pdf | Bin 0 -> 96715 bytes .../Summary_001431_case42_50mm_rafters.pdf | Bin 0 -> 79563 bytes .../Summary_001431_case42_unknown_rafters.pdf | Bin 0 -> 78790 bytes .../tests/test_summary_pdf_mapper_chain.py | 66 ++++++++++++++++++ .../worksheet/heat_transmission.py | 35 +++++++++- domain/sap10_ml/rdsap_uvalues.py | 60 ++++++++++++++-- domain/sap10_ml/tests/test_rdsap_uvalues.py | 63 +++++++++++++++++ .../worksheet/test_heat_transmission.py | 38 ++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 17 +++++ 9 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case41_rafters.pdf create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case42_50mm_rafters.pdf create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case42_unknown_rafters.pdf diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case41_rafters.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case41_rafters.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1f21db8ce7efa149b043f2d63950bf6f3166cd1b GIT binary patch literal 96715 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 literal 0 HcmV?d00001 diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case42_50mm_rafters.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case42_50mm_rafters.pdf new file mode 100644 index 0000000000000000000000000000000000000000..dbb3f4c8a2bde0a2543d5f79797d091b80d51ae6 GIT binary patch literal 79563 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 Zaos&E+&q4N>#lnxz+hY!7J0P?{{usX`0oG! 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 e37db750..2104cdaa 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -99,6 +99,9 @@ _SUMMARY_000565_PDF = _FIXTURES / "Summary_000565.pdf" # cert 000565 (5-bp Elmh _SUMMARY_001431_CASE20_PDF = _FIXTURES / "Summary_001431_case20.pdf" # sim case 20 (storage heaters + RR type-2 + wrapped "Double between 2002 and 2021" glazing) _SUMMARY_001431_TOPFLOOR_PDF = _FIXTURES / "Summary_001431_topfloor_flat.pdf" # gas-boiler-upgrade recommendation "after" — top-floor flat, PS sloping roof; exercises the Date-Built age-band + flat-position layout regressions _SUMMARY_001431_LPG_PDF = _FIXTURES / "Summary_001431_lpg_boiler.pdf" # lpg-boiler recommendation "before" — §14 SAP code 115, §15 "Bottled gas"; exercises the bottled-LPG main-fuel mapping +_SUMMARY_001431_CASE41_RAFTERS_PDF = _FIXTURES / "Summary_001431_case41_rafters.pdf" # sim case 41 — 4-bp roof: Main joists 200mm, Ext1 rafters 200mm, Ext2 joists unknown, Ext3 rafters As Built (RdSAP 10 §5.11.2 Table 16 col 2 + Table 18 col 2) +_SUMMARY_001431_CASE42_50MM_RAFTERS_PDF = _FIXTURES / "Summary_001431_case42_50mm_rafters.pdf" # sim case 42 — single-bp roof: rafters 50mm (Table 16 col 2 → 0.88) +_SUMMARY_001431_CASE42_UNKNOWN_RAFTERS_PDF = _FIXTURES / "Summary_001431_case42_unknown_rafters.pdf" # sim case 42 — single-bp roof: rafters unknown thickness (Table 18 col 2 band C → 2.30) # GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the # Summary_001479.pdf fixture. Together they drive the API ≡ Summary @@ -178,6 +181,69 @@ def test_summary_001431_case20_fabric_heat_loss_matches_worksheet_line_33() -> N assert abs(ht.fabric_heat_loss_w_per_k - 285.9847) <= 1e-4 +def test_summary_001431_case41_roof_drives_rafters_column_per_part() -> None: + # Arrange — sim case 41's 4 building parts exercise both RdSAP 10 roof + # columns per-part (RdSAP 10 §5.11.2 Table 16 + §5.11 Table 18, + # PDF p.42-45). The P960 §3 line (30) "External roof" A×U per part: + # Main joists 200mm → 0.21 × 59.5 = 12.4950 (Table 16 col 1) + # Ext1 rafters 200mm → 0.29 × 10.0 = 2.9000 (Table 16 col 2) + # Ext2 joists unknown→ 0.40 × 10.0 = 4.0000 (Table 18 col 1, band E) + # Ext3 rafters AsBlt → 0.68 × 8.0 = 5.4400 (Table 18 col 2, band F) + # Total (sum of (30)) = 24.8350 W/K. Before the rafters column the two + # rafter parts were mis-billed at the joists U (Ext1 0.21, Ext3 0.40). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_CASE41_RAFTERS_PDF) + epc = EpcPropertyDataMapper.from_elmhurst_site_notes( + ElmhurstSiteNotesExtractor(pages).extract() + ) + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert + assert abs(ht.roof_w_per_k - 24.8350) <= 1e-4 + + +def test_summary_001431_case42_rafters_50mm_uses_table16_column_2() -> None: + # Arrange — sim case 42's single-bp roof lodged "R Rafters" + 50 mm. + # RdSAP 10 §5.11.2 Table 16 (PDF p.43) column (2) "insulation at + # rafters" 50 mm → U=0.88 (vs the joists column (1) 0.68). P960 §3 (30) + # = 0.88 × 59.5 = 52.3600 W/K. + pages = _summary_pdf_to_textract_style_pages( + _SUMMARY_001431_CASE42_50MM_RAFTERS_PDF + ) + epc = EpcPropertyDataMapper.from_elmhurst_site_notes( + ElmhurstSiteNotesExtractor(pages).extract() + ) + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert + assert abs(ht.roof_w_per_k - 52.3600) <= 1e-4 + + +def test_summary_001431_case42_rafters_unknown_uses_table18_column_2() -> None: + # Arrange — sim case 42's single-bp roof lodged "R Rafters" with an + # unknown thickness, age band C. RdSAP 10 §5.11 Table 18 (PDF p.45) + # column (2) "insulation at rafters" applies "for unknown and as built" + # (footnote 1) → band A-D = 2.30 (NOT the joists column (1) 100 mm + # default 0.40, which only applies to the "between joists or unknown" + # column). Worksheet-confirmed by the case-42 variant set. P960 §3 (30) + # = 2.30 × 59.5 = 136.8500 W/K. + pages = _summary_pdf_to_textract_style_pages( + _SUMMARY_001431_CASE42_UNKNOWN_RAFTERS_PDF + ) + epc = EpcPropertyDataMapper.from_elmhurst_site_notes( + ElmhurstSiteNotesExtractor(pages).extract() + ) + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert + assert abs(ht.roof_w_per_k - 136.8500) <= 1e-4 + + def test_summary_001431_topfloor_extracts_main_property_age_band() -> None: # Arrange — the gas-boiler-upgrade recommendation "after" Summary # renders "3.0 Date Built:" glued to its "Main Property" row header diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index ebdb2b52..6084addc 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -41,7 +41,7 @@ from __future__ import annotations from dataclasses import dataclass from decimal import ROUND_HALF_UP, Decimal -from typing import Any, Final, Optional +from typing import Any, Final, Optional, Union from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, @@ -349,6 +349,27 @@ def _joined_descriptions(elements: list[Any]) -> Optional[str]: return " | ".join(parts) +def _roof_insulation_at_rafters(location: Optional[Union[int, str]]) -> bool: + """True when a building part's roof insulation sits at the rafters + (the sloping side of the roof) rather than between the ceiling joists. + + `roof_insulation_location` is the authoritative per-part signal — it + carries the gov-EPC API integer code (1 = Rafters, per the empirically + watertight single-roof corpus map) on the API path and the stripped + Elmhurst Summary label ("Rafters" from "R Rafters") on the Summary + path. Both resolve here so `u_roof` selects the RdSAP 10 §5.11.2 + Table 16 column (2) / Table 18 rafters column instead of the loft- + joist column (1). The flat deduplicated `epc.roofs[]` description list + cannot give this per-part — 190/329 multi-part certs have + len(roofs) != len(parts) — so the per-part location is the only + reliable discriminator (worksheet-validated by simulated case 41).""" + if location is None: + return False + if isinstance(location, int): + return location == 1 + return "rafter" in location.strip().lower() + + def _joined_main_roof_descriptions(roofs: list[Any]) -> Optional[str]: """Join roof descriptions for the MAIN (non-RR) roof U-value, dropping "Roof room(s)" entries. @@ -873,7 +894,17 @@ def heat_transmission_from_cert( # 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) + # RdSAP 10 §5.11.2 Table 16 column (2) / Table 18 rafters column — + # a roof lodged insulated AT RAFTERS (per-part + # `roof_insulation_location` == 1 / "R Rafters") sits on the + # shallower sloping side, so the same insulation depth yields a + # higher U than the loft-joist column (1). Driven per-part because + # the deduplicated `epc.roofs[]` description list cannot attribute + # a location to each building part. + insulation_at_rafters = _roof_insulation_at_rafters( + getattr(part, "roof_insulation_location", None) + ) + 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, insulation_at_rafters=insulation_at_rafters) # RdSAP 10 §5.1 — a lodged/known roof U-value (the assessor's RdSAP # output, surfaced by the gov-EPC API as `roof_u_value`) is used # directly in place of the §5.11 construction-default cascade. The gov diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index ea1188db..b2e76b8f 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -747,6 +747,34 @@ _ROOF_BY_AGE: Final[dict[str, float]] = { "K": 0.16, "L": 0.16, "M": 0.15, } +# Table 16 column (2): insulation AT RAFTERS (sloping side of the roof, +# rather than between the ceiling joists). RdSAP 10 §5.11.2 Table 16 +# (PDF p.42-43). The rafter cavity is shallower than a loft void, so the +# same insulation depth yields a HIGHER U than the column (1) joist row +# (e.g. 200 mm: rafters 0.29 vs joists 0.21). Thickness mm -> U. +_ROOF_RAFTERS_BY_THICKNESS: Final[list[tuple[int, float]]] = [ + (0, 2.30), (12, 1.75), (25, 1.30), (50, 0.88), (75, 0.67), + (100, 0.54), (125, 0.45), (150, 0.39), (175, 0.32), (200, 0.29), + (225, 0.25), (250, 0.23), (270, 0.21), (300, 0.19), (350, 0.16), + (400, 0.14), +] + +# Table 18 rafters column: pitched-roof "insulation at rafters" default U +# by age band when the thickness cannot be determined. RdSAP 10 §5.11 +# Table 18 (PDF p.45). Identical to the joist column (1) for bands A-G +# (2.30 → 0.40), then diverges higher (H 0.35 vs 0.30, I 0.35 vs 0.26, +# J/K 0.20 vs 0.16, L 0.18 vs 0.16). Unlike the loft-joist default this +# does NOT collapse to the optimistic 0.40 "assume modern retrofit" floor +# at old bands — a rafter cavity cannot be topped up from the loft, so an +# unknown-thickness rafter roof keeps the as-built age-band U (band F +# 0.68, band E 1.50, A-D 2.30). Worksheet-validated by simulated case 41 +# Ext3 (band F, R Rafters, As Built → P960 §3 (30) U=0.68). +_ROOF_RAFTERS_BY_AGE: Final[dict[str, float]] = { + "A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, "E": 1.50, + "F": 0.68, "G": 0.40, "H": 0.35, "I": 0.35, "J": 0.20, + "K": 0.20, "L": 0.18, "M": 0.18, +} + # Table 18 column (3): flat-roof default U by age band when thickness unknown. # RdSAP 10 §5.11 Table 18 page 45 — the pitched-roof column (1) defaults # bottom out at 0.40 because "between joists insulation" is the implicit @@ -793,6 +821,7 @@ def u_roof( is_flat_roof: bool = False, is_sloping_ceiling: bool = False, is_pitched_sloping_ceiling: bool = False, + insulation_at_rafters: bool = False, ) -> float: """RdSAP10 roof U-value in W/m^2K, never null. @@ -829,7 +858,27 @@ def u_roof( (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. + + `insulation_at_rafters` selects the RdSAP 10 §5.11.2 Table 16 column + (2) thickness ladder and the Table 18 rafters age-band column instead + of the loft-joist column (1). A roof lodged insulated AT RAFTERS + (`roof_insulation_location == 1` on the API path, "R Rafters" on the + Summary path) sits on the sloping side of the roof — a shallower + cavity than a loft void, so the same insulation depth yields a higher + U (200 mm: 0.29 vs the joists 0.21). Ignored for flat / sloping- + ceiling roofs (the rafter distinction is a pitched-with-loft concept). + Worksheet-validated by simulated case 41 Ext1 (band C, R Rafters, + 200 mm → 0.29) and Ext3 (band F, R Rafters, As Built → 0.68). """ + # RdSAP 10 §5.11.2 Table 16 / §5.11 Table 18 — pick the rafters + # column when the insulation sits at the rafters rather than the + # loft joists. Flat / sloping-ceiling geometries keep their own + # dedicated tables (rafters is meaningless there). + use_rafters = insulation_at_rafters and not (is_flat_roof or is_sloping_ceiling) + roof_by_thickness = ( + _ROOF_RAFTERS_BY_THICKNESS if use_rafters else _ROOF_BY_THICKNESS + ) + roof_by_age = _ROOF_RAFTERS_BY_AGE if use_rafters else _ROOF_BY_AGE measured = _measured_u_from_description(description) if measured is not None: # Full-SAP cert lodges a measured roof U-value in the description @@ -852,7 +901,7 @@ def u_roof( # genuine "no insulation" lodgement, which keeps 2.30 (below). The # discriminator is the deterministic "Unknown" text RdSAP renders # for an undetermined-thickness observation. - table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else _ROOF_BY_AGE + table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else roof_by_age return table_18.get(age_band.upper(), 0.4) if ( is_sloping_ceiling @@ -877,9 +926,10 @@ def u_roof( # uninsulated 2.30 W/m²K. return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level" if insulation_thickness_mm is not None: - # nearest tabulated thickness <= supplied - u = _ROOF_BY_THICKNESS[0][1] - for t, val in _ROOF_BY_THICKNESS: + # nearest tabulated thickness <= supplied (Table 16 column (1) + # joists or column (2) rafters per `insulation_at_rafters`) + u = roof_by_thickness[0][1] + for t, val in roof_by_thickness: if insulation_thickness_mm >= t: u = val return u @@ -923,7 +973,7 @@ def u_roof( 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) + return roof_by_age.get(age_band.upper(), 0.4) # RdSAP10 Table 17 — U-values for rooms in roof where insulation thickness diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 1018ce30..8d4e3612 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -961,6 +961,69 @@ def test_u_roof_with_explicit_insulation_thickness_uses_table16() -> None: assert result == pytest.approx(0.21, abs=0.001) +def test_u_roof_at_rafters_explicit_thickness_uses_table16_column_2() -> None: + # Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2) + # "insulation at rafters". A roof lodged insulated AT RAFTERS + # (roof_insulation_location == 1, "R Rafters" on the Summary path) + # takes the rafters thickness ladder, NOT the column (1) joist row: + # at 200 mm the rafters U is 0.29 W/m²K vs the joists 0.21 — a ~38% + # heat-loss understatement when the joists column is mis-used. The + # joists column (1) stays 0.21 for the same thickness. + + # Act + at_rafters = u_roof( + country=Country.ENG, age_band="C", insulation_thickness_mm=200, + insulation_at_rafters=True, + ) + at_joists = u_roof( + country=Country.ENG, age_band="C", insulation_thickness_mm=200, + insulation_at_rafters=False, + ) + + # Assert + assert abs(at_rafters - 0.29) <= 0.001 + assert abs(at_joists - 0.21) <= 0.001 + + +def test_u_roof_at_rafters_thickness_ladder_matches_table16_column_2() -> None: + # Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2) rows: + # 50 mm → 0.88, 100 mm → 0.54, 150 mm → 0.39, 270 mm → 0.21. Each is + # higher than the joists column (1) value at the same thickness (the + # rafter cavity is shallower so the same insulation depth yields a + # higher U). + + # Act / Assert + assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=50, insulation_at_rafters=True) - 0.88) <= 0.001 + assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=100, insulation_at_rafters=True) - 0.54) <= 0.001 + assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=150, insulation_at_rafters=True) - 0.39) <= 0.001 + assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=270, insulation_at_rafters=True) - 0.21) <= 0.001 + + +def test_u_roof_at_rafters_unknown_thickness_uses_table18_rafters_age_band() -> None: + # Arrange — RdSAP 10 §5.11 Table 18 (PDF p.45) rafters age-band + # column. A rafter-insulated roof with no determinable thickness + # ("R Rafters" + "As Built" → thickness None) takes the rafters + # age-band default. Band F → 0.68 (== the joists value at F), band H + # → 0.35 (vs joists 0.30), band J → 0.20 (vs joists 0.16). Unlike a + # loft-joist roof the rafter cavity cannot be topped up, so the + # optimistic 0.40 "assume modern retrofit" joist floor does NOT apply + # at old bands — band C stays 2.30 (vs the joists-unknown 0.40). + # Worksheet-validated by simulated case 41 Ext3 (band F, R Rafters, + # As Built → P960 §3 (30) U=0.68). + + # Act + band_f = u_roof(country=Country.ENG, age_band="F", insulation_thickness_mm=None, insulation_at_rafters=True) + band_h = u_roof(country=Country.ENG, age_band="H", insulation_thickness_mm=None, insulation_at_rafters=True) + band_j = u_roof(country=Country.ENG, age_band="J", insulation_thickness_mm=None, insulation_at_rafters=True) + band_c = u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=None, insulation_at_rafters=True) + + # Assert + assert abs(band_f - 0.68) <= 0.001 + assert abs(band_h - 0.35) <= 0.001 + assert abs(band_j - 0.20) <= 0.001 + assert abs(band_c - 2.30) <= 0.001 + + def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None: # Arrange — nothing known. diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 3153f1bc..16e362f9 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -151,6 +151,44 @@ def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0) +def test_roof_insulation_location_rafters_drives_table16_column_2_api_int_path() -> None: + # Arrange — the gov-EPC API lodges roof_insulation_location as an + # integer (1 = Rafters per the empirically watertight corpus map). A + # roof insulated AT RAFTERS with 200 mm takes RdSAP 10 §5.11.2 Table 16 + # (PDF p.43) column (2) → U=0.29, NOT the joists column (1) 0.21 — the + # rafter cavity is shallower so the same depth yields a higher U. The + # per-part location is the authoritative signal (the deduplicated + # epc.roofs[] list cannot attribute a location per building part). + # Geometry: 100 m² plan → roof area 100 m². rafters: 0.29 × 100 = 29 + # W/K (vs the joists 0.21 × 100 = 21 W/K). + main = make_building_part( + construction_age_band="C", + wall_construction=3, + 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, + ), + ], + ) + main.roof_insulation_location = 1 # gov-API int: Rafters + main.roof_insulation_thickness = "200mm" + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert + assert abs(result.roof_w_per_k - 29.0) <= 1e-4 + + def test_lodged_roof_u_value_overrides_construction_default() -> None: # Arrange — RdSAP 10 §5.1: where an element's U-value is known from the # assessment (documentary evidence / the lodged RdSAP output) it is used diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 13e9f6e6..fbf10705 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -67,6 +67,23 @@ _CORPUS = Path( # energy were 5% high; actual SAP bias is +0.145). # So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is # no one-slice factor fix. RATCHET any ceiling up when a slice tightens it. +# +# RAFTERS ROOF U-TABLE (RdSAP 10 §5.11.2 Table 16 col 2 + §5.11 Table 18 col 2): +# this slice billed roofs lodged insulated AT RAFTERS (roof_insulation_location +# == 1) on the spec rafters column instead of the joists column. Within-0.5 went +# 66.9% -> 66.5% (MAE 1.039 -> 1.064) — a SPEC-CORRECT move, NOT a regression to +# chase. The calculator is worksheet-validated to 1e-4 on simulated case 41 +# (4-bp: measured rafters 200mm -> 0.29; rafters As-Built band F -> 0.68) and +# case 42 (6 variants: rafters 50mm -> 0.88; rafters unknown band C -> 2.30 per +# Table 18 footnote 1 "applies for unknown and as built"). The dip is a gov +# open-data REDACTION artifact: all 15 corpus rafter certs carry NO thickness +# (blanked to None) yet lodge roof energy_efficiency_rating 2-4 (insulated), +# proving they had a SPECIFIED thickness the open API redacted. With the +# thickness gone the spec's unknown-rafter default (2.30) correctly fires but +# over-states those certs' real (insulated) roof. Recovering them needs a +# roof-EER -> assumed-thickness inference on the API path (future slice), NOT a +# change to the spec-correct U-table. Do NOT revert the rafters column to "fix" +# the gauge. _MIN_WITHIN_HALF_SAP = 0.65 _MAX_SAP_MAE = 1.08 _MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current From 7cfd54129be5e2d25257a7651430c4a210019757 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 05:04:39 +0000 Subject: [PATCH 03/10] fix(mapper): read the dropped `rafter_insulation_thickness` API field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roofs lodged insulated at rafters carry their thickness in a DEDICATED gov-EPC API field, `rafter_insulation_thickness` (e.g. "225mm"), while `roof_insulation_thickness` stays None (rafters aren't loft joists). That field was undeclared on the 21.0.x schemas, so `from_dict` silently dropped it — the rafter certs only *looked* redacted (roof EER 2-4 = insulated, yet no thickness), and the cascade fell to the Table 18 col (2) unknown default (2.30), badly under-rating them. - declare `rafter_insulation_thickness` on RdSapSchema21_0_0/21_0_1 + EpcPropertyData.SapBuildingPart (mirrors the existing sloping_ceiling_/flat_roof_insulation_thickness dropped-field handling). - thread it through `from_rdsap_schema_21_0_0/21_0_1` (older schemas get None via getattr). - `heat_transmission` prefers `rafter_insulation_thickness` over `roof_insulation_thickness` when the part is at-rafters, so the measured RdSAP 10 §5.11.2 Table 16 column (2) row applies (225 mm → 0.25). Completes the rafters roof fix: with the real thickness read, the rafter certs are recovered rather than over-stated — cert 3100-8675-0922-8628 (band E, rafters 225mm) +8.93 → +0.43 SAP. Corpus within-0.5 67.0% (MAE 1.025) and /tmp 71.2% (MAE 0.889) — both NET ABOVE the pre-rafters baseline (66.9% / 70.6%). Worksheet harness 47/47; regression = only the 3 pre-existing fails; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/epc_property_data.py | 7 ++++ datatypes/epc/domain/mapper.py | 23 +++++++++++ .../domain/tests/test_from_rdsap_schema.py | 29 ++++++++++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 8 ++++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 8 ++++ .../worksheet/heat_transmission.py | 30 +++++++++----- .../worksheet/test_heat_transmission.py | 39 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 30 +++++++------- 8 files changed, 149 insertions(+), 25 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index a3fd091b..8d61a2ef 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -552,6 +552,13 @@ class SapBuildingPart: roof_insulation_thickness: Optional[Union[str, int]] = ( None # TODO: make enum/mapping? ) + # Lodged insulation thickness (e.g. "225mm", or "AB" As Built) for a roof + # insulated AT RAFTERS (roof_insulation_location == 1). The gov API lodges + # rafter insulation in this dedicated field — `roof_insulation_thickness` + # stays None for rafter roofs. `heat_transmission` prefers this field over + # `roof_insulation_thickness` when the part is at-rafters, so the measured + # Table 16 column (2) row applies instead of the unknown-thickness default. + rafter_insulation_thickness: Optional[Union[str, int]] = None sap_room_in_roof: Optional[SapRoomInRoof] = None # Per RdSAP 10 §5.18 (PDF p.48), a curtain wall (wall_construction # =WALL_CURTAIN=9) takes its U-value from the per-BP installation diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 280806d1..0b71a1a9 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1023,6 +1023,11 @@ class EpcPropertyDataMapper: bp.roof_insulation_thickness, bp.construction_age_band, ), + # Rafter insulation thickness lives in its own gov-API field + # (only on the 21.0.x schemas; getattr is None elsewhere). + rafter_insulation_thickness=getattr( + bp, "rafter_insulation_thickness", None + ), sap_room_in_roof=( SapRoomInRoof( floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), @@ -1220,6 +1225,11 @@ class EpcPropertyDataMapper: bp.roof_insulation_thickness, bp.construction_age_band, ), + # Rafter insulation thickness lives in its own gov-API field + # (only on the 21.0.x schemas; getattr is None elsewhere). + rafter_insulation_thickness=getattr( + bp, "rafter_insulation_thickness", None + ), sap_room_in_roof=( SapRoomInRoof( # ADR-0028: floor_area is usually a Measurement but @@ -1470,6 +1480,11 @@ class EpcPropertyDataMapper: bp.roof_insulation_thickness, bp.construction_age_band, ), + # Rafter insulation thickness lives in its own gov-API field + # (only on the 21.0.x schemas; getattr is None elsewhere). + rafter_insulation_thickness=getattr( + bp, "rafter_insulation_thickness", None + ), sap_room_in_roof=( SapRoomInRoof( floor_area=_measurement_value( @@ -1705,6 +1720,10 @@ class EpcPropertyDataMapper: bp.construction_age_band, bp.sloping_ceiling_insulation_thickness, ), + # RdSAP 10 §5.11.2 — rafter insulation thickness lives in its + # own gov-API field (roof_insulation_thickness stays None for + # rafter roofs); heat_transmission prefers it when at-rafters. + rafter_insulation_thickness=bp.rafter_insulation_thickness, # RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U # overrides the §5.6/§5.7/§5.11 construction-default cascade # (gov open data can redact the backing insulation). @@ -2014,6 +2033,10 @@ class EpcPropertyDataMapper: bp.construction_age_band, bp.sloping_ceiling_insulation_thickness, ), + # RdSAP 10 §5.11.2 — rafter insulation thickness lives in its + # own gov-API field (roof_insulation_thickness stays None for + # rafter roofs); heat_transmission prefers it when at-rafters. + rafter_insulation_thickness=bp.rafter_insulation_thickness, # RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U # overrides the §5.6/§5.7/§5.11 construction-default cascade # (gov open data can redact the backing insulation). diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 75cb929d..ba955f63 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -406,6 +406,35 @@ class TestFromRdSapSchema21_0_1: # worksheet uses per-bp sums and the mapper now mirrors that. assert result.total_floor_area_m2 == 45.82 + def test_rafter_insulation_thickness_threaded( + self, schema: RdSapSchema21_0_1 + ) -> None: + # Arrange — the gov API lodges rafter insulation in the dedicated + # `rafter_insulation_thickness` field (RdSAP 10 §5.11.2); it was + # previously undeclared, so `from_dict` dropped it and the cascade + # fell to the Table 18 col (2) unknown default. The mapper must + # thread it through to the domain SapBuildingPart so + # heat_transmission can reach the measured Table 16 col (2) row. + import dataclasses + + bps = schema.sap_building_parts + patched = dataclasses.replace( + schema, + sap_building_parts=[ + dataclasses.replace( + bps[0], roof_insulation_location=1, + rafter_insulation_thickness="225mm", + ), + *bps[1:], + ], + ) + + # Act + result = EpcPropertyDataMapper.from_rdsap_schema_21_0_1(patched) + + # Assert + assert result.sap_building_parts[0].rafter_insulation_thickness == "225mm" + # --- property flags --- def test_solar_water_heating(self, result: EpcPropertyData) -> None: diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index e70d7b52..3aa30c1a 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -265,6 +265,14 @@ class SapBuildingPart: # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None + # Lodged insulation thickness (e.g. "225mm", or "AB" As Built) for a roof + # insulated AT RAFTERS (roof_insulation_location == 1). The gov API lodges + # rafter insulation in this dedicated field — NOT `roof_insulation_thickness` + # (which stays None for rafter roofs, since rafters aren't loft joists). + # Previously undeclared → dropped by `from_dict`, so the cascade fell to the + # Table 18 col (2) unknown default (2.30) instead of the measured Table 16 + # col (2) row. Consumed by `heat_transmission` when at-rafters. + rafter_insulation_thickness: Optional[Union[str, int]] = None # Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U, # authoritative when the open data redacts the backing insulation # thickness. Consumed by `heat_transmission` as a §5.1 documentary-evidence diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 48843b05..3a21c47b 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -303,6 +303,14 @@ class SapBuildingPart: # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None + # Lodged insulation thickness (e.g. "225mm", or "AB" As Built) for a roof + # insulated AT RAFTERS (roof_insulation_location == 1). The gov API lodges + # rafter insulation in this dedicated field — NOT `roof_insulation_thickness` + # (which stays None for rafter roofs, since rafters aren't loft joists). + # Previously undeclared → dropped by `from_dict`, so the cascade fell to the + # Table 18 col (2) unknown default (2.30) instead of the measured Table 16 + # col (2) row. Consumed by `heat_transmission` when at-rafters. + rafter_insulation_thickness: Optional[Union[str, int]] = None # Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U. The # gov open data can redact the backing insulation thickness, so this is the # authoritative per-element value; consumed by `heat_transmission` as a diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 6084addc..83668e6f 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -791,7 +791,22 @@ def heat_transmission_from_cert( or _described_as_retrofit_insulated(wall_description) ) party_construction = _int_or_none(part.party_wall_construction) + # RdSAP 10 §5.11.2 — a roof insulated AT RAFTERS lodges its thickness in + # the dedicated gov-API `rafter_insulation_thickness` field, NOT + # `roof_insulation_thickness` (which stays None for rafter roofs, since + # rafters aren't loft joists). Prefer the rafter field when the part is + # at-rafters so the measured Table 16 column (2) row applies instead of + # the unknown-thickness default. The Summary path lodges rafter + # thickness in `roof_insulation_thickness` (no separate field), so the + # fallback covers it. + insulation_at_rafters = _roof_insulation_at_rafters( + getattr(part, "roof_insulation_location", None) + ) raw_roof_thickness = getattr(part, "roof_insulation_thickness", None) + if insulation_at_rafters: + raw_rafter_thickness = getattr(part, "rafter_insulation_thickness", None) + if raw_rafter_thickness is not None: + raw_roof_thickness = raw_rafter_thickness roof_thickness = _parse_thickness_mm(raw_roof_thickness) floor_ins_thickness = _parse_thickness_mm(getattr(part, "floor_insulation_thickness", None)) @@ -895,15 +910,12 @@ def heat_transmission_from_cert( # string triggers the col (3) age-band default in `u_roof`. is_pitched_sloping_ceiling = "sloping ceiling" in roof_type_lower # RdSAP 10 §5.11.2 Table 16 column (2) / Table 18 rafters column — - # a roof lodged insulated AT RAFTERS (per-part - # `roof_insulation_location` == 1 / "R Rafters") sits on the - # shallower sloping side, so the same insulation depth yields a - # higher U than the loft-joist column (1). Driven per-part because - # the deduplicated `epc.roofs[]` description list cannot attribute - # a location to each building part. - insulation_at_rafters = _roof_insulation_at_rafters( - getattr(part, "roof_insulation_location", None) - ) + # a roof lodged insulated AT RAFTERS sits on the shallower sloping + # side, so the same insulation depth yields a higher U than the + # loft-joist column (1). `insulation_at_rafters` (computed above) is + # driven per-part from `roof_insulation_location` because the + # deduplicated `epc.roofs[]` description list cannot attribute a + # location to each building part. 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, insulation_at_rafters=insulation_at_rafters) # RdSAP 10 §5.1 — a lodged/known roof U-value (the assessor's RdSAP # output, surfaced by the gov-EPC API as `roof_u_value`) is used diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 16e362f9..d1ed0c92 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -189,6 +189,45 @@ def test_roof_insulation_location_rafters_drives_table16_column_2_api_int_path() assert abs(result.roof_w_per_k - 29.0) <= 1e-4 +def test_rafter_insulation_thickness_field_drives_table16_column_2() -> None: + # Arrange — the gov-EPC API lodges rafter insulation in a DEDICATED + # `rafter_insulation_thickness` field (e.g. "225mm"), leaving + # `roof_insulation_thickness` None for rafter roofs (rafters aren't loft + # joists). heat_transmission must prefer the rafter field when the part + # is at-rafters (roof_insulation_location == 1) so the measured RdSAP 10 + # §5.11.2 Table 16 column (2) row applies — 225 mm → U=0.25 — instead of + # the Table 18 col (2) unknown default (2.30). Cert 3100-8675-0922-8628 + # (band E, rafters 225mm) went +8.93 -> +0.43 SAP on this field. + # Geometry: 100 m² plan → roof area 100 m². 0.25 × 100 = 25 W/K. + main = make_building_part( + construction_age_band="E", + 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, + ), + ], + ) + main.roof_insulation_location = 1 # Rafters + main.roof_insulation_thickness = None # gov leaves this None for rafters + main.rafter_insulation_thickness = "225mm" # the thickness lives here + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert + assert abs(result.roof_w_per_k - 25.0) <= 1e-4 + + def test_lodged_roof_u_value_overrides_construction_default() -> None: # Arrange — RdSAP 10 §5.1: where an element's U-value is known from the # assessment (documentary evidence / the lodged RdSAP output) it is used diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index fbf10705..fb38613f 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -68,22 +68,20 @@ _CORPUS = Path( # So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is # no one-slice factor fix. RATCHET any ceiling up when a slice tightens it. # -# RAFTERS ROOF U-TABLE (RdSAP 10 §5.11.2 Table 16 col 2 + §5.11 Table 18 col 2): -# this slice billed roofs lodged insulated AT RAFTERS (roof_insulation_location -# == 1) on the spec rafters column instead of the joists column. Within-0.5 went -# 66.9% -> 66.5% (MAE 1.039 -> 1.064) — a SPEC-CORRECT move, NOT a regression to -# chase. The calculator is worksheet-validated to 1e-4 on simulated case 41 -# (4-bp: measured rafters 200mm -> 0.29; rafters As-Built band F -> 0.68) and -# case 42 (6 variants: rafters 50mm -> 0.88; rafters unknown band C -> 2.30 per -# Table 18 footnote 1 "applies for unknown and as built"). The dip is a gov -# open-data REDACTION artifact: all 15 corpus rafter certs carry NO thickness -# (blanked to None) yet lodge roof energy_efficiency_rating 2-4 (insulated), -# proving they had a SPECIFIED thickness the open API redacted. With the -# thickness gone the spec's unknown-rafter default (2.30) correctly fires but -# over-states those certs' real (insulated) roof. Recovering them needs a -# roof-EER -> assumed-thickness inference on the API path (future slice), NOT a -# change to the spec-correct U-table. Do NOT revert the rafters column to "fix" -# the gauge. +# RAFTERS ROOF (RdSAP 10 §5.11.2 Table 16 col 2 + §5.11 Table 18 col 2): roofs +# insulated AT RAFTERS (roof_insulation_location == 1) are billed on the spec +# rafters column instead of the joists column, AND their thickness is read from +# the dedicated gov-API `rafter_insulation_thickness` field. That field was +# UNDECLARED on the schema, so `from_dict` dropped it — the rafter certs only +# *looked* redacted (roof EER 2-4 = insulated yet `roof_insulation_thickness` +# None); the thickness was there all along in `rafter_insulation_thickness` +# (e.g. "225mm"). Declaring + threading it recovers them: cert 3100-8675-0922 +# (band E, rafters 225mm) +8.93 -> +0.43 SAP. Net of both changes within-0.5 +# went 66.9% -> 67.0% (MAE 1.039 -> 1.025). Worksheet-validated to 1e-4 on +# simulated case 41 (measured rafters 200mm -> 0.29; rafters As-Built band F +# -> 0.68) and case 42 (rafters 50mm -> 0.88; rafters genuine-unknown band C +# -> 2.30 per Table 18 footnote 1 "applies for unknown and as built"). Do NOT +# revert the rafters column. _MIN_WITHIN_HALF_SAP = 0.65 _MAX_SAP_MAE = 1.08 _MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current From b55b969b84c4c86ff676d9b278bbf8411ac441a1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 05:27:47 +0000 Subject: [PATCH 04/10] fix(water-heating): use lodged `cylinder_heat_loss` declared-loss factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov API lodges a manufacturer's declared cylinder loss factor (kWh/day) in `sap_heating.cylinder_heat_loss`, in which case it leaves the cylinder volume / insulation type / thickness None. That field was undeclared on the 21.0.x schemas, so `from_dict` dropped it — then `_cylinder_storage_loss_override` hit its insulation-None / volume-None guards and returned None, dropping the §4 storage loss ENTIRELY. The dwelling over-rated (the declared loss is typically ~1.5 kWh/day ≈ 550 kWh/yr). SAP 10.2 §4 branch a) (PDF p.136): when the declared loss factor is known, storage loss (50) = (48) declared loss × (49) Table-2b temperature factor — replacing the Table 2 V×L×VF computation. - declare `cylinder_heat_loss` on RdSapSchema21_0_0/21_0_1.SapHeating + EpcPropertyData.SapHeating; thread through the 21.0.x mappers. - `cylinder_storage_loss_monthly_kwh` gains `declared_loss_kwh_per_day`: when set, combined_55 = declared × TF (volume/insulation unused). - `_cylinder_storage_loss_override` resolves the declared loss BEFORE the insulation/volume guards (the gov omits those when the loss is lodged). 12 /tmp certs carry it (mean |err| 3.00 -> 2.51; the clean ones close hard, e.g. 2360 2.65 -> 0.30, 0245 2.25 -> 0.53). Corpus within-0.5 67.0% -> 67.3% (MAE 1.025 -> 1.020); /tmp 71.2% -> 71.4% (0.889 -> 0.882). Worksheet harness 47/47; regression = only the 3 pre-existing fails; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/epc_property_data.py | 6 +++ datatypes/epc/domain/mapper.py | 2 + .../domain/tests/test_from_rdsap_schema.py | 22 ++++++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 5 +++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 6 +++ .../sap10_calculator/rdsap/cert_to_inputs.py | 40 +++++++++++++++++++ .../worksheet/water_heating.py | 32 +++++++++++---- .../worksheet/test_water_heating.py | 34 ++++++++++++++++ 8 files changed, 139 insertions(+), 8 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 8d61a2ef..1472963d 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -153,6 +153,12 @@ class SapHeating: None # int from API; str from site notes ) cylinder_insulation_thickness_mm: Optional[int] = None + # SAP 10.2 §4 branch a) — manufacturer's declared cylinder loss factor + # (kWh/day). When present, `_cylinder_storage_loss_override` uses it + # directly (× Table-2b temperature factor) in place of the Table 2 + # V×L×VF computation; the gov lodges it instead of cylinder volume / + # insulation, so it must be read or the storage loss is dropped. + cylinder_heat_loss: Optional[float] = None # SAP10 hot-water demand inputs from sap_heating. number_baths: Optional[int] = None number_baths_wwhrs: Optional[int] = None diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0b71a1a9..9f75847d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1588,6 +1588,7 @@ class EpcPropertyDataMapper: == "true", cylinder_size=schema.sap_heating.cylinder_size, cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured, + cylinder_heat_loss=schema.sap_heating.cylinder_heat_loss, water_heating_code=schema.sap_heating.water_heating_code, water_heating_fuel=schema.sap_heating.water_heating_fuel, immersion_heating_type=schema.sap_heating.immersion_heating_type, @@ -1902,6 +1903,7 @@ class EpcPropertyDataMapper: == "true", cylinder_size=schema.sap_heating.cylinder_size, cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured, + cylinder_heat_loss=schema.sap_heating.cylinder_heat_loss, water_heating_code=schema.sap_heating.water_heating_code, water_heating_fuel=schema.sap_heating.water_heating_fuel, immersion_heating_type=schema.sap_heating.immersion_heating_type, diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index ba955f63..04073801 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -435,6 +435,28 @@ class TestFromRdSapSchema21_0_1: # Assert assert result.sap_building_parts[0].rafter_insulation_thickness == "225mm" + def test_cylinder_heat_loss_threaded( + self, schema: RdSapSchema21_0_1 + ) -> None: + # Arrange — the gov API lodges the manufacturer's declared cylinder + # loss factor (kWh/day) in `sap_heating.cylinder_heat_loss` (SAP + # 10.2 §4 branch a). Previously undeclared → `from_dict` dropped it + # and the §4 storage loss fell to None → the dwelling over-rated. + import dataclasses + + patched = dataclasses.replace( + schema, + sap_heating=dataclasses.replace( + schema.sap_heating, cylinder_heat_loss=1.72 + ), + ) + + # Act + result = EpcPropertyDataMapper.from_rdsap_schema_21_0_1(patched) + + # Assert + assert result.sap_heating.cylinder_heat_loss == 1.72 + # --- property flags --- def test_solar_water_heating(self, result: EpcPropertyData) -> None: diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 3aa30c1a..d74cd3ca 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -79,6 +79,11 @@ class SapHeating: # RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged # only when `cylinder_size` is the "Exact" descriptor (code 6). cylinder_size_measured: Optional[int] = None + # SAP 10.2 §4 branch a) (PDF p.136) — the manufacturer's declared + # cylinder loss factor (kWh/day). When lodged it replaces the Table 2 + # V×L×VF storage-loss computation. Previously undeclared → dropped by + # `from_dict`, so the storage loss fell through to None. + cylinder_heat_loss: Optional[float] = 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 3a21c47b..12628a7b 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -84,6 +84,12 @@ class SapHeating: # RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged # only when `cylinder_size` is the "Exact" descriptor (code 6). cylinder_size_measured: Optional[int] = None + # SAP 10.2 §4 branch a) (PDF p.136) — the manufacturer's declared + # cylinder loss factor (kWh/day). When lodged it replaces the Table 2 + # V×L×VF storage-loss computation (the gov leaves volume/insulation + # None in that case). Previously undeclared → dropped by `from_dict`, + # so the storage loss fell through to None and the dwelling over-rated. + cylinder_heat_loss: Optional[float] = None @dataclass diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index d60daefd..f4a86295 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3992,6 +3992,22 @@ def _int_or_none(value: object) -> Optional[int]: return value if isinstance(value, int) else None +def _float_or_none(value: object) -> Optional[float]: + """Coerce a lodged numeric (int / float / numeric string) to float, + else None. Used for measured overrides like the cylinder declared + loss factor (`cylinder_heat_loss`, kWh/day).""" + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError: + return None + return None + + def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float: """RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from the MAIN building's wall construction. @@ -6489,7 +6505,31 @@ def _cylinder_storage_loss_override( if not epc.has_hot_water_cylinder: return None sh = epc.sap_heating + # SAP 10.2 §4 branch a) (PDF p.136) — a lodged manufacturer's declared + # cylinder loss factor (kWh/day, gov-API `cylinder_heat_loss`) replaces + # the Table 2 V×L×VF computation. It does NOT need the insulation + # type / thickness / volume (which the gov leaves None precisely + # because the declared loss is lodged instead), so resolve it BEFORE + # those guards — otherwise the storage loss is dropped entirely and the + # dwelling over-rates (the declared-loss is typically ~1.5 kWh/day ≈ + # 550 kWh/yr). The Table-2b temperature factor still applies (49)→(50). + declared_loss = _float_or_none(getattr(sh, "cylinder_heat_loss", None)) volume_l = _cylinder_volume_l_from_code(epc) + if declared_loss is not None: + storage_56m = cylinder_storage_loss_monthly_kwh( + volume_l=volume_l or 0.0, + insulation_type="factory_insulated", # unused in the declared branch + thickness_mm=0.0, # unused in the declared branch + has_cylinder_thermostat=_cylinder_thermostat_present(epc, main), + separately_timed_dhw=_table_2b_note_b_multiplier_applies(epc, main), + declared_loss_kwh_per_day=declared_loss, + ) + # (57)m solar adjustment only when solar HW + a resolvable volume. + if not epc.solar_water_heating or volume_l is None: + return storage_56m + vs_l = round(volume_l * _COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION) + factor = (volume_l - vs_l) / volume_l + return tuple(s * factor for s in storage_56m) if volume_l is None: return None insulation_label = _cylinder_storage_loss_insulation_label( diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index 56eb0cd9..bf0dd475 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -628,10 +628,20 @@ def cylinder_storage_loss_monthly_kwh( thickness_mm: float, has_cylinder_thermostat: bool, separately_timed_dhw: bool, + declared_loss_kwh_per_day: Optional[float] = None, ) -> tuple[float, ...]: - """SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136): - (54) = V × L × VF × TF (Table 2 absence-of-declared-loss branch) - (55) = (54) (no manufacturer's declared loss) + """SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136). + + Two branches, selected by whether the manufacturer's declared loss + factor is lodged: + + a) declared loss known (`declared_loss_kwh_per_day` set): + (50) = (48) declared loss (kWh/day) × (49) Table-2b temperature factor + → `volume_l` / `insulation_type` / `thickness_mm` are unused. + b) declared loss not known (the default): + (54) = (47) V × (51) L × (52) VF × (53) TF + + (55) = (50) or (54) (56)m = (55) × n_m (n_m = days in month) Returns 12 monthly values in calendar order Jan..Dec. The cert's @@ -639,15 +649,21 @@ def cylinder_storage_loss_monthly_kwh( solar storage is present in the vessel — callers handling solar storage must adjust further per `(57)m = (56)m × [(47) - Vs] / (47)`. """ - L = cylinder_storage_loss_factor_table_2( - insulation_type=insulation_type, thickness_mm=thickness_mm, - ) - VF = cylinder_volume_factor_table_2a(volume_l) TF = cylinder_temperature_factor_table_2b( has_cylinder_thermostat=has_cylinder_thermostat, separately_timed_dhw=separately_timed_dhw, ) - combined_55 = volume_l * L * VF * TF + if declared_loss_kwh_per_day is not None: + # SAP 10.2 §4 (PDF p.136) branch a) — the lodged manufacturer's + # declared loss (kWh/day) replaces the Table 2 V×L×VF computation; + # the Table-2b temperature factor still applies (line (49)→(50)). + combined_55 = declared_loss_kwh_per_day * TF + else: + L = cylinder_storage_loss_factor_table_2( + insulation_type=insulation_type, thickness_mm=thickness_mm, + ) + VF = cylinder_volume_factor_table_2a(volume_l) + combined_55 = volume_l * L * VF * TF return tuple(combined_55 * n for n in _DAYS_IN_MONTH) diff --git a/tests/domain/sap10_calculator/worksheet/test_water_heating.py b/tests/domain/sap10_calculator/worksheet/test_water_heating.py index 81086c46..5a96deb5 100644 --- a/tests/domain/sap10_calculator/worksheet/test_water_heating.py +++ b/tests/domain/sap10_calculator/worksheet/test_water_heating.py @@ -676,6 +676,40 @@ def test_water_efficiency_monthly_via_equation_d1_weights_winter_summer_per_mont assert monthly[0] == pytest.approx(num / denom, abs=1e-6) +def test_cylinder_storage_loss_uses_declared_loss_factor_times_temp_factor() -> None: + # Arrange — SAP 10.2 §4 branch a) (PDF p.136): when the manufacturer's + # declared cylinder loss factor (kWh/day) is lodged, storage loss + # (50) = (48) declared × (49) Table-2b temperature factor — replacing + # the Table 2 V×L×VF computation. Volume / insulation are unused. + from domain.sap10_calculator.worksheet.water_heating import ( + cylinder_storage_loss_monthly_kwh, + cylinder_temperature_factor_table_2b, + ) + + declared = 1.72 + tf: float = cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=True, separately_timed_dhw=False, + ) + + # Act + result = cylinder_storage_loss_monthly_kwh( + volume_l=110.0, insulation_type="factory_insulated", thickness_mm=0.0, + has_cylinder_thermostat=True, separately_timed_dhw=False, + declared_loss_kwh_per_day=declared, + ) + # Same declared loss with a different volume / insulation must give the + # same result — they are not consulted in the declared branch. + result_other_geometry = cylinder_storage_loss_monthly_kwh( + volume_l=300.0, insulation_type="loose_jacket", thickness_mm=50.0, + has_cylinder_thermostat=True, separately_timed_dhw=False, + declared_loss_kwh_per_day=declared, + ) + + # Assert — January (31 days) = declared × TF × 31; geometry-invariant. + assert abs(result[0] - declared * tf * 31) <= 1e-9 + assert result == result_other_geometry + + def test_000474_cert_to_inputs_hot_water_kwh_closes_within_1pct_post_slice_2() -> None: """Cert-round-trip conformance: 000474 mid-terrace combi-gas (PDF HW fuel = 2291.78 kWh/yr). Slice 1 closed Σ(61) via PCDB Table 3b From 363f14fbb2e34afe1cb925b05626e5462e61a9e0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 05:56:47 +0000 Subject: [PATCH 05/10] fix(mapper): read dropped detailed room-in-roof slope + stud-wall surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov-EPC API lodges a Detailed RR (RdSAP 10 §3.9, Figure 4) with up to two sloping ceilings (`slope_*`) and two vertical stud/knee walls (`stud_wall_*`) in addition to the gable + flat-ceiling surfaces. Those slope/stud fields were undeclared on the 21.0.x schema, so `from_dict` silently dropped them and `_api_rir_detailed_surfaces` built ONLY the gable + flat-ceiling surfaces. The (large) sloping roof and the knee walls contributed ZERO heat loss → undercounted RR fabric loss → a systematic over-rate. Fix: declare `slope_*`/`stud_wall_*` on `RoomInRoofDetails` (rdsap_schema_21_0_0 + _21_0_1) and build `kind="slope"` / `kind="stud_wall"` surfaces in the mapper. The cascade's Detailed-RR branch already routes both to the roof aggregate via `u_rr_slope` (Table 17 col 1) and `u_rr_stud_wall` (Table 17 col 3) — RdSAP 10 §5.11.3, p.43-44 — so no calculator change is needed (Summary path worksheet-validated by the 000565 detailed-RR fixtures). insulation_type is left None to defer to the Table 17 col-(a) mineral-wool default, mirroring the existing flat_ceiling branch. 15 /tmp certs carry `slope_height_1`: cohort mean|err| 4.26 -> 2.05, signed +4.09 -> centred (14/15 were over-rating; e.g. 0390-2538 +5.95 -> +3.56). Gauges: corpus within-0.5 67.3% -> 67.5% (MAE 1.020 -> 0.987); /tmp 71.4% -> 71.6% (MAE 0.882 -> 0.846). Harness 47/47 0 raised; regression = the 3 pre-existing fails; pyright net-zero (65=65). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 33 +++++++++ .../domain/tests/test_from_rdsap_schema.py | 69 +++++++++++++++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 19 +++++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 21 ++++++ 4 files changed, 142 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 9f75847d..98feac78 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3960,6 +3960,39 @@ def _api_rir_detailed_surfaces( if length is not None and height is not None and length > 0 and height > 0: area = _round_half_up_2dp(float(length), float(height)) surfaces.append(SapRoomInRoofSurface(kind=gable_kind, area_m2=area)) + # Sloping ceiling + stud walls — up to two of each (RdSAP §3.9 Figure 4). + # Both route to the roof aggregate (line (30)) via the cascade's + # Detailed-RR branch (`u_rr_slope` / `u_rr_stud_wall`, Table 17 cols 1/3). + # insulation_type is left None so the cascade defers to the Table 17 + # column (a) mineral-wool default, mirroring the flat_ceiling branch. + slope_specs = ( + (details.slope_length_1, details.slope_height_1, + details.slope_insulation_thickness_1), + (details.slope_length_2, details.slope_height_2, + details.slope_insulation_thickness_2), + ) + stud_specs = ( + (details.stud_wall_length_1, details.stud_wall_height_1, + details.stud_wall_insulation_thickness_1), + (details.stud_wall_length_2, details.stud_wall_height_2, + details.stud_wall_insulation_thickness_2), + ) + for kind, specs in (("slope", slope_specs), ("stud_wall", stud_specs)): + for length, height, thickness_str in specs: + if ( + length is not None and height is not None + and length > 0 and height > 0 + ): + area = _round_half_up_2dp(float(length), float(height)) + surfaces.append( + SapRoomInRoofSurface( + kind=kind, + area_m2=area, + insulation_thickness_mm=( + _parse_rir_insulation_thickness_mm(thickness_str) + ), + ) + ) if ( details.flat_ceiling_length_1 is not None and details.flat_ceiling_height_1 is not None diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 04073801..5d29893a 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2152,3 +2152,72 @@ class TestRdSap17_1ReducedFieldSynthesis: assert result.sap_heating.number_baths == expected_baths assert result.sap_heating.mixer_shower_count == expected_mixers + + +class TestRoomInRoofDetailedSlopeAndStudWall: + """RdSAP 10 §3.9 Detailed RR — the gov API lodges the sloping ceiling + and stud-wall surfaces under `room_in_roof_details.slope_*` / + `stud_wall_*`. These were undeclared on the schema, so `from_dict` + dropped them and the API mapper built ONLY the gable + flat-ceiling + surfaces — omitting the (large) sloping roof and vertical knee walls → + undercounted RR heat loss → a systematic ~+4 SAP over-rate across the + 15 detailed-RR corpus certs carrying `slope_height_1`.""" + + def test_slope_surface_survives_from_dict_round_trip(self) -> None: + # Arrange — a 21.0.1 detailed-RR block (cert 0390-2538 shape). + from datatypes.epc.schema.rdsap_schema_21_0_1 import RoomInRoofDetails + + raw = { + "slope_length_1": 7.0, + "slope_height_1": 1.4, + "slope_insulation_thickness_1": "100mm", + "stud_wall_length_1": 7.0, + "stud_wall_height_1": 1.03, + "stud_wall_insulation_thickness_1": "75mm", + } + + # Act + details = from_dict(RoomInRoofDetails, raw) + + # Assert — the fields are no longer silently dropped. + assert details.slope_height_1 == 1.4 + assert details.slope_insulation_thickness_1 == "100mm" + assert details.stud_wall_height_1 == 1.03 + + def test_from_api_response_builds_slope_and_stud_wall_surfaces(self) -> None: + # Arrange — drive the PUBLIC API path: take the 21.0.1 fixture's RR + # building part and replace its Simplified Type-1 block with a + # Detailed RR carrying two sloping ceilings (7 × 1.4) + two stud + # walls (7 × 1.03). cert 0390-2538 went +5.95 -> +3.56 SAP once these + # surfaces entered the roof aggregate. + cert = load("21_0_1.json") + rir = cert["sap_building_parts"][0]["sap_room_in_roof"] + rir.pop("room_in_roof_type_1", None) + rir["room_in_roof_details"] = { + "slope_length_1": 7.0, "slope_height_1": 1.4, + "slope_length_2": 7.0, "slope_height_2": 1.4, + "slope_insulation_thickness_1": "100mm", + "slope_insulation_thickness_2": "100mm", + "stud_wall_length_1": 7.0, "stud_wall_height_1": 1.03, + "stud_wall_length_2": 7.0, "stud_wall_height_2": 1.03, + "stud_wall_insulation_thickness_1": "75mm", + "stud_wall_insulation_thickness_2": "75mm", + } + + # Act + result = EpcPropertyDataMapper.from_api_response(cert) + + # Assert — both slopes + both stud walls reach the cascade, with the + # lodged thickness parsed and the L × H area to 2 d.p. + rir_part = result.sap_building_parts[0].sap_room_in_roof + assert rir_part is not None + surfaces = rir_part.detailed_surfaces + assert surfaces is not None + slopes = [s for s in surfaces if s.kind == "slope"] + studs = [s for s in surfaces if s.kind == "stud_wall"] + assert len(slopes) == 2 + assert len(studs) == 2 + assert abs(slopes[0].area_m2 - 9.8) <= 1e-9 + assert slopes[0].insulation_thickness_mm == 100 + assert abs(studs[0].area_m2 - 7.21) <= 1e-9 + assert studs[0].insulation_thickness_mm == 75 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index d74cd3ca..fdb0d17c 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -209,6 +209,25 @@ class RoomInRoofDetails: flat_ceiling_height_1: Optional[float] = None flat_ceiling_insulation_type_1: Optional[int] = None flat_ceiling_insulation_thickness_1: Optional[str] = None + # Sloping-ceiling + stud-wall surfaces of a Detailed RR — see + # `rdsap_schema_21_0_1.RoomInRoofDetails`. Previously undeclared and + # dropped by `from_dict`. + slope_length_1: Optional[float] = None + slope_length_2: Optional[float] = None + slope_height_1: Optional[float] = None + slope_height_2: Optional[float] = None + slope_insulation_type_1: Optional[int] = None + slope_insulation_type_2: Optional[int] = None + slope_insulation_thickness_1: Optional[str] = None + slope_insulation_thickness_2: Optional[str] = None + stud_wall_length_1: Optional[float] = None + stud_wall_length_2: Optional[float] = None + stud_wall_height_1: Optional[float] = None + stud_wall_height_2: Optional[float] = None + stud_wall_insulation_type_1: Optional[int] = None + stud_wall_insulation_type_2: Optional[int] = None + stud_wall_insulation_thickness_1: Optional[str] = None + stud_wall_insulation_thickness_2: 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 12628a7b..db6d4c1a 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -246,6 +246,27 @@ class RoomInRoofDetails: flat_ceiling_height_1: Optional[float] = None flat_ceiling_insulation_type_1: Optional[int] = None flat_ceiling_insulation_thickness_1: Optional[str] = None + # The sloping-ceiling and stud-wall surfaces of a Detailed RR. Up to two + # of each per spec Figure 4. Previously undeclared, so `from_dict` + # silently dropped them and the API mapper built ONLY the gable + flat- + # ceiling surfaces — omitting the (large) sloping roof and the vertical + # stud walls → undercounted RR heat loss → systematic over-rate. + slope_length_1: Optional[float] = None + slope_length_2: Optional[float] = None + slope_height_1: Optional[float] = None + slope_height_2: Optional[float] = None + slope_insulation_type_1: Optional[int] = None + slope_insulation_type_2: Optional[int] = None + slope_insulation_thickness_1: Optional[str] = None + slope_insulation_thickness_2: Optional[str] = None + stud_wall_length_1: Optional[float] = None + stud_wall_length_2: Optional[float] = None + stud_wall_height_1: Optional[float] = None + stud_wall_height_2: Optional[float] = None + stud_wall_insulation_type_1: Optional[int] = None + stud_wall_insulation_type_2: Optional[int] = None + stud_wall_insulation_thickness_1: Optional[str] = None + stud_wall_insulation_thickness_2: Optional[str] = None @dataclass From e4adab0e8828217e341b9523c7a619a633339de2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 05:57:27 +0000 Subject: [PATCH 06/10] test(corpus): ratchet SAP floor 0.65->0.67, ceiling 1.08->1.00 Lock in the detailed-RR slope + stud-wall gain (corpus within-0.5 67.3% -> 67.5%, MAE 1.020 -> 0.987). The corpus is a fixed 1000-cert deterministic gauge, so the thresholds track measured HEAD with a small margin per the ratchet convention. Co-Authored-By: Claude Opus 4.8 --- .../epc_client/test_sap_accuracy_corpus.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index fb38613f..a19c9b6d 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -82,8 +82,19 @@ _CORPUS = Path( # -> 0.68) and case 42 (rafters 50mm -> 0.88; rafters genuine-unknown band C # -> 2.30 per Table 18 footnote 1 "applies for unknown and as built"). Do NOT # revert the rafters column. -_MIN_WITHIN_HALF_SAP = 0.65 -_MAX_SAP_MAE = 1.08 +# +# DETAILED RR SLOPE + STUD WALL (RdSAP 10 §3.9 Figure 4 + §5.11.3 Table 17 cols +# 1/3, p.43-44): the gov API lodges a Detailed RR's sloping ceilings (slope_*) +# and stud/knee walls (stud_wall_*) alongside the gable + flat-ceiling surfaces. +# Those fields were UNDECLARED on the schema, so `from_dict` dropped them and the +# mapper built only gable + flat-ceiling — the (large) sloping roof and knee +# walls contributed ZERO heat loss -> undercounted RR fabric -> over-rate. +# Declaring + threading slope/stud into `detailed_surfaces` (cascade already +# routes both to the roof aggregate) recovered the 15-cert /tmp cohort from +# mean|err| 4.26 -> 2.05 (e.g. 0390-2538 +5.95 -> +3.56). Corpus within-0.5 +# 67.3% -> 67.5% (MAE 1.020 -> 0.987). +_MIN_WITHIN_HALF_SAP = 0.67 +_MAX_SAP_MAE = 1.00 _MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current From 26998152a774d64d6a98bd2a0b5f759fbff472d3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 06:22:45 +0000 Subject: [PATCH 07/10] fix(mapper): read dropped detailed room-in-roof common-wall surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on to the slope/stud slice. A Detailed RR (RdSAP 10 §3.9.2) can also lodge `common_wall_*` — the wall separating the room-in-roof from the rest of the cold roof void. Those fields were undeclared → `from_dict` dropped them → `_api_rir_detailed_surfaces` omitted the common walls → the RR undercounted wall heat loss → over-rate. Fix: declare `common_wall_length/height_1/2` on `RoomInRoofDetails` (21_0_0 + 21_0_1) and build `kind="common_wall"` surfaces (raw L × H area to 2 d.p.). The cascade's Detailed-RR branch already bills common walls at the storey-below main-wall U (Table 4 p.22 "Common wall") and deducts their area from the §3.10.1 residual roof — no calculator change. No insulation thickness is read: common walls take the main-wall U, not a Table 17 RR-element U. 6 /tmp certs carry detailed `common_wall_length_1`: cohort mean|err| 2.43 -> 1.25 (all were over-rating; e.g. 2877-3059 +4.55 -> +2.79). Gauges: corpus within-0.5 67.5% -> 67.6% (MAE 0.987 -> 0.979); /tmp 71.6% -> 71.7% (MAE 0.846 -> 0.838). Harness 47/47 0 raised; regression = the 3 pre-existing fails; pyright net-zero (65=65). Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 20 +++++++++++++++++++ .../domain/tests/test_from_rdsap_schema.py | 9 ++++++++- datatypes/epc/schema/rdsap_schema_21_0_0.py | 6 ++++++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 9 +++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 98feac78..d09a46e6 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3993,6 +3993,26 @@ def _api_rir_detailed_surfaces( ), ) ) + # Common walls — billed as external wall at the storey-below main-wall U + # (cascade `kind="common_wall"`), so no insulation thickness is read. + # Detailed BPs use the raw L × H area (RdSAP 10 §3.9.2; the cascade's + # common_wall branch applies the L × (0.25 + H) form only to Simplified + # BPs). The cascade deducts this area from the §3.10.1 residual roof. + common_wall_specs = ( + (details.common_wall_length_1, details.common_wall_height_1), + (details.common_wall_length_2, details.common_wall_height_2), + ) + for length, height in common_wall_specs: + if ( + length is not None and height is not None + and length > 0 and height > 0 + ): + surfaces.append( + SapRoomInRoofSurface( + kind="common_wall", + area_m2=_round_half_up_2dp(float(length), float(height)), + ) + ) if ( details.flat_ceiling_length_1 is not None and details.flat_ceiling_height_1 is not None diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 5d29893a..054e9864 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2202,22 +2202,29 @@ class TestRoomInRoofDetailedSlopeAndStudWall: "stud_wall_length_2": 7.0, "stud_wall_height_2": 1.03, "stud_wall_insulation_thickness_1": "75mm", "stud_wall_insulation_thickness_2": "75mm", + "common_wall_length_1": 8.6, "common_wall_height_1": 1.2, + "common_wall_length_2": 8.6, "common_wall_height_2": 1.2, } # Act result = EpcPropertyDataMapper.from_api_response(cert) # Assert — both slopes + both stud walls reach the cascade, with the - # lodged thickness parsed and the L × H area to 2 d.p. + # lodged thickness parsed and the L × H area to 2 d.p. Common walls + # route to the `common_wall` kind (raw L × H, billed at main-wall U). rir_part = result.sap_building_parts[0].sap_room_in_roof assert rir_part is not None surfaces = rir_part.detailed_surfaces assert surfaces is not None slopes = [s for s in surfaces if s.kind == "slope"] studs = [s for s in surfaces if s.kind == "stud_wall"] + commons = [s for s in surfaces if s.kind == "common_wall"] assert len(slopes) == 2 assert len(studs) == 2 + assert len(commons) == 2 assert abs(slopes[0].area_m2 - 9.8) <= 1e-9 assert slopes[0].insulation_thickness_mm == 100 assert abs(studs[0].area_m2 - 7.21) <= 1e-9 assert studs[0].insulation_thickness_mm == 75 + assert abs(commons[0].area_m2 - 10.32) <= 1e-9 + assert commons[0].insulation_thickness_mm is None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index fdb0d17c..4f7e7e40 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -228,6 +228,12 @@ class RoomInRoofDetails: stud_wall_insulation_type_2: Optional[int] = None stud_wall_insulation_thickness_1: Optional[str] = None stud_wall_insulation_thickness_2: Optional[str] = None + # §3.9.2 common walls of a Detailed RR — see + # `rdsap_schema_21_0_1.RoomInRoofDetails`. Previously dropped. + common_wall_length_1: Optional[float] = None + common_wall_length_2: Optional[float] = None + common_wall_height_1: Optional[float] = None + common_wall_height_2: Optional[float] = 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 db6d4c1a..37714034 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -267,6 +267,15 @@ class RoomInRoofDetails: stud_wall_insulation_type_2: Optional[int] = None stud_wall_insulation_thickness_1: Optional[str] = None stud_wall_insulation_thickness_2: Optional[str] = None + # The §3.9.2 common walls of a Detailed RR (the wall separating the RR + # from the rest of the cold roof void). Billed as external wall at the + # storey-below main-wall U (cascade `kind="common_wall"`). Detailed BPs + # use the raw L × H area (Simplified Type-2 BPs use L × (0.25 + H)). + # Previously undeclared → dropped → the RR undercounted wall loss. + common_wall_length_1: Optional[float] = None + common_wall_length_2: Optional[float] = None + common_wall_height_1: Optional[float] = None + common_wall_height_2: Optional[float] = None @dataclass From 8a70d222784ab3f7101e22d0aa7f447d42bdb671 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 06:23:11 +0000 Subject: [PATCH 08/10] test(corpus): ratchet SAP ceiling 1.00->0.99 (detailed-RR common walls) Co-Authored-By: Claude Opus 4.8 --- tests/infrastructure/epc_client/test_sap_accuracy_corpus.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index a19c9b6d..aa05c9af 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -92,9 +92,11 @@ _CORPUS = Path( # Declaring + threading slope/stud into `detailed_surfaces` (cascade already # routes both to the roof aggregate) recovered the 15-cert /tmp cohort from # mean|err| 4.26 -> 2.05 (e.g. 0390-2538 +5.95 -> +3.56). Corpus within-0.5 -# 67.3% -> 67.5% (MAE 1.020 -> 0.987). +# 67.3% -> 67.5% (MAE 1.020 -> 0.987). The follow-on `common_wall_*` Detailed-RR +# surfaces (billed at main-wall U, deducted from the §3.10.1 residual) took the +# 6-cert detailed-common-wall cohort 2.43 -> 1.25; corpus -> 67.6% (MAE 0.979). _MIN_WITHIN_HALF_SAP = 0.67 -_MAX_SAP_MAE = 1.00 +_MAX_SAP_MAE = 0.99 _MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current From a33707f851262b1b71a8724d8023054c5bbb372b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 07:51:33 +0000 Subject: [PATCH 09/10] fix(elmhurst): read main-wall dry-lining + fix last-RR-row U over-read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compensating Summary-extractor bugs surfaced by simulated case 43 (a 2-BP mid-terrace with a detailed room-in-roof + a dry-lined extension wall). Their fabric errors nearly cancelled (walls net −0.76 W/K), hiding both behind a deceptively small +0.05 SAP delta. Bug 1 — main/extension wall dry-lining never read. The §7 "Dry-lining: Yes/No" line was parsed only for ALTERNATIVE walls; the main/extension WallDetails dropped it, so a dry-lined solid wall was billed at its un-adjusted base U. RdSAP 10 §5.8 + Table 14: a dry-lined uninsulated wall adds R=0.17 → U = 1/(1/U_base + 0.17). Case 43 Ext1: solid brick 1.70 → 1.32. Added `WallDetails.dry_lined`, read it in the extractor (both the main-wall builder and the As-Main copy), threaded it to the domain `wall_dry_lined` (emit None when undried — cascade-equivalent to False, keeps the field absent for the non-dry-lined majority). Bug 2 — the LAST room-in-roof surface row's U over-read. The per-row token scan stops at the next RIR-row name; the final surface (no successor) over- read into the following section, shifting the trailing-token slotting and silently zeroing its `default_u` (case 43 Common Wall 2: 1.90 → 0.00 → the 2.4 m² common wall billed at U=0 instead of the main-wall 1.90). Stop the scan at the row's natural end — the "Yes"/"No" u_value_known flag plus the trailing u_value numeric. Case 43 now reproduces the P960 EXACTLY: (29a) walls 74.5800, (33) fabric 172.7844, continuous SAP 73.2332 = (258), CO2 3518.30 = (272), all <1e-4 (was SAP +0.0455 / CO2 −8.04). Harness 47/47 0 raised; regression = the 3 pre-existing fails; pyright net-zero (51=51). Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 19 +++++++++++++++++++ datatypes/epc/domain/epc_property_data.py | 2 +- datatypes/epc/domain/mapper.py | 6 ++++++ datatypes/epc/surveys/elmhurst_site_notes.py | 6 ++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 11e94fba..a8c9e596 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -290,6 +290,10 @@ class ElmhurstSiteNotesExtractor: party_wall_type=self._local_str(lines, "Party Wall Type"), thickness_mm=thickness_mm, insulation_thickness_mm=insulation_thickness_mm, + # Summary §7 "Dry-lining: Yes/No" on the main/extension wall. + # RdSAP 10 §5.8 + Table 14 dry-lining R=0.17 adjustment. The + # alt-wall path reads its own "Alternative Wall N Dry-lining". + dry_lined=self._local_bool(lines, "Dry-lining"), alternative_walls=self._alternative_walls_from_lines(lines), # Summary §7 lodges the per-BP "Curtain Wall Age" line only # when `Type: CW Curtain Wall`. Per RdSAP 10 §5.18 (PDF @@ -548,6 +552,20 @@ class ElmhurstSiteNotesExtractor: if self._is_next_rir_row(lines[j]): break tokens.append(lines[j]) + # Every RIR row ends with [default_u, "Yes"/"No", u_value]; the + # "Yes"/"No" is the unique u_value_known marker (gable types and + # insulation cells never take that value). Stop once we've + # appended that flag plus the trailing u_value numeric so the + # LAST surface row (no next-row name to bound it) does not + # over-read into the following section and shift the trailing + # token slotting — which silently zeroed Common Wall 2's + # default_u (case 43: 1.90 -> 0.00). + if ( + len(tokens) >= 2 + and tokens[-2] in ("Yes", "No") + and self._RIR_NUMERIC_RE.match(tokens[-1]) + ): + break # First two numerics = length, height length = float(tokens[0]) if tokens and self._RIR_NUMERIC_RE.match(tokens[0]) else 0.0 height = float(tokens[1]) if len(tokens) > 1 and self._RIR_NUMERIC_RE.match(tokens[1]) else 0.0 @@ -698,6 +716,7 @@ class ElmhurstSiteNotesExtractor: party_wall_type=ext_party_wall_type, thickness_mm=main_walls.thickness_mm, insulation_thickness_mm=main_walls.insulation_thickness_mm, + dry_lined=main_walls.dry_lined, alternative_walls=self._alternative_walls_from_lines(wall_lines), ) else: diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 1472963d..baf1db00 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -505,7 +505,7 @@ class SapBuildingPart: building_part_number: Optional[int] = ( None # Not sure how we get this from site notes ) - wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes + wall_dry_lined: Optional[bool] = None # Summary §7 "Dry-lining: Yes/No" wall_thickness_mm: Optional[int] = None # Union[str, int]: a numeric mm value when the API lodges # `wall_insulation_thickness == "measured"` (resolved from the diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index d09a46e6..b3fc944f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4436,6 +4436,12 @@ def _map_elmhurst_building_part( 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, + # Summary §7 "Dry-lining: Yes" → RdSAP 10 §5.8 Table 14 R=0.17 + # adjustment in the cascade (`dry_lined=bool(part.wall_dry_lined)`). + # Emit None (not False) when undried so the field stays absent for + # the non-dry-lined majority (cascade-equivalent: bool(None) == False); + # only a lodged "Yes" populates it. + wall_dry_lined=walls.dry_lined or None, party_wall_construction=_elmhurst_party_wall_construction_int( walls.party_wall_type ), diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index eded346f..4614d33c 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -94,6 +94,12 @@ class WallDetails: # "Insulation Thickness" / "100 mm" line pair when a composite or # retrofit insulation is recorded. None when the PDF omits the line. insulation_thickness_mm: Optional[int] = None + # Summary §7 "Dry-lining: Yes/No" on the main/extension wall (distinct + # from the per-alt-wall `AlternativeWall.dry_lined`). Per RdSAP 10 + # §5.8 + Table 14 a dry-lined uninsulated wall adds R=0.17 m²K/W → + # U = 1/(1/U_base + 0.17). Previously unread, so dry-lined solid/ + # cavity walls were billed at the un-adjusted (higher) base U. + dry_lined: bool = False # Per-BP curtain-wall installation age, lodged in Summary §7 as # "Curtain Wall Age" when `wall_type` is "CW Curtain Wall". Per # RdSAP 10 §5.18 (PDF p.48) the curtain-wall U-value keys on this From 419e340477e15d37531b74519e63fd43cbe0ab34 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 08:26:05 +0000 Subject: [PATCH 10/10] test(worksheet): pin simulated case 43 at 1e-4 (RR + dry-line + mixed roof) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden regression fixture for the multi-feature dwelling that surfaced the two Elmhurst-extractor bugs in a33707f8. case 43 is a 2-BP mid-terrace with a DETAILED room-in-roof (two slopes, two flat ceilings, party + exposed gables, two common walls), a MIXED-insulation multi-section roof (Main insulated + Extension uninsulated 2.30), a DRY-LINED extension solid wall, a mains-gas boiler (102 / control 2106) and a House-coal solid-fuel secondary (633). Routes the Summary PDF through the WHOLE extractor + mapper + calculator pipeline (no hand-built EpcPropertyData) and pins the §3 fabric + SAP-rating block at abs=1e-4: (29a) walls 74.5800, (30) roof 38.5008, (33) fabric 172.7844, continuous SAP 73.2332 = (258), CO2 3518.3037 = (272). Guards the detailed-RR slope/common_wall surfaces, the dry-lining R=0.17 adjustment, and the per-part mixed-roof billing together. Summary mirrored to backend/documents_parser/tests/fixtures/Summary_001431_case43.pdf; provider module mirrors the _case6/_case21 pattern, assertion in test_section_cascade_pins. Harness 47/47; regression = the 3 pre-existing fails; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../tests/fixtures/Summary_001431_case43.pdf | Bin 0 -> 79920 bytes .../_elmhurst_worksheet_001431_case43.py | 116 ++++++++++++++++++ .../worksheet/test_section_cascade_pins.py | 42 +++++++ 3 files changed, 158 insertions(+) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case43.pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case43.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case43.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case43.pdf new file mode 100644 index 0000000000000000000000000000000000000000..080fe6184738e22fb3313d4c366d455e79c84fca GIT binary patch literal 79920 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 literal 0 HcmV?d00001 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case43.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case43.py new file mode 100644 index 00000000..2e4a7ae3 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case43.py @@ -0,0 +1,116 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 43" worksheet — a 2-storey mid-terrace deliberately built to +exercise every feature in one dwelling: + + - a DETAILED room-in-roof on the Main BP (two slopes, two flat ceilings, + a party + an exposed gable, two common walls) — exercises the + slope / stud / common_wall detailed-RR surfaces end-to-end; + - a MIXED-insulation multi-section roof (Main insulated 0.16/0.54/0.68/0.11 + + Extension uninsulated 2.30); + - a DRY-LINED extension solid wall (RdSAP 10 §5.8 Table 14 R=0.17: + solid brick 1.70 -> 1.32); + - a mains-gas boiler (SAP 102, control 2106 interlock) with a House-coal + solid-fuel SECONDARY (633, 60%) and a 210 L declared-loss cylinder. + +This case was generated to settle the room-in-roof + mixed-roof + secondary +feature set with a single 1e-4 pin. It exposed two compensating Elmhurst- +extractor bugs (commit `a33707f8`) whose fabric errors nearly cancelled +(walls net -0.76 W/K, hidden behind a +0.05 SAP delta): + 1. the main/extension wall "Dry-lining: Yes" line was read only for + ALTERNATIVE walls -> the dry-lined extension wall billed at the + un-adjusted 1.70 instead of 1.32; + 2. the LAST room-in-roof surface row's per-row token scan over-read into + the next section -> Common Wall 2's default U silently zeroed + (1.90 -> 0.00). +With both fixed the whole §3 fabric and the SAP/CO2 reproduce EXACTLY. + +Like 000565 / the _rr cases / case 20 / 21 / 38 / 39, this fixture does NOT +hand-build the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises +the WHOLE extractor + mapper + calculator pipeline. + +Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/ +simulated case 43/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case43.pdf` so the +test runs without depending on the unstaged workspace. + +Worksheet pin targets (P960-0001-001431, "11a. SAP rating" / "12a. CO2 +emissions" block — the UK-average-climate rating block our cascade +reproduces): +- SAP value (un-rounded, before (258) integer rounding) = 73.2332 (band C) +- (272) Total CO2, kg/year = 3518.30 + +Per [[feedback-zero-error-strict]] + [[feedback-continuous-sap-tolerance]]: +pins are abs <= 1e-3 against the worksheet PDF (printed to 4 dp). +""" + +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_case43.pdf" +) + +LINE_29A_WALLS_W_PER_K: Final[float] = 74.5800 +# (30) = ΣA×U: FlatCeil1 4.3200 + FlatCeil2 6.9000 + Slope1 1.0200 + +# Slope2 0.1408 + roof Main 3.1200 + roof Ext1 (uninsulated) 23.0000. +LINE_30_ROOF_W_PER_K: Final[float] = 38.5008 +LINE_33_FABRIC_W_PER_K: Final[float] = 172.7844 +LINE_258_SAP_VALUE_CONTINUOUS: Final[float] = 73.2332 +LINE_272_TOTAL_CO2_KG_PER_YR: Final[float] = 3518.3037 + + +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/value token sequences). + Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures. + """ + 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-43 Summary through extractor + mapper. + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. This module is a pin PROVIDER (build_epc + LINE_* + constants, mirroring `_elmhurst_worksheet_001431_case6` / `_case21`); + the collected assertion lives in + `test_section_cascade_pins.test_case43_*`.""" + 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 b8f166ab..4e7336e3 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -44,6 +44,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_000516 as _w000516, _elmhurst_worksheet_001431_case6 as _w001431_case6, _elmhurst_worksheet_001431_case21 as _w001431_case21, + _elmhurst_worksheet_001431_case43 as _w001431_case43, ) @@ -328,6 +329,47 @@ def test_section_3_wall_u_by_thickness_case21_match_pdf() -> None: ) +def test_case43_detailed_rr_dryline_and_mixed_roof_match_pdf() -> None: + """Full-feature pin for simulated case 43 — a 2-BP mid-terrace with a + DETAILED room-in-roof (slopes + flat ceilings + party/exposed gables + + common walls), a MIXED-insulation multi-section roof (Main insulated + + Extension uninsulated), a DRY-LINED extension solid wall (RdSAP 10 §5.8 + Table 14: 1.70 -> 1.32), a mains-gas boiler (102, control 2106) and a + House-coal solid-fuel secondary (633). Exposed + regression-guards two + compensating Elmhurst-extractor bugs (commit a33707f8): the unread + main-wall dry-lining and the last-RR-row default-U over-read, whose + fabric errors nearly cancelled (walls net -0.76). With both fixed the + §3 fabric and the SAP-rating block reproduce the P960 exactly.""" + # Arrange + from domain.sap10_calculator.calculator import calculate_sap_from_inputs + + epc = _w001431_case43.build_epc() + + # Act + ht = heat_transmission_section_from_cert(epc) + result = calculate_sap_from_inputs(cert_to_inputs(epc)) + + # Assert — §3 fabric (the RR + dry-lining + mixed-roof fixes) and the + # SAP-rating block, each at abs=1e-4. + _pin(ht.walls_w_per_k, _w001431_case43.LINE_29A_WALLS_W_PER_K, "§3 (29a) case43") + _pin(ht.roof_w_per_k, _w001431_case43.LINE_30_ROOF_W_PER_K, "§3 (30) case43") + _pin( + ht.fabric_heat_loss_w_per_k, + _w001431_case43.LINE_33_FABRIC_W_PER_K, + "§3 (33) case43", + ) + _pin( + result.sap_score_continuous, + _w001431_case43.LINE_258_SAP_VALUE_CONTINUOUS, + "(258) case43", + ) + _pin( + result.co2_kg_per_yr, + _w001431_case43.LINE_272_TOTAL_CO2_KG_PER_YR, + "(272) case43", + ) + + 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