§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 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, ...],

View file

@ -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 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)