feat(conservatory): read §6.1 geometry through extractor + mapper

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 15:37:05 +00:00
parent 688bb4d601
commit fa131cca0b
4 changed files with 124 additions and 0 deletions

View file

@ -7,6 +7,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
BathsAndShowers,
BuildingPartDimensions,
CommunityHeating,
Conservatory,
ElmhurstSiteNotes,
ExtensionPart,
FloorDetails,
@ -30,6 +31,17 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
)
def _parse_conservatory_storeys(raw: Optional[str]) -> float:
"""Parse the §5 "Room Height" lodgement ("1 Storey", "1.5 Storey",
"1½ Storey") into the equivalent-storey count RdSAP 10 §6.1 translates
to a metre height. Defaults to 1.0 (single storey) when unparseable."""
if not raw:
return 1.0
text = raw.replace("½", ".5")
m = re.search(r"\d+(?:\.\d+)?", text)
return float(m.group(0)) if m else 1.0
def _parse_solar_pitch_deg(raw: Optional[str]) -> Optional[int]:
"""Parse the §16.0 "Collector elevation" lodgement (e.g. "30°", "60°",
or a bare integer). Returns None when absent or unparseable."""
@ -81,6 +93,13 @@ class ElmhurstSiteNotesExtractor:
except (ValueError, IndexError):
return 0
def _float_val(self, label: str) -> Optional[float]:
v = self._next_val(label)
if not v:
return None
m = re.search(r"-?\d+(?:\.\d+)?", v)
return float(m.group(0)) if m else None
def _date_val(self, label: str) -> date:
v = self._next_val(label)
if not v:
@ -179,8 +198,39 @@ class ElmhurstSiteNotesExtractor:
v = self._local_val(lines, label)
return v is not None and v.lower() == "yes"
def _local_float(self, lines: List[str], label: str) -> Optional[float]:
v = self._local_val(lines, label)
if not v:
return None
m = re.search(r"-?\d+(?:\.\d+)?", v)
return float(m.group(0)) if m else None
# --- section extractors ---
def _extract_conservatory(self) -> Optional[Conservatory]:
"""Summary §5.0 — geometry of a conservatory (RdSAP 10 §6, PDF
p.49). Returns None when none is lodged. Scoped to the §5 block
so the generic labels ("Floor Area", "Room Height") can't collide
with §4 dimensions. A separated conservatory is still returned
(with `thermally_separated=True`); the mapper drops it per §6.2."""
if not self._bool_val("Is there a conservatory?"):
return None
lines = self._section_lines_first_end(
"5.0 Conservatory", ("7.0 Walls", "6.0 ", "Summary Information"),
)
return Conservatory(
thermally_separated=self._local_bool(
lines, "Is it thermally separated?"
),
floor_area_m2=self._local_float(lines, "Floor Area [m2]") or 0.0,
double_glazed=self._local_bool(lines, "Double Glazed"),
glazed_perimeter_m=self._local_float(lines, "Glazed Perimeter [m]")
or 0.0,
room_height_storeys=_parse_conservatory_storeys(
self._local_val(lines, "Room Height")
),
)
def _extract_surveyor_info(self) -> SurveyorInfo:
return SurveyorInfo(
surveyor_code=self._str_val("Surveyor"),
@ -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(),

View file

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

View file

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

View file

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