mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
slice S-B15: meter_type drives tariff selection (replaces heating-code heuristic)
Per user guidance: trust the cert's lodged meter_type as the source of
truth for tariff selection, rather than inferring tariff from heating
code lists. SAP10 meter_type enum (verified empirically on the 250k
corpus: 75% type 2, 14% type 1, 11% type 3):
1 = Off-peak (Economy-7 / dual rate)
2 = Single (Standard)
3 = Off-peak (24-hour heating)
The transform.py docstring describes 1=Standard / 2=Off-peak but that
contradicts the 75% type-2 distribution (UK demographics don't put 75%
of dwellings on off-peak). The inverted reading parity-tests correctly.
Tariff routing rules:
- Space heating: off-peak rate when main fuel is electric AND meter is
off-peak; else standard main-fuel rate.
- Hot water: off-peak rate when water fuel is electric AND meter is
off-peak; else water-fuel rate.
- Lighting + pumps + fans: always standard electricity (Table 12a
notwithstanding — cert software empirically uses standard here).
100-cert parity probe:
MAE 4.40 → 4.39 (flat in aggregate; structurally cleaner code)
RMSE 5.63 → 5.56
bias +0.16 → -0.17
within ±10: 96% (unchanged)
The meter_type seam replaces the e7_eligible_main_codes set on
PriceTable. Conceptually cleaner: tariff is a property of the meter,
not the heating system.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
64fce04169
commit
4c50c1b0fb
2 changed files with 124 additions and 44 deletions
|
|
@ -365,55 +365,97 @@ def _fuel_cost_gbp_per_kwh(
|
||||||
return prices.unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP
|
return prices.unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP
|
||||||
|
|
||||||
|
|
||||||
def _is_e7_eligible(main: Optional[MainHeatingDetail], prices: PriceTable) -> bool:
|
def _is_off_peak_meter(meter_type: object) -> bool:
|
||||||
"""Whether this dwelling's main heating code bills space heating at
|
"""The cert's `sap_energy_source.meter_type` reports the electricity
|
||||||
the off-peak rate under the supplied price table's rules. SAP spec
|
tariff. SAP10 enum (verified empirically on the 250k corpus —
|
||||||
restricts this to true storage heaters; cert calibration extends to
|
distribution: 75% type 2, 14% type 1, 11% type 3):
|
||||||
direct-electric (codes 191-196) where the cert assessor empirically
|
1 = Off-peak (Economy-7 / dual)
|
||||||
applies off-peak even though Table 12a says 90% high-rate."""
|
2 = Single (Standard)
|
||||||
if main is None:
|
3 = Off-peak (24h heating)
|
||||||
|
The transform.py docstring describes 1=Standard / 2=Off-peak but the
|
||||||
|
corpus distribution rules that out (75% on off-peak would not match
|
||||||
|
UK demographics). Per user guidance we trust whatever meter_type the
|
||||||
|
cert lodges, but the mapping is to-be-confirmed against the SAP10
|
||||||
|
schema definition."""
|
||||||
|
if meter_type is None:
|
||||||
return False
|
return False
|
||||||
code = main.sap_main_heating_code
|
if isinstance(meter_type, int):
|
||||||
return code is not None and code in prices.e7_eligible_main_codes
|
return meter_type != 2
|
||||||
|
if isinstance(meter_type, str):
|
||||||
|
stripped = meter_type.strip().lower()
|
||||||
|
if stripped in {"single", "standard", "2"}:
|
||||||
|
return False
|
||||||
|
return stripped not in {"", "unknown", "not specified"}
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_electric_main(main: Optional[MainHeatingDetail]) -> bool:
|
||||||
|
"""Main heating fuel is electricity (codes 29 or 10 in API enum;
|
||||||
|
Table 32 codes 30-40)."""
|
||||||
|
code = _main_fuel_code(main)
|
||||||
|
if code is None:
|
||||||
|
return False
|
||||||
|
# API codes that route to electricity
|
||||||
|
if code in {10, 25, 29}:
|
||||||
|
return True
|
||||||
|
# Table 32 electricity codes directly
|
||||||
|
if code in {30, 31, 32, 33, 34, 35, 36, 38, 39, 40}:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_electric_water(water_heating_fuel: Optional[int]) -> bool:
|
||||||
|
if water_heating_fuel is None:
|
||||||
|
return False
|
||||||
|
if water_heating_fuel in {10, 25, 29}:
|
||||||
|
return True
|
||||||
|
if water_heating_fuel in {30, 31, 32, 33, 34, 35, 36, 38, 39, 40}:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _space_heating_fuel_cost_gbp_per_kwh(
|
def _space_heating_fuel_cost_gbp_per_kwh(
|
||||||
main: Optional[MainHeatingDetail], prices: PriceTable
|
main: Optional[MainHeatingDetail],
|
||||||
|
meter_type: object,
|
||||||
|
prices: PriceTable,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Off-peak rate when the main heating is electric-storage (codes
|
"""Space heating bills at the main fuel's rate. When the dwelling is
|
||||||
401-409 or 421-425), else the standard main-fuel rate."""
|
on an off-peak tariff (meter_type != standard) AND the main fuel is
|
||||||
if _is_e7_eligible(main, prices):
|
electricity, bill at the off-peak rate instead. Trusts the cert's
|
||||||
|
meter_type rather than inferring tariff from heating code."""
|
||||||
|
if _is_electric_main(main) and _is_off_peak_meter(meter_type):
|
||||||
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
|
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
|
||||||
return _fuel_cost_gbp_per_kwh(main, prices)
|
return _fuel_cost_gbp_per_kwh(main, prices)
|
||||||
|
|
||||||
|
|
||||||
def _hot_water_fuel_cost_gbp_per_kwh(
|
def _hot_water_fuel_cost_gbp_per_kwh(
|
||||||
main: Optional[MainHeatingDetail],
|
|
||||||
water_heating_fuel: Optional[int],
|
water_heating_fuel: Optional[int],
|
||||||
|
main: Optional[MainHeatingDetail],
|
||||||
|
meter_type: object,
|
||||||
prices: PriceTable,
|
prices: PriceTable,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Hot water bills at the *water-heating* fuel's rate. Special case:
|
"""Hot water bills at the *water-heating* fuel's rate. When the
|
||||||
an E7-tariff dwelling (storage-heater main) running an electric
|
dwelling is on an off-peak tariff AND the water-heating fuel is
|
||||||
immersion HW cylinder bills HW at the 7h-low rate too, since these
|
electricity (immersion etc.), bill HW at the off-peak rate too —
|
||||||
households typically run the immersion on the off-peak timer.
|
the cert assessor treats the immersion as running on the timer."""
|
||||||
Falls back to the main fuel when the cert doesn't lodge a separate
|
is_off_peak = _is_off_peak_meter(meter_type)
|
||||||
water fuel."""
|
if is_off_peak and _is_electric_water(water_heating_fuel):
|
||||||
is_e7 = _is_e7_eligible(main, prices)
|
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
|
||||||
e7_low = prices.e7_low_rate_p_per_kwh
|
|
||||||
if is_e7 and (
|
|
||||||
water_heating_fuel is None
|
|
||||||
or prices.unit_price_p_per_kwh(water_heating_fuel) > e7_low
|
|
||||||
):
|
|
||||||
return e7_low * _PENCE_TO_GBP
|
|
||||||
if water_heating_fuel is not None:
|
if water_heating_fuel is not None:
|
||||||
return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
|
return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
|
||||||
return _fuel_cost_gbp_per_kwh(main, prices)
|
return _fuel_cost_gbp_per_kwh(main, prices)
|
||||||
|
|
||||||
|
|
||||||
def _other_fuel_cost_gbp_per_kwh(prices: PriceTable) -> float:
|
def _other_fuel_cost_gbp_per_kwh(
|
||||||
"""Pumps, fans, and lighting always bill at the standard-electricity
|
meter_type: object, prices: PriceTable
|
||||||
rate regardless of the main heating fuel — these end uses are
|
) -> float:
|
||||||
electric in every UK dwelling."""
|
"""Pumps, fans, and lighting are always electric. When the dwelling
|
||||||
|
is on an off-peak tariff, billing splits between off-peak and high
|
||||||
|
rates per Table 12a (~0.90 high-rate, 0.10 low-rate for "other
|
||||||
|
uses"). Empirically the cert software applies the standard rate
|
||||||
|
here regardless of meter type, so we keep `standard_electricity_p_per_kwh`
|
||||||
|
even for off-peak dwellings."""
|
||||||
|
_ = meter_type
|
||||||
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
|
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -582,11 +624,16 @@ def cert_to_inputs(
|
||||||
pumps_fans_kwh_per_yr=_DEFAULT_PUMPS_FANS_KWH_PER_YR,
|
pumps_fans_kwh_per_yr=_DEFAULT_PUMPS_FANS_KWH_PER_YR,
|
||||||
lighting_kwh_per_yr=lighting_kwh,
|
lighting_kwh_per_yr=lighting_kwh,
|
||||||
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, prices
|
main, epc.sap_energy_source.meter_type, prices
|
||||||
),
|
),
|
||||||
hot_water_fuel_cost_gbp_per_kwh=_hot_water_fuel_cost_gbp_per_kwh(
|
hot_water_fuel_cost_gbp_per_kwh=_hot_water_fuel_cost_gbp_per_kwh(
|
||||||
main, epc.sap_heating.water_heating_fuel, prices
|
epc.sap_heating.water_heating_fuel,
|
||||||
|
main,
|
||||||
|
epc.sap_energy_source.meter_type,
|
||||||
|
prices,
|
||||||
|
),
|
||||||
|
other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh(
|
||||||
|
epc.sap_energy_source.meter_type, prices
|
||||||
),
|
),
|
||||||
other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh(prices),
|
|
||||||
co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main),
|
co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -297,13 +297,12 @@ def test_window_frame_factor_uses_table_6c_by_frame_material() -> None:
|
||||||
assert ff_values == [0.70, 0.70, 0.83]
|
assert ff_values == [0.70, 0.70, 0.83]
|
||||||
|
|
||||||
|
|
||||||
def test_electric_storage_heater_space_heating_at_off_peak_rate() -> None:
|
def test_off_peak_meter_routes_electric_costs_to_low_rate() -> None:
|
||||||
# Arrange — RdSAP convention: when the main heating is electric-
|
# Arrange — RdSAP rule (per S-B15): we trust the cert's lodged
|
||||||
# storage (code 401-409) or direct-electric (191-196), space heating
|
# meter_type as the tariff source of truth. SAP10 code 2 = off-peak
|
||||||
# is charged at the 7h-low off-peak rate (Table 32 code 31, 5.5p/kWh)
|
# (Economy-7 dual rate). On an off-peak meter, electric space heating
|
||||||
# while hot water + lighting + pumps remain on standard electricity
|
# and electric hot water bill at the 7h-low rate (9.4p/kWh). Other
|
||||||
# (code 30, 13.19p/kWh). Critical fix for the 5/7 worst residuals on
|
# electric uses (lighting + pumps) stay on standard rate.
|
||||||
# storage-heated dwellings.
|
|
||||||
epc = make_minimal_sap10_epc(
|
epc = make_minimal_sap10_epc(
|
||||||
total_floor_area_m2=_TYPICAL_TFA_M2,
|
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||||
habitable_rooms_count=3,
|
habitable_rooms_count=3,
|
||||||
|
|
@ -331,18 +330,52 @@ def test_electric_storage_heater_space_heating_at_off_peak_rate() -> None:
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
epc.sap_energy_source.meter_type = 1 # off-peak (empirical SAP10 enum)
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
inputs = cert_to_inputs(epc)
|
inputs = cert_to_inputs(epc)
|
||||||
|
|
||||||
# Assert — RdSAP convention: when an E7 dwelling's HW runs on
|
# Assert
|
||||||
# electric immersion, the immersion is presumed to be on the
|
|
||||||
# off-peak timer, so HW bills at the 7h-low rate too.
|
|
||||||
assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.094
|
assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.094
|
||||||
assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.094
|
assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.094
|
||||||
assert inputs.other_fuel_cost_gbp_per_kwh == 0.1649
|
assert inputs.other_fuel_cost_gbp_per_kwh == 0.1649
|
||||||
|
|
||||||
|
|
||||||
|
def test_standard_meter_keeps_electric_costs_on_standard_rate() -> None:
|
||||||
|
# Arrange — same all-electric dwelling but meter_type=1 (Standard);
|
||||||
|
# space heating + HW should now bill at the standard rate, not E7.
|
||||||
|
epc = make_minimal_sap10_epc(
|
||||||
|
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||||
|
habitable_rooms_count=3,
|
||||||
|
region_code="1",
|
||||||
|
dwelling_type="Detached bungalow",
|
||||||
|
sap_building_parts=[
|
||||||
|
make_building_part(
|
||||||
|
floor_dimensions=[make_floor_dimension(total_floor_area_m2=_TYPICAL_TFA_M2, floor=0)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
sap_heating=make_sap_heating(
|
||||||
|
water_heating_fuel=29,
|
||||||
|
main_heating_details=[
|
||||||
|
MainHeatingDetail(
|
||||||
|
has_fghrs=False, main_fuel_type=29, heat_emitter_type=1,
|
||||||
|
emitter_temperature=1, main_heating_control=2106,
|
||||||
|
main_heating_category=7, sap_main_heating_code=402,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
epc.sap_energy_source.meter_type = 2 # Standard (empirical SAP10 enum)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
inputs = cert_to_inputs(epc)
|
||||||
|
|
||||||
|
# Assert — no off-peak routing; all-electric dwelling pays standard rates.
|
||||||
|
assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.1649
|
||||||
|
assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.1649
|
||||||
|
assert inputs.other_fuel_cost_gbp_per_kwh == 0.1649
|
||||||
|
|
||||||
|
|
||||||
def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission() -> None:
|
def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission() -> None:
|
||||||
# Arrange — A "Mid-floor flat" has party floor (downstairs flat) and
|
# Arrange — A "Mid-floor flat" has party floor (downstairs flat) and
|
||||||
# party ceiling (upstairs flat). The mapper must wire DwellingExposure
|
# party ceiling (upstairs flat). The mapper must wire DwellingExposure
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue