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:
Khalim Conn-Kowlessar 2026-06-16 15:59:26 +00:00
parent fa131cca0b
commit d4d2b222fc
6 changed files with 388 additions and 2 deletions

Binary file not shown.

View 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,
)

View file

@ -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
),

View file

@ -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)
)

View file

@ -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 , 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, = 95.3800
- (5) Dwelling volume, = 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)

View file

@ -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