fix(fuel): cost main heating system 2 at its own fuel price, not main 1's (SAP 10.2 §10a worksheet line 213)

Main heating system 2's space-heating fuel cost (worksheet (213)) was billed
at main system 1's Table 32 unit price (`main_2_high_rate_gbp_per_kwh` reused
`main_1_high_rate_gbp_per_kwh`). For a dual-FUEL pair this grossly mis-costs the
second main: cert 10032957680 "Copse Cottage" (main 1 electric room heaters
fuel 30, main 2 wood logs fuel 6) charged its 9481 kWh of wood at 13.19 p/kWh
instead of 4.23 p/kWh — +£850/yr → SAP 21.75 vs lodged 45.

Route main 2 through its own fuel code (`_main_fuel_code(details[1])`), mirroring
the existing secondary-fuel handling. Copse Cottage 21.75 -> 45.94. Corpus
within-0.5 holds 72.5%, SAP MAE 0.815 -> 0.793 (ratcheted ceiling 0.82 -> 0.80);
CO2/PE unchanged. Same-fuel dual mains (gas+gas) unaffected. Off-peak-tariff
dual-fuel mains still defer to the legacy scalar path (separate slice).

Spec-cited unit pin 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:31:04 +00:00
parent a9632937d5
commit 702150002f
3 changed files with 76 additions and 2 deletions

View file

@ -6927,6 +6927,26 @@ def _fuel_cost(
main_1_high_rate_gbp_per_kwh = (
table_32_unit_price_p_per_kwh(main_fuel_code) * _PENCE_TO_GBP
)
# Main heating system 2 is costed at ITS OWN fuel price (SAP 10.2 §10a
# worksheet line (213) bills main system 2's fuel separately from main 1's
# (211)) — Table 32 unit price keyed on main 2's fuel code. Pre-fix this
# column reused `main_1_high_rate_gbp_per_kwh`, charging a dual-fuel second
# main (e.g. wood logs SAP code 633, fuel 6 @ 4.23 p/kWh) at the main-1
# electric rate (13.19 p/kWh), grossly over-costing electric+wood
# room-heater dwellings (cert 10032957680 "Copse Cottage" +£850/yr ->
# SAP -23). Falls back to main 1's price only when no second main is lodged.
main_2_detail = (
epc.sap_heating.main_heating_details[1]
if epc.sap_heating
and len(epc.sap_heating.main_heating_details or []) >= 2
else None
)
main_2_fuel_code = _main_fuel_code(main_2_detail)
main_2_high_rate_gbp_per_kwh = (
table_32_unit_price_p_per_kwh(main_2_fuel_code) * _PENCE_TO_GBP
if main_2_fuel_code is not None
else main_1_high_rate_gbp_per_kwh
)
water_high_rate_gbp_per_kwh = (
table_32_unit_price_p_per_kwh(water_heating_fuel_code)
* _PENCE_TO_GBP
@ -6976,7 +6996,7 @@ def _fuel_cost(
main_1_low_rate_gbp_per_kwh=0.0,
main_1_high_rate_fraction=1.0,
main_2_kwh_per_yr=main_2_kwh,
main_2_high_rate_gbp_per_kwh=main_1_high_rate_gbp_per_kwh,
main_2_high_rate_gbp_per_kwh=main_2_high_rate_gbp_per_kwh,
main_2_low_rate_gbp_per_kwh=0.0,
main_2_high_rate_fraction=1.0 if main_2_kwh > 0.0 else 0.0,
secondary_kwh_per_yr=secondary_kwh,

View file

@ -473,6 +473,60 @@ def test_heat_network_primary_loss_uses_p1_h3_all_months_per_table_3() -> None:
assert abs(sum(wh_result.primary_loss_monthly_kwh) - expected_primary_kwh) <= 1e-6
def test_dual_main_system_2_costed_at_its_own_fuel_price() -> None:
# Arrange — SAP 10.2 §10a: main heating system 2's fuel cost (worksheet
# line (213)) is billed at ITS OWN Table 32 fuel price, separately from
# main system 1's (211). A dwelling with main-1 = electric room heaters
# (SAP 691, fuel 30 standard electricity @ 13.19 p/kWh) and main-2 =
# wood-log room heaters (SAP 633, fuel 6 @ 4.23 p/kWh), 50/50 split, on a
# single-rate meter (so the §10a standard path runs). Pre-fix the main-2
# column reused main-1's electric price, charging the wood at 13.19 p/kWh
# and grossly over-costing electric+wood dwellings (cert 10032957680
# "Copse Cottage" SAP 21.75 -> 45.94 vs lodged 45).
main_1_electric = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=30, # standard electricity
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, # wood logs — Table 32 = 4.23 p/kWh
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(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
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
inputs = cert_to_inputs(epc)
result = Sap10Calculator().calculate(epc)
fc = inputs.fuel_cost
main_2_kwh = result.main_2_heating_fuel_kwh_per_yr
# Assert — main-2 wood billed at 4.23 p/kWh (0.0423 £/kWh), NOT main-1's
# electric 13.19 p/kWh. (213) main_2_total_cost = kWh × wood price.
assert main_2_kwh > 0.0
assert abs(fc.main_2_total_cost_gbp - main_2_kwh * 0.0423) <= 1e-6
# And explicitly NOT the electric rate (the pre-fix value).
assert abs(fc.main_2_total_cost_gbp - main_2_kwh * 0.1319) > 1.0
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

View file

@ -194,7 +194,7 @@ _CORPUS = Path(
# stress worksheet (simulated case 46): closed its last ventilation residual
# (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst).
_MIN_WITHIN_HALF_SAP = 0.72
_MAX_SAP_MAE = 0.82
_MAX_SAP_MAE = 0.80
_MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 3.7 # kWh / m2 / yr vs energy_consumption_current