Slice 58: secondary fuel cost routes through lodged secondary_fuel_type

Two coupled bugs surfaced by cert 001479's mains-gas-fire secondary
heating (Summary §14.1 lodges "SAP code 605, Flush fitting live effect
gas fire" → fuel 26 mains gas):

1. **Mapper**: `_map_elmhurst_sap_heating` only set
   `secondary_heating_type` (the SAP code int) — `secondary_fuel_type`
   stayed None. The Summary PDF doesn't lodge the fuel int separately;
   it has to be derived from the SAP code range. Add
   `_elmhurst_secondary_fuel_from_sap_code`: codes 601-630 → 26
   (mains gas); other codes return None (the cascade defaults to
   electric, matching cohort 000490 SAP code 691 electric panel).

2. **Cascade**: `_fuel_cost` in cert_to_inputs hardcoded
   `secondary_high_rate_gbp_per_kwh = other_uses_gbp_per_kwh` (the
   standard-electricity tariff) regardless of `secondary_fuel_type`.
   For gas secondaries this charged 1846 kWh/yr at electric rate
   (£0.132/kWh = £243) instead of gas rate (£0.0348/kWh = £64) —
   a ~£175/yr ECF distortion ≈ 9 SAP points on cert 001479. Route
   the cost through `table_32_unit_price_p_per_kwh(secondary_fuel)`
   when lodged.

Worksheet line (242) confirms the gas pricing:
  `Space heating - secondary  2025.93  3.4800  70.5022`

Cert 001479 chain pin delta narrows: SAP_continuous 61.39 → 70.64
(was −7.62 vs 69.0094, now +1.63 — overshooting target by 1.63 SAP).
The remaining overshoot maps to the cascade's ~16 W/K HLC undercount
(cascade HLP 2.89 vs worksheet 3.13 × TFA) — work for follow-up
slices.

Cohort 6 chain certs still green at 1e-4 (all-electric or no-
secondary). Golden cohort: cert 0300-2747 (mains-gas secondary)
SAP residual tightens −7 → +2 — biggest single SAP improvement on
the golden cohort to date; pin updated and notes annotated. Other
7 golden certs unchanged (None or electric secondary fuel). Pyright
net-zero (35 baseline each on mapper.py + cert_to_inputs.py).

Chain pin `test_summary_001479_full_chain_sap_matches_worksheet_pdf_
exactly` is the load-bearing RED — committed failing per TDD; closes
to GREEN once the HLC undercount lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 22:54:00 +00:00
parent a0d9d09410
commit e3dc0b28f5
4 changed files with 94 additions and 6 deletions

View file

@ -327,3 +327,54 @@ def test_summary_001479_ext2_sloping_ceiling_roof_uninsulated_for_pre_1950() ->
# Assert
assert epc.sap_building_parts[2].roof_insulation_thickness == 0
assert epc.sap_building_parts[1].roof_insulation_thickness is None
def test_summary_001479_secondary_heating_routes_mains_gas_fuel() -> None:
# Arrange — cert 001479 §14.1 Main Heating2 lodges "Secondary Heating
# Code: SAP code 605, Flush fitting live effect gas fire, sealed to
# chimney". The Summary surfaces only the SAP code (605); the fuel
# type 26 (mains gas) must be derived from the code range so the
# `_fuel_cost` orchestrator's `secondary_high_rate_gbp_per_kwh`
# picks up Table 32's gas tariff (£0.0348/kWh) rather than the
# default standard-electricity tariff (£0.132/kWh). Worksheet line
# (242) "Space heating - secondary … 3.4800 70.5022" confirms gas
# pricing.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert
assert epc.sap_heating.secondary_heating_type == 605
assert epc.sap_heating.secondary_fuel_type == 26
def test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
# Arrange — cert 001479 (Summary_001479.pdf / P960-0001-001479.pdf)
# is the first cohort cert with a real GOV.UK EPB API counterpart
# (cert ref 0535-9020-6509-0821-6222). Worksheet PDF line "SAP value"
# lodges unrounded SAP **69.0094** (rating C 69, also the API-
# published integer). This is the load-bearing forcing function for
# the API↔Elmhurst parity workstream: any drift from 1e-4 means a
# mapper gap, not a calculator bug — the cohort 6 cert cascades all
# reproduce Elmhurst exactly at 1e-4 on hand-built fixtures.
#
# Source-data caveat (documented for future debuggers): Summary §3
# lodges Ext1 age band as "M 2023 onwards"; the worksheet header
# records "Ext1: L". Likely assessor data-entry inconsistency. The
# mapper trusts the Summary (its source of truth); accept whatever
# residual the M vs L disagreement produces.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
# Assert — 1e-4 pin, no widening, no xfail (project memory
# `feedback_zero_error_strict`).
worksheet_unrounded_sap = 69.0094
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4

View file

@ -2487,6 +2487,25 @@ def _elmhurst_heat_emitter_int(emitter: str) -> Optional[int]:
return _ELMHURST_HEAT_EMITTER_TO_SAP10.get(emitter)
def _elmhurst_secondary_fuel_from_sap_code(
sap_code: Optional[int],
) -> Optional[int]:
"""Derive `secondary_fuel_type` from an Elmhurst secondary-heating SAP
code. The Summary PDF lodges the SAP code (e.g. 605 for "Flush
fitting live effect gas fire") but not the fuel int separately; the
cascade's `_secondary_fuel_cost_gbp_per_kwh` defaults to standard
electricity when `secondary_fuel_type` is None correct for the
portable-electric default but wrong for cert 001479's mains-gas fire.
Returns 26 (mains gas) for SAP codes in the 600-630 range; None for
other codes (cascade default fires, matching cohort 000490 SAP code
691 electric panel)."""
if sap_code is None:
return None
if 601 <= sap_code <= 630:
return 26 # Mains gas, matching `_ELMHURST_MAIN_FUEL_TO_SAP10`
return None
def _elmhurst_sap_control_code(sap_control: str) -> Optional[int]:
"""Extract the SAP code integer from a heating-controls field like
'SAP code 2106, Programmer, room thermostat and TRVs' 2106. The
@ -2587,6 +2606,9 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
),
water_heating_code=survey.water_heating.water_heating_sap_code,
secondary_heating_type=mh.secondary_heating_sap_code,
secondary_fuel_type=_elmhurst_secondary_fuel_from_sap_code(
mh.secondary_heating_sap_code,
),
number_baths=survey.baths_and_showers.number_of_baths,
electric_shower_count=1 if has_electric_shower else None,
mixer_shower_count=0 if has_electric_shower else None,

View file

@ -1877,10 +1877,21 @@ def _fuel_cost(
table_32_unit_price_p_per_kwh(water_heating_fuel_code or main_fuel_code)
* _PENCE_TO_GBP
)
# Secondary fuel = standard electricity by default (portable electric
# heater per §A.2.2). Scope A has no lodged secondaries; the fraction
# is zero so the price contributes nothing to (242).
secondary_high_rate_gbp_per_kwh = other_uses_gbp_per_kwh
# Secondary fuel cost: route through the cert's `secondary_fuel_type`
# when lodged (e.g. mains-gas fire SAP code 605 → fuel 26 → Table 32
# gas price), otherwise default to standard electricity (the portable
# electric heater per §A.2.2 — same as the cohort's electric panel
# SAP code 691). Pre-slice this column hardcoded `other_uses_gbp_per_
# kwh` regardless of fuel type, charging gas-secondary kWh at the
# electric tariff and dropping ~£175/yr from the ECF on cert 001479.
secondary_fuel = (
epc.sap_heating.secondary_fuel_type if epc.sap_heating else None
)
secondary_high_rate_gbp_per_kwh = (
table_32_unit_price_p_per_kwh(secondary_fuel) * _PENCE_TO_GBP
if secondary_fuel is not None
else other_uses_gbp_per_kwh
)
# Table 32 PV export credit (code 60 = 13.19 p/kWh, same as std
# electricity under RdSAP10 amendment).

View file

@ -94,13 +94,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0300-2747-7640-2526-2135",
actual_sap=78,
expected_sap_resid=-7,
expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-1.9082,
expected_co2_resid_tonnes_per_yr=-1.0829,
notes=(
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
"(no Table 4b code). Cert lodges open_flues_count=1 + "
"has_draught_lobby=true."
"has_draught_lobby=true + mains-gas secondary (SAP code 605 / "
"fuel 26). Slice 58 cascade routed secondary fuel cost through "
"the lodged fuel_type (rather than hardcoding the electric "
"tariff), tightening this cert's SAP residual 7 → +2 — the "
"biggest single SAP improvement on the golden cohort to date."
),
),
_GoldenExpectation(