mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.161: SAP 10.2 Table 5a warm-air fan gain (SFP × 0.04 × V)
SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans
a) c)" computes the gain as SFP × 0.04 × V (W). Footnote c) sets
the default SFP to 1.5 W/(l/s) when no PCDB warm-air-unit record
is lodged; footnote a) applies the heating-season-only mask
(zero in summer months). Footnote c) further omits the gain when
the dwelling has balanced whole-house mechanical ventilation
(MVHR / MV) — same omission as the Table 4f kWh-side footnote e).
Pre-slice the cascade's `internal_gains_from_cert` only wired the
central-heating-pump row of Table 5a; the warm-air-fan gain helper
(`warm_air_heating_fan_w`) existed but was unwired. The kWh-side
parallel (Table 4f, 136.35 kWh/yr) was wired in S0380.158 — this
slice closes the symmetry on the gain side.
Per-line walk on electric 2 (SAP code 524 = Cat 5 ASHP with
warm-air distribution, V = 227.25 m³, no balanced MV):
worksheet (70)[Jan] = 13.6350 W
cascade (70)[Jan] = 0.0000 W delta = -13.635 W
worksheet (98c)[Jan] = 1600.43 kWh
cascade (98c)[Jan] = 1608.12 kWh delta = +7.69 kWh
13.635 W = 1.5 × 0.04 × 227.25 exactly. The -13.6 W winter gain
shortfall propagates through the §7 utilisation cascade and over-
states cascade SH demand by ~57 kWh/yr (cascade 9483 vs worksheet
9426), under-charging cost by ~£2.50 with opposite sign to the
S0380.156-.158 closures.
Fix: new `_any_main_system_has_warm_air_distribution(epc)` +
`_has_balanced_mechanical_ventilation(epc)` predicates in
`internal_gains.py`, mirroring `cert_to_inputs._TABLE_4A_WARM_AIR_SAP_CODES`
+ `_BALANCED_MV_KIND_NAMES` (kept here as siblings so the worksheet
layer stays free of rdsap deps). Orchestrator wires
`warm_air_heating_fan_w(sfp=1.5, dwelling_volume_m3)` into the
heating-season term of `pumps_fans_monthly_w` when warm-air
distribution is present and balanced MV is not.
Closures electric 2:
ΔSAP_c -0.1087 → -0.0000 EXACT
Δcost +£2.50 → -£0.00 EXACT
ΔCO2 +16.54 → +11.95 (joins lighting-PE deferred cohort)
ΔPE +97.69 → +48.66 (joins lighting-PE deferred cohort)
Electric 2 joins the 15-variant lighting-PE deferred cohort
(electric 1 + electric 3/5/6/7/8/9 + solid fuel 5/6/7/8 + solid
fuel 4/9/10/11 + electric 2) where SAP/cost are EXACT but PE/CO2
carry an Elmhurst-vs-spec MONTHLY-factor offset (cohort uses
Table 12 annual factors on the off-peak HW immersion line; spec
mandates Table 12d/12e monthly per the header).
Verbatim spec quote (SAP 10.2 Table 5a row "Warm air heating
system fans a) c)", PDF p.177):
"Warm air heating system fans a) c) SFP × 0.04 × V"
Footnote c): "SFP is the specific fan power from the database
record for the warm air unit if applicable; otherwise
1.5 W/(l/s). These values of SFP include an in-use factor.
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."
Footnote a): "... Set to zero in summer months. ..."
Σ |ΔSAP_c| across 25-variant cohort: 0.18 → 0.07 (~60% reduction).
No regressions on the other 24 variants or any golden fixture —
gate keyed on Table 4a warm-air SAP code frozenset (only electric
2 in the corpus has a code in that set).
Tests: 905 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
af34ad9846
commit
482ce88b55
3 changed files with 160 additions and 4 deletions
|
|
@ -319,10 +319,25 @@ class _CorpusExpectation:
|
|||
# variants; only the lighting-PE +48.66 / +11.95 CO2 deferred quirk
|
||||
# remains (same offset as electric 1 + solid fuel 5/6/7/8). Cluster
|
||||
# Σ|ΔSAP_c| 1.06 → 0.00 in one slice.
|
||||
#
|
||||
# Slice S0380.161 closed electric 2 (Cat 5 warm-air HP, code 524) by
|
||||
# wiring the SAP 10.2 Table 5a row "Warm air heating system fans
|
||||
# a) c)" (PDF p.177) GAIN side. Pre-slice S0380.158 wired the kWh
|
||||
# side (136.35 kWh/yr via Table 4f) but the parallel GAIN row was
|
||||
# never wired, so cascade (70) m = 0 every month vs worksheet 13.6350
|
||||
# W in heating months (= 1.5 × 0.04 × 227.25 with SFP default 1.5
|
||||
# W/(l/s) per footnote c). The -13.6 W winter gain shortfall over-
|
||||
# stated cascade SH demand by ~57 kWh/yr (cascade 9483 vs worksheet
|
||||
# 9426), under-charging cost by ~£2.50 with opposite sign. New
|
||||
# `_any_main_system_has_warm_air_distribution` + `_has_balanced_
|
||||
# mechanical_ventilation` predicates + leaf wiring in the orchestrator.
|
||||
# Electric 2 SAP -0.1087 → -0.0000 EXACT; joins the lighting-PE
|
||||
# deferred cohort (CO2 +11.95 / PE +48.66). Cohort Σ|ΔSAP_c|
|
||||
# 0.18 → 0.07 in one slice.
|
||||
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
||||
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=-0.0240, expected_cost_resid_gbp=+0.5536, expected_co2_resid_kg=+7.3267, expected_pe_resid_kwh=+36.3435),
|
||||
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6605),
|
||||
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1087, expected_cost_resid_gbp=+2.5037, expected_co2_resid_kg=+16.5405, expected_pe_resid_kwh=+97.6875),
|
||||
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604),
|
||||
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604),
|
||||
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604),
|
||||
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,11 @@ _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})
|
||||
|
||||
|
|
@ -730,6 +735,59 @@ def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool:
|
|||
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,
|
||||
|
|
@ -803,11 +861,29 @@ def internal_gains_from_cert(
|
|||
)
|
||||
else:
|
||||
pump_w = 0.0
|
||||
# Liquid-fuel + warm-air + PIV + MV + HIU branches default to zero for
|
||||
# the combi-gas-natural-vent population; future slices will detect them
|
||||
# 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,
|
||||
heating_season_w=pump_w + warm_air_fan_w,
|
||||
year_round_w=0.0,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -575,6 +575,71 @@ def test_internal_gains_from_cert_reproduces_000490_worksheet_end_to_end() -> No
|
|||
assert result.total_internal_gains_monthly_w[m] == pytest.approx(expected_73[m], abs=1e-1), f"(73) month {m+1}"
|
||||
|
||||
|
||||
def test_internal_gains_pumps_fans_adds_warm_air_fan_gain_for_cat5_hp_main() -> None:
|
||||
"""SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans a) c)"
|
||||
— gain = SFP × 0.04 × V (W). Default SFP = 1.5 W/(l/s) per footnote c
|
||||
when no PCDB warm-air-unit record is lodged. Heating-season mask per
|
||||
footnote a). Worksheet evidence: heating-systems corpus electric 2
|
||||
(SAP code 524 ASHP with warm-air distribution, V = 227.25 m³,
|
||||
no balanced MV) lodges (70) = 13.6350 W in heating months
|
||||
(Jan-May, Oct-Dec), 0 W in summer (Jun-Sep) — exactly
|
||||
1.5 × 0.04 × 227.25 = 13.635.
|
||||
|
||||
Sister to S0380.158 which wired the Table 4f KWH side of the same
|
||||
row (136.35 kWh/yr). This slice wires the gain side. Footnote c)
|
||||
omission "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" parallels Table 4f
|
||||
footnote e) for the kWh.
|
||||
"""
|
||||
# Arrange — Cat 5 warm-air HP (code 524), no balanced MV.
|
||||
sap_heating = SapHeating(
|
||||
instantaneous_wwhrs=InstantaneousWwhrs(),
|
||||
main_heating_details=[
|
||||
MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=30,
|
||||
heat_emitter_type="",
|
||||
emitter_temperature="",
|
||||
sap_main_heating_code=524,
|
||||
main_heating_category=4, # HP per Table 4a Cat 5
|
||||
main_heating_control=2401,
|
||||
central_heating_pump_age_str="Unknown",
|
||||
),
|
||||
],
|
||||
has_fixed_air_conditioning=False,
|
||||
)
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=90.0,
|
||||
low_energy_fixed_lighting_bulbs_count=6,
|
||||
sap_windows=[],
|
||||
sap_heating=sap_heating,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = internal_gains_from_cert(
|
||||
epc=epc,
|
||||
dwelling_volume_m3=227.25,
|
||||
heat_gains_from_water_heating_monthly_kwh=(0.0,) * 12,
|
||||
overshading=OvershadingCategory.AVERAGE,
|
||||
)
|
||||
|
||||
# Assert — 13.635 W (= 1.5 × 0.04 × 227.25) in heating months,
|
||||
# zero in summer (Jun-Sep) per footnote a) heating-season mask.
|
||||
expected_warm_air_fan_w = 1.5 * 0.04 * 227.25 # 13.635
|
||||
expected = (
|
||||
expected_warm_air_fan_w, expected_warm_air_fan_w, expected_warm_air_fan_w,
|
||||
expected_warm_air_fan_w, expected_warm_air_fan_w,
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
expected_warm_air_fan_w, expected_warm_air_fan_w, expected_warm_air_fan_w,
|
||||
)
|
||||
for m in range(12):
|
||||
assert abs(result.pumps_fans_monthly_w[m] - expected[m]) <= 1e-6, (
|
||||
f"(70) month {m+1} = {result.pumps_fans_monthly_w[m]:.4f}, "
|
||||
f"expected {expected[m]:.4f}"
|
||||
)
|
||||
|
||||
|
||||
def test_internal_gains_pumps_fans_is_zero_for_electric_storage_heater_main() -> None:
|
||||
"""SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated
|
||||
space" — the gain applies only to mains with a water-loop circulation
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue