mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge pull request #1245 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
This commit is contained in:
commit
74c1aff530
19 changed files with 1222 additions and 10 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
BIN
backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf
vendored
Normal file
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) − Σ(H−H_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×(2−1)²/2) = round(22.5−1) = 21.50
|
||||
# party gable = round(6 × (0.25+2) − 1.0) = round(13.5−1) = 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
149
domain/sap10_calculator/worksheet/conservatory.py
Normal file
149
domain/sap10_calculator/worksheet/conservatory.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""RdSAP 10 §6.1 — non-separated (heated) conservatory geometry.
|
||||
|
||||
A non-separated conservatory is treated as part of the dwelling
|
||||
(RdSAP 10 Specification, 9th June 2025, §6.1 + Table 25, pages 49-51):
|
||||
|
||||
- its floor area and volume are added to TFA (4) and volume (5);
|
||||
- its fully-glazed walls bill as a window — line (27) — at the Table 25
|
||||
"U-value of window"; its glazed roof bills as a rooflight — line (27a)
|
||||
— at the Table 25 "U-value of roof window"; both U-values already
|
||||
include the §3.2 curtain resistance (R=0.04 m²K/W);
|
||||
- its floor adds a ground-loss term — line (28a) — via BS EN ISO 13370,
|
||||
taken as an uninsulated solid floor with 300 mm walls (§5.12 note,
|
||||
spec p.43), exposed perimeter = glazed perimeter;
|
||||
- its glazed wall + glazed roof + floor areas count toward the total
|
||||
exposed area (31) and hence thermal bridging (36); the fully-glazed
|
||||
"structure" walls/roof themselves add nothing (the glazing IS the
|
||||
window/rooflight).
|
||||
|
||||
Its roof area is the floor area / cos(20°) and its wall area is the
|
||||
exposed perimeter × height; the height is translated from the lodged
|
||||
equivalent storey count (§6.1): 1 storey → ground-floor room height;
|
||||
1½ → ground + 0.25 + 0.5×first; 2 → ground + 0.25 + first; etc.
|
||||
|
||||
A SEPARATED conservatory (§6.2) is disregarded entirely — the mapper
|
||||
maps it to None, so it never reaches this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from math import cos, radians
|
||||
from typing import Final, Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
|
||||
# RdSAP 10 §6.1 — conservatory roof area = floor area / cos(20°); §6.1
|
||||
# also fixes the rooflight solar pitch at 20°.
|
||||
CONSERVATORY_ROOF_PITCH_DEG: Final[float] = 20.0
|
||||
_COS_ROOF_PITCH: Final[float] = cos(radians(CONSERVATORY_ROOF_PITCH_DEG))
|
||||
|
||||
# RdSAP 10 Table 25 (PDF p.51) — default conservatory glazing U-values
|
||||
# (W/m²K, INCLUSIVE of the §3.2 curtain resistance) and g-values. The
|
||||
# Summary lodges only double vs single (no triple), so a bool selects the
|
||||
# row: True → double (6 mm gap), False → single.
|
||||
_TABLE_25_WALL_U: Final[dict[bool, float]] = {True: 3.1, False: 4.8}
|
||||
_TABLE_25_ROOF_U: Final[dict[bool, float]] = {True: 3.4, False: 5.3}
|
||||
_TABLE_25_G_VALUE: Final[dict[bool, float]] = {True: 0.76, False: 0.85}
|
||||
_TABLE_25_FRAME_FACTOR: Final[float] = 0.70 # Table 25 — wood/PVC frame
|
||||
|
||||
# SAP 10.2 §3.2 formula (2) curtain/blind resistance. Table 25 U-values
|
||||
# are "adjusted for curtains" already, so the EFFECTIVE conduction U is
|
||||
# 1 / (1/U_table25 + 0.04) — the same transform `heat_transmission`
|
||||
# applies to regular windows/rooflights.
|
||||
_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04
|
||||
|
||||
# RdSAP 10 §5.12 (spec p.43) — a non-separated conservatory floor is an
|
||||
# uninsulated solid ground floor with 300 mm walls.
|
||||
_CONSERVATORY_WALL_THICKNESS_MM: Final[int] = 300
|
||||
_AREA_ROUND_DP: Final[int] = 2
|
||||
|
||||
|
||||
def _round2(value: float) -> float:
|
||||
"""RdSAP 10 §15 (p.66): element areas + conservatory height → 2 d.p."""
|
||||
return float(
|
||||
Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConservatoryGeometry:
|
||||
"""Derived §6.1 geometry for one non-separated conservatory. Areas and
|
||||
height are rounded to 2 d.p. per RdSAP 10 §15."""
|
||||
|
||||
height_m: float
|
||||
floor_area_m2: float
|
||||
glazed_wall_area_m2: float
|
||||
glazed_roof_area_m2: float
|
||||
glazed_perimeter_m: float
|
||||
wall_u_raw: float # Table 25 window U, pre-curtain
|
||||
roof_u_raw: float # Table 25 roof-window U, pre-curtain
|
||||
wall_u_eff: float # post-curtain conduction U for line (27)
|
||||
roof_u_eff: float # post-curtain conduction U for line (27a)
|
||||
g_value: float
|
||||
frame_factor: float
|
||||
volume_m3: float
|
||||
|
||||
|
||||
def _conservatory_height_m(epc: EpcPropertyData, storeys: float) -> float:
|
||||
"""Translate the equivalent storey count into a metre height per
|
||||
RdSAP 10 §6.1 using the dwelling's per-storey room heights:
|
||||
|
||||
1 storey → ground-floor room height
|
||||
1½ storey → ground + 0.25 + 0.5 × first-floor room height
|
||||
2 storey → ground + 0.25 + first-floor room height
|
||||
etc.
|
||||
|
||||
Room heights are taken from the Main building part's floor
|
||||
dimensions (floor 0 = ground, 1 = first, ...). Returns 0.0 when no
|
||||
storeys are lodged (defensive; the conservatory then bills no walls)."""
|
||||
parts = epc.sap_building_parts or []
|
||||
heights: list[float] = []
|
||||
if parts:
|
||||
fds = sorted(
|
||||
parts[0].sap_floor_dimensions,
|
||||
key=lambda fd: fd.floor if fd.floor is not None else 0,
|
||||
)
|
||||
heights = [fd.room_height_m for fd in fds if fd.room_height_m]
|
||||
if not heights:
|
||||
return 0.0
|
||||
n_full = int(storeys)
|
||||
height = heights[0]
|
||||
for s in range(1, n_full):
|
||||
height += 0.25 + heights[min(s, len(heights) - 1)]
|
||||
if storeys - n_full >= 0.5:
|
||||
height += 0.25 + 0.5 * heights[min(n_full, len(heights) - 1)]
|
||||
return _round2(height)
|
||||
|
||||
|
||||
def conservatory_geometry(
|
||||
epc: EpcPropertyData,
|
||||
) -> Optional[ConservatoryGeometry]:
|
||||
"""Build the §6.1 conservatory geometry, or None when there is no
|
||||
(non-separated) conservatory."""
|
||||
cons = epc.sap_conservatory
|
||||
if cons is None or cons.thermally_separated:
|
||||
return None
|
||||
height = _conservatory_height_m(epc, cons.room_height_storeys)
|
||||
floor_area = cons.floor_area_m2
|
||||
glazed_perimeter = cons.glazed_perimeter_m
|
||||
glazed_wall = _round2(glazed_perimeter * height)
|
||||
glazed_roof = _round2(floor_area / _COS_ROOF_PITCH)
|
||||
dg = cons.double_glazed
|
||||
wall_u_raw = _TABLE_25_WALL_U[dg]
|
||||
roof_u_raw = _TABLE_25_ROOF_U[dg]
|
||||
return ConservatoryGeometry(
|
||||
height_m=height,
|
||||
floor_area_m2=floor_area,
|
||||
glazed_wall_area_m2=glazed_wall,
|
||||
glazed_roof_area_m2=glazed_roof,
|
||||
glazed_perimeter_m=glazed_perimeter,
|
||||
wall_u_raw=wall_u_raw,
|
||||
roof_u_raw=roof_u_raw,
|
||||
wall_u_eff=1.0 / (1.0 / wall_u_raw + _CURTAIN_RESISTANCE_M2K_PER_W),
|
||||
roof_u_eff=1.0 / (1.0 / roof_u_raw + _CURTAIN_RESISTANCE_M2K_PER_W),
|
||||
g_value=_TABLE_25_G_VALUE[dg],
|
||||
frame_factor=_TABLE_25_FRAME_FACTOR,
|
||||
volume_m3=floor_area * height,
|
||||
)
|
||||
|
|
@ -21,6 +21,7 @@ from dataclasses import dataclass
|
|||
from typing import Final
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart
|
||||
from domain.sap10_calculator.worksheet.conservatory import conservatory_geometry
|
||||
|
||||
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
|
||||
|
||||
|
|
@ -145,17 +146,28 @@ def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions:
|
|||
total_storey_count = max(part_storey_counts) if part_storey_counts else 0
|
||||
|
||||
has_storeys = sum_per_storey_area_m2 > 0
|
||||
# `avg_height` (used by §2 (9) dwelling height → infiltration) is a
|
||||
# property of the dwelling's storeys, so the conservatory is excluded
|
||||
# from it. The conservatory IS added to TFA (4) and volume (5) per
|
||||
# RdSAP 10 §6.1 ("The floor area and volume of a non-separated
|
||||
# conservatory are added to the total floor area and volume of the
|
||||
# dwelling") — it just doesn't form a storey.
|
||||
avg_height = (
|
||||
sum_per_storey_volume_m3 / sum_per_storey_area_m2
|
||||
if has_storeys
|
||||
else _DEFAULT_STOREY_HEIGHT_M
|
||||
)
|
||||
cons = conservatory_geometry(epc)
|
||||
cons_floor_area_m2 = cons.floor_area_m2 if cons is not None else 0.0
|
||||
cons_volume_m3 = cons.volume_m3 if cons is not None else 0.0
|
||||
return Dimensions(
|
||||
total_floor_area_m2=(
|
||||
sum_per_storey_area_m2 if has_storeys else epc.total_floor_area_m2
|
||||
sum_per_storey_area_m2 + cons_floor_area_m2
|
||||
if has_storeys
|
||||
else epc.total_floor_area_m2
|
||||
),
|
||||
volume_m3=(
|
||||
sum_per_storey_volume_m3
|
||||
sum_per_storey_volume_m3 + cons_volume_m3
|
||||
if has_storeys
|
||||
else epc.total_floor_area_m2 * _DEFAULT_STOREY_HEIGHT_M
|
||||
),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ from domain.sap10_ml.rdsap_uvalues import (
|
|||
u_wall,
|
||||
u_window,
|
||||
)
|
||||
from domain.sap10_calculator.worksheet.conservatory import conservatory_geometry
|
||||
from math import cos, floor, radians, sqrt
|
||||
|
||||
|
||||
|
|
@ -123,6 +124,9 @@ _DEFAULT_DOOR_AREA_M2: Final[float] = 1.85
|
|||
# deducts from that wall, not the main wall.
|
||||
_CORRIDOR_DOOR_U_W_PER_M2K: Final[float] = 1.4
|
||||
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
|
||||
# RdSAP 10 §5.12 (spec p.43) — a non-separated conservatory floor is an
|
||||
# uninsulated solid ground floor with 300 mm walls.
|
||||
_CONSERVATORY_WALL_THICKNESS_MM: Final[int] = 300
|
||||
# SAP10.2 §3.2 curtain/blind thermal resistance applied to windows (and
|
||||
# roof windows) — turns raw window U into the worksheet's (27) effective U.
|
||||
_WINDOW_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 m², Glazed Perimeter 9.00 m,
|
||||
Double Glazed Yes, thermally separated No, Room Height 1 Storey. From that
|
||||
the §6.1 cascade derives (all verified against the P960 §3 to 1e-4):
|
||||
|
||||
- conservatory height = ground-floor room height = 2.60 m (1 storey);
|
||||
- glazed WALL → window (27): A = perimeter × height = 9.0 × 2.60 = 23.40,
|
||||
U = 1/(1/3.1 + 0.04) = 2.758 (Table 25 double 3.1 + §3.2 curtain);
|
||||
- glazed ROOF → rooflight (27a): A = floor_area / cos(20°) = 12.77,
|
||||
U = 1/(1/3.4 + 0.04) = 2.993 (Table 25 roof 3.4 + curtain);
|
||||
- FLOOR → ground floor (28a): A = 12.00, U = 0.89 via BS EN ISO 13370
|
||||
(uninsulated solid, 300 mm walls, P = glazed perimeter 9.0);
|
||||
- the fully-glazed structure walls/roof bill at U=0 (the glazing IS the
|
||||
window/rooflight) — they contribute nothing but DO count their glazed
|
||||
area toward (31)/(36);
|
||||
- TFA (4) += 12.00 → 95.38; volume (5) += 12.00 × 2.60 = 31.20 → 257.16.
|
||||
|
||||
Like the other `_elmhurst_worksheet_001431_case*` fixtures this does NOT
|
||||
hand-build the EpcPropertyData: it routes the Summary PDF through
|
||||
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises
|
||||
the WHOLE extractor + mapper + calculator pipeline.
|
||||
|
||||
Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/
|
||||
simulated case 44/`. The Summary is mirrored into the tracked
|
||||
`backend/documents_parser/tests/fixtures/Summary_001431_case44.pdf` so the
|
||||
test runs without depending on the unstaged workspace.
|
||||
|
||||
Worksheet pin targets (P960-0001-001431, "11a. SAP rating" UK-average
|
||||
rating block our cascade reproduces):
|
||||
- (4) TFA, m² = 95.3800
|
||||
- (5) Dwelling volume, m³ = 257.1630
|
||||
- (27) Windows (31.5795 main + 64.5374 cons) = 96.1169
|
||||
- (27a) Roof windows (conservatory glazed roof) = 38.2201
|
||||
- (28a) Ground floor (10.7364 main + 10.6800) = 21.4164
|
||||
- (29a) External walls = 35.5852
|
||||
- (30) External roof = 7.4688
|
||||
- (31) Total net area of external elements = 294.2900
|
||||
- (33) Fabric heat loss, W/K = 207.3274
|
||||
- (36) Thermal bridges (0.080 × (31)) = 23.5432
|
||||
|
||||
Per [[feedback-zero-error-strict]]: pins are abs <= 1e-4 against the PDF.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
|
||||
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
|
||||
# [4]=repo root.
|
||||
_SUMMARY_PDF: Final[Path] = (
|
||||
Path(__file__).resolve().parents[4]
|
||||
/ "backend" / "documents_parser" / "tests" / "fixtures"
|
||||
/ "Summary_001431_case44.pdf"
|
||||
)
|
||||
|
||||
LINE_4_TFA_M2: Final[float] = 95.3800
|
||||
LINE_5_VOLUME_M3: Final[float] = 257.1630
|
||||
LINE_27_WINDOWS_W_PER_K: Final[float] = 96.1169
|
||||
LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 38.2201
|
||||
LINE_28A_FLOOR_W_PER_K: Final[float] = 21.4164
|
||||
LINE_29A_WALLS_W_PER_K: Final[float] = 35.5852
|
||||
LINE_30_ROOF_W_PER_K: Final[float] = 7.4688
|
||||
LINE_31_EXTERNAL_AREA_M2: Final[float] = 294.2900
|
||||
LINE_33_FABRIC_W_PER_K: Final[float] = 207.3274
|
||||
LINE_36_THERMAL_BRIDGING_W_PER_K: Final[float] = 23.5432
|
||||
|
||||
# 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue