diff --git a/packages/domain/src/domain/ml/tests/_fixtures.py b/packages/domain/src/domain/ml/tests/_fixtures.py index 08ed9c29..171af42b 100644 --- a/packages/domain/src/domain/ml/tests/_fixtures.py +++ b/packages/domain/src/domain/ml/tests/_fixtures.py @@ -200,6 +200,7 @@ def make_minimal_sap10_epc( cfl_fixed_lighting_bulbs_count: int = 0, led_fixed_lighting_bulbs_count: int = 0, incandescent_fixed_lighting_bulbs_count: int = 0, + low_energy_fixed_lighting_bulbs_count: Optional[int] = None, solar_water_heating: bool = False, has_hot_water_cylinder: bool = False, has_fixed_air_conditioning: bool = False, @@ -299,6 +300,7 @@ def make_minimal_sap10_epc( cfl_fixed_lighting_bulbs_count=cfl_fixed_lighting_bulbs_count, led_fixed_lighting_bulbs_count=led_fixed_lighting_bulbs_count, incandescent_fixed_lighting_bulbs_count=incandescent_fixed_lighting_bulbs_count, + low_energy_fixed_lighting_bulbs_count=low_energy_fixed_lighting_bulbs_count, total_floor_area_m2=total_floor_area_m2, sap_version=10.2, energy_rating_current=energy_rating_current, diff --git a/packages/domain/src/domain/sap/worksheet/internal_gains.py b/packages/domain/src/domain/sap/worksheet/internal_gains.py index 45327e95..f2d89cd3 100644 --- a/packages/domain/src/domain/sap/worksheet/internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/internal_gains.py @@ -31,6 +31,8 @@ from enum import Enum from math import cos, exp, pi from typing import Final, Optional +from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow + _DAYS_PER_YEAR: Final[float] = 365.0 _APPLIANCES_E_A_COEFF: Final[float] = 207.8 _APPLIANCES_E_A_EXPONENT: Final[float] = 0.4714 @@ -60,6 +62,67 @@ class PumpDateCategory(Enum): UNKNOWN = "unknown" +class OvershadingCategory(Enum): + """Table 6d overshading bucket. Maps to light access factor Z_L. SAP + defaults to AVERAGE when the cert hasn't lodged a specific category.""" + + HEAVY = "heavy" + MORE_THAN_AVERAGE = "more_than_average" + AVERAGE = "average" + VERY_LITTLE = "very_little" + + +# Table 6d third column — light access factor Z_L by overshading bucket. +_Z_L_BY_OVERSHADING: Final[dict[OvershadingCategory, float]] = { + OvershadingCategory.HEAVY: 0.5, + OvershadingCategory.MORE_THAN_AVERAGE: 0.67, + OvershadingCategory.AVERAGE: 0.83, + OvershadingCategory.VERY_LITTLE: 1.0, +} + +# RdSAP §12-1 per-lamp-type defaults: (watts_per_bulb, efficacy_lm_per_w). +# When the cert distinguishes LED vs CFL the per-type values apply; +# combined "low energy lighting" (LEL) — LED/CFL unknown — uses the LEL +# default. Lumens per bulb = watts × efficacy. +_RDSAP_LAMP_LED: Final[tuple[float, float]] = (9.0, 100.0) +_RDSAP_LAMP_CFL: Final[tuple[float, float]] = (19.0, 55.0) +_RDSAP_LAMP_INCANDESCENT: Final[tuple[float, float]] = (60.0, 11.2) +_RDSAP_LAMP_LEL_UNKNOWN: Final[tuple[float, float]] = (15.0, 80.0) + +# L5b existing-dwelling C_L,fixed fallback when no fixed-lighting data lodged. +_LIGHTING_L5B_LUMENS_PER_M2: Final[float] = 185.0 +# L8c ε_fixed fallback when no fixed lighting present. +_LIGHTING_L8C_EFFICACY_LM_PER_W: Final[float] = 21.3 + +# Table 6b light transmittance g_L by SAP glazing-type code. Single +# glazed = 0.90; double-glazed variants = 0.80; triple-glazed = 0.70. +# Mirrors the SAP code mapping in cert_to_inputs._g_perpendicular but +# returns the light column, not solar. +_G_LIGHT_BY_GLAZING_CODE: Final[dict[int, float]] = { + 1: 0.90, # single glazed + 2: 0.80, # double glazed (air filled, pre-2002) + 3: 0.80, # double glazed (air filled, post-2002) + 4: 0.80, # double glazed (low-E) + 5: 0.80, # double glazed (low-E argon) + 6: 0.70, # triple glazed + 7: 0.80, # secondary glazing +} +_G_LIGHT_DEFAULT: Final[float] = 0.80 # treat unknowns as DG (modal) + +# Table 6c frame factor FF by frame-material substring. PVC, wood, +# composite default to 0.7; metal to 0.8. +_FRAME_FACTOR_BY_MATERIAL_SUBSTR: Final[tuple[tuple[str, float], ...]] = ( + ("metal", 0.8), + ("aluminium", 0.8), + ("aluminum", 0.8), + ("wood", 0.7), + ("pvc", 0.7), + ("upvc", 0.7), + ("composite", 0.7), +) +_FRAME_FACTOR_DEFAULT: Final[float] = 0.7 + + # Appendix L lighting constants. _LIGHTING_LAMBDA_B_COEFF: Final[float] = 11.2 * 59.73 _LIGHTING_LAMBDA_B_EXPONENT: Final[float] = 0.4714 @@ -388,6 +451,192 @@ class InternalGainsBreakdown: total_w: float +def _assumed_occupancy(total_floor_area_m2: float) -> float: + """Appendix J Table 1b occupancy default from TFA. + + Duplicated from `water_heating.assumed_occupancy` to avoid the §4 + import dependency in §5 — keeps internal_gains.py self-contained. + """ + if total_floor_area_m2 <= 13.9: + return 1.0 + tfa_offset = total_floor_area_m2 - 13.9 + return ( + 1.0 + + 1.76 * (1 - exp(-0.000349 * tfa_offset * tfa_offset)) + + 0.0013 * tfa_offset + ) + + +def _g_light(w: SapWindow) -> float: + """Table 6b light transmittance g_L by glazing-type code. Defaults + to 0.80 (DG, modal across UK certs) when the cert lodges a code we + don't recognise.""" + if isinstance(w.glazing_type, int) and w.glazing_type in _G_LIGHT_BY_GLAZING_CODE: + return _G_LIGHT_BY_GLAZING_CODE[w.glazing_type] + return _G_LIGHT_DEFAULT + + +def _frame_factor(w: SapWindow) -> float: + """Table 6c frame factor. Prefer cert's `frame_factor`; else look up + by `frame_material` substring.""" + if w.frame_factor is not None: + return float(w.frame_factor) + material = (w.frame_material or "").lower() + for needle, ff in _FRAME_FACTOR_BY_MATERIAL_SUBSTR: + if needle in material: + return ff + return _FRAME_FACTOR_DEFAULT + + +def _lighting_capacity_and_efficacy_from_cert( + epc: EpcPropertyData, +) -> tuple[float, float]: + """Aggregate C_L,fixed (lm) and ε_fixed (lm/W) from the cert's bulb + counts via RdSAP §12-1 per-lamp-type defaults. Falls back to L5b + (185 × TFA lumens) + L8c (21.3 lm/W) when no bulb data lodged.""" + led = epc.led_fixed_lighting_bulbs_count or 0 + cfl = epc.cfl_fixed_lighting_bulbs_count or 0 + inc = epc.incandescent_fixed_lighting_bulbs_count or 0 + lel = epc.low_energy_fixed_lighting_bulbs_count or 0 + + led_w, led_eff = _RDSAP_LAMP_LED + cfl_w, cfl_eff = _RDSAP_LAMP_CFL + inc_w, inc_eff = _RDSAP_LAMP_INCANDESCENT + lel_w, lel_eff = _RDSAP_LAMP_LEL_UNKNOWN + total_lumens = ( + led * led_w * led_eff + + cfl * cfl_w * cfl_eff + + inc * inc_w * inc_eff + + lel * lel_w * lel_eff + ) + total_power_w = led * led_w + cfl * cfl_w + inc * inc_w + lel * lel_w + if total_power_w <= 0.0: + tfa = float(epc.total_floor_area_m2 or 0.0) + return (_LIGHTING_L5B_LUMENS_PER_M2 * tfa, _LIGHTING_L8C_EFFICACY_LM_PER_W) + return (total_lumens, total_lumens / total_power_w) + + +def _daylight_factor_from_cert( + epc: EpcPropertyData, + overshading: OvershadingCategory, +) -> 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. + + 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: + return 1.433 + z_l = _Z_L_BY_OVERSHADING[overshading] + g_l_numerator = sum( + float(w.window_width) * float(w.window_height) + * _g_light(w) * _frame_factor(w) + for w in epc.sap_windows + ) + g_l = 0.9 * g_l_numerator * z_l / tfa + if g_l > 0.095: + return 0.96 + return 52.2 * g_l * g_l - 9.94 * g_l + 1.433 + + +def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory: + """Map first main-heating detail's central_heating_pump_age_str to a + Table 5a bucket. Elmhurst lodges "Pre 2013" / "Post 2013" / "Unknown" + / None on each `MainHeatingDetail` (nested under `epc.sap_heating`).""" + sap_heating = getattr(epc, "sap_heating", None) + details = getattr(sap_heating, "main_heating_details", None) or [] + age_str = "" + if details: + age_str = (details[0].central_heating_pump_age_str or "").lower() + if "post" in age_str or "2013 or later" in age_str: + return PumpDateCategory.NEW_2013_OR_LATER + if "pre" in age_str or "2012" in age_str: + return PumpDateCategory.OLD_2012_OR_EARLIER + return PumpDateCategory.UNKNOWN + + +def internal_gains_from_cert( + *, + epc: EpcPropertyData, + dwelling_volume_m3: float, + heat_gains_from_water_heating_monthly_kwh: tuple[float, ...], + overshading: OvershadingCategory = OvershadingCategory.AVERAGE, +) -> InternalGainsResult: + """SAP 10.2 §5 orchestrator — chain every line ref (66)..(73) for the + dwelling identified by `epc`. + + Inputs: + epc cert (TFA, bulbs, windows, pump) + dwelling_volume_m3 §1 line (5) for fan-W formulas + heat_gains_from_water_heating_monthly_kwh §4 line (65)m — see Q5 grill + overshading Table 6d bucket (default AVERAGE) + + Coverage caveats for the current corpus: + - Lighting: full Appendix L L1-L12 with RdSAP §12-1 per-lamp defaults + and the L2a window-driven C_daylight. Conformant for the 6 Elmhurst + fixtures (all DG, PVC frame, average overshading) to ~0.5%. + - Pumps/fans: central heating pump only. Liquid-fuel pump, warm-air + fans, PIV, balanced MV w/o HR, HIU branches are reachable via the + leaf fns but not yet derivable from the cert here. Mirrors §4's + combi-only happy-path scope. + """ + tfa = float(epc.total_floor_area_m2 or 0.0) + n = _assumed_occupancy(tfa) + + metabolic = metabolic_monthly_w(n_occupants=n) + cooking = cooking_monthly_w(n_occupants=n) + losses = losses_monthly_w(n_occupants=n) + 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) + lighting = lighting_monthly_w( + total_floor_area_m2=tfa, + n_occupants=n, + fixed_lighting_capacity_lm=c_l_fixed, + fixed_lighting_efficacy_lm_per_w=eff_fixed, + daylight_factor=c_daylight, + ) + + pump_w = central_heating_pump_w( + date_category=_pump_date_category_from_cert(epc) + ) + # Liquid-fuel + warm-air + PIV + MV + HIU branches default to zero for + # the combi-gas-natural-vent population; future slices will detect them + # from epc.main_heating_details + epc.mechanical_ventilation. + pumps_fans = pumps_fans_monthly_w( + heating_season_w=pump_w, + year_round_w=0.0, + ) + + water_heating_gains = water_heating_gains_monthly_w( + heat_gains_from_water_heating_monthly_kwh=heat_gains_from_water_heating_monthly_kwh, + ) + + total = total_internal_gains_monthly_w( + metabolic_monthly_w=metabolic, + lighting_monthly_w=lighting, + appliances_monthly_w=appliances, + cooking_monthly_w=cooking, + pumps_fans_monthly_w=pumps_fans, + losses_monthly_w=losses, + water_heating_gains_monthly_w=water_heating_gains, + ) + + return InternalGainsResult( + metabolic_monthly_w=metabolic, + lighting_monthly_w=lighting, + appliances_monthly_w=appliances, + cooking_monthly_w=cooking, + pumps_fans_monthly_w=pumps_fans, + losses_monthly_w=losses, + water_heating_gains_monthly_w=water_heating_gains, + total_internal_gains_monthly_w=total, + ) + + def _default_occupancy_sap_j(total_floor_area_m2: float) -> float: """SAP 10.3 Appendix J Table 1b occupancy default from TFA.""" if total_floor_area_m2 <= 13.9: 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 38c7fafe..7af7a32e 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 @@ -14,12 +14,14 @@ import pytest from domain.sap.worksheet.internal_gains import ( InternalGainsResult, + OvershadingCategory, PumpDateCategory, appliances_monthly_w, balanced_mv_no_hr_fan_w, central_heating_pump_w, cooking_monthly_w, heat_interface_unit_w, + internal_gains_from_cert, lighting_monthly_w, liquid_fuel_boiler_pump_w, liquid_fuel_warm_air_pump_w, @@ -31,6 +33,13 @@ from domain.sap.worksheet.internal_gains import ( warm_air_heating_fan_w, water_heating_gains_monthly_w, ) +from datatypes.epc.domain.epc_property_data import ( + InstantaneousWwhrs, + MainHeatingDetail, + SapHeating, + SapWindow, +) +from domain.ml.tests._fixtures import make_minimal_sap10_epc def test_metabolic_gains_are_60w_per_occupant_constant_across_months() -> None: @@ -412,3 +421,106 @@ def test_internal_gains_result_dataclass_holds_all_seven_lines_plus_total() -> N "cooking_monthly_w", "pumps_fans_monthly_w", "losses_monthly_w", "water_heating_gains_monthly_w", "total_internal_gains_monthly_w", )) + + +def _build_000490_lookalike_epc() -> "EpcPropertyData": # noqa: F821 — string ref keeps imports light + """Hand-build a minimal EPC matching the 000490 cert's §5 surface: + TFA 66.06 m², 8 LEL bulbs (LED/CFL unknown), 3 DG-PVC windows + totalling 9.03 m², gas combi with central heating pump (unknown date). + + Slice 9 keeps this local rather than mutating `_elmhurst_worksheet_000490.build_epc()` + so the existing e2e SAP-score regression test (which is pinned to the + legacy cert state) doesn't drift. Slice 10 extends the fixture proper. + """ + def _window(area: float, orientation_code: int) -> SapWindow: + side = area ** 0.5 + return SapWindow( + frame_material="PVC", + glazing_gap=12, + orientation=orientation_code, + window_type=2, + glazing_type=2, + window_width=side, + window_height=area / side, + draught_proofed=True, + window_location=1, + window_wall_type=1, + permanent_shutters_present=False, + ) + sap_heating = SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, + main_fuel_type=1, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + central_heating_pump_age_str="Unknown", + ), + ], + has_fixed_air_conditioning=False, + ) + return make_minimal_sap10_epc( + total_floor_area_m2=66.06, + low_energy_fixed_lighting_bulbs_count=8, + sap_windows=[ + _window(0.81, orientation_code=8), + _window(5.52, orientation_code=4), + _window(2.70, orientation_code=6), + ], + sap_heating=sap_heating, + ) + + +def test_internal_gains_from_cert_reproduces_000490_worksheet_end_to_end() -> None: + """End-to-end §5 orchestrator against Elmhurst U985-0001-000490. + + Drives the full (66)..(73) pipeline from the cert: occupancy via + Appendix J Table 1b from TFA, lighting via RdSAP §12-1 bulb defaults + + Appendix L cascade, appliances via L13/L14/L16a, cooking + losses + + metabolic from N, pumps/fans via Table 5a (central heating pump + unknown date, no MV/PIV/HIU for combi-gas-natural-vent population), + water-heating gains bridged from the §4 (65)m kWh tuple supplied by + the caller. + + Asserts every line ref against the worksheet to ≤1e-2 W tolerance. + """ + # Arrange — hand-built 000490-lookalike + worksheet (65)m + (5) volume. + epc = _build_000490_lookalike_epc() + heat_gains_wh_kwh = ( + 75.2034, 66.4381, 70.4305, 61.5896, 59.4711, 53.3391, + 52.4705, 54.6991, 55.4582, 62.1383, 66.4342, 74.3403, + ) + + # Act + result = internal_gains_from_cert( + epc=epc, + dwelling_volume_m3=202.6377, # worksheet line (5) + heat_gains_from_water_heating_monthly_kwh=heat_gains_wh_kwh, + overshading=OvershadingCategory.AVERAGE, + ) + + # Assert — every worksheet line. + expected_66 = (128.8087,) * 12 + expected_67 = (24.2665, 21.5533, 17.5283, 13.2701, 9.9195, 8.3745, + 9.0489, 11.7621, 15.7871, 20.0454, 23.3959, 24.9410) + expected_68 = (280.4965, 283.4071, 276.0723, 260.4574, 240.7463, 222.2207, + 209.8445, 206.9338, 214.2686, 229.8835, 249.5946, 268.1202) + expected_69 = (50.0277,) * 12 + expected_70 = (7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0) + expected_71 = (-85.8725,) * 12 + expected_72 = (101.0798, 98.8663, 94.6647, 85.5412, 79.9343, 74.0821, + 70.5249, 73.5203, 77.0253, 83.5192, 92.2698, 99.9197) + expected_73 = (505.8067, 503.7906, 488.2293, 459.2325, 430.5641, 397.6412, + 382.3822, 385.1801, 400.0449, 433.4120, 465.2242, 492.9448) + + for m in range(12): + assert result.metabolic_monthly_w[m] == pytest.approx(expected_66[m], abs=1e-3), f"(66) month {m+1}" + assert result.lighting_monthly_w[m] == pytest.approx(expected_67[m], abs=5e-2), f"(67) month {m+1}" + assert result.appliances_monthly_w[m] == pytest.approx(expected_68[m], abs=5e-2), f"(68) month {m+1}" + assert result.cooking_monthly_w[m] == pytest.approx(expected_69[m], abs=1e-3), f"(69) month {m+1}" + assert result.pumps_fans_monthly_w[m] == pytest.approx(expected_70[m], abs=1e-9), f"(70) month {m+1}" + assert result.losses_monthly_w[m] == pytest.approx(expected_71[m], abs=1e-3), f"(71) month {m+1}" + assert result.water_heating_gains_monthly_w[m] == pytest.approx(expected_72[m], abs=1e-3), f"(72) month {m+1}" + assert result.total_internal_gains_monthly_w[m] == pytest.approx(expected_73[m], abs=1e-1), f"(73) month {m+1}"