diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 71b5ca0e..2bdb155e 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -256,6 +256,32 @@ class _CorpusExpectation: # not apply). No regressions on other variants — only electric 2 has # the (Cat 4 HP + WHC=903 + cylinder) combination in the corpus. # +# Slice S0380.159 promoted the Table 4a Cat 7 (Electric storage +# heaters) responsiveness dispatch from sap_code-only to +# (sap_code, tariff)-aware. Spec text: Table 4a p.166 lists code 402 +# "Slimline storage heaters" with R=0.2 under the Off-peak section +# AND R=0.4 under the 24-hour heating tariff section. Per SAP 10.2 +# §12.4.3 (PDF p.36) the 18-hour tariff has electricity at low rate +# for 18h/day with ≤6h interruption (max 2h windows) — operationally +# equivalent to 24-hour for storage-heater charging. Pre-slice the +# cascade used R=0.20 unconditionally for code 402, producing T_living +# (87)[Jan]=20.12 and (93)[Jan]=19.10 (cascade +0.49 K vs worksheet +# (93)[Jan]=18.6063). Per-line walk + back-solve from worksheet +# T_living=19.6519 confirmed R=0.4 (Tsc = 0.6×19 + 0.4×(4.3+0.9933× +# 705.4/210.23) = 14.4528 → u_sum = 0.5×6.547×113/274.32 = 1.3481 → +# T_living = 21 − 1.3481 = 19.6519 EXACT). New +# `_CONTINUOUS_CHARGING_TARIFFS = {EIGHTEEN_HOUR, TWENTY_FOUR_HOUR}` + +# `_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE` (codes 402/403/405/ +# 406) consulted at the top of `_responsiveness` before the off-peak +# default lookup. Tariff threaded through both call sites of MIT +# cascade (rating + demand paths). Closures electric 5: ΔSAP −1.1759 +# → +0.1081 (91% reduction), Δcost +£27.09 → −£2.49, ΔCO2 +62.72 → +# +7.30 kg, ΔPE +438.03 → +0.07 kWh (PE essentially EXACT). Electric +# 5 now joins the same residual-shape cluster as electric 3/6/7/8/9 +# (+0.09..+0.12 SAP, −£2..−£3 cost, +£7 CO2). No regressions on the +# other 24 variants — only code 402 (electric 5) has a tariff +# override that applies in the corpus. +# # Slice S0380.158 wired the SAP 10.2 Table 4f (PDF p.174) row "Warm # air heating system fans" = SFP × 0.4 × V (footnote e default SFP = # 1.5 W/(l/s) when no PCDB warm-air-unit record). Pre-slice the @@ -283,7 +309,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6605), _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1087, expected_cost_resid_gbp=+2.5037, expected_co2_resid_kg=+16.5405, expected_pe_resid_kwh=+97.6875), _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+0.1215, expected_cost_resid_gbp=-2.8003, expected_co2_resid_kg=+6.7227, expected_pe_resid_kwh=-5.9859), - _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-1.1759, expected_cost_resid_gbp=+27.0929, expected_co2_resid_kg=+62.7232, expected_pe_resid_kwh=+438.0333), + _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+0.1081, expected_cost_resid_gbp=-2.4918, expected_co2_resid_kg=+7.2978, expected_pe_resid_kwh=+0.0658), _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+0.1081, expected_cost_resid_gbp=-2.4918, expected_co2_resid_kg=+7.3225, expected_pe_resid_kwh=+0.1603), _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+0.1017, expected_cost_resid_gbp=-2.3444, expected_co2_resid_kg=+7.6424, expected_pe_resid_kwh=+3.0976), _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+0.0941, expected_cost_resid_gbp=-2.1679, expected_co2_resid_kg=+7.9230, expected_pe_resid_kwh=+6.5824), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index bd3e0202..0a83526e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1306,7 +1306,10 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int: raise UnmappedSapCode("main_heating_control", code) -def _responsiveness(main: Optional[MainHeatingDetail]) -> float: +def _responsiveness( + main: Optional[MainHeatingDetail], + tariff: Optional[Tariff] = None, +) -> float: """SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271: "R = responsiveness of main heating system (Table 4a or @@ -1327,6 +1330,16 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float: `heat_emitter_type`. Used as the fallback when the SAP code isn't in the Table 4a dispatch dict. + For electric storage SAP codes (402, 403, 405, 406) Table 4a + Cat 7 splits R between the off-peak tariff (7-hour / 10-hour) + section and the 24-hour heating tariff section. Per SAP 10.2 + §12.4.3 (PDF p.36) the 18-hour tariff has "electricity at the + low-rate price ... available for 18 hours per day" with at most + 6h of interruption / 2h max each — operationally equivalent to + 24-hour for storage-heater charging. The cascade therefore routes + EIGHTEEN_HOUR + TWENTY_FOUR_HOUR through the 24-hour Table 4a + sub-rows when an override is registered for the lodged SAP code. + Cert-side heat_emitter_type enum (per `_ELMHURST_HEAT_EMITTER_TO_SAP10` at datatypes/epc/domain/mapper.py:3646): 1 = Radiators → R = 1.0 @@ -1348,8 +1361,16 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float: return 1.0 # Table 4a — per-heating-system R (overrides emitter lookup). sap_code = main.sap_main_heating_code - if sap_code is not None and sap_code in _RESPONSIVENESS_BY_SAP_CODE: - return _RESPONSIVENESS_BY_SAP_CODE[sap_code] + if sap_code is not None: + # 24-hour / 18-hour tariff override for electric storage heater + # rows that split between the off-peak and 24-hour sub-tables. + if ( + tariff in _CONTINUOUS_CHARGING_TARIFFS + and sap_code in _RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE + ): + return _RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE[sap_code] + if sap_code in _RESPONSIVENESS_BY_SAP_CODE: + return _RESPONSIVENESS_BY_SAP_CODE[sap_code] # Table 4d — fallback per emitter type. emitter = main.heat_emitter_type if not emitter: @@ -1359,6 +1380,30 @@ def _responsiveness(main: Optional[MainHeatingDetail]) -> float: raise UnmappedSapCode("heat_emitter_type", emitter) +# SAP 10.2 §12.4.3 (PDF p.36) — tariffs with near-continuous low-rate +# availability for storage heaters. The 18-hour tariff allows at most +# 6h of interruption split into ≤2h windows, so the storage heaters +# charge essentially continuously — functionally the same as the +# explicit 24-hour heating tariff for the purposes of selecting the +# Table 4a R sub-row. +_CONTINUOUS_CHARGING_TARIFFS: Final[frozenset[Tariff]] = frozenset({ + Tariff.EIGHTEEN_HOUR, + Tariff.TWENTY_FOUR_HOUR, +}) + + +# SAP 10.2 Table 4a (PDF p.166) Cat 7 "Electric storage heaters" — +# 24-hour heating tariff sub-table overrides for the codes whose R +# differs from the off-peak default (only the differing rows; 404, +# 407, 409 keep the same R in both sub-tables). +_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE: Final[dict[int, float]] = { + 402: 0.40, # Slimline storage (off-peak 0.20 → 24-hr 0.40) + 403: 0.40, # Convector storage (off-peak 0.20 → 24-hr 0.40) + 405: 0.60, # Slimline + Celect (off-peak 0.40 → 24-hr 0.60) + 406: 0.60, # Convector + Celect (off-peak 0.40 → 24-hr 0.60) +} + + # SAP 10.2 Table 4a (PDF p.163-170) — per-heating-system responsiveness R. # These rows override the emitter-based Table 4d lookup because the spec # explicitly lists R against the heating system (the system's intrinsic @@ -2997,6 +3042,7 @@ def mean_internal_temperature_section_from_cert( ) main = _first_main_heating(epc) climate = _climate_source(postcode_climate) + tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type) return mean_internal_temperature_monthly( monthly_external_temp_c=tuple( external_temperature_c(climate, m) for m in range(1, 13) @@ -3006,7 +3052,7 @@ def mean_internal_temperature_section_from_cert( thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, total_floor_area_m2=dim.total_floor_area_m2, control_type=_control_type(main), - responsiveness=_responsiveness(main), + responsiveness=_responsiveness(main, tariff=tariff), living_area_fraction=_living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ), @@ -5382,7 +5428,9 @@ def cert_to_inputs( # = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0 # for the Elmhurst corpus (cert-side mapping is a future slice). control_type_value = _control_type(main) - responsiveness_value = _responsiveness(main) + responsiveness_value = _responsiveness( + main, tariff=tariff_from_meter_type(epc.sap_energy_source.meter_type), + ) living_area_fraction_value = _living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ) 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 656044d3..7c353d98 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -4408,6 +4408,110 @@ def test_sap_table_3_primary_loss_skipped_for_whc_903_electric_immersion_with_he ) +def test_sap_table_4a_responsiveness_for_slimline_storage_18_hour_tariff() -> None: + """SAP 10.2 Table 4a (PDF p.166) — code 402 "Slimline storage heaters" + R value splits by tariff: + + Off-peak tariff: + Slimline storage heaters ... R = 0.2 402 + 24-hour heating tariff: + Slimline storage heaters ... R = 0.4 402 + + Per SAP 10.2 §12.4.3 (PDF p.36) the 18-hour tariff "is only for use + with electric CPSUs ... electricity at the low-rate price is + available for 18 hours per day, with interruptions totalling 6 + hours per day, with the proviso that no interruption will exceed + 2 hours". With 18h of low-rate availability the storage heaters + are charged near-continuously — operationally equivalent to the + 24-hour tariff for responsiveness purposes. Elmhurst's lodging + behaviour for property 001431 electric 5 (sap_main_heating_code= + 402 + Tariff="18 Hour" + cylinder + WHC=903) computes the §7 MIT + cascade with R=0.4 (back-solved from worksheet (87)[Jan]=19.6519 + via Table 9b: T_sc = 0.6×19 + 0.4×(4.3 + 0.9933×705.4/210.23) = + 14.4528 → u_sum=1.3481 → T_living = 21−1.3481 = 19.6519 EXACT). + + Pre-slice `_responsiveness` ignored the tariff and returned R=0.2 + for code 402 unconditionally — yielding T_living=20.1213, T_other= + 18.0903, (92)=18.6996, (93)=19.0996 (cascade +0.49 K vs worksheet + 18.6063) → SH demand +366 kWh/yr over the worksheet, ΔSAP −1.18. + + The Table 4a 24-hour-tariff override applies for any tariff with + near-continuous low-rate availability: EIGHTEEN_HOUR + TWENTY_FOUR_ + HOUR. 7-hour / 10-hour off-peak keep the off-peak defaults. + """ + # Arrange — electric 5 corpus variant: code 402 + 18-hour tariff + + # 110 L cylinder + WHC=903 electric immersion + cylinder thermostat. + import re + import subprocess + from pathlib import Path + + from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor + from datatypes.epc.domain.mapper import EpcPropertyDataMapper + + corpus_electric_5 = ( + Path(__file__).parents[4] + / "sap worksheets/heating systems examples/electric 5" + ) + summary_pdf = next(corpus_electric_5.glob("Summary_*.pdf")) + info = subprocess.run( + ["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True, + ).stdout + pc_match = re.search(r"Pages:\s+(\d+)", info) + assert pc_match is not None + pc = int(pc_match.group(1)) + pages: list[str] = [] + for i in range(1, pc + 1): + layout = subprocess.run( + ["pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(summary_pdf), "-"], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(notes) + + main = epc.sap_heating.main_heating_details[0] + assert main.sap_main_heating_code == 402 + assert epc.sap_energy_source.meter_type == "18 Hour" + + # Act — drive cert_to_inputs and read the responsiveness threaded + # into the MIT cascade via `inputs.adjusted_mean_internal_temp_monthly`. + # The R value isn't exposed directly on `CalculatorInputs`; instead + # we check the downstream effect: the Jan adjusted MIT must match + # the worksheet's (93)[Jan] = 18.6063 (which only happens when the + # Tsc formula uses R=0.4, not R=0.2). + inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + # Assert — adjusted MIT (93) Jan must match the worksheet at 1e-3. + # Pre-slice cascade uses R=0.2 → (93)[Jan] = 19.0996 (off by + # +0.4933 K). + # Tolerance: ±0.01 K absorbs the small upstream gains divergence + # between the cascade's (84) and the worksheet's (~7 W diff on + # internal-gains calc precision); the R-flip itself shifts the + # cascade by +0.49 K — closing the residual from 0.49 → ~0.003. + expected_adjusted_mit_jan = 18.6063 # worksheet (93) Jan, R=0.4 + got = inputs.mean_internal_temp_monthly_c[0] + assert abs(got - expected_adjusted_mit_jan) <= 1e-2, ( + f"electric 5 (Table 4a code 402 Slimline storage + 18-hour " + f"tariff) cascade adjusted MIT (93)[Jan] = {got:.4f}; want " + f"{expected_adjusted_mit_jan:.4f} per worksheet. Pre-slice the " + f"`_responsiveness` dispatch keyed on sap_code only and " + f"returned R=0.2 for code 402 regardless of tariff; per SAP " + f"10.2 Table 4a (PDF p.166) the 24-hour-heating-tariff section " + f"lists code 402 with R=0.4, and per §12.4.3 the 18-hour " + f"tariff is operationally equivalent (18h low-rate availability " + f"with ≤6h interruption / 2h max each = near-continuous " + f"charging like 24-hour)." + ) + + def test_sap_table_4f_warm_air_heating_system_fans_kwh_for_cat5_heat_pump() -> None: """SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans" + footnote e) — verbatim: