mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue