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