From 23245a085c811b3233e4c00fe9b7fee1fcf5a890 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 27 Jun 2026 07:06:04 +0000 Subject: [PATCH 1/3] =?UTF-8?q?test(elmhurst):=20party-ceiling=20BP=20wind?= =?UTF-8?q?ow=20is=20vertical,=20not=20a=20rooflight=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _is_elmhurst_roof_window mis-routes a vertical window (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"). A party ceiling has no external roof, so it cannot host a rooflight. Surfaced by simulated case 53 (cert 000565 re-keyed as a mid-floor electric- storage flat, roof "A Another dwelling above"): its External-wall window (U 2.00) routed to sap_roof_windows -> window area not deducted from the wall (wall over-counted ~7 W/K) + priced as a roof window -> our SAP 74.0 vs Elmhurst worksheet 75. Elmhurst lodges it Type "Window", Location "External wall". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_mapper_roof_window_classification.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/datatypes/epc/domain/test_mapper_roof_window_classification.py diff --git a/tests/datatypes/epc/domain/test_mapper_roof_window_classification.py b/tests/datatypes/epc/domain/test_mapper_roof_window_classification.py new file mode 100644 index 00000000..fc0c8a85 --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_roof_window_classification.py @@ -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 From 0851b48807cddbd4515b3b4cd82883adc7b73ae3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 28 Jun 2026 21:25:20 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(elmhurst):=20party-ceiling=20Main=20BP?= =?UTF-8?q?=20window=20is=20vertical,=20not=20a=20rooflight=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _is_elmhurst_roof_window routed a vertical window (Location "External wall") to sap_roof_windows whenever its building part's roof type was a party ceiling (A "Another dwelling above" / NR "Non-residential space above"). That is right for an EXTENSION (cert 000565 Ext2/Ext4 score on worksheet (27a)) but wrong for the MAIN BP: a Main "A"/"NR" roof is the dwelling's own party ceiling — no external roof — so its windows are vertical. Gate the BP-roof-type rooflight rule to non-Main building parts. On a mid- floor flat (case 53 / cert 000565 re-keyed) the Main External-wall window is now vertical: its area deducts from the wall (was over-counted ~7 W/K) and it prices as a window. Summary-path SAP 74.0 -> 75.17, matching Elmhurst's accredited worksheet (75; space-heat 3408.6 vs 3408.58) — confirming the calculator is correct; the gap was the misclassification. 000565's extension rooflights, the "Roof of Room" branch, and the U>3.0 room-in-roof backstop are unchanged. gov-API path unaffected (it routes roof windows via window_wall_type==4). Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 46 +++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index efacc71b..e0758e3d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6229,12 +6229,16 @@ def _elmhurst_orientation_int(orientation: str) -> int: # skylight per the worksheet's (27a) row. _ELMHURST_ROOF_WINDOW_U_THRESHOLD: Final[float] = 3.0 -# RdSAP 10 §8.2 (PDF p.50) — BPs whose roof type is "Another dwelling -# above" (A) or "Non-residential space above" (NR) have their own -# external roof structure with potential rooflights, distinct from a -# pitched-roof dwelling. Windows lodged against these BPs are routed -# to `sap_roof_windows` regardless of their U-value. +# Elmhurst §8 roof types that route an EXTENSION's window to a rooflight: +# "A Another dwelling above" / "NR Non-residential space above". On an +# extension BP these score on the worksheet (27a) Roof Windows line (cert +# 000565 Ext2 [NR] + Ext4 [A]). NOT applied to the Main BP — a Main roof +# 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_MAIN_BUILDING_PARTS: Final[tuple[str, ...]] = ("Main", "Main Property") 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 matching extension).""" bp = w.building_part - if bp in ("Main", "Main Property"): + if bp in _ELMHURST_MAIN_BUILDING_PARTS: return survey.roof.roof_type for ext in survey.extensions: if ext.name == bp: @@ -6279,12 +6283,16 @@ def _is_elmhurst_roof_window( 1. Single-glazed windows are never rooflights — Part L 2006 minimum glazing for any rooflight is double-glazed. - 2. BP roof type ∈ {A, NR} → rooflight — the BP has its own - external roof structure with rooflights (worksheet (30) - External roof + (27a) Roof Windows treatment). - 3. U > 3.0 W/m²K → rooflight — cohort backstop catching old - skylights on pitched roofs (cohort cert 000516 W6). - 4. Otherwise vertical. + 2. Explicit "Roof of Room" location → rooflight. + 3. EXTENSION BP roof type ∈ {A, NR} → rooflight (worksheet (30) + External roof + (27a) Roof Windows treatment). EXCLUDES the Main + BP: a Main roof of "A"/"NR" is a party ceiling (whole dwelling + under another dwelling), so its windows are vertical — simulated + 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"): return False @@ -6293,11 +6301,15 @@ def _is_elmhurst_roof_window( # regardless of BP roof type or U-value. if "roof of room" in (w.location or "").lower(): 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 + # EXTENSION BP whose roof type is a party ceiling (A / NR) scores its + # windows on (27a) — but never the Main BP (a Main "A"/"NR" roof is the + # dwelling's own party ceiling; case 53). + if w.building_part not in _ELMHURST_MAIN_BUILDING_PARTS: + 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 # 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 From 02ef67fd8fd4824ee747511d646b85318bc96c19 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 28 Jun 2026 21:26:30 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(debug):=20summary=5Fto=5Fsap.py=20?= =?UTF-8?q?=E2=80=94=20Elmhurst=20Summary=20PDF=20->=20our=20SAP=20+=20tra?= =?UTF-8?q?il=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs a Summary PDF through the chain-test path (pdftotext -> ElmhurstSiteNotesExtractor -> from_elmhurst_site_notes) into Sap10Calculator and dumps SAP + per-end-use kWh + the intermediate worksheet trail, for diffing our calc against the accompanying Elmhurst U985 worksheet PDF. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/summary_to_sap.py | 94 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 scripts/summary_to_sap.py diff --git a/scripts/summary_to_sap.py b/scripts/summary_to_sap.py new file mode 100644 index 00000000..f8f30790 --- /dev/null +++ b/scripts/summary_to_sap.py @@ -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 "" + +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])