diff --git a/packages/domain/src/domain/ml/tests/_fixtures.py b/packages/domain/src/domain/ml/tests/_fixtures.py index 0d139839..37fccde4 100644 --- a/packages/domain/src/domain/ml/tests/_fixtures.py +++ b/packages/domain/src/domain/ml/tests/_fixtures.py @@ -85,6 +85,7 @@ def make_sap_heating( cylinder_size: Optional[Union[int, str]] = None, cylinder_insulation_thickness_mm: Optional[int] = None, secondary_fuel_type: Optional[int] = None, + secondary_heating_type: Optional[int] = None, ) -> SapHeating: """Build a SapHeating with SAP10 API defaults.""" return SapHeating( @@ -98,6 +99,7 @@ def make_sap_heating( cylinder_size=cylinder_size, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, secondary_fuel_type=secondary_fuel_type, + secondary_heating_type=secondary_heating_type, ) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index d5bc6383..5b0b63d7 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -533,7 +533,17 @@ def _secondary_fraction( when (a) the cert has a secondary system lodged OR (b) the main heating code is in the §A.2.2 forced-secondary set (electric storage heaters). Returns 0.0 when neither applies — the most common case - for gas/oil main systems whose cert doesn't lodge a secondary.""" + for gas/oil main systems whose cert doesn't lodge a secondary. + + `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 + main, 100% allocation"), not main-vs-secondary. Elmhurst applies + Table 11's 10% secondary even when main_heating_fraction=1; the + spec is silent on overriding (only the §A.2.2 forced-secondary rule + is explicit), and an S-B30 attempt to override yielded SAP MAE + +0.16 — the wrong direction. + """ if main is None: return 0.0 code = main.sap_main_heating_code diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index 2915dde0..2d13e2f5 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -16,6 +16,8 @@ from __future__ import annotations from typing import Final +import pytest + from datatypes.epc.domain.epc_property_data import MainHeatingDetail from domain.ml.tests._fixtures import ( @@ -78,6 +80,75 @@ def _typical_semi_detached_epc(): ) +def test_main_heating_fraction_does_not_override_table11_secondary_default() -> None: + # Arrange — the S-B30 attempt assumed `main_heating_fraction=1` meant + # "no secondary heating" and dropped the Table 11 default in that + # case. Empirically that gave SAP MAE +0.16 (worse). The cert + # software (Elmhurst) DOES apply Table 11's 10% secondary even when + # main_heating_fraction=1 — the field tracks multi-main allocation + # not main-vs-secondary. This test pins the spec-aligned behaviour + # to prevent regressions. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=102, + main_heating_fraction=1, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[main], + secondary_heating_type=691, + ), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — Table 11's 0.10 applies regardless of main_heating_fraction. + assert inputs.secondary_heating_fraction == pytest.approx(0.1, abs=0.001) + + +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 + # applies (the pre-S-B30 behaviour for ~340 of 30 000 corpus certs). + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=102, + main_heating_fraction=None, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[main], + secondary_heating_type=691, + ), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert + assert inputs.secondary_heating_fraction == pytest.approx(0.1, abs=0.001) + + def test_minimal_cert_round_trips_through_calculator_and_returns_sap_result() -> None: # Arrange — a complete-enough RdSAP cert (envelope + heating + windows # + region) that the mapper can populate every CalculatorInputs field.