mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
fix(fuel): close case 47 — correct the second main heating system's fuel (off-peak pricing + Elmhurst Summary solid-fuel)
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) <noreply@anthropic.com>
This commit is contained in:
parent
44fff76722
commit
6a4539f26b
4 changed files with 181 additions and 1 deletions
|
|
@ -5857,6 +5857,21 @@ def _is_elmhurst_roof_window(
|
||||||
# room-in-roof. The §11 row alone can't separate them; the room-in-roof
|
# 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
|
# context is the discriminator (the location string is a §11 lodging
|
||||||
# artifact, so it is not a reliable vertical signal either).
|
# 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):
|
if not _elmhurst_bp_has_room_in_roof(w, survey):
|
||||||
return False
|
return False
|
||||||
return w.u_value > _ELMHURST_ROOF_WINDOW_U_THRESHOLD
|
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
|
and mh2.main_heating_sap_code in _ELECTRIC_SAP_MAIN_HEATING_CODES
|
||||||
):
|
):
|
||||||
main_fuel_int = _STANDARD_ELECTRICITY_FUEL_CODE
|
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
|
# §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
|
# second main system shares Main 1's fuel (simulated case 6: one oil
|
||||||
# boiler feeding radiators + underfloor, so the Summary lodges the
|
# boiler feeding radiators + underfloor, so the Summary lodges the
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,13 @@ class CalculatorInputs:
|
||||||
secondary_heating_fraction: float = 0.0
|
secondary_heating_fraction: float = 0.0
|
||||||
secondary_heating_efficiency: float = 1.0
|
secondary_heating_efficiency: float = 1.0
|
||||||
secondary_heating_fuel_cost_gbp_per_kwh: float = 0.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
|
# SAP10.2 (107)m — space cooling requirement kWh per month from §8c
|
||||||
# orchestrator `space_cooling_monthly_kwh`. Includes spec Jun-Aug
|
# orchestrator `space_cooling_monthly_kwh`. Includes spec Jun-Aug
|
||||||
# inclusion mask + 1-kWh clamp. Default (0,)*12 for backwards
|
# 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_generation_kwh_per_yr
|
||||||
* inputs.pv_export_credit_gbp_per_kwh
|
* 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_heating_cost = (
|
||||||
secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh
|
secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2511,6 +2511,33 @@ def _space_heating_fuel_cost_gbp_per_kwh(
|
||||||
return blended * _PENCE_TO_GBP
|
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(
|
def _main_space_heating_high_rate_fraction(
|
||||||
main: Optional[MainHeatingDetail],
|
main: Optional[MainHeatingDetail],
|
||||||
tariff: Tariff,
|
tariff: Tariff,
|
||||||
|
|
@ -7899,6 +7926,11 @@ def cert_to_inputs(
|
||||||
space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(
|
space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(
|
||||||
main, _rdsap_tariff(epc), prices
|
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,
|
hot_water_fuel_cost_gbp_per_kwh=hw_cost_rate,
|
||||||
other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh(
|
other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh(
|
||||||
_rdsap_tariff(epc), prices
|
_rdsap_tariff(epc), prices
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||||
_secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage]
|
_secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage]
|
||||||
_section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage]
|
_section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage]
|
||||||
_separately_timed_dhw, # 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]
|
_space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
|
||||||
_tariff_high_low_rates_p_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]
|
_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
|
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:
|
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
|
# 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
|
# assumed to be present when the domestic hot water is obtained from a
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue