From 34e52a893c1e15b79245f97e69f58aea3b4255cd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 21 Jun 2026 06:15:37 +0000 Subject: [PATCH] fix(heating): assume portable-electric secondary for unheated habitable rooms (SAP 10.2 Appendix A.2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the main heating system does not heat every habitable room (heated rooms < habitable rooms), SAP 10.2 Appendix A.2.2 assumes the unheated rooms are served by a portable-electric secondary heater, so the Table 11 secondary fraction (0.10 for a boiler main) must be costed at the electricity tariff — even when the cert lodges no explicit secondary system. `_secondary_fraction` previously returned 0 unless a secondary was lodged or the main was a forced-secondary electric-storage code, dropping the assumed secondary and billing 100% of space heat to the (cheaper) main fuel — an over-rate. Added an `unheated_habitable_rooms` trigger plus `_has_unheated_habitable_rooms(epc)`, which prefers the lodged `any_unheated_rooms` flag and guards the gov-API `heated_rooms_count == 0` "not provided" sentinel. The secondary fuel/efficiency cascade already defaults to portable electric (code 693) when no secondary code is lodged. Worksheet-validated on simulated case 46 (heated 4 < habitable 7, no lodged secondary): the assumed 10% electric secondary (2289 kWh, ~£260) lifted our SAP 39 -> 29.35 vs accredited Elmhurst 30 (cost £1502 vs £1493, within 0.6%). Corpus UNCHANGED (71.6% / MAE 0.819): all 17 corpus certs with heated < habitable already lodge an explicit secondary description, so the gov-API path was already costing it; this only adds the assumed secondary where none is lodged (Elmhurst / reduced-field path). pyright not installed locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 30 ++++++++++- .../rdsap/test_cert_to_inputs.py | 53 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index cd146109..41c1415d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2631,6 +2631,7 @@ def _secondary_fraction( main: Optional[MainHeatingDetail], secondary_heating_type: object, secondary_lodged: bool = False, + unheated_habitable_rooms: bool = False, ) -> float: """SAP 10.2 Table 11 lookup by main heating category, applied only when (a) the cert has a secondary system lodged OR (b) the main @@ -2672,7 +2673,12 @@ def _secondary_fraction( code = main.sap_main_heating_code has_lodged_secondary = secondary_heating_type is not None or secondary_lodged force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES - if not has_lodged_secondary and not force: + # SAP 10.2 Appendix A.2.2 — when the main system does not heat every + # habitable room, the unheated rooms are assumed to be served by a + # portable-electric secondary heater, so the Table 11 fraction is costed + # even with no lodged secondary (the secondary fuel/efficiency cascade + # already defaults to portable electric, code 693, when no code lodged). + if not has_lodged_secondary and not force and not unheated_habitable_rooms: return 0.0 if ( code is not None @@ -2682,6 +2688,26 @@ def _secondary_fraction( return _secondary_heating_fraction_for_category(main.main_heating_category) +def _has_unheated_habitable_rooms(epc: EpcPropertyData) -> bool: + """SAP 10.2 Appendix A.2.2 — the main heating system does not heat every + habitable room (heated rooms < habitable rooms), so the unheated rooms + take an assumed portable-electric secondary heater. + + Prefers the lodged `any_unheated_rooms` flag (set on both the gov-API and + Elmhurst paths). Falls back to the heated/habitable room-count comparison + only when the heated count is a real positive value — a lodged + `heated_rooms_count == 0` is the "not provided" sentinel on the gov-API + path, not literally zero heated rooms, so it must not spuriously trigger + the assumed secondary.""" + if epc.any_unheated_rooms is not None: + return epc.any_unheated_rooms + return ( + epc.heated_rooms_count > 0 + and epc.habitable_rooms_count > 0 + and epc.heated_rooms_count < epc.habitable_rooms_count + ) + + def _has_lodged_secondary_description(epc: EpcPropertyData) -> bool: """True when the cert lodges a secondary-heating DESCRIPTION (the gov-API path surfaces the secondary as `secondary_heating.description`, @@ -4735,6 +4761,7 @@ def energy_requirements_section_from_cert( main, epc.sap_heating.secondary_heating_type if epc.sap_heating else None, secondary_lodged=_has_lodged_secondary_description(epc), + unheated_habitable_rooms=_has_unheated_habitable_rooms(epc), ) # When no secondary system is lodged the worksheet displays (208) = 0; # the per-system fuel formula already collapses to 0 via fraction_201 = 0 @@ -7272,6 +7299,7 @@ def cert_to_inputs( main, epc.sap_heating.secondary_heating_type, secondary_lodged=_has_lodged_secondary_description(epc), + unheated_habitable_rooms=_has_unheated_habitable_rooms(epc), ) # SAP10.2 §4 — compute the worksheet (45..65) values now (they only # depend on the cert dwelling shape, not on water_efficiency). The diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index d8934c16..4b4951bc 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1104,6 +1104,59 @@ def test_secondary_fraction_fires_when_secondary_lodged_via_description_only() - assert abs(description_lodged - 0.10) <= 1e-9 +def test_secondary_fraction_fires_for_unheated_habitable_rooms_per_appendix_a22() -> None: + # Arrange — SAP 10.2 Appendix A.2.2: when the main system does not heat + # every habitable room (heated rooms < habitable rooms), the unheated + # rooms take an assumed portable-electric secondary heater, so the Table + # 11 0.10 fraction is costed EVEN WITH no lodged secondary. A gas boiler + # main (cat 2, not forced-secondary) with no secondary lodged returns 0.0 + # normally, but 0.10 once `unheated_habitable_rooms=True`. Worksheet- + # validated on simulated case 46 (heated 4 < habitable 7): the assumed + # secondary lifted our SAP from 39 to 29 (Elmhurst 30). + from domain.sap10_calculator.rdsap.cert_to_inputs import _secondary_fraction # pyright: ignore[reportPrivateUsage] + + main = _gas_boiler_detail() # cat 2, code 102 — not forced-secondary + + # Act + all_rooms_heated = _secondary_fraction(main, None, unheated_habitable_rooms=False) + has_unheated = _secondary_fraction(main, None, unheated_habitable_rooms=True) + + # Assert + assert all_rooms_heated == 0.0 + assert abs(has_unheated - 0.10) <= 1e-9 + + +def test_has_unheated_habitable_rooms_prefers_flag_and_guards_zero_sentinel() -> None: + # Arrange — `_has_unheated_habitable_rooms` prefers the lodged + # `any_unheated_rooms` flag; its room-count fallback must NOT trigger on a + # `heated_rooms_count == 0` "not provided" sentinel (gov-API), only on a + # real positive heated count below the habitable count. + import dataclasses + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( # pyright: ignore[reportPrivateUsage] + _has_unheated_habitable_rooms, + ) + + base = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG", + ) + + flag_true = dataclasses.replace(base, any_unheated_rooms=True) + flag_false = dataclasses.replace(base, any_unheated_rooms=False) + count_unheated = dataclasses.replace( + base, any_unheated_rooms=None, heated_rooms_count=4, habitable_rooms_count=7 + ) + zero_sentinel = dataclasses.replace( + base, any_unheated_rooms=None, heated_rooms_count=0, habitable_rooms_count=5 + ) + + # Act / Assert + assert _has_unheated_habitable_rooms(flag_true) is True + assert _has_unheated_habitable_rooms(flag_false) is False + assert _has_unheated_habitable_rooms(count_unheated) is True + assert _has_unheated_habitable_rooms(zero_sentinel) is False + + def test_main_heating_fraction_missing_falls_back_to_table11_default() -> None: # Arrange — when main_heating_fraction isn't lodged AND the cert # has a secondary system lodged, Table 11's 0.10 default still