From fa131cca0b7650e19f823a6e548c8652493c32aa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 15:37:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(conservatory):=20read=20=C2=A76.1=20geomet?= =?UTF-8?q?ry=20through=20extractor=20+=20mapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §6.1 (PDF p.49) models a non-separated (heated) conservatory as part of the dwelling. Until now the Summary §5 block was reduced to an inert `has_conservatory` bool and the geometry (floor area, glazed perimeter, glazing, storey height) was dropped on both paths. Plumbing only — no cascade consumer yet (Slices B/C/D wire §3/§6): - ElmhurstSiteNotesExtractor reads the §5 Conservatory block into a new `Conservatory` site-notes record (scoped to §5 so the generic "Floor Area"/"Room Height" labels can't collide with §4 dimensions); - domain gains a frozen `SapConservatory` (floor area, glazed perimeter, double/single glazing, thermally-separated guard, equivalent storey count) on `EpcPropertyData.sap_conservatory`; - the Elmhurst mapper threads it through, dropping SEPARATED conservatories per §6.2 ("A separated conservatory ... is disregarded"). Verified against the simulated case-44 Summary (RefNo 001431): extracts floor_area=12.0, glazed_perimeter=9.0, double_glazed=True, 1 storey. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 51 +++++++++++++++++++ datatypes/epc/domain/epc_property_data.py | 31 +++++++++++ datatypes/epc/domain/mapper.py | 21 ++++++++ datatypes/epc/surveys/elmhurst_site_notes.py | 21 ++++++++ 4 files changed, 124 insertions(+) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index a8c9e596..6f7e4936 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -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"), @@ -1820,6 +1870,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(), diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index baf1db00..c12d87f0 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -261,6 +261,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 +801,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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b79b51d1..acd24efd 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -24,6 +24,7 @@ from datatypes.epc.domain.epc_property_data import ( SapEnergySource, SapFlatDetails, SapFloorDimension, + SapConservatory, SapHeating, SapRoofWindow, SapRoomInRoof, @@ -68,6 +69,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, @@ -420,6 +422,7 @@ class EpcPropertyDataMapper: 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, @@ -5226,6 +5229,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, diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 4614d33c..02f27849 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -478,6 +478,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 +575,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