S0380.212: fix community fuel-code collision in heat-network CO2/PE/cost

Cert 9390-2722-3520 (community mains-gas boiler scheme, sap_main_heating_
code=301, main_fuel_type=20) emitted CO2 0.44 t vs lodged 2.8 t — 6.4x low.

Root cause: the EPC `main_fuel_type` enum and the SAP Table 12 / Table 32
fuel-code numbering COLLIDE in the 18-25 range. Per
`datatypes/epc/domain/epc_codes.csv` (RdSAP-Schema-17.0) EPC fuel
20 = "mains gas (community)", but Table 12/32 code 20 is a solid biomass
fuel (CO2 0.028, PE 1.046, wood-logs price). The factor lookups
(`co2_factor_kg_per_kwh` / `primary_energy_factor` / `unit_price_p_per_kwh`)
check the Table-12/32 dict FIRST, so the EPC community fuel 20 silently
returned the biomass factor instead of translating 20 -> Table 12 code 51
(community mains gas: CO2 0.210, PE 1.130, mains-gas price).

Fix: new `_heat_network_factor_fuel_code(main)` translates the EPC community
fuel to its Table-12 code via `API_FUEL_TO_TABLE_12`, but ONLY for
heat-network mains (`_is_heat_network_main`) — a genuine biomass boiler
(non-community) keeps its raw Table-12 factor. Applied at the five
heat-network factor sites: space-heating CO2 / PE / unit-price and
water-heating (WHC 901) CO2 / PE. The Summary path is unaffected (it maps
"Mains gas - community" to code 1, no collision), so the community-heating
corpus (CH1-6) is untouched.

Worksheet-validated against simulated case 14 (community boilers + mains
gas, SAP code 301): worksheet (367) CO2 factor 0.2100, (467) PE factor
1.1300 — exactly the Table-12 code-51 values the translator now reaches.
9390 CO2 0.44 -> 3.03 t (lodged 2.8; spec-correct factors over the API-only
register residual per [[feedback-worksheet-not-api-reference]]), PE 204 ->
220 (the spec-correct 1.13 factor; the prior 204≈205 match was the
collision coinciding with the register residual). 9390 is unpinned (retired
at P2.2 per ADR-0010 §10); the translator is locked by two unit tests.

REMAINING (separate follow-up): 9390 SAP +4 is a cost-side gap — the
heat-network cost path does not apply the 1/heat_source_eff (1/0.80)
scaling that the CO2/PE paths do, so community fuel cost under-counts.

Suite: 2616 passed, 1 skipped (community corpus green); the 2
test_rdsap_uvalues stone-formula failures are pre-existing (HEAD 58ff7d88).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 23:25:53 +00:00
parent c3c44fa3d0
commit 08dd0b4c73
2 changed files with 96 additions and 5 deletions

View file

@ -1855,6 +1855,37 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:
raise MissingMainFuelType(fuel, main.sap_main_heating_code)
def _heat_network_factor_fuel_code(
main: Optional[MainHeatingDetail],
) -> Optional[int]:
"""Fuel code to feed the Table 12 / Table 32 factor lookups, with the
EPCTable-12 translation applied for heat-network (community) mains.
The EPC `main_fuel_type` enum and the SAP Table 12 / Table 32 fuel-code
numbering COLLIDE in the 18-25 range: `epc_codes.csv` lists
20='mains gas (community)', 21='LPG (community)', 22='oil (community)',
..., whereas Table 12/32 code 20-25 are solid biomass fuels. The factor
lookups (`co2_factor_kg_per_kwh` / `primary_energy_factor` /
`unit_price_p_per_kwh`) check the Table-12/32 dict FIRST, so an EPC
community fuel 20 silently returns the biomass factor (CO2 0.028, PE
1.046, wood-logs price) instead of community mains gas (CO2 0.210, PE
1.130, mains-gas price + £120 standing charge).
Resolution: for a heat-network main, translate the EPC community fuel to
its Table-12 code via `API_FUEL_TO_TABLE_12` (20->51) so the lookups hit
the heat-network row. NON-heat-network mains are returned unchanged so a
genuine biomass boiler (EPC 6 wood logs / 12 biomass, etc.) keeps its raw
Table-12 factor. The Summary path is unaffected it maps
"Mains gas - community" to code 1 (no collision). Worksheet-validated:
simulated case 14 (community boilers + mains gas, SAP code 301)
(367) CO2 factor 0.2100, (467) PE factor 1.1300.
"""
fuel = _main_fuel_code(main)
if fuel is None or not _is_heat_network_main(main):
return fuel
return API_FUEL_TO_TABLE_12.get(fuel, fuel)
def _fuel_cost_gbp_per_kwh(
main: Optional[MainHeatingDetail], prices: PriceTable
) -> float:
@ -1882,7 +1913,10 @@ def _fuel_cost_gbp_per_kwh(
)
blended_p = chp_frac * chp_price + (1.0 - chp_frac) * boiler_price
return blended_p * _PENCE_TO_GBP
return prices.unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP
return (
prices.unit_price_p_per_kwh(_heat_network_factor_fuel_code(main))
* _PENCE_TO_GBP
)
# RdSAP energy_tariff enum (per datatypes/epc/domain/epc_codes.csv):
@ -2816,7 +2850,7 @@ def _main_heating_co2_factor_kg_per_kwh(
)
if monthly is not None:
return monthly * scaling
return _co2_factor_kg_per_kwh(main) * scaling
return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_co2_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
@ -2895,7 +2929,7 @@ def _main_heating_primary_factor(
)
if monthly is not None:
return monthly * scaling
return primary_energy_factor(fuel) * scaling
return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_pe_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
@ -3171,7 +3205,7 @@ def _hot_water_co2_factor_kg_per_kwh(
)
if monthly is not None:
return monthly * scaling
return _co2_factor_kg_per_kwh(main) * scaling
return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_CO2_KG_PER_KWH
@ -3235,7 +3269,7 @@ def _hot_water_primary_factor(
)
if monthly is not None:
return monthly * scaling
return primary_energy_factor(_main_fuel_code(main)) * scaling
return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_PEF

View file

@ -52,6 +52,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage]
_heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
_is_electric_water, # pyright: ignore[reportPrivateUsage]
@ -5816,3 +5817,59 @@ def test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_table_4b_boi
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%)"
)
def test_heat_network_community_gas_fuel_translates_epc_20_to_table12_51() -> None:
# Arrange — a community mains-gas BOILER main (SAP code 301) lodges
# main_fuel_type=20. Per epc_codes.csv (RdSAP-Schema-17.0) EPC fuel 20
# is "mains gas (community)", but the SAP Table 12 / Table 32 numbering
# uses 20 for a solid biomass fuel — a collision. The factor lookups
# check the Table-12 dict first, so co2_factor_kg_per_kwh(20) returns
# the biomass 0.028 instead of community mains gas 0.210. The
# heat-network fuel-code translator must route EPC 20 → Table 12 51.
from domain.sap10_calculator.tables.table_12 import (
co2_factor_kg_per_kwh,
primary_energy_factor,
)
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20, # EPC "mains gas (community)"
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6, # heat network
sap_main_heating_code=301, # community boilers
)
# Act
code = _heat_network_factor_fuel_code(main)
# Assert — translates to Table 12 code 51 (community mains gas), and the
# factor lookups then return the worksheet-validated case-14 values
# ((367) CO2 0.2100, (467) PE 1.1300), NOT the collided biomass factors.
assert code == 51
assert abs(co2_factor_kg_per_kwh(code) - 0.210) <= 1e-9
assert abs(primary_energy_factor(code) - 1.130) <= 1e-9
assert abs(co2_factor_kg_per_kwh(20) - 0.028) <= 1e-9 # the collided value
def test_non_heat_network_biomass_fuel_not_translated() -> None:
# Arrange — a NON-heat-network main lodging the same integer fuel code
# must NOT be translated: a genuine biomass boiler keeps its raw
# Table-12 factor. The translator only fires for heat-network mains.
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2, # ordinary boiler, NOT heat network
sap_main_heating_code=102,
)
# Act
code = _heat_network_factor_fuel_code(main)
# Assert — unchanged (raw code, biomass factor preserved).
assert code == 20