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],
|
main: Optional[MainHeatingDetail],
|
||||||
secondary_heating_type: object,
|
secondary_heating_type: object,
|
||||||
secondary_lodged: bool = False,
|
secondary_lodged: bool = False,
|
||||||
|
unheated_habitable_rooms: bool = False,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""SAP 10.2 Table 11 lookup by main heating category, applied only
|
"""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
|
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
|
code = main.sap_main_heating_code
|
||||||
has_lodged_secondary = secondary_heating_type is not None or secondary_lodged
|
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
|
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
|
return 0.0
|
||||||
if (
|
if (
|
||||||
code is not None
|
code is not None
|
||||||
|
|
@ -2682,6 +2688,26 @@ def _secondary_fraction(
|
||||||
return _secondary_heating_fraction_for_category(main.main_heating_category)
|
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:
|
def _has_lodged_secondary_description(epc: EpcPropertyData) -> bool:
|
||||||
"""True when the cert lodges a secondary-heating DESCRIPTION (the
|
"""True when the cert lodges a secondary-heating DESCRIPTION (the
|
||||||
gov-API path surfaces the secondary as `secondary_heating.description`,
|
gov-API path surfaces the secondary as `secondary_heating.description`,
|
||||||
|
|
@ -4735,6 +4761,7 @@ def energy_requirements_section_from_cert(
|
||||||
main,
|
main,
|
||||||
epc.sap_heating.secondary_heating_type if epc.sap_heating else None,
|
epc.sap_heating.secondary_heating_type if epc.sap_heating else None,
|
||||||
secondary_lodged=_has_lodged_secondary_description(epc),
|
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;
|
# When no secondary system is lodged the worksheet displays (208) = 0;
|
||||||
# the per-system fuel formula already collapses to 0 via fraction_201 = 0
|
# the per-system fuel formula already collapses to 0 via fraction_201 = 0
|
||||||
|
|
@ -7272,6 +7299,7 @@ def cert_to_inputs(
|
||||||
main,
|
main,
|
||||||
epc.sap_heating.secondary_heating_type,
|
epc.sap_heating.secondary_heating_type,
|
||||||
secondary_lodged=_has_lodged_secondary_description(epc),
|
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
|
# SAP10.2 §4 — compute the worksheet (45..65) values now (they only
|
||||||
# depend on the cert dwelling shape, not on water_efficiency). The
|
# 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
|
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:
|
def test_main_heating_fraction_missing_falls_back_to_table11_default() -> None:
|
||||||
# Arrange — when main_heating_fraction isn't lodged AND the cert
|
# Arrange — when main_heating_fraction isn't lodged AND the cert
|
||||||
# has a secondary system lodged, Table 11's 0.10 default still
|
# has a secondary system lodged, Table 11's 0.10 default still
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue