diff --git a/packages/domain/src/domain/sap/worksheet/internal_gains.py b/packages/domain/src/domain/sap/worksheet/internal_gains.py index 73f44782..3261f6c9 100644 --- a/packages/domain/src/domain/sap/worksheet/internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/internal_gains.py @@ -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, diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py index 78377857..3e237527 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py @@ -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" diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py index 045ec96b..a9f030ea 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py index 08246823..dd14e6ae 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py index f3789d83..deb6a15e 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py index be98b226..721447c0 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py @@ -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 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 d9a8d1b3..05c460a3 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 @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py b/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py index 0e00cac6..f819652d 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py @@ -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}"