mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge pull request #1346 from Hestia-Homes/fix/elmhurst-summary-roof-window-classification
fix(elmhurst): party-ceiling Main BP window is vertical, not a rooflight
This commit is contained in:
commit
d55f520f83
3 changed files with 212 additions and 17 deletions
|
|
@ -6229,12 +6229,16 @@ def _elmhurst_orientation_int(orientation: str) -> int:
|
||||||
# skylight per the worksheet's (27a) row.
|
# skylight per the worksheet's (27a) row.
|
||||||
_ELMHURST_ROOF_WINDOW_U_THRESHOLD: Final[float] = 3.0
|
_ELMHURST_ROOF_WINDOW_U_THRESHOLD: Final[float] = 3.0
|
||||||
|
|
||||||
# RdSAP 10 §8.2 (PDF p.50) — BPs whose roof type is "Another dwelling
|
# Elmhurst §8 roof types that route an EXTENSION's window to a rooflight:
|
||||||
# above" (A) or "Non-residential space above" (NR) have their own
|
# "A Another dwelling above" / "NR Non-residential space above". On an
|
||||||
# external roof structure with potential rooflights, distinct from a
|
# extension BP these score on the worksheet (27a) Roof Windows line (cert
|
||||||
# pitched-roof dwelling. Windows lodged against these BPs are routed
|
# 000565 Ext2 [NR] + Ext4 [A]). NOT applied to the Main BP — a Main roof
|
||||||
# to `sap_roof_windows` regardless of their U-value.
|
# of "A"/"NR" means the whole dwelling sits under another dwelling (a party
|
||||||
|
# ceiling with no external roof), so its windows are vertical (simulated
|
||||||
|
# case 53 / cert 000565 re-keyed as a mid-floor flat: Main roof "A",
|
||||||
|
# External-wall window U 2.0 = vertical, Elmhurst Type "Window").
|
||||||
_ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS: Final[tuple[str, ...]] = ("A ", "NR ")
|
_ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS: Final[tuple[str, ...]] = ("A ", "NR ")
|
||||||
|
_ELMHURST_MAIN_BUILDING_PARTS: Final[tuple[str, ...]] = ("Main", "Main Property")
|
||||||
|
|
||||||
|
|
||||||
def _elmhurst_bp_roof_type(
|
def _elmhurst_bp_roof_type(
|
||||||
|
|
@ -6245,7 +6249,7 @@ def _elmhurst_bp_roof_type(
|
||||||
Returns None when the BP isn't found (single-bp cert with no
|
Returns None when the BP isn't found (single-bp cert with no
|
||||||
matching extension)."""
|
matching extension)."""
|
||||||
bp = w.building_part
|
bp = w.building_part
|
||||||
if bp in ("Main", "Main Property"):
|
if bp in _ELMHURST_MAIN_BUILDING_PARTS:
|
||||||
return survey.roof.roof_type
|
return survey.roof.roof_type
|
||||||
for ext in survey.extensions:
|
for ext in survey.extensions:
|
||||||
if ext.name == bp:
|
if ext.name == bp:
|
||||||
|
|
@ -6279,12 +6283,16 @@ def _is_elmhurst_roof_window(
|
||||||
|
|
||||||
1. Single-glazed windows are never rooflights — Part L 2006
|
1. Single-glazed windows are never rooflights — Part L 2006
|
||||||
minimum glazing for any rooflight is double-glazed.
|
minimum glazing for any rooflight is double-glazed.
|
||||||
2. BP roof type ∈ {A, NR} → rooflight — the BP has its own
|
2. Explicit "Roof of Room" location → rooflight.
|
||||||
external roof structure with rooflights (worksheet (30)
|
3. EXTENSION BP roof type ∈ {A, NR} → rooflight (worksheet (30)
|
||||||
External roof + (27a) Roof Windows treatment).
|
External roof + (27a) Roof Windows treatment). EXCLUDES the Main
|
||||||
3. U > 3.0 W/m²K → rooflight — cohort backstop catching old
|
BP: a Main roof of "A"/"NR" is a party ceiling (whole dwelling
|
||||||
skylights on pitched roofs (cohort cert 000516 W6).
|
under another dwelling), so its windows are vertical — simulated
|
||||||
4. Otherwise vertical.
|
case 53 / cert 000565 mid-floor flat (Main roof "A", External-wall
|
||||||
|
window U 2.0, Elmhurst Type "Window").
|
||||||
|
4. U > 3.0 W/m²K AND the BP has a room-in-roof → rooflight — cohort
|
||||||
|
backstop catching old skylights on a sloping ceiling (cert 000516 W6).
|
||||||
|
5. Otherwise vertical.
|
||||||
"""
|
"""
|
||||||
if w.glazing_type.startswith("Single"):
|
if w.glazing_type.startswith("Single"):
|
||||||
return False
|
return False
|
||||||
|
|
@ -6293,11 +6301,15 @@ def _is_elmhurst_roof_window(
|
||||||
# regardless of BP roof type or U-value.
|
# regardless of BP roof type or U-value.
|
||||||
if "roof of room" in (w.location or "").lower():
|
if "roof of room" in (w.location or "").lower():
|
||||||
return True
|
return True
|
||||||
bp_roof_type = _elmhurst_bp_roof_type(w, survey)
|
# EXTENSION BP whose roof type is a party ceiling (A / NR) scores its
|
||||||
if bp_roof_type is not None and bp_roof_type.startswith(
|
# windows on (27a) — but never the Main BP (a Main "A"/"NR" roof is the
|
||||||
_ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS
|
# dwelling's own party ceiling; case 53).
|
||||||
):
|
if w.building_part not in _ELMHURST_MAIN_BUILDING_PARTS:
|
||||||
return True
|
bp_roof_type = _elmhurst_bp_roof_type(w, survey)
|
||||||
|
if bp_roof_type is not None and bp_roof_type.startswith(
|
||||||
|
_ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS
|
||||||
|
):
|
||||||
|
return True
|
||||||
# U > 3.0 backstop, gated on the BP having a room-in-roof. Elmhurst routes a
|
# U > 3.0 backstop, gated on the BP having a room-in-roof. Elmhurst routes a
|
||||||
# high-U "Double pre 2002" unit through the worksheet's (27a) Roof Windows
|
# high-U "Double pre 2002" unit through the worksheet's (27a) Roof Windows
|
||||||
# line regardless of its lodged "External wall" location — but ONLY where the
|
# line regardless of its lodged "External wall" location — but ONLY where the
|
||||||
|
|
|
||||||
94
scripts/summary_to_sap.py
Normal file
94
scripts/summary_to_sap.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Elmhurst Summary PDF -> EpcPropertyData -> Sap10Calculator, with a dump of
|
||||||
|
our SAP score + per-end-use kWh + the `intermediate` worksheet trail, for
|
||||||
|
diffing against the accompanying Elmhurst worksheet PDF.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m scripts.summary_to_sap "<path to Summary_*.pdf>"
|
||||||
|
|
||||||
|
Reuses the exact preprocessing the Summary->EpcPropertyData chain test uses
|
||||||
|
(`backend/documents_parser/tests/test_summary_pdf_mapper_chain.py`):
|
||||||
|
`pdftotext -layout` -> Textract-style label/value stream -> extractor ->
|
||||||
|
`from_elmhurst_site_notes` mapper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||||
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||||
|
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||||
|
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
|
||||||
|
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
|
||||||
|
tokens.extend(p for p in re.split(r"\s{2,}", line.strip()) if p)
|
||||||
|
pages.append("\n".join(tokens))
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def main(pdf: str) -> None:
|
||||||
|
pdf_path = Path(pdf)
|
||||||
|
pages = _summary_pdf_to_textract_style_pages(pdf_path)
|
||||||
|
survey = ElmhurstSiteNotesExtractor(pages).extract()
|
||||||
|
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(survey)
|
||||||
|
inp = cert_to_inputs(epc)
|
||||||
|
r = Sap10Calculator().calculate(epc)
|
||||||
|
p = epc.sap_building_parts[0] if epc.sap_building_parts else None
|
||||||
|
|
||||||
|
print(f"=== {pdf_path.name} ===")
|
||||||
|
print(f"dwelling_type={epc.dwelling_type!r} property_type={epc.property_type!r} "
|
||||||
|
f"age_band={p.construction_age_band if p else None} TFA={epc.total_floor_area_m2}")
|
||||||
|
print(f"OUR SAP = {r.sap_score} ({r.sap_score_continuous:.4f}) "
|
||||||
|
f"CO2={r.co2_kg_per_yr/1000:.3f} t/yr PEUI={r.primary_energy_kwh_per_m2:.1f}")
|
||||||
|
print("--- per end use (kWh/yr) ---")
|
||||||
|
print(f" space_heating useful = {r.space_heating_kwh_per_yr:.1f}")
|
||||||
|
print(f" main_heating fuel = {r.main_heating_fuel_kwh_per_yr:.1f}")
|
||||||
|
print(f" secondary fuel = {r.secondary_heating_fuel_kwh_per_yr:.1f}")
|
||||||
|
print(f" hot_water = {r.hot_water_kwh_per_yr:.1f}")
|
||||||
|
print(f" lighting = {r.lighting_kwh_per_yr:.1f}")
|
||||||
|
print(f" pumps_fans = {r.pumps_fans_kwh_per_yr:.1f}")
|
||||||
|
print(f" delivered fuel total = {r.intermediate.get('delivered_fuel_kwh_per_yr', float('nan')):.1f}")
|
||||||
|
print("--- costs / rating ---")
|
||||||
|
for k in ("main_heating_cost_gbp", "secondary_heating_cost_gbp", "hot_water_cost_gbp",
|
||||||
|
"pumps_fans_cost_gbp", "lighting_cost_gbp", "ecf"):
|
||||||
|
print(f" {k:28s} {r.intermediate.get(k, float('nan')):.4f}")
|
||||||
|
print(f" is_off_peak={r.is_off_peak_meter} main_hrf={r.main_heating_high_rate_fraction} "
|
||||||
|
f"hw_hrf={r.hot_water_high_rate_fraction:.4f} other_hrf={r.other_electricity_high_rate_fraction}")
|
||||||
|
print(f" space £/kWh={inp.space_heating_fuel_cost_gbp_per_kwh} "
|
||||||
|
f"hw £/kWh={inp.hot_water_fuel_cost_gbp_per_kwh} other £/kWh={inp.other_fuel_cost_gbp_per_kwh}")
|
||||||
|
print("--- heat balance (intermediate) ---")
|
||||||
|
for k in ("heat_transfer_coefficient_w_per_k", "heat_loss_parameter_w_per_m2k",
|
||||||
|
"walls_w_per_k", "roof_w_per_k", "floor_w_per_k", "party_walls_w_per_k",
|
||||||
|
"windows_w_per_k", "doors_w_per_k", "thermal_bridging_w_per_k",
|
||||||
|
"infiltration_w_per_k", "infiltration_ach", "internal_gains_annual_avg_w",
|
||||||
|
"mean_internal_temp_annual_avg_c", "useful_space_heating_kwh_per_yr"):
|
||||||
|
print(f" {k:38s} {r.intermediate.get(k, float('nan')):.4f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(2)
|
||||||
|
main(sys.argv[1])
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Mapper boundary: Elmhurst §11 window vs roof window classification.
|
||||||
|
|
||||||
|
`_is_elmhurst_roof_window` must NOT route a vertical window (lodged Location
|
||||||
|
"External wall") to `sap_roof_windows` when the building part's roof is a
|
||||||
|
PARTY ceiling — "A Another dwelling above" / "NR Non-residential space above"
|
||||||
|
/ "S Same dwelling above". A party ceiling has no external roof structure, so
|
||||||
|
it cannot host a rooflight (RdSAP 10 §3.7.1: a rooflight is glazing in a roof;
|
||||||
|
party codes carry no heat-loss roof per `_ELMHURST_PARTY_ROOF_CODES`).
|
||||||
|
|
||||||
|
Surfaced by "simulated case 53" (cert 000565 re-keyed as a mid-floor electric-
|
||||||
|
storage flat, roof "A Another dwelling above"): its one External-wall window
|
||||||
|
(U 2.00) was mis-routed to `sap_roof_windows`, so the window area was never
|
||||||
|
deducted from the wall (wall over-counted ~7 W/K) and it was priced as a roof
|
||||||
|
window — our SAP 74.0 vs Elmhurst worksheet 75. Elmhurst's own entry lodges it
|
||||||
|
Type "Window", Location "External wall".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datatypes.epc.surveys.elmhurst_site_notes import (
|
||||||
|
RoofDetails,
|
||||||
|
Window as ElmhurstWindow,
|
||||||
|
)
|
||||||
|
from datatypes.epc.surveys.elmhurst_site_notes import ElmhurstSiteNotes
|
||||||
|
from datatypes.epc.domain.mapper import (
|
||||||
|
_is_elmhurst_roof_window, # pyright: ignore[reportPrivateUsage]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _external_wall_window(*, glazing: str, u_value: float) -> ElmhurstWindow:
|
||||||
|
return ElmhurstWindow(
|
||||||
|
width_m=15.14,
|
||||||
|
height_m=1.0,
|
||||||
|
area_m2=15.14,
|
||||||
|
glazing_type=glazing,
|
||||||
|
frame_factor=0.7,
|
||||||
|
building_part="Main",
|
||||||
|
location="External wall",
|
||||||
|
orientation="North",
|
||||||
|
data_source="Manufacturer",
|
||||||
|
u_value=u_value,
|
||||||
|
g_value=0.72,
|
||||||
|
draught_proofed=True,
|
||||||
|
permanent_shutters="None",
|
||||||
|
frame_type="PVC",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _survey_with_main_roof(roof_type: str) -> ElmhurstSiteNotes:
|
||||||
|
"""Partial `ElmhurstSiteNotes` carrying only what the roof-window
|
||||||
|
classifier reads: `roof`, `room_in_roof`, `extensions`."""
|
||||||
|
survey = object.__new__(ElmhurstSiteNotes)
|
||||||
|
survey.roof = RoofDetails(
|
||||||
|
roof_type=roof_type, insulation="", u_value_known=False
|
||||||
|
)
|
||||||
|
survey.room_in_roof = None
|
||||||
|
survey.extensions = []
|
||||||
|
return survey
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_wall_window_on_party_ceiling_is_vertical() -> None:
|
||||||
|
# Arrange — mid-floor flat: Main BP roof is "Another dwelling above"
|
||||||
|
# (party ceiling, no external roof). Window lodged on External wall.
|
||||||
|
window = _external_wall_window(
|
||||||
|
glazing="Double between 2002 and 2021", u_value=2.0
|
||||||
|
)
|
||||||
|
survey = _survey_with_main_roof("A Another dwelling above")
|
||||||
|
|
||||||
|
# Act / Assert — vertical, not a rooflight.
|
||||||
|
assert _is_elmhurst_roof_window(window, survey) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_wall_window_under_non_residential_space_is_vertical() -> None:
|
||||||
|
window = _external_wall_window(
|
||||||
|
glazing="Double between 2002 and 2021", u_value=2.0
|
||||||
|
)
|
||||||
|
survey = _survey_with_main_roof("NR Non-residential space above")
|
||||||
|
|
||||||
|
assert _is_elmhurst_roof_window(window, survey) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_roof_of_room_location_still_classified_as_rooflight() -> None:
|
||||||
|
# Guard: an explicit "Roof of Room" location stays a rooflight
|
||||||
|
# (unaffected by the party-ceiling fix).
|
||||||
|
window = _external_wall_window(
|
||||||
|
glazing="Double between 2002 and 2021", u_value=2.0
|
||||||
|
)
|
||||||
|
window.location = "Roof of Room"
|
||||||
|
survey = _survey_with_main_roof("A Another dwelling above")
|
||||||
|
|
||||||
|
assert _is_elmhurst_roof_window(window, survey) is True
|
||||||
Loading…
Add table
Reference in a new issue