slice S-B5: main_heating_control code → SAP control type

Maps the Table 9 main_heating_control code to SAP control type 1/2/3:
codes 2101-2104 = type 1, 2105-2109 = type 2, 2110+ = type 3. Default
remains type 2 when code is missing or unrecognised.

Two other fixes tried-and-reverted in this slice based on the 100-cert
parity probe:
  - NI-thickness → None (the "wall insulated but thickness unknown,
    use 50mm row" path): over-corrected in aggregate because many "NI"
    certs are genuinely uninsulated. Reverted to legacy NI→0 with a
    note to revisit once wall_insulation_type is used as a stronger
    signal.
  - boiler-age efficiency rescue (cat 1/2, A-F → 0.74, K-M → 0.85):
    same issue — stacked with NI fix it over-shot, on its own it gave
    marginal MAE without bias improvement. Dropped pending further
    investigation.

100-cert parity probe:
  MAE 5.72 → 5.65   (-0.07; control-type-only is a small net win)
  RMSE 7.58 → 7.48  (-0.10)
  bias +1.20 → +1.13

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 14:37:44 +00:00
parent 8e1d30c97d
commit f3baa51a9b
3 changed files with 67 additions and 6 deletions

View file

@ -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,

View file

@ -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

View file

@ -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):