From 737e5d6bf5c52a3419588c49c153cb343c538c28 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 15:43:15 +0000 Subject: [PATCH] slice S-B11: e7_eligible_main_codes on PriceTable; cert calibration adds 191-196 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 52 +++++++++++-------- .../sap/tables/table_12_cert_calibration.py | 10 ++++ 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 21443d1d..cb8032bd 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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 diff --git a/packages/domain/src/domain/sap/tables/table_12_cert_calibration.py b/packages/domain/src/domain/sap/tables/table_12_cert_calibration.py index 7cbe1f18..576307ae 100644 --- a/packages/domain/src/domain/sap/tables/table_12_cert_calibration.py +++ b/packages/domain/src/domain/sap/tables/table_12_cert_calibration.py @@ -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, )