diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 4a0cd8cd..a0313cb5 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -221,11 +221,11 @@ class _CorpusExpectation: _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.0573, expected_cost_resid_gbp=+1.3188, expected_co2_resid_kg=+8.0120, expected_pe_resid_kwh=+94.4789), - _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.4737, expected_cost_resid_gbp=-10.9153, expected_co2_resid_kg=+10.9544, expected_pe_resid_kwh=+100.9401), - _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.5452, expected_cost_resid_gbp=-58.6455, expected_co2_resid_kg=-108.8821, expected_pe_resid_kwh=-1093.1815), - _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+0.0747, expected_cost_resid_gbp=-1.7232, expected_co2_resid_kg=-2.4846, expected_pe_resid_kwh=-133.3636), - _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.3278, expected_cost_resid_gbp=-30.5954, expected_co2_resid_kg=-56.1047, expected_pe_resid_kwh=-562.5298), - _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.2903, expected_cost_resid_gbp=-29.7300, expected_co2_resid_kg=-53.5730, expected_pe_resid_kwh=-549.3654), + _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1842, expected_cost_resid_gbp=+4.2439, expected_co2_resid_kg=+38.7768, expected_pe_resid_kwh=+392.8379), + _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.0874, expected_cost_resid_gbp=+2.0136, expected_co2_resid_kg=+4.2088, expected_pe_resid_kwh=+81.9107), + _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-1.4255, expected_cost_resid_gbp=+32.8452, expected_co2_resid_kg=+61.9651, expected_pe_resid_kwh=+535.1955), + _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-0.1698, expected_cost_resid_gbp=+3.9118, expected_co2_resid_kg=+7.8972, expected_pe_resid_kwh=+103.4643), + _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=-0.2044, expected_cost_resid_gbp=+4.7098, expected_co2_resid_kg=+9.6362, expected_pe_resid_kwh=+112.7064), _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896), _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145), _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), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 797eb23e..714be843 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -740,6 +740,75 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = { } +# SAP 10.2 Table 4e (PDF p.171-173) — "Temperature adjustment, °C" +# column. Spec verbatim (p.170): "3. The 'Temperature adjustment' +# modifies the mean internal temperature and is added to worksheet +# (92)m." Table 9c step 8: "Apply adjustment to the mean internal +# temperature from Table 4e, where appropriate". +# +# Pre-S0380.145 the cascade hardcoded `control_temperature_adjustment +# _c=0.0` at all three call sites of `mean_internal_temperature_ +# monthly`. The non-zero adjustments are concentrated on systems +# without thermostatic control (which run permanently at setpoint +# during their heating periods, raising MIT) and on Group 4 electric +# storage where the storage charging strategy raises the maintained +# mean (Manual charge +0.7, Automatic charge +0.4, Celect +0.4, +# HHR-specific controls 0). +_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE: Final[dict[int, float]] = { + # Group 0 — NO HEATING SYSTEM PRESENT + 2699: +0.3, + # Group 1 — BOILER SYSTEMS WITH RADIATORS / UFH (and micro-CHP) + # 2100 = "Not applicable (boiler DHW only)" — no MIT contribution + # from this main; treat as 0. + 2100: 0.0, + 2101: +0.6, 2102: +0.6, + 2103: 0.0, 2104: 0.0, 2105: 0.0, 2106: 0.0, 2107: 0.0, 2108: 0.0, + 2109: 0.0, 2110: 0.0, 2111: 0.0, 2112: 0.0, 2113: 0.0, + # Group 2 — HEAT PUMPS WITH RADIATORS / UFH + 2201: +0.3, 2202: +0.3, + 2203: 0.0, 2204: 0.0, 2205: 0.0, 2206: 0.0, + 2207: 0.0, 2208: 0.0, 2209: 0.0, 2210: 0.0, + # Group 3 — HEAT NETWORKS + 2301: +0.3, 2302: +0.3, + 2303: 0.0, 2304: 0.0, 2305: 0.0, 2306: 0.0, 2307: 0.0, 2308: 0.0, + 2309: 0.0, 2310: 0.0, 2311: 0.0, 2312: 0.0, 2313: 0.0, 2314: 0.0, + # Group 4 — ELECTRIC STORAGE SYSTEMS + 2401: +0.7, 2402: +0.4, 2403: +0.4, 2404: 0.0, + # Group 5 — WARM AIR SYSTEMS (incl. HP with warm air distribution) + 2501: +0.3, 2502: +0.3, + 2503: 0.0, 2504: 0.0, 2505: 0.0, 2506: 0.0, + # Group 6 — ROOM HEATER SYSTEMS + 2601: +0.3, + 2602: 0.0, 2603: 0.0, 2604: 0.0, 2605: 0.0, + # Group 7 — OTHER SYSTEMS + 2701: +0.3, 2702: +0.3, + 2703: 0.0, 2704: 0.0, 2705: 0.0, 2706: 0.0, +} + + +def _control_temperature_adjustment_c( + main: Optional[MainHeatingDetail], +) -> float: + """SAP 10.2 Table 4e (PDF p.171-173) "Temperature adjustment, °C" + per Table 9c step 8 (PDF p.184). The adjustment is added to (92)m + to produce (93)m, which feeds the §8 heat loss rate calc and the + Table 9c step 9 re-calculated utilisation factor. + + Returns 0.0 when no main is lodged or the cert's + `main_heating_control` is not an int. Raises `UnmappedSapCode` + for present-but-unmapped codes per [[reference-unmapped-sap-code]] + so spec-coverage gaps surface at test time. + """ + if main is None: + return 0.0 + code = main.main_heating_control + if not isinstance(code, int): + return 0.0 + if code in _CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE: + return _CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE[code] + raise UnmappedSapCode("main_heating_control_temperature_adjustment", code) + + from domain.sap10_calculator.exceptions import ( MissingMainFuelType, UnmappedSapCode, @@ -2607,7 +2676,7 @@ def mean_internal_temperature_section_from_cert( living_area_fraction=_living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ), - control_temperature_adjustment_c=0.0, + control_temperature_adjustment_c=_control_temperature_adjustment_c(main), ) @@ -4615,7 +4684,7 @@ def cert_to_inputs( control_type=control_type_value, responsiveness=responsiveness_value, living_area_fraction=living_area_fraction_value, - control_temperature_adjustment_c=0.0, + control_temperature_adjustment_c=_control_temperature_adjustment_c(main), extended_heating_days_per_month=extended_heating_days, ) @@ -4809,7 +4878,7 @@ def cert_to_inputs( control_type=control_type_value, responsiveness=responsiveness_value, living_area_fraction=living_area_fraction_value, - control_temperature_adjustment_c=0.0, + control_temperature_adjustment_c=_control_temperature_adjustment_c(main), thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, main_heating_efficiency=eff, hot_water_kwh_per_yr=hw_kwh, 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 9fd0dc0b..db60e6d9 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -3095,6 +3095,131 @@ def test_sap_10_2_table_11_electric_storage_secondary_fraction_dispatches_per_ta ) +def test_sap_10_2_table_4e_temperature_adjustment_applied_to_adjusted_mit_per_table_9c_step_8() -> None: + """SAP 10.2 Table 4e (PDF p.171-173) "Heating system controls": + + 3. The 'Temperature adjustment' modifies the mean internal + temperature and is added to worksheet (92)m. + + Per Table 9c step 8: "Apply adjustment to the mean internal + temperature from Table 4e, where appropriate". The adjusted + (93)m drives the §8 heat loss rate calc → SH demand. + + Table 4e adjustments per control code: + + Group 0 — No heating system: + 2699: +0.3 + Group 1 — Boilers with radiators/UFH: + 2101, 2102: +0.6 (No thermo / programmer-only) + 2103..2113: 0 + Group 2 — Heat pumps with radiators/UFH: + 2201, 2202: +0.3 + 2203..2210: 0 + Group 3 — Heat networks: + 2301, 2302: +0.3 + 2303..2314: 0 + Group 4 — Electric storage: + 2401 (Manual charge): +0.7 + 2402 (Automatic charge): +0.4 + 2403 (Celect): +0.4 + 2404 (HHR controls): 0 + Group 5 — Warm air: + 2501, 2502: +0.3 + 2503..2506: 0 + Group 6 — Room heaters: + 2601: +0.3 + 2602..2605: 0 + Group 7 — Other systems: + 2701, 2702: +0.3 + 2703..2706: 0 + + Pre-slice the cascade hardcoded `control_temperature_adjustment_c + =0.0` at all three call sites of `mean_internal_temperature_ + monthly` and `space_heating_section_with_results`. Cert electric 3 + (corpus 001431, SAP code 401 + main_heating_control 2401 "Manual + charge control") exposes the gap: worksheet (93) MIT_adjusted Jan + = 19.8897 = (92) MIT 19.1897 + 0.7000; cascade (93) Jan = 19.21 + (= (92) with no adjustment). The missing +0.7 K on internal + temperature drives a ~10% undercount in §8 SH demand + (cascade 10083 kWh vs worksheet 11088 kWh annual). + """ + # Arrange — route corpus electric variants through the Elmhurst + # extractor → mapper chain and exercise the cascade's + # `_control_temperature_adjustment_c` dispatch via the public + # `cert_to_inputs` interface. Each variant lodges a distinct + # `main_heating_control` (Table 4e code) so the adjustment + # dispatch is observable end-to-end. + import re + import subprocess + from pathlib import Path + + from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor + from datatypes.epc.domain.mapper import EpcPropertyDataMapper + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _control_temperature_adjustment_c, # pyright: ignore[reportPrivateUsage] + ) + + def _epc_for(variant: str): + corpus = ( + Path(__file__).parents[4] + / "sap worksheets/heating systems examples" + / variant + ) + summary_pdf = next(corpus.glob("Summary_*.pdf")) + info = subprocess.run( + ["pdfinfo", str(summary_pdf)], capture_output=True, text=True, check=True, + ).stdout + pc = int(re.search(r"Pages:\s+(\d+)", info).group(1)) # type: ignore[union-attr] + 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)) + return EpcPropertyDataMapper.from_elmhurst_site_notes( + ElmhurstSiteNotesExtractor(pages).extract() + ) + + epc_e3 = _epc_for("electric 3") # control 2401 → +0.7 + epc_e5 = _epc_for("electric 5") # control 2402 → +0.4 + epc_e8 = _epc_for("electric 8") # control 2404 → 0 + epc_pcdb1 = _epc_for("pcdb 1") # control 2105 → 0 + + # Act + adj_e3 = _control_temperature_adjustment_c(epc_e3.sap_heating.main_heating_details[0]) + adj_e5 = _control_temperature_adjustment_c(epc_e5.sap_heating.main_heating_details[0]) + adj_e8 = _control_temperature_adjustment_c(epc_e8.sap_heating.main_heating_details[0]) + adj_pcdb1 = _control_temperature_adjustment_c(epc_pcdb1.sap_heating.main_heating_details[0]) + + # Assert + assert abs(adj_e3 - 0.7) < 1e-9, ( + f"electric 3 main_heating_control=2401 (Manual charge control): " + f"got {adj_e3!r}, want +0.7 per SAP 10.2 Table 4e Group 4" + ) + assert abs(adj_e5 - 0.4) < 1e-9, ( + f"electric 5 main_heating_control=2402 (Automatic charge control): " + f"got {adj_e5!r}, want +0.4 per SAP 10.2 Table 4e Group 4" + ) + assert adj_e8 == 0.0, ( + f"electric 8 main_heating_control=2404 (HHR controls): " + f"got {adj_e8!r}, want 0 per SAP 10.2 Table 4e Group 4" + ) + assert adj_pcdb1 == 0.0, ( + f"pcdb 1 main_heating_control=2105 (Programmer + ≥2 room " + f"thermostats): got {adj_pcdb1!r}, want 0 per SAP 10.2 " + f"Table 4e Group 1" + ) + + def test_cylinder_storage_loss_applies_57m_solar_adjustment_per_sap_4_line_7693() -> None: """SAP 10.2 §4 line 7693 (PDF p.137):