Slice S0380.110: per-rooflight g_L in Appendix L L2a (SAP 10.2 p.88)

SAP 10.2 Appendix L §L2a (PDF p.88) verbatim:

    GL = 0.9 × Σ (Aw × gL × FF × ZL) / TFA                  (L2a)

    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²
      gL is the light transmittance factor from Table 6b
      ZL is the light access factor from Table 6d

Table 6b gL (PDF p.178) — light transmittance column:
  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."

Pre-slice `_daylight_factor_from_cert` collapsed every rooflight into
a single `rooflight_total_area_m2 × _G_LIGHT_DEFAULT (0.80) ×
_FRAME_FACTOR_DEFAULT (0.70)` product, overcounting any Triple-glazed
rooflight (gL=0.70) or any non-default frame factor.

Cert 000565 §11 lodges 2 rooflights (per S0380.107 routing):
  Item 2 (Ext2 NR rooflight): 1.2 m², "Triple between 2002 and 2021",
    PVC FF=0.70 → gL=0.70 (Table 6b Triple). Correct numerator
    contribution 1.2 × 0.70 × 0.70 = 0.588; pre-slice cascade used
    1.2 × 0.80 × 0.70 = 0.672 (+0.084 over).
  Item 5 (Ext4 A rooflight): 0.5 m², "Double between 2002 and 2021",
    Wood FF=0.70 → gL=0.80 (Table 6b Double). Already matched.

The +0.084 numerator delta lowered GL → lowered C_daylight → lowered
worksheet (232) by 2.17 kWh/yr.

3-layer fix:
1. `datatypes/epc/domain/epc_property_data.py`: add `glazing_type:
   int = 3` to SapRoofWindow (default = Double 2002-2021, the cohort
   modal).
2. `datatypes/epc/domain/mapper.py` `_map_elmhurst_roof_window`:
   populate `glazing_type` via `_elmhurst_glazing_type_code(w.
   glazing_type)` — mirror of `_map_elmhurst_window`.
3. `domain/sap10_calculator/worksheet/internal_gains.py`
   `_daylight_factor_from_cert`: iterate `epc.sap_roof_windows` for
   the rooflight g_L numerator, dispatching via existing
   `_G_LIGHT_BY_GLAZING_CODE` + `rw.frame_factor`. Z_L = 1.0 per
   Table 6d note 2.

Test coverage:
- AAA test `test_summary_000565_rooflight_per_window_g_l_routes_via_
  glazing_type_per_sap_10_2_appendix_l_l2a` pins both per-rooflight
  glazing codes (9 Triple / 3 Double) AND `inputs.lighting_kwh_per_
  yr` at 1384.8353 ±1e-4.
- 000516 hand-built fixture updated to explicitly set glazing_type=2
  ("Double pre 2002") matching the lodged label.

Cert 000565 cascade snapshot (HEAD 98a4b5b9 → this):
  sap_score (int)             29       ✓ EXACT (preserved)
  lighting_kwh_per_yr     1382.6657 → 1384.8353  ✓ EXACT (-2.17 → 0)
  sap_score_continuous     28.5028  →  28.5002   (Δ -0.0059 → -0.0085)
  ecf                       5.3874  →   5.3877   (Δ +0.0008 → +0.0011)
  total_fuel_cost_gbp    4680.78    → 4681.01    (+0.52 → +0.75)
  co2_kg_per_yr          6448.34    → 6448.59    (+0.72 → +0.96)
  space_heating_kwh     59020.02    → 59019.18   (+11.67 → +10.83)
  main_heating_fuel     34717.66    → 34717.16   (+6.87  → +6.37)

Lighting closure exposes a previously-cancelling residual elsewhere —
continuous SAP magnitude widens slightly (-0.0059 → -0.0085) but the
spec-correct path is now in place, per [[feedback-spec-floor-
skepticism]]. SH + main_heating_fuel improve (added lighting energy
contributes internal gains, reducing SH demand). Integer SAP 29 ✓
EXACT preserved.

Cohort safety: 6 cohort certs have at most 1 rooflight each
(000516 W6 only, lodged "Double pre 2002" → code 2). Their gL still
resolves to 0.80 via the existing `_G_LIGHT_BY_GLAZING_CODE` table,
so the per-rooflight dispatch produces the same numerator as the
old default branch.

Pyright net-zero (50 → 50 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 18:31:35 +00:00
parent 98a4b5b9e6
commit 9461e657a5
5 changed files with 116 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

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