From c60a2ddc17ace38b42405428fa5b6fcc60ccee0f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 18:39:43 +0000 Subject: [PATCH] Slice S0380.139: route _is_off_peak_meter through tariff_from_meter_type canonical dispatch (bare '18 Hour' lodging) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-slice `_is_off_peak_meter` carried its own string-dispatch that only recognised the RdSAP 10 long form `"off-peak 18 hour"`. The bare `"18 Hour"` lodging (Elmhurst Summary §14.2 surface form, lodged by 41/41 corpus variants) fell into the catch-all `return False` branch. That mis-classified every 18-hour cert as non-off-peak for the secondary / PV cost paths and billed electric secondary heating at standard 13.19 p/kWh (Table 32 code 30) instead of the 18-hour low rate 7.41 p/kWh (Table 32 code 40). The fix routes `_is_off_peak_meter` through `tariff_from_meter_type` so every lodging form already recognised there (int 1/4/5, `"18 Hour"`, `"off-peak 18 hour"`, `"Dual"`, `"Dual (24 hour)"`, numeric strings) is consistently classified. Single (code 2) stays standard; Unknown (code 3) retains the heuristic "electric end-uses on Unknown meters typically come from E7-eligible dwellings whose tariff the assessor couldn't pin down — apply off-peak". Per [[feedback-zero-error-strict]] the now-dead `_RDSAP_DEFINITELY_OFF_PEAK` frozenset is deleted (canonical dispatch covers the same codes). Spec citation per [[feedback-spec-citation-in-commits]]: > RdSAP 10 §17 page 85 row 10-2 (Electricity meter): "Dual / single / > 10-hour / 18-hour / 24-hour / unknown" > RdSAP 10 §12 page 62: "if the meter is dual 18-hour/24-hour it is > 18-hour/24-hour tariff" Corpus impact (6 storage-heater / underfloor variants on forced secondary): | variant | SAP code | old ΔSAP | new ΔSAP | |---|---:|---:|---:| | electric 3 | 401 | -0.10 | +2.42 | | electric 5 | 402 | -2.48 | -0.06 | | electric 6 | 404 | -1.14 | +1.19 | | electric 7 | 408 | -1.08 | +1.14 | | electric 8 | 409 | -2.54 | -0.41 | | electric 9 | 421 | -2.76 | -0.24 | Total absolute SAP residual across the cluster: 10.10 → 5.46. The 3 sign-flipped variants (electric 3/6/7) surface a separate cascade bug: `_secondary_heating_fraction_for_category` defaults to 0.10 when the mapper leaves `main_heating_category=None` for electric storage, but the worksheet for codes 401/402 uses 0.15 = Table 11 Cat 7. Mapper-side fix queued. Tests: - new AAA test `test_is_off_peak_meter_recognises_bare_18_hour_lodging` covers 7 lodging forms (bare, lowercase, long-form, Single, standard, Unknown+electric, Unknown+non-electric) - 6 corpus pins re-tightened (electric 3/5/6/7/8/9) Extended handover suite: 882 pass (was 881; +1 new test), 0 fail. Pyright net-zero on touched files (43 → 43 errors, all pre-existing). Per [[reference-unmapped-sap-code]] strict-dispatch routing. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 30 ++++++++--- .../sap10_calculator/rdsap/cert_to_inputs.py | 50 +++++++++++-------- .../rdsap/tests/test_cert_to_inputs.py | 34 +++++++++++++ 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index fc72783c..437dba5f 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -178,16 +178,34 @@ class _CorpusExpectation: # per tariff. Per [[feedback-zero-error-strict]] # PriceTable.e7_low_rate_p_per_kwh was deleted (dead code; no fallback # can silently re-introduce 5.50). +# +# Slice S0380.139 routed `_is_off_peak_meter` through the canonical +# `tariff_from_meter_type` lookup. Pre-slice `_is_off_peak_meter` had +# its own string dispatch that only recognised the RdSAP long-form +# "off-peak 18 hour" — the bare "18 Hour" lodging (Elmhurst Summary +# §14.2 surface form, 41/41 corpus variants) fell into the catch-all +# `return False` branch, so the secondary cost path billed electric +# secondary heating at 13.19 p/kWh (standard) instead of the 18-hour +# low rate 7.41 p/kWh (Table 32 code 40). Six storage-heater / +# underfloor variants (electric 3/5/6/7/8/9) re-pinned: SAP residuals +# from -0.10..-2.76 to -0.06..+2.42 (mostly closer to zero; electric +# 3/6/7 sign-flipped, which surfaces a separate cascade vs worksheet +# secondary-kWh mismatch — `_secondary_heating_fraction_for_category` +# defaults to 0.10 when the mapper leaves `main_heating_category=None` +# for electric storage, but the worksheet for codes 401/402 uses 0.15 +# = Table 11 Cat 7). Total absolute SAP residual across the cluster +# went from 10.10 to 5.46. _RDSAP_DEFINITELY_OFF_PEAK frozenset was +# deleted (dead code; canonical dispatch covers it). _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017), _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.2021, expected_cost_resid_gbp=+4.6562, expected_co2_resid_kg=+14.3441, expected_pe_resid_kwh=+164.9052), _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-1.2714, expected_cost_resid_gbp=+29.2944, expected_co2_resid_kg=+94.4364, expected_pe_resid_kwh=+970.7570), - _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.1013, expected_cost_resid_gbp=+2.3332, expected_co2_resid_kg=-112.3439, expected_pe_resid_kwh=-1059.2875), - _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-2.4807, expected_cost_resid_gbp=+57.1568, expected_co2_resid_kg=-5.3096, expected_pe_resid_kwh=-95.6333), - _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-1.1367, expected_cost_resid_gbp=+26.1898, expected_co2_resid_kg=-50.0685, expected_pe_resid_kwh=-494.3960), - _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=-1.0835, expected_cost_resid_gbp=+24.9648, expected_co2_resid_kg=-31.5507, expected_pe_resid_kwh=-427.5932), - _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-2.5400, expected_cost_resid_gbp=+58.5256, expected_co2_resid_kg=+18.2051, expected_pe_resid_kwh=+199.7233), - _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-2.7646, expected_cost_resid_gbp=+63.7004, expected_co2_resid_kg=+11.1781, expected_pe_resid_kwh=+154.0936), + _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.4189, expected_cost_resid_gbp=-55.7339, expected_co2_resid_kg=-112.3439, expected_pe_resid_kwh=-1059.2875), + _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.0579, expected_cost_resid_gbp=+1.3337, expected_co2_resid_kg=-5.3096, expected_pe_resid_kwh=-95.6333), + _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.1888, expected_cost_resid_gbp=-27.3926, expected_co2_resid_kg=-50.0685, expected_pe_resid_kwh=-494.3960), + _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.1449, expected_cost_resid_gbp=-26.3805, expected_co2_resid_kg=-31.5507, expected_pe_resid_kwh=-427.5932), + _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.4086, expected_cost_resid_gbp=+9.4133, expected_co2_resid_kg=+18.2051, expected_pe_resid_kwh=+199.7233), + _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.2444, expected_cost_resid_gbp=+5.6333, expected_co2_resid_kg=+11.1781, expected_pe_resid_kwh=+154.0936), _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023), _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919), _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index c52801df..0b58f8bc 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1135,44 +1135,50 @@ def _fuel_cost_gbp_per_kwh( # # Different from the SAP-Schema enum which is 1=standard, 2=off-peak. # Our corpus is RdSAP so we use RdSAP codes. -_RDSAP_DEFINITELY_OFF_PEAK: Final[frozenset[int]] = frozenset({1, 4, 5}) _RDSAP_UNKNOWN_METER: Final[frozenset[int]] = frozenset({3}) def _is_off_peak_meter(meter_type: object, *, fuel_is_electric: bool) -> bool: """Whether the dwelling bills the given end-use (fuel_is_electric) at - the off-peak rate. RdSAP codes 1/4/5 are explicit off-peak. Code 3 - (Unknown) defers to the fuel: electric end-uses on Unknown meters - typically come from E7-eligible dwellings whose tariff the assessor - couldn't pin down, so we apply off-peak. Non-electric end-uses on - Unknown meters are unaffected. Per user guidance + Elmhurst test on - a single gas-heated property, code 2 (Single) is always standard.""" + the off-peak rate. Routes through `tariff_from_meter_type` so every + lodging form recognised there (int 1/4/5, bare "18 Hour", long + "off-peak 18 hour", "Dual", "Dual (24 hour)", numeric strings) is + consistently classified as off-peak. Code 2 (Single) is always + standard. Code 3 (Unknown) routes to STANDARD per the spec-faithful + table_12a default, but `_is_off_peak_meter` applies the heuristic + "electric end-uses on Unknown meters typically come from E7- + eligible dwellings whose tariff the assessor couldn't pin down" — + so Unknown + electric returns True, Unknown + non-electric stays + False. Pre-S0380.139 this helper had its own string-dispatch that + only recognised "off-peak 18 hour" (the RdSAP long form), so the + bare "18 Hour" lodging (Elmhurst Summary §14.2's surface form per + [[reference-elmhurst-only-test-pattern]]) mis-classified to False + and billed electric secondary heating at standard 13.19 p/kWh + instead of the 18-hour low rate 7.41 p/kWh across the 41-variant + corpus.""" if meter_type is None: return False - code: Optional[int] + try: + tariff = tariff_from_meter_type(meter_type) + except UnmappedSapCode: + return False + if tariff is not Tariff.STANDARD: + return True + # STANDARD branch — distinguish Single (always standard) from Unknown + # (off-peak heuristic for electric end-uses only). Per the + # `_METER_INT_TO_TARIFF` mapping both Single (code 2) and Unknown + # (code 3) land here; we need the code itself to decide. if isinstance(meter_type, int): code = meter_type elif isinstance(meter_type, str): s = meter_type.strip().lower() - if s in {"single", "standard", "2"}: - return False - if s in {"dual", "1"}: - code = 1 - elif s in {"unknown", "3", ""}: + if s in {"unknown", "3", ""}: code = 3 - elif s in {"dual (24 hour)", "4"}: - code = 4 - elif s in {"off-peak 18 hour", "5"}: - code = 5 else: return False else: return False - if code in _RDSAP_DEFINITELY_OFF_PEAK: - return True - if code in _RDSAP_UNKNOWN_METER and fuel_is_electric: - return True - return False + return code in _RDSAP_UNKNOWN_METER and fuel_is_electric def _is_electric_main(main: Optional[MainHeatingDetail]) -> bool: diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index dda4a481..0f58d2aa 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -43,6 +43,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] + _is_off_peak_meter, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] @@ -1396,6 +1397,39 @@ def test_tariff_high_low_rates_full_dispatch_coverage() -> None: assert excinfo.value.field == "tariff_high_low_rates" +def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None: + # Arrange — RdSAP 10 §17 page 85 row 10-2 lodges 18-hour meter + # as the bare "18-hour" or "18 Hour" form (Elmhurst Summary §14.2 + # carries the latter). `tariff_from_meter_type("18 Hour")` already + # resolves it to `Tariff.EIGHTEEN_HOUR` per [[reference-unmapped- + # sap-code]]'s strict dispatch (slice S0380.125). Pre-S0380.139 + # `_is_off_peak_meter` had its own string-dispatch that only + # recognised the long form "off-peak 18 hour" (RdSAP code 5 + # spelt-out); the bare "18 Hour" fell into the catch-all `return + # False` branch, mis-classifying every 18-hour cert as non-off- + # peak for the secondary / PV cost paths and billing electric + # secondary heating at 13.19 p/kWh (standard) instead of the + # 18-hour low rate 7.41 p/kWh. All 41 corpus variants lodge + # `meter_type='18 Hour'`, so the inconsistency surfaced as a + # secondary-cost residual cluster on the 6 storage-heater / + # underfloor variants (electric 3/5/6/7/8/9) that carry a forced + # secondary. The canonical fix routes through + # `tariff_from_meter_type`. + + # Act / Assert — every off-peak tariff alias resolves to True; + # standard and unknown-non-electric paths stay False. + assert _is_off_peak_meter("18 Hour", fuel_is_electric=True) is True + assert _is_off_peak_meter("18 hour", fuel_is_electric=True) is True + assert _is_off_peak_meter("off-peak 18 hour", fuel_is_electric=True) is True + # explicit Single tariff stays standard regardless of fuel + assert _is_off_peak_meter("Single", fuel_is_electric=True) is False + assert _is_off_peak_meter("standard", fuel_is_electric=True) is False + # Unknown meter on electric end-use stays heuristic-off-peak + assert _is_off_peak_meter("Unknown", fuel_is_electric=True) is True + # Unknown meter on non-electric end-use stays standard + assert _is_off_peak_meter("Unknown", fuel_is_electric=False) is False + + def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> None: # Arrange — an electric storage heater (SAP code 401) on an 18-hour # tariff. `_table_12a_system_for_main` returns None for storage