diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 8530268c..cdffe4b4 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -462,15 +462,17 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # fuel (219) = 4099.5872 EXACT. ΔSAP +3.0518 → +0.0782; Δcost # -£69.79 → -£1.68; ΔCO2 -240.66 → -1.71; ΔPE -1112.66 → -18.61. # - # The residual that remains is a SINGLE distinct cause the interlock - # fix exposed: the central heating pump (230c). Cascade reads - # `central_heating_pump_age=2` → Table 4f 41 kWh, but ws (230c) = - # 53.3 kWh (non-standard — not a Table 4f age value of 41/115/165; - # likely a lodged pump power). The 12.3 kWh gap fully explains the - # residual: cost 12.3 x 0.1367 = £1.68, CO2 12.3 x 0.1387 = 1.71 kg, - # PE 12.3 x 1.5128 = 18.61 kWh. Pinned as the next-slice forcing - # function (S0380.178 central-heating-pump 53.3 kWh). - _CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=+0.0782, expected_cost_resid_gbp=-1.6814, expected_co2_resid_kg=-1.7061, expected_pe_resid_kwh=-18.6074), + # Slice S0380.178 then closed the residual S0380.177 exposed — the + # central heating pump (230c). SAP 10.2 Table 4f (PDF p.175) footnote + # a) on the "Circulation pump" rows: "Multiply by a factor of 1.3 if + # room thermostat is absent." Control 2101 has no room thermostat, so + # the cert's "2013 or later" pump (Table 4f 41 kWh) scales to 41 x + # 1.3 = 53.3 kWh = ws (230c); pumps/fans (231) = 53.3 + 100 (oil aux) + # = 153.3 EXACT. Same root cause (no room thermostat) as the .177 + # interlock fix. oil 6 now FULLY EXACT on all four metrics. The + # sibling oil 5 (same pump age but control 2106 WITH a room + # thermostat) keeps the bare 41 kWh and is unaffected. + _CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), @@ -890,6 +892,34 @@ def test_oil_6_no_room_thermostat_applies_table_4c2_minus_5pp_space_efficiency() ) +def test_oil_6_absent_room_thermostat_applies_table_4f_pump_1_3_multiplier() -> None: + # Arrange — oil 6 lodges Main Heating Controls Sap code 2101 ("No + # time or thermostatic control of room temperature") = no room + # thermostat. SAP 10.2 Table 4f (PDF p.175) footnote a) on the + # "Circulation pump" rows reads verbatim: "Multiply by a factor of + # 1.3 if room thermostat is absent." The cert's central heating + # pump is "2013 or later" -> Table 4f 41 kWh; with the absent-room- + # thermostat x1.3 it becomes 41 x 1.3 = 53.3 kWh, matching worksheet + # (230c) = 53.3000. With the liquid-fuel boiler flue-fan/pump 100 + # kWh (230d), total pumps/fans (231) = 153.3000. The sibling oil 5 + # (same "2013 or later" pump age but control 2106 WITH a room + # thermostat) keeps the bare 41 kWh — worksheet (230c) = 41.0000. + summary_pdf, _ = _variant_paths('oil 6') + pages = _summary_pdf_to_textract_style_pages(summary_pdf) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act — run the rating cascade and read the resolved pumps/fans kWh. + inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + # Assert — 41 x 1.3 (circulation pump) + 100 (oil flue fan/pump) = + # 153.3 kWh (matches worksheet (231)). + assert abs(inputs.pumps_fans_kwh_per_yr - 153.3) <= 1e-9, ( + f"oil 6 pumps/fans {inputs.pumps_fans_kwh_per_yr:.4f} kWh " + f"!= 153.3 (41 x 1.3 absent-room-thermostat pump + 100 oil aux)" + ) + + @pytest.mark.skipif( not _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE, reason="all blocked variants have been unblocked (latest: S0380.170)", diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index d537ca55..5fc529c2 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -229,6 +229,14 @@ _TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE: Final[dict[int, float]] = { # to use the unknown-date value. _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT: Final[float] = 115.0 +# SAP 10.2 Table 4f (PDF p.175) footnote a) on the "Circulation pump" +# rows reads verbatim: "Multiply by a factor of 1.3 if room thermostat +# is absent." A gas/liquid-fuel boiler under control code 2101 / 2102 +# (`_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES`) has no room thermostat, +# so its circulation pump electricity is scaled by 1.3 — e.g. oil 6 +# (pump_age "2013 or later" → 41 kWh) lands ws (230c) = 41 × 1.3 = 53.3. +_TABLE_4F_NO_ROOM_THERMOSTAT_PUMP_MULTIPLIER: Final[float] = 1.3 + # Heat pumps from PCDB include circulation pump electricity in COP per # Table 4f note: "Not applicable for electric heat pumps from # database." Cat 4 (heat pump) → 0 kWh circulation pump. @@ -352,16 +360,23 @@ def _table_4f_circulation_pump_kwh(main: Optional[MainHeatingDetail]) -> float: 0 / None → 115 kWh (Unknown date) 1 → 165 kWh (Pre 2013 / 2012 or earlier) 2 → 41 kWh (2013 or later) + + Table 4f footnote a) then multiplies the row by 1.3 when the room + thermostat is absent (control code 2101 / 2102). """ if not _is_wet_boiler_main(main): return 0.0 assert main is not None # _is_wet_boiler_main guards None age = main.central_heating_pump_age if age is None: - return _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT - return _TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE.get( - age, _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT - ) + kwh = _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT + else: + kwh = _TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE.get( + age, _TABLE_4F_CIRCULATION_PUMP_KWH_DEFAULT + ) + if main.main_heating_control in _BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES: + kwh *= _TABLE_4F_NO_ROOM_THERMOSTAT_PUMP_MULTIPLIER + return kwh def _table_4f_main_1_gas_boiler_flue_fan_kwh(