Model/domain/sap10_calculator/worksheet/internal_gains.py
Khalim Conn-Kowlessar 8d465d973f Slice S0380.162: SAP 10.2 Appendix N3.1 default pump gain for electric HPs
SAP 10.2 Appendix N3.1 (PDF p.105) "Circulation pump and fan":
"For electric heat pumps: The electricity used by the water
circulation pump or fan is included within the calculated annual
space and hot water heating efficiency and is not included in
worksheet (230c). **The default heat gain from Table 5a is included
via worksheet (70).**"

This rule applies the Table 5a row "Central heating pump in heated
space" GAIN (3 / 10 / 7 W per pump-age bucket) to electric heat
pumps even though the pump ELECTRICITY is hidden in the COP and
excluded from (230c). The "Not applicable for electric heat pumps
from database" clause in Table 5a footnote a) scopes only to the
PCDB-Table-362 cascade case (Appendix N1.2.1: "For heat pumps held
in the PCDB ... a single water circulation pump serving the heat
emitters is sufficient" — pump kWh AND gain embedded in COP).

S0380.160 over-stripped the gain by zeroing pump_w for every HP
category-4 main, conflating the PCDB-Table-362 case with the Table-4a
default cascade. This slice refines the HP gate in
`_any_main_system_has_central_heating_pump`:
  - Cat 4 HP WITH `main_heating_index_number` lodged (PCDB Table
    362) → continue (skip; pump in COP per N1.2.1);
  - Cat 4 HP with SAP code in `_TABLE_4A_WARM_AIR_SAP_CODES` (Cat 5
    warm-air HPs distribute via ducted air, no water circulation
    pump; warm-air fan handled separately by Table 5a "Warm air
    heating system fans" row, S0380.161) → continue;
  - Otherwise (Cat 4 HP, Table 4a default cascade, water-emitter)
    → apply Table 5a default per Appendix N3.1.

Per-line walk on ashp (SAP code 214 air-to-water HP, Cat 4, no PCDB,
"Post 2013" pump age):
  worksheet (70)[Jan] = 3.0000 W
  cascade pre-slice    = 0.0000 W      delta = -3.000 W
The -3 W winter gain shortfall over-stated cascade (84) Total gains
by -3 W in heating months → cascade SH demand +12.27 kWh/yr
(cascade 9302 vs worksheet 9290), pushing continuous SAP down 0.024
because the cost residual was driven by the +1.5 kWh × 12 month
shortfall flowing through the £0.0741 low-rate cost.

Closures:
  ashp:  ΔSAP -0.0240 → +0.0000 EXACT, Δcost +£0.55 → +£0.00 EXACT
  gshp:  ΔSAP -0.0178 → -0.0000 EXACT, Δcost +£0.41 → -£0.00 EXACT

ΔPE +36 → +25.51 (and ΔCO2 +7.33 → +6.31) — residuals narrow to the
Elmhurst-vs-spec HW PE annual-vs-monthly Table 12e/12d quirk only
(same pattern as the 16-variant lighting-PE deferred cohort,
scaled by HW kWh = 1138 vs 2384 → 25.51 vs 48.66). Cohort
Σ |ΔSAP_c| 0.07 → 0.03; all 25 cascade-OK variants now SAP+cost EXACT.

Cohort-1 (cert 0380 et al.) golden fixtures unaffected — those certs
lodge `main_heating_index_number` (PCDB Table 362) → HP gate skips
correctly → (70) = 0 preserved. Cert 000565 (HP main 1 + gas boiler
main 2) unaffected — wet-boiler branch fires for main 2.

Verbatim spec quote (SAP 10.2 Appendix N3.1, PDF p.105):
  "For electric heat pumps: The electricity used by the water
   circulation pump or fan is included within the calculated annual
   space and hot water heating efficiency and is not included in
   worksheet (230c). The default heat gain from Table 5a is
   included via worksheet (70)."

Tests: 906 pass (+1), 0 fail. Pyright net-zero (35 → 35).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:59:29 +00:00

938 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SAP 10.2 §5 + Appendix L — internal gains.
Worksheet lines (66)..(73), each a monthly 12-tuple of watts:
(66) metabolic = 60 × N (Table 5 Col A)
(67) lighting = Appendix L L1-L12 cascade
(68) appliances = Appendix L L13/L14/L16
(69) cooking = 35 + 7 × N (Table 5 Col A)
(70) pumps + fans = Table 5a dispatch + heating-season mask
(71) losses = -40 × N (Table 5 Col A)
(72) water heating = (65)m × 1000 / (24 × n_m)
(73) total = (66) + (67) + (68) + (69) + (70) + (71) + (72)
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.
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), Table 6d (Z_L light access
factor for L2a daylighting calc).
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from math import cos, exp, pi
from typing import Final
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
def _decimal_window_area_2dp(width: float, height: float) -> float:
"""W × H in Decimal arithmetic, HALF_UP-quantised at 2 d.p. Per
RdSAP10 §15 "Rounding of data" (p.66) "All element areas (gross)
including window areas: 2 d.p." — uses Decimal so the lookup lands
on the exact .005 spec boundary that float multiplication drops
(e.g. 0.65 × 0.70 = 0.4550 exact / 0.45499... in float — float
rounding snaps to 0.45 vs spec 0.46). Matches `heat_transmission.
_decimal_round_half_up_product` so the daylight factor's per-window
areas agree with the fabric cascade."""
d = Decimal(str(width)) * Decimal(str(height))
return float(d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
_DAYS_PER_YEAR: Final[float] = 365.0
_APPLIANCES_E_A_COEFF: Final[float] = 207.8
_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
# Table 5a footnote c) default SFP when no PCDB warm-air-unit SFP is
# lodged: "otherwise 1.5 W/(l/s). These values of SFP include an
# in-use factor." Same default as Table 4f footnote e) for the kWh
# side (see `cert_to_inputs._TABLE_4F_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S`).
_TABLE_5A_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S: Final[float] = 1.5
_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"
class OvershadingCategory(Enum):
"""Table 6d overshading bucket. Maps to light access factor Z_L. SAP
defaults to AVERAGE when the cert hasn't lodged a specific category."""
HEAVY = "heavy"
MORE_THAN_AVERAGE = "more_than_average"
AVERAGE = "average"
VERY_LITTLE = "very_little"
# Table 6d third column — light access factor Z_L by overshading bucket.
_Z_L_BY_OVERSHADING: Final[dict[OvershadingCategory, float]] = {
OvershadingCategory.HEAVY: 0.5,
OvershadingCategory.MORE_THAN_AVERAGE: 0.67,
OvershadingCategory.AVERAGE: 0.83,
OvershadingCategory.VERY_LITTLE: 1.0,
}
# RdSAP §12-1 per-lamp-type defaults: (watts_per_bulb, efficacy_lm_per_w).
# When the cert distinguishes LED vs CFL the per-type values apply;
# combined "low energy lighting" (LEL) — LED/CFL unknown — uses the LEL
# default. Lumens per bulb = watts × efficacy.
_RDSAP_LAMP_LED: Final[tuple[float, float]] = (9.0, 100.0)
_RDSAP_LAMP_CFL: Final[tuple[float, float]] = (19.0, 55.0)
_RDSAP_LAMP_INCANDESCENT: Final[tuple[float, float]] = (60.0, 11.2)
_RDSAP_LAMP_LEL_UNKNOWN: Final[tuple[float, float]] = (15.0, 80.0)
# L5b existing-dwelling C_L,fixed fallback when no fixed-lighting data lodged.
_LIGHTING_L5B_LUMENS_PER_M2: Final[float] = 185.0
# L8c ε_fixed fallback when no fixed lighting present.
_LIGHTING_L8C_EFFICACY_LM_PER_W: Final[float] = 21.3
# Table 6b light transmittance g_L by SAP glazing-type code. Single
# glazed = 0.90; double-glazed variants = 0.80; triple-glazed = 0.70.
# Mirrors the SAP code mapping in cert_to_inputs._g_perpendicular but
# returns the light column, not solar.
#
# Codes 1-7 follow the legacy SAP 10.2 Table 6b ordering (also matched
# by the RdSAP 17 schema for the modal cohort lodgement values 2/3/6).
# Codes 8-15 are RdSAP-21 schema additions (per
# datatypes/epc/domain/epc_codes.csv) — every API-path cert lodges its
# glazing-type integer via the RdSAP 21 enum, and triple-glazed certs
# in the cohort surface as code 14 (triple 2022+) which previously fell
# through to the 0.80 default and over-bonused the daylight factor.
_G_LIGHT_BY_GLAZING_CODE: Final[dict[int, float]] = {
1: 0.90, # single glazed
2: 0.80, # double glazed (air filled, pre-2002)
3: 0.80, # double glazed (air filled, post-2002)
4: 0.80, # double glazed (low-E)
5: 0.80, # double glazed (low-E argon)
6: 0.70, # triple glazed
7: 0.80, # secondary glazing
# RdSAP 21 schema extensions
8: 0.70, # triple glazing, known data
9: 0.70, # triple glazing, installed 2002-2022
10: 0.70, # triple glazing, installed pre-2002
11: 0.80, # secondary glazing, normal emissivity
12: 0.80, # secondary glazing, low emissivity
13: 0.80, # double glazing, installed 2022+
14: 0.70, # triple glazing, installed 2022+
15: 0.90, # single glazing, known data
}
_G_LIGHT_DEFAULT: Final[float] = 0.80 # treat unknowns as DG (modal)
# Table 6c frame factor FF by frame-material substring. PVC, wood,
# composite default to 0.7; metal to 0.8.
_FRAME_FACTOR_BY_MATERIAL_SUBSTR: Final[tuple[tuple[str, float], ...]] = (
("metal", 0.8),
("aluminium", 0.8),
("aluminum", 0.8),
("wood", 0.7),
("pvc", 0.7),
("upvc", 0.7),
("composite", 0.7),
)
_FRAME_FACTOR_DEFAULT: Final[float] = 0.7
# Appendix L lighting constants.
_LIGHTING_LAMBDA_B_COEFF: Final[float] = 11.2 * 59.73
_LIGHTING_LAMBDA_B_EXPONENT: Final[float] = 0.4714
_LIGHTING_C_L_REF_PER_M2: Final[float] = 330.0
_LIGHTING_TOPUP_EFFICACY_LM_PER_W: Final[float] = 21.3
_LIGHTING_PORTABLE_EFFICACY_LM_PER_W: Final[float] = 21.3
_LIGHTING_INTERNAL_FRACTION: Final[float] = 0.85
_LIGHTING_MONTHLY_AMPLITUDE: Final[float] = 0.5
_LIGHTING_MONTHLY_PHASE: Final[float] = 0.2
_MONTHS_IN_YEAR: Final[int] = 12
_DAYS_PER_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
_HOURS_PER_DAY: Final[float] = 24.0
_KWH_TO_WH: Final[float] = 1000.0
_METABOLIC_GAIN_W_PER_OCCUPANT_COL_A: Final[float] = 60.0
_LOSSES_W_PER_OCCUPANT_COL_A: Final[float] = -40.0
_COOKING_W_BASE_COL_A: Final[float] = 35.0
_COOKING_W_PER_OCCUPANT_COL_A: Final[float] = 7.0
def metabolic_monthly_w(*, n_occupants: float) -> tuple[float, ...]:
"""SAP 10.2 §5 line (66) — metabolic gains in watts per month.
Table 5 Column A: G_M = 60 × N watts, constant year-round.
Column B (50 × N) applies only to new-build DPER/TPER calculations.
"""
return tuple(_METABOLIC_GAIN_W_PER_OCCUPANT_COL_A * n_occupants
for _ in range(_MONTHS_IN_YEAR))
def losses_monthly_w(*, n_occupants: float) -> tuple[float, ...]:
"""SAP 10.2 §5 line (71) — internal-gain losses in watts per month.
Table 5: losses = -40 × N watts (constant year-round). Comprises heat
sunk to incoming cold water + evaporation from washing/showering. Sign
is negative — these reduce the dwelling's net internal gains.
"""
return tuple(_LOSSES_W_PER_OCCUPANT_COL_A * n_occupants
for _ in range(_MONTHS_IN_YEAR))
def cooking_monthly_w(*, n_occupants: float) -> tuple[float, ...]:
"""SAP 10.2 §5 line (69) — cooking gains in watts per month.
Table 5 Column A: G_C = 35 + 7 × N watts, constant year-round.
Fuel-agnostic (gas vs electric cooker has same heat gain; only the
§12 cost calc differentiates).
"""
gain = _COOKING_W_BASE_COL_A + _COOKING_W_PER_OCCUPANT_COL_A * n_occupants
return tuple(gain for _ in range(_MONTHS_IN_YEAR))
def appliances_monthly_w(
*,
total_floor_area_m2: float,
n_occupants: float,
) -> tuple[float, ...]:
"""SAP 10.2 §5 line (68) — appliance gains in watts per month.
Appendix L equations L13, L14, L16a (Column A, typical gains):
E_A = 207.8 × (TFA × N)^0.4714 [kWh/yr]
E_A,m = E_A × [1 + 0.157 × cos(2π × (m - 1.78) / 12)] × n_m / 365 [kWh/mo]
G_A,m = E_A,m × 1000 / (24 × n_m) [W]
The cosine peaks ~end-of-January (m=1.78) and troughs ~end-of-July.
All electrical appliance energy stays as internal heat — full 1.0×
conversion. Column B's L16 (0.67×) reduced form is for new-build
DPER/TPER only.
"""
e_a_annual = (
_APPLIANCES_E_A_COEFF
* (total_floor_area_m2 * n_occupants) ** _APPLIANCES_E_A_EXPONENT
)
monthly: list[float] = []
for m_idx, days in enumerate(_DAYS_PER_MONTH):
m = m_idx + 1
factor = 1.0 + _APPLIANCES_MONTHLY_AMPLITUDE * cos(
2.0 * pi * (m - _APPLIANCES_MONTHLY_PHASE) / _MONTHS_IN_YEAR
)
e_a_m_kwh = e_a_annual * factor * days / _DAYS_PER_YEAR
monthly.append(e_a_m_kwh * _KWH_TO_WH / (_HOURS_PER_DAY * days))
return tuple(monthly)
def _lighting_monthly_kwh(
*,
total_floor_area_m2: float,
n_occupants: float,
fixed_lighting_capacity_lm: float,
fixed_lighting_efficacy_lm_per_w: float,
daylight_factor: float,
) -> tuple[float, ...]:
"""SAP 10.2 Appendix L1-L11 — per-month lighting energy in kWh.
Internal helper shared by `annual_lighting_kwh` (sum to get the
worksheet-lodged (232) value) and `lighting_monthly_w` (convert to
watts via L12 internal-fraction + hours-in-month). Surfacing the
monthly kWh tuple as the single source of truth ensures the cost
side and the gains side always agree to within float precision —
Σ(monthly_kwh) IS the (232) lodge by construction.
"""
lambda_b = (
_LIGHTING_LAMBDA_B_COEFF
* (total_floor_area_m2 * n_occupants) ** _LIGHTING_LAMBDA_B_EXPONENT
)
lambda_req = (2.0 / 3.0) * lambda_b * daylight_factor
c_l_ref = _LIGHTING_C_L_REF_PER_M2 * total_floor_area_m2
lambda_prov = (
lambda_req * fixed_lighting_capacity_lm / c_l_ref if c_l_ref > 0 else 0.0
)
lambda_topup = max(0.0, lambda_req / 3.0 - lambda_prov)
e_l_fixed = (
max(lambda_req, lambda_prov) / fixed_lighting_efficacy_lm_per_w
if fixed_lighting_efficacy_lm_per_w > 0
else 0.0
)
e_l_topup = lambda_topup / _LIGHTING_TOPUP_EFFICACY_LM_PER_W
e_l_portable = (
(1.0 / 3.0) * lambda_b * daylight_factor / _LIGHTING_PORTABLE_EFFICACY_LM_PER_W
)
e_l_continuous = e_l_fixed + e_l_topup + e_l_portable
monthly: list[float] = []
for m_idx, days in enumerate(_DAYS_PER_MONTH):
m = m_idx + 1
factor = 1.0 + _LIGHTING_MONTHLY_AMPLITUDE * cos(
2.0 * pi * (m - _LIGHTING_MONTHLY_PHASE) / _MONTHS_IN_YEAR
)
monthly.append(e_l_continuous * factor * days / _DAYS_PER_YEAR)
return tuple(monthly)
def annual_lighting_kwh(
*,
total_floor_area_m2: float,
n_occupants: float,
fixed_lighting_capacity_lm: float,
fixed_lighting_efficacy_lm_per_w: float,
daylight_factor: float,
) -> float:
"""SAP 10.2 line ref (232) — annual lighting kWh AS LODGED.
Sum of the L11 monthly distribution. The L1-L9 formula yields a
"continuous" annual E_L; L11 then redistributes via the cosine
modulation `1 + 0.5·cos(2π(m 0.2)/12)` weighted by n_m/365.
Because the discrete monthly integral Σ(n_m × factor) / 365 =
0.998539 (not 1.0 exactly), the worksheet-lodged (232) value
differs from the continuous E_L by 0.146%. The lodged value is
what fuels the cost-side `inputs.lighting_kwh_per_yr`, so this
function returns Σ(monthly_kwh) directly — same source of truth
as `lighting_monthly_w`.
See `lighting_monthly_w` for the per-kwarg semantics + RdSAP §12-1
lamp-type / L5b / L8c / L2a/L2b fallback rules.
"""
return sum(_lighting_monthly_kwh(
total_floor_area_m2=total_floor_area_m2,
n_occupants=n_occupants,
fixed_lighting_capacity_lm=fixed_lighting_capacity_lm,
fixed_lighting_efficacy_lm_per_w=fixed_lighting_efficacy_lm_per_w,
daylight_factor=daylight_factor,
))
def lighting_monthly_w(
*,
total_floor_area_m2: float,
n_occupants: float,
fixed_lighting_capacity_lm: float,
fixed_lighting_efficacy_lm_per_w: float,
daylight_factor: float,
) -> tuple[float, ...]:
"""SAP 10.2 §5 line (67) — lighting gains in watts per month.
Applies the full Appendix L L1-L12 cascade with Column A (standard
gains) per Table 5. Caller pre-computes:
- fixed_lighting_capacity_lm (C_L,fixed, L5a): Σ(count × lumens) over
all fixed-lighting outlets. For existing dwellings RdSAP §12-1 gives
per-lamp-type defaults (LED 9 W/100 lm/W, CFL 19 W/55 lm/W, LEL
15 W/80 lm/W, incandescent 60 W/11.2 lm/W). L5b fallback C_L,fixed
= 185 × TFA applies only if no fixed lighting data lodged.
- fixed_lighting_efficacy_lm_per_w (ε_fixed, L8): C_L,fixed / Σpower.
Fall back to 21.3 lm/W per L8c when no fixed lighting present.
- daylight_factor (C_daylight, L2b): C_daylight = 52.2 G_L² - 9.94 G_L
+ 1.433 for G_L ≤ 0.095, else 0.96. G_L per L2a uses **Table 6d
third column (light access factor Z_L), NOT solar Z** — a common
source of bias when conflated with the §6 solar calc.
L12 reduced-gain branch (L12a, used for new-build DPER/TPER) is deferred.
"""
monthly_kwh = _lighting_monthly_kwh(
total_floor_area_m2=total_floor_area_m2,
n_occupants=n_occupants,
fixed_lighting_capacity_lm=fixed_lighting_capacity_lm,
fixed_lighting_efficacy_lm_per_w=fixed_lighting_efficacy_lm_per_w,
daylight_factor=daylight_factor,
)
return tuple(
kwh * _LIGHTING_INTERNAL_FRACTION * _KWH_TO_WH
/ (_HOURS_PER_DAY * days)
for kwh, days in zip(monthly_kwh, _DAYS_PER_MONTH)
)
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 total_internal_gains_monthly_w(
*,
metabolic_monthly_w: tuple[float, ...],
lighting_monthly_w: tuple[float, ...],
appliances_monthly_w: tuple[float, ...],
cooking_monthly_w: tuple[float, ...],
pumps_fans_monthly_w: tuple[float, ...],
losses_monthly_w: tuple[float, ...],
water_heating_gains_monthly_w: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP10.2 §5 line (73) — total internal gains.
(73)m = (66)m + (67)m + (68)m + (69)m + (70)m + (71)m + (72)m
Pure element-wise sum across the seven gain streams. (71)m carries a
negative sign (losses) so the contribution is a subtraction.
"""
return tuple(
m + l + a + c + p + s + w
for m, l, a, c, p, s, w in zip(
metabolic_monthly_w,
lighting_monthly_w,
appliances_monthly_w,
cooking_monthly_w,
pumps_fans_monthly_w,
losses_monthly_w,
water_heating_gains_monthly_w,
)
)
@dataclass(frozen=True)
class InternalGainsResult:
"""SAP10.2 §5 line refs (66)..(73), each a 12-tuple of watts per month.
Returned by `internal_gains_from_cert`. Downstream §6/§7/§9 calculators
consume `total_internal_gains_monthly_w` directly; the per-line tuples
are exposed for worksheet conformance + audit. Field names mirror the
SAP10.2 line refs.
"""
metabolic_monthly_w: tuple[float, ...] # line (66)
lighting_monthly_w: tuple[float, ...] # line (67)
appliances_monthly_w: tuple[float, ...] # line (68)
cooking_monthly_w: tuple[float, ...] # line (69)
pumps_fans_monthly_w: tuple[float, ...] # line (70)
losses_monthly_w: tuple[float, ...] # line (71)
water_heating_gains_monthly_w: tuple[float, ...] # line (72)
total_internal_gains_monthly_w: tuple[float, ...] # line (73)
lighting_kwh_per_yr: float # line (232) — Appendix L annual kWh; fuels cost side
def water_heating_gains_monthly_w(
*,
heat_gains_from_water_heating_monthly_kwh: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP 10.2 §5 line (72) — water-heating contribution to internal gains.
Table 5 row "Water heating": G_WH,m = 1000 × (65)m / (n_m × 24). Pure
unit conversion from §4 line (65)m (kWh/month) to watts. (65)m itself
already encodes the 25%/80% spec-recovery factors for delivered-heat
vs pipe-side losses (see §4 heat_gains_from_water_heating_monthly_kwh).
"""
return tuple(
kwh * _KWH_TO_WH / (days * _HOURS_PER_DAY)
for kwh, days in zip(heat_gains_from_water_heating_monthly_kwh, _DAYS_PER_MONTH)
)
def _assumed_occupancy(total_floor_area_m2: float) -> float:
"""Appendix J Table 1b occupancy default from TFA.
Duplicated from `water_heating.assumed_occupancy` to avoid the §4
import dependency in §5 — keeps internal_gains.py self-contained.
"""
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 _g_light(w: SapWindow) -> float:
"""Table 6b light transmittance g_L by glazing-type code. Defaults
to 0.80 (DG, modal across UK certs) when the cert lodges a code we
don't recognise."""
if isinstance(w.glazing_type, int) and w.glazing_type in _G_LIGHT_BY_GLAZING_CODE:
return _G_LIGHT_BY_GLAZING_CODE[w.glazing_type]
return _G_LIGHT_DEFAULT
def _frame_factor(w: SapWindow) -> float:
"""Table 6c frame factor. Prefer cert's `frame_factor`; else look up
by `frame_material` substring."""
if w.frame_factor is not None:
return float(w.frame_factor)
material = (w.frame_material or "").lower()
for needle, ff in _FRAME_FACTOR_BY_MATERIAL_SUBSTR:
if needle in material:
return ff
return _FRAME_FACTOR_DEFAULT
def _lighting_capacity_and_efficacy_from_cert(
epc: EpcPropertyData,
) -> tuple[float, float]:
"""Aggregate C_L,fixed (lm) and ε_fixed (lm/W) from the cert's bulb
counts via RdSAP §12-1 per-lamp-type defaults. Falls back to L5b
(185 × TFA lumens) + L8c (21.3 lm/W) when no bulb data lodged."""
led = epc.led_fixed_lighting_bulbs_count or 0
cfl = epc.cfl_fixed_lighting_bulbs_count or 0
inc = epc.incandescent_fixed_lighting_bulbs_count or 0
lel = epc.low_energy_fixed_lighting_bulbs_count or 0
led_w, led_eff = _RDSAP_LAMP_LED
cfl_w, cfl_eff = _RDSAP_LAMP_CFL
inc_w, inc_eff = _RDSAP_LAMP_INCANDESCENT
lel_w, lel_eff = _RDSAP_LAMP_LEL_UNKNOWN
total_lumens = (
led * led_w * led_eff
+ cfl * cfl_w * cfl_eff
+ inc * inc_w * inc_eff
+ lel * lel_w * lel_eff
)
total_power_w = led * led_w + cfl * cfl_w + inc * inc_w + lel * lel_w
if total_power_w <= 0.0:
tfa = float(epc.total_floor_area_m2 or 0.0)
return (_LIGHTING_L5B_LUMENS_PER_M2 * tfa, _LIGHTING_L8C_EFFICACY_LM_PER_W)
return (total_lumens, total_lumens / total_power_w)
def _daylight_factor_from_cert(
epc: EpcPropertyData,
overshading: OvershadingCategory,
rooflight_total_area_m2: float,
) -> float:
"""Compute C_daylight via L2a + L2b from the cert's windows + any
rooflights.
Per SAP 10.2 Appendix L §L2a (PDF p.88) the G_L numerator sums each
window's `A_w × g_L × FF × Z_L` product — per-window, NOT a single
dwelling-wide default. Vertical glazing uses Table 6d's overshading-
bucketed Z_L (note 3: same factor across the dwelling). Rooflights
use Z_L = 1.0 regardless of overshading (Table 6d note 2).
Per-rooflight g_L and FF route via `SapRoofWindow.glazing_type` +
`frame_factor` — mirrors the per-window dispatch on `sap_windows`.
Pre-S0380.110 the rooflight contribution defaulted to
`total_area × 0.80 × 0.70`, overcounting Triple-glazed rooflights
(g_L=0.70) and any non-default frame factor.
When `total_floor_area_m2` is missing or no windows are lodged the
SAP "no-bonus" default 1.433 is used.
"""
tfa = float(epc.total_floor_area_m2 or 0.0)
if tfa <= 0.0 or (not epc.sap_windows and rooflight_total_area_m2 <= 0.0):
return 1.433
z_l = _Z_L_BY_OVERSHADING[overshading]
# RdSAP 10 §15 "Rounding of data" (p.66): "All element areas (gross)
# including window areas: 2 d.p." — mirrors solar_gains and heat_
# transmission so G_L sees the same area as the fabric cascade.
wall_g_l_numerator = sum(
_decimal_window_area_2dp(float(w.window_width), float(w.window_height))
* _g_light(w) * _frame_factor(w) * z_l
for w in epc.sap_windows
)
rooflight_g_l_numerator = sum(
float(rw.area_m2)
* _G_LIGHT_BY_GLAZING_CODE.get(rw.glazing_type, _G_LIGHT_DEFAULT)
* float(rw.frame_factor)
* 1.0 # Z_L = 1.0 for rooflights per Table 6d note 2
for rw in epc.sap_roof_windows or []
)
g_l = 0.9 * (wall_g_l_numerator + rooflight_g_l_numerator) / tfa
if g_l > 0.095:
return 0.96
return 52.2 * g_l * g_l - 9.94 * g_l + 1.433
def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory:
"""Map first main-heating detail's central_heating_pump_age_str to a
Table 5a bucket. Elmhurst lodges "Pre 2013" / "Post 2013" / "Unknown"
/ None on each `MainHeatingDetail` (nested under `epc.sap_heating`)."""
sap_heating = getattr(epc, "sap_heating", None)
details = getattr(sap_heating, "main_heating_details", None) or []
age_str = ""
if details:
age_str = (details[0].central_heating_pump_age_str or "").lower()
if "post" in age_str or "2013 or later" in age_str:
return PumpDateCategory.NEW_2013_OR_LATER
if "pre" in age_str or "2012" in age_str:
return PumpDateCategory.OLD_2012_OR_EARLIER
return PumpDateCategory.UNKNOWN
# SAP 10.2 Table 5a Note a) (PDF p.177): "Not applicable for electric
# heat pumps from database." The pump GAIN (worksheet line 70) is
# omitted only for HP-category systems. Where the cert lodges a
# non-HP main system alongside an HP (e.g. cert 000565 with HP main 1
# + gas boiler main 2), the non-HP system's pump still applies — so
# the gain is zero ONLY when EVERY lodged main system is an HP.
#
# (Distinct from Table 4f, which governs pump ELECTRICITY accounting:
# HP pump electricity is hidden in the COP regardless of whether
# secondary boilers are present.)
_HEAT_PUMP_MAIN_HEATING_CATEGORY: Final[int] = 4
# SAP 10.2 Table 5a row "Central heating pump in heated space" (PDF
# p.177) only applies to mains with a water-loop circulation pump.
# Dry mains — electric storage heaters (Table 4a Cat 7 codes 401-409,
# 421), warm-air heaters without HPs (Cat 9), solid-fuel room heaters
# without back-boilers (codes 631-636 minus the boiler combos at
# 151-161), electric direct-acting heaters — have no primary water
# loop, so the row simply doesn't apply and worksheet (70)m = 0.
#
# Mirrors `cert_to_inputs._WET_BOILER_CODE_RANGES` (Table 4f kWh
# accounting). Kept as a sibling constant here so the worksheet layer
# does not depend on rdsap. Same code-range coverage:
# 101-141 Gas/oil boilers (Table 4b)
# 151-161 Solid-fuel boilers + back-boiler combos (Table 4a)
# 191-196 Electric boilers + CPSU (Table 4a)
_WET_BOILER_SAP_CODE_RANGES: Final[tuple[range, ...]] = (
range(101, 142),
range(151, 162),
range(191, 197),
)
# Heat-emitter types (Table 4d) that imply a wet primary loop —
# radiators (1) and fan-coil units (3) require water-side delivery.
# UFH (2) excluded because it can be wet OR electric (in-screed cable);
# the SAP code or category disambiguates. Warm-air (4) and electric
# storage / direct-acting emitters are dry. Used only as a fallback
# when no SAP code / PCDB index / category is lodged (e.g. the 000490
# hand-built unit-test fixture).
_WET_HEAT_EMITTER_TYPES: Final[frozenset[int]] = frozenset({1, 3})
def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool:
"""SAP 10.2 Table 5a row "Central heating pump in heated space"
(PDF p.177) — predicate for whether the pump-gain row applies.
Identifies wet, non-HP mains by (any of):
- sap_main_heating_code in Table 4a/4b wet-boiler ranges
(gas/oil/solid-fuel/electric boilers)
- main_heating_index_number lodged + category not HP (PCDB
Table 322 gas/oil boiler record)
- main_heating_category in {1, 2} (RdSAP "central heating" with
or without separate HW — both wet)
- heat_emitter_type in {1 radiators, 3 fan-coil} (Table 4d wet
emitter types; UFH/2 excluded as it can be electric)
HP mains (category 4) are skipped per Table 5a Note a) "Not
applicable for electric heat pumps from database." Where any
non-HP main qualifies as wet, the pump gain applies (per the
same note's clause about two mains in the same space).
Mirrors `cert_to_inputs._is_wet_boiler_main` — see docstring there
for the kWh-side parallel in Table 4f.
Electric heat pump exception per SAP 10.2 Appendix N3.1 (PDF p.105):
"For electric heat pumps: The electricity used by the water
circulation pump or fan is included within the calculated annual
space and hot water heating efficiency and is not included in
worksheet (230c). **The default heat gain from Table 5a is included
via worksheet (70).**" → Cat 4 HPs WITHOUT a PCDB record (Table 4a
default cascade) get the Table 5a default pump gain. Cat 4 HPs
WITH a PCDB record (Table 362) embed the pump gain in the COP →
no separate Table 5a gain. Cat 5 warm-air HPs (codes 521/523-527)
distribute via fans, not a water pump — handled by the warm-air
fan row of Table 5a (see `_any_main_system_has_warm_air_distribution`).
"""
details = epc.sap_heating.main_heating_details
if not details:
return False
for d in details:
if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY:
# PCDB Table 362 record → pump electricity AND gain are
# embedded in COP (Appendix N1.2.1); no separate gain row.
if d.main_heating_index_number is not None:
continue
# Cat 5 warm-air HP (codes 521/523-527) → no water pump.
code = d.sap_main_heating_code
if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES:
continue
# Cat 4 HP, Table 4a default cascade → apply Table 5a
# pump gain per Appendix N3.1.
return True
code = d.sap_main_heating_code
if code is not None and any(
code in r for r in _WET_BOILER_SAP_CODE_RANGES
):
return True
if d.main_heating_index_number is not None:
return True
if d.main_heating_category in {1, 2}:
return True
if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES:
return True
return False
# SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. The
# Table 5a "Warm air heating system fans" gain (and Table 4f
# electricity row) fire for these mains:
# - Cat 5 (heat pumps with warm-air distribution): 521, 523-527
# - Cat 9 (warm air NOT heat pump): 501-515, 520
# Mirrors `cert_to_inputs._TABLE_4A_WARM_AIR_SAP_CODES` — kept here as
# a sibling so the worksheet layer does not depend on rdsap. Keep in
# sync manually with the cert_to_inputs constant.
_TABLE_4A_WARM_AIR_SAP_CODES: Final[frozenset[int]] = frozenset({
501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 520,
512, 513, 514, 515,
521, 523, 524, 525, 526, 527,
})
# SAP 10.2 Table 5a footnote c) (PDF p.177) for the "Warm air heating
# system fans" row: "If the heating system is a warm air unit and
# there is balanced whole house mechanical ventilation, the gains for
# the warm air system should not be included."
# Mirrors `cert_to_inputs._BALANCED_MV_KIND_NAMES`. Balanced MV kinds
# = MVHR (balanced with HR) + MV (balanced without HR). MEV, PIV from
# outside, and natural ventilation do NOT trigger the omission.
_BALANCED_MV_KIND_NAMES: Final[frozenset[str]] = frozenset({"MVHR", "MV"})
def _any_main_system_has_warm_air_distribution(epc: EpcPropertyData) -> bool:
"""True iff any lodged main heating system distributes heat as warm
air (Table 4a Cat 5 HPs with warm-air dist. + Cat 9 warm-air not
HP) — qualifying for the SAP 10.2 Table 5a "Warm air heating
system fans" gain row.
"""
details = epc.sap_heating.main_heating_details
if not details:
return False
for d in details:
code = d.sap_main_heating_code
if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES:
return True
return False
def _has_balanced_mechanical_ventilation(epc: EpcPropertyData) -> bool:
"""SAP 10.2 Table 5a footnote c) / Table 4f footnote e) balanced-MV
gate: True when the cert lodges either MVHR or MV (both balanced).
Mirrors `cert_to_inputs._has_balanced_mechanical_ventilation`.
"""
sv = getattr(epc, "sap_ventilation", None)
if sv is None:
return False
name = getattr(sv, "mechanical_ventilation_kind", None)
return name in _BALANCED_MV_KIND_NAMES
def internal_gains_from_cert(
*,
epc: EpcPropertyData,
dwelling_volume_m3: float,
heat_gains_from_water_heating_monthly_kwh: tuple[float, ...],
overshading: OvershadingCategory = OvershadingCategory.AVERAGE,
rooflight_total_area_m2: float = 0.0,
) -> InternalGainsResult:
"""SAP 10.2 §5 orchestrator — chain every line ref (66)..(73) for the
dwelling identified by `epc`.
Inputs:
epc cert (TFA, bulbs, windows, pump)
dwelling_volume_m3 §1 line (5) for fan-W formulas
heat_gains_from_water_heating_monthly_kwh §4 line (65)m — see Q5 grill
overshading Table 6d bucket (default AVERAGE)
Coverage caveats for the current corpus:
- Lighting: full Appendix L L1-L12 with RdSAP §12-1 per-lamp defaults
and the L2a window-driven C_daylight. Conformant for the 6 Elmhurst
fixtures (all DG, PVC frame, average overshading) to ~0.5%.
- Pumps/fans: central heating pump only. Liquid-fuel pump, warm-air
fans, PIV, balanced MV w/o HR, HIU branches are reachable via the
leaf fns but not yet derivable from the cert here. Mirrors §4's
combi-only happy-path scope.
"""
tfa = float(epc.total_floor_area_m2 or 0.0)
n = _assumed_occupancy(tfa)
metabolic = metabolic_monthly_w(n_occupants=n)
cooking = cooking_monthly_w(n_occupants=n)
losses = losses_monthly_w(n_occupants=n)
appliances = appliances_monthly_w(total_floor_area_m2=tfa, n_occupants=n)
c_l_fixed, eff_fixed = _lighting_capacity_and_efficacy_from_cert(epc)
c_daylight = _daylight_factor_from_cert(
epc, overshading, rooflight_total_area_m2=rooflight_total_area_m2
)
lighting_kwh = annual_lighting_kwh(
total_floor_area_m2=tfa,
n_occupants=n,
fixed_lighting_capacity_lm=c_l_fixed,
fixed_lighting_efficacy_lm_per_w=eff_fixed,
daylight_factor=c_daylight,
)
lighting = lighting_monthly_w(
total_floor_area_m2=tfa,
n_occupants=n,
fixed_lighting_capacity_lm=c_l_fixed,
fixed_lighting_efficacy_lm_per_w=eff_fixed,
daylight_factor=c_daylight,
)
# SAP 10.2 Table 5a row "Central heating pump in heated space"
# (PDF p.177) — the gain applies only to mains with a water-loop
# circulation pump. Excludes:
# (i) HP mains per Table 5a Note a) "Not applicable for electric
# heat pumps from database" (cert 0380 HP-only → 0 W),
# (ii) Dry mains with no primary water loop — electric storage
# heaters (Cat 7), warm-air heaters (Cat 9), solid-fuel room
# heaters without back-boilers, electric direct-acting.
# Worksheet (70)m = 0 across the 41-variant controlled-
# variable corpus for every dry main; see
# `_any_main_system_has_central_heating_pump`.
# Mixed HP + wet-boiler mains (cert 000565: HP main 1 + gas boiler
# main 2) DO carry the gain via the non-HP main's pump (worksheet
# line 70 confirms 3.0000 W in 8 winter months, 0 in summer).
if _any_main_system_has_central_heating_pump(epc):
pump_w = central_heating_pump_w(
date_category=_pump_date_category_from_cert(epc)
)
else:
pump_w = 0.0
# SAP 10.2 Table 5a row "Warm air heating system fans a) c)" (PDF
# p.177): SFP × 0.04 × V W, heating-season only per footnote a),
# omitted when balanced whole-house MV is present per footnote c).
# Default SFP 1.5 W/(l/s) per footnote c) — no PCDB warm-air-unit
# SFP lookup yet. Sister to the Table 4f kWh-side wiring in
# `_table_4f_warm_air_heating_fans_kwh` (S0380.158). Cohort
# entry point: heating-systems corpus electric 2 (code 524 ASHP
# warm-air, V=227.25 m³, no MV → 13.6350 W matches worksheet (70)).
if (
_any_main_system_has_warm_air_distribution(epc)
and not _has_balanced_mechanical_ventilation(epc)
):
warm_air_fan_w = warm_air_heating_fan_w(
sfp_w_per_l_per_s=_TABLE_5A_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S,
dwelling_volume_m3=dwelling_volume_m3,
)
else:
warm_air_fan_w = 0.0
# Liquid-fuel + PIV + MV + HIU branches default to zero for the
# combi-gas-natural-vent population; future slices will detect them
# from epc.main_heating_details + epc.mechanical_ventilation.
pumps_fans = pumps_fans_monthly_w(
heating_season_w=pump_w + warm_air_fan_w,
year_round_w=0.0,
)
water_heating_gains = water_heating_gains_monthly_w(
heat_gains_from_water_heating_monthly_kwh=heat_gains_from_water_heating_monthly_kwh,
)
total = total_internal_gains_monthly_w(
metabolic_monthly_w=metabolic,
lighting_monthly_w=lighting,
appliances_monthly_w=appliances,
cooking_monthly_w=cooking,
pumps_fans_monthly_w=pumps_fans,
losses_monthly_w=losses,
water_heating_gains_monthly_w=water_heating_gains,
)
return InternalGainsResult(
metabolic_monthly_w=metabolic,
lighting_monthly_w=lighting,
appliances_monthly_w=appliances,
cooking_monthly_w=cooking,
pumps_fans_monthly_w=pumps_fans,
losses_monthly_w=losses,
water_heating_gains_monthly_w=water_heating_gains,
total_internal_gains_monthly_w=total,
lighting_kwh_per_yr=lighting_kwh,
)