diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf new file mode 100644 index 00000000..4312e6c1 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf differ diff --git a/domain/sap10_calculator/worksheet/conservatory.py b/domain/sap10_calculator/worksheet/conservatory.py new file mode 100644 index 00000000..b9729e1f --- /dev/null +++ b/domain/sap10_calculator/worksheet/conservatory.py @@ -0,0 +1,149 @@ +"""RdSAP 10 §6.1 — non-separated (heated) conservatory geometry. + +A non-separated conservatory is treated as part of the dwelling +(RdSAP 10 Specification, 9th June 2025, §6.1 + Table 25, pages 49-51): + + - its floor area and volume are added to TFA (4) and volume (5); + - its fully-glazed walls bill as a window — line (27) — at the Table 25 + "U-value of window"; its glazed roof bills as a rooflight — line (27a) + — at the Table 25 "U-value of roof window"; both U-values already + include the §3.2 curtain resistance (R=0.04 m²K/W); + - its floor adds a ground-loss term — line (28a) — via BS EN ISO 13370, + taken as an uninsulated solid floor with 300 mm walls (§5.12 note, + spec p.43), exposed perimeter = glazed perimeter; + - its glazed wall + glazed roof + floor areas count toward the total + exposed area (31) and hence thermal bridging (36); the fully-glazed + "structure" walls/roof themselves add nothing (the glazing IS the + window/rooflight). + +Its roof area is the floor area / cos(20°) and its wall area is the +exposed perimeter × height; the height is translated from the lodged +equivalent storey count (§6.1): 1 storey → ground-floor room height; +1½ → ground + 0.25 + 0.5×first; 2 → ground + 0.25 + first; etc. + +A SEPARATED conservatory (§6.2) is disregarded entirely — the mapper +maps it to None, so it never reaches this module. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP +from math import cos, radians +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData + +# RdSAP 10 §6.1 — conservatory roof area = floor area / cos(20°); §6.1 +# also fixes the rooflight solar pitch at 20°. +CONSERVATORY_ROOF_PITCH_DEG: Final[float] = 20.0 +_COS_ROOF_PITCH: Final[float] = cos(radians(CONSERVATORY_ROOF_PITCH_DEG)) + +# RdSAP 10 Table 25 (PDF p.51) — default conservatory glazing U-values +# (W/m²K, INCLUSIVE of the §3.2 curtain resistance) and g-values. The +# Summary lodges only double vs single (no triple), so a bool selects the +# row: True → double (6 mm gap), False → single. +_TABLE_25_WALL_U: Final[dict[bool, float]] = {True: 3.1, False: 4.8} +_TABLE_25_ROOF_U: Final[dict[bool, float]] = {True: 3.4, False: 5.3} +_TABLE_25_G_VALUE: Final[dict[bool, float]] = {True: 0.76, False: 0.85} +_TABLE_25_FRAME_FACTOR: Final[float] = 0.70 # Table 25 — wood/PVC frame + +# SAP 10.2 §3.2 formula (2) curtain/blind resistance. Table 25 U-values +# are "adjusted for curtains" already, so the EFFECTIVE conduction U is +# 1 / (1/U_table25 + 0.04) — the same transform `heat_transmission` +# applies to regular windows/rooflights. +_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04 + +# RdSAP 10 §5.12 (spec p.43) — a non-separated conservatory floor is an +# uninsulated solid ground floor with 300 mm walls. +_CONSERVATORY_WALL_THICKNESS_MM: Final[int] = 300 +_AREA_ROUND_DP: Final[int] = 2 + + +def _round2(value: float) -> float: + """RdSAP 10 §15 (p.66): element areas + conservatory height → 2 d.p.""" + return float( + Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) + + +@dataclass(frozen=True) +class ConservatoryGeometry: + """Derived §6.1 geometry for one non-separated conservatory. Areas and + height are rounded to 2 d.p. per RdSAP 10 §15.""" + + height_m: float + floor_area_m2: float + glazed_wall_area_m2: float + glazed_roof_area_m2: float + glazed_perimeter_m: float + wall_u_raw: float # Table 25 window U, pre-curtain + roof_u_raw: float # Table 25 roof-window U, pre-curtain + wall_u_eff: float # post-curtain conduction U for line (27) + roof_u_eff: float # post-curtain conduction U for line (27a) + g_value: float + frame_factor: float + volume_m3: float + + +def _conservatory_height_m(epc: EpcPropertyData, storeys: float) -> float: + """Translate the equivalent storey count into a metre height per + RdSAP 10 §6.1 using the dwelling's per-storey room heights: + + 1 storey → ground-floor room height + 1½ storey → ground + 0.25 + 0.5 × first-floor room height + 2 storey → ground + 0.25 + first-floor room height + etc. + + Room heights are taken from the Main building part's floor + dimensions (floor 0 = ground, 1 = first, ...). Returns 0.0 when no + storeys are lodged (defensive; the conservatory then bills no walls).""" + parts = epc.sap_building_parts or [] + heights: list[float] = [] + if parts: + fds = sorted( + parts[0].sap_floor_dimensions, + key=lambda fd: fd.floor if fd.floor is not None else 0, + ) + heights = [fd.room_height_m for fd in fds if fd.room_height_m] + if not heights: + return 0.0 + n_full = int(storeys) + height = heights[0] + for s in range(1, n_full): + height += 0.25 + heights[min(s, len(heights) - 1)] + if storeys - n_full >= 0.5: + height += 0.25 + 0.5 * heights[min(n_full, len(heights) - 1)] + return _round2(height) + + +def conservatory_geometry( + epc: EpcPropertyData, +) -> Optional[ConservatoryGeometry]: + """Build the §6.1 conservatory geometry, or None when there is no + (non-separated) conservatory.""" + cons = epc.sap_conservatory + if cons is None or cons.thermally_separated: + return None + height = _conservatory_height_m(epc, cons.room_height_storeys) + floor_area = cons.floor_area_m2 + glazed_perimeter = cons.glazed_perimeter_m + glazed_wall = _round2(glazed_perimeter * height) + glazed_roof = _round2(floor_area / _COS_ROOF_PITCH) + dg = cons.double_glazed + wall_u_raw = _TABLE_25_WALL_U[dg] + roof_u_raw = _TABLE_25_ROOF_U[dg] + return ConservatoryGeometry( + height_m=height, + floor_area_m2=floor_area, + glazed_wall_area_m2=glazed_wall, + glazed_roof_area_m2=glazed_roof, + glazed_perimeter_m=glazed_perimeter, + wall_u_raw=wall_u_raw, + roof_u_raw=roof_u_raw, + wall_u_eff=1.0 / (1.0 / wall_u_raw + _CURTAIN_RESISTANCE_M2K_PER_W), + roof_u_eff=1.0 / (1.0 / roof_u_raw + _CURTAIN_RESISTANCE_M2K_PER_W), + g_value=_TABLE_25_G_VALUE[dg], + frame_factor=_TABLE_25_FRAME_FACTOR, + volume_m3=floor_area * height, + ) diff --git a/domain/sap10_calculator/worksheet/dimensions.py b/domain/sap10_calculator/worksheet/dimensions.py index f48e24d5..792770b4 100644 --- a/domain/sap10_calculator/worksheet/dimensions.py +++ b/domain/sap10_calculator/worksheet/dimensions.py @@ -21,6 +21,7 @@ from dataclasses import dataclass from typing import Final from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart +from domain.sap10_calculator.worksheet.conservatory import conservatory_geometry _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 @@ -145,17 +146,28 @@ def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions: total_storey_count = max(part_storey_counts) if part_storey_counts else 0 has_storeys = sum_per_storey_area_m2 > 0 + # `avg_height` (used by §2 (9) dwelling height → infiltration) is a + # property of the dwelling's storeys, so the conservatory is excluded + # from it. The conservatory IS added to TFA (4) and volume (5) per + # RdSAP 10 §6.1 ("The floor area and volume of a non-separated + # conservatory are added to the total floor area and volume of the + # dwelling") — it just doesn't form a storey. avg_height = ( sum_per_storey_volume_m3 / sum_per_storey_area_m2 if has_storeys else _DEFAULT_STOREY_HEIGHT_M ) + cons = conservatory_geometry(epc) + cons_floor_area_m2 = cons.floor_area_m2 if cons is not None else 0.0 + cons_volume_m3 = cons.volume_m3 if cons is not None else 0.0 return Dimensions( total_floor_area_m2=( - sum_per_storey_area_m2 if has_storeys else epc.total_floor_area_m2 + sum_per_storey_area_m2 + cons_floor_area_m2 + if has_storeys + else epc.total_floor_area_m2 ), volume_m3=( - sum_per_storey_volume_m3 + sum_per_storey_volume_m3 + cons_volume_m3 if has_storeys else epc.total_floor_area_m2 * _DEFAULT_STOREY_HEIGHT_M ), diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 83668e6f..4d2971d8 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -71,6 +71,7 @@ from domain.sap10_ml.rdsap_uvalues import ( u_wall, u_window, ) +from domain.sap10_calculator.worksheet.conservatory import conservatory_geometry from math import cos, floor, radians, sqrt @@ -123,6 +124,9 @@ _DEFAULT_DOOR_AREA_M2: Final[float] = 1.85 # deducts from that wall, not the main wall. _CORRIDOR_DOOR_U_W_PER_M2K: Final[float] = 1.4 _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 +# RdSAP 10 §5.12 (spec p.43) — a non-separated conservatory floor is an +# uninsulated solid ground floor with 300 mm walls. +_CONSERVATORY_WALL_THICKNESS_MM: Final[int] = 300 # SAP10.2 §3.2 curtain/blind thermal resistance applied to windows (and # roof windows) — turns raw window U into the worksheet's (27) effective U. _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04 @@ -1368,6 +1372,51 @@ def heat_transmission_from_cert( # door line. doors += _CORRIDOR_DOOR_U_W_PER_M2K * corridor_door_area roof_windows_w_per_k = roof_windows_w_per_k_total + + # RdSAP 10 §6.1 (PDF p.49) + Table 25 (p.51) — a non-separated + # conservatory. Its fully-glazed walls bill as a window (27), its + # glazed roof as a rooflight (27a), and its floor adds a ground-loss + # term (28a) via BS EN ISO 13370 (uninsulated solid floor, 300 mm + # walls per §5.12; exposed perimeter = glazed perimeter). The glazed + # wall + roof + floor areas join (31)/(36) external area; the fully- + # glazed "structure" walls/roof add nothing (the glazing IS the + # window/rooflight). A separated conservatory (§6.2) is mapped to + # None upstream and never reaches here. + cons_geom = conservatory_geometry(epc) + cons_windows_w_per_k: float = 0.0 + if cons_geom is not None: + cons_windows_w_per_k = ( + cons_geom.glazed_wall_area_m2 * cons_geom.wall_u_eff + ) + roof_windows_w_per_k += ( + cons_geom.glazed_roof_area_m2 * cons_geom.roof_u_eff + ) + u_cons_floor = u_floor( + country=country, + age_band=primary_age, + construction=None, + insulation_thickness_mm=0, + area_m2=cons_geom.floor_area_m2, + perimeter_m=cons_geom.glazed_perimeter_m, + wall_thickness_mm=_CONSERVATORY_WALL_THICKNESS_MM, + # Force the solid-floor branch of BS EN ISO 13370 regardless of + # age band (§5.12: conservatory floor is an uninsulated SOLID + # ground floor — the A/B suspended-timber default must not fire). + description="Solid", + ) + floor += u_cons_floor * cons_geom.floor_area_m2 + cons_external_area = ( + cons_geom.glazed_wall_area_m2 + + cons_geom.glazed_roof_area_m2 + + cons_geom.floor_area_m2 + ) + total_external_area += cons_external_area + bridging += dwelling_y * cons_external_area + # Fold the conservatory glazed wall into the (27) window readout. The + # `windows` accumulator is partially-typed upstream (the per-window + # `u_value` arrives as `Any`); `float(...)` re-asserts the strict float + # type as we add the strictly-typed conservatory term. + windows = float(windows) + cons_windows_w_per_k fabric_heat_loss = ( walls + roof + floor + party + windows + roof_windows_w_per_k + doors # (33) ) diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py new file mode 100644 index 00000000..39d7da49 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py @@ -0,0 +1,119 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 44" worksheet — a 2-storey mid-terrace with a NON-SEPARATED +(heated, type-4) DOUBLE-glazed CONSERVATORY. + +Case 44 is the 1e-4 oracle for RdSAP 10 §6.1 (PDF p.49) + Table 25 (p.51). +The Summary §5 lodges: Floor Area 12.00 m², Glazed Perimeter 9.00 m, +Double Glazed Yes, thermally separated No, Room Height 1 Storey. From that +the §6.1 cascade derives (all verified against the P960 §3 to 1e-4): + + - conservatory height = ground-floor room height = 2.60 m (1 storey); + - glazed WALL → window (27): A = perimeter × height = 9.0 × 2.60 = 23.40, + U = 1/(1/3.1 + 0.04) = 2.758 (Table 25 double 3.1 + §3.2 curtain); + - glazed ROOF → rooflight (27a): A = floor_area / cos(20°) = 12.77, + U = 1/(1/3.4 + 0.04) = 2.993 (Table 25 roof 3.4 + curtain); + - FLOOR → ground floor (28a): A = 12.00, U = 0.89 via BS EN ISO 13370 + (uninsulated solid, 300 mm walls, P = glazed perimeter 9.0); + - the fully-glazed structure walls/roof bill at U=0 (the glazing IS the + window/rooflight) — they contribute nothing but DO count their glazed + area toward (31)/(36); + - TFA (4) += 12.00 → 95.38; volume (5) += 12.00 × 2.60 = 31.20 → 257.16. + +Like the other `_elmhurst_worksheet_001431_case*` fixtures this 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 44/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf` so the +test runs without depending on the unstaged workspace. + +Worksheet pin targets (P960-0001-001431, "11a. SAP rating" UK-average +rating block our cascade reproduces): +- (4) TFA, m² = 95.3800 +- (5) Dwelling volume, m³ = 257.1630 +- (27) Windows (31.5795 main + 64.5374 cons) = 96.1169 +- (27a) Roof windows (conservatory glazed roof) = 38.2201 +- (28a) Ground floor (10.7364 main + 10.6800) = 21.4164 +- (29a) External walls = 35.5852 +- (30) External roof = 7.4688 +- (31) Total net area of external elements = 294.2900 +- (33) Fabric heat loss, W/K = 207.3274 +- (36) Thermal bridges (0.080 × (31)) = 23.5432 + +Per [[feedback-zero-error-strict]]: pins are abs <= 1e-4 against the PDF. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_case44.pdf" +) + +LINE_4_TFA_M2: Final[float] = 95.3800 +LINE_5_VOLUME_M3: Final[float] = 257.1630 +LINE_27_WINDOWS_W_PER_K: Final[float] = 96.1169 +LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 38.2201 +LINE_28A_FLOOR_W_PER_K: Final[float] = 21.4164 +LINE_29A_WALLS_W_PER_K: Final[float] = 35.5852 +LINE_30_ROOF_W_PER_K: Final[float] = 7.4688 +LINE_31_EXTERNAL_AREA_M2: Final[float] = 294.2900 +LINE_33_FABRIC_W_PER_K: Final[float] = 207.3274 +LINE_36_THERMAL_BRIDGING_W_PER_K: Final[float] = 23.5432 + + +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-44 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); the collected assertions live in + `test_section_cascade_pins`.""" + 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 4e7336e3..d4725df9 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -45,6 +45,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431_case6 as _w001431_case6, _elmhurst_worksheet_001431_case21 as _w001431_case21, _elmhurst_worksheet_001431_case43 as _w001431_case43, + _elmhurst_worksheet_001431_case44 as _w001431_case44, ) @@ -370,6 +371,62 @@ def test_case43_detailed_rr_dryline_and_mixed_roof_match_pdf() -> None: ) +def test_case44_non_separated_conservatory_fabric_matches_pdf() -> None: + """§3 fabric pin for simulated case 44 — a non-separated DOUBLE-glazed + conservatory (RdSAP 10 §6.1 + Table 25). The conservatory's glazed wall + bills as a window (27), its glazed roof as a rooflight (27a), its floor + adds a ground-loss term (28a), and its glazed wall + roof + floor areas + join (31)/(36); TFA (4) and volume (5) absorb its floor area + volume. + The main dwelling's walls (29a) / roof (30) are untouched — pinned to + guard against the conservatory leaking into the wrong element.""" + # Arrange + epc = _w001431_case44.build_epc() + + # Act + ht = heat_transmission_section_from_cert(epc) + dim = dimensions_from_cert(epc) + + # Assert — §1 totals + §3 fabric, each at abs=1e-4. + _pin(dim.total_floor_area_m2, _w001431_case44.LINE_4_TFA_M2, "§1 (4) case44") + _pin(dim.volume_m3, _w001431_case44.LINE_5_VOLUME_M3, "§1 (5) case44") + _pin( + ht.windows_w_per_k, + _w001431_case44.LINE_27_WINDOWS_W_PER_K, + "§3 (27) case44", + ) + _pin( + ht.roof_windows_w_per_k, + _w001431_case44.LINE_27A_ROOF_WINDOWS_W_PER_K, + "§3 (27a) case44", + ) + _pin( + ht.floor_w_per_k, + _w001431_case44.LINE_28A_FLOOR_W_PER_K, + "§3 (28a) case44", + ) + _pin( + ht.walls_w_per_k, + _w001431_case44.LINE_29A_WALLS_W_PER_K, + "§3 (29a) case44", + ) + _pin(ht.roof_w_per_k, _w001431_case44.LINE_30_ROOF_W_PER_K, "§3 (30) case44") + _pin( + ht.total_external_element_area_m2, + _w001431_case44.LINE_31_EXTERNAL_AREA_M2, + "§3 (31) case44", + ) + _pin( + ht.fabric_heat_loss_w_per_k, + _w001431_case44.LINE_33_FABRIC_W_PER_K, + "§3 (33) case44", + ) + _pin( + ht.thermal_bridging_w_per_k, + _w001431_case44.LINE_36_THERMAL_BRIDGING_W_PER_K, + "§3 (36) case44", + ) + + 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