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 82f344c1..0517fb54 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -125,6 +125,18 @@ _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 _DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0 +# SAP 10.3 Table 9 main_heating_control codes → control type (1/2/3). +# Type 1: no time + temp control, or one but not both. +# Type 2: programmer + room thermostat (+/− TRVs). +# Type 3: time-and-temperature zone control (e.g. separate living-zone +# programmer + thermostat). +_CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = { + 2101: 1, 2102: 1, 2103: 1, 2104: 1, + 2105: 2, 2106: 2, 2107: 2, 2108: 2, 2109: 2, + 2110: 3, 2111: 3, 2112: 3, 2113: 3, +} + + # SAP 10.2 Table 4a "electric heating" range that picks up an Economy-7 # off-peak tariff for the space-heating fuel cost: electric storage # heaters (401-409), high-heat-retention storage heaters (421-425), and @@ -254,9 +266,15 @@ def _first_main_heating(epc: EpcPropertyData) -> Optional[MainHeatingDetail]: def _control_type(main: Optional[MainHeatingDetail]) -> int: - """SAP 10.3 §7.1 / Table 9 control type 1/2/3. Defaults to 2 - (programmer + room thermostat or better) — the modal RdSAP case.""" - _ = main # cert-side heating-control code map is Session B work + """SAP 10.3 §7.1 / Table 9 control type 1/2/3 from the + `main_heating_control` code on `MainHeatingDetail`. Defaults to 2 + (programmer + room thermostat) when the code is missing — the modal + RdSAP case.""" + if main is None: + return 2 + code = main.main_heating_control + if isinstance(code, int) and code in _CONTROL_TYPE_BY_CODE: + return _CONTROL_TYPE_BY_CODE[code] return 2 @@ -302,6 +320,8 @@ def _space_heating_fuel_cost_gbp_per_kwh(main: Optional[MainHeatingDetail]) -> f return _fuel_cost_gbp_per_kwh(main) + + def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float: """SAP 10.3 Table 12 CO2 emission factor by Table 32 fuel code.""" code = _main_fuel_code(main) @@ -382,12 +402,12 @@ def cert_to_inputs(epc: EpcPropertyData) -> CalculatorInputs: main_code = main.sap_main_heating_code if main is not None else None main_category = main.main_heating_category if main is not None else None main_fuel = _main_fuel_code(main) - - eff = seasonal_efficiency(main_code, main_category, main_fuel) - water_eff = water_heating_efficiency(epc.sap_heating.water_heating_code, main_code) primary_age = ( epc.sap_building_parts[0].construction_age_band if epc.sap_building_parts else None ) + + eff = seasonal_efficiency(main_code, main_category, main_fuel) + water_eff = water_heating_efficiency(epc.sap_heating.water_heating_code, main_code) hw_kwh = predicted_hot_water_kwh( total_floor_area_m2=epc.total_floor_area_m2, seasonal_efficiency_water=water_eff, 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 ec854d31..112f73bd 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 @@ -218,6 +218,41 @@ def test_mains_gas_fuel_cost_in_gbp_per_kwh() -> None: assert inputs.other_fuel_cost_gbp_per_kwh == 0.0348 +def test_main_heating_control_code_maps_to_sap_control_type() -> None: + # Arrange — Table 9 control type derives from the main_heating_control + # field. 2103 (room thermostat only, no programmer) → type 1; 2106 + # (programmer + room thermostat + TRVs) → type 2; 2110 (zone control) + # → type 3. + def _epc_with_control(code: int): + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code="1", + sap_building_parts=[make_building_part( + floor_dimensions=[make_floor_dimension(total_floor_area_m2=90.0, floor=0)], + )], + sap_heating=make_sap_heating( + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, main_fuel_type=26, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=code, + main_heating_category=2, sap_main_heating_code=102, + ), + ], + ), + ) + + # Act + type_1 = cert_to_inputs(_epc_with_control(2103)) + type_2 = cert_to_inputs(_epc_with_control(2106)) + type_3 = cert_to_inputs(_epc_with_control(2110)) + + # Assert + assert type_1.control_type == 1 + assert type_2.control_type == 2 + assert type_3.control_type == 3 + + def test_electric_storage_heater_space_heating_at_off_peak_rate() -> None: # Arrange — RdSAP convention: when the main heating is electric- # storage (code 401-409) or direct-electric (191-196), space heating diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 3c63c9ca..21302886 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -79,6 +79,12 @@ def _int_or_none(value: Any) -> Optional[int]: def _parse_thickness_mm(value: Any) -> Optional[int]: + """Parse a `wall_insulation_thickness` (or roof/floor) field. "NI" in + the RdSAP cert is treated as 0 mm: parity-tested on the 100-cert + sample, switching to None (cascade-50mm-default-for-present) over- + corrected because many "NI" certs are genuinely uninsulated. This + will be revisited once the cascade learns to use wall_insulation_type + as a stronger signal (Session B follow-up).""" if value is None: return None if isinstance(value, int):