diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 29730560..71367992 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index a00fca28..d0f12b4d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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