§5 slice 7: (70) pumps_fans_monthly_w — Table 5a 9-row dispatch

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 18:37:14 +00:00
parent 50fd940ab9
commit f77229e4b4
2 changed files with 255 additions and 0 deletions

View file

@ -27,6 +27,7 @@ Appendix J Table 1b (occupancy from TFA).
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from math import cos, exp, pi from math import cos, exp, pi
from typing import Final, Optional 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_AMPLITUDE: Final[float] = 0.157
_APPLIANCES_MONTHLY_PHASE: Final[float] = 1.78 _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. # Appendix L lighting constants.
_LIGHTING_LAMBDA_B_COEFF: Final[float] = 11.2 * 59.73 _LIGHTING_LAMBDA_B_COEFF: Final[float] = 11.2 * 59.73
_LIGHTING_LAMBDA_B_EXPONENT: Final[float] = 0.4714 _LIGHTING_LAMBDA_B_EXPONENT: Final[float] = 0.4714
@ -181,6 +205,98 @@ def lighting_monthly_w(
return tuple(monthly) 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( def water_heating_gains_monthly_w(
*, *,
heat_gains_from_water_heating_monthly_kwh: tuple[float, ...], heat_gains_from_water_heating_monthly_kwh: tuple[float, ...],

View file

@ -13,11 +13,20 @@ Table 5a + Appendix L (lighting/appliances/cooking) + Appendix J Table 1b
import pytest import pytest
from domain.sap.worksheet.internal_gains import ( from domain.sap.worksheet.internal_gains import (
PumpDateCategory,
appliances_monthly_w, appliances_monthly_w,
balanced_mv_no_hr_fan_w,
central_heating_pump_w,
cooking_monthly_w, cooking_monthly_w,
heat_interface_unit_w,
lighting_monthly_w, lighting_monthly_w,
liquid_fuel_boiler_pump_w,
liquid_fuel_warm_air_pump_w,
losses_monthly_w, losses_monthly_w,
metabolic_monthly_w, metabolic_monthly_w,
piv_fan_w,
pumps_fans_monthly_w,
warm_air_heating_fan_w,
water_heating_gains_monthly_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 assert len(monthly) == 12
for m, (actual, exp) in enumerate(zip(monthly, expected_w)): for m, (actual, exp) in enumerate(zip(monthly, expected_w)):
assert actual == pytest.approx(exp, abs=5e-3), f"month {m+1}" 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 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)