diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 143e734b..d779adb5 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1587,9 +1587,17 @@ class EpcPropertyDataMapper: schema.sap_heating.shower_outlets, _API_SHOWER_OUTLET_CODE_MIXER, ), ), - # SAP windows + # SAP windows — split vertical wall windows (27) from roof + # windows (27a) on the RdSAP `window_wall_type=4` signal. sap_windows=[ - _api_sap_window(w) for w in schema.sap_windows + _api_sap_window(w) + for w in schema.sap_windows + if not _api_is_roof_window(w) + ], + sap_roof_windows=[ + _api_sap_roof_window(w) + for w in schema.sap_windows + if _api_is_roof_window(w) ], # SAP energy source sap_energy_source=SapEnergySource( @@ -2631,6 +2639,50 @@ def _api_secondary_fuel_type( return lodged_fuel_type +# RdSAP API `window_wall_type` code 4 = roof window ("Roof of Room" +# rooflight / inclined glazing). Codes 1=main wall, 2=alt wall 1, 3=alt +# wall 2 (see `_window_on_alt_wall`). A roof window is billed on worksheet +# (27a) at the Table 6e Note 2 inclination-adjusted U and draws 45°- +# inclined solar gains, NOT on (27) as vertical glazing. Cert 0240's 6 +# "Roof of Room" windows lodge this code; the simulated-case-6 worksheet +# confirms the (27a) treatment at U_eff 2.1062. +_API_WINDOW_WALL_TYPE_ROOF: Final[int] = 4 + + +def _api_is_roof_window(w: Any) -> bool: + """True when an API sap_windows entry is a roof window (rooflight), + keyed on `window_wall_type == 4`. `window_type` is NOT the signal — + certs 0390 / 7536 lodge `window_type=2` on ordinary main-wall + (wall_type=1) windows.""" + return w.window_wall_type == _API_WINDOW_WALL_TYPE_ROOF + + +def _api_sap_roof_window(w: Any) -> SapRoofWindow: + """Build a `SapRoofWindow` from one API roof-window entry + (`window_wall_type=4`). The lodged glazing type gives the vertical + U / g / frame-factor via the same SAP 10.2 Table 24 lookup the + vertical-window path uses; the U is then raised by the SAP 10.2 + Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to + the inclined-position value the worksheet bills on (27a). Mirror of + the site-notes `_map_elmhurst_roof_window`.""" + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + vertical_u = transmission[0] if transmission is not None else 2.0 + g_perp = transmission[1] if transmission is not None else 0.76 + frame_factor = w.frame_factor + if frame_factor is None: + frame_factor = transmission[2] if transmission is not None else 0.70 + return SapRoofWindow( + area_m2=_measurement_value(w.window_width) * _measurement_value(w.window_height), + u_value_raw=vertical_u + _ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K, + orientation=w.orientation, + pitch_deg=45.0, + g_perpendicular=g_perp, + frame_factor=frame_factor, + glazing_type=_api_cascade_glazing_type(w.glazing_type), + window_location=w.window_location, + ) + + def _api_sap_window(w: Any) -> SapWindow: """Build a `SapWindow` from one API schema sap_windows entry, routing the glazing-type + glazing-gap pair through the spec diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 375fe930..03f5fe93 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -82,9 +82,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0240-0200-5706-2365-8010", actual_sap=73, - expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+3.9138, - expected_co2_resid_tonnes_per_yr=+0.2213, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=+1.9459, + expected_co2_resid_tonnes_per_yr=+0.1226, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -127,7 +127,16 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "Party) with no height, previously dropped → roof over-count. " "Routing them through `detailed_surfaces` deducts 2×(6.4×2.45) " "from the A_RR shell → roof drops, tightening PE +5.8007 → " - "+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72." + "+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72. " + "Slice S0380.198 CLOSED the SAP: this cert lodges 6 windows " + "with `window_wall_type=4` = roof windows ('Roof of Room' " + "rooflights). The API mapper had flattened them into " + "`sap_windows` (vertical glazing, (27), U=2.0); they belong on " + "(27a) at the Table 6e Note 2 inclination-adjusted U=2.30 with " + "45°-inclined solar gains. Validated against the simulated-" + "case-6 worksheet ((27a) U_eff 2.1062). The inclined solar " + "gain dominates → SAP cont 72.14 → 72.55 (resid -1 → +0 " + "EXACT), PE +3.9138 → +1.9459, CO2 +0.2213 → +0.1226." ), ), _GoldenExpectation( @@ -201,8 +210,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="6035-7729-2309-0879-2296", actual_sap=70, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+1.8379, - expected_co2_resid_tonnes_per_yr=+0.0103, + expected_pe_resid_kwh_per_m2=+1.3743, + expected_co2_resid_tonnes_per_yr=-0.0004, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "S0380.189 fixed the dominant driver: walls are solid brick " @@ -246,8 +255,14 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "height 2.45 m; Exposed → gable_wall_external, Party → " "gable_wall) so the cascade's Detailed-RR residual fires. " "SAP resid -2 → +0 (exact), PE +19.16 → +1.84, CO2 " - "+0.42 → +0.01. Remaining +1.84 PE is unrelated gains/HW " - "(no worksheet for 6035 itself to pin further)." + "+0.42 → +0.01. " + "Slice S0380.198 (the 0240 roof-window fix) also applies: " + "6035 lodges 2 windows with `window_wall_type=4` (room-in-roof " + "rooflights) which were billed as vertical glazing; routing " + "them to roof windows (27a) at inclined U=2.30 + 45° solar " + "tightened PE +1.84 → +1.37 and CO2 +0.01 → -0.0004 (SAP still " + "exact). Remaining +1.37 PE is unrelated gains/HW (no " + "worksheet for 6035 itself to pin further)." ), ), _GoldenExpectation( @@ -757,6 +772,35 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3 +def test_0240_api_wall_type_4_windows_map_to_roof_windows() -> None: + """Cert 0240 lodges 6 windows with `window_wall_type=4` — the RdSAP + API code for a roof window ("Roof of Room" rooflight / inclined + glazing), distinct from main-wall (1) and alternative-wall (2/3) + windows. They belong on worksheet line (27a) Roof Windows at the + Table 6e Note 2 inclination-adjusted U (DG 2002+ vertical 2.0 + 0.30 + = 2.30 W/m²K), with 45°-inclined solar gains — NOT on (27) as vertical + wall windows at U=2.0. + + Before the fix the API mapper flattened all windows into + `sap_windows`, so these 6 billed as vertical glazing (wrong U *and* + wrong solar). Validated against the simulated-case-6 worksheet, which + bills the identical 6 windows on (27a) at U_eff 2.1062 (= 2.30 with + the §3.2 R=0.04 curtain transform). + """ + # Arrange + doc = _load_cert("0240-0200-5706-2365-8010") + + # Act + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Assert — the 6 wall_type=4 windows route to roof windows; the other + # 5 (wall_type=1, main wall) stay vertical. + assert epc.sap_roof_windows is not None + assert len(epc.sap_roof_windows) == 6 + assert len(epc.sap_windows) == 5 + assert all(abs(rw.u_value_raw - 2.30) <= 1e-9 for rw in epc.sap_roof_windows) + + def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None: """Cert 6035 lodges a Simplified Type 1 room-in-roof (`room_in_roof_ type_1`) with two gable walls (L=4.65 each). Per RdSAP 10 §3.9.1(e) @@ -785,5 +829,9 @@ def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None: # Act roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k - # Assert - assert abs(roof_w_per_k - 78.3336) <= 1e-4 + # Assert — 78.3336 (gable-deducted residual + loft + ext roof) less + # the S0380.198 deduction of 6035's 2 room-in-roof rooflights + # (window_wall_type=4, 2 × 1.2×0.8 = 1.92 m²) from the gross roof at + # U_roof=0.14 → 78.3336 − 0.2688 = 78.0648. The rooflights' own A×U + # moves to roof_windows_w_per_k. + assert abs(roof_w_per_k - 78.0648) <= 1e-4