Merge pull request #1245 from Hestia-Homes/feature/per-cert-mapper-validation

Feature/per cert mapper validation
This commit is contained in:
Jun-te Kim 2026-06-17 09:49:36 +01:00 committed by GitHub
commit 74c1aff530
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1222 additions and 10 deletions

View file

@ -7,6 +7,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
BathsAndShowers,
BuildingPartDimensions,
CommunityHeating,
Conservatory,
ElmhurstSiteNotes,
ExtensionPart,
FloorDetails,
@ -30,6 +31,17 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
)
def _parse_conservatory_storeys(raw: Optional[str]) -> float:
"""Parse the §5 "Room Height" lodgement ("1 Storey", "1.5 Storey",
"1½ Storey") into the equivalent-storey count RdSAP 10 §6.1 translates
to a metre height. Defaults to 1.0 (single storey) when unparseable."""
if not raw:
return 1.0
text = raw.replace("½", ".5")
m = re.search(r"\d+(?:\.\d+)?", text)
return float(m.group(0)) if m else 1.0
def _parse_solar_pitch_deg(raw: Optional[str]) -> Optional[int]:
"""Parse the §16.0 "Collector elevation" lodgement (e.g. "30°", "60°",
or a bare integer). Returns None when absent or unparseable."""
@ -81,6 +93,13 @@ class ElmhurstSiteNotesExtractor:
except (ValueError, IndexError):
return 0
def _float_val(self, label: str) -> Optional[float]:
v = self._next_val(label)
if not v:
return None
m = re.search(r"-?\d+(?:\.\d+)?", v)
return float(m.group(0)) if m else None
def _date_val(self, label: str) -> date:
v = self._next_val(label)
if not v:
@ -179,8 +198,39 @@ class ElmhurstSiteNotesExtractor:
v = self._local_val(lines, label)
return v is not None and v.lower() == "yes"
def _local_float(self, lines: List[str], label: str) -> Optional[float]:
v = self._local_val(lines, label)
if not v:
return None
m = re.search(r"-?\d+(?:\.\d+)?", v)
return float(m.group(0)) if m else None
# --- section extractors ---
def _extract_conservatory(self) -> Optional[Conservatory]:
"""Summary §5.0 — geometry of a conservatory (RdSAP 10 §6, PDF
p.49). Returns None when none is lodged. Scoped to the §5 block
so the generic labels ("Floor Area", "Room Height") can't collide
with §4 dimensions. A separated conservatory is still returned
(with `thermally_separated=True`); the mapper drops it per §6.2."""
if not self._bool_val("Is there a conservatory?"):
return None
lines = self._section_lines_first_end(
"5.0 Conservatory", ("7.0 Walls", "6.0 ", "Summary Information"),
)
return Conservatory(
thermally_separated=self._local_bool(
lines, "Is it thermally separated?"
),
floor_area_m2=self._local_float(lines, "Floor Area [m2]") or 0.0,
double_glazed=self._local_bool(lines, "Double Glazed"),
glazed_perimeter_m=self._local_float(lines, "Glazed Perimeter [m]")
or 0.0,
room_height_storeys=_parse_conservatory_storeys(
self._local_val(lines, "Room Height")
),
)
def _extract_surveyor_info(self) -> SurveyorInfo:
return SurveyorInfo(
surveyor_code=self._str_val("Surveyor"),
@ -1302,6 +1352,10 @@ class ElmhurstSiteNotesExtractor:
air_permeability_ap4_m3_h_m2 = float(ap4_raw.split()[0])
except (ValueError, IndexError):
air_permeability_ap4_m3_h_m2 = None
# SAP 10.2 §2 (17) "Measured/design AP50" from a Blower Door test.
# Routes the cascade's (18) via `AP50 / 20 + (8)` (preferred over
# AP4). Absent when the test method is "Not available".
ap50_raw = self._local_float(pressure_lines, "Pressure Test Result (AP50)")
# Summary §12.1 "Mechanical Ventilation Type" — scoped to §12.1
# body so the global "Type" labels in §14 / §15 can't shadow it.
mv_lines = self._section_lines(
@ -1350,6 +1404,7 @@ class ElmhurstSiteNotesExtractor:
mechanical_ventilation=self._bool_val("Mechanical Ventilation"),
pressure_test_method=self._str_val("Test Method"),
air_permeability_ap4_m3_h_m2=air_permeability_ap4_m3_h_m2,
air_permeability_ap50_m3_h_m2=ap50_raw,
mechanical_ventilation_type=mechanical_ventilation_type,
mechanical_ventilation_pcdf_reference=mev_pcdf_reference,
wet_rooms_count=wet_rooms_count,
@ -1820,6 +1875,7 @@ class ElmhurstSiteNotesExtractor:
construction_age_band=self._extract_main_age_band(),
dimensions=self._extract_dimensions(),
has_conservatory=self._bool_val("Is there a conservatory?"),
conservatory=self._extract_conservatory(),
walls=self._extract_walls(),
roof=self._extract_roof(),
floor=self._extract_floor(),

Binary file not shown.

View file

@ -202,6 +202,10 @@ class SapVentilation:
# Pulse pressure test, m³/h per m² of envelope area. When present the
# cascade routes (18) via the AP4 formula `0.263 × AP4^0.924 + (8)`.
air_permeability_ap4_m3_h_m2: Optional[float] = None
# SAP 10.2 §2 (17) — air permeability at 50 Pa from a Blower Door test,
# m³/h per m² of envelope area. When present the cascade routes (18)
# via `AP50 / 20 + (8)` (preferred over AP4).
air_permeability_ap50_m3_h_m2: Optional[float] = None
# SAP 10.2 §2 (23a)/(24a..d) — Elmhurst "Mechanical Ventilation Type"
# string mapped to the `MechanicalVentilationKind` enum name (e.g.
# "EXTRACT_OR_PIV_OUTSIDE" for MEV decentralised). The cascade uses
@ -261,6 +265,33 @@ class SapRoofWindow:
window_location: Union[int, str] = 0
@dataclass(frozen=True)
class SapConservatory:
"""RdSAP 10 §6.1 (PDF p.49) — a NON-SEPARATED (heated) conservatory.
Its floor area and volume are added to the dwelling total (TFA (4),
volume (5)); its fully-glazed walls bill as a window (27) and its
fully-glazed roof as a rooflight (27a); the floor adds a ground-loss
term (28a). U-values come from RdSAP 10 Table 25 (p.51): double 6 mm
window 3.1 / roof 3.4 / g 0.76; single window 4.8 / roof 5.3 / g 0.85.
`room_height_storeys` is the equivalent number of storey heights of
the dwelling to the nearest half (Summary §5 "Room Height", gov-API
glazed building part), translated to a metre height per §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 and is never
represented here (`thermally_separated` stays a guard for the cascade).
"""
floor_area_m2: float
glazed_perimeter_m: float
double_glazed: bool
thermally_separated: bool
room_height_storeys: float
@dataclass
class SapWindow:
frame_material: Optional[str]
@ -774,6 +805,10 @@ class EpcPropertyData:
# has no roof windows; for cert-cascade fixtures the bootstrap path
# lodges per-window area + raw U.
sap_roof_windows: Optional[List[SapRoofWindow]] = None
# RdSAP 10 §6.1 — geometry of a non-separated (heated) conservatory.
# None when no conservatory is lodged or it is thermally separated
# (§6.2 disregards separated conservatories).
sap_conservatory: Optional[SapConservatory] = None
calculation_software_version: Optional[str] = None # Do we care about this?
mechanical_vent_duct_placement: Optional[int] = None
mechanical_vent_duct_insulation: Optional[int] = None

View file

@ -25,6 +25,7 @@ from datatypes.epc.domain.epc_property_data import (
SapEnergySource,
SapFlatDetails,
SapFloorDimension,
SapConservatory,
SapHeating,
SapRoofWindow,
SapRoomInRoof,
@ -69,6 +70,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
AlternativeWall as ElmhurstAlternativeWall,
BuildingPartDimensions as ElmhurstBuildingPartDimensions,
CommunityHeating,
Conservatory as ElmhurstConservatory,
ElmhurstSiteNotes,
FloorDetails as ElmhurstFloorDetails,
MainHeating as ElmhurstMainHeating,
@ -415,12 +417,25 @@ class EpcPropertyDataMapper:
ext.room_in_roof.floor_area_m2
for ext in survey.extensions
if ext.room_in_roof is not None
)
# RdSAP 10 §6.1 (PDF p.49) — a non-separated conservatory's
# floor area is added to the dwelling total floor area. TFA
# drives occupancy → §5 internal gains + §4 hot-water demand,
# so it must include the conservatory (the worksheet's (4) =
# 95.38 carries it). Separated conservatories (§6.2) are
# disregarded.
+ (
survey.conservatory.floor_area_m2
if survey.conservatory is not None
and not survey.conservatory.thermally_separated
else 0.0
),
2,
),
built_form=built_form,
property_type=property_type,
has_conservatory=survey.has_conservatory,
sap_conservatory=_map_elmhurst_conservatory(survey.conservatory),
blocked_chimneys_count=survey.ventilation.blocked_chimneys_count,
number_of_storeys=survey.number_of_storeys,
hydro=survey.renewables.hydro_electricity_generated_kwh > 0,
@ -2077,8 +2092,14 @@ class EpcPropertyDataMapper:
else None
),
)
# RdSAP 10 §6.1 — exclude the glazed conservatory BP from the
# fabric loop; it is carried as `sap_conservatory` below and
# billed by the §6.1 cascade (window/rooflight/floor), not as
# a dwelling building part.
for bp in schema.sap_building_parts
if getattr(bp, "glazed_perimeter", None) is None
],
sap_conservatory=_api_sap_conservatory(schema.sap_building_parts),
renewable_heat_incentive=RenewableHeatIncentive(
space_heating_kwh=float(
schema.renewable_heat_incentive.space_heating_existing_dwelling
@ -2483,6 +2504,33 @@ def _measurement_value(field: Any) -> float:
return float(field)
def _api_sap_conservatory(building_parts: Any) -> Optional[SapConservatory]:
"""Build the domain `SapConservatory` from the gov-API glazed
conservatory building part the part the API uses for a NON-SEPARATED
conservatory (RdSAP 10 §6.1, PDF p.49), identified by a lodged
`glazed_perimeter` (real dwelling parts carry fabric + floor dimensions
instead, never `glazed_perimeter`). Only type-4 (non-separated)
conservatories lodge this BP; separated ones (§6.2) lodge nothing, so
its presence is the §6.1 signal. Mirror of `_map_elmhurst_conservatory`
for the API path proven equivalent by cross-mapper parity (the cascade
reads `epc.sap_conservatory` identically). Returns None when absent."""
if not building_parts:
return None
for bp in building_parts:
if getattr(bp, "glazed_perimeter", None) is None:
continue
return SapConservatory(
floor_area_m2=_measurement_value(bp.floor_area),
glazed_perimeter_m=_measurement_value(bp.glazed_perimeter),
double_glazed=bp.double_glazed == "Y",
# The gov API only lodges this glazed BP for NON-separated
# (type-4) conservatories; separated ones (§6.2) lodge no BP.
thermally_separated=False,
room_height_storeys=float(_measurement_value(bp.room_height)),
)
return None
def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float]:
"""Sum per-bp `sap_floor_dimensions[*].total_floor_area` (plus each bp's
`sap_room_in_roof.floor_area` when present) to recover the precise
@ -2507,6 +2555,13 @@ def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float
total = 0.0
found = False
for bp in building_parts:
# RdSAP 10 §6.1 — a non-separated conservatory's glazed BP (no floor
# dimensions) adds its floor area to the dwelling TFA. TFA drives
# occupancy → §4/§5 demand, so the conservatory must be in the sum.
if getattr(bp, "glazed_perimeter", None) is not None:
total += _measurement_value(bp.floor_area)
found = True
continue
floor_dims: Any = bp.sap_floor_dimensions or []
for fd in floor_dims:
total += _measurement_value(fd.total_floor_area)
@ -4003,12 +4058,88 @@ def _api_build_room_in_roof(
# §3.9.1 default RR storey height (2.45 m); the type code routes
# the U-value (Exposed → main-wall U, Party → 0.25).
rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1)
type_2 = getattr(bp_rir, "room_in_roof_type_2", None)
if type_2 is not None:
rir.detailed_surfaces = _api_type_2_surfaces(type_2)
details = getattr(bp_rir, "room_in_roof_details", None)
if details is not None:
rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat)
return rir
def _api_type_2_surfaces(
type_2: Any,
) -> Optional[List[SapRoomInRoofSurface]]:
"""Translate the §3.9.2 Simplified Type 2 block into the per-surface
list the cascade's Detailed-RR branch consumes — MIRRORING the
worksheet-validated Summary path (`_map_elmhurst_rir_surface`,
is_simplified, validated to 1e-4 by cohort cert 000565). Unlike the
Type 1 block (gable lengths only, billed raw L × 2.45), Type 2 lodges
gable + common-wall lengths AND heights, so the spec's §3.9.2 areas
apply:
common wall `L × (0.25 + H)` (billed at uw)
gable `L × (0.25 + H_gable)
Σ_each_common (H_gable H_common,n)² / 2`
The gable correction is taken over ALL common walls for an exposed/
party/sheltered gable (the worksheet evaluates it literally, incl. the
H_gable=0 absent-gable case a negative area that deducts from the
A_RR residual without billing a physical wall); a Connected gable
(Table 4 row 4, U=0) sums only the common walls it overtops, matching
the Summary's connected-gable branch. The `gable_wall_type_*` code
routes the kind (0 Party / 1 Exposed / 2 Sheltered / 3 Connected) via
`_api_type_1_gable_kind`; U-values are left to the cascade (no per-
gable U is lodged on the API path)."""
cw_heights = [
float(h)
for length, h in (
(type_2.common_wall_length_1, type_2.common_wall_height_1),
(type_2.common_wall_length_2, type_2.common_wall_height_2),
)
if length is not None and h is not None and length > 0 and h > 0
]
surfaces: List[SapRoomInRoofSurface] = []
gable_specs = (
(type_2.gable_wall_type_1, type_2.gable_wall_length_1,
type_2.gable_wall_height_1),
(type_2.gable_wall_type_2, type_2.gable_wall_length_2,
type_2.gable_wall_height_2),
)
for gable_type, length, height in gable_specs:
# Length is mandatory; H may be 0 for the §3.9.2 absent-gable
# quadratic (only when common walls drive the correction).
if length is None or length <= 0 or height is None:
continue
if height <= 0 and not cw_heights:
continue
kind = _api_type_1_gable_kind(gable_type)
length_m, height_m = float(length), float(height)
if cw_heights:
if kind == "connected_wall":
correction = sum(
((height_m - h) ** 2) / 2.0 for h in cw_heights if height_m > h
)
else:
correction = sum(((height_m - h) ** 2) / 2.0 for h in cw_heights)
area = _round_half_up_2dp(1.0, length_m * (0.25 + height_m) - correction)
else:
area = _round_half_up_2dp(length_m, height_m)
surfaces.append(SapRoomInRoofSurface(kind=kind, area_m2=area))
common_specs = (
(type_2.common_wall_length_1, type_2.common_wall_height_1),
(type_2.common_wall_length_2, type_2.common_wall_height_2),
)
for length, height in common_specs:
if length is None or height is None or length <= 0 or height <= 0:
continue
surfaces.append(
SapRoomInRoofSurface(
kind="common_wall",
area_m2=_round_half_up_2dp(float(length), 0.25 + float(height)),
)
)
return surfaces or None
def _api_rir_detailed_surfaces(
details: Any,
*,
@ -5222,6 +5353,24 @@ def _elmhurst_roof_window_u_value(w: ElmhurstWindow) -> float:
return w.u_value + _ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K
def _map_elmhurst_conservatory(
cons: Optional[ElmhurstConservatory],
) -> Optional[SapConservatory]:
"""RdSAP 10 §6 — translate the Summary §5 conservatory geometry into
the domain `SapConservatory`. A SEPARATED conservatory (§6.2, PDF
p.49) is disregarded entirely, so it maps to None (the cascade adds
nothing). Returns None when no conservatory is lodged."""
if cons is None or cons.thermally_separated:
return None
return SapConservatory(
floor_area_m2=cons.floor_area_m2,
glazed_perimeter_m=cons.glazed_perimeter_m,
double_glazed=cons.double_glazed,
thermally_separated=cons.thermally_separated,
room_height_storeys=cons.room_height_storeys,
)
def _map_elmhurst_roof_window(w: ElmhurstWindow) -> SapRoofWindow:
return SapRoofWindow(
area_m2=w.area_m2,
@ -6893,5 +7042,6 @@ def _map_elmhurst_ventilation(
else (False if has_suspended_timber_floor else None)
),
air_permeability_ap4_m3_h_m2=v.air_permeability_ap4_m3_h_m2,
air_permeability_ap50_m3_h_m2=v.air_permeability_ap50_m3_h_m2,
mechanical_ventilation_kind=_elmhurst_mv_kind(v.mechanical_ventilation_type),
)

View file

@ -2228,3 +2228,132 @@ class TestRoomInRoofDetailedSlopeAndStudWall:
assert studs[0].insulation_thickness_mm == 75
assert abs(commons[0].area_m2 - 10.32) <= 1e-9
assert commons[0].insulation_thickness_mm is None
class TestRoomInRoofType2SimplifiedQuadratic:
"""RdSAP 10 §3.9.2 Simplified Type 2 RR — the gov API lodges gable +
common-wall lengths AND heights under `room_in_roof_type_2`. The block
was undeclared dropped the cascade billed the whole A_RR shell at
the Table-18-col-4 default (over-count under-rate, 7 corpus certs at
signed 5.02). The mapper now MIRRORS the worksheet-validated Summary
§3.9.2 areas (cross-mapper parity, proven identical on cohort cert
000565): common walls L×(0.25+H), gables L×(0.25+H) Σ(HH_cw)²/2."""
def test_from_api_response_applies_3_9_2_gable_quadratic(self) -> None:
# Arrange — two common walls (L=8, H=1 → cw_heights [1,1]); an
# exposed gable (L=10, H=2) and a party gable (L=6, H=2).
# common wall = round(8 × (0.25+1)) = 10.00
# exposed gable= round(10 × (0.25+2) 2×(21)²/2) = round(22.51) = 21.50
# party gable = round(6 × (0.25+2) 1.0) = round(13.51) = 12.50
cert = load("21_0_1.json")
rir = cert["sap_building_parts"][0]["sap_room_in_roof"]
rir.pop("room_in_roof_type_1", None)
rir["room_in_roof_type_2"] = {
"gable_wall_type_1": 1, "gable_wall_length_1": 10.0,
"gable_wall_height_1": 2.0,
"gable_wall_type_2": 0, "gable_wall_length_2": 6.0,
"gable_wall_height_2": 2.0,
"common_wall_length_1": 8.0, "common_wall_height_1": 1.0,
"common_wall_length_2": 8.0, "common_wall_height_2": 1.0,
}
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
# Assert
rir_part = result.sap_building_parts[0].sap_room_in_roof
assert rir_part is not None
surfaces = rir_part.detailed_surfaces
assert surfaces is not None
ext = [s for s in surfaces if s.kind == "gable_wall_external"]
party = [s for s in surfaces if s.kind == "gable_wall"]
commons = [s for s in surfaces if s.kind == "common_wall"]
assert len(ext) == 1 and abs(ext[0].area_m2 - 21.50) <= 1e-9
assert len(party) == 1 and abs(party[0].area_m2 - 12.50) <= 1e-9
assert len(commons) == 2
assert abs(commons[0].area_m2 - 10.00) <= 1e-9
class TestNonSeparatedConservatoryApiMirror:
"""RdSAP 10 §6.1 (PDF p.49) — the gov API lodges a NON-SEPARATED
conservatory (conservatory_type=4) as a glazed "building part" carrying
only {floor_area, room_height, double_glazed, glazed_perimeter}. The
block was undeclared `from_dict` dropped it the conservatory was
silently lost (5 corpus certs over-rating). The mapper now splits it
into `EpcPropertyData.sap_conservatory`, excludes it from the fabric
building-part loop, and adds its floor area to TFA.
Validation is cross-mapper parity, NOT a corpus back-solve: the API
mapper feeds the SAME worksheet-validated §6.1 cascade
(`conservatory_geometry`, pinned to 1e-4 against the case-44 Summary)
as the Elmhurst path so the API conservatory fabric is correct by
construction."""
def test_from_api_response_splits_out_conservatory_building_part(
self,
) -> None:
# Arrange — a 1-BP dwelling (ground-floor room height 2.45 m) plus a
# non-separated double-glazed conservatory glazed BP.
from datatypes.epc.domain.epc_property_data import SapConservatory
from domain.sap10_calculator.worksheet.conservatory import (
conservatory_geometry,
)
baseline_tfa = EpcPropertyDataMapper.from_api_response(
load("21_0_1.json")
).total_floor_area_m2
cert = load("21_0_1.json")
cert["conservatory_type"] = 4
cert["sap_building_parts"].append(
{
"floor_area": 12.0,
"room_height": 1,
"double_glazed": "Y",
"glazed_perimeter": 9.0,
}
)
# Act
epc = EpcPropertyDataMapper.from_api_response(cert)
# Assert — conservatory split out; the glazed BP is NOT a fabric part.
assert epc.sap_conservatory == SapConservatory(
floor_area_m2=12.0,
glazed_perimeter_m=9.0,
double_glazed=True,
thermally_separated=False,
room_height_storeys=1.0,
)
assert len(epc.sap_building_parts) == 1
# §6.1: the conservatory floor area joins TFA (drives occupancy).
assert abs(epc.total_floor_area_m2 - (baseline_tfa + 12.0)) <= 1e-9
# Cross-mapper parity: the shared §6.1 cascade derives the same
# surfaces it does for the case-44 Summary — glazed wall = exposed
# perimeter × ground-floor room height (9.0 × 2.45 = 22.05); glazed
# roof = floor / cos(20°) (12.0 / 0.9397 = 12.77); Table 25 double
# U_eff = 1/(1/3.1 + 0.04) = 2.758 (wall) / 1/(1/3.4 + 0.04) = 2.993.
geom = conservatory_geometry(epc)
assert geom is not None
assert abs(geom.glazed_wall_area_m2 - 22.05) <= 1e-4
assert abs(geom.glazed_roof_area_m2 - 12.77) <= 1e-4
assert abs(geom.wall_u_eff - 2.7580) <= 1e-4
assert abs(geom.roof_u_eff - 2.9930) <= 1e-4
def test_separated_conservatory_lodges_no_glazed_building_part(self) -> None:
# Arrange — a separated conservatory (type 2/3) lodges NO glazed BP
# (verified across the gov corpus); the dwelling is unchanged.
from domain.sap10_calculator.worksheet.conservatory import (
conservatory_geometry,
)
cert = load("21_0_1.json")
cert["conservatory_type"] = 2
# Act
epc = EpcPropertyDataMapper.from_api_response(cert)
# Assert — §6.2: disregarded; no conservatory geometry.
assert epc.sap_conservatory is None
assert conservatory_geometry(epc) is None

View file

@ -236,12 +236,29 @@ class RoomInRoofDetails:
common_wall_height_2: Optional[float] = None
@dataclass
class RoomInRoofType2:
"""RdSAP §3.9.2 Simplified Type 2 RR — gable + common-wall geometry.
See `rdsap_schema_21_0_1.RoomInRoofType2`. Previously dropped."""
gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None
gable_wall_length_2: Optional[float] = None
gable_wall_height_1: Optional[float] = None
gable_wall_height_2: Optional[float] = None
common_wall_length_1: Optional[float] = None
common_wall_length_2: Optional[float] = None
common_wall_height_1: Optional[float] = None
common_wall_height_2: Optional[float] = None
@dataclass
class SapRoomInRoof:
"""Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0."""
floor_area: Union[int, float]
construction_age_band: str
room_in_roof_type_1: Optional[RoomInRoofType1] = None
room_in_roof_type_2: Optional[RoomInRoofType2] = None
room_in_roof_details: Optional[RoomInRoofDetails] = None

View file

@ -278,14 +278,38 @@ class RoomInRoofDetails:
common_wall_height_2: Optional[float] = None
@dataclass
class RoomInRoofType2:
"""RdSAP §3.9.2 Simplified Type 2 RR — a room-in-roof bounded by
continuous common walls (accessible common-wall height < 1.8 m, so the
space counts as RR not a separate storey). Lodges gable + common-wall
lengths AND heights (unlike Type 1, gable lengths only). `gable_wall_
type_*` is the Table 4 variant (0 Party / 1 Exposed / 2 Sheltered /
3 Connected). Previously undeclared dropped by `from_dict`, so the
cascade billed the whole A_RR shell at the Table-18-col-4 default
(over-count under-rate)."""
gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None
gable_wall_length_2: Optional[float] = None
gable_wall_height_1: Optional[float] = None
gable_wall_height_2: Optional[float] = None
common_wall_length_1: Optional[float] = None
common_wall_length_2: Optional[float] = None
common_wall_height_1: Optional[float] = None
common_wall_height_2: Optional[float] = None
@dataclass
class SapRoomInRoof:
floor_area: Union[int, float]
construction_age_band: str
# Two real-API shapes coexist: older certs (cohort 6035, 0240, test
# fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; newer
# certs (9501) lodge the Detailed-RR block. Accept both.
# Three real-API shapes coexist: older certs (cohort 6035, 0240, test
# fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; some lodge
# the §3.9.2 Simplified Type 2 wrapper (gable + common-wall geometry);
# newer certs (9501) lodge the Detailed-RR block. Accept all three.
room_in_roof_type_1: Optional[RoomInRoofType1] = None
room_in_roof_type_2: Optional[RoomInRoofType2] = None
room_in_roof_details: Optional[RoomInRoofDetails] = None
@ -358,6 +382,16 @@ class SapBuildingPart:
# redacts the backing insulation. Previously undeclared → dropped.
wall_u_value: Optional[float] = None
floor_u_value: Optional[float] = None
# RdSAP 10 §6.1 (PDF p.49) — a NON-SEPARATED conservatory is lodged by
# the gov API as a glazed "building part" carrying ONLY these four
# fields (no fabric, no floor dimensions); `conservatory_type == 4` at
# the property level. Previously undeclared → dropped by `from_dict`,
# so the conservatory was silently lost on the API path. The mapper
# splits this BP out into `EpcPropertyData.sap_conservatory`.
floor_area: Optional[Union[Measurement, int, float]] = None
room_height: Optional[Union[Measurement, int, float]] = None
double_glazed: Optional[str] = None
glazed_perimeter: Optional[Union[Measurement, int, float]] = None
@dataclass

View file

@ -202,6 +202,9 @@ class VentilationAndCooling:
# SAP 10.2 §2 (17a) AP4 reading from §12.2 "Pressure Test Result
# (AP4)" — only present when `pressure_test_method == "Pulse"`.
air_permeability_ap4_m3_h_m2: Optional[float] = None
# SAP 10.2 §2 (17) AP50 reading from §12.2 "Pressure Test Result
# (AP50)" — present for a Blower Door test. Routes (18) via AP50/20.
air_permeability_ap50_m3_h_m2: Optional[float] = None
# Summary §12.1 "Mechanical Ventilation Type" — e.g. "Mechanical
# extract, decentralised (MEV dc)". None when `mechanical_ventilation
# is False` (no MV system).
@ -478,6 +481,21 @@ class ExtensionPart:
room_in_roof: Optional[RoomInRoof] = None
@dataclass
class Conservatory:
"""Summary §5 geometry of a NON-SEPARATED conservatory (RdSAP 10
§6.1). `room_height_storeys` is the lodged equivalent-storey count
("1 Storey" 1.0, "1.5 Storey" 1.5); the mapper/cascade translate
it to a metre height. A SEPARATED conservatory (§6.2) is disregarded,
so `thermally_separated=True` records are dropped before the cascade."""
thermally_separated: bool
floor_area_m2: float
double_glazed: bool
glazed_perimeter_m: float
room_height_storeys: float
@dataclass
class ElmhurstSiteNotes:
surveyor_info: SurveyorInfo
@ -560,3 +578,9 @@ class ElmhurstSiteNotes:
# cold loft instead of a room-in-roof). The mapper translates the
# surface table into a `SapRoomInRoof` attached to the Main bp.
room_in_roof: Optional[RoomInRoof] = None
# §5.0 Conservatory geometry — None when the dwelling has no
# conservatory (`has_conservatory=False`). Populated (incl. for
# separated conservatories) so the mapper can apply the §6.1/§6.2
# rule; the mapper drops separated ones.
conservatory: Optional[Conservatory] = None

View file

@ -4986,6 +4986,9 @@ def ventilation_from_cert(
# cascade's `(18) = 0.263 × AP4^0.924 + (8)` formula; absent value
# falls through to the components-based (16) ach.
ap4 = sv.air_permeability_ap4_m3_h_m2 if sv is not None else None
# SAP 10.2 §2 (17) — AP50 Blower Door reading routes (18) via
# `AP50 / 20 + (8)`, preferred over AP4 when both are lodged.
ap50 = sv.air_permeability_ap50_m3_h_m2 if sv is not None else None
# SAP 10.2 §2 (23a)/(24a..d) — MV kind dispatch chooses the (25)m
# effective-ach formula. The Elmhurst mapper translates the lodged
# "Mechanical Ventilation Type" string to an enum *name*; resolve
@ -5023,6 +5026,7 @@ def ventilation_from_cert(
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0),
sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2,
air_permeability_ap4=ap4,
air_permeability_ap50=ap50,
mv_kind=mv_kind,
mv_system_ach=mv_system_ach,
**wind_kwargs,

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
@ -396,6 +400,32 @@ def _joined_main_roof_descriptions(roofs: list[Any]) -> Optional[str]:
return " | ".join(parts)
def _main_roof_descriptions_by_kind(
roofs: list[Any],
) -> tuple[Optional[str], Optional[str]]:
"""Partition the non-RR roof descriptions into ``(pitched, flat)`` joins.
The deduplicated ``epc.roofs[]`` list cannot be indexed 1:1 against the
building parts (190/329 multi-part certs have len(roofs) != len(parts)),
so each part's ``u_roof`` historically consumed the SINGLE join of every
roof description. That leaks one part's insulation state onto another: a
"Flat, no insulation" extension dragged a "Pitched, insulated (assumed)"
main roof to the uninsulated 2.30, ~3x over-stating its heat loss (cert
100010129331: roof 110.5 -> ~28 W/K, +13 SAP). Splitting by flat vs
pitched/sloping lets each part match its own kind; the global join
(`_joined_main_roof_descriptions`) stays the fallback when a part's kind
has no matching entry. "Roof room(s)" entries are dropped (they carry
their own §3.9/§3.10 shell cascade)."""
pitched: list[str] = []
flat: list[str] = []
for e in roofs:
d = getattr(e, "description", "")
if not d or "roof room" in d.lower():
continue
(flat if "flat" in d.lower() else pitched).append(d)
return (" | ".join(pitched) or None, " | ".join(flat) or None)
def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
if not part.sap_floor_dimensions:
# A part with no floor dimensions has no derivable RR shell or
@ -613,6 +643,9 @@ def heat_transmission_from_cert(
country = Country.from_code(epc.country_code)
roof_description = _joined_main_roof_descriptions(epc.roofs)
pitched_roof_description, flat_roof_description = (
_main_roof_descriptions_by_kind(epc.roofs)
)
wall_description = _joined_descriptions(epc.walls)
floor_description = _joined_descriptions(epc.floors)
@ -884,8 +917,19 @@ def heat_transmission_from_cert(
roof_thickness_explicitly_zero = (
isinstance(raw_roof_thickness, int) and raw_roof_thickness == 0
)
# RdSAP 10 §5.11 — match THIS part's roof to its own kind's lodged
# description (flat vs pitched/sloping) rather than the global join,
# so a flat "no insulation" part does not drag a pitched insulated
# part to the uninsulated 2.30. Fall back to the global join when the
# part's kind has no matching `epc.roofs[]` entry.
part_roof_is_flat = "flat" in (part.roof_construction_type or "").lower()
matched_roof_description = (
flat_roof_description if part_roof_is_flat else pitched_roof_description
)
if matched_roof_description is None:
matched_roof_description = roof_description
effective_roof_description = (
None if roof_thickness_explicitly_zero else roof_description
None if roof_thickness_explicitly_zero else matched_roof_description
)
# RdSAP 10 §5.11 Table 18 page 45: column (3) "Flat roof" applies
# when the per-bp roof construction lodges as a flat roof and the
@ -1368,6 +1412,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

@ -36,6 +36,10 @@ from math import cos, radians, sin
from typing import Final
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
from domain.sap10_calculator.worksheet.conservatory import (
CONSERVATORY_ROOF_PITCH_DEG,
conservatory_geometry,
)
from domain.sap10_calculator.tables.pcdb.postcode_weather import PostcodeClimate
from domain.sap10_calculator.climate.appendix_u import (
horizontal_solar_irradiance_w_per_m2,
@ -435,6 +439,48 @@ def solar_gains_from_cert(
o: _sum_tuples(*per_orientation[o]) for o in Orientation
}
# RdSAP 10 §6.1 (PDF p.49) + Table 25 note (p.51): "The orientation of
# windows in a conservatory is not recorded, thus solar gains are
# calculated using the default solar flux (East/West orientation, with
# 20° pitch for roof windows)." Average overshading per §7 (Table 6d).
# The glazed wall bills onto the (76) East line (vertical, Z=z_vertical);
# the glazed roof onto the (82) roof-window line (20° pitch, Z=1.0).
cons = conservatory_geometry(epc)
if cons is not None:
cons_wall_monthly = tuple(
window_solar_gain_w(
area_m2=cons.glazed_wall_area_m2,
surface_flux_w_per_m2=surface_solar_flux_w_per_m2(
orientation=Orientation.E, pitch_deg=90.0,
region=region, month=m,
),
g_perpendicular=cons.g_value,
frame_factor=cons.frame_factor,
overshading_factor=z_vertical,
)
for m in _MONTHS
)
cons_roof_monthly = tuple(
window_solar_gain_w(
area_m2=cons.glazed_roof_area_m2,
surface_flux_w_per_m2=surface_solar_flux_w_per_m2(
orientation=Orientation.E,
pitch_deg=CONSERVATORY_ROOF_PITCH_DEG,
region=region, month=m,
),
g_perpendicular=cons.g_value,
frame_factor=cons.frame_factor,
overshading_factor=_HORIZONTAL_Z,
)
for m in _MONTHS
)
per_orientation_summed[Orientation.E] = _sum_tuples(
per_orientation_summed[Orientation.E], cons_wall_monthly,
)
roof_windows_monthly = _sum_tuples(
roof_windows_monthly, cons_roof_monthly,
)
total = _sum_tuples(
*per_orientation_summed.values(),
roof_windows_monthly,

View file

@ -589,6 +589,35 @@ def u_wall(
):
u0 = _u_stone_thin_wall_age_a_to_e(construction, wall_thickness_mm)
if u0 is not None:
# RdSAP 10 §5.8 + Table 14 (PDF p.41-42) — added External/Internal
# insulation on a stone wall: U = 1/(1/U₀ + R_ins), with U₀ the
# RAW §5.6 stone result (the Table-6 footnote (a) 1.7 cap does NOT
# apply to the insulated path — same rule the brick branch below
# and the dry-lined granite pin 000565 follow). λ defaults to
# 0.04 W/m·K per §5.8 final note. Mirrors the WALL_SOLID_BRICK
# insulated branch; without it a stone wall lodging code 1/3 +
# a thickness was billed at its UNINSULATED U (e.g. sandstone
# 520 mm + 100 mm internal: 1.64 → 0.30), the dominant cause of
# the wall_insulation_type=3 corpus under-rate cluster.
if (
wall_insulation_type in (
_WALL_INSULATION_EXTERNAL, _WALL_INSULATION_INTERNAL,
)
and insulation_thickness_mm is not None
and insulation_thickness_mm > 0
):
r_ins = _r_insulation_table_14(
insulation_thickness_mm,
_resolve_wall_insulation_lambda_w_per_mk(
wall_insulation_thermal_conductivity
),
)
u_unrounded = 1.0 / (1.0 / u0 + r_ins)
return float(
Decimal(str(u_unrounded)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
)
if dry_lined:
# Round to 2 d.p. — worksheet (29a) A×U product uses
# the 2-d.p.-displayed U (cf. 000565 Main alt_wall_1:

View file

@ -725,6 +725,80 @@ def test_u_wall_stone_sandstone_thin_wall_age_a_120mm_uses_5_6_sandstone_formula
assert abs(result - 3.7408) <= 1e-3
def test_u_wall_stone_sandstone_with_internal_insulation_applies_5_8_table_14_r_value() -> None:
# Arrange — RdSAP 10 §5.8 + Table 14 (PDF p.41-42): a stone wall lodging
# External/Internal insulation (wall_insulation_type 1/3) + a thickness
# gets the same R-value adjustment as solid brick, applied to the RAW §5.6
# U₀. Mirrors corpus cert 100052159386 (Sandstone, 520 mm, 100 mm internal):
# U₀ = 54.876 × 520^(-0.561) = 1.6433
# R = 0.025 × 100 + 0.25 = 2.75 (Table 14, λ = 0.04)
# U = 1 / (1/1.6433 + 2.75) = 0.2977 → 0.30 (2 d.p.)
# Before this branch the wall was billed at its UNINSULATED U (≈1.64),
# the dominant cause of the wall_insulation_type=3 corpus under-rate cluster.
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_SANDSTONE,
insulation_thickness_mm=100,
insulation_present=True,
wall_insulation_type=3,
dry_lined=False,
wall_thickness_mm=520,
)
# Assert
assert abs(result - 0.30) <= 1e-4
def test_u_wall_stone_sandstone_insulated_feeds_raw_u0_not_table_6_cap() -> None:
# Arrange — the Table-6 footnote (a) 1.7 cap applies ONLY to the as-built
# row; the insulated §5.8 path takes the RAW §5.6 U₀ (same rule the brick
# branch and the dry-lined granite pin 000565 follow). At W=120 mm the raw
# sandstone U₀ = 3.7408 (> 1.7), so the 100 mm internal result must be
# 1 / (1/3.7408 + 2.75) = 0.331 → 0.33 (raw),
# NOT the capped 1 / (1/1.7 + 2.75) = 0.30. The 0.33 vs 0.30 split proves
# the cap is bypassed on the insulated path.
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_SANDSTONE,
insulation_thickness_mm=100,
insulation_present=True,
wall_insulation_type=3,
dry_lined=False,
wall_thickness_mm=120,
)
# Assert
assert abs(result - 0.33) <= 1e-4
def test_u_wall_stone_granite_with_external_insulation_applies_5_8_table_14_r_value() -> None:
# Arrange — granite/whinstone §5.6 formula + §5.8 external insulation:
# U₀ = 45.315 × 120^(-0.513) = 3.8871
# R = 0.025 × 50 + 0.25 = 1.50 (Table 14, λ = 0.04)
# U = 1 / (1/3.8871 + 1.50) = 0.567 → 0.57 (2 d.p.)
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_GRANITE,
insulation_thickness_mm=50,
insulation_present=True,
wall_insulation_type=1,
dry_lined=False,
wall_thickness_mm=120,
)
# Assert
assert abs(result - 0.57) <= 1e-4
def test_u_wall_stone_granite_age_g_with_wall_thickness_ignores_5_6_formula_per_age_a_to_e_gate() -> None:
# Arrange — §5.6 (PDF p.40) heading explicitly scopes the formula
# to "age bands A to E". For age F onwards Table 6 gives literal

View file

@ -0,0 +1,144 @@
"""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
# Demand-side line refs (Jan column, UK-average rating block). These
# integrate the WHOLE §6.1 conservatory chain end-to-end:
# - (73) internal gains — the conservatory floor area enters TFA (4),
# which drives occupancy → §5 appliance/cooking/metabolic gains;
# - (83) solar gains — the glazed wall (E/W flux, 90° pitch) + glazed
# roof (E/W flux, 20° pitch) at Table 25 g=0.76, FF=0.70;
# - (95) useful gains = (84) total gains × the §7 utilisation factor —
# matches only when fabric (33), ventilation (38) AND gains (84) all
# agree, so it is the single tightest end-to-end conservatory pin;
# - (99) space heating per m² = (98c)/(4) — the integrated demand.
LINE_73_INTERNAL_GAINS_JAN_W: Final[float] = 625.1759
LINE_83_SOLAR_GAINS_JAN_W: Final[float] = 495.8655
LINE_95_USEFUL_GAINS_JAN_W: Final[float] = 1079.6510
LINE_99_SPACE_HEATING_PER_M2_KWH: Final[float] = 89.8073
# NB — the full SAP value (72.9517) + (272) CO2 (3241.8656) are NOT pinned
# here. The case-44 Summary PDF omits the House-Coal secondary heater
# (SAP 633, 60% eff) that the P960 worksheet's descriptor block carries
# (the same secondary as case 43); routed through the extractor the
# Summary therefore yields NO secondary system, and the residual SAP/CO2
# gap is exactly that missing secondary (main+secondary CO2 1927.31 +
# 563.92 = 2491.23 vs cascade 2141.46 → +349.77 ≈ the 350 kg deficit).
# This is a Summary-input defect, independent of §6.1 — every
# conservatory-affected line ref above reproduces the P960 EXACTLY.
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

@ -38,6 +38,7 @@ from domain.sap10_calculator.worksheet.heat_transmission import (
from domain.sap10_calculator.worksheet.heat_transmission import (
_alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage]
_joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage]
_main_roof_descriptions_by_kind, # pyright: ignore[reportPrivateUsage]
_part_geometry, # pyright: ignore[reportPrivateUsage]
_round_half_up, # pyright: ignore[reportPrivateUsage]
_window_bp_index, # pyright: ignore[reportPrivateUsage]
@ -82,6 +83,80 @@ def test_joined_main_roof_descriptions_keeps_pure_rr_fallback() -> None:
assert result == "Roof room(s), no insulation (assumed)"
def test_main_roof_descriptions_by_kind_splits_flat_from_pitched() -> None:
# Arrange — a cert with a pitched insulated main roof + a flat
# uninsulated extension. The deduplicated epc.roofs[] cannot be indexed
# 1:1 against the parts, so each part must match its own KIND's
# description: the flat part's "no insulation" must not leak onto the
# pitched part (which would force the whole pitched roof to U=2.30).
roofs = [
_Desc("Pitched, insulated (assumed)"),
_Desc("Flat, no insulation"),
_Desc("Roof room(s), no insulation (assumed)"),
]
# Act
pitched, flat = _main_roof_descriptions_by_kind(roofs)
# Assert — RR dropped; flat and pitched kept apart.
assert pitched == "Pitched, insulated (assumed)"
assert flat == "Flat, no insulation"
def test_mixed_flat_pitched_roof_does_not_contaminate_pitched_u_value() -> None:
# Arrange — 2-part dwelling: a 100 m² pitched insulated-assumed main
# roof (U=0.40) + a 2 m² flat uninsulated extension (U=2.30). Before the
# per-kind split, the joined "Pitched, insulated (assumed) | Flat, no
# insulation" description leaked the flat's "no insulation" onto the
# pitched part, billing the WHOLE roof at 2.30 (100×2.30 + 2×2.30 =
# 234.6 W/K). Correct: 100×0.40 + 2×2.30 = 44.6 W/K. Mirrors corpus
# cert 100010129331 (roof 110.5 -> 31.3 W/K, +13 -> 0 SAP).
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="C",
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
ext = make_building_part(
identifier=BuildingPartIdentifier.EXTENSION_1,
construction_age_band="C",
roof_construction=5,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=2.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=6.0, floor=0,
),
],
)
ext.roof_construction_type = "Flat"
epc = make_minimal_sap10_epc(
total_floor_area_m2=102.0,
country_code="ENG",
sap_building_parts=[main, ext],
)
epc.roofs = [
EnergyElement(
description="Pitched, insulated (assumed)",
energy_efficiency_rating=4, environmental_efficiency_rating=4,
),
EnergyElement(
description="Flat, no insulation",
energy_efficiency_rating=1, environmental_efficiency_rating=1,
),
]
# Act
result = heat_transmission_from_cert(epc)
# Assert — pitched main billed at its insulated U, not the flat's 2.30.
assert abs(result.roof_w_per_k - 44.6) <= 2.0
def test_part_geometry_floorless_part_honours_full_key_contract() -> None:
# Arrange — a building part lodged with NO sap_floor_dimensions (e.g.
# a party-wall-only or RR-only extension; observed on 5 certs in a

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,126 @@ 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_case44_conservatory_demand_side_matches_pdf() -> None:
"""End-to-end §6.1 conservatory demand pin for simulated case 44.
Beyond the §3 fabric, the conservatory ripples through the demand
cascade: its floor area enters TFA (4) occupancy §5 internal
gains (73); its glazing contributes §6 solar gains (83) at the
default E/W flux (Table 25 g=0.76, FF=0.70, 20° roof pitch); fabric
+ ventilation + gains combine into the §7 useful gains (95) and the
space-heating demand (99). Every line ref reproduces the P960 to 1e-4.
The full SAP/CO2 is NOT asserted: the case-44 Summary omits the
House-Coal secondary heater the P960 carries (see the provider's NB) —
a Summary-input defect downstream of, and independent of, §6.1."""
# Arrange
epc = _w001431_case44.build_epc()
# Act
ig = internal_gains_section_from_cert(epc)
sg = solar_gains_section_from_cert(epc)
sh = space_heating_section_from_cert(epc)
assert ig is not None # TFA present ⇒ §5 helper returns a result
# Assert — §5/§6/§7 demand line refs, each at abs=1e-4.
_pin(
ig.total_internal_gains_monthly_w[0],
_w001431_case44.LINE_73_INTERNAL_GAINS_JAN_W,
"§5 (73) case44",
)
_pin(
sg.total_solar_gains_monthly_w[0],
_w001431_case44.LINE_83_SOLAR_GAINS_JAN_W,
"§6 (83) case44",
)
_pin(
sh.useful_gains_monthly_w[0],
_w001431_case44.LINE_95_USEFUL_GAINS_JAN_W,
"§7 (95) case44",
)
_pin(
sh.space_heating_per_m2_kwh,
_w001431_case44.LINE_99_SPACE_HEATING_PER_M2_KWH,
"§7 (99) case44",
)
def test_case44_blower_door_pressure_test_matches_pdf() -> None:
"""Simulated case 44 lodges a Blower Door air-pressure test
(§12.2 "Pressure Test Result (AP50) 4.50"). SAP 10.2 §2 (17)-(18):
the AP50 reading routes infiltration via `(18) = AP50/20 + (8)` =
4.5/20 + 0.1167 = 0.3417, in preference to the components-based (16)
estimate. The extractor previously read only the AP4 (Pulse) column,
so a Blower Door result fell through to the structural-infiltration
default (effective ach 0.81 vs the worksheet's 0.58 → ventilation
heat loss over-counted by ~38%)."""
# Arrange
epc = _w001431_case44.build_epc()
# Act
vent = ventilation_from_cert(epc)
# Assert — (18) infiltration + (25) Jan effective ach, at abs=1e-4.
_pin(vent.pressure_test_ach, 0.3417, "§2 (18) case44")
_pin(vent.effective_monthly_ach[0], 0.5812, "§2 (25) Jan 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

View file

@ -95,10 +95,34 @@ _CORPUS = Path(
# 67.3% -> 67.5% (MAE 1.020 -> 0.987). The follow-on `common_wall_*` Detailed-RR
# surfaces (billed at main-wall U, deducted from the §3.10.1 residual) took the
# 6-cert detailed-common-wall cohort 2.43 -> 1.25; corpus -> 67.6% (MAE 0.979).
_MIN_WITHIN_HALF_SAP = 0.67
_MAX_SAP_MAE = 0.99
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current
# The §3.9.2 Simplified Type-2 RR mapper (room_in_roof_type_2: gable quadratic +
# common-wall L×(0.25+H), MIRRORING the worksheet-validated Summary path,
# cross-mapper-parity-exact on cert 000565) -> 67.9% (MAE 0.959).
# The §6.1 non-separated conservatory mapper (the gov API's glazed building
# part → SapConservatory → §6.1 window/rooflight/floor cascade + TFA, MIRRORING
# the case-44 Summary path pinned to 1e-4) -> 68.6% (MAE 0.942). 5 type-4
# certs were over-rating (conservatory dropped → too little heat loss).
# STONE WALL + INTERNAL/EXTERNAL INSULATION (RdSAP 10 §5.8 + Table 14, p.41-42):
# the §5.8 added-insulation R-value adjustment was applied ONLY to WALL_SOLID_
# BRICK; a stone (granite/sandstone) wall lodging wall_insulation_type 1/3 + a
# thickness fell through the §5.6 branch and was billed at its UNINSULATED U
# (e.g. sandstone 520 mm + 100 mm internal: 1.64 instead of 0.30 → 5× wall heat
# loss). Mirroring the brick branch into the stone block recovered the worst of
# the wall_insulation_type=3 under-rate cluster (cert 100052159386 -26.2 -> -4.1
# SAP, walls 300 -> 55 W/K). within-0.5 68.6% -> 68.8% (MAE 0.942 -> 0.888;
# PE MAE 14.3 -> 13.9; CO2 MAE 0.27 -> 0.26). Unit-pinned in test_rdsap_uvalues.
# PER-PART ROOF DESCRIPTION (RdSAP 10 §5.11): the deduplicated epc.roofs[] list
# was joined into ONE description fed to EVERY building part's u_roof, so a flat
# "no insulation" extension dragged a pitched "insulated (assumed)" main roof to
# the uninsulated 2.30 (3-part certs systematically under-rated: 56% within,
# -0.79 mean). Matching each part to its own kind (flat vs pitched) fixed cert
# 100010129331 (roof 110.5 -> 31.3 W/K, +13.1 -> -0.05 SAP). within-0.5
# 68.8% -> 69.5% (MAE 0.888 -> 0.859; PE 13.9 -> 13.6); 3-part cohort 56% ->
# 61%. Pinned in test_heat_transmission (by_kind split + no-contamination).
_MIN_WITHIN_HALF_SAP = 0.69
_MAX_SAP_MAE = 0.86
_MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current
def _load_corpus() -> list[dict[str, Any]]: