From 6a4539f26b8bf3f9807760e51f782ef76a8f217d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Jun 2026 08:46:00 +0000 Subject: [PATCH] =?UTF-8?q?fix(fuel):=20close=20case=2047=20=E2=80=94=20co?= =?UTF-8?q?rrect=20the=20second=20main=20heating=20system's=20fuel=20(off-?= =?UTF-8?q?peak=20pricing=20+=20Elmhurst=20Summary=20solid-fuel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- datatypes/epc/domain/mapper.py | 31 ++++++ domain/sap10_calculator/calculator.py | 20 +++- .../sap10_calculator/rdsap/cert_to_inputs.py | 32 ++++++ .../rdsap/test_cert_to_inputs.py | 99 +++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 380192d3..40c32b0c 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index fb859bf6..74b038e0 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -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 ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 1614ef05..281d87b5 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 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 4f04720a..add25b03 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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