Slice S0380.176: Table 4b combi sub-row dispatch for (61)m

SAP 10.2 §4 line 7702 (PDF p.137) defines (61)m as "Combi loss for
each month from Table 3a, 3b or 3c (enter '0' if not a combi
boiler)". Table 4b sub-rows 128 / 129 / 130 are explicit combi sub-
rows per the spec row names:
    128: Combi oil boiler, pre-1998
    129: Combi oil boiler, 1998 or later
    130: Condensing combi oil boiler

Pre-slice `_table_3a_combi_loss_default_applies` gated only on
`main_heating_category ∈ {1, 2, 3, 6}`. The Elmhurst mapper leaves
`main_heating_category=None` on Table 4b liquid-fuel boilers (FAME,
HVO, B30K) — the cascade fell through to (61)m=0 despite the lodged
SAP code being a combi sub-row, under-counting (62)m by 600 kWh/yr
for FAME combi certs.

Extended the helper with a `_TABLE_4B_COMBI_OR_CPSU_CODES` fall-
through (set already exists for the symmetric `_primary_loss_
applies` Table 4b non-combi branch — see S0380.146). The set carries
the canonical combi + CPSU sub-row codes (103/104/107/108/112/113/
118/120-123/128-130). For cylinder-lodged certs the existing
`if epc.has_hot_water_cylinder: combi_loss_override = zero_monthly`
guard in `_water_heating_worksheet_and_gains` still pre-empts the
combi-loss fall-through correctly — non-combi codes with cylinders
remain (61)m=0.

Closures (heating-systems corpus 001431):
  oil 3 (code 128, FAME, no cylinder) ALL EXACT (±0.0000):
    ΔSAP_c +2.5863 → -0.0000
    Δcost  -£61.89 → -£0.00
    ΔCO2   -14.58  → +0.00
    ΔPE    -967.10 → +0.00
  oil 4 (code 129, FAME, no cylinder) ALL EXACT (±0.0000):
    ΔSAP_c +2.5603 → +0.0000
    Δcost  -£56.66 → +£0.00
    ΔCO2   -13.35  → +0.00
    ΔPE    -884.90 → +0.00

Oil 6 (code 126, NOT a combi, with cylinder) unchanged — the fix
is gated on the combi sub-row set. Cohort moves from 9 pinned
residuals to 7.

933 pass + 0 fail (+1 new mapper test). Pyright net-zero on cert_
to_inputs.py + tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 13:36:17 +00:00 committed by Jun-te Kim
parent a002c7895f
commit ff80fb4b5c
3 changed files with 100 additions and 3 deletions

View file

@ -433,9 +433,20 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# 32 code-73 price flip (5.44 → 7.64) per S0380.131's TODO. oil 6
# (B30K) carries a cascade-side residual (HW kWh / SH demand /
# CO2/PE blend) — see open fronts in the post-S0380.168 handover.
#
# Slice S0380.176 closed oil 3 + oil 4 fully via Table 4b combi sub-
# row dispatch in `_table_3a_combi_loss_default_applies`. Pre-slice
# the helper gated only on `main_heating_category` ∈ {1, 2, 3, 6};
# the Elmhurst mapper leaves `main_heating_category=None` on Table
# 4b liquid-fuel boilers, so the cascade fell through to (61)m=0
# despite codes 128/129 being explicit combi sub-rows per SAP 10.2
# Table 4b row names ("Combi oil boiler, pre-/post-1998"). Adding
# the `_TABLE_4B_COMBI_OR_CPSU_CODES` fall-through lands (61)m at
# the spec Table 3a row 1 keep-hot 600 kWh/yr default. Both oil 3
# and oil 4 now EXACT on SAP / cost / CO2 / PE.
_CorpusExpectation(variant='oil 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 3', block='11a', expected_sap_resid=+2.5863, expected_cost_resid_gbp=-61.8906, expected_co2_resid_kg=-14.5815, expected_pe_resid_kwh=-967.0971),
_CorpusExpectation(variant='oil 4', block='11a', expected_sap_resid=+2.5603, expected_cost_resid_gbp=-56.6586, expected_co2_resid_kg=-13.3489, expected_pe_resid_kwh=-884.8990),
_CorpusExpectation(variant='oil 3', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 4', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=+3.0518, expected_cost_resid_gbp=-69.7943, expected_co2_resid_kg=-240.6595, expected_pe_resid_kwh=-1112.6558),
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),

View file

@ -4794,10 +4794,24 @@ def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> b
boiler family or a community heat network outside that set the spec's
"enter '0' if not a combi boiler" rule fires and the cascade must zero
(61)m.
The `main_heating_category` route covers the Open EPC API path where
the cert lodges a SAP 10.2 boiler / heat-network category integer.
The `_TABLE_4B_COMBI_OR_CPSU_CODES` fall-through covers the Elmhurst-
path case where the mapper leaves `main_heating_category=None` but
the cert lodges a Table 4b combi sub-row directly (oil 3 / oil 4 in
heating-systems corpus 001431 SAP codes 128 / 129 "Combi oil
boiler, pre-/post-1998", FAME fuel — Elmhurst's mapper artifact
leaves the category unset).
"""
if main is None:
return False
return main.main_heating_category in _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES
if main.main_heating_category in _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES:
return True
code = main.sap_main_heating_code
if isinstance(code, int) and code in _TABLE_4B_COMBI_OR_CPSU_CODES:
return True
return False
def _water_heating_worksheet_and_gains(

View file

@ -4460,6 +4460,78 @@ def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_
)
def test_table_4b_combi_oil_boiler_applies_table_3a_combi_loss_per_sap_4_line_7702() -> None:
"""SAP 10.2 §4 line 7702: "Combi loss for each month from Table 3a,
3b or 3c (enter '0' if not a combi boiler)". Table 4b sub-rows 128
("Combi oil boiler, pre-1998") and 129 ("Combi oil boiler, 1998 or
later") are explicitly combi boilers per the Table 4b row names.
Pre-slice `_table_3a_combi_loss_default_applies` gated only on
`main_heating_category` {1, 2, 3, 6}. The Elmhurst mapper leaves
`main_heating_category=None` on Table 4b liquid-fuel boilers (FAME,
HVO, B30K codes 128/129/130 combi sub-rows and 124/125/126/127
regular sub-rows). For combi-without-cylinder certs the (61)m
keep-hot default fell through to zero instead of the spec's 600
kWh/yr Table 3a row 1.
Worksheet evidence for heating-systems corpus 001431 oil 3 (Table
4b code 128 + WHC=901 + no cylinder + FAME fuel):
(61)m Jan 50.96 kWh, annual 600 kWh/yr
(62)m Jan = 251.39 = 0.85 × (45) + (46) + (61)
(219) HW fuel sum 3787 kWh/yr (= (62)m / (217)m via Eq D1)
Pre-slice cascade for oil 3: (61)m = 0, (62)m sum = 1935.37 ( (45)
sum, off by -600 vs ws 2535.37), (219) = 2876 (off by -911 vs ws
3787).
"""
# Arrange — synthesise oil 3 / oil 4 cert shape: Table 4b code 128
# combi oil boiler (FAME fuel = Table 32 code 73), no cylinder,
# WHC=901 ("HW from main heating"), main_heating_category=None
# (Elmhurst mapper artifact).
combi_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=73, # FAME fuel
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=None, # Elmhurst leaves None on Table 4b
sap_main_heating_code=128, # Combi oil boiler, pre-1998
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=False, # combi → no cylinder
sap_building_parts=[make_building_part()],
sap_heating=make_sap_heating(
main_heating_details=[combi_main],
water_heating_code=901,
water_heating_fuel=73,
),
)
# Act
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=0.62, # summer eff for Table 4b code 128
is_instantaneous=False,
primary_age="D",
pcdb_record=None,
)
# Assert — (61)m must be Table 3a row 1 default (600 kWh/yr
# prorated by days-in-month), NOT zero. Sum must land at ~600.
assert wh_result is not None
annual_combi_loss = sum(wh_result.combi_loss_monthly_kwh)
assert abs(annual_combi_loss - 600.0) < 1e-3, (
f"(61)m sum: got {annual_combi_loss!r}, want 600.0 — Table 4b "
f"combi sub-row 128 must apply Table 3a row 1 keep-hot default "
f"per SAP 10.2 §4 line 7702. Pre-slice cascade gated on "
f"main_heating_category ∈ {{1, 2, 3, 6}} only; the Elmhurst-"
f"path Table 4b combi codes (128/129/130) need a fall-through."
)
def test_lighting_co2_factor_blends_table_12a_grid_2_with_table_12d_dual_rate_on_off_peak_certs() -> None:
"""SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d (PDF p.194) —
"other electricity uses" (lighting, pumps + fans, electric shower) on