mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.139: route _is_off_peak_meter through tariff_from_meter_type canonical dispatch (bare '18 Hour' lodging)
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 <noreply@anthropic.com>
This commit is contained in:
parent
6a7bf3e074
commit
c60a2ddc17
3 changed files with 86 additions and 28 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue