slice S-B11: e7_eligible_main_codes on PriceTable; cert calibration adds 191-196

Hand-tracing cert 0800-1364 (Detached bungalow, code 191/direct-electric,
actual SAP 71, predicted 37) showed the cert assessor applies off-peak
rates to direct-electric main heating despite SAP 10.2 Table 12a
specifying 90% high-rate. Adds e7_eligible_main_codes to PriceTable so
each price source carries its own rule:
  - SAP_10_2_SPEC_PRICES: {401-409, 421-425} (storage only, per Table 12a)
  - CERT_CALIBRATION:     {191-196, 401-409, 421-425} (empirically what
                            the cert software does)

100-cert parity probe:
  MAE 4.99 → 4.66 (recovered to pre-S-B9 best state)
  bias -1.03 → -0.70
  within ±1:  23% → 24%
  within ±3:  47% → 48%
  within ±10: 93% → 94%

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 15:43:15 +00:00
parent 92727568a3
commit 737e5d6bf5
2 changed files with 39 additions and 23 deletions

View file

@ -133,25 +133,41 @@ class PriceTable:
"""Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and
the empirical cert-calibration prices used to parity-test against
the corpus's lodged ratings. The cert assessor software diverges
from spec on unit prices (see slice S-B9 commit); this struct lets
the cert mapper switch between modes without touching the engine.
from spec on unit prices AND on which heating codes pick up the
off-peak rate (see slice S-B9 commit + S-B11 hand-trace).
`unit_price_p_per_kwh` accepts either an API fuel code or a Table 12
code; implementations translate before lookup. `e7_low_rate_p_per_kwh`
is the off-peak rate used for true storage-heater space heating, and
is the off-peak rate used for E7-eligible space heating, and
`standard_electricity_p_per_kwh` is the rate applied to lighting +
pumps + fans regardless of main fuel.
pumps + fans regardless of main fuel. `e7_eligible_main_codes` lists
the SAP Table 4a main-heating codes that bill space heating at
`e7_low_rate_p_per_kwh` narrower under the spec (storage heaters
only per Table 12a) than under cert calibration (the cert assessor
appears to apply off-peak to direct-electric too).
"""
unit_price_p_per_kwh: Callable[[Optional[int]], float]
e7_low_rate_p_per_kwh: float
standard_electricity_p_per_kwh: float
e7_eligible_main_codes: frozenset[int]
# SAP 10.2/10.3 spec-correct: per Table 12a, only true storage heaters
# (401-409) and high-heat-retention storage (421-425) bill space heating
# at the low rate. Direct-acting electric (191-196), heat pumps, and
# underfloor heating bill 70-100% at the high rate, so they're not in
# the off-peak set here.
_SPEC_E7_ELIGIBLE_MAIN_CODES: Final[frozenset[int]] = frozenset(
list(range(401, 410)) + list(range(421, 426))
)
SAP_10_2_SPEC_PRICES: Final[PriceTable] = PriceTable(
unit_price_p_per_kwh=unit_price_p_per_kwh,
e7_low_rate_p_per_kwh=9.40,
standard_electricity_p_per_kwh=16.49,
e7_eligible_main_codes=_SPEC_E7_ELIGIBLE_MAIN_CODES,
)
@ -167,18 +183,6 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = {
}
# SAP 10.2 Table 12a "high-rate fractions" — only true storage-type
# electric heating systems bill space heating at the off-peak rate.
# Storage heaters on 7h tariff have a 0% high-rate fraction (genuinely
# all off-peak); high-heat-retention storage heaters likewise. Direct-
# acting electric heating (codes 191-196), heat pumps, and underfloor
# heating run 70-100% at the high rate — they were incorrectly grouped
# into this set in slice S-B4. Hot water on these dwellings still
# inherits the off-peak rate if the dwelling carries E7 (see
# _hot_water_fuel_cost_gbp_per_kwh).
_E7_SPACE_HEATING_CODES: Final[frozenset[int]] = frozenset(
list(range(401, 410)) + list(range(421, 426))
)
def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure:
@ -355,14 +359,16 @@ def _fuel_cost_gbp_per_kwh(
return prices.unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP
def _is_electric_storage_or_direct(main: Optional[MainHeatingDetail]) -> bool:
"""RdSAP convention: electric storage heaters + direct-electric main
systems bill space heating at the off-peak rate while hot water +
lighting + pumps stay on the on-peak/standard rate."""
def _is_e7_eligible(main: Optional[MainHeatingDetail], prices: PriceTable) -> bool:
"""Whether this dwelling's main heating code bills space heating at
the off-peak rate under the supplied price table's rules. SAP spec
restricts this to true storage heaters; cert calibration extends to
direct-electric (codes 191-196) where the cert assessor empirically
applies off-peak even though Table 12a says 90% high-rate."""
if main is None:
return False
code = main.sap_main_heating_code
return code is not None and code in _E7_SPACE_HEATING_CODES
return code is not None and code in prices.e7_eligible_main_codes
def _space_heating_fuel_cost_gbp_per_kwh(
@ -370,7 +376,7 @@ def _space_heating_fuel_cost_gbp_per_kwh(
) -> float:
"""Off-peak rate when the main heating is electric-storage (codes
401-409 or 421-425), else the standard main-fuel rate."""
if _is_electric_storage_or_direct(main):
if _is_e7_eligible(main, prices):
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
return _fuel_cost_gbp_per_kwh(main, prices)
@ -386,7 +392,7 @@ def _hot_water_fuel_cost_gbp_per_kwh(
households typically run the immersion on the off-peak timer.
Falls back to the main fuel when the cert doesn't lodge a separate
water fuel."""
is_e7 = _is_electric_storage_or_direct(main)
is_e7 = _is_e7_eligible(main, prices)
e7_low = prices.e7_low_rate_p_per_kwh
if is_e7 and (
water_heating_fuel is None

View file

@ -99,10 +99,20 @@ def _build_cert_calibration_table():
spec table_12. We expose a factory the caller uses to build the
`PriceTable` value at validation-script init time."""
from domain.sap.rdsap.cert_to_inputs import PriceTable
# Cert-calibration empirically extends the E7 off-peak set to
# direct-electric codes (191-196) — the cert assessor applies the
# off-peak rate to these even though SAP 10.2 Table 12a says 90%
# high-rate. Confirmed by hand-tracing cert 0800-1364 (Detached
# bungalow, code 191, actual SAP 71, predicted 37 before this fix).
cert_calibration_e7_codes = frozenset(
list(range(191, 197)) + list(range(401, 410)) + list(range(421, 426))
)
return PriceTable(
unit_price_p_per_kwh=unit_price_p_per_kwh,
e7_low_rate_p_per_kwh=E7_LOW_RATE_P_PER_KWH,
standard_electricity_p_per_kwh=STANDARD_ELECTRICITY_P_PER_KWH,
e7_eligible_main_codes=cert_calibration_e7_codes,
)