Slice 26b: §6 solar gains cascade pin + SapRoofWindow solar attrs

Added `solar_gains_section_from_cert` and 12 strict pin cases for §6
LINE_83 (total solar W) and LINE_84 (total internal + solar gains).

Extended SapRoofWindow with the solar attrs needed for line (82) roof-
window monthly gain: `orientation` (SAP10.2 code 1..8), `pitch_deg`,
`g_perpendicular`, `frame_factor`. Defaults match the modal RdSAP roof
window (45° pitch, DG g⊥=0.76, PVC FF=0.70, N). 000516 lodges
orientation=2 (NE) + pitch=45 from the U985 cert.

Plumbed `_roof_windows_for_solar_gains` through both `solar_gains_
section_from_cert` and the internal `cert_to_inputs` cascade so the
production §6 cascade now picks up 000516's NE roof window contribution
to (82). Exposed `ORIENTATION_BY_SAP10_CODE` from solar_gains for the
SAP10.2 code → Orientation enum mapping the cascade needs.

§6 cascade (LINE_83 monthly):
  fixture | LINE_83 | LINE_84
  000474  |    ✓    |    ✓
  000477  |    ✓    |    ✗ (cascaded §4 LINE_65 → §5 LINE_72/73)
  000480  |    ✓    |    ✓
  000487  |    ✓    |    ✗ (cascaded HW lodgement defect, slice 25)
  000490  |    ✓    |    ✓
  000516  |    ✓    |    ✓ (roof window now feeding (82))

Scoreboard:
  section_cascade_pins: 220 → 230 PASS (+10; 12 new tests, 2 fail)
  e2e SapResult:        30 →  32 PASS (+2, downstream of §6 closure)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-23 21:41:58 +00:00
parent 9cb79d9c98
commit 1e9654ce28
6 changed files with 155 additions and 24 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -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}")