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 7db3d6f1..6bcc0a2e 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -2265,6 +2265,91 @@ def test_summary_000565_mev_decentralised_routes_to_extract_or_piv_outside_mv_ki assert epc.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE" +def test_summary_000565_rooflight_per_window_g_l_routes_via_glazing_type_per_sap_10_2_appendix_l_l2a() -> None: + # Arrange — SAP 10.2 Appendix L §L2a (PDF p.88) verbatim: + # + # 0.9 × Σ Aw × gL × FF × ZL + # GL = --------------------------- (L2a) + # TFA + # + # "where + # FF is the frame factor (fraction of window that is glazed) for + # the actual window or from Table 6c + # Aw is the area of a window, m² + # TFA is the total floor area of the dwelling, m² + # gL is the light transmittance factor from Table 6b + # ZL is the light access factor from Table 6d" + # + # Table 6b gL by glazing type (PDF p.178): + # Single glazed 0.90 + # Double glazed (any variant) 0.80 + # Triple glazed (any variant) 0.70 + # + # Table 6d note 2 (PDF p.178): "A solar access factor of 1.0 and a + # light access factor of 1.0 should be used for roof windows/ + # rooflights." → ZL = 1.0 for every rooflight regardless of cert + # overshading. + # + # The numerator sum is PER WINDOW — each rooflight contributes its + # own gL and FF, not a single dwelling-wide default. Pre-slice the + # cascade collapsed every rooflight into a single + # `rooflight_total_area_m2 × _G_LIGHT_DEFAULT (0.80) × _FRAME_FACTOR_ + # DEFAULT (0.70)` product, which over-counted any rooflight whose + # actual gL or FF was below the default. + # + # Cert 000565 §11 lodges 2 rooflights (per S0380.107 routing): + # Item 2 (Ext2 NR rooflight): 1.2 m², "Triple between 2002 and + # 2021", PVC frame FF=0.70 → gL=0.70 (Table 6b Triple) + # Item 5 (Ext4 A rooflight): 0.5 m², "Double between 2002 and + # 2021", Wood frame FF=0.70 → gL=0.80 (Table 6b Double) + # + # Per-rooflight L2a numerator contributions (Z_L=1.0): + # Item 2: 1.2 × 0.70 × 0.70 × 1.0 = 0.5880 + # Item 5: 0.5 × 0.80 × 0.70 × 1.0 = 0.2800 + # Sum : 0.8680 + # + # Pre-slice cascade (defaults across both): + # Sum : 1.7 × 0.80 × 0.70 × 1.0 = 0.9520 (over by +0.0840) + # + # The +0.0840 numerator delta lowers GL → lowers C_daylight (via the + # L2b convex quadratic 52.2 GL² − 9.94 GL + 1.433) → lowers + # E_L,fixed (L9a) → lowers worksheet (232). The cascade was 2.17 + # kWh/yr under the worksheet's (232) = 1384.8353 kWh/yr until this + # spec-correct fix. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + inputs = cert_to_inputs(epc) + + # Assert — sap_roof_windows lodge the lodged glazing types so the + # cascade's L2a per-rooflight gL dispatch can fire. SAP 10.2 codes: + # 9 = "Triple between 2002 and 2021"; 3 = "Double between 2002 and + # 2021" (and "Double with unknown install date" variants). + assert epc.sap_roof_windows is not None + rooflights_by_area = { + round(float(rw.area_m2), 2): rw for rw in epc.sap_roof_windows + } + assert rooflights_by_area[1.2].glazing_type == 9, ( + f"Ext2 rooflight glazing_type={rooflights_by_area[1.2].glazing_type} " + f"(expected 9 'Triple between 2002 and 2021' for gL=0.70 dispatch)" + ) + assert rooflights_by_area[0.5].glazing_type == 3, ( + f"Ext4 rooflight glazing_type={rooflights_by_area[0.5].glazing_type} " + f"(expected 3 'Double between 2002 and 2021' for gL=0.80 dispatch)" + ) + + # Assert — worksheet (232) closes to PDF lodgement at abs=1e-4 after + # the per-rooflight gL dispatch corrects the daylight factor. + assert abs(inputs.lighting_kwh_per_yr - 1384.8353) <= 1e-4, ( + f"cascade lighting_kwh_per_yr={inputs.lighting_kwh_per_yr:.4f}; " + f"ws (232)=1384.8353; Δ={inputs.lighting_kwh_per_yr - 1384.8353:+.4f} " + f"(expected within 1e-4 after L2a iterates sap_roof_windows for " + f"per-rooflight gL × FF instead of applying defaults to total area)" + ) + + def test_summary_mapper_raises_on_unmapped_wall_type_code() -> None: # Arrange — strict-coverage gate per [[reference-unmapped-api- # code]] mirror: an Elmhurst wall_type lodgement that isn't in diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index eb7c228e..91323eca 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -205,6 +205,13 @@ class SapRoofWindow: feed `solar_gains_from_cert` — defaults match the modal RdSAP roof window (45° pitch, manufacturer-default DG g⊥=0.76, PVC FF=0.70, N-facing) and are intended to be overridden per-fixture. + + `glazing_type` is the SAP 10.2 Table U2 integer code (e.g. 1=Single, + 3=Double 2002-2021, 9=Triple 2002-2021) that drives the Appendix L + §L2a daylight-factor cascade's per-rooflight g_L lookup (Table 6b + Light transmittance column). Defaults to 3 (Double 2002-2021) — the + modal cohort lodgement and the type assumed by hand-built worksheet + fixtures that pre-date this field. """ area_m2: float @@ -213,6 +220,7 @@ class SapRoofWindow: pitch_deg: float = 45.0 g_perpendicular: float = 0.76 frame_factor: float = 0.70 + glazing_type: int = 3 # SAP10.2 Table U2; 3 = Double 2002-2021 (cohort modal). @dataclass diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e8e2d4b6..c5ca153f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3693,6 +3693,12 @@ def _map_elmhurst_roof_window(w: ElmhurstWindow) -> SapRoofWindow: pitch_deg=45.0, g_perpendicular=w.g_value, frame_factor=w.frame_factor, + # SAP 10.2 Appendix L §L2a (PDF p.88): the per-rooflight gL + # dispatch in `_daylight_factor_from_cert` reads Table 6b via + # this code (Single=0.90, Double=0.80, Triple=0.70). Mirrors + # `_map_elmhurst_window` so cohort and 000565 rooflights both + # carry their lodged glazing-type signal end-to-end. + glazing_type=_elmhurst_glazing_type_code(w.glazing_type), ) diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index 470e59de..0ff6129f 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -589,11 +589,17 @@ def _daylight_factor_from_cert( """Compute C_daylight via L2a + L2b from the cert's windows + any rooflights. - Per Table 6d note 3 a single Z_L applies to all wall glazing. Per - Table 6d note 2 rooflights use Z_L = 1.0 regardless of overshading. - Rooflights are summed at default g_L = 0.80 (Table 6b DG) × FF = 0.7 - (Table 6c PVC) — non-default rooflight glazing or framing requires a - richer cert-derived representation in a future slice. + Per SAP 10.2 Appendix L §L2a (PDF p.88) the G_L numerator sums each + window's `A_w × g_L × FF × Z_L` product — per-window, NOT a single + dwelling-wide default. Vertical glazing uses Table 6d's overshading- + bucketed Z_L (note 3: same factor across the dwelling). Rooflights + use Z_L = 1.0 regardless of overshading (Table 6d note 2). + + Per-rooflight g_L and FF route via `SapRoofWindow.glazing_type` + + `frame_factor` — mirrors the per-window dispatch on `sap_windows`. + Pre-S0380.110 the rooflight contribution defaulted to + `total_area × 0.80 × 0.70`, overcounting Triple-glazed rooflights + (g_L=0.70) and any non-default frame factor. When `total_floor_area_m2` is missing or no windows are lodged the SAP "no-bonus" default 1.433 is used. @@ -610,11 +616,12 @@ def _daylight_factor_from_cert( * _g_light(w) * _frame_factor(w) * z_l for w in epc.sap_windows ) - rooflight_g_l_numerator = ( - rooflight_total_area_m2 - * _G_LIGHT_DEFAULT - * _FRAME_FACTOR_DEFAULT + rooflight_g_l_numerator = sum( + float(rw.area_m2) + * _G_LIGHT_BY_GLAZING_CODE.get(rw.glazing_type, _G_LIGHT_DEFAULT) + * float(rw.frame_factor) * 1.0 # Z_L = 1.0 for rooflights per Table 6d note 2 + for rw in epc.sap_roof_windows or [] ) g_l = 0.9 * (wall_g_l_numerator + rooflight_g_l_numerator) / tfa if g_l > 0.095: diff --git a/domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000516.py b/domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000516.py index 732ddbde..4512f0f8 100644 --- a/domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000516.py +++ b/domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000516.py @@ -169,6 +169,7 @@ def build_epc() -> EpcPropertyData: pitch_deg=45.0, g_perpendicular=0.76, frame_factor=0.70, + glazing_type=2, # SAP10.2 Table U2 "Double pre 2002" ), ], percent_draughtproofed=75,