Fall back to nested has_hot_water_cylinder for full-SAP certs

A SAP-Schema-17.0 full-SAP cohort cert (8265-7433-3220-9736-7902) omits the
top-level `has_hot_water_cylinder` and lodges it only under `sap_heating`. The
required top-level field on SapSchema17_1 made `from_dict` raise and skip the
cert.

Fix: make the top-level field Optional, and have the full-SAP mapper
(`from_sap_schema_17_1`, also used by 17.0/18.0.0 via delegation) fall back to
`sap_heating.has_hot_water_cylinder`. RdSAP mappers are unchanged (they keep the
top-level required field; their SapHeating has no such attribute).

Cert now maps + calculates (sap 76). Regression tests for the nested true/false
fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-24 10:43:18 +00:00
parent 22cb47a280
commit 18063a2d7f
3 changed files with 41 additions and 2 deletions

View file

@ -735,7 +735,11 @@ class EpcPropertyDataMapper:
postcode=schema.postcode,
post_town=schema.post_town,
total_floor_area_m2=float(schema.total_floor_area),
has_hot_water_cylinder=schema.has_hot_water_cylinder == "true",
has_hot_water_cylinder=(
schema.has_hot_water_cylinder
or schema.sap_heating.has_hot_water_cylinder
)
== "true",
has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true",
solar_water_heating=False,
extensions_count=0,

View file

@ -77,6 +77,39 @@ class TestFromSapSchema17_1Tracer:
assert result.total_floor_area_m2 == 68.0
class TestFullSapHasHotWaterCylinderFallback:
"""Some full-SAP certs (e.g. SAP-Schema-17.0 cert 8265-7433-3220-9736-7902)
omit the top-level `has_hot_water_cylinder` and lodge it only under
`sap_heating`. The required top-level field made `from_dict` raise; it is now
optional and the mapper falls back to the nested value."""
def test_top_level_omitted_falls_back_to_sap_heating(self) -> None:
# Arrange — drop the top-level flag, keep the nested one ("true").
data = load("sap_17_1.json")
data.pop("has_hot_water_cylinder", None)
data["sap_heating"]["has_hot_water_cylinder"] = "true"
# Act
schema = from_dict(SapSchema17_1, data)
result = EpcPropertyDataMapper.from_sap_schema_17_1(schema)
# Assert — resolved from sap_heating, not crashed
assert result.has_hot_water_cylinder is True
def test_top_level_omitted_nested_false_resolves_false(self) -> None:
# Arrange
data = load("sap_17_1.json")
data.pop("has_hot_water_cylinder", None)
data["sap_heating"]["has_hot_water_cylinder"] = "false"
# Act
schema = from_dict(SapSchema17_1, data)
result = EpcPropertyDataMapper.from_sap_schema_17_1(schema)
# Assert
assert result.has_hot_water_cylinder is False
class TestFromSapSchema17_1FabricDescriptions:
"""Slice 3 (D4): the measured-U fabric descriptions flow through to
epc.walls/floors/roofs so the engine's u_wall/u_floor/u_roof can parse

View file

@ -184,7 +184,6 @@ class SapSchema17_1:
postcode: str
post_town: str
inspection_date: str
has_hot_water_cylinder: str
has_fixed_air_conditioning: str
roofs: List[EnergyElement]
walls: List[EnergyElement]
@ -197,3 +196,6 @@ class SapSchema17_1:
# 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
# Some 17.0 full-SAP certs omit the top-level flag and lodge it only under
# sap_heating; the mapper falls back to sap_heating.has_hot_water_cylinder.
has_hot_water_cylinder: Optional[str] = None