fix(heating): assume portable-electric secondary for unheated habitable rooms (SAP 10.2 Appendix A.2.2)

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-21 06:15:37 +00:00
parent d05fdbe6f2
commit 34e52a893c
2 changed files with 82 additions and 1 deletions

View file

@ -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

View file

@ -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