fix(fuel): close case 47 — correct the second main heating system's fuel (off-peak pricing + Elmhurst Summary solid-fuel)

Two second-main fuel errors mis-cost a dual-main dwelling whose two main systems
burn different fuels (SAP 10.2 §10a worksheet (213) bills main 2 at its own fuel):

1. Off-peak/legacy scalar cost path (calculator.py + cert_to_inputs.py): main 2's
   kWh was priced at main 1's `space_heating_fuel_cost_gbp_per_kwh` scalar. Split
   main 1 vs main 2 and price main 2 at its OWN rate via the new
   `_main_2_space_heating_fuel_cost_gbp_per_kwh` (+ CalculatorInputs field).
   Scoped to a NON-electric second main (wood/oil/coal) — an electric second
   main keeps main 1's scalar (its off-peak Table 12a split is the deferred §10a
   slice; per-system splitting it regresses the off-peak electric cohort, certs
   13 Parkers Hill / 34 Dunley Road). 0 corpus impact (no corpus cert has a
   non-electric second main on an off-peak meter).

2. Elmhurst Summary mapper (mapper.py): when §14.1 omits the Fuel Type cell, a
   fuel-fired second main (room-heater SAP code) inherited main 1's fuel. Derive
   it from the SAP code's Table 4a category (solid 631-636 -> house coal, gas ->
   mains gas, liquid -> oil) before the main-1 inherit, mirroring
   `_elmhurst_secondary_fuel_from_sap_code` (same modal sub-fuel caveat). Boiler
   codes (<601) still inherit main 1 (case 6 oil rads+UFH).

simulated case 47 (electric room heaters + solid room heaters 633): our SAP
37.81 -> 55.09 vs Elmhurst current 57 (residual is the wood-vs-coal sub-fuel the
Summary export does not carry). Corpus unchanged 72.5% / MAE 0.793; batch 0
raised / 0 diverge; 000565 e2e green. (mapper.py also carries an earlier,
behaviour-free roof-window doc comment.) Spec-cited unit pins added (AAA).
pyright not installed locally — strict type gate not run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 08:46:00 +00:00
parent 44fff76722
commit 6a4539f26b
4 changed files with 181 additions and 1 deletions

View file

@ -5857,6 +5857,21 @@ def _is_elmhurst_roof_window(
# room-in-roof. The §11 row alone can't separate them; the room-in-roof
# context is the discriminator (the location string is a §11 lodging
# artifact, so it is not a reliable vertical signal either).
# KNOWN LIMIT (falls through to vertical below) — a roof window on a PLAIN
# PITCHED roof (not a room-in-roof, BP roof type not A/NR) with a normal
# U <= 3.0 is UNDETECTABLE from the Elmhurst Summary §11. The Summary has
# no roof/window "Type" column (that exists only in the U985/P960
# *worksheet* input echo, which this pipeline does not read), and such
# rooflights lodge with Location "External wall" and the vertical U —
# byte-identical to ordinary wall windows. Khalim's "simulated case 46"
# exercises this: 2 of its 6 openings are roof windows (worksheet (27a),
# combined area 1.70, U_eff 2.1062) yet opening 1 (wall) and opening 2
# (roof) read character-for-character the same in §11, so the extractor
# cannot separate them. The residual is 29.58 vs Elmhurst 29.68 (both round
# to 30) — accepted as a lossy-Summary limit, not a bug. The gov-API path
# is unaffected: roof windows carry a dedicated signal there
# (`window_wall_type == 4`, see `_api_is_roof_window`; 55 corpus certs /
# 124 roof windows routed correctly, worksheet-validated U/solar treatment).
if not _elmhurst_bp_has_room_in_roof(w, survey):
return False
return w.u_value > _ELMHURST_ROOF_WINDOW_U_THRESHOLD
@ -7163,6 +7178,22 @@ def _map_elmhurst_main_heating_2(
and mh2.main_heating_sap_code in _ELECTRIC_SAP_MAIN_HEATING_CODES
):
main_fuel_int = _STANDARD_ELECTRICITY_FUEL_CODE
# A fuel-fired second main (room-heater SAP code) whose §14.1 omits the
# Fuel Type cell must derive its fuel from the SAP code's Table 4a CATEGORY
# — NOT inherit Main 1's fuel below. Mirrors `_elmhurst_secondary_fuel_
# from_sap_code` (same modal-fuel + sub-fuel caveat): a wood/coal solid
# second main (633) billed at Main 1's electric rate is the dual-main
# over-cost (simulated case 47: electric room heaters + solid room heaters
# 633). Boiler codes (<601) fall through to the Main-1 inherit (case 6:
# one oil boiler feeding rads + underfloor).
if main_fuel_int is None:
_sc = mh2.main_heating_sap_code
if _sc in _ELMHURST_SECONDARY_SOLID_CODES:
main_fuel_int = _SECONDARY_FUEL_HOUSE_COAL
elif _sc in _ELMHURST_SECONDARY_GAS_CODES:
main_fuel_int = _SECONDARY_FUEL_MAINS_GAS
elif _sc in _ELMHURST_SECONDARY_LIQUID_CODES:
main_fuel_int = _SECONDARY_FUEL_HEATING_OIL
# §14.1 Main Heating2 often omits the "Fuel Type" cell when the
# second main system shares Main 1's fuel (simulated case 6: one oil
# boiler feeding radiators + underfloor, so the Summary lodges the

View file

@ -301,6 +301,13 @@ class CalculatorInputs:
secondary_heating_fraction: float = 0.0
secondary_heating_efficiency: float = 1.0
secondary_heating_fuel_cost_gbp_per_kwh: float = 0.0
# Main heating system 2's fuel cost per kWh (legacy/off-peak scalar path).
# main system 2 may burn a DIFFERENT fuel from main 1 (e.g. wood logs vs
# electric room heaters) — pricing its kWh at main 1's rate grossly mis-
# costs it. Defaults to 0.0; cert_to_inputs sets it to main 2's own rate
# when a second main is lodged (the term is multiplied by main 2's kWh,
# which is 0 when no second main exists, so the default is inert).
main_2_heating_fuel_cost_gbp_per_kwh: float = 0.0
# SAP10.2 (107)m — space cooling requirement kWh per month from §8c
# orchestrator `space_cooling_monthly_kwh`. Includes spec Jun-Aug
# inclusion mask + 1-kWh clamp. Default (0,)*12 for backwards
@ -579,7 +586,18 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
inputs.pv_generation_kwh_per_yr
* inputs.pv_export_credit_gbp_per_kwh
)
main_heating_cost = main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh
# `main_fuel_kwh` aggregates main systems 1 and 2 (see (211)+(213)
# above). Bill main 2 at ITS OWN fuel rate, not main 1's — a dual-fuel
# pair (e.g. electric room heaters + wood logs) is mis-costed otherwise
# (the §10a standard path already splits these; this is the legacy/
# off-peak scalar path). main 2's kWh is 0 when no second main is
# lodged, so the default 0.0 rate is inert.
main_2_fuel_kwh = inputs.energy_requirements.main_2_fuel_kwh_per_yr
main_1_fuel_kwh = main_fuel_kwh - main_2_fuel_kwh
main_heating_cost = (
main_1_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh
+ main_2_fuel_kwh * inputs.main_2_heating_fuel_cost_gbp_per_kwh
)
secondary_heating_cost = (
secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh
)

View file

@ -2511,6 +2511,33 @@ def _space_heating_fuel_cost_gbp_per_kwh(
return blended * _PENCE_TO_GBP
def _main_2_space_heating_fuel_cost_gbp_per_kwh(
epc: EpcPropertyData,
tariff: Tariff,
prices: PriceTable,
) -> float:
"""Main heating system 2's space-heating fuel rate (£/kWh) for the
legacy/off-peak scalar cost path. Keyed on main 2's OWN fuel via the same
`_space_heating_fuel_cost_gbp_per_kwh` logic (off-peak Table 12a split when
main 2 is electric, flat Table 32 rate otherwise) so a wood-log or other
non-electric second main is not billed at main 1's electric rate. Returns
0.0 when no second main is lodged (multiplied by main 2's 0 kWh).
Scoped to a NON-electric second main (the unambiguous correction): a wood/
oil/coal second main billed at main 1's rate is plainly wrong. An ELECTRIC
second main keeps main 1's space-heating scalar — its off-peak Table 12a
high/low split is the deferred §10a off-peak slice, and applying the split
per-system here regresses the off-peak electric cohort (certs 13 Parkers
Hill / 34 Dunley Road)."""
details = epc.sap_heating.main_heating_details if epc.sap_heating else None
if not details or len(details) < 2:
return 0.0
main_2 = details[1]
if _is_electric_main(main_2):
return _space_heating_fuel_cost_gbp_per_kwh(details[0], tariff, prices)
return _space_heating_fuel_cost_gbp_per_kwh(main_2, tariff, prices)
def _main_space_heating_high_rate_fraction(
main: Optional[MainHeatingDetail],
tariff: Tariff,
@ -7899,6 +7926,11 @@ def cert_to_inputs(
space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(
main, _rdsap_tariff(epc), prices
),
main_2_heating_fuel_cost_gbp_per_kwh=(
_main_2_space_heating_fuel_cost_gbp_per_kwh(
epc, _rdsap_tariff(epc), prices
)
),
hot_water_fuel_cost_gbp_per_kwh=hw_cost_rate,
other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh(
_rdsap_tariff(epc), prices

View file

@ -85,6 +85,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage]
_section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage]
_separately_timed_dhw, # pyright: ignore[reportPrivateUsage]
_main_2_space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage]
_thermal_mass_parameter_kj_per_m2_k, # pyright: ignore[reportPrivateUsage]
@ -527,6 +528,104 @@ def test_dual_main_system_2_costed_at_its_own_fuel_price() -> None:
assert abs(fc.main_2_total_cost_gbp - main_2_kwh * 0.1319) > 1.0
def test_off_peak_main_2_non_electric_priced_at_own_fuel() -> None:
# Arrange — legacy/off-peak scalar path. Main 1 = electric room heaters,
# Main 2 = WOOD logs (fuel 6, Table 32 4.23 p/kWh). On an off-peak tariff
# the second main's space-heating rate must be its OWN fuel (wood flat
# rate), not main 1's electric rate — SAP 10.2 §10a worksheet (213).
from domain.sap10_calculator.tables.table_12a import Tariff
main_1_electric = MainHeatingDetail(
has_fghrs=False, main_fuel_type=30, heat_emitter_type=0,
emitter_temperature="NA", main_heating_control=2106,
main_heating_category=10, sap_main_heating_code=691,
main_heating_fraction=50,
)
main_2_wood = MainHeatingDetail(
has_fghrs=False, main_fuel_type=6, heat_emitter_type=0,
emitter_temperature="NA", main_heating_control=2106,
main_heating_category=10, sap_main_heating_code=633,
main_heating_fraction=50,
)
epc = make_minimal_sap10_epc(
sap_building_parts=[make_building_part(construction_age_band="D")],
sap_heating=make_sap_heating(
main_heating_details=[main_1_electric, main_2_wood],
),
)
# Act
rate = _main_2_space_heating_fuel_cost_gbp_per_kwh(
epc, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
# Assert — wood flat rate 4.23 p/kWh, not the off-peak electric rate.
assert abs(rate - 0.0423) <= 1e-9
def test_off_peak_main_2_electric_keeps_main_1_scalar() -> None:
# Arrange — both mains electric on an off-peak tariff. The electric
# second-main off-peak Table 12a split is the deferred §10a slice, so the
# helper keeps main 1's space-heating scalar (no per-system split here) to
# avoid regressing the off-peak electric cohort.
from domain.sap10_calculator.tables.table_12a import Tariff
main_1 = MainHeatingDetail(
has_fghrs=False, main_fuel_type=29, heat_emitter_type=0,
emitter_temperature="NA", main_heating_control=2106,
main_heating_category=10, sap_main_heating_code=691,
main_heating_fraction=50,
)
main_2 = MainHeatingDetail(
has_fghrs=False, main_fuel_type=29, heat_emitter_type=0,
emitter_temperature="NA", main_heating_control=2106,
main_heating_category=10, sap_main_heating_code=691,
main_heating_fraction=50,
)
epc = make_minimal_sap10_epc(
sap_building_parts=[make_building_part(construction_age_band="D")],
sap_heating=make_sap_heating(main_heating_details=[main_1, main_2]),
)
# Act — main 2's rate equals main 1's space-heating scalar.
main_2_rate = _main_2_space_heating_fuel_cost_gbp_per_kwh(
epc, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
main_1_rate = _space_heating_fuel_cost_gbp_per_kwh(
main_1, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
# Assert
assert abs(main_2_rate - main_1_rate) <= 1e-9
def test_elmhurst_main_2_solid_sap_code_derives_house_coal_not_main_1_fuel() -> None:
# Arrange — Elmhurst §14.1 Main Heating2 lodges SAP code 633 (solid-fuel
# room heater) with NO "Fuel Type" cell, on a dwelling whose Main 1 is
# electric. The second main must derive its fuel from the SAP code's
# Table 4a category (solid → house coal 11, per the documented modal sub-
# fuel convention), NOT inherit Main 1's electric fuel — otherwise the
# solid second main is billed at the electric rate (simulated case 47
# dual-main over-cost: SAP 37.81 -> 55.09).
from datatypes.epc.surveys.elmhurst_site_notes import MainHeating2
from datatypes.epc.domain.mapper import ( # pyright: ignore[reportPrivateUsage]
_map_elmhurst_main_heating_2,
)
mh2 = MainHeating2(
fuel_type="", # §14.1 omits the Fuel Type cell
percentage_of_heat=50,
main_heating_sap_code=633, # solid-fuel room heater
)
# Act — Main 1 resolved to electricity (fallback_fuel_type=30).
detail = _map_elmhurst_main_heating_2(mh2, fallback_fuel_type=30)
# Assert — house coal (11), not the inherited electric 30.
assert detail is not None
assert detail.main_fuel_type == 11
def test_cylinder_thermostat_assumed_present_per_sap_9_4_9() -> None:
# Arrange — SAP 10.2 §9.4.9 (PDF p.32): "A cylinder thermostat should be
# assumed to be present when the domestic hot water is obtained from a