mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(conservatory): §6.1 fabric cascade (27/27a/28a + TFA/volume)
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 <noreply@anthropic.com>
This commit is contained in:
parent
fa131cca0b
commit
d4d2b222fc
6 changed files with 388 additions and 2 deletions
BIN
backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf
vendored
Normal file
Binary file not shown.
149
domain/sap10_calculator/worksheet/conservatory.py
Normal file
149
domain/sap10_calculator/worksheet/conservatory.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue