From 7dceeff24b1f55d966edb0cfa116116aef105f7a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 1 Jun 2026 08:22:46 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.147:=20Appendix=20D=20Eq=20D1=20?= =?UTF-8?q?=E2=80=94=20Table=204b=20non-PCDB=20boilers=20(winter/summer=20?= =?UTF-8?q?monthly=20cascade)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix D §D2.1 (2) Equation (D1) (PDF p.57): If the boiler provides both space and water heating, and the summer seasonal efficiency is lower than the winter seasonal efficiency, the efficiency is a combination of winter and summer seasonal efficiencies according to the relative proportion of heat needed from the boiler for space and water heating in the month concerned: Q_space + Q_water η_water,m = ─────────────────────────────── Q_space/η_winter + Q_water/η_summer where Q_space (kWh/month) is the quantity calculated at (98c)m multiplied by (204) or by (205); Q_water (kWh/month) is the quantity calculated at (64)m; η_winter and η_summer are the winter and summer seasonal efficiencies (from Table 4b). Pre-slice the cascade only wired Eq D1 for PCDB-tested boilers (the `pcdb_record` branch in `_apply_water_efficiency`). For non-PCDB Table 4b boilers (`sap_main_heating_code` 101-141) where the cert lodges no `main_heating_index_number`, the cascade fell through to the scalar `water_efficiency_pct` divisor — which resolved via WHC 901 inherit to Table 4b WINTER eff (wrong direction; spec wants the monthly Eq D1 blend). This slice: - Adds `domain/sap10_calculator/tables/table_4b.py` with the full 41-row Table 4b (winter, summer) pair dict for codes 101-141 verbatim from SAP 10.2 PDF p.168 (Table 4b). - Refactors `_apply_water_efficiency` parameter from `pcdb_record: Optional[GasOilBoilerRecord]` to `eq_d1_winter_summer_pct: Optional[tuple[float, float]]` — decouples the Eq D1 input from the PCDB record so a Table 4b fallback can populate it without faking a PCDB record. - Resolves Eq D1 inputs at the call site with priority order: 1. PCDB Table 105 winter/summer (existing path) 2. SAP 10.2 Table 4b (PDF p.168) winter/summer when PCDB absent + WHC=901 (`_WHC_FROM_MAIN_HEATING`, the spec form of "boiler provides both space and water heating"). §9.4.11 -5pp interlock applies symmetrically to both columns of whichever (winter, summer) tuple is resolved. Oil 1 cert worksheet (217)m verified Jan 81.83 / Apr 81.42 / May 79.94 / Jun-Sep 72.00 / Dec 81.86 — exact back-solve to Eq D1 with Table 4b code 127 (winter 84, summer 72). Annual HW fuel (219) = Σ (64)m × 100 / (217)m = 3638.99 kWh/yr ≡ cascade post-slice. Cascade impact: Heating-systems corpus (worksheet-pinned, oil 1 only on pin grid): oil 1 SAP +1.76 → +1.18 (Δ -0.59) cost -£40.60 → -£27.12 (Δ +£13.48) CO2 -129.22 → -55.36 (Δ +73.86 kg/yr) PE -590.02 → -275.52 (Δ +314.50 kWh/yr) Remaining oil 1 residual is Table 4f auxiliary energy (cascade pumps_fans 130 kWh vs worksheet 265 kWh — missing the oil-boiler pump 100 kWh + CH pump 130 vs ws 165). Follow-up slice. Golden fixtures (cert-pinned, integer-rounded PE): cert 0240 (dual oil combi 130, no cylinder): PE +0.05 → +1.02 cert 6035 (gas combi 104, no cylinder): PE +46.10 → +47.29 Both shifts reflect spec-correct Eq D1 now firing for non-PCDB combi-no-cylinder configs. The pre-slice near-zero pin on cert 0240 was masking offsetting cascade gaps (likely Table 4f auxiliary energy and/or dual-main Q_space split per (98c)m × (204) which the cascade currently treats as full demand). Following [[reference-unmapped-sap-code]] discipline, the new Table 4b dict is the canonical spec-source — `domain.sap10_ml.sap_ efficiencies._SPACE_EFF_BY_CODE` still carries the winter column for the ML feature cascade and is left in place per the sap10_ml deprecation plan (separate migration). Test: test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_ table_4b_boiler_with_cylinder — asserts cert 1431 oil 1 HW fuel annual = 3638.99 ± 1.0 kWh/yr (matches worksheet (219)). Extended handover suite: 890 pass, 0 fail. Pyright net-zero (44=44). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 2 +- .../sap10_calculator/rdsap/cert_to_inputs.py | 92 +++++++++++++----- .../rdsap/tests/test_cert_to_inputs.py | 89 +++++++++++++++++ .../rdsap/tests/test_golden_fixtures.py | 30 ++++-- domain/sap10_calculator/tables/table_4b.py | 96 +++++++++++++++++++ 5 files changed, 275 insertions(+), 34 deletions(-) create mode 100644 domain/sap10_calculator/tables/table_4b.py diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index eca1793d..de8a29e6 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -229,7 +229,7 @@ _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.7621, expected_cost_resid_gbp=-40.6035, expected_co2_resid_kg=-129.2211, expected_pe_resid_kwh=-590.0236), + _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), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0fec19d7..7107686c 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -50,7 +50,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification from __future__ import annotations import math -from dataclasses import dataclass, replace +from dataclasses import dataclass from decimal import ROUND_HALF_UP, Decimal from typing import Callable, Final, Literal, Optional @@ -109,6 +109,9 @@ from domain.sap10_calculator.tables.table_32 import ( is_electric_fuel_code, unit_price_p_per_kwh as table_32_unit_price_p_per_kwh, ) +from domain.sap10_calculator.tables.table_4b import ( + table_4b_seasonal_efficiencies_pct, +) from domain.sap10_calculator.worksheet.fuel_cost import FuelCostResult, fuel_cost from domain.sap10_calculator.worksheet.rating import ( ENERGY_COST_DEFLATOR, @@ -1919,6 +1922,10 @@ def _pumps_fans_fuel_cost_gbp_per_kwh( # the SAME cascade the main heating uses, including the main_heating_ # category fallback (e.g. heat pumps return 2.30 via category 4). _WATER_INHERIT_FROM_MAIN_CODES: Final[frozenset[int]] = frozenset({901, 902, 914}) +# Water-heating code 901 = "From main heating system" — used by the +# SAP 10.2 Appendix D §D2.1 (2) Equation D1 gate, which only applies +# when "the boiler provides both space and water heating". +_WHC_FROM_MAIN_HEATING: Final[int] = 901 def _water_efficiency_with_category_inherit( @@ -4255,25 +4262,24 @@ def _apply_water_efficiency( wh_output_monthly_kwh: tuple[float, ...], wh_output_annual_kwh: float, water_efficiency_pct: float, - pcdb_record: Optional[GasOilBoilerRecord], + eq_d1_winter_summer_pct: Optional[tuple[float, float]], space_heating_monthly_useful_kwh: tuple[float, ...], ) -> float: """Divide §4 (64)m by the appropriate efficiency to land HW fuel kWh. - For PCDB-tested combis with distinct winter/summer efficiencies (and - a (98c)m × (204) tuple in hand): use the SAP 10.2 Appendix D §D2.1 - (2) Equation D1 monthly cascade. Otherwise stay on the legacy scalar - `water_efficiency_pct` divisor (single-value PCDB or Table 4a/4b).""" + When (winter, summer) seasonal efficiencies are provided — either + from a PCDB Table 105 record OR from the SAP 10.2 Table 4b non-PCDB + fallback (`tables.table_4b.table_4b_seasonal_efficiencies_pct`) — + use the SAP 10.2 Appendix D §D2.1 (2) Equation D1 monthly cascade. + Otherwise stay on the legacy scalar `water_efficiency_pct` divisor + (single-value PCDB summer eff, Table 4a inherit, etc.).""" if water_efficiency_pct <= 0: return 0.0 - if ( - pcdb_record is not None - and pcdb_record.winter_efficiency_pct is not None - and pcdb_record.summer_efficiency_pct is not None - ): + if eq_d1_winter_summer_pct is not None: + winter_pct, summer_pct = eq_d1_winter_summer_pct monthly_eff = water_efficiency_monthly_via_equation_d1( - winter_efficiency_pct=pcdb_record.winter_efficiency_pct, - summer_efficiency_pct=pcdb_record.summer_efficiency_pct, + winter_efficiency_pct=winter_pct, + summer_efficiency_pct=summer_pct, space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh, water_heating_output_monthly_kwh=wh_output_monthly_kwh, ) @@ -4566,20 +4572,54 @@ def cert_to_inputs( ) if no_interlock and water_pcdb_main is not None: water_eff -= 0.05 - pcdb_main_for_eq_d1 = pcdb_main + # Resolve the (winter, summer) seasonal efficiency pair that feeds + # the SAP 10.2 Appendix D §D2.1 (2) Equation D1 monthly cascade. + # Priority order: + # 1. PCDB Table 105 record on the SH main (gas/oil boiler) — + # `pcdb_main.{winter,summer}_efficiency_pct` are spec-derived. + # 2. SAP 10.2 Table 4b (PDF p.168) non-PCDB fallback when the + # cert's `sap_main_heating_code` is in the 101-141 boiler + # range AND the DHW is from the main (WHC 901). Eq D1 only + # applies when "the boiler provides both space and water + # heating" per spec — WHC 901 is the cert form of that. + # Codes on the Table 3 zero-loss list (combi, CPSU) get no + # primary loss but ARE still eligible for Eq D1 — the spec's + # §D2.1 (2) test is "summer < winter" + "boiler provides both", + # not the primary-loss test. + eq_d1_winter_summer_pct: Optional[tuple[float, float]] = None + if ( + pcdb_main is not None + and pcdb_main.winter_efficiency_pct is not None + and pcdb_main.summer_efficiency_pct is not None + ): + eq_d1_winter_summer_pct = ( + pcdb_main.winter_efficiency_pct, + pcdb_main.summer_efficiency_pct, + ) + elif ( + pcdb_main is None + and main is not None + and epc.sap_heating.water_heating_code == _WHC_FROM_MAIN_HEATING + ): + # Non-PCDB Table 4b boiler + DHW from main. SAP 10.2 Appendix D + # §D2.1 (2) applies whenever "the boiler provides both space + # and water heating" — combi (no cylinder) and regular (with + # cylinder) alike. Spec text doesn't gate on cylinder presence. + eq_d1_winter_summer_pct = table_4b_seasonal_efficiencies_pct( + main.sap_main_heating_code + ) if no_interlock and pcdb_main is not None: eff -= 0.05 - # Equation D1 reads PCDB winter/summer directly; apply -5pp - # to both so the monthly cascade matches worksheet (217)m. - if ( - pcdb_main.winter_efficiency_pct is not None - and pcdb_main.summer_efficiency_pct is not None - ): - pcdb_main_for_eq_d1 = replace( - pcdb_main, - winter_efficiency_pct=pcdb_main.winter_efficiency_pct - 5.0, - summer_efficiency_pct=pcdb_main.summer_efficiency_pct - 5.0, - ) + # §9.4.11 -5pp interlock applies symmetrically to both winter and + # summer columns of the Equation D1 input — matches worksheet + # (217)m for pcdb 1 (PCDB 716 winter 65 / summer 53 → 60 / 48). + # No -5pp on the Table 4b branch when interlock is present (oil 1 + # cert has cylinder thermostat → interlock OK → no adjustment). + if no_interlock and eq_d1_winter_summer_pct is not None: + eq_d1_winter_summer_pct = ( + eq_d1_winter_summer_pct[0] - 5.0, + eq_d1_winter_summer_pct[1] - 5.0, + ) # SAP 10.2 Appendix N3.6 + N3.7(a) — when an HP cert lodges a PCDB # Table 362 record, the cascade replaces the Table 4a defaults with # APM-interpolated η_space and η_water at the dwelling's PSR. @@ -4760,7 +4800,7 @@ def cert_to_inputs( wh_output_monthly_kwh=wh_result.output_monthly_kwh, wh_output_annual_kwh=wh_result.output_kwh_per_yr, water_efficiency_pct=water_eff, - pcdb_record=pcdb_main_for_eq_d1, + eq_d1_winter_summer_pct=eq_d1_winter_summer_pct, space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh, ) else: 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 dcb01a7f..ba0d1f64 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -3640,3 +3640,92 @@ def test_sap_table_3_primary_loss_applies_to_non_pcdb_table_4b_regular_boiler_wi f"primary pipework + cylinder thermostat + separately timed " f"DHW)" ) + + +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): + + If the boiler provides both space and water heating, and the + summer seasonal efficiency is lower than the winter seasonal + efficiency, the efficiency is a combination of winter and + summer seasonal efficiencies according to the relative + proportion of heat needed from the boiler for space and water + heating in the month concerned: + + Q_space + Q_water + η_water,m = ───────────────────────── + Q_space/η_winter + Q_water/η_summer + + Pre-slice the cascade only wired Equation D1 for PCDB-tested + boilers (the `pcdb_record` branch in `_apply_water_efficiency`). + For non-PCDB Table 4b boilers (`sap_main_heating_code` 101-141) + where the cert lodges no `main_heating_index_number`, the cascade + fell through to the scalar `water_efficiency_pct` divisor — + which for oil 1 (WHC 901 → inherit) resolved to the Table 4b + WINTER efficiency (84%), the wrong direction (worksheet (216) + "Efficiency of water heater" = 72% summer). + + This slice adds a Table 4b summer-eff lookup + (`tables.table_4b.table_4b_seasonal_efficiencies_pct`, citing + SAP 10.2 PDF p.168) and feeds (winter, summer) into Equation D1 + for non-PCDB Table 4b boilers that supply DHW from the main. + + Oil 1 cert lodges sap_main_heating_code 127 ("Condensing oil + boiler", Table 4b winter 84 / summer 72) + WHC 901 + cylinder. + Worksheet (217)m oscillates 72.0 (summer Jun-Sep, no SH demand) + to 81.86 (Dec, max SH demand) — back-solves to exactly Equation + D1 with winter=84, summer=72. Annual HW fuel (219) = + Σ (64)m / (217)m / 100 = 3638.99 kWh — matches the cascade exactly + when we wire the (84, 72) Eq D1 path. + """ + # Arrange — oil 1 corpus variant: Table 4b code 127 + cylinder. + import re + import subprocess + from pathlib import Path + + from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor + from datatypes.epc.domain.mapper import EpcPropertyDataMapper + + corpus_oil_1 = ( + Path(__file__).parents[4] + / "sap worksheets/heating systems examples/oil 1" + ) + summary_pdf = next(corpus_oil_1.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, read final HW fuel kWh. + inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + # Assert — HW fuel (219) annual matches worksheet to <1 kWh/yr. + # Worksheet (219) annual = 3638.9862 (P960 line 358). Pre-slice + # the cascade produced ~3392 kWh (under by ~247 kWh) because the + # Eq D1 monthly blend wasn't wired for Table 4b. + expected_hw_fuel = 3638.99 + got_hw_fuel = inputs.hot_water_kwh_per_yr + assert abs(got_hw_fuel - expected_hw_fuel) < 1.0, ( + f"oil 1 HW fuel annual: got {got_hw_fuel!r}, " + f"want {expected_hw_fuel!r} per SAP 10.2 Appendix D §D2.1 (2) " + f"Equation D1 with Table 4b code 127 (winter 84%, summer 72%)" + ) diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 9d5f6168..0504dd1d 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -75,8 +75,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+0.0542, - expected_co2_resid_tonnes_per_yr=+0.0626, + expected_pe_resid_kwh_per_m2=+1.0211, + expected_co2_resid_tonnes_per_yr=+0.1118, 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_" @@ -91,8 +91,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "residual was -10 throughout the Slice 97..130 range; " "Slice S0380.131 flipped table_32.py heating-oil price 7.64 → " "5.44 per Elmhurst worksheet evidence + this cert's gov.uk " - "back-solve, closing SAP residual -10 → +0 exactly. PE / CO2 " - "residuals are unaffected by the unit-price flip." + "back-solve, closing SAP residual -10 → +0 exactly. " + "Slice S0380.147 wired SAP 10.2 Appendix D §D2.1 (2) Equation " + "D1 for non-PCDB Table 4b boilers (code 130 = condensing " + "combi oil, winter 82 / summer 73); this cert is dual-main " + "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))." ), ), _GoldenExpectation( @@ -157,8 +166,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="6035-7729-2309-0879-2296", actual_sap=70, expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=+46.0952, - expected_co2_resid_tonnes_per_yr=+1.0495, + expected_pe_resid_kwh_per_m2=+47.2928, + expected_co2_resid_tonnes_per_yr=+1.0779, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "Slice 59 per-bp window apportionment tightens all 3 " @@ -170,7 +179,14 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "→ +46.76, CO2 +1.09 → +1.07. S0380.109: §5.7+§5.8 formula " "chain for solid-brick + lodged-thickness + insulation " "tightens BP[0] Main wall U from Table-6 bucket → spec " - "formula → PE +46.76 → +46.09, CO2 +1.065 → +1.049." + "formula → PE +46.76 → +46.09, CO2 +1.065 → +1.049. " + "Slice S0380.147 wired SAP 10.2 Appendix D §D2.1 (2) Equation " + "D1 for non-PCDB Table 4b boilers (code 104 = condensing combi " + "gas, winter 84 / summer 75 — combi-no-cylinder routes via " + "WHC=901 + main code 104). Eq D1 monthly blend (mean ~80%) " + "produces ~150 kWh/yr more HW fuel than the pre-slice flat-" + "winter calc → PE residual +46.0952 → +47.2928, CO2 +1.0495 " + "→ +1.0779." ), ), _GoldenExpectation( diff --git a/domain/sap10_calculator/tables/table_4b.py b/domain/sap10_calculator/tables/table_4b.py new file mode 100644 index 00000000..09605394 --- /dev/null +++ b/domain/sap10_calculator/tables/table_4b.py @@ -0,0 +1,96 @@ +"""SAP 10.2 Table 4b (PDF p.168) — "Seasonal efficiency for gas and +liquid fuel boilers", winter / summer pair per Table 4b sub-row code +(`sap_main_heating_code` 101-141). + +This table is the spec-canonical fallback when a gas / oil boiler is +NOT in the PCDB. Winter efficiency feeds (206)..(212) space heating; +summer efficiency feeds Appendix D §D2.1 (2) Equation D1 alongside +winter to derive the worksheet (217)m monthly water-heating efficiency. + +Codes are grouped in Table 4b by boiler type: + + 101-109 Gas boilers (mains, LPG, biogas) 1998 or later + 110-114 Gas pre-1998 with fan-assisted flue + 115-119 Gas pre-1998 with balanced / open flue + 120-123 Combined Primary Storage Units (CPSU) + 124-132 Liquid fuel boilers (oil, etc.) + 133-141 Range cooker boilers (gas + liquid fuel) + +The winter column is duplicated in `domain.sap10_ml.sap_efficiencies. +_SPACE_EFF_BY_CODE` for backward-compat with that module's interim +ML cascade; the canonical source for new cascade work is here per +[[sap10_ml deprecation]] memory. +""" + +from __future__ import annotations + +from typing import Final, Optional + + +# Verbatim from SAP 10.2 spec PDF p.168 (the "Boiler ... Efficiency, % +# Winter / Summer" table). All values percent. +_TABLE_4B_SEASONAL_EFF_PCT_BY_CODE: Final[dict[int, tuple[float, float]]] = { + # Gas boilers (including mains gas, LPG and biogas) 1998 or later + 101: (74.0, 64.0), # Regular non-condensing with automatic ignition + 102: (84.0, 74.0), # Regular condensing with automatic ignition + 103: (74.0, 65.0), # Non-condensing combi with automatic ignition + 104: (84.0, 75.0), # Condensing combi with automatic ignition + 105: (70.0, 60.0), # Regular non-condensing with permanent pilot + 106: (80.0, 70.0), # Regular condensing with permanent pilot + 107: (70.0, 61.0), # Non-condensing combi with permanent pilot + 108: (80.0, 71.0), # Condensing combi with permanent pilot + 109: (66.0, 56.0), # Back boiler to radiators + # Gas pre-1998 with fan-assisted flue + 110: (73.0, 63.0), # Regular, low thermal capacity + 111: (69.0, 59.0), # Regular, high or unknown thermal capacity + 112: (71.0, 62.0), # Combi + 113: (84.0, 75.0), # Condensing combi + 114: (84.0, 74.0), # Regular, condensing + # Gas pre-1998 with balanced or open flue + 115: (66.0, 56.0), # Regular, wall mounted + 116: (56.0, 46.0), # Regular, floor mounted, pre 1979 + 117: (66.0, 56.0), # Regular, floor mounted, 1979 to 1997 + 118: (66.0, 57.0), # Combi + 119: (66.0, 56.0), # Back boiler to radiators + # Combined Primary Storage Units (CPSU) + 120: (74.0, 72.0), # With automatic ignition (non-condensing) + 121: (83.0, 81.0), # With automatic ignition (condensing) + 122: (70.0, 68.0), # With permanent pilot (non-condensing) + 123: (79.0, 77.0), # With permanent pilot (condensing) + # Liquid fuel boilers + 124: (66.0, 54.0), # Standard oil boiler pre-1985 + 125: (71.0, 59.0), # Standard oil boiler 1985 to 1997 + 126: (80.0, 68.0), # Standard oil boiler, 1998 or later + 127: (84.0, 72.0), # Condensing oil boiler + 128: (71.0, 62.0), # Combi oil boiler, pre-1998 + 129: (77.0, 68.0), # Combi oil boiler, 1998 or later + 130: (82.0, 73.0), # Condensing combi oil boiler + 131: (66.0, 54.0), # Oil room heater with boiler to radiators, pre 2000 + 132: (71.0, 59.0), # Oil room heater with boiler to radiators, 2000 or later + # Range cooker boilers (mains gas, LPG and biogas) + 133: (47.0, 37.0), # Single burner with permanent pilot + 134: (51.0, 41.0), # Single burner with automatic ignition + 135: (61.0, 51.0), # Twin burner with permanent pilot (non-condensing) pre 1998 + 136: (66.0, 56.0), # Twin burner with automatic ignition (non-condensing) pre 1998 + 137: (66.0, 56.0), # Twin burner with permanent pilot (non-condensing) 1998 or later + 138: (71.0, 61.0), # Twin burner with automatic ignition (non-condensing) 1998 or later + # Range cooker boilers (liquid fuel) + 139: (61.0, 49.0), # Single burner + 140: (71.0, 59.0), # Twin burner (non-condensing) pre 1998 + 141: (76.0, 64.0), # Twin burner (non-condensing) 1998 or later +} + + +def table_4b_seasonal_efficiencies_pct( + sap_main_heating_code: Optional[int], +) -> Optional[tuple[float, float]]: + """Return the SAP 10.2 Table 4b `(winter, summer)` efficiency pair + as percentages, or `None` when the lodged code is not a Table 4b + boiler sub-row (e.g. Table 4a category code, no lodging). + + Total contract — never raises; non-Table-4b codes fall through to + None so the caller can route to the scalar / category cascade. + """ + if sap_main_heating_code is None: + return None + return _TABLE_4B_SEASONAL_EFF_PCT_BY_CODE.get(sap_main_heating_code)