From f77229e4b4e4aad31342d3ce0899ce475a3d1a8a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 May 2026 18:37:14 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A75=20slice=207:=20(70)=20pumps=5Ffans=5Fmo?= =?UTF-8?q?nthly=5Fw=20=E2=80=94=20Table=205a=209-row=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Table 5a row-by-row leaf functions: central_heating_pump_w pump install-date bucket (3/7/10 W) liquid_fuel_boiler_pump_w 10 W when oil-fuel pump inside dwelling liquid_fuel_warm_air_pump_w 10 W for liquid-fuel warm-air systems warm_air_heating_fan_w SFP × 0.04 × V (heating-season) piv_fan_w IUF × SFP × 0.12 × V (year-round) balanced_mv_no_hr_fan_w IUF × SFP × 0.06 × V (year-round) heat_interface_unit_w PCDB kWh/day × 1000 / 24 (year-round) Plus pumps_fans_monthly_w(heating_season_w, year_round_w) which applies the Table 5a footnote-a seasonal mask (Jun-Sep = 0 W heating-season contribution per Elmhurst worksheet convention). PumpDateCategory enum maps from EpcPropertyData.central_heating_pump_age_str ("Pre 2013" / "Post 2013" / "Unknown" / etc.) at the orchestrator layer. MVHR and MEV systems intentionally have no leaf fn — gains are zero per Table 5a notes (MVHR effect is in MVHR efficiency; MEV simply omitted). Co-Authored-By: Claude Opus 4.7 --- .../domain/sap/worksheet/internal_gains.py | 116 +++++++++++++++ .../worksheet/tests/test_internal_gains.py | 139 ++++++++++++++++++ 2 files changed, 255 insertions(+) diff --git a/packages/domain/src/domain/sap/worksheet/internal_gains.py b/packages/domain/src/domain/sap/worksheet/internal_gains.py index d26eff8d..2a9d6d3d 100644 --- a/packages/domain/src/domain/sap/worksheet/internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/internal_gains.py @@ -27,6 +27,7 @@ Appendix J Table 1b (occupancy from TFA). from __future__ import annotations from dataclasses import dataclass +from enum import Enum from math import cos, exp, pi from typing import Final, Optional @@ -36,6 +37,29 @@ _APPLIANCES_E_A_EXPONENT: Final[float] = 0.4714 _APPLIANCES_MONTHLY_AMPLITUDE: Final[float] = 0.157 _APPLIANCES_MONTHLY_PHASE: Final[float] = 1.78 +# Table 5a constants. +_PUMP_W_NEW_2013_OR_LATER: Final[float] = 3.0 +_PUMP_W_OLD_2012_OR_EARLIER: Final[float] = 10.0 +_PUMP_W_UNKNOWN_DATE: Final[float] = 7.0 +_LIQUID_FUEL_BOILER_PUMP_W: Final[float] = 10.0 +_LIQUID_FUEL_WARM_AIR_PUMP_W: Final[float] = 10.0 +_WARM_AIR_HEATING_VOLUME_COEFF: Final[float] = 0.04 +_PIV_VOLUME_COEFF: Final[float] = 0.12 +_BALANCED_MV_NO_HR_VOLUME_COEFF: Final[float] = 0.06 +_HIU_HOURS_PER_DAY: Final[float] = 24.0 +_SUMMER_MONTHS: Final[frozenset[int]] = frozenset({6, 7, 8, 9}) + + +class PumpDateCategory(Enum): + """Pump install-date bucket per Table 5a, used to dispatch the central + heating pump wattage. Maps from the cert's `central_heating_pump_age_str` + field (Elmhurst lodges "Pre 2013" / "Post 2013" / "Unknown").""" + + NEW_2013_OR_LATER = "new" + OLD_2012_OR_EARLIER = "old" + UNKNOWN = "unknown" + + # Appendix L lighting constants. _LIGHTING_LAMBDA_B_COEFF: Final[float] = 11.2 * 59.73 _LIGHTING_LAMBDA_B_EXPONENT: Final[float] = 0.4714 @@ -181,6 +205,98 @@ def lighting_monthly_w( return tuple(monthly) +def central_heating_pump_w(*, date_category: PumpDateCategory) -> float: + """Table 5a row "Central heating pump in heated space". Pump wattage + depends on install-date bucket: 3 W for 2013+, 10 W for ≤2012, 7 W + for unknown date. Applies only in heating-season months (caller + applies seasonal mask via `pumps_fans_monthly_w`).""" + if date_category is PumpDateCategory.NEW_2013_OR_LATER: + return _PUMP_W_NEW_2013_OR_LATER + if date_category is PumpDateCategory.OLD_2012_OR_EARLIER: + return _PUMP_W_OLD_2012_OR_EARLIER + return _PUMP_W_UNKNOWN_DATE + + +def liquid_fuel_boiler_pump_w() -> float: + """Table 5a row "Liquid fuel boiler pump, inside dwelling": 10 W. + Additive to central heating pump per footnote (b).""" + return _LIQUID_FUEL_BOILER_PUMP_W + + +def liquid_fuel_warm_air_pump_w() -> float: + """Table 5a row "Liquid-fuel-fired warm air system, inside dwelling": 10 W.""" + return _LIQUID_FUEL_WARM_AIR_PUMP_W + + +def warm_air_heating_fan_w( + *, + sfp_w_per_l_per_s: float, + dwelling_volume_m3: float, +) -> float: + """Table 5a row "Warm air heating system fans": SFP × 0.04 × V (W). + SFP defaults to 1.5 W/(l/s) when PCDB data is unknown (footnote c).""" + return sfp_w_per_l_per_s * _WARM_AIR_HEATING_VOLUME_COEFF * dwelling_volume_m3 + + +def piv_fan_w( + *, + in_use_factor: float, + sfp_w_per_l_per_s: float, + dwelling_volume_m3: float, +) -> float: + """Table 5a row "Fans for positive input ventilation from outside": + IUF × SFP × 0.12 × V (W). Year-round contribution.""" + return ( + in_use_factor + * sfp_w_per_l_per_s + * _PIV_VOLUME_COEFF + * dwelling_volume_m3 + ) + + +def balanced_mv_no_hr_fan_w( + *, + in_use_factor: float, + sfp_w_per_l_per_s: float, + dwelling_volume_m3: float, +) -> float: + """Table 5a row "Fans for balanced whole house mechanical ventilation + without heat recovery": IUF × SFP × 0.06 × V (W). Year-round.""" + return ( + in_use_factor + * sfp_w_per_l_per_s + * _BALANCED_MV_NO_HR_VOLUME_COEFF + * dwelling_volume_m3 + ) + + +def heat_interface_unit_w(*, electricity_kwh_per_day: float) -> float: + """Table 5a row "Electricity use by heat interface unit": PCDB entry + (kWh/day) × 1000 / 24 → constant W year-round.""" + return electricity_kwh_per_day * _KWH_TO_WH / _HIU_HOURS_PER_DAY + + +def pumps_fans_monthly_w( + *, + heating_season_w: float, + year_round_w: float, +) -> tuple[float, ...]: + """SAP10.2 §5 line (70) — pumps and fans gains in W per month. + + Combines Table 5a contributions split by seasonal mode: + heating-season-only (footnote a/b): pump rows + warm-air fans + year-round: PIV, balanced MV w/o HR, HIU electricity + + Summer = June-September inclusive (months 6-9). Mirrors the Elmhurst + worksheet convention visible across all 6 U985 fixtures. + """ + return tuple( + (heating_season_w + year_round_w) if m not in _SUMMER_MONTHS + else year_round_w + for m in range(1, _MONTHS_IN_YEAR + 1) + ) + + def water_heating_gains_monthly_w( *, heat_gains_from_water_heating_monthly_kwh: tuple[float, ...], 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 487c9e33..044dac53 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 @@ -13,11 +13,20 @@ Table 5a + Appendix L (lighting/appliances/cooking) + Appendix J Table 1b import pytest from domain.sap.worksheet.internal_gains import ( + PumpDateCategory, appliances_monthly_w, + balanced_mv_no_hr_fan_w, + central_heating_pump_w, cooking_monthly_w, + heat_interface_unit_w, lighting_monthly_w, + liquid_fuel_boiler_pump_w, + liquid_fuel_warm_air_pump_w, losses_monthly_w, metabolic_monthly_w, + piv_fan_w, + pumps_fans_monthly_w, + warm_air_heating_fan_w, water_heating_gains_monthly_w, ) @@ -205,3 +214,133 @@ def test_lighting_gains_match_appendix_l1_l12_for_000490() -> None: assert len(monthly) == 12 for m, (actual, exp) in enumerate(zip(monthly, expected_w)): assert actual == pytest.approx(exp, abs=5e-3), f"month {m+1}" + + +def test_pumps_fans_seasonal_mask_zeroes_summer_months_jun_to_sep() -> None: + """SAP10.2 Table 5a footnote a/b: pumps and warm-air fans are "Set to + zero in summer months". Year-round contributions (PIV, balanced MV + without HR, HIU) are unaffected. + + Defines "summer" as June-September inclusive (months 6-9), the + Elmhurst worksheet convention visible across all 6 U985 fixtures + where (70)m shows 7W Oct-May and 0W Jun-Sep. + """ + # Arrange + heating_season_w = 7.0 # central heating pump unknown date + year_round_w = 0.0 # no PIV/MV/HIU + + # Act + monthly = pumps_fans_monthly_w( + heating_season_w=heating_season_w, + year_round_w=year_round_w, + ) + + # Assert — heating season Oct-May = 7W; summer Jun-Sep = 0W + expected = (7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0) + assert monthly == pytest.approx(expected, abs=1e-9) + + +def test_pumps_fans_year_round_w_persists_through_summer() -> None: + """Table 5a row d) (PIV / balanced MV w/o HR) and HIU electricity + are not marked with footnote (a) — they run year-round. Heating- + season-only contributions add on top during Oct-May.""" + # Arrange + heating_season_w = 7.0 + year_round_w = 25.0 # e.g. balanced MV w/o HR fan + + # Act + monthly = pumps_fans_monthly_w( + heating_season_w=heating_season_w, + year_round_w=year_round_w, + ) + + # Assert + expected = (32.0, 32.0, 32.0, 32.0, 32.0, 25.0, 25.0, 25.0, 25.0, 32.0, 32.0, 32.0) + assert monthly == pytest.approx(expected, abs=1e-9) + + +def test_central_heating_pump_returns_7w_for_unknown_install_date() -> None: + """Table 5a row "Central heating pump in heated space, unknown date": + 7 W. The modal lodging across the 6 Elmhurst fixtures.""" + # Arrange / Act + w = central_heating_pump_w(date_category=PumpDateCategory.UNKNOWN) + + # Assert + assert w == 7.0 + + +def test_central_heating_pump_returns_3w_for_2013_or_later() -> None: + """Table 5a row "Central heating pump in heated space, 2013 or later": 3 W.""" + assert central_heating_pump_w(date_category=PumpDateCategory.NEW_2013_OR_LATER) == 3.0 + + +def test_central_heating_pump_returns_10w_for_2012_or_earlier() -> None: + """Table 5a row "Central heating pump in heated space, 2012 or earlier": 10 W.""" + assert central_heating_pump_w(date_category=PumpDateCategory.OLD_2012_OR_EARLIER) == 10.0 + + +def test_liquid_fuel_boiler_pump_inside_dwelling_is_10w() -> None: + """Table 5a row "Liquid fuel boiler pump, inside dwelling": 10 W. + Additive to central heating pump per footnote (b).""" + assert liquid_fuel_boiler_pump_w() == 10.0 + + +def test_liquid_fuel_warm_air_system_inside_is_10w() -> None: + """Table 5a row "Liquid-fuel-fired warm air system, inside dwelling": 10 W.""" + assert liquid_fuel_warm_air_pump_w() == 10.0 + + +def test_warm_air_heating_fan_uses_sfp_times_004_times_volume() -> None: + """Table 5a row "Warm air heating system fans": SFP × 0.04 × V where + V is dwelling volume m³ and SFP defaults to 1.5 W/(l/s) when PCDB + data is absent (footnote c).""" + # Arrange + sfp = 1.5 + volume_m3 = 250.0 + + # Act + w = warm_air_heating_fan_w(sfp_w_per_l_per_s=sfp, dwelling_volume_m3=volume_m3) + + # Assert + assert w == pytest.approx(1.5 * 0.04 * 250.0, abs=1e-9) # = 15.0 W + + +def test_piv_fan_uses_iuf_times_sfp_times_012_times_volume() -> None: + """Table 5a row "Fans for positive input ventilation from outside": + IUF × SFP × 0.12 × V. Footnote (d): SFP in W/(l/s), IUF is in-use + factor.""" + # Arrange / Act + w = piv_fan_w( + in_use_factor=1.6, + sfp_w_per_l_per_s=0.8, + dwelling_volume_m3=250.0, + ) + + # Assert + assert w == pytest.approx(1.6 * 0.8 * 0.12 * 250.0, abs=1e-9) + + +def test_balanced_mv_no_hr_fan_uses_iuf_times_sfp_times_006_times_volume() -> None: + """Table 5a row "Fans for balanced whole house mechanical ventilation + without heat recovery": IUF × SFP × 0.06 × V.""" + # Arrange / Act + w = balanced_mv_no_hr_fan_w( + in_use_factor=2.0, + sfp_w_per_l_per_s=1.2, + dwelling_volume_m3=250.0, + ) + + # Assert + assert w == pytest.approx(2.0 * 1.2 * 0.06 * 250.0, abs=1e-9) + + +def test_heat_interface_unit_converts_kwh_per_day_to_constant_watts() -> None: + """Table 5a row "Electricity use by heat interface unit": PCDB entry + kWh/day × 1000 / 24 → W constant. For an HIU lodging 0.48 kWh/day: + 0.48 × 1000 / 24 = 20 W constant + """ + # Arrange / Act + w = heat_interface_unit_w(electricity_kwh_per_day=0.48) + + # Assert + assert w == pytest.approx(20.0, abs=1e-9)