diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 8155d49d..cd3f3e7c 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -172,16 +172,25 @@ class WindowTransmissionDetails: @dataclass class SapRoofWindow: - """RdSAP10 §3 worksheet line (27a) — a pitched roof window cut into a - storey-below roof. Heat-transmission contribution is A × U_eff where - U_eff applies the SAP10.2 §3.2 curtain resistance (R=0.04 m²K/W) to - `u_value_raw`. Roof windows draw their U-value from RdSAP 10 Table 24 - (p.50/113) "Roof window" column — distinct from the standard-window - column (e.g. double-glazed roof window U=3.4, vs 2.8 for standard). + """RdSAP10 worksheet roof window — feeds §3 (27a) heat transmission + and §6 (82) solar gain. Heat-transmission contribution is A × U_eff + where U_eff applies the SAP10.2 §3.2 curtain resistance (R=0.04 + m²K/W) to `u_value_raw`. Roof windows draw their U-value from RdSAP + 10 Table 24 (p.50/113) "Roof window" column (e.g. double-glazed roof + window U=3.4 vs 2.8 for standard). + + Solar fields (orientation, pitch, g_perpendicular, frame_factor) + 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. """ area_m2: float u_value_raw: float # RdSAP10 Table 24 roof-window column, pre-curtain. + orientation: int = 1 # SAP10.2 code: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW. + pitch_deg: float = 45.0 + g_perpendicular: float = 0.76 + frame_factor: float = 0.70 @dataclass diff --git a/docs/sap-spec/HANDOVER_NEXT.md b/docs/sap-spec/HANDOVER_NEXT.md index 0e9664a5..2951205d 100644 --- a/docs/sap-spec/HANDOVER_NEXT.md +++ b/docs/sap-spec/HANDOVER_NEXT.md @@ -133,12 +133,12 @@ Two test files contain the strict pins: Total: **169 PASS / 83 FAIL** across the strict pins. 4 of 6 fixtures fully close §1+§2+§4. 000487 is the worst (RR fixture defect propagates everywhere). -(Post-slice-26: section_cascade_pins 220 PASS / 20 FAIL, e2e SapResult -30 PASS / 42 FAIL. §3 + §5 fully close for 5 of 6 fixtures at abs=1e-4. -Remaining cascade failures: §4 monthly (000477/487 HW defects, slice 25), -§3 + §5 LINE_72/73 (000487 RR + 000477 LINE_61 cascade defects, slice 25), -and downstream SapResult pins still drifting because of §6–§9a precision -not yet pinned.) +(Post-slice-26b: section_cascade_pins 230 PASS / 22 FAIL, e2e SapResult +32 PASS / 40 FAIL. §3 + §5 + §6 fully close for 5 of 6 fixtures at +abs=1e-4. Remaining cascade failures: §4 monthly (000477/487 HW defects, +slice 25), §5 LINE_72/73 + §6 LINE_84 on 000477/487 (cascaded from §4), +§3 (000487 RR defect, slice 25), and downstream SapResult pins still +drifting because of §7–§9a precision not yet pinned.) ### B.2 SapResult pin matrix (post-slice-22/23) @@ -199,6 +199,7 @@ fixture | section §4 pin status ### B.5 Recent slices (in reverse order — newest first) ``` +Slice 26b: §6 solar gains cascade pin (12 cases, 10 PASS) + SapRoofWindow solar attrs + plumb to §6 cascade Slice 26: §5 internal gains cascade pin (54 cases, 50 PASS / 4 FAIL) + rooflight plumb to daylight factor Slice 27b: §3 element-area + door-area rounding to 2 d.p. per RdSAP10 §15 (p.66) Slice 27: BS EN ISO 13370 floor U rounded to 2 d.p. per RdSAP10 §5.12 @@ -266,7 +267,7 @@ The cascade pin work continues in worksheet order. For each section: Sections still to pin: - ~~**§5 internal gains** (lines 66-73 + 232 lighting kWh)~~ DONE (slice 26) -- **§6 solar gains** (lines 83-84). 2 monthly tuples. +- ~~**§6 solar gains** (lines 83-84)~~ DONE (slice 26b — 5/6 fixtures close, 000477/487 cascade from §4) - **§7 mean internal temperature** (lines 85-94). 10 line refs, mostly monthly. - **§8 space heating** (lines 95-99). 4 monthly + 2 annual. - **§9a energy requirements** (lines 201, 206-208, 211-215, 219). 5 scalar + 2 diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 93f81670..a9dc40b8 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -75,6 +75,12 @@ from domain.sap.worksheet.internal_gains import ( OvershadingCategory, internal_gains_from_cert, ) +from domain.sap.worksheet.solar_gains import ( + ORIENTATION_BY_SAP10_CODE, + RoofWindowInput, + SolarGainsResult, + solar_gains_from_cert, +) from domain.sap.worksheet.heat_transmission import ( DwellingExposure, HeatTransmission, @@ -84,7 +90,6 @@ from domain.sap.climate.appendix_u import external_temperature_c from domain.sap.worksheet.mean_internal_temperature import ( mean_internal_temperature_monthly, ) -from domain.sap.worksheet.solar_gains import solar_gains_from_cert from domain.sap.worksheet.energy_requirements import ( EnergyRequirementsResult, space_heating_fuel_monthly_kwh, @@ -798,6 +803,51 @@ def internal_gains_section_from_cert( ) +def _roof_windows_for_solar_gains( + epc: EpcPropertyData, +) -> tuple[RoofWindowInput, ...]: + """Convert `epc.sap_roof_windows` (SapRoofWindow) to the §6 calc's + `RoofWindowInput` tuple — projecting area + orientation + pitch + + g_perp + frame_factor for line (82) monthly solar gain. + + Roof-window U-value lives on SapRoofWindow but doesn't flow into §6; + it's a §3 (27a) heat-transmission input handled by + `heat_transmission_from_cert` separately.""" + return tuple( + RoofWindowInput( + area_m2=float(rw.area_m2), + orientation=ORIENTATION_BY_SAP10_CODE.get( + rw.orientation, list(ORIENTATION_BY_SAP10_CODE.values())[0] + ), + g_perpendicular=float(rw.g_perpendicular), + frame_factor=float(rw.frame_factor), + pitch_deg=float(rw.pitch_deg), + ) + for rw in epc.sap_roof_windows or [] + ) + + +def solar_gains_section_from_cert(epc: EpcPropertyData) -> SolarGainsResult: + """SAP 10.2 §6 cert→inputs cascade for `solar_gains_from_cert`. + + Returns the full `SolarGainsResult` (every (74)..(83) per-orientation + line ref + (82)/(82a) roof-window/rooflight monthly tuples) computed + from the cert's `sap_windows` (vertical wall windows) and + `sap_roof_windows` (pitched roof windows for line (82)) at default + AVERAGE overshading and UK-average region (matches cert_to_inputs' + internal cascade for the SAP-rating pass). + + Rooflights (horizontal Z=1.0 glazing) are not yet lodged on the cert + datatype distinct from roof windows — they pass through as empty. + """ + return solar_gains_from_cert( + epc=epc, + region=_region_index(epc.region_code), + overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING, + roof_windows=_roof_windows_for_solar_gains(epc), + ) + + def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult: """SAP 10.2 §2 cert→inputs cascade for `ventilation_from_inputs`. @@ -1266,6 +1316,7 @@ def cert_to_inputs( epc=epc, region=_region_index(epc.region_code), overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING, + roof_windows=_roof_windows_for_solar_gains(epc), ).total_solar_gains_monthly_w # SAP10.2 §7 — compose (93)m + (94)m via the orchestrator. Per-month HTC diff --git a/packages/domain/src/domain/sap/worksheet/solar_gains.py b/packages/domain/src/domain/sap/worksheet/solar_gains.py index 498d4942..78b4109b 100644 --- a/packages/domain/src/domain/sap/worksheet/solar_gains.py +++ b/packages/domain/src/domain/sap/worksheet/solar_gains.py @@ -189,7 +189,7 @@ _FRAME_FACTOR_DEFAULT: Final[float] = 0.7 # SAP10 octant code → Orientation enum. Cert windows with a code outside 1..8 # (e.g. 0, "NR") are dropped — no solar gain contribution, mirroring the # legacy `cert_to_inputs._window_inputs` shortcut. -_ORIENTATION_BY_SAP10_CODE: Final[dict[int, Orientation]] = { +ORIENTATION_BY_SAP10_CODE: Final[dict[int, Orientation]] = { 1: Orientation.N, 2: Orientation.NE, 3: Orientation.E, @@ -283,8 +283,8 @@ def _frame_factor(w: SapWindow) -> float: def _orientation(w: SapWindow) -> Orientation | None: """Map cert `orientation` code (1..8) to enum; None for unmapped.""" - if isinstance(w.orientation, int) and w.orientation in _ORIENTATION_BY_SAP10_CODE: - return _ORIENTATION_BY_SAP10_CODE[w.orientation] + if isinstance(w.orientation, int) and w.orientation in ORIENTATION_BY_SAP10_CODE: + return ORIENTATION_BY_SAP10_CODE[w.orientation] return None diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py index 235d6eab..8b938a7c 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py @@ -131,13 +131,23 @@ def build_epc() -> EpcPropertyData: door_count=2, # 000516 PDF (27a) — single 1.18 m² roof window cut into the # storey-below "External roof Main". Cert lodges it as - # "Manufacturer, Roof Window, Double glazed, FF=0.70" without a - # measured U; raw U=3.40 from RdSAP10 Table 24 (p.50/113) "Roof - # window" column. After SAP10.2 §3.2 curtain transform (R=0.04 - # m²K/W): U_eff = 1/(1/3.40 + 0.04) = 2.9930, matching the PDF - # (27a) lodgement exactly. Contribution = 1.18 × 2.9930 = 3.5317 - # W/K. - sap_roof_windows=[SapRoofWindow(area_m2=1.18, u_value_raw=3.40)], + # "Manufacturer, Roof Window, Double glazed, FF=0.70" with + # orientation NE and pitch 45° (see U985 txt line 49). Raw U=3.40 + # from RdSAP10 Table 24 (p.50/113) "Roof window" column. After + # SAP10.2 §3.2 curtain transform (R=0.04 m²K/W): U_eff = + # 1/(1/3.40 + 0.04) = 2.9930, matching the PDF (27a) lodgement + # exactly. Heat-transmission contribution = 1.18 × 2.9930 = 3.5317 + # W/K. Solar attrs feed §6 (82) roof-window monthly tuple. + sap_roof_windows=[ + SapRoofWindow( + area_m2=1.18, + u_value_raw=3.40, + orientation=2, # NE + pitch_deg=45.0, + g_perpendicular=0.76, + frame_factor=0.70, + ), + ], percent_draughtproofed=75, low_energy_fixed_lighting_bulbs_count=SECTION_5_BULB_COUNT_LEL, sap_windows=list(SECTION_6_VERTICAL_WINDOWS), diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py index 656e8c21..6ef8532a 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py @@ -20,6 +20,7 @@ from domain.sap.rdsap.cert_to_inputs import ( cert_to_inputs, heat_transmission_section_from_cert, internal_gains_section_from_cert, + solar_gains_section_from_cert, ventilation_from_cert, water_heating_section_from_cert, ) @@ -379,3 +380,62 @@ def test_section_5_line_232_lighting_kwh_per_yr_matches_pdf( expected, f"§5 (232) {fixture_name}", ) + + +# ============================================================================ +# §6 Solar gains — LINE_83 monthly total +# ============================================================================ + + +@pytest.mark.parametrize("fixture_name", list(_FIXTURES), ids=lambda x: x) +def test_section_6_line_83_total_solar_gains_match_pdf( + fixture_name: str, +) -> None: + """§6 (83) monthly — total solar gains W per month, summed across + per-orientation contributions (74)..(81) + (82) roof windows + + (82a) rooflights. + + Roof windows and rooflights pass through the cert cascade as empty + today — `SapRoofWindow` carries only area + raw U (slice 24) so + 000516's NE roof window doesn't contribute to (82) here. A future + slice should extend SapRoofWindow with orientation/pitch/g_perp/ + frame_factor; until then this pin fails on 000516 specifically. + """ + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = mod.LINE_83_M_TOTAL_SOLAR_W # type: ignore[attr-defined] + + # Act + sg = solar_gains_section_from_cert(epc) + actual = sg.total_solar_gains_monthly_w + + # Assert + for m in range(12): + _pin(actual[m], expected[m], f"§6 (83)[{m+1}] {fixture_name}") + + +@pytest.mark.parametrize("fixture_name", list(_FIXTURES), ids=lambda x: x) +def test_section_6_line_84_total_gains_match_pdf( + fixture_name: str, +) -> None: + """§6 (84) monthly — total internal + solar gains. Arithmetic identity + LINE_73 + LINE_83 = LINE_84, asserted on the cascade to catch any + drift between the §5 and §6 cascades.""" + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = mod.LINE_84_M_TOTAL_GAINS_W # type: ignore[attr-defined] + + # Act + ig = internal_gains_section_from_cert(epc) + sg = solar_gains_section_from_cert(epc) + assert ig is not None, f"{fixture_name}: internal_gains_from_cert returned None" + actual = tuple( + ig.total_internal_gains_monthly_w[m] + sg.total_solar_gains_monthly_w[m] + for m in range(12) + ) + + # Assert + for m in range(12): + _pin(actual[m], expected[m], f"§6 (84)[{m+1}] {fixture_name}")