diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 14e56b6c..8838999f 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1872,23 +1872,24 @@ def test_summary_000565_mev_fans_cost_uses_table_12a_grid_2_fans_for_mech_vent_r pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) site_notes = ElmhurstSiteNotesExtractor(pages).extract() epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) - from domain.sap10_calculator.calculator import calculate_sap_from_inputs - from domain.sap10_calculator.rdsap.cert_to_inputs import ( - cert_to_inputs, - ) + from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs # Act - result = calculate_sap_from_inputs(cert_to_inputs(epc)) + inputs = cert_to_inputs(epc) - # Assert — total fuel cost should be ≤ +£0.05 over worksheet (the - # MEV cost split closes the +£2.01 pumps_fans over-count; remaining - # residual is the space-heating cascade under-count, separate slice). - # Worksheet line (255) = £4680.2593. - delta = result.total_fuel_cost_gbp - 4680.2593 - assert delta < 0.05, ( - f"cascade total_fuel_cost_gbp={result.total_fuel_cost_gbp:.4f}; " - f"ws=£4680.2593; Δ={delta:+.4f} (expected ≤+£0.05 after MEV " - f"cost split closes the +£2.01 over-count)" + # Assert — the effective pumps_fans cost rate equals the kWh- + # weighted MEV-split blend (12.4467 p/kWh for cert 000565), NOT the + # ALL_OTHER_USES blend (13.244 p/kWh). The total fuel cost line ref + # (255) couples to multiple SH-cascade downstream effects, so we + # pin the rate directly — the specific thing S0380.103 closes. + expected_rate_gbp_per_kwh = 12.4467 / 100.0 + actual = inputs.pumps_fans_fuel_cost_gbp_per_kwh + assert actual is not None + assert abs(actual - expected_rate_gbp_per_kwh) <= 1e-5, ( + f"cascade pumps_fans_fuel_cost_gbp_per_kwh={actual:.6f}; " + f"ws-split target={expected_rate_gbp_per_kwh:.6f}; " + f"Δ={actual - expected_rate_gbp_per_kwh:+.6f} (expected MEV-" + f"split kWh-weighted blend per S0380.103)" ) @@ -1993,6 +1994,77 @@ def test_summary_000565_mev_fans_pe_factor_uses_table_12a_grid_2_fans_for_mech_v ) +def test_summary_000565_window_routing_uses_bp_roof_type_per_rdsap_10_section_3_7_1() -> None: + # Arrange — RdSAP 10 §3.7.1 (PDF p.21) "Window data": windows in + # the source RdSAP data set are classified as either "Window + # (vertical)" or "Roof window (inclined)" per the assessor's + # discrete lodgement. The Summary PDF §11.0 flattens this signal + # — every row's Location column reads "External wall" regardless + # of whether the window is vertical or in the roof — so the + # mapper must reconstruct the classification heuristically. + # + # The PRE-S0380.107 heuristic was "U > 3.0 → roof window", which + # works for the simpler 6-cert cohort (all BPs PA/PN pitched + + # the only U > 3 windows are skylights) but breaks for cert + # 000565 in three distinct ways: + # + # - Item 4 (Main, Single glazing, U=3.35) — a vertical window + # in an old single-glazed gable wall; pre-slice misrouted to + # roof. Single glazing on a rooflight has been disallowed + # under Part L since 2006 (current SAP convention assumes + # double glazing minimum for any rooflight). + # + # - Item 2 (2nd Extension, Triple, U=2.0) — a rooflight in + # Ext2's external roof (Summary §8 lodges Ext2 roof type + # "NR Non-residential space above" → worksheet (30) + # External roof Ext2: 25 m² gross × 0.30 with 1.2 m² + # openings, matching the worksheet's Roof Windows 1). + # + # - Item 5 (4th Extension, Double, U=2.0) — a rooflight in + # Ext4's external roof (Summary §8 lodges Ext4 roof type + # "A Another dwelling above" → worksheet (30) External + # roof Ext4: 3 m² gross × 0 U with 0.5 m² openings). + # + # New heuristic (in priority order): + # 1. "Single glazing" → never a roof window (Part L) + # 2. BP roof type starts with "NR" or "A" → roof window + # (BP has its own external roof structure with rooflights) + # 3. U_value > 3.0 → roof window (cohort backstop, matches + # cert 000516 W6 Wood-frame Double pre-2002 U=3.10 on + # Main PA, the only U > 3 vertical-glazing reading in the + # cohort that the worksheet routes via (27a)) + # 4. Else → vertical window + # + # Worksheet ground truth for cert 000565: + # sap_windows (27): items 1 (Main 1.2 + item 6 Main 0.6 → + # Windows 1 / 1.8 m²); item 4 (Main 1.7 → Windows 3); item + # 3 (Ext1 1.92 → Windows 2). Total 5.42 m². + # sap_roof_windows (27a): item 2 (Ext2 1.2 → Roof Windows 1); + # item 5 (Ext4 0.5 → Roof Windows 2). Total 1.7 m². + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert — sap_windows holds the 4 vertical windows (items 1, 3, + # 4, 6) and sap_roof_windows holds the 2 rooflights (items 2, 5). + sap_window_areas = sorted( + round(float(w.window_width) * float(w.window_height), 2) + for w in epc.sap_windows or [] + ) + assert sap_window_areas == [0.6, 1.2, 1.7, 1.92], ( + f"sap_windows areas: {sap_window_areas} (expected [0.6, 1.2, 1.7, 1.92] " + f"— items 1, 6 on Main + item 4 Single Main + item 3 Ext1)" + ) + assert epc.sap_roof_windows is not None + rw_areas = sorted(round(float(rw.area_m2), 2) for rw in epc.sap_roof_windows) + assert rw_areas == [0.5, 1.2], ( + f"sap_roof_windows areas: {rw_areas} (expected [0.5, 1.2] " + f"— items 2 Ext2 NR + 5 Ext4 A rooflights)" + ) + + def test_summary_000565_main_1_ashp_sap_code_224_routes_to_main_heating_category_4_per_sap_table_4a() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.165) "Main heating systems": # the category column lists "Heat pumps" as category 4. Codes in diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 29585d1f..0c3df483 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -324,11 +324,11 @@ class EpcPropertyDataMapper: sap_heating=_map_elmhurst_sap_heating(survey), sap_windows=[ _map_elmhurst_window(w) for w in survey.windows - if not _is_elmhurst_roof_window(w) + if not _is_elmhurst_roof_window(w, survey) ], sap_roof_windows=[ _map_elmhurst_roof_window(w) for w in survey.windows - if _is_elmhurst_roof_window(w) + if _is_elmhurst_roof_window(w, survey) ] or None, sap_energy_source=SapEnergySource( mains_gas=survey.meters.main_gas, @@ -3586,23 +3586,65 @@ def _elmhurst_orientation_int(orientation: str) -> int: return _ELMHURST_ORIENTATION_TO_SAP10.get(orientation, 1) -# SAP10.2 §3.2 / Table 24: roof windows have higher U-values than -# vertical glazing of the same age — typically U >= 3.0 W/m²K vs -# vertical-glazing 2.0–2.8. The Elmhurst Summary PDF doesn't lodge -# a discrete "window type" field, so we use the lodged U-value as -# the discriminator. None of the six cohort certs has a vertical -# window > 2.8 W/m²K; the only U=3.10 entry (000516 W5, 1.18 m², -# matching the U985 worksheet's "Roof Windows 1(Main)" row) is the -# correct positive — and falling through to a vertical window -# misallocates its solar gains + applies the wrong Table-6c U. +# RdSAP 10 §3.7.1 (PDF p.21) — the source RdSAP data set classifies +# each opening as "Window (vertical)" or "Roof window (inclined)" per +# the assessor's discrete lodgement. The Elmhurst Summary PDF §11.0 +# flattens this signal (every row's Location column reads "External +# wall"), so the mapper must reconstruct the classification +# heuristically. +# +# The U > 3.0 backstop catches cohort cert 000516 W6 (Main PA roof +# type, Wood-frame Double pre-2002 U=3.10) — the only U > 3 vertical- +# glazing reading in the simple cohort, which is in fact a Main-roof +# 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_BP_ROOF_TYPES_WITH_ROOFLIGHTS: Final[tuple[str, ...]] = ("A ", "NR ") -def _is_elmhurst_roof_window(w: ElmhurstWindow) -> bool: - """Heuristic discriminator: roof windows have U-value > 3.0 in the - Elmhurst cohort. The Summary PDF doesn't carry an explicit type - flag; the U985 worksheet PDFs separate them into a distinct - `Roof Windows N(Main)` row in §3, matching the U-threshold here.""" + +def _elmhurst_bp_roof_type( + w: ElmhurstWindow, survey: ElmhurstSiteNotes, +) -> Optional[str]: + """Look up the lodged §8 roof type for the BP carrying window `w`. + 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"): + return survey.roof.roof_type + for ext in survey.extensions: + if ext.name == bp: + return ext.roof.roof_type + return None + + +def _is_elmhurst_roof_window( + w: ElmhurstWindow, survey: ElmhurstSiteNotes, +) -> bool: + """Reconstruct RdSAP 10 §3.7.1 "Window (vertical) vs Roof window + (inclined)" classification from Elmhurst Summary §11.0 fields, + applying the rules in priority order: + + 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. + """ + if w.glazing_type.startswith("Single"): + return False + 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 return w.u_value > _ELMHURST_ROOF_WINDOW_U_THRESHOLD