mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.149: Table 4f — circulation pump dispatch by pump age + wet-boiler gate
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" — Heating system circulation pump rows:
Circulation pump, 2013 or later 41 kWh/yr
Circulation pump, 2012 or earlier 165 kWh/yr
Circulation pump, unknown date 115 kWh/yr
Pre-slice the cascade hardcoded `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY[2]
= 160 kWh/yr` (115 Unknown CH + 45 gas flue fan) for category=2 gas
boilers and fell through to `_DEFAULT_PUMPS_FANS_KWH_PER_YR = 130`
for any other category. Both shortcuts ignored the per-cert
`central_heating_pump_age` lodging AND incorrectly applied
circulation pump electricity to dry electric storage / direct-acting
/ room heater systems (no primary water loop).
Implementation:
- Mapper: `_elmhurst_pump_age_int` now recognises both "Pre 2013"
and "2012 or earlier" string forms as the SAP10 enum 1 (Pre 2013).
Pre-slice "2012 or earlier" silently returned 2 (2013 or later)
on the entire oil corpus, mis-applying the 41 kWh post-2013
circulation pump to certs that lodge "2012 or earlier" via
Elmhurst Summary §14 "Heat pump age".
- New `_is_wet_boiler_main(main)` gate: identifies wet-boiler
systems by Table 4a/4b code range (101-141 gas/oil, 151-161
solid fuel, 191-196 electric boilers), PCDB Table 322 record,
or category ∈ {1, 2} fallback. Heat pumps (cat 4) return False
per Table 4f note "Not applicable for electric heat pumps from
database". Electric storage / direct / room heater codes
(401-499, 601-699) return False — they have no primary loop.
- New `_table_4f_circulation_pump_kwh(main)` dispatches on
`central_heating_pump_age`:
None / 0 → 115 kWh (Unknown date)
1 → 165 kWh (Pre 2013 / 2012 or earlier)
2 → 41 kWh (2013 or later)
- New `_table_4f_main_1_gas_boiler_flue_fan_kwh(main)` extracts
the gas-flue-fan 45 kWh logic from the old category dispatch.
Gated on `_is_wet_boiler_main` + gas fuel + fan_flue_present.
- Remove `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` and
`_DEFAULT_PUMPS_FANS_KWH_PER_YR` constants (the new helpers
replace both).
Worksheet evidence for the wet-boiler gate:
electric 1 (code 191 electric boiler): ws (230c) = 41 kWh ✓
electric 5 (code 402 electric storage): ws (231) = 0 kWh ✗
solid fuel 2 (code 158 anthracite): ws (230c) = 41 kWh ✓
solid fuel 9 (code 636 wood stove): ws (231) = 0 kWh ✗
oil 1 (code 127 condensing oil): ws (230c) = 165 kWh ✓
oil pcdb 3 (PCDB 18573): ws (230c) = 41 kWh ✓
Cascade impact across heating-systems corpus (vs S0380.148 state):
| Variant | SAP Δ | Cause |
|----------------|--------------|-------|
| oil 1 | +0.60→+0.40 | 165 + 100 = 265 ≡ worksheet exact |
| oil pcdb 1/2 | -0.15→+0.36 | 41 + 100 = 141 ≡ ws exact |
| oil pcdb 3 | +0.59→+0.39 | same |
| pcdb 1 | -0.03→+0.50 | 41 + 100 = 141 ≡ ws (was over) |
| electric 1 | -0.06→+0.45 | 41 (wet electric boiler) |
| electric 3-9 | -0.1..-1.4→ | 0 (dry storage/UFH) |
| | +0.5..+0.6 | was 130 default; now 0 |
| solid fuel 2-8 | various | 41 (boilers) — partial closures |
| solid fuel 9-11| -0.2→+0.5 | 0 (room heaters) — was 130 |
Re-pins reflect spec-correct application. Per
[[feedback-software-no-special-handling]]: pre-slice near-zero pins
were masking pre-existing offsetting cascade gaps; spec correctness
unmasks them.
Golden fixtures impact:
- cert 0240 (dual oil combi, pump_age=0 Unknown): PE +2.52→+2.18
- cert 0390 (Firebird PCDF oil, pump_age=0): PE -28.08→-28.27
- cert 6035 (gas combi, pump_age=2 post-2013): PE +47.29→+46.42
Cert 6035 closer to zero (post-2013 41 kWh < pre-slice 115 unknown).
Cert 0240/0390 small shifts from removing the gas-cat-2 hardcoded
160 path for oil mains.
Tests:
- test_sap_table_4f_circulation_pump_dispatches_per_central_heating_
pump_age — asserts oil 1 inputs.pumps_fans_kwh_per_yr == 265
(165 Pre 2013 + 100 liquid fuel) ± 1.0.
- test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
100_kwh (S0380.148) still passes.
Extended handover suite: 892 pass, 0 fail. Pyright net-improved
(removed unused `main_category` variable, file 33→32 errors).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1b1f45b679
commit
35ea664db8
5 changed files with 245 additions and 50 deletions
|
|
@ -220,20 +220,20 @@ class _CorpusExpectation:
|
|||
# for codes 401/402) remains the open driver of those SAP residuals.
|
||||
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
||||
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017),
|
||||
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0573, expected_cost_resid_gbp=+1.3188, expected_co2_resid_kg=+8.0120, expected_pe_resid_kwh=+94.4789),
|
||||
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=+0.4522, expected_cost_resid_gbp=-10.4203, expected_co2_resid_kg=-4.3334, expected_pe_resid_kwh=-40.1603),
|
||||
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1842, expected_cost_resid_gbp=+4.2439, expected_co2_resid_kg=+38.7768, expected_pe_resid_kwh=+392.8379),
|
||||
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.0874, expected_cost_resid_gbp=+2.0136, expected_co2_resid_kg=+4.2088, expected_pe_resid_kwh=+81.9107),
|
||||
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-1.4255, expected_cost_resid_gbp=+32.8452, expected_co2_resid_kg=+61.9651, expected_pe_resid_kwh=+535.1955),
|
||||
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-0.1698, expected_cost_resid_gbp=+3.9118, expected_co2_resid_kg=+7.8972, expected_pe_resid_kwh=+103.4643),
|
||||
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=-0.2044, expected_cost_resid_gbp=+4.7098, expected_co2_resid_kg=+9.6362, expected_pe_resid_kwh=+112.7064),
|
||||
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896),
|
||||
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145),
|
||||
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+0.6568, expected_cost_resid_gbp=-15.1334, expected_co2_resid_kg=-13.8238, expected_pe_resid_kwh=-114.7533),
|
||||
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.6813, expected_cost_resid_gbp=+15.6982, expected_co2_resid_kg=+43.9325, expected_pe_resid_kwh=+338.5315),
|
||||
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+0.5744, expected_cost_resid_gbp=-13.2352, expected_co2_resid_kg=-10.1354, expected_pe_resid_kwh=-93.1997),
|
||||
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+0.5398, expected_cost_resid_gbp=-12.4372, expected_co2_resid_kg=-8.3964, expected_pe_resid_kwh=-83.9576),
|
||||
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+0.4874, expected_cost_resid_gbp=-11.2307, expected_co2_resid_kg=-6.4095, expected_pe_resid_kwh=-70.5744),
|
||||
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+0.6261, expected_cost_resid_gbp=-14.4253, expected_co2_resid_kg=-12.3507, expected_pe_resid_kwh=-105.2495),
|
||||
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023),
|
||||
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+0.6045, expected_cost_resid_gbp=-13.9307, expected_co2_resid_kg=-41.4921, expected_pe_resid_kwh=-124.2355),
|
||||
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=-0.1485, expected_cost_resid_gbp=+3.4232, expected_co2_resid_kg=-22.0838, expected_pe_resid_kwh=+67.4561),
|
||||
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=-0.1485, expected_cost_resid_gbp=+3.4232, expected_co2_resid_kg=-22.0838, expected_pe_resid_kwh=+67.4561),
|
||||
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.5872, expected_cost_resid_gbp=-13.5304, expected_co2_resid_kg=-39.2997, expected_pe_resid_kwh=-120.1551),
|
||||
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=-0.0288, expected_cost_resid_gbp=+0.6418, expected_co2_resid_kg=-37.3200, expected_pe_resid_kwh=+41.8245),
|
||||
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+0.4042, expected_cost_resid_gbp=-9.3142, expected_co2_resid_kg=-36.6371, expected_pe_resid_kwh=-71.2875),
|
||||
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.3609, expected_cost_resid_gbp=-8.3159, expected_co2_resid_kg=-34.4292, expected_pe_resid_kwh=-67.1831),
|
||||
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.3609, expected_cost_resid_gbp=-8.3159, expected_co2_resid_kg=-34.4292, expected_pe_resid_kwh=-67.1831),
|
||||
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.3869, expected_cost_resid_gbp=-8.9139, expected_co2_resid_kg=-34.4447, expected_pe_resid_kwh=-67.2071),
|
||||
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+0.5018, expected_cost_resid_gbp=-11.0973, expected_co2_resid_kg=-49.6654, expected_pe_resid_kwh=-92.8147),
|
||||
# Slice S0380.133 unblocked 10 solid-fuel variants by routing the
|
||||
# Elmhurst §14.0 "Main Heating EES Code" through the new
|
||||
# `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the
|
||||
|
|
@ -241,16 +241,16 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
|||
# cost / CO2 / PE all route via the correct Table 32 fuel code.
|
||||
# Remaining residuals are likely heating-system efficiency or
|
||||
# control-type gaps — separate slices.
|
||||
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.6383, expected_cost_resid_gbp=-60.7914, expected_co2_resid_kg=+53.9038, expected_pe_resid_kwh=-1211.3624),
|
||||
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983),
|
||||
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.2919, expected_cost_resid_gbp=+6.7262, expected_co2_resid_kg=-68.4116, expected_pe_resid_kwh=+89.7782),
|
||||
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=-0.1655, expected_cost_resid_gbp=+3.8136, expected_co2_resid_kg=-44.3197, expected_pe_resid_kwh=+92.8384),
|
||||
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0281, expected_cost_resid_gbp=-0.6473, expected_co2_resid_kg=+0.6642, expected_pe_resid_kwh=+44.7851),
|
||||
_CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.0994, expected_cost_resid_gbp=-2.3310, expected_co2_resid_kg=-75.1034, expected_pe_resid_kwh=+16.7917),
|
||||
_CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.0804, expected_cost_resid_gbp=+1.8511, expected_co2_resid_kg=+18.0444, expected_pe_resid_kwh=+45.1812),
|
||||
_CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.1956, expected_cost_resid_gbp=+4.5065, expected_co2_resid_kg=+19.6820, expected_pe_resid_kwh=+92.8981),
|
||||
_CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=-0.1605, expected_cost_resid_gbp=+3.6988, expected_co2_resid_kg=+17.7916, expected_pe_resid_kwh=+66.5227),
|
||||
_CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=-0.2633, expected_cost_resid_gbp=+6.0671, expected_co2_resid_kg=+23.5398, expected_pe_resid_kwh=+104.1723),
|
||||
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+3.1478, expected_cost_resid_gbp=-72.5305, expected_co2_resid_kg=+41.5584, expected_pe_resid_kwh=-1346.0016),
|
||||
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.8310, expected_cost_resid_gbp=-42.1903, expected_co2_resid_kg=-441.0048, expected_pe_resid_kwh=-1069.2375),
|
||||
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+0.4523, expected_cost_resid_gbp=-10.4208, expected_co2_resid_kg=-86.4442, expected_pe_resid_kwh=-106.8858),
|
||||
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.3440, expected_cost_resid_gbp=-7.9255, expected_co2_resid_kg=-56.6651, expected_pe_resid_kwh=-41.8008),
|
||||
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.5376, expected_cost_resid_gbp=-12.3864, expected_co2_resid_kg=-11.6812, expected_pe_resid_kwh=-89.8541),
|
||||
_CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.6029, expected_cost_resid_gbp=-14.0701, expected_co2_resid_kg=-87.4488, expected_pe_resid_kwh=-117.8475),
|
||||
_CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+0.4291, expected_cost_resid_gbp=-9.8880, expected_co2_resid_kg=+5.6990, expected_pe_resid_kwh=-89.4580),
|
||||
_CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+0.5486, expected_cost_resid_gbp=-12.6405, expected_co2_resid_kg=+1.6494, expected_pe_resid_kwh=-103.7659),
|
||||
_CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+0.5837, expected_cost_resid_gbp=-13.4482, expected_co2_resid_kg=-0.2410, expected_pe_resid_kwh=-130.1413),
|
||||
_CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+0.4809, expected_cost_resid_gbp=-11.0799, expected_co2_resid_kg=+5.5072, expected_pe_resid_kwh=-92.4917),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3965,13 +3965,24 @@ def _elmhurst_pump_age_int(age_str: Optional[str]) -> Optional[int]:
|
|||
mapper's `MainHeatingDetail.central_heating_pump_age` field. The
|
||||
cascade reads the str field (`_str` suffix) via internal_gains.py;
|
||||
the int dual-encoding exists purely for cross-mapper field
|
||||
parity. "Unknown" → 0, "Pre 2013" → 1, modern post-2013 → 2."""
|
||||
parity. "Unknown" → 0, "Pre 2013" / "2012 or earlier" → 1, modern
|
||||
post-2013 / "2013 or later" → 2.
|
||||
|
||||
Elmhurst Summary §14 lodges the pump age string verbatim in one of
|
||||
two equivalent forms: "Pre 2013" or "2012 or earlier" (semantically
|
||||
identical — both predate the 2013 Ecodesign circulation-pump
|
||||
threshold per SAP 10.2 Table 4f). Pre-S0380.149 only "Pre 2013"
|
||||
was recognised and "2012 or earlier" silently returned 2 (2013 or
|
||||
later), misclassifying the oil corpus pump_age and leading the
|
||||
Table 4f circulation pump dispatch to pick 41 kWh (2013+) instead
|
||||
of 165 kWh (Pre 2013).
|
||||
"""
|
||||
if age_str is None:
|
||||
return None
|
||||
s = age_str.strip().lower()
|
||||
if s in ("", "unknown"):
|
||||
return 0
|
||||
if "pre 2013" in s:
|
||||
if "pre 2013" in s or "2012 or earlier" in s:
|
||||
return 1
|
||||
return 2
|
||||
|
||||
|
|
|
|||
|
|
@ -210,21 +210,80 @@ _LIVING_AREA_FRACTION_MIN: Final[float] = 0.13
|
|||
|
||||
_PENCE_TO_GBP: Final[float] = 0.01
|
||||
_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
|
||||
_DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0
|
||||
# SAP10.2 Table 4f cascade — annual pumps + fans electricity by main
|
||||
# heating system category. The Elmhurst gas-combi cohort lodges 115
|
||||
# (230c central heating pump, post-2013 install) + 45 (230e main
|
||||
# heating flue fan, balanced/condensing) = 160 kWh/yr. Heat pumps,
|
||||
# warm-air, oil/biomass, electric storage etc. use different rows
|
||||
# (Table 4f spec lines 7905-8076) — deferred until a fixture exercises.
|
||||
_PUMPS_FANS_KWH_BY_MAIN_CATEGORY: Final[dict[int, float]] = {
|
||||
2: 160.0, # Gas-fired boilers (115 pump + 45 flue fan)
|
||||
4: 0.0, # Heat pumps — circulation pump + fans already in COP
|
||||
# per SAP 10.2 Table 4f. Worksheet line (249) shows
|
||||
# 0 kWh on cert 0380 (HP ASHP). Without this explicit
|
||||
# entry HP certs fell through to the 130 kWh/yr DEFAULT
|
||||
# and over-billed £17/yr at electricity rate.
|
||||
|
||||
# SAP 10.2 Table 4f (PDF p.174) — Heating system circulation pump
|
||||
# rows. Keyed on RdSAP API `central_heating_pump_age` enum:
|
||||
# 0 = Unknown → 115 kWh/yr (Table 4f "Circulation pump, unknown date")
|
||||
# 1 = Pre 2013 → 165 kWh/yr (Table 4f "Circulation pump, 2012 or earlier")
|
||||
# 2 = 2013 or later→ 41 kWh/yr (Table 4f "Circulation pump, 2013 or later")
|
||||
# Elmhurst-path certs route here via `_elmhurst_pump_age_int` (mapper)
|
||||
# which recognises both "Pre 2013" and "2012 or earlier" variants.
|
||||
_TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE: Final[dict[int, float]] = {
|
||||
0: 115.0,
|
||||
1: 165.0,
|
||||
2: 41.0,
|
||||
}
|
||||
# Default circulation pump kWh when pump_age is None (no lodging at
|
||||
# all) — Table 4f doesn't have a "missing" row; the SAP convention is
|
||||
# to use the unknown-date value.
|
||||
_TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT: Final[float] = 115.0
|
||||
|
||||
# Heat pumps from PCDB include circulation pump electricity in COP per
|
||||
# Table 4f note: "Not applicable for electric heat pumps from
|
||||
# database." Cat 4 (heat pump) → 0 kWh circulation pump.
|
||||
_HP_MAIN_HEATING_CATEGORY: Final[int] = 4
|
||||
|
||||
# Wet-boiler SAP main_heating_code ranges (Table 4a + Table 4b). The
|
||||
# Table 4f "Circulation pump" rows apply to systems with a primary
|
||||
# water loop — i.e. boilers driving radiators / wet underfloor /
|
||||
# convectors. Dry electric storage heaters (401-499), room heaters
|
||||
# (601-699), and electric direct-acting / warm-air (501-515, 691+)
|
||||
# have NO circulation pump per worksheet evidence:
|
||||
#
|
||||
# - electric 1 (code 191 electric boiler): ws (230c) = 41 kWh ✓
|
||||
# - electric 5 (code 402 electric storage): ws (231) = 0 kWh ✗
|
||||
# - solid fuel 2 (code 158 boiler): ws (230c) = 41 kWh ✓
|
||||
# - solid fuel 9 (code 636 room heater): ws (231) = 0 kWh ✗
|
||||
#
|
||||
# Code ranges:
|
||||
# 101-141 Gas/oil boilers (Table 4b)
|
||||
# 151-161 Solid fuel boilers (Table 4a)
|
||||
# 191-196 Electric boilers (Table 4a)
|
||||
_WET_BOILER_CODE_RANGES: Final[tuple[range, ...]] = (
|
||||
range(101, 142), # Gas/oil boilers
|
||||
range(151, 162), # Solid fuel boilers
|
||||
range(191, 197), # Electric boilers
|
||||
)
|
||||
|
||||
|
||||
def _is_wet_boiler_main(main: Optional[MainHeatingDetail]) -> bool:
|
||||
"""Whether `main` is a wet boiler system (has a water-loop
|
||||
circulation pump per Table 4f). Identifies by Table 4a/4b code
|
||||
when lodged; falls back to PCDB Table 322 (gas/oil boiler) record
|
||||
when the cert lodges an index number; finally falls back to
|
||||
`main_heating_category` ∈ {1, 2} ("central heating" — conventionally
|
||||
wet). Heat pumps (cat 4) return False here (Table 4f note "Not
|
||||
applicable for electric heat pumps from database").
|
||||
"""
|
||||
if main is None:
|
||||
return False
|
||||
if main.main_heating_category == _HP_MAIN_HEATING_CATEGORY:
|
||||
return False
|
||||
code = main.sap_main_heating_code
|
||||
if code is not None:
|
||||
return any(code in r for r in _WET_BOILER_CODE_RANGES)
|
||||
# No SAP code lodged. Try PCDB Table 322 (gas/oil boiler) record —
|
||||
# the Elmhurst-path cohort certs (e.g. oil pcdb 1/2/3, pcdb 1)
|
||||
# lodge `main_heating_index_number` but no Table 4b code, and a
|
||||
# Table 322 record is sufficient evidence the main is a wet boiler.
|
||||
if main.main_heating_index_number is not None:
|
||||
if gas_oil_boiler_record(main.main_heating_index_number) is not None:
|
||||
return True
|
||||
# Final fallback — RdSAP categories 1/2 = central heating (without/
|
||||
# with separate HW); both imply a wet primary loop. The gas-API
|
||||
# cohort lodges cat=2 with no code and routed via this branch
|
||||
# pre-S0380.149's refactor.
|
||||
return main.main_heating_category in {1, 2}
|
||||
|
||||
# SAP 10.2 Table 4f (page 174) — flue fan kWh for a gas-fired boiler
|
||||
# with fan-assisted flue (row "Gas boiler – flue fan"). Liquid-fuel
|
||||
|
|
@ -247,6 +306,56 @@ _TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH: Final[float] = 100.0
|
|||
_TABLE_4F_SOLAR_HW_PUMP_DEFAULT_H1_M2: Final[float] = 3.0
|
||||
|
||||
|
||||
def _table_4f_circulation_pump_kwh(main: Optional[MainHeatingDetail]) -> float:
|
||||
"""SAP 10.2 Table 4f (PDF p.174) — Main 1 circulation pump kWh
|
||||
based on `central_heating_pump_age` lodging.
|
||||
|
||||
Heat-pump mains (category 4) return 0 per Table 4f note "Not
|
||||
applicable for electric heat pumps from database" — the HP's COP
|
||||
already accounts for pump electricity internally. Dry electric
|
||||
storage / direct-acting / room heaters also return 0 (no primary
|
||||
water loop, no pump) — see `_is_wet_boiler_main`.
|
||||
|
||||
For wet boiler mains the dispatch reads the pump_age int enum:
|
||||
0 / None → 115 kWh (Unknown date)
|
||||
1 → 165 kWh (Pre 2013 / 2012 or earlier)
|
||||
2 → 41 kWh (2013 or later)
|
||||
"""
|
||||
if not _is_wet_boiler_main(main):
|
||||
return 0.0
|
||||
assert main is not None # _is_wet_boiler_main guards None
|
||||
age = main.central_heating_pump_age
|
||||
if age is None:
|
||||
return _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT
|
||||
return _TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE.get(
|
||||
age, _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT
|
||||
)
|
||||
|
||||
|
||||
def _table_4f_main_1_gas_boiler_flue_fan_kwh(
|
||||
main: Optional[MainHeatingDetail],
|
||||
) -> float:
|
||||
"""SAP 10.2 Table 4f (PDF p.174) row "Gas boiler – flue fan (if
|
||||
fan assisted flue)": 45 kWh/yr.
|
||||
|
||||
Fires only when Main 1 is a wet gas-fuelled boiler with a
|
||||
fan-assisted flue. Heat pumps (cat 4) and electric mains return
|
||||
0 — different Table 4f rows govern (HPs subsumed in COP; electric
|
||||
mains have no flue). Liquid fuel mains have their own 100 kWh
|
||||
row, applied via `_table_4f_additive_components`.
|
||||
"""
|
||||
if not _is_wet_boiler_main(main):
|
||||
return 0.0
|
||||
assert main is not None # _is_wet_boiler_main guards None
|
||||
fuel = main.main_fuel_type
|
||||
# Gas fuel codes per Table 32 + their RdSAP API equivalents (same
|
||||
# set the Main 2 branch in _table_4f_additive_components uses).
|
||||
fuel_is_gas = isinstance(fuel, int) and fuel in {1, 2, 3, 5, 7, 9, 26, 27}
|
||||
if fuel_is_gas and main.fan_flue_present:
|
||||
return _TABLE_4F_GAS_FLUE_FAN_KWH
|
||||
return 0.0
|
||||
|
||||
|
||||
def _table_4f_additive_components(epc: EpcPropertyData) -> float:
|
||||
"""Sum the SAP 10.2 Table 4f line items that the base
|
||||
`_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` lookup doesn't already cover —
|
||||
|
|
@ -4512,14 +4621,16 @@ def cert_to_inputs(
|
|||
|
||||
main = _first_main_heating(epc)
|
||||
main_code = main.sap_main_heating_code if main is not None else None
|
||||
main_category = main.main_heating_category if main is not None else None
|
||||
main_fuel = _main_fuel_code(main)
|
||||
pumps_fans_kwh = _PUMPS_FANS_KWH_BY_MAIN_CATEGORY.get(
|
||||
main_category if main_category is not None else -1,
|
||||
_DEFAULT_PUMPS_FANS_KWH_PER_YR,
|
||||
# SAP 10.2 Table 4f (p.174) — Main 1 circulation pump (per
|
||||
# `central_heating_pump_age`) + Main 1 gas-boiler flue fan (45
|
||||
# kWh when fan_flue_present + gas fuel). HP mains (cat 4) return
|
||||
# 0 for both. Additive components add MEV, Main 2 flue fan,
|
||||
# solar HW pump, and Main 1/2 liquid fuel boiler aux (100 kWh).
|
||||
pumps_fans_kwh = (
|
||||
_table_4f_circulation_pump_kwh(main)
|
||||
+ _table_4f_main_1_gas_boiler_flue_fan_kwh(main)
|
||||
)
|
||||
# SAP 10.2 Table 4f (p.174) — additive components on top of the
|
||||
# Main 1 category base. Each component is per-cert-lodging:
|
||||
pumps_fans_kwh += _table_4f_additive_components(epc)
|
||||
# Track the MEV/MVHR-fan portion separately so the cost cascade can
|
||||
# apply Table 12a Grid 2 `FANS_FOR_MECH_VENT` (0.58 high-frac on
|
||||
|
|
|
|||
|
|
@ -3642,6 +3642,79 @@ def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_wi
|
|||
)
|
||||
|
||||
|
||||
def test_sap_table_4f_circulation_pump_dispatches_per_central_heating_pump_age() -> None:
|
||||
"""SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and
|
||||
other auxiliary uses" — Heating system circulation pump rows:
|
||||
|
||||
Circulation pump, 2013 or later 41 kWh/yr
|
||||
Circulation pump, 2012 or earlier 165 kWh/yr
|
||||
Circulation pump, unknown date 115 kWh/yr
|
||||
|
||||
Pre-slice the cascade hardcoded gas-category=2 → 160 kWh/yr
|
||||
(115 Unknown CH pump + 45 gas flue fan) and fell through to
|
||||
`_DEFAULT_PUMPS_FANS_KWH_PER_YR = 130` for any other category
|
||||
(including Elmhurst-path oil certs with `main_heating_category=None`).
|
||||
Both shortcuts ignored the per-cert `central_heating_pump_age`
|
||||
lodging.
|
||||
|
||||
For oil 1 + oil pcdb 3 (Elmhurst Summary lodges "Heat pump age:
|
||||
2012 or earlier" which the mapper normalises to pump_age=1):
|
||||
worksheet (230c) = 165 kWh/yr. The cascade should now dispatch
|
||||
on pump_age int per the spec rows.
|
||||
"""
|
||||
# Arrange — oil 1 corpus variant (Table 4b code 127, no PCDB,
|
||||
# cylinder, central_heating_pump_age_str = "2012 or earlier").
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
|
||||
corpus_dir = (
|
||||
Path(__file__).parents[4]
|
||||
/ "sap worksheets/heating systems examples/oil 1"
|
||||
)
|
||||
summary_pdf = next(corpus_dir.glob("Summary_*.pdf"))
|
||||
info = subprocess.run(
|
||||
["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
pc_match = re.search(r"Pages:\s+(\d+)", info)
|
||||
assert pc_match is not None
|
||||
pc = int(pc_match.group(1))
|
||||
pages: list[str] = []
|
||||
for i in range(1, pc + 1):
|
||||
layout = subprocess.run(
|
||||
["pdftotext", "-layout", "-f", str(i), "-l", str(i),
|
||||
str(summary_pdf), "-"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
tokens: list[str] = []
|
||||
for line in layout.splitlines():
|
||||
if not line.strip():
|
||||
tokens.append("")
|
||||
continue
|
||||
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
|
||||
tokens.extend(parts)
|
||||
pages.append("\n".join(tokens))
|
||||
notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(notes)
|
||||
|
||||
# Act — full cascade.
|
||||
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
|
||||
# Assert — Worksheet (231) = (230c) 165 (Pre 2013 circulation
|
||||
# pump) + (230d) 100 (liquid fuel boiler aux from S0380.148) =
|
||||
# 265 kWh/yr. Cascade should match.
|
||||
expected_kwh = 265.0
|
||||
got_kwh = inputs.pumps_fans_kwh_per_yr
|
||||
assert abs(got_kwh - expected_kwh) < 1.0, (
|
||||
f"oil 1 pumps_fans annual: got {got_kwh!r}, "
|
||||
f"expected {expected_kwh!r} per SAP 10.2 Table 4f "
|
||||
f"(Pre 2013 circulation pump 165 + liquid fuel boiler aux 100)"
|
||||
)
|
||||
|
||||
|
||||
def test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_100_kwh() -> None:
|
||||
"""SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and
|
||||
other auxiliary uses" row "Liquid fuel boiler – flue fan and fuel
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="0240-0200-5706-2365-8010",
|
||||
actual_sap=73,
|
||||
expected_sap_resid=-1,
|
||||
expected_pe_resid_kwh_per_m2=+2.5225,
|
||||
expected_co2_resid_tonnes_per_yr=+0.1395,
|
||||
expected_pe_resid_kwh_per_m2=+2.1847,
|
||||
expected_co2_resid_tonnes_per_yr=+0.1333,
|
||||
notes=(
|
||||
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
|
||||
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
|
||||
|
|
@ -143,8 +143,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="0390-2954-3640-2196-4175",
|
||||
actual_sap=60,
|
||||
expected_sap_resid=+7,
|
||||
expected_pe_resid_kwh_per_m2=-28.0830,
|
||||
expected_co2_resid_tonnes_per_yr=-2.7342,
|
||||
expected_pe_resid_kwh_per_m2=-28.2719,
|
||||
expected_co2_resid_tonnes_per_yr=-2.7404,
|
||||
notes=(
|
||||
"Detached, TFA 360, age F, Firebird oil combi PCDF 9005 "
|
||||
"(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + "
|
||||
|
|
@ -178,8 +178,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="6035-7729-2309-0879-2296",
|
||||
actual_sap=70,
|
||||
expected_sap_resid=-6,
|
||||
expected_pe_resid_kwh_per_m2=+47.2928,
|
||||
expected_co2_resid_tonnes_per_yr=+1.0779,
|
||||
expected_pe_resid_kwh_per_m2=+46.4156,
|
||||
expected_co2_resid_tonnes_per_yr=+1.0677,
|
||||
notes=(
|
||||
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
|
||||
"Slice 59 per-bp window apportionment tightens all 3 "
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue