Fix has_hot_water_cylinder derivation to lodge the string convention, not a bool

The prior commit derived a Python bool (`cylinder_size != 1`), but
`from_rdsap_schema_17_1` reads the field via `schema.has_hot_water_cylinder
== "true"` — every real 15.0/16.x fixture lodges the lowercase string
"true"/"false", not a JSON boolean. A bare bool compares False against
that string check either way, so the derivation silently mapped every
cylinder-present cert to has_hot_water_cylinder=False too.

Caught by the requested true-branch test (mutating sap_16_2.json, which
lodges cylinder_size=2/has_hot_water_cylinder="true", to omit the field)
— it failed under the original bool-typed fix. Now emits "true"/"false"
strings to match the lodged convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-10 11:58:07 +00:00
parent 4f9bec7a4a
commit c0b4fd1e6a
2 changed files with 25 additions and 4 deletions

View file

@ -4000,19 +4000,25 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]:
# Some 16.x/15.0 certs (e.g. cert 9568-3034-6211-6089-5960, UPRN
# 100010349400, SAP-Schema-15.0) lodge `sap_heating.cylinder_size` but
# omit the top-level `has_hot_water_cylinder` boolean entirely —
# omit the top-level `has_hot_water_cylinder` field entirely —
# RdSapSchema17_1 requires both as independent fields. Unlike
# `multiple_glazed_proportion` above, this is NOT a guess: RdSAP 10
# §10.5's `cylinder_size` code 1 means "no cylinder" by definition
# (see `_CYLINDER_SIZE_CODE_TO_LITRES` / cert_to_inputs.py:5902), the
# exact same fact `has_hot_water_cylinder` encodes — confirmed 1:1
# across every real 15.0/16.x fixture that lodges both fields
# (cylinder_size == 1 <-> has_hot_water_cylinder == false, every
# other code <-> true). Derive rather than leave the cert unmappable.
# (cylinder_size == 1 <-> has_hot_water_cylinder == "false", every
# other code <-> "true"). Derive rather than leave the cert
# unmappable. Lodged as the lowercase string "true"/"false" (RdSAP
# convention every real fixture uses — `from_rdsap_schema_17_1`
# reads it via `schema.has_hot_water_cylinder == "true"`, a bare
# Python bool would silently compare False either way).
if "has_hot_water_cylinder" not in d:
cylinder_size = heating.get("cylinder_size")
if isinstance(cylinder_size, int):
d["has_hot_water_cylinder"] = cylinder_size != 1
d["has_hot_water_cylinder"] = (
"true" if cylinder_size != 1 else "false"
)
for bp in _dicts(d.get("sap_building_parts")):
for fd in _dicts(bp.get("sap_floor_dimensions")):

View file

@ -107,3 +107,18 @@ class TestFromSapSchema15_0:
assert epc.has_hot_water_cylinder is False
assert epc.sap_heating.cylinder_size == 1
def test_derives_has_hot_water_cylinder_true_from_a_sized_cylinder(
self,
) -> None:
# Mirror of the above for the other branch: a lodged size code other
# than 1 (here 2 = Normal/110L, per sap_16_2.json) means a cylinder
# IS present, so an omitted `has_hot_water_cylinder` must derive
# True — not just default-False by omission.
data = load("sap_16_2.json")
assert data["sap_heating"]["cylinder_size"] == 2
del data["has_hot_water_cylinder"]
epc = EpcPropertyDataMapper.from_api_response(data)
assert epc.has_hot_water_cylinder is True