mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
938 lines
38 KiB
Python
938 lines
38 KiB
Python
"""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,
|
||
)
|
||
|
||
|