Merge pull request #1381 from Hestia-Homes/fix/rdsap-17-0-sap-heating-drops

Fix RdSAP-17.0 mapper dropping the rest of the lodged sap_heating block
This commit is contained in:
Jun-te Kim 2026-07-01 13:21:27 +01:00 committed by GitHub
commit f1299cc554
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 96 additions and 6 deletions

View file

@ -620,8 +620,13 @@ class EpcPropertyDataMapper:
MainHeatingDetail(
has_fghrs=d.has_fghrs == "Y",
main_fuel_type=d.main_fuel_type,
boiler_flue_type=None,
fan_flue_present=None,
# Preserve the lodged flue/pump/PCDB fields, matching the
# 19.0/20.0/… paths. The 17.0 schema *does* lodge all of
# these (RdSapSchema17_0.MainHeatingDetail); hardcoding
# them to None silently dropped a PCDB main-heating index
# (efficiency lookup) and the flue/pump SAP adjustments.
boiler_flue_type=d.boiler_flue_type,
fan_flue_present=d.fan_flue_present == "Y",
heat_emitter_type=d.heat_emitter_type,
emitter_temperature=d.emitter_temperature,
main_heating_number=d.main_heating_number,
@ -629,9 +634,9 @@ class EpcPropertyDataMapper:
main_heating_category=d.main_heating_category,
main_heating_fraction=d.main_heating_fraction,
sap_main_heating_code=d.sap_main_heating_code,
central_heating_pump_age=None,
central_heating_pump_age=d.central_heating_pump_age,
main_heating_data_source=d.main_heating_data_source,
main_heating_index_number=None,
main_heating_index_number=d.main_heating_index_number,
)
for d in schema.sap_heating.main_heating_details
],
@ -642,7 +647,15 @@ class EpcPropertyDataMapper:
water_heating_fuel=schema.sap_heating.water_heating_fuel,
immersion_heating_type=schema.sap_heating.immersion_heating_type,
cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type,
cylinder_thermostat=None,
# Preserve the lodged cylinder thermostat + insulation thickness,
# matching the 19.0/20.0/… paths. Both are lodged by the 17.0
# schema; hardcoding them to None (a) read as "no cylinder
# thermostat" so the water-heating worksheet over-applied its
# ×1.3 penalty and `recommend_cylinder_thermostat` could fire on
# a dwelling that already has one, and (b) discarded the measured
# insulation thickness so the cylinder-loss cascade fell back to
# age-band defaults.
cylinder_thermostat=schema.sap_heating.cylinder_thermostat,
# Preserve the lodged secondary heating from the 17.0 sap_heating
# block, matching every other API path (19.0/20.0/…). Hardcoding
# these to None silently dropped a lodged FIXED secondary (SAP
@ -654,7 +667,7 @@ class EpcPropertyDataMapper:
schema.sap_heating.secondary_heating_type,
),
secondary_heating_type=schema.sap_heating.secondary_heating_type,
cylinder_insulation_thickness_mm=None,
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
),
# ADR-0028: 990/1000 omit sap_windows -> synthesised from the
# glazed_area band + TFA via the shared reduced-field core. The 10

View file

@ -0,0 +1,77 @@
"""Regression: the RdSAP-Schema-17.0 mapper path must preserve the whole lodged
`sap_heating` block, like every other API path (19.0/20.0/).
`from_rdsap_schema_17_0` was an older copy that hardcoded a cluster of lodged
`sap_heating` fields to None while the 19.0+ paths read them from
`schema.sap_heating`:
* MainHeatingDetail.boiler_flue_type / fan_flue_present / central_heating_pump_age
/ main_heating_index_number (the last is the PCDB efficiency index)
* SapHeating.cylinder_thermostat gates the water-heating ×1.3 penalty
and `recommend_cylinder_thermostat`
* SapHeating.cylinder_insulation_thickness drives the cylinder-loss cascade
The 17.0 schema lodges all of them (see RdSapSchema17_0), so the hardcode silently
degraded both the SAP water-heating figure and the hot-water recommendations for
17.0 certs. This guard makes sure the block stays mapped. (The `secondary_*_type`
pair of the same block is covered by its own dedicated secondary-heating test.)
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# A real RdSAP-Schema-17.0 cert that lodges a cylinder thermostat, a measured
# cylinder-insulation thickness and flue/pump fields on its main heating.
_FIXTURE = Path("tests/fixtures/epc_prediction/PE71NT/cert-26619d8e7f8e.json")
# A PCDB main-heating index the fixture happens not to carry; injected to prove
# the field flows through (it used to be dropped to None unconditionally).
_MAIN_HEATING_INDEX = 4321
def _load_17_0_cert() -> dict[str, Any]:
data: dict[str, Any] = json.loads(_FIXTURE.read_text())
assert data["schema_type"] == "RdSAP-Schema-17.0"
return data
def test_17_0_path_preserves_lodged_cylinder_fields() -> None:
# Arrange — the real cert lodges a cylinder thermostat + measured thickness.
data = _load_17_0_cert()
assert data["sap_heating"]["cylinder_thermostat"] == "Y"
assert data["sap_heating"]["cylinder_insulation_thickness"] == 25
# Act
epc = EpcPropertyDataMapper.from_api_response(data)
# Assert — both survive the 17.0 path now (they used to be dropped to None),
# so the water-heating worksheet and cylinder-thermostat recommendation see
# the real dwelling.
assert epc.sap_heating.cylinder_thermostat == "Y"
assert epc.sap_heating.cylinder_insulation_thickness_mm == 25
def test_17_0_path_preserves_lodged_main_heating_detail_fields() -> None:
# Arrange — the fixture lodges flue/pump on the main heating; inject a PCDB
# index (absent on this cert) to cover the efficiency-lookup field too.
data = _load_17_0_cert()
detail = data["sap_heating"]["main_heating_details"][0]
assert detail["boiler_flue_type"] == 2
assert detail["fan_flue_present"] == "Y"
assert detail["central_heating_pump_age"] == 1
detail["main_heating_index_number"] = _MAIN_HEATING_INDEX
# Act
epc = EpcPropertyDataMapper.from_api_response(data)
# Assert — the whole MainHeatingDetail block carries through (was all None).
mapped = epc.sap_heating.main_heating_details[0]
assert mapped.boiler_flue_type == 2
assert mapped.fan_flue_present is True
assert mapped.central_heating_pump_age == 1
assert mapped.main_heating_index_number == _MAIN_HEATING_INDEX