mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
d05fdbe6f2
commit
34e52a893c
2 changed files with 82 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue