diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 3bae9dc7..8530268c 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -448,7 +448,29 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='oil 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), _CorpusExpectation(variant='oil 4', 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 5', 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 6', block='11a', expected_sap_resid=+3.0518, expected_cost_resid_gbp=-69.7943, expected_co2_resid_kg=-240.6595, expected_pe_resid_kwh=-1112.6558), + # Slice S0380.177 closed oil 6 (B30K, Table 4b regular boiler code + # 126) main heating + HW efficiency via the SAP 10.2 Table 4c(2) + # (PDF p.169) "No thermostatic control of room temperature – regular + # boiler" -5pp adjustment. The cert lodges control code 2101 (no + # room thermostat) WITH a cylinder thermostat; per RdSAP 10 §3 (PDF + # p.57) boiler interlock needs BOTH a room thermostat AND a cylinder + # thermostat, so the 2101 control means NO interlock despite the + # cylinderstat (P960 header "Boiler Interlock: No"). Pre-slice the + # `no_interlock` gate only checked the cylinder thermostat, so oil 6 + # kept raw efficiency: space 0.80 vs ws (210) 0.75, HW (217)m summer + # 68 vs ws 63. Post-slice space fuel (211) = 13446.3457 EXACT and HW + # 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), _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), @@ -838,6 +860,36 @@ def test_heating_systems_corpus_residual_matches_pin( ) +def test_oil_6_no_room_thermostat_applies_table_4c2_minus_5pp_space_efficiency() -> None: + # Arrange — oil 6 (B30K standard liquid-fuel boiler, Table 4b code + # 126 winter 80 / summer 68) lodges "Main Heating Controls Sap: SAP + # code 2101, No time or thermostatic control of room temperature" + # WITH a cylinder thermostat present. Per RdSAP 10 §3 (PDF p.57) + # boiler interlock is "assumed present if there is a room thermostat + # and (for stored hot water systems heated by the boiler) a cylinder + # thermostat. Otherwise not interlocked." Control 2101 provides no + # room thermostat, so the boiler is NOT interlocked despite the + # cylinder thermostat. SAP 10.2 Table 4c(2) (PDF p.169) "No + # thermostatic control of room temperature – regular boiler" deducts + # 5pp from BOTH the space and DHW seasonal efficiency. The worksheet + # confirms it: P960 header "Boiler Interlock: No"; (210) space + # efficiency = 75.0000 = 80 - 5; (217)m summer = 63.0000 = 68 - 5. + 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 space efficiency. + inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + # Assert — Table 4b 80% winter less the Table 4c(2) -5pp interlock + # penalty = 75% (matches worksheet (210)). + assert abs(inputs.main_heating_efficiency - 0.75) <= 1e-9, ( + f"oil 6 space efficiency {inputs.main_heating_efficiency:.4f} " + f"!= 0.75 (Table 4b 0.80 - Table 4c(2) 0.05 interlock penalty)" + ) + + @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 1322ccea..d537ca55 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1022,6 +1022,25 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = { } +# SAP 10.2 Table 4e Group 1 (PDF p.171) — boiler control codes providing +# NO thermostatic control of room temperature, i.e. no room thermostat +# ("No time or thermostatic control of room temperature" 2101 / +# "Programmer, no room thermostat" 2102 — the two Group-1 rows carrying +# the "+0.6 °C / Table 4c(2)" annotation). Per RdSAP 10 §3 (PDF p.57) +# boiler interlock is "assumed present if there is a room thermostat and +# (for stored hot water systems heated by the boiler) a cylinder +# thermostat. Otherwise not interlocked." A gas/liquid-fuel boiler under +# one of these controls therefore has NO boiler interlock regardless of +# the cylinder thermostat, triggering the Table 4c(2) (PDF p.169) "No +# thermostatic control of room temperature – regular boiler" -5pp Space +# + DHW seasonal-efficiency adjustment. The combi rows of Table 4c(2) +# take Space -5 / DHW 0; the DHW leg is gated separately on a cylinder +# being present (regular boiler) at the call site. +_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES: Final[frozenset[int]] = frozenset( + {2101, 2102} +) + + # 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 @@ -5551,11 +5570,33 @@ def cert_to_inputs( # cylinder + Cylinder Stat: No) closes 65% → 60% — matches # worksheet (210) exactly. Cert 000565 closes WH 79% → 74% # unchanged from S0380.79. - no_interlock = ( + # RdSAP 10 §3 (PDF p.57): a gas/liquid-fuel boiler is interlocked iff + # it has BOTH a room thermostat AND (for stored hot water) a cylinder + # thermostat. Two independent ways to lose interlock: + # (a) no room thermostat — control code 2101 / 2102 (Table 4e + # Group 1 "no thermostatic control of room temperature"), e.g. + # oil 6 (B30K, code 2101; P960 header "Boiler Interlock: No" + # despite "Cylinder Stat: Yes"); + # (b) stored HW from the boiler with no cylinder thermostat. + # Either triggers the Table 4c(2) (PDF p.169) -5pp seasonal- + # efficiency adjustment. The DHW leg is additionally gated on a + # cylinder being present (regular boiler — Table 4c(2) "no + # thermostatic control / no interlock – combi" takes DHW 0). + no_room_thermostat = ( + main is not None + and main.main_heating_control + in _BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES + ) + no_stored_hw_interlock = ( epc.has_hot_water_cylinder and epc.sap_heating.cylinder_thermostat != "Y" ) - if no_interlock and water_pcdb_main is not None: + no_interlock = no_room_thermostat or no_stored_hw_interlock + if ( + no_interlock + and water_pcdb_main is not None + and epc.has_hot_water_cylinder + ): water_eff -= 0.05 # Resolve the (winter, summer) seasonal efficiency pair that feeds # the SAP 10.2 Appendix D §D2.1 (2) Equation D1 monthly cascade. @@ -5593,7 +5634,17 @@ def cert_to_inputs( eq_d1_winter_summer_pct = table_4b_seasonal_efficiencies_pct( main.sap_main_heating_code ) - if no_interlock and pcdb_main is not None: + # Space leg of the Table 4c(2) adjustment — applies to PCDB-record + # boilers AND Table 4b non-PCDB boilers (code 101-141), regular and + # combi alike (both take Space -5). oil 6 (Table 4b code 126, pcdb_ + # main None) reaches the penalty only via the Table 4b branch. + if no_interlock and ( + pcdb_main is not None + or ( + main is not None + and main.sap_main_heating_code in _TABLE_4B_CODE_RANGE + ) + ): eff -= 0.05 # SAP 10.2 §9.4.11 -5pp interlock is applied to the Eq D1 OUTPUT # via `_apply_water_efficiency`'s `interlock_penalty_pp` kwarg — @@ -5602,7 +5653,10 @@ def cert_to_inputs( # in η; the worksheet's (217)m for pcdb 1 matches the post-Eq-D1 # form. See `_apply_water_efficiency` docstring + S0380.165 commit. eq_d1_interlock_penalty_pp = ( - 5.0 if no_interlock and eq_d1_winter_summer_pct is not None + 5.0 + if no_interlock + and eq_d1_winter_summer_pct is not None + and epc.has_hot_water_cylinder else 0.0 ) # SAP 10.2 Appendix N3.6 + N3.7(a) — when an HP cert lodges a PCDB