Slice S0380.147: Appendix D Eq D1 — Table 4b non-PCDB boilers (winter/summer monthly cascade)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-01 08:22:46 +00:00
parent bd193e06fc
commit 7dceeff24b
5 changed files with 275 additions and 34 deletions

View file

@ -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),

View file

@ -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:

View file

@ -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%)"
)

View file

@ -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(

View file

@ -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)