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, BathsAndShowers,
BuildingPartDimensions, BuildingPartDimensions,
CommunityHeating, CommunityHeating,
Conservatory,
ElmhurstSiteNotes, ElmhurstSiteNotes,
ExtensionPart, ExtensionPart,
FloorDetails, 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]: def _parse_solar_pitch_deg(raw: Optional[str]) -> Optional[int]:
"""Parse the §16.0 "Collector elevation" lodgement (e.g. "30°", "60°", """Parse the §16.0 "Collector elevation" lodgement (e.g. "30°", "60°",
or a bare integer). Returns None when absent or unparseable.""" or a bare integer). Returns None when absent or unparseable."""
@ -81,6 +93,13 @@ class ElmhurstSiteNotesExtractor:
except (ValueError, IndexError): except (ValueError, IndexError):
return 0 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: def _date_val(self, label: str) -> date:
v = self._next_val(label) v = self._next_val(label)
if not v: if not v:
@ -179,8 +198,39 @@ class ElmhurstSiteNotesExtractor:
v = self._local_val(lines, label) v = self._local_val(lines, label)
return v is not None and v.lower() == "yes" 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 --- # --- 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: def _extract_surveyor_info(self) -> SurveyorInfo:
return SurveyorInfo( return SurveyorInfo(
surveyor_code=self._str_val("Surveyor"), surveyor_code=self._str_val("Surveyor"),
@ -1302,6 +1352,10 @@ class ElmhurstSiteNotesExtractor:
air_permeability_ap4_m3_h_m2 = float(ap4_raw.split()[0]) air_permeability_ap4_m3_h_m2 = float(ap4_raw.split()[0])
except (ValueError, IndexError): except (ValueError, IndexError):
air_permeability_ap4_m3_h_m2 = None 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 # Summary §12.1 "Mechanical Ventilation Type" — scoped to §12.1
# body so the global "Type" labels in §14 / §15 can't shadow it. # body so the global "Type" labels in §14 / §15 can't shadow it.
mv_lines = self._section_lines( mv_lines = self._section_lines(
@ -1350,6 +1404,7 @@ class ElmhurstSiteNotesExtractor:
mechanical_ventilation=self._bool_val("Mechanical Ventilation"), mechanical_ventilation=self._bool_val("Mechanical Ventilation"),
pressure_test_method=self._str_val("Test Method"), pressure_test_method=self._str_val("Test Method"),
air_permeability_ap4_m3_h_m2=air_permeability_ap4_m3_h_m2, 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_type=mechanical_ventilation_type,
mechanical_ventilation_pcdf_reference=mev_pcdf_reference, mechanical_ventilation_pcdf_reference=mev_pcdf_reference,
wet_rooms_count=wet_rooms_count, wet_rooms_count=wet_rooms_count,
@ -1820,6 +1875,7 @@ class ElmhurstSiteNotesExtractor:
construction_age_band=self._extract_main_age_band(), construction_age_band=self._extract_main_age_band(),
dimensions=self._extract_dimensions(), dimensions=self._extract_dimensions(),
has_conservatory=self._bool_val("Is there a conservatory?"), has_conservatory=self._bool_val("Is there a conservatory?"),
conservatory=self._extract_conservatory(),
walls=self._extract_walls(), walls=self._extract_walls(),
roof=self._extract_roof(), roof=self._extract_roof(),
floor=self._extract_floor(), 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 # 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)`. # cascade routes (18) via the AP4 formula `0.263 × AP4^0.924 + (8)`.
air_permeability_ap4_m3_h_m2: Optional[float] = None 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" # SAP 10.2 §2 (23a)/(24a..d) — Elmhurst "Mechanical Ventilation Type"
# string mapped to the `MechanicalVentilationKind` enum name (e.g. # string mapped to the `MechanicalVentilationKind` enum name (e.g.
# "EXTRACT_OR_PIV_OUTSIDE" for MEV decentralised). The cascade uses # "EXTRACT_OR_PIV_OUTSIDE" for MEV decentralised). The cascade uses
@ -261,6 +265,33 @@ class SapRoofWindow:
window_location: Union[int, str] = 0 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 @dataclass
class SapWindow: class SapWindow:
frame_material: Optional[str] frame_material: Optional[str]
@ -774,6 +805,10 @@ class EpcPropertyData:
# has no roof windows; for cert-cascade fixtures the bootstrap path # has no roof windows; for cert-cascade fixtures the bootstrap path
# lodges per-window area + raw U. # lodges per-window area + raw U.
sap_roof_windows: Optional[List[SapRoofWindow]] = None 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? calculation_software_version: Optional[str] = None # Do we care about this?
mechanical_vent_duct_placement: Optional[int] = None mechanical_vent_duct_placement: Optional[int] = None
mechanical_vent_duct_insulation: 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, SapEnergySource,
SapFlatDetails, SapFlatDetails,
SapFloorDimension, SapFloorDimension,
SapConservatory,
SapHeating, SapHeating,
SapRoofWindow, SapRoofWindow,
SapRoomInRoof, SapRoomInRoof,
@ -69,6 +70,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
AlternativeWall as ElmhurstAlternativeWall, AlternativeWall as ElmhurstAlternativeWall,
BuildingPartDimensions as ElmhurstBuildingPartDimensions, BuildingPartDimensions as ElmhurstBuildingPartDimensions,
CommunityHeating, CommunityHeating,
Conservatory as ElmhurstConservatory,
ElmhurstSiteNotes, ElmhurstSiteNotes,
FloorDetails as ElmhurstFloorDetails, FloorDetails as ElmhurstFloorDetails,
MainHeating as ElmhurstMainHeating, MainHeating as ElmhurstMainHeating,
@ -415,12 +417,25 @@ class EpcPropertyDataMapper:
ext.room_in_roof.floor_area_m2 ext.room_in_roof.floor_area_m2
for ext in survey.extensions for ext in survey.extensions
if ext.room_in_roof is not None 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, 2,
), ),
built_form=built_form, built_form=built_form,
property_type=property_type, property_type=property_type,
has_conservatory=survey.has_conservatory, has_conservatory=survey.has_conservatory,
sap_conservatory=_map_elmhurst_conservatory(survey.conservatory),
blocked_chimneys_count=survey.ventilation.blocked_chimneys_count, blocked_chimneys_count=survey.ventilation.blocked_chimneys_count,
number_of_storeys=survey.number_of_storeys, number_of_storeys=survey.number_of_storeys,
hydro=survey.renewables.hydro_electricity_generated_kwh > 0, hydro=survey.renewables.hydro_electricity_generated_kwh > 0,
@ -2077,8 +2092,14 @@ class EpcPropertyDataMapper:
else None 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 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( renewable_heat_incentive=RenewableHeatIncentive(
space_heating_kwh=float( space_heating_kwh=float(
schema.renewable_heat_incentive.space_heating_existing_dwelling schema.renewable_heat_incentive.space_heating_existing_dwelling
@ -2483,6 +2504,33 @@ def _measurement_value(field: Any) -> float:
return float(field) 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]: 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 """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 `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 total = 0.0
found = False found = False
for bp in building_parts: 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 [] floor_dims: Any = bp.sap_floor_dimensions or []
for fd in floor_dims: for fd in floor_dims:
total += _measurement_value(fd.total_floor_area) 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 # §3.9.1 default RR storey height (2.45 m); the type code routes
# the U-value (Exposed → main-wall U, Party → 0.25). # the U-value (Exposed → main-wall U, Party → 0.25).
rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1) 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) details = getattr(bp_rir, "room_in_roof_details", None)
if details is not None: if details is not None:
rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat) rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat)
return rir 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( def _api_rir_detailed_surfaces(
details: Any, 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 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: def _map_elmhurst_roof_window(w: ElmhurstWindow) -> SapRoofWindow:
return SapRoofWindow( return SapRoofWindow(
area_m2=w.area_m2, area_m2=w.area_m2,
@ -6893,5 +7042,6 @@ def _map_elmhurst_ventilation(
else (False if has_suspended_timber_floor else None) else (False if has_suspended_timber_floor else None)
), ),
air_permeability_ap4_m3_h_m2=v.air_permeability_ap4_m3_h_m2, 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), 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 studs[0].insulation_thickness_mm == 75
assert abs(commons[0].area_m2 - 10.32) <= 1e-9 assert abs(commons[0].area_m2 - 10.32) <= 1e-9
assert commons[0].insulation_thickness_mm is None 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 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 @dataclass
class SapRoomInRoof: class SapRoomInRoof:
"""Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0.""" """Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0."""
floor_area: Union[int, float] floor_area: Union[int, float]
construction_age_band: str construction_age_band: str
room_in_roof_type_1: Optional[RoomInRoofType1] = None room_in_roof_type_1: Optional[RoomInRoofType1] = None
room_in_roof_type_2: Optional[RoomInRoofType2] = None
room_in_roof_details: Optional[RoomInRoofDetails] = None room_in_roof_details: Optional[RoomInRoofDetails] = None

View file

@ -278,14 +278,38 @@ class RoomInRoofDetails:
common_wall_height_2: Optional[float] = None 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 @dataclass
class SapRoomInRoof: class SapRoomInRoof:
floor_area: Union[int, float] floor_area: Union[int, float]
construction_age_band: str construction_age_band: str
# Two real-API shapes coexist: older certs (cohort 6035, 0240, test # Three real-API shapes coexist: older certs (cohort 6035, 0240, test
# fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; newer # fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; some lodge
# certs (9501) lodge the Detailed-RR block. Accept both. # 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_1: Optional[RoomInRoofType1] = None
room_in_roof_type_2: Optional[RoomInRoofType2] = None
room_in_roof_details: Optional[RoomInRoofDetails] = None room_in_roof_details: Optional[RoomInRoofDetails] = None
@ -358,6 +382,16 @@ class SapBuildingPart:
# redacts the backing insulation. Previously undeclared → dropped. # redacts the backing insulation. Previously undeclared → dropped.
wall_u_value: Optional[float] = None wall_u_value: Optional[float] = None
floor_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 @dataclass

View file

@ -202,6 +202,9 @@ class VentilationAndCooling:
# SAP 10.2 §2 (17a) AP4 reading from §12.2 "Pressure Test Result # SAP 10.2 §2 (17a) AP4 reading from §12.2 "Pressure Test Result
# (AP4)" — only present when `pressure_test_method == "Pulse"`. # (AP4)" — only present when `pressure_test_method == "Pulse"`.
air_permeability_ap4_m3_h_m2: Optional[float] = None 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 # Summary §12.1 "Mechanical Ventilation Type" — e.g. "Mechanical
# extract, decentralised (MEV dc)". None when `mechanical_ventilation # extract, decentralised (MEV dc)". None when `mechanical_ventilation
# is False` (no MV system). # is False` (no MV system).
@ -478,6 +481,21 @@ class ExtensionPart:
room_in_roof: Optional[RoomInRoof] = None 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 @dataclass
class ElmhurstSiteNotes: class ElmhurstSiteNotes:
surveyor_info: SurveyorInfo surveyor_info: SurveyorInfo
@ -560,3 +578,9 @@ class ElmhurstSiteNotes:
# cold loft instead of a room-in-roof). The mapper translates the # cold loft instead of a room-in-roof). The mapper translates the
# surface table into a `SapRoomInRoof` attached to the Main bp. # surface table into a `SapRoomInRoof` attached to the Main bp.
room_in_roof: Optional[RoomInRoof] = None 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 # cascade's `(18) = 0.263 × AP4^0.924 + (8)` formula; absent value
# falls through to the components-based (16) ach. # falls through to the components-based (16) ach.
ap4 = sv.air_permeability_ap4_m3_h_m2 if sv is not None else None 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 # SAP 10.2 §2 (23a)/(24a..d) — MV kind dispatch chooses the (25)m
# effective-ach formula. The Elmhurst mapper translates the lodged # effective-ach formula. The Elmhurst mapper translates the lodged
# "Mechanical Ventilation Type" string to an enum *name*; resolve # "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), 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, 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_ap4=ap4,
air_permeability_ap50=ap50,
mv_kind=mv_kind, mv_kind=mv_kind,
mv_system_ach=mv_system_ach, mv_system_ach=mv_system_ach,
**wind_kwargs, **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 typing import Final
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart 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 _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 total_storey_count = max(part_storey_counts) if part_storey_counts else 0
has_storeys = sum_per_storey_area_m2 > 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 = ( avg_height = (
sum_per_storey_volume_m3 / sum_per_storey_area_m2 sum_per_storey_volume_m3 / sum_per_storey_area_m2
if has_storeys if has_storeys
else _DEFAULT_STOREY_HEIGHT_M 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( return Dimensions(
total_floor_area_m2=( 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=( volume_m3=(
sum_per_storey_volume_m3 sum_per_storey_volume_m3 + cons_volume_m3
if has_storeys if has_storeys
else epc.total_floor_area_m2 * _DEFAULT_STOREY_HEIGHT_M 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_wall,
u_window, u_window,
) )
from domain.sap10_calculator.worksheet.conservatory import conservatory_geometry
from math import cos, floor, radians, sqrt 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. # deducts from that wall, not the main wall.
_CORRIDOR_DOOR_U_W_PER_M2K: Final[float] = 1.4 _CORRIDOR_DOOR_U_W_PER_M2K: Final[float] = 1.4
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 _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 # 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. # roof windows) — turns raw window U into the worksheet's (27) effective U.
_WINDOW_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04 _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) 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]: def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
if not part.sap_floor_dimensions: if not part.sap_floor_dimensions:
# A part with no floor dimensions has no derivable RR shell or # 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) country = Country.from_code(epc.country_code)
roof_description = _joined_main_roof_descriptions(epc.roofs) 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) wall_description = _joined_descriptions(epc.walls)
floor_description = _joined_descriptions(epc.floors) floor_description = _joined_descriptions(epc.floors)
@ -884,8 +917,19 @@ def heat_transmission_from_cert(
roof_thickness_explicitly_zero = ( roof_thickness_explicitly_zero = (
isinstance(raw_roof_thickness, int) and raw_roof_thickness == 0 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 = ( 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 # 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 # when the per-bp roof construction lodges as a flat roof and the
@ -1368,6 +1412,51 @@ def heat_transmission_from_cert(
# door line. # door line.
doors += _CORRIDOR_DOOR_U_W_PER_M2K * corridor_door_area doors += _CORRIDOR_DOOR_U_W_PER_M2K * corridor_door_area
roof_windows_w_per_k = roof_windows_w_per_k_total 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 = ( fabric_heat_loss = (
walls + roof + floor + party + windows + roof_windows_w_per_k + doors # (33) 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 typing import Final
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow 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.tables.pcdb.postcode_weather import PostcodeClimate
from domain.sap10_calculator.climate.appendix_u import ( from domain.sap10_calculator.climate.appendix_u import (
horizontal_solar_irradiance_w_per_m2, 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 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( total = _sum_tuples(
*per_orientation_summed.values(), *per_orientation_summed.values(),
roof_windows_monthly, 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) u0 = _u_stone_thin_wall_age_a_to_e(construction, wall_thickness_mm)
if u0 is not None: 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: if dry_lined:
# Round to 2 d.p. — worksheet (29a) A×U product uses # Round to 2 d.p. — worksheet (29a) A×U product uses
# the 2-d.p.-displayed U (cf. 000565 Main alt_wall_1: # 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 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: 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 # 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 # 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 ( from domain.sap10_calculator.worksheet.heat_transmission import (
_alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage] _alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage]
_joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage] _joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage]
_main_roof_descriptions_by_kind, # pyright: ignore[reportPrivateUsage]
_part_geometry, # pyright: ignore[reportPrivateUsage] _part_geometry, # pyright: ignore[reportPrivateUsage]
_round_half_up, # pyright: ignore[reportPrivateUsage] _round_half_up, # pyright: ignore[reportPrivateUsage]
_window_bp_index, # 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)" 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: def test_part_geometry_floorless_part_honours_full_key_contract() -> None:
# Arrange — a building part lodged with NO sap_floor_dimensions (e.g. # 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 # 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_case6 as _w001431_case6,
_elmhurst_worksheet_001431_case21 as _w001431_case21, _elmhurst_worksheet_001431_case21 as _w001431_case21,
_elmhurst_worksheet_001431_case43 as _w001431_case43, _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: def test_case6_main_2_emitter_and_control_extracted() -> None:
"""Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter """Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter
("Underfloor Heating") and control ("SAP code 2110, ...") the two ("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 # 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 # 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). # 6-cert detailed-common-wall cohort 2.43 -> 1.25; corpus -> 67.6% (MAE 0.979).
_MIN_WITHIN_HALF_SAP = 0.67 # The §3.9.2 Simplified Type-2 RR mapper (room_in_roof_type_2: gable quadratic +
_MAX_SAP_MAE = 0.99 # common-wall L×(0.25+H), MIRRORING the worksheet-validated Summary path,
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current # cross-mapper-parity-exact on cert 000565) -> 67.9% (MAE 0.959).
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current # 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]]: def _load_corpus() -> list[dict[str, Any]]: