From 08dd0b4c73b63348ccb59c0d8f0589c6ed8b9da1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 23:25:53 +0000 Subject: [PATCH] S0380.212: fix community fuel-code collision in heat-network CO2/PE/cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 44 ++++++++++++-- .../rdsap/test_cert_to_inputs.py | 57 +++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 13a7cf62..4fa6fde3 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 + EPC→Table-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 diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index fbccd757..932552d2 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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