mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.148: Table 4f — liquid fuel boiler flue fan and fuel pump (100 kWh/yr)
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" row:
Liquid fuel boiler — flue fan and fuel pump 100 kWh/yr c) d)
Note c): "Applies to all liquid fuel boilers that provide main heating,
but not if boiler provides hot water only. Where there are two main
heating systems include two figures from this table."
Pre-slice the cascade's `_table_4f_additive_components` only wired:
- (230a) MEV / MVHR
- (230e) Main 2 gas-boiler flue fan (45 kWh)
- (230g) Solar HW pump
The liquid-fuel sibling row was missing — oil 1 worksheet (230d) and
oil pcdb 3 worksheet (230d) both lodge 100 kWh/yr "oil boiler pump"
that the cascade was silently skipping.
Implementation:
- Add `_LIQUID_FUEL_CODES = frozenset({4, 71, 73, 75, 76})` and new
`is_liquid_fuel_code(fuel_code)` helper in
`domain/sap10_calculator/tables/table_32.py`. Mirror of
`is_electric_fuel_code` — routes through `_to_table_32_code`
normalisation so Elmhurst-derived Table 32 codes (e.g. code 23
= bulk wood pellets, solid) don't collide with API enum codes
(where 23 = B30D community).
- Extend `_table_4f_additive_components` to add 100 kWh for Main 1
when `is_liquid_fuel_code(main.main_fuel_type)` returns True
(`isinstance(int)` guard for the `Union[int, str]` field). Mirror
the same gate for Main 2 per Note c) "Where there are two main
heating systems include two figures".
- LPG is GAS (Table 4b/4f convention, Ecodesign classification) —
`_LIQUID_FUEL_CODES` deliberately excludes 2/3/5/9 LPG codes.
Cascade impact across heating-systems corpus:
| Variant | SAP Δ | Cost Δ | PE Δ |
|-----------|-------------|-------------|-------------|
| oil 1 | +1.18→+0.60 | -£27→-£14 | -276→-124 |
| oil pcdb 1| +0.42→-0.15 | -£10→+£3.4 | -84→+67 |
| oil pcdb 2| +0.42→-0.15 | -£10→+£3.4 | -84→+67 |
| oil pcdb 3| +1.16→+0.59 | -£27→-£14 | -271→-120 |
| pcdb 1 | +0.57→-0.03 | -£13→+£0.6 | -109→+42 |
Cohort closures: pcdb 1 EXACT (-0.03), oil pcdb 1/2 closed to -0.15.
Golden fixtures impact:
- cert 0240 (dual-main oil combi 130): SAP integer 73→72 (resid
+0→-1), PE +1.02→+2.52, CO2 +0.11→+0.14. Dual-main certs add
2 × 100 = 200 kWh aux per Note c). Cert's published SAP 73
suggests the dual-main Q_space split (main_heating_fraction)
may also need wiring — slice candidate.
- cert 0390 (Firebird PCDF 9005 oil combi): PE -28.50→-28.08
(CLOSER to zero), CO2 -2.75→-2.73 (CLOSER to zero), SAP +7
unchanged.
Test:
test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
100_kwh — asserts oil pcdb 3 inputs.pumps_fans_kwh_per_yr ≥ 230
(130 base + 100 liquid fuel boiler aux).
Extended handover suite: 891 pass, 0 fail. Pyright net-zero (44=44).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c9c418be64
commit
3f68ec1f0d
5 changed files with 158 additions and 16 deletions
|
|
@ -229,11 +229,11 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
|||
_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='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=+1.1770, expected_cost_resid_gbp=-27.1207, expected_co2_resid_kg=-55.3633, expected_pe_resid_kwh=-275.5155),
|
||||
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
|
||||
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
|
||||
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+1.1597, expected_cost_resid_gbp=-26.7204, expected_co2_resid_kg=-53.1709, expected_pe_resid_kwh=-271.4351),
|
||||
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+0.5677, expected_cost_resid_gbp=-12.5482, expected_co2_resid_kg=-51.1912, expected_pe_resid_kwh=-109.4555),
|
||||
_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),
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ from domain.sap10_calculator.tables.table_12a import (
|
|||
from domain.sap10_calculator.tables.table_32 import (
|
||||
additional_standing_charges_gbp,
|
||||
is_electric_fuel_code,
|
||||
is_liquid_fuel_code,
|
||||
unit_price_p_per_kwh as table_32_unit_price_p_per_kwh,
|
||||
)
|
||||
from domain.sap10_calculator.tables.table_4b import (
|
||||
|
|
@ -230,6 +231,14 @@ _PUMPS_FANS_KWH_BY_MAIN_CATEGORY: Final[dict[int, float]] = {
|
|||
# (oil) boilers use 100; gas-fired heat pumps and warm-air also 45.
|
||||
_TABLE_4F_GAS_FLUE_FAN_KWH: Final[float] = 45.0
|
||||
|
||||
# SAP 10.2 Table 4f (PDF p.174) row "Liquid fuel boiler – flue fan and
|
||||
# fuel pump": 100 kWh/yr. Note c): "Applies to all liquid fuel boilers
|
||||
# that provide main heating, but not if boiler provides hot water only.
|
||||
# Where there are two main heating systems include two figures from
|
||||
# this table." First exercised by oil 1 + oil pcdb 3 corpus variants.
|
||||
_TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH: Final[float] = 100.0
|
||||
|
||||
|
||||
# SAP 10.2 Table 4f row "Solar thermal system pump, electrically
|
||||
# powered" — formula `[25 + 5×H1] × 2`. H1 is the solar collector
|
||||
# aperture area in m². For cert 000565 the lodged 3 m² flat-panel
|
||||
|
|
@ -268,12 +277,33 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float:
|
|||
total = 0.0
|
||||
total += _mev_decentralised_kwh_per_yr_from_cert(epc)
|
||||
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
|
||||
if details:
|
||||
main_1 = details[0]
|
||||
# SAP 10.2 Table 4f row "Liquid fuel boiler – flue fan and fuel
|
||||
# pump" (100 kWh/yr). Note c): "Applies to all liquid fuel
|
||||
# boilers that provide main heating, but not if boiler provides
|
||||
# hot water only." Main 1 is by definition a main-heating
|
||||
# boiler, so the gate reduces to "is the fuel liquid". Worksheet
|
||||
# line (230d) on oil 1 + oil pcdb 3 confirms 100 kWh.
|
||||
# `is_liquid_fuel_code` routes through Table-32 normalisation so
|
||||
# Elmhurst-derived Table 32 codes (e.g. 23 = bulk wood pellets,
|
||||
# solid) don't collide with API enum codes (where 23 = B30D
|
||||
# community).
|
||||
main_1_fuel = main_1.main_fuel_type
|
||||
if isinstance(main_1_fuel, int) and is_liquid_fuel_code(main_1_fuel):
|
||||
total += _TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH
|
||||
if len(details) >= 2:
|
||||
main_2 = details[1]
|
||||
# Gas fuel codes per Table 32 + their RdSAP API equivalents.
|
||||
main_2_fuel_is_gas = main_2.main_fuel_type in {1, 2, 3, 5, 7, 9, 26, 27}
|
||||
if main_2.fan_flue_present and main_2_fuel_is_gas:
|
||||
total += _TABLE_4F_GAS_FLUE_FAN_KWH
|
||||
# Note c): "Where there are two main heating systems include
|
||||
# two figures from this table" — Main 2 liquid fuel boiler also
|
||||
# gets its own 100 kWh per the spec.
|
||||
main_2_fuel = main_2.main_fuel_type
|
||||
if isinstance(main_2_fuel, int) and is_liquid_fuel_code(main_2_fuel):
|
||||
total += _TABLE_4F_LIQUID_FUEL_BOILER_AUX_KWH
|
||||
if epc.solar_water_heating:
|
||||
total += (
|
||||
25.0 + 5.0 * _TABLE_4F_SOLAR_HW_PUMP_DEFAULT_H1_M2
|
||||
|
|
|
|||
|
|
@ -3642,6 +3642,82 @@ def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_wi
|
|||
)
|
||||
|
||||
|
||||
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
|
||||
pump":
|
||||
|
||||
Liquid fuel boiler — flue fan and fuel pump 100 kWh/yr
|
||||
|
||||
Note c): "Applies to all liquid fuel boilers that provide main
|
||||
heating, but not if boiler provides hot water only. Where there
|
||||
are two main heating systems include two figures from this table."
|
||||
|
||||
Pre-slice the cascade's `_table_4f_additive_components` only wired
|
||||
the Main 2 GAS-boiler flue fan (45 kWh) — the liquid-fuel sibling
|
||||
row was missing. Oil 1 worksheet (230d) "oil boiler pump" =
|
||||
100 kWh/yr, oil pcdb 3 worksheet (230d) = 100 kWh/yr; cascade
|
||||
pumps_fans was under by 100 kWh on both.
|
||||
"""
|
||||
# Arrange — oil pcdb 3 corpus variant: PCDB 18573 Firebird oil
|
||||
# combi + WHC=901 + no cylinder. Cert lodges Heating oil fuel
|
||||
# (Elmhurst → main_fuel_type 28).
|
||||
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 pcdb 3"
|
||||
)
|
||||
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)
|
||||
main = epc.sap_heating.main_heating_details[0]
|
||||
assert main.main_fuel_type == 28 # oil (not community)
|
||||
|
||||
# Act — read inputs.pumps_fans_kwh_per_yr (includes (230c-g) total).
|
||||
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
|
||||
# Assert — cascade pumps_fans includes the 100 kWh Table 4f row.
|
||||
# Pre-slice base = 130 kWh (default fallback for category=None).
|
||||
# Post-slice = 130 + 100 (liquid fuel pump) = 230 kWh (still under
|
||||
# worksheet (231) 265 by 35 kWh — the remaining gap is the per-
|
||||
# pump-age circulation pump dispatch, slice S0380.149).
|
||||
expected_min_kwh = 230.0
|
||||
got_kwh = inputs.pumps_fans_kwh_per_yr
|
||||
assert got_kwh >= expected_min_kwh - 0.5, (
|
||||
f"oil pcdb 3 pumps_fans annual: got {got_kwh!r}, "
|
||||
f"expected >= {expected_min_kwh!r} per SAP 10.2 Table 4f row "
|
||||
f"\"Liquid fuel boiler – flue fan and fuel pump\" (100 kWh) "
|
||||
f"added to the base circulation pump kWh"
|
||||
)
|
||||
|
||||
|
||||
def test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_table_4b_boiler_with_cylinder() -> None:
|
||||
"""SAP 10.2 Appendix D §D2.1 (2) Equation (D1) (PDF p.57):
|
||||
|
||||
|
|
|
|||
|
|
@ -74,9 +74,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
_GoldenExpectation(
|
||||
cert_number="0240-0200-5706-2365-8010",
|
||||
actual_sap=73,
|
||||
expected_sap_resid=+0,
|
||||
expected_pe_resid_kwh_per_m2=+1.0211,
|
||||
expected_co2_resid_tonnes_per_yr=+0.1118,
|
||||
expected_sap_resid=-1,
|
||||
expected_pe_resid_kwh_per_m2=+2.5225,
|
||||
expected_co2_resid_tonnes_per_yr=+0.1395,
|
||||
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_"
|
||||
|
|
@ -98,10 +98,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
"oil combi (51%/49%) with WHC=901 + no cylinder. Spec-correct "
|
||||
"Eq D1 monthly blend (mean ~78%) produces ~150 kWh/yr more HW "
|
||||
"fuel than the pre-slice flat-winter calc — PE residual "
|
||||
"+0.0542 → +1.0211, CO2 +0.0626 → +0.1118. The pre-slice "
|
||||
"near-zero pin was masking a compensating cascade gap (likely "
|
||||
"Table 4f auxiliary energy or the dual-main Q_space split for "
|
||||
"Eq D1 per (98c)m × (204))."
|
||||
"+0.0542 → +1.0211, CO2 +0.0626 → +0.1118. "
|
||||
"Slice S0380.148 added SAP 10.2 Table 4f "
|
||||
"\"Liquid fuel boiler – flue fan and fuel pump\" 100 kWh/yr "
|
||||
"for both Main 1 + Main 2 (note c) "
|
||||
"\"Where there are two main heating systems include two "
|
||||
"figures from this table\"). Cascade pumps_fans 160 → 360 "
|
||||
"(+200 kWh/yr) drops cascade SAP integer 73 → 72 (resid +0 "
|
||||
"→ -1) and raises PE +1.0211 → +2.5225, CO2 +0.1118 → "
|
||||
"+0.1395. Residual remains net-positive — the 100 kWh "
|
||||
"spec figure may need refinement when the dual-main "
|
||||
"main_heating_fraction split lands (slice candidate)."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
|
|
@ -136,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.5027,
|
||||
expected_co2_resid_tonnes_per_yr=-2.7481,
|
||||
expected_pe_resid_kwh_per_m2=-28.0830,
|
||||
expected_co2_resid_tonnes_per_yr=-2.7342,
|
||||
notes=(
|
||||
"Detached, TFA 360, age F, Firebird oil combi PCDF 9005 "
|
||||
"(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + "
|
||||
|
|
@ -158,8 +165,13 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
"exceeded the primary-loss gain → cascade HW fuel dropped "
|
||||
"~650 kWh; PE residual shifted -26.37 → -28.50, CO2 -2.55 → "
|
||||
"-2.75. SAP integer unchanged because the cascade was already "
|
||||
"well above SAP 60 (actual). Remaining residual is a fabric or "
|
||||
"different §4 driver — follow-up slice candidate."
|
||||
"well above SAP 60 (actual). "
|
||||
"Slice S0380.148 added SAP 10.2 Table 4f "
|
||||
"\"Liquid fuel boiler – flue fan and fuel pump\" 100 kWh/yr "
|
||||
"for the oil combi Main 1 — cascade pumps_fans +100 kWh/yr, "
|
||||
"PE residual -28.5027 → -28.0830 (closer to zero), CO2 "
|
||||
"-2.7481 → -2.7342 (closer to zero). Remaining residual is "
|
||||
"a fabric or different §4 driver — follow-up slice candidate."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
|
|
|
|||
|
|
@ -170,6 +170,16 @@ _ELECTRIC_FUEL_CODES: Final[frozenset[int]] = frozenset(
|
|||
{30, 31, 32, 33, 34, 35, 38, 40, 60}
|
||||
)
|
||||
|
||||
# Liquid fuel Table 32 codes (oil + bioliquids) after API enum
|
||||
# translation. Drawn from Table 32 PDF p.95 rows:
|
||||
# 4 heating oil
|
||||
# 71 bio-liquid HVO
|
||||
# 73 bio-liquid FAME
|
||||
# 75 B30K
|
||||
# 76 bioethanol
|
||||
# LPG is treated as GAS (its own rows 2/3/5/9) and is NOT in this set.
|
||||
_LIQUID_FUEL_CODES: Final[frozenset[int]] = frozenset({4, 71, 73, 75, 76})
|
||||
|
||||
# Off-peak tariff → high-rate Table 32 code (the row carrying the
|
||||
# off-peak meter standing per Table 32 PDF page 95).
|
||||
_OFF_PEAK_STANDING_CODE: Final[dict[Tariff, int]] = {
|
||||
|
|
@ -211,6 +221,20 @@ def is_electric_fuel_code(fuel_code: Optional[int]) -> bool:
|
|||
return code is not None and code in _ELECTRIC_FUEL_CODES
|
||||
|
||||
|
||||
def is_liquid_fuel_code(fuel_code: Optional[int]) -> bool:
|
||||
"""Whether the fuel code maps to a Table 32 liquid fuel row
|
||||
(heating oil + bioliquids), after T32-first / API-translate
|
||||
normalisation. Mirrors `is_electric_fuel_code`. Used by SAP 10.2
|
||||
Table 4f (PDF p.174) "Liquid fuel boiler – flue fan and fuel
|
||||
pump" (100 kWh/yr) gate.
|
||||
|
||||
LPG is treated as GAS by Table 4f (separate "Gas boiler" row,
|
||||
45 kWh/yr) — `is_liquid_fuel_code` returns False for LPG codes.
|
||||
"""
|
||||
code = _to_table_32_code(fuel_code)
|
||||
return code is not None and code in _LIQUID_FUEL_CODES
|
||||
|
||||
|
||||
def additional_standing_charges_gbp(
|
||||
*,
|
||||
main_fuel_code: Optional[int],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue