Merge pull request #1308 from Hestia-Homes/fix/conservatory-age-band-sibling-mappers

Map non-separated conservatory on the 17.0/17.1/18.0/20.0.0 gov paths 🟩
This commit is contained in:
Daniel Roth 2026-06-24 17:19:23 +01:00 committed by GitHub
commit 1a37caa44d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 159 additions and 3 deletions

View file

@ -711,8 +711,13 @@ class EpcPropertyDataMapper:
roof_insulation_thickness=bp.roof_insulation_thickness,
sap_room_in_roof=None,
)
# RdSAP 10 §6.1 — exclude the glazed conservatory BP from the
# fabric loop; carried as `sap_conservatory` below. Mirrors the
# 19.0 / 21.0.1 path.
for bp in schema.sap_building_parts
if getattr(bp, "glazed_perimeter", None) is None
],
sap_conservatory=_api_sap_conservatory(schema.sap_building_parts),
)
@staticmethod
@ -1148,8 +1153,13 @@ class EpcPropertyDataMapper:
roof_insulation_thickness=bp.roof_insulation_thickness,
sap_room_in_roof=None,
)
# RdSAP 10 §6.1 — exclude the glazed conservatory BP from the
# fabric loop; carried as `sap_conservatory` below. Mirrors the
# 19.0 / 21.0.1 path.
for bp in schema.sap_building_parts
if getattr(bp, "glazed_perimeter", None) is None
],
sap_conservatory=_api_sap_conservatory(schema.sap_building_parts),
)
@staticmethod
@ -1371,8 +1381,13 @@ class EpcPropertyDataMapper:
else None
),
)
# RdSAP 10 §6.1 — exclude the glazed conservatory BP from the
# fabric loop; carried as `sap_conservatory` below. Mirrors the
# 19.0 / 21.0.1 path.
for bp in schema.sap_building_parts
if getattr(bp, "glazed_perimeter", None) is None
],
sap_conservatory=_api_sap_conservatory(schema.sap_building_parts),
)
@staticmethod
@ -1826,8 +1841,13 @@ class EpcPropertyDataMapper:
else None
),
)
# RdSAP 10 §6.1 — exclude the glazed conservatory BP from the
# fabric loop; carried as `sap_conservatory` below. Mirrors the
# 19.0 / 21.0.1 path.
for bp in schema.sap_building_parts
if getattr(bp, "glazed_perimeter", None) is None
],
sap_conservatory=_api_sap_conservatory(schema.sap_building_parts),
)
@staticmethod

View file

@ -2533,3 +2533,106 @@ class TestNonSeparatedConservatoryApiMirror19_0:
# Assert — §6.2: disregarded; no conservatory geometry.
assert epc.sap_conservatory is None
assert conservatory_geometry(epc) is None
class TestNonSeparatedConservatoryApiMirror20_0_0:
"""RdSAP 10 §6.1 — the same glazed-BP conservatory, on RdSAP-Schema-20.0.0.
Surfaced by the modelling_e2e triage: 5 type-4 certs (e.g. property 725213)
failed `NotNullViolation: construction_age_band` because the 20.0.0 schema
declared none of the four glazed fields, so the conservatory mapped to a
null-age fabric part. Mirrors the 19.0 / 21.0.1 split."""
def test_from_api_response_splits_out_conservatory_building_part(
self,
) -> None:
from datatypes.epc.domain.epc_property_data import SapConservatory
from domain.sap10_calculator.worksheet.conservatory import (
conservatory_geometry,
)
dwelling_part_count = len(
EpcPropertyDataMapper.from_api_response(load("20_0_0.json")).sap_building_parts
)
cert = load("20_0_0.json")
cert["conservatory_type"] = 4
cert["sap_building_parts"].append(
{
"floor_area": 12.0,
"room_height": 1,
"double_glazed": "Y",
"glazed_perimeter": 9.0,
}
)
# Act
epc = EpcPropertyDataMapper.from_api_response(cert)
# Assert — conservatory split out; the glazed BP is NOT a fabric part
# (so it never hits the NOT-NULL construction_age_band on persist).
assert epc.sap_conservatory == SapConservatory(
floor_area_m2=12.0,
glazed_perimeter_m=9.0,
double_glazed=True,
thermally_separated=False,
room_height_storeys=1.0,
)
assert len(epc.sap_building_parts) == dwelling_part_count
assert all(
bp.construction_age_band is not None for bp in epc.sap_building_parts
)
assert conservatory_geometry(epc) is not None
def test_separated_conservatory_lodges_no_glazed_building_part(self) -> None:
from domain.sap10_calculator.worksheet.conservatory import (
conservatory_geometry,
)
cert = load("20_0_0.json")
cert["conservatory_type"] = 2
# Act
epc = EpcPropertyDataMapper.from_api_response(cert)
# Assert — §6.2: disregarded; no conservatory geometry.
assert epc.sap_conservatory is None
assert conservatory_geometry(epc) is None
@pytest.mark.parametrize("fixture", ["17_0.json", "17_1.json", "18_0.json"])
def test_gov_mappers_split_non_separated_conservatory(fixture: str) -> None:
"""RdSAP 10 §6.1 — every gov mapper splits a non-separated conservatory
(conservatory_type=4) glazed BP out of the fabric parts and into
`sap_conservatory`, so it never persists as a null-age fabric part (the
modelling_e2e `construction_age_band` NOT-NULL triage). 19.0 / 20.0.0 /
21.0.1 are covered by their own classes; this pins the rest.
21.0.0 is intentionally excluded: unlike every other gov schema it never
adopted the ADR-0027 "every SapBuildingPart field is Optional" treatment, so
a sparse conservatory part fails `from_dict` on its required fields. Porting
it needs that broader strictness change tracked as a follow-up; no 21.0.0
conservatory cert surfaced in the triage."""
from datatypes.epc.domain.epc_property_data import SapConservatory
base_parts = len(
EpcPropertyDataMapper.from_api_response(load(fixture)).sap_building_parts
)
cert = load(fixture)
cert["conservatory_type"] = 4
cert["sap_building_parts"].append(
{"floor_area": 12.0, "room_height": 1, "double_glazed": "Y", "glazed_perimeter": 9.0}
)
# Act
epc = EpcPropertyDataMapper.from_api_response(cert)
# Assert — split out, not a (null-age) fabric part.
assert epc.sap_conservatory == SapConservatory(
floor_area_m2=12.0,
glazed_perimeter_m=9.0,
double_glazed=True,
thermally_separated=False,
room_height_storeys=1.0,
)
assert len(epc.sap_building_parts) == base_parts
assert all(bp.construction_age_band is not None for bp in epc.sap_building_parts)

View file

@ -120,6 +120,14 @@ class SapBuildingPart:
wall_insulation_thickness: Optional[str] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None
# RdSAP 10 §6.1 — non-separated conservatory glazed-BP fields
# ({double_glazed, floor_area, glazed_perimeter, room_height}); declared so
# `from_dict` keeps them and the mapper splits the BP into
# `EpcPropertyData.sap_conservatory`. Mirrors the 19.0 / 21.0.1 schema.
floor_area: Optional[Union[Measurement, int, float]] = None
room_height: Optional[Union[Measurement, int, float]] = None
double_glazed: Optional[str] = None
glazed_perimeter: Optional[Union[Measurement, int, float]] = None
@dataclass

View file

@ -106,6 +106,14 @@ class SapBuildingPart:
# Can be a thickness string (e.g. "100mm") or 0 for uninsulated flat roofs
roof_insulation_thickness: Optional[Union[str, int]] = None
wall_insulation_thickness: Optional[str] = None
# RdSAP 10 §6.1 — non-separated conservatory glazed-BP fields
# ({double_glazed, floor_area, glazed_perimeter, room_height}); declared so
# `from_dict` keeps them and the mapper splits the BP into
# `EpcPropertyData.sap_conservatory`. Mirrors the 19.0 / 21.0.1 schema.
floor_area: Optional[Union[Measurement, int, float]] = None
room_height: Optional[Union[Measurement, int, float]] = None
double_glazed: Optional[str] = None
glazed_perimeter: Optional[Union[Measurement, int, float]] = None
@dataclass

View file

@ -121,6 +121,14 @@ class SapBuildingPart:
wall_insulation_thickness: Optional[str] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None
# RdSAP 10 §6.1 — non-separated conservatory glazed-BP fields
# ({double_glazed, floor_area, glazed_perimeter, room_height}); declared so
# `from_dict` keeps them and the mapper splits the BP into
# `EpcPropertyData.sap_conservatory`. Mirrors the 19.0 / 21.0.1 schema.
floor_area: Optional[Union[Measurement, int, float]] = None
room_height: Optional[Union[Measurement, int, float]] = None
double_glazed: Optional[str] = None
glazed_perimeter: Optional[Union[Measurement, int, float]] = None
@dataclass

View file

@ -131,9 +131,12 @@ class SapBuildingPart:
# ADR-0027: 17/1000 certs lodge a CONSERVATORY-shaped building part carrying
# only {double_glazed, floor_area, glazed_perimeter, room_height} — none of
# the wall/roof/floor construction fields below. Following the 21.0.1
# precedent every field is Optional, so a conservatory part parses to an
# all-None SapBuildingPart; its thermal effect is carried separately by the
# cert-level conservatory_type, so the empty part flows through harmlessly.
# precedent every field is Optional. RdSAP 10 §6.1: the four glazed fields
# are declared so `from_dict` keeps them and the mapper can split this BP out
# into `EpcPropertyData.sap_conservatory` (a non-separated conservatory,
# `conservatory_type == 4`). Previously undeclared → dropped → the part
# mapped to an all-None fabric part that mis-scored AND violated the
# NOT-NULL `construction_age_band` column on persist (modelling_e2e triage).
identifier: Optional[str] = None
wall_dry_lined: Optional[str] = None
floor_heat_loss: Optional[int] = None
@ -153,6 +156,12 @@ class SapBuildingPart:
wall_insulation_thickness: Optional[str] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None
# RdSAP 10 §6.1 — non-separated conservatory glazed-BP fields (see the
# class docstring). Mirrors the 19.0 / 21.0.1 schema declaration.
floor_area: Optional[Union[Measurement, int, float]] = None
room_height: Optional[Union[Measurement, int, float]] = None
double_glazed: Optional[str] = None
glazed_perimeter: Optional[Union[Measurement, int, float]] = None
@dataclass