mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
8e1d30c97d
commit
f3baa51a9b
3 changed files with 67 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue