Map full-SAP heating systems onto the domain SapHeating model 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-15 14:18:01 +00:00
parent 125ff6f4dd
commit cb4d080da2
5 changed files with 162 additions and 11 deletions

View file

@ -350,6 +350,30 @@ _Avoid_: new API, current API
The auth credential required by the New EPC API; stored in the `EPC_AUTH_TOKEN` environment variable.
_Avoid_: API key, auth token, secret
## Team
Who's who on the project, so commit authorship and review history read
correctly. The git author names below map to people as follows.
**Khalim Conn-Kowlessar**:
CTO and the technical decision-maker — the boss. Treat his calls as
authoritative when guidance conflicts. Git author: `Khalim
Conn-Kowlessar <kconnkowlessar@gmail.com>` (also commits as `KhalimCK`,
same email).
**Daniel Roth ("Dan")**:
Software engineer. Git author: `Daniel Roth <daniel_roth@hotmail.co.uk>`
(also `Daniel Roth <36244509+dancafc@users.noreply.github.com>`).
**Jun-te Kim ("Junte")**:
Software engineer. Git author: `Jun-te Kim <junte.kim@mealcraft.com>`
(also `<juntekim@googlemail.com>` and `<39764191+kimjunte@users.noreply.github.com>`).
**Michael Duong**:
Contractor (software). Git author: `Michael Duong <michael123ster@gmail.com>`
(also commits as `<michaelduong22@gmail.com>` and from local machine
addresses `<michaelduong@Michaels-MacBook-Pro.local>`, `<michael@11s-MacBook.local>`).
## Relationships
- A **Property** represents a single physical dwelling for modelling; identified by `(portfolio_id, UPRN)` or `(portfolio_id, landlord_property_id)`.

View file

@ -710,11 +710,10 @@ class EpcPropertyDataMapper:
_sap_17_1_building_part(bp, i)
for i, bp in enumerate(schema.sap_building_parts)
],
sap_heating=SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[],
has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true",
),
# D6: full-SAP heating — translate the differing field names onto
# the domain SapHeating the calculator consumes (PCDB efficiency via
# main_heating_index_number; water cascade via water_heating_code).
sap_heating=_sap_17_1_heating(schema),
sap_energy_source=SapEnergySource(
mains_gas=False,
meter_type="",
@ -2505,6 +2504,42 @@ _SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = {
}
def _sap_17_1_heating(schema: SapSchema17_1) -> SapHeating:
"""D6: map full-SAP `sap_heating` onto the domain `SapHeating`. Field names
differ from RdSAP `is_flue_fan_present``fan_flue_present`,
`main_heating_flue_type``boiler_flue_type`, `water_fuel_type`
`water_heating_fuel`; has_fghrs isn't lodged (default False)."""
sh = schema.sap_heating
return SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
has_fixed_air_conditioning=sh.has_fixed_air_conditioning == "true",
water_heating_code=sh.water_heating_code,
water_heating_fuel=sh.water_fuel_type,
main_heating_details=[
MainHeatingDetail(
has_fghrs=False,
main_fuel_type=d.main_fuel_type,
heat_emitter_type=d.heat_emitter_type,
emitter_temperature=d.emitter_temperature,
main_heating_control=d.main_heating_control,
fan_flue_present=d.is_flue_fan_present == "true",
boiler_flue_type=d.main_heating_flue_type,
central_heating_pump_age=d.central_heating_pump_age,
main_heating_index_number=d.main_heating_index_number,
main_heating_number=d.main_heating_number,
main_heating_category=d.main_heating_category,
main_heating_fraction=(
int(d.main_heating_fraction)
if d.main_heating_fraction is not None
else None
),
main_heating_data_source=d.main_heating_data_source,
)
for d in sh.main_heating_details
],
)
def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int:
"""D3: pick the habitable-room count whose Table 27 fraction is closest to
the measured living_area/total_floor_area, so the engine's Table-27 path

View file

@ -232,6 +232,31 @@ class TestFromSapSchema17_1LivingArea:
assert self._map("sap_17_1_flat.json").habitable_rooms_count == 3
class TestFromSapSchema17_1Heating:
"""Slice D6: full-SAP sap_heating (differing field names) maps onto the
domain SapHeating + MainHeatingDetail the calculator consumes."""
@pytest.fixture
def sample(self) -> EpcPropertyData:
schema = from_dict(SapSchema17_1, load("sap_17_1.json"))
return EpcPropertyDataMapper.from_sap_schema_17_1(schema)
def test_main_heating_detail_mapped(self, sample: EpcPropertyData) -> None:
details = sample.sap_heating.main_heating_details
assert len(details) == 1
assert details[0].main_fuel_type == 1
# PCDB boiler index drives the efficiency lookup — must survive.
assert details[0].main_heating_index_number == 17929
def test_flue_fan_field_renamed(self, sample: EpcPropertyData) -> None:
# full SAP lodges is_flue_fan_present; domain field is fan_flue_present.
assert sample.sap_heating.main_heating_details[0].fan_flue_present is True
def test_water_heating_carried(self, sample: EpcPropertyData) -> None:
assert sample.sap_heating.water_heating_code == 901
assert sample.sap_heating.water_heating_fuel == 1
class TestFromSapSchema17_1Perimeter:
"""Slice 5 (D1): full SAP lodges no heat-loss perimeter; derive it from the
measured exposed-wall areas (wall_type 1/2/3) ÷ Σ storey-heights, with party

View file

@ -88,6 +88,41 @@ class SapBuildingPart:
building_part_number: Optional[int] = None
@dataclass
class SapMainHeatingDetail:
"""One main-heating system. Field names differ from RdSAP (e.g.
`is_flue_fan_present` vs `fan_flue_present`, `main_heating_flue_type` vs
`boiler_flue_type`); the mapper translates them."""
main_fuel_type: int
heat_emitter_type: int
emitter_temperature: Union[int, str]
main_heating_control: int
main_heating_number: Optional[int] = None
main_heating_category: Optional[int] = None
main_heating_fraction: Optional[Union[int, float]] = None
main_heating_data_source: Optional[int] = None
main_heating_index_number: Optional[int] = None
main_heating_flue_type: Optional[int] = None
is_flue_fan_present: Optional[str] = None
central_heating_pump_age: Optional[int] = None
load_or_weather_compensation: Optional[int] = None
@dataclass
class SapHeating:
"""Heating + hot-water systems. `water_fuel_type`/`water_heating_code` drive
the hot-water cascade; `main_heating_index_number` keys the PCDB efficiency
lookup."""
main_heating_details: List[SapMainHeatingDetail] = field(default_factory=list)
water_fuel_type: Optional[int] = None
water_heating_code: Optional[int] = None
has_hot_water_cylinder: Optional[str] = None
has_fixed_air_conditioning: Optional[str] = None
secondary_heating_category: Optional[int] = None
@dataclass
class EnergyElement:
"""A fabric/system element with its lodged description. On full SAP the
@ -121,6 +156,7 @@ class SapSchema17_1:
floors: List[EnergyElement]
sap_opening_types: List[SapOpeningType]
sap_building_parts: List[SapBuildingPart]
sap_heating: SapHeating
# measured living-room area (m²); the engine consumes it via a back-solved
# habitable_rooms_count (Table 27). Optional — 100% present in the corpus.
living_area: Optional[Union[int, float]] = None

View file

@ -52,6 +52,12 @@ class RealCertExpectation:
`unsupported_schema=True` marks a cert whose schema the mapper can't
yet consume (full SAP vs RdSAP). Those cases are expected to FAIL at
the mapper until support lands see `test_real_cert_sap_score`.
`known_bug_xfail` marks a cert whose `sap_score` is the verified
ground truth (e.g. reproduced in Elmhurst on identical inputs) but
which the engine doesn't yet hit because of a localised, documented
calculator bug. Strict xfail: when the bug is fixed the test flips to
a failure, prompting removal of the marker.
"""
schema: str
@ -63,6 +69,7 @@ class RealCertExpectation:
hot_water_kwh_per_yr: Optional[float] = None
co2_kg_per_yr: Optional[float] = None
unsupported_schema: bool = False
known_bug_xfail: Optional[str] = None
# Absolute tolerance for float pins — matches the Elmhurst cohort.
@ -93,6 +100,28 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
sap_score=83,
unsupported_schema=True,
),
# UPRN 10002468137 → cert 0215-2818-7357-9703-2145. RdSAP-Schema-17.1,
# all-electric high-heat-retention storage heaters on Economy 7, solid-
# brick uninsulated end-terrace. Ground truth is Elmhurst RdSAP10 = 60,
# reproduced on identical inputs (summary + full SAP 10.2 worksheet saved
# alongside: elmhurst_summary.pdf / elmhurst_worksheet.pdf). The engine
# produces 62 — a +2 over-rating localised to OFF-PEAK WATER HEATING:
# the worksheet (lines 243-246) prices the 7-hour off-peak immersion at a
# Table 13 split (19.36% @ 15.29p high + 80.64% @ 5.5p low), but the engine
# prices 100% at the 5.5p low rate, under-costing the bill (£595.68 vs
# £629.67) → lower ECF (2.69 vs 2.84) → SAP 62 not 60. (Space heating 100%
# off-peak IS correct for storage heaters — the worksheet agrees.) Strict
# xfail until the off-peak water-heating rate split is implemented.
RealCertExpectation(
schema="RdSAP-Schema-17.1",
sample="uprn_10002468137",
cert_num="0215-2818-7357-9703-2145",
sap_score=60,
known_bug_xfail=(
"off-peak (7-hour) water-heating high/low rate split not applied — "
"engine prices 100% at the low rate; see elmhurst_worksheet.pdf (243-246)"
),
),
)
@ -100,17 +129,19 @@ def _as_param(exp: RealCertExpectation) -> object:
"""Wrap a case as a pytest param, marking unsupported-schema certs as
strict xfails (they raise `ValueError` at the mapper until full-SAP
support exists; strict so the marker can't silently outlive the gap)."""
marks = (
[
marks = []
if exp.unsupported_schema:
marks.append(
pytest.mark.xfail(
reason="full-SAP (non-RdSAP) schema not yet supported by the mapper",
raises=ValueError,
strict=True,
)
]
if exp.unsupported_schema
else []
)
)
elif exp.known_bug_xfail is not None:
marks.append(
pytest.mark.xfail(reason=exp.known_bug_xfail, strict=True)
)
return pytest.param(exp, id=exp.sample, marks=marks)