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