diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index de8a29e6..d33c2a20 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 7107686c..d7d9421a 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index ba0d1f64..afea5bd4 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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): diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 0504dd1d..de7baff1 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -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( diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index e66736e8..03c6dbb3 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -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],