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. The auth credential required by the New EPC API; stored in the `EPC_AUTH_TOKEN` environment variable.
_Avoid_: API key, auth token, secret _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 ## Relationships
- A **Property** represents a single physical dwelling for modelling; identified by `(portfolio_id, UPRN)` or `(portfolio_id, landlord_property_id)`. - 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) _sap_17_1_building_part(bp, i)
for i, bp in enumerate(schema.sap_building_parts) for i, bp in enumerate(schema.sap_building_parts)
], ],
sap_heating=SapHeating( # D6: full-SAP heating — translate the differing field names onto
instantaneous_wwhrs=InstantaneousWwhrs(), # the domain SapHeating the calculator consumes (PCDB efficiency via
main_heating_details=[], # main_heating_index_number; water cascade via water_heating_code).
has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", sap_heating=_sap_17_1_heating(schema),
),
sap_energy_source=SapEnergySource( sap_energy_source=SapEnergySource(
mains_gas=False, mains_gas=False,
meter_type="", 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: def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int:
"""D3: pick the habitable-room count whose Table 27 fraction is closest to """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 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 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: class TestFromSapSchema17_1Perimeter:
"""Slice 5 (D1): full SAP lodges no heat-loss perimeter; derive it from the """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 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 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 @dataclass
class EnergyElement: class EnergyElement:
"""A fabric/system element with its lodged description. On full SAP the """A fabric/system element with its lodged description. On full SAP the
@ -121,6 +156,7 @@ class SapSchema17_1:
floors: List[EnergyElement] floors: List[EnergyElement]
sap_opening_types: List[SapOpeningType] sap_opening_types: List[SapOpeningType]
sap_building_parts: List[SapBuildingPart] sap_building_parts: List[SapBuildingPart]
sap_heating: SapHeating
# measured living-room area (m²); the engine consumes it via a back-solved # measured living-room area (m²); the engine consumes it via a back-solved
# habitable_rooms_count (Table 27). Optional — 100% present in the corpus. # habitable_rooms_count (Table 27). Optional — 100% present in the corpus.
living_area: Optional[Union[int, float]] = None 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 `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 yet consume (full SAP vs RdSAP). Those cases are expected to FAIL at
the mapper until support lands see `test_real_cert_sap_score`. 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 schema: str
@ -63,6 +69,7 @@ class RealCertExpectation:
hot_water_kwh_per_yr: Optional[float] = None hot_water_kwh_per_yr: Optional[float] = None
co2_kg_per_yr: Optional[float] = None co2_kg_per_yr: Optional[float] = None
unsupported_schema: bool = False unsupported_schema: bool = False
known_bug_xfail: Optional[str] = None
# Absolute tolerance for float pins — matches the Elmhurst cohort. # Absolute tolerance for float pins — matches the Elmhurst cohort.
@ -93,6 +100,28 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
sap_score=83, sap_score=83,
unsupported_schema=True, 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 """Wrap a case as a pytest param, marking unsupported-schema certs as
strict xfails (they raise `ValueError` at the mapper until full-SAP strict xfails (they raise `ValueError` at the mapper until full-SAP
support exists; strict so the marker can't silently outlive the gap).""" support exists; strict so the marker can't silently outlive the gap)."""
marks = ( marks = []
[ if exp.unsupported_schema:
marks.append(
pytest.mark.xfail( pytest.mark.xfail(
reason="full-SAP (non-RdSAP) schema not yet supported by the mapper", reason="full-SAP (non-RdSAP) schema not yet supported by the mapper",
raises=ValueError, raises=ValueError,
strict=True, strict=True,
) )
] )
if exp.unsupported_schema elif exp.known_bug_xfail is not None:
else [] marks.append(
) pytest.mark.xfail(reason=exp.known_bug_xfail, strict=True)
)
return pytest.param(exp, id=exp.sample, marks=marks) return pytest.param(exp, id=exp.sample, marks=marks)