From 27375d93a4c9179b6dda88bb9c3f3e98d53c3acb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 14:40:06 +0000 Subject: [PATCH] =?UTF-8?q?fix(u-value):=20solid=20brick=20as-built=20U=20?= =?UTF-8?q?by=20thickness=20=E2=80=94=20=C2=A75.7=20Table=2013?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 440 mm (>420 mm) solid brick AS-BUILT wall computed U = 1.70 (the 220 mm bucket default) instead of the RdSAP-correct 1.10. The §5.7 Table 13 thickness path only fired for *insulated* brick (external/ internal + thickness > 0); the as-built case fell through to the Table 6 cavity/solid age-band default. Spec: RdSAP 10 Specification (9th June 2025), §5.7 "U-values for uninsulated brick walls, age bands A to E", Table 13 (PDF p.40): ≤200 mm → 2.5, 200–280 mm → 1.7, 280–420 mm → 1.4, >420 mm → 1.1. Table 6 footnote (b) on the "Solid brick as built" row (PDF p.40): "Or from 5.7 if wall thickness is other than 200mm to 280mm" — the thickness table supersedes the flat 1.7 default whenever a documentary wall thickness is lodged (200–280 mm gives 1.7 either way). The §5.8 / Table 14 dry-lining R is added on top only when the wall is dry-lined, per the §5.7 closing sentence. Validated against the user-generated Elmhurst worksheet "simulated case 21" (replica of API cert 2818-3053-3203-2655-9204: mid-terrace, age band B, solid brick as-built 440 mm, room-in-roof). New §3 cascade pin `test_section_3_wall_u_by_thickness_case21_match_pdf` routes the Summary through the real extractor + mapper and pins: (31) 155.1000, (33) 175.6208, (36) 23.2650, (37) 198.8858 — all 1e-4. External walls Main U → 1.1000; Sheltered RR gable → 1/(1/1.10+0.5) = 0.71 (was 0.92). Pinned on §3 only (case-6 precedent): its code-908 instantaneous multi-point gas water heater has a separate §4 (219) gap. Cross-check: sim case 20 (220 mm) stays at 1.70 — unchanged. API SAP accuracy (scripts/eval_api_sap_accuracy.py, 896 computed certs): % |err| < 0.5 SAP vs lodged: 42.6% → 43.8%; mean |err| 2.045 → 2.010. Regression: tests/domain/sap10_calculator/ (1861), backend/ documents_parser/tests/ (574), datatypes/epc/ + rdsap golden fixtures all green (pre-existing test_total_floor_area excepted). pyright strict net-zero. No solid-brick fixture pin shifted (200–280 mm unchanged). Co-Authored-By: Claude Opus 4.8 --- .../tests/fixtures/Summary_001431_case21.pdf | Bin 0 -> 80915 bytes domain/sap10_ml/rdsap_uvalues.py | 29 ++++ .../_elmhurst_worksheet_001431_case21.py | 129 ++++++++++++++++++ .../worksheet/test_section_cascade_pins.py | 45 ++++++ 4 files changed, 203 insertions(+) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case21.pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case21.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case21.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case21.pdf new file mode 100644 index 0000000000000000000000000000000000000000..dc7da3aba7005c3a090a08926fd7bfa4f167e881 GIT binary patch literal 80915 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 literal 0 HcmV?d00001 diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 8013e3d7..44c7e79d 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -648,6 +648,35 @@ def u_wall( return float( Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) ) + # RdSAP 10 §5.7 Table 13 (PDF p.40) — uninsulated ("as built") solid + # brick wall U₀ by lodged wall thickness, age bands A-E. Table 6 + # footnote (b) on the "Solid brick as built" row (PDF p.40): + # "Or from 5.7 if wall thickness is other than 200mm to 280mm" — the + # thickness table supersedes the flat 1.7 Table-6 default whenever a + # documentary wall thickness is lodged. 200-280 mm gives 1.7 either + # way, so the table is applied unconditionally here: + # ≤200 → 2.5, 200-280 → 1.7, 280-420 → 1.4, >420 → 1.1. + # The §5.8 + Table 14 dry-lining R is added on top only when the wall + # is dry-lined (§5.7 closing sentence: "Apply the adjustment according + # to Table 14 ... if wall is insulated or/and dry-lined including lath + # and plaster"). The insulated External/Internal case is handled by + # the branch above; this is the as-built (and dry-lined-only) path. + # Worksheet sim case 21: solid brick 440 mm (>420) as-built, Dry-lining + # No → U=1.10 (§3 (29a)). Cross-check sim case 20: 220 mm → 1.70. + if ( + wall_type == WALL_SOLID_BRICK + and band in _STONE_AGE_A_TO_E + and wall_thickness_mm is not None + ): + u0 = _u_brick_thin_wall_age_a_to_e(wall_thickness_mm) + if dry_lined: + u_unrounded = 1.0 / (1.0 / u0 + _DRY_LINING_RESISTANCE_M2K_PER_W) + return float( + Decimal(str(u_unrounded)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + ) + return u0 if wall_type == WALL_CAVITY and wall_insulation_type in ( WALL_INSULATION_CAVITY_PLUS_EXTERNAL, WALL_INSULATION_CAVITY_PLUS_INTERNAL, diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case21.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case21.py new file mode 100644 index 00000000..a36a78ed --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case21.py @@ -0,0 +1,129 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 21" worksheet — a replica of API cert +2818-3053-3203-2655-9204: a mid-terrace, age-band-B dwelling whose Main +wall is **solid brick, as built, 440 mm** (room-in-roof above). + +Like 000565 / the _rr cases / case 20, this fixture does NOT hand-build +the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result +pin grid exercises the WHOLE extractor + mapper + calculator pipeline. + +This case validates the RdSAP 10 §5.7 Table 13 (PDF p.40) "uninsulated +brick wall by thickness" path for an **as-built** wall. A 440 mm solid +brick wall is >420 mm → U = 1.10 (not the 220 mm bucket default 1.70). +Table 6 footnote (b) on the "Solid brick as built" row makes this +explicit: "Or from 5.7 if wall thickness is other than 200mm to 280mm". +The wall is lodged "Dry-lining No", so no §5.8 / Table 14 adjustment is +applied — U is the raw Table 13 value. + +The fix flows through to the Sheltered room-in-roof gable, which is +1/(1/1.10 + 0.5) = 0.71 (worksheet §3 Gable Wall 1), down from the +pre-fix 0.92 that a 1.70 wall U produced (case 20's 220 mm wall). + +Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/ +simulated case 21/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case21.pdf` so +the test runs without depending on the unstaged workspace. + +Cert shape: Main mid-terrace, solid brick as-built 440 mm, age band B, +2 storeys + Detailed room-in-roof on the Main (Sheltered + Connected +gables), suspended uninsulated ground floor, mains-gas boiler (SAP code +119, 84% efficiency, control 2113), mains-gas multi-point instantaneous +water heater (code 908, 65% efficiency), Dual/E7 electricity meter, no +secondary heating, no PV. + +This fixture is pinned on the **§3 heat-loss line refs only** +((31)/(33)/(36)/(37)) — the values the wall-U-by-thickness fix directly +controls. Following the same rationale as simulated case 6 (see +`test_section_3_roof_windows_case6_match_pdf`), it is NOT added to the +full §1-§13 SAP cascade grid because its water heater — code 908, +multi-point gas **instantaneous** serving several taps — exposes a +separate, unrelated §4 water-heating gap (the cascade over-computes +(219) vs the worksheet's 1859.1534). That is its own cause / own slice; +folding it in here would force a tolerance widening this slice does not +own. The §3 pins below fully exercise the wall-U fix end-to-end through +the real extractor + mapper. + +Worksheet §3 pin targets (P960-0001-001431 page 2, "3. Heat losses"): +- (31) Total net area of external elements = 155.1000 m² +- (33) Fabric heat loss Σ(A×U) = 175.6208 W/K +- (36) Thermal bridges (0.150 × exposed) = 23.2650 W/K +- (37) Total fabric heat loss (33)+(36) = 198.8858 W/K +- §3 element refs: External walls Main U = 1.1000 (§5.7 Table 13, 440 mm + > 420 mm); Roof room Main Gable Wall 1 (Sheltered) = 0.71 = + 1/(1/1.10 + 0.5); Common Walls = 1.10. + +Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation- +philosophy]]: pins are abs=1e-4 against the worksheet PDF. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_case21.pdf" +) + +# §3 heat-loss line refs from the P960 worksheet (page 2, "3. Heat +# losses"). These are the dimensions the wall-U-by-thickness fix drives: +# a 440 mm (>420) solid brick as-built wall takes RdSAP 10 §5.7 Table 13 +# U=1.10, lifting fabric heat loss to 175.6208 (pre-fix the 220 mm bucket +# default 1.70 over-stated it). +LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 155.1000 +LINE_33_FABRIC_HEAT_LOSS_W_PER_K: Final[float] = 175.6208 +LINE_36_THERMAL_BRIDGING_W_PER_K: Final[float] = 23.2650 +LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: Final[float] = 198.8858 + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label\\nvalue sequences). Mirror + of the helper in 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-21 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) 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 2835eb16..b8f166ab 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -43,6 +43,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_000490 as _w000490, _elmhurst_worksheet_000516 as _w000516, _elmhurst_worksheet_001431_case6 as _w001431_case6, + _elmhurst_worksheet_001431_case21 as _w001431_case21, ) @@ -283,6 +284,50 @@ def test_section_3_roof_windows_case6_match_pdf() -> None: ) +def test_section_3_wall_u_by_thickness_case21_match_pdf() -> None: + """§3 heat-loss pins for simulated case 21 — a replica of API cert + 2818 whose Main wall is solid brick, **as built, 440 mm**. + + RdSAP 10 §5.7 Table 13 (PDF p.40) defaults an uninsulated brick wall + by thickness: >420 mm → U = 1.10 (not the 220 mm bucket default 1.70). + Table 6 footnote (b) on the "Solid brick as built" row makes this + explicit: "Or from 5.7 if wall thickness is other than 200mm to + 280mm". The lower wall U flows through (33) and the Sheltered + room-in-roof gable (1/(1/1.10 + 0.5) = 0.71). + + Pinned on §3 line refs only (not added to `_FIXTURES`) — the same + rationale as case 6: its instantaneous multi-point gas water heater + (code 908) exposes a separate §4 (219) gap, so the full §10/§12 SAP + cascade is non-comparable. See the fixture module docstring.""" + # Arrange + epc = _w001431_case21.build_epc() + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert + _pin( + ht.total_external_element_area_m2, + _w001431_case21.LINE_31_TOTAL_EXTERNAL_AREA_M2, + "§3 (31) case21", + ) + _pin( + ht.fabric_heat_loss_w_per_k, + _w001431_case21.LINE_33_FABRIC_HEAT_LOSS_W_PER_K, + "§3 (33) case21", + ) + _pin( + ht.thermal_bridging_w_per_k, + _w001431_case21.LINE_36_THERMAL_BRIDGING_W_PER_K, + "§3 (36) case21", + ) + _pin( + ht.total_w_per_k, + _w001431_case21.LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K, + "§3 (37) case21", + ) + + 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