mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
§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:
parent
50fd940ab9
commit
f77229e4b4
2 changed files with 255 additions and 0 deletions
|
|
@ -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, ...],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue