fix(secondary): apply Table 11 secondary when lodged via description only (§A.2.2)

`_secondary_fraction` keyed "has a secondary" off the integer
`secondary_heating_type` code. The gov-API path surfaces the secondary as a
DESCRIPTION instead (`secondary_heating.description`, e.g. "Portable electric
heaters (assumed)") and leaves the integer code None. So a gas/oil boiler
main (not in the §A.2.2 forced-secondary set) with an assumed portable-electric
secondary dropped the secondary entirely (sec_kWh=0), under-costing the
dwelling and over-rating its SAP.

Per RdSAP §A.2.2 / SAP 10.2 Table 11, a lodged secondary is costed at its
Table 11 fraction (cat-2 boiler = 0.10, billed at standard-rate electricity per
the §A.2.2 assumed portable-electric default). New
`_has_lodged_secondary_description` treats a real `secondary_heating.description`
as a lodged secondary; passed to `_secondary_fraction` at both call sites. The
description is authoritative — same lesson as floor_heat_loss / roof codes.
(Electric-storage mains were unaffected: they force the secondary already.)

Also adds the Table 11 fraction for main_heating_category=8 (electric underfloor,
"Integrated storage/direct-acting electric systems" = 0.10) — the strict-raise
surfaced this latent gap once cat-8 mains were routed through the lookup.

Eval: 909 computed, 0 raises, 46.9% -> 47.6% within 0.5 (+13 certs: 420 -> 433),
mean|err| 1.633 -> 1.586. 13 improved / 1 regressed (2610, a cat-10 room-heater
cert with an independent over-count). Bucket "Portable electric heaters"
median +2.73 -> ~0 on the gas/cat-2 subset (cat-7 storage was already correct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 14:23:17 +00:00
parent 5e7ef5c7ff
commit faf29942ba
2 changed files with 63 additions and 4 deletions

View file

@ -794,6 +794,14 @@ _SECONDARY_HEATING_FRACTION_BY_CATEGORY: Final[dict[int, float]] = {
5: 0.10,
6: 0.10,
7: 0.15,
8: 0.10, # Electric underfloor heating (direct-acting electric, e.g.
# SAP code 424): SAP 10.2 Table 11 (PDF p.188) row
# "Integrated storage/direct-acting electric systems" /
# "Other electric systems" = 0.10. First exercised when the
# description-lodged-secondary fix routed cat-8 mains (which
# previously short-circuited to 0) through the Table 11
# lookup (cert 2051-9502, electric underfloor + assumed
# portable-electric secondary).
9: 0.10, # Warm-air systems (NOT heat pump): a gas/oil warm-air unit
# is an "All gas, liquid and solid fuel systems" row (0.10),
# and electric warm air is "Other electric systems" (also
@ -2305,7 +2313,9 @@ def _hot_water_fuel_cost_gbp_per_kwh(
def _secondary_fraction(
main: Optional[MainHeatingDetail], secondary_heating_type: object
main: Optional[MainHeatingDetail],
secondary_heating_type: object,
secondary_lodged: 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
@ -2313,6 +2323,17 @@ def _secondary_fraction(
heaters). Returns 0.0 when neither applies the most common case
for gas/oil main systems whose cert doesn't lodge a secondary.
`secondary_lodged` covers the gov-API path: the register publishes
the secondary as a DESCRIPTION (`secondary_heating.description`, e.g.
"Portable electric heaters (assumed)") even when the integer
`secondary_heating_type` code is absent. The description is
authoritative a lodged secondary description means RdSAP assessed a
secondary (per §A.2.2 the assumed system is portable electric heaters)
and its Table 11 fraction must be costed. Without this a gas/oil
boiler main with an assumed portable-electric secondary dropped the
secondary entirely (sec_kWh=0), under-costing the dwelling and
over-rating its SAP by a clean systematic +2.7 (median).
`main_heating_fraction` on the cert is NOT consulted here: empirical
probe shows it tracks main-system-1 vs main-system-2 allocation in
multi-main configurations (99% of corpus has =1, meaning "single
@ -2334,7 +2355,7 @@ def _secondary_fraction(
if main is None:
return 0.0
code = main.sap_main_heating_code
has_lodged_secondary = secondary_heating_type is not None
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:
return 0.0
@ -2346,6 +2367,19 @@ def _secondary_fraction(
return _secondary_heating_fraction_for_category(main.main_heating_category)
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`,
e.g. "Portable electric heaters (assumed)", even when the integer
`secondary_heating_type` code is None). RdSAP treats a lodged
secondary as costed (§A.2.2), so this gates the Table 11 fraction."""
sec = epc.secondary_heating
if sec is None:
return False
desc = getattr(sec, "description", None)
return desc is not None and desc not in ("None", "")
def _secondary_heating_fraction_for_category(
main_heating_category: Optional[int],
) -> float:
@ -4290,7 +4324,9 @@ def energy_requirements_section_from_cert(
main_category = main.main_heating_category if main is not None else None
main_fuel = _main_fuel_code(main)
secondary_fraction_value = _secondary_fraction(
main, epc.sap_heating.secondary_heating_type if epc.sap_heating else None
main,
epc.sap_heating.secondary_heating_type if epc.sap_heating else None,
secondary_lodged=_has_lodged_secondary_description(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
@ -6559,7 +6595,9 @@ def cert_to_inputs(
# without recomputing it. Pure function over the cert; same value
# later when §9a `space_heating_fuel_monthly_kwh` runs.
secondary_fraction_value = _secondary_fraction(
main, epc.sap_heating.secondary_heating_type
main,
epc.sap_heating.secondary_heating_type,
secondary_lodged=_has_lodged_secondary_description(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

@ -522,6 +522,27 @@ def test_main_heating_fraction_does_not_override_table11_secondary_default() ->
assert inputs.secondary_heating_fraction == pytest.approx(0.1, abs=0.001)
def test_secondary_fraction_fires_when_secondary_lodged_via_description_only() -> None:
# Arrange — SAP 10.2 Table 11 / RdSAP §A.2.2: a gas boiler main (cat 2,
# not in the §A.2.2 forced-secondary set) whose cert lodges NO integer
# `secondary_heating_type` but DOES carry a secondary DESCRIPTION (the
# gov-API path surfaces the secondary only as a description, e.g.
# "Portable electric heaters (assumed)") must cost the secondary at its
# Table 11 0.10 fraction. Previously this returned 0.0 — the secondary
# was dropped (sec_kWh=0) → a clean systematic SAP over-rate (+2.7 med).
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
no_secondary = _secondary_fraction(main, None, secondary_lodged=False)
description_lodged = _secondary_fraction(main, None, secondary_lodged=True)
# Assert — a description-only lodged secondary fires the 0.10 fraction.
assert no_secondary == 0.0
assert abs(description_lodged - 0.10) <= 1e-9
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