From d4d2b222fc8a7474b18aca1e1f9a847a30f937d5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 15:59:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(conservatory):=20=C2=A76.1=20fabric=20casc?= =?UTF-8?q?ade=20(27/27a/28a=20+=20TFA/volume)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the non-separated conservatory into the §3 heat-transmission + §1 dimensions cascade per RdSAP 10 §6.1 (PDF p.49) + Table 25 (p.51): "The floor area and volume of a non-separated conservatory are added to the total floor area and volume of the dwelling. Its roof area is taken as its floor area divided by cos(20°), and wall area is taken as the product of its exposed perimeter and its height. ... The conservatory walls and roof are taken as fully glazed ... Glazed walls are taken as windows, glazed roof as rooflight." New `worksheet/conservatory.py` derives the geometry: - height from the equivalent storey count (§6.1: 1 storey → ground-floor room height; 1½ → ground + 0.25 + 0.5×first; etc.); - glazed WALL → window (27) at Table 25 U (double 3.1 / single 4.8) with the §3.2 curtain resistance (R=0.04) → U_eff 2.758; - glazed ROOF → rooflight (27a) at Table 25 roof U (double 3.4 / single 5.3) + curtain → U_eff 2.993; - FLOOR → (28a) via BS EN ISO 13370 as an uninsulated SOLID ground floor with 300 mm walls (§5.12, spec p.43), exposed perimeter = glazed perimeter → U 0.89; - glazed wall + roof + floor areas join (31)/(36); the fully-glazed structure walls/roof add nothing (the glazing IS the window/rooflight). `dimensions_from_cert` adds the conservatory floor area to TFA (4) and floor area × height to volume (5) (feeds ventilation (8)), without making it a storey (avg storey height for §2 infiltration is unchanged). Pinned against the simulated case-44 P960 §3 at abs=1e-4 — every line ref EXACT: (4) 95.3800, (5) 257.1630, (27) 96.1169, (27a) 38.2201, (28a) 21.4164, (29a) 35.5852, (30) 7.4688, (31) 294.2900, (33) 207.3274, (36) 23.5432. The remaining whole-dwelling SAP/CO2 gap is the §6 solar gains, closed in the next slice. Worksheet harness stays 47/47 0-raised. Co-Authored-By: Claude Opus 4.8 --- .../tests/fixtures/Summary_001431_case44.pdf | Bin 0 -> 77581 bytes .../worksheet/conservatory.py | 149 ++++++++++++++++++ .../sap10_calculator/worksheet/dimensions.py | 16 +- .../worksheet/heat_transmission.py | 49 ++++++ .../_elmhurst_worksheet_001431_case44.py | 119 ++++++++++++++ .../worksheet/test_section_cascade_pins.py | 57 +++++++ 6 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf create mode 100644 domain/sap10_calculator/worksheet/conservatory.py create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case44.py 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 0000000000000000000000000000000000000000..4312e6c13ac6ed13d32af936eccca615d420720b GIT binary patch literal 77581 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 literal 0 HcmV?d00001 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