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 00000000..dc7da3ab Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case21.pdf differ 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