§5 slice 13: rooflight Z_L=1.0 closes 000516 to ≤5e-3 W on every line

Table 6d note 2: roof windows / rooflights use Z_L = 1.0 regardless of
the overshading bucket applied to the rest of the dwelling's glazing.

Before this slice the orchestrator approximated rooflights as average
overshading (Z_L=0.83), driving 000516's (67) lighting 0.18 W (0.54%)
high. All wall windows in our 6-fixture corpus were correctly handled;
000516 is the only fixture with a lodged rooflight (the 1.18 m² NE
"window" showing Z=1.0 in the worksheet §6).

  fixture | (67) max |err| before | after
  --------+----------------------+--------
  000516  | 0.1823 W (0.54%)     | <0.005 W (<0.02%)
  others  | <0.0003 W            | <0.0003 W

Changes:
  - internal_gains_from_cert gains rooflight_total_area_m2 (default 0).
    Rooflights summed at g_L=0.80 (Table 6b DG) × FF=0.7 (Table 6c PVC)
    × Z_L=1.0 alongside wall windows (which still use the dwelling's
    overshading-derived Z_L).
  - SECTION_5_ROOFLIGHT_AREAS_M2 added to every fixture (empty tuple
    except 000516 which carries (1.18,)).
  - Tolerances on the §5 parametrised e2e test tightened from 2e-1 W
    on (67) and 3e-1 W on (73) to 5e-3 W on both — every fixture now
    closes to display rounding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 19:25:53 +00:00
parent 2d4fa24de9
commit 380115e244
8 changed files with 40 additions and 16 deletions

View file

@ -493,23 +493,36 @@ def _lighting_capacity_and_efficacy_from_cert(
def _daylight_factor_from_cert(
epc: EpcPropertyData,
overshading: OvershadingCategory,
rooflight_total_area_m2: float,
) -> float:
"""Compute C_daylight via L2a + L2b from the cert's windows. Per
Table 6d note 3 a single Z_L applies to all glazing in the dwelling.
"""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.
When `total_floor_area_m2` is missing or no windows are lodged the
SAP "no-bonus" default 1.433 is used.
"""
tfa = float(epc.total_floor_area_m2 or 0.0)
if tfa <= 0.0 or not epc.sap_windows:
if tfa <= 0.0 or (not epc.sap_windows and rooflight_total_area_m2 <= 0.0):
return 1.433
z_l = _Z_L_BY_OVERSHADING[overshading]
g_l_numerator = sum(
wall_g_l_numerator = sum(
float(w.window_width) * float(w.window_height)
* _g_light(w) * _frame_factor(w)
* _g_light(w) * _frame_factor(w) * z_l
for w in epc.sap_windows
)
g_l = 0.9 * g_l_numerator * z_l / tfa
rooflight_g_l_numerator = (
rooflight_total_area_m2
* _G_LIGHT_DEFAULT
* _FRAME_FACTOR_DEFAULT
* 1.0 # Z_L = 1.0 for rooflights per Table 6d note 2
)
g_l = 0.9 * (wall_g_l_numerator + rooflight_g_l_numerator) / tfa
if g_l > 0.095:
return 0.96
return 52.2 * g_l * g_l - 9.94 * g_l + 1.433
@ -537,6 +550,7 @@ def internal_gains_from_cert(
dwelling_volume_m3: float,
heat_gains_from_water_heating_monthly_kwh: tuple[float, ...],
overshading: OvershadingCategory = OvershadingCategory.AVERAGE,
rooflight_total_area_m2: float = 0.0,
) -> InternalGainsResult:
"""SAP 10.2 §5 orchestrator — chain every line ref (66)..(73) for the
dwelling identified by `epc`.
@ -565,7 +579,9 @@ def internal_gains_from_cert(
appliances = appliances_monthly_w(total_floor_area_m2=tfa, n_occupants=n)
c_l_fixed, eff_fixed = _lighting_capacity_and_efficacy_from_cert(epc)
c_daylight = _daylight_factor_from_cert(epc, overshading)
c_daylight = _daylight_factor_from_cert(
epc, overshading, rooflight_total_area_m2=rooflight_total_area_m2
)
lighting = lighting_monthly_w(
total_floor_area_m2=tfa,
n_occupants=n,

View file

@ -246,8 +246,9 @@ COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
# §12-1 default 80 lm/W × 15 W = 1200 lm each.
SECTION_5_BULB_COUNT_LEL: int = 8
# Window areas per worksheet §6 (5 windows: East 3.74 ×2, NW 1.76 + 1.98,
# SE 0.5). All DG air-filled (g_L=0.80) on PVC frames (FF=0.7).
# SE 0.5). All DG air-filled (g_L=0.80) on PVC frames (FF=0.7). No rooflights.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (3.74, 3.74, 1.76, 1.98, 0.5)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
# Vaillant ecoTEC pro 28 combi, pump in heated space, unknown install date
# → Table 5a 7 W heating-season-only row.
SECTION_5_PUMP_AGE_STR: str = "Unknown"

View file

@ -185,8 +185,9 @@ COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 9
# 3 windows: East 1.28, West 1.17 + 6.76. All DG / PVC.
# 3 windows: East 1.28, West 1.17 + 6.76. All DG / PVC. No rooflights.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (1.28, 1.17, 6.76)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (144.9204,) * 12

View file

@ -217,8 +217,9 @@ COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 10
# 2 windows: NE 8.74, SW 1.8. All DG / PVC.
# 2 windows: NE 8.74, SW 1.8. All DG / PVC. No rooflights.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (8.74, 1.8)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (152.4740,) * 12

View file

@ -232,8 +232,9 @@ ELECTRIC_SHOWER_OVERRIDE: Optional[tuple[float, ...]] = LINE_64A_M_ELECTRIC_SHOW
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 7
# 2 windows: South 0.77, South 6.69. All DG / PVC.
# 2 windows: South 0.77, South 6.69. All DG / PVC. No rooflights.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (0.77, 6.69)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (149.5185,) * 12

View file

@ -228,8 +228,9 @@ LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 8
# 3 windows: NE 0.81, NW 2.7, SE 5.52. All DG / PVC.
# 3 windows: NE 0.81, NW 2.7, SE 5.52. All DG / PVC. No rooflights.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (0.81, 2.7, 5.52)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (128.8087,) * 12

View file

@ -194,8 +194,10 @@ COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 9
# 3 windows: NE 1.18 + 3.88, SW 4.43. All DG / PVC.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (1.18, 3.88, 4.43)
# Wall windows: NE 3.88, SW 4.43. The 1.18 m² NE entry is a rooflight
# (Z=1.0 in the worksheet §6 column, per Table 6d note 2).
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (3.88, 4.43)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = (1.18,)
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (157.9824,) * 12

View file

@ -601,6 +601,7 @@ def test_internal_gains_from_cert_matches_elmhurst_worksheet_all_fixtures(
dwelling_volume_m3=fixture.LINE_5_VOLUME_M3,
heat_gains_from_water_heating_monthly_kwh=fixture.LINE_65_M_HEAT_GAINS_FROM_WH_KWH,
overshading=OvershadingCategory.AVERAGE,
rooflight_total_area_m2=sum(fixture.SECTION_5_ROOFLIGHT_AREAS_M2),
)
# Assert
@ -609,7 +610,7 @@ def test_internal_gains_from_cert_matches_elmhurst_worksheet_all_fixtures(
fixture.LINE_66_M_METABOLIC_W[m], abs=1e-3
), f"(66) month {m+1}"
assert result.lighting_monthly_w[m] == pytest.approx(
fixture.LINE_67_M_LIGHTING_W[m], abs=2e-1
fixture.LINE_67_M_LIGHTING_W[m], abs=5e-3
), f"(67) month {m+1}"
assert result.appliances_monthly_w[m] == pytest.approx(
fixture.LINE_68_M_APPLIANCES_W[m], abs=5e-2
@ -627,5 +628,5 @@ def test_internal_gains_from_cert_matches_elmhurst_worksheet_all_fixtures(
fixture.LINE_72_M_WATER_HEATING_GAINS_W[m], abs=1e-3
), f"(72) month {m+1}"
assert result.total_internal_gains_monthly_w[m] == pytest.approx(
fixture.LINE_73_M_TOTAL_INTERNAL_GAINS_W[m], abs=3e-1
fixture.LINE_73_M_TOTAL_INTERNAL_GAINS_W[m], abs=5e-3
), f"(73) month {m+1}"