diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index e32579b4..9bd7c855 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -41,7 +41,6 @@ if TYPE_CHECKING: from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.sap.worksheet.dimensions import Dimensions from domain.sap.worksheet.heat_transmission import HeatTransmission -from domain.sap.worksheet.internal_gains import internal_gains_w from domain.sap.worksheet.mean_internal_temperature import mean_internal_temperature_c from domain.sap.worksheet.rating import ( ECF_LOG_THRESHOLD, @@ -101,6 +100,11 @@ class CalculatorInputs: # and MV mode (§2 lines 24a-d). Constant-monthly inputs work too: # pass `(ach,) * 12` to model a single rate across all months. monthly_infiltration_ach: tuple[float, ...] + # SAP10.2 (73)m — total internal gains W per month (Jan..Dec). + # Per-month because lighting/appliances cosine-modulate and pumps/fans + # zero out in summer per Table 5a. Produced by §5 orchestrator + # `internal_gains_from_cert` (called from cert_to_inputs). + internal_gains_monthly_w: tuple[float, ...] region: int windows: tuple[WindowInput, ...] control_type: int @@ -213,10 +217,7 @@ def _solve_month( heat_loss_parameter: float, ) -> MonthlyEntry: t_ext = external_temperature_c(inputs.region, month) - g_int = internal_gains_w( - total_floor_area_m2=inputs.dimensions.total_floor_area_m2, - month=month, - ).total_w + g_int = inputs.internal_gains_monthly_w[month - 1] g_sol = _solar_gains_w(windows=inputs.windows, region=inputs.region, month=month) g_total = g_int + g_sol diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 3224e6f0..af00d1fc 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -59,6 +59,10 @@ from domain.sap.tables.table_12 import ( unit_price_p_per_kwh, ) from domain.sap.worksheet.dimensions import dimensions_from_cert +from domain.sap.worksheet.internal_gains import ( + OvershadingCategory, + internal_gains_from_cert, +) from domain.sap.worksheet.heat_transmission import ( DwellingExposure, heat_transmission_from_cert, @@ -137,6 +141,12 @@ _FRAME_FACTOR_DEFAULT: Final[float] = 0.70 _PENCE_TO_GBP: Final[float] = 0.01 _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 _DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0 +# SAP10.2 Table 6d note 1: "average or unknown" overshading is the +# default for existing dwellings. RdSAP doesn't lodge a per-dwelling +# overshading code so §5 always uses AVERAGE → Z_L = 0.83. +_INTERNAL_GAINS_DEFAULT_OVERSHADING: Final[OvershadingCategory] = ( + OvershadingCategory.AVERAGE +) # Water-heating codes for instantaneous (no-cylinder) systems — SAP §4 @@ -785,7 +795,7 @@ def _hot_water_fuel_kwh_per_yr( water_efficiency_pct: float, is_instantaneous: bool, primary_age: Optional[str], -) -> float: +) -> tuple[float, tuple[float, ...]]: """Annual hot water FUEL kWh (the slot calculator.CalculatorInputs expects). Wires the SAP10.2 §4 worksheet orchestrator into the cert→ inputs adapter. @@ -799,9 +809,15 @@ def _hot_water_fuel_kwh_per_yr( to convert delivered heat to fuel kWh, mirroring the worksheet's (219) line. Falls back to legacy `predicted_hot_water_kwh` if the TFA is missing (the orchestrator requires it for occupancy). + + Returns a 2-tuple `(fuel_kwh_per_yr, heat_gains_monthly_kwh)`. The + heat-gains tuple is the §4 (65)m output, plumbed onward into the + §5 internal-gains orchestrator's `water_heating_gains_monthly_w` + bridge. Falls back to a 12-zero tuple when the legacy HW path is used. """ + zero_monthly = (0.0,) * 12 if epc.total_floor_area_m2 is None: - return predicted_hot_water_kwh( + legacy_kwh = predicted_hot_water_kwh( total_floor_area_m2=epc.total_floor_area_m2, seasonal_efficiency_water=water_efficiency_pct, cylinder_size=None if is_instantaneous else _int_or_none(epc.sap_heating.cylinder_size), @@ -816,6 +832,7 @@ def _hot_water_fuel_kwh_per_yr( has_wwhrs=False, has_solar_water_heating=epc.solar_water_heating, ) + return legacy_kwh, zero_monthly result = water_heating_from_cert( epc=epc, mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc), @@ -827,11 +844,11 @@ def _hot_water_fuel_kwh_per_yr( low_water_use=False, ) if water_efficiency_pct <= 0: - return 0.0 + return 0.0, result.heat_gains_monthly_kwh # `water_efficiency_pct` is misnamed in the calling code — the value # is a decimal (0.0–1.0), not a percent. Divide the orchestrator's # delivered-heat output by the decimal efficiency to land fuel kWh. - return result.output_kwh_per_yr / water_efficiency_pct + return result.output_kwh_per_yr / water_efficiency_pct, result.heat_gains_monthly_kwh def cert_to_inputs( @@ -941,7 +958,7 @@ def cert_to_inputs( # = q_generated, matching the per-kWh-generated unit price. water_eff = 1.0 / _heat_network_dlf(primary_age) is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES - hw_kwh = _hot_water_fuel_kwh_per_yr( + hw_kwh, hw_heat_gains_monthly_kwh = _hot_water_fuel_kwh_per_yr( epc=epc, water_efficiency_pct=water_eff, is_instantaneous=is_instantaneous, @@ -953,6 +970,24 @@ def cert_to_inputs( led_count=epc.led_fixed_lighting_bulbs_count, incandescent_count=epc.incandescent_fixed_lighting_bulbs_count, ) + # SAP10.2 §5: chain (66)..(73) internal-gain components via the §5 + # orchestrator. The orchestrator needs the §4 (65)m heat-gains tuple, + # which we just plumbed out of `water_heating_from_cert` above. + # Falls back to a 12-zero tuple when TFA is missing — matches the + # legacy `internal_gains_w` zero-floor behaviour. Overshading default + # is AVERAGE per Table 6d note 1 (existing dwellings). + if epc.total_floor_area_m2 is None: + internal_gains_monthly_w = (0.0,) * 12 + else: + internal_gains_result = internal_gains_from_cert( + epc=epc, + dwelling_volume_m3=dim.volume_m3, + heat_gains_from_water_heating_monthly_kwh=hw_heat_gains_monthly_kwh, + overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING, + ) + internal_gains_monthly_w = ( + internal_gains_result.total_internal_gains_monthly_w + ) return CalculatorInputs( dimensions=dim, @@ -960,6 +995,9 @@ def cert_to_inputs( # SAP10.2 line (25)m — 12-month effective air-change rate from the # full §2 worksheet (openings, shelter, wind adjustment, MV mode). monthly_infiltration_ach=ventilation.effective_monthly_ach, + # SAP10.2 line (73)m — total internal gains W/month from §5 + # orchestrator (composed above). + internal_gains_monthly_w=internal_gains_monthly_w, region=_region_index(epc.region_code), windows=_window_inputs(epc.sap_windows), control_type=_control_type(main), diff --git a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py index e9717033..a02a8bf3 100644 --- a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py +++ b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py @@ -84,6 +84,7 @@ def _baseline_dwelling() -> CalculatorInputs: dimensions=dim, heat_transmission=ht, monthly_infiltration_ach=(0.7,) * 12, + internal_gains_monthly_w=(450.0,) * 12, region=0, windows=windows, control_type=2, diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py index 371937b7..ee47c7af 100644 --- a/packages/domain/src/domain/sap/tests/test_calculator.py +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -80,6 +80,10 @@ def _baseline_inputs() -> CalculatorInputs: dimensions=dim, heat_transmission=ht, monthly_infiltration_ach=(0.7,) * 12, + # Synthetic baseline internal gains: 450 W constant. Real + # per-month variation lives in §5 orchestrator output; tracer + # tests don't need the modulation to verify the SAP loop. + internal_gains_monthly_w=(450.0,) * 12, region=0, windows=windows, control_type=2, diff --git a/packages/domain/src/domain/sap/worksheet/internal_gains.py b/packages/domain/src/domain/sap/worksheet/internal_gains.py index f2d89cd3..73f44782 100644 --- a/packages/domain/src/domain/sap/worksheet/internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/internal_gains.py @@ -15,13 +15,10 @@ Column A (typical gains) is used for the SAP rating + cooling calc per Table 5 footnote 3; Column B (reduced gains) is only for new-build DPER/TPER/DER/TER. We rate existing dwellings → always Column A. -The `internal_gains_w` scalar API below is the legacy SAP-10.3 stub; it -remains so `calculator.py` keeps building until §5 wiring (slice 11) -lands. Tests + worksheet conformance target the new 12-tuple functions. - Reference: SAP 10.2 specification (14-03-2025), §5 (page 25), Table 5 + Table 5a (page 177), Appendix L (lighting/appliances/cooking), -Appendix J Table 1b (occupancy from TFA). +Appendix J Table 1b (occupancy from TFA), Table 6d (Z_L light access +factor for L2a daylighting calc). """ from __future__ import annotations @@ -428,29 +425,6 @@ def water_heating_gains_monthly_w( ) -_METABOLIC_W_PER_OCCUPANT: Final[float] = 60.0 - - -_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) -# Appendix L existing-dwelling lighting fallback constants. -_FIXED_LIGHTING_EFFICACY_LM_PER_W: Final[float] = 21.3 -_FIXED_LIGHTING_LUMENS_PER_M2: Final[float] = 185.0 -_REFERENCE_LIGHTING_LUMENS_PER_M2: Final[float] = 330.0 -_DAYLIGHT_FACTOR_NO_BONUS: Final[float] = 1.433 -_LIGHTING_INTERNAL_FRACTION: Final[float] = 0.85 - - -@dataclass(frozen=True) -class InternalGainsBreakdown: - """SAP 10.3 §5 internal-gain components in watts for a given month.""" - - metabolic_w: float - cooking_w: float - appliances_w: float - lighting_w: float - total_w: float - - def _assumed_occupancy(total_floor_area_m2: float) -> float: """Appendix J Table 1b occupancy default from TFA. @@ -637,51 +611,3 @@ def internal_gains_from_cert( ) -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: - 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 internal_gains_w( - *, - total_floor_area_m2: float, - month: int, - occupancy: Optional[float] = None, -) -> InternalGainsBreakdown: - """SAP 10.3 §5 internal gains in watts for a given month.""" - n = occupancy if occupancy is not None else _default_occupancy_sap_j(total_floor_area_m2) - if not 1 <= month <= 12: - raise ValueError(f"month must be 1..12, got {month}") - n_m = _DAYS_IN_MONTH[month - 1] - metabolic = _METABOLIC_W_PER_OCCUPANT * n - cooking = 35.0 + 7.0 * n - # Appendix L (L13) + (L14) + (L16a): appliances energy by month, - # converted to a watt heat-gain (100% of appliance energy stays internal). - e_a_annual = 207.8 * (total_floor_area_m2 * n) ** 0.4714 - appliances_month_factor = 1.0 + 0.157 * cos(2.0 * pi * (month - 1.78) / 12.0) - e_a_m_kwh = e_a_annual * appliances_month_factor * n_m / 365.0 - appliances = e_a_m_kwh * 1000.0 / (24.0 * n_m) - # Appendix L lighting — existing-dwelling fallback path (L5b, L8c, L9c-d, L10, L12). - lambda_b = 11.2 * 59.73 * (total_floor_area_m2 * n) ** 0.4714 - c_daylight = _DAYLIGHT_FACTOR_NO_BONUS - lambda_req = (2.0 / 3.0) * lambda_b * c_daylight - c_l_fixed = _FIXED_LIGHTING_LUMENS_PER_M2 * total_floor_area_m2 - c_l_ref = _REFERENCE_LIGHTING_LUMENS_PER_M2 * total_floor_area_m2 - lambda_prov = lambda_req * c_l_fixed / c_l_ref if c_l_ref > 0 else 0.0 - e_l_fixed = (lambda_prov if lambda_req >= lambda_prov else lambda_req) / _FIXED_LIGHTING_EFFICACY_LM_PER_W - e_l_topup = max(0.0, lambda_req / 3.0 - lambda_prov) / _FIXED_LIGHTING_EFFICACY_LM_PER_W - e_l_portable = (1.0 / 3.0) * lambda_b * c_daylight / _FIXED_LIGHTING_EFFICACY_LM_PER_W - e_l_annual = e_l_fixed + e_l_topup + e_l_portable - lighting_month_factor = 1.0 + 0.5 * cos(2.0 * pi * (month - 0.2) / 12.0) - e_l_m_kwh = e_l_annual * lighting_month_factor * n_m / 365.0 - lighting = e_l_m_kwh * _LIGHTING_INTERNAL_FRACTION * 1000.0 / (24.0 * n_m) - return InternalGainsBreakdown( - metabolic_w=metabolic, - cooking_w=cooking, - appliances_w=appliances, - lighting_w=lighting, - total_w=metabolic + cooking + appliances + lighting, - )