mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
688bb4d601
commit
fa131cca0b
4 changed files with 124 additions and 0 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"),
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue