Map non-separated conservatory on the gov-API RdSAP-19.0 path 🟩

Mirror the merged 21.0.1 fix (d501535c): declare the four glazed conservatory
fields on the 19.0 SapBuildingPart so from_dict stops dropping them, exclude the
glazed BP from the fabric loop, and carry it as EpcPropertyData.sap_conservatory
(§6.1). Fixes cert 718138 (conservatory_type=4) mis-scoring as a fabric part and
failing to persist on the NOT-NULL construction_age_band.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-24 13:56:03 +00:00
parent f6ec96fbcd
commit 56add97bd9
3 changed files with 89 additions and 0 deletions

View file

@ -1568,8 +1568,14 @@ class EpcPropertyDataMapper:
else None
),
)
# RdSAP 10 §6.1 — exclude the glazed conservatory BP from the
# fabric loop; it is carried as `sap_conservatory` below and
# billed by the §6.1 cascade (window/rooflight/floor), not as
# a dwelling building part. Mirrors the 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

@ -2433,3 +2433,76 @@ class TestNonSeparatedConservatoryApiMirror:
# Assert — §6.2: disregarded; no conservatory geometry.
assert epc.sap_conservatory is None
assert conservatory_geometry(epc) is None
class TestNonSeparatedConservatoryApiMirror19_0:
"""RdSAP 10 §6.1 — the same glazed-BP conservatory the 21.0.1 mapper
handles is also lodged on RdSAP-Schema-19.0 (repro cert 718138). The four
glazed fields were undeclared on the 19.0 `SapBuildingPart`, so `from_dict`
dropped them: the conservatory mapped to a fabric-less building part that
mis-scored and could not persist (NOT-NULL `construction_age_band`). The
19.0 mapper now mirrors the 21.0.1 split.
Unlike 21.0.1, the 19.0 mapper keeps the **lodged** `total_floor_area`
scalar (a real 19.0 cert already lodges the non-separated conservatory in
it 718138: dwelling 140.23 + conservatory 9.71 = 149.94 lodged 150), so
appending a conservatory does NOT change `total_floor_area_m2`. The §6.1
floor-area fold reaches the score through the calculator's dimensions, not
through the scalar."""
def test_from_api_response_splits_out_conservatory_building_part(
self,
) -> None:
# Arrange — the 19.0 dwelling plus a non-separated double-glazed
# conservatory glazed BP.
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("19_0.json")).sap_building_parts
)
cert = load("19_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.
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
# §6.1 fold is active (surfaces derived by the shared cascade).
assert conservatory_geometry(epc) is not None
def test_separated_conservatory_lodges_no_glazed_building_part(self) -> None:
# Arrange — a separated conservatory (type 2/3) lodges NO glazed BP;
# the dwelling is unchanged.
from domain.sap10_calculator.worksheet.conservatory import (
conservatory_geometry,
)
cert = load("19_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

View file

@ -122,6 +122,16 @@ 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 — a NON-SEPARATED conservatory (`conservatory_type == 4`) is
# lodged by the gov API as a glazed "building part" carrying ONLY these four
# fields (no fabric, no floor dimensions). Previously undeclared → dropped by
# `from_dict`, so the conservatory was silently lost on the 19.0 path. The
# mapper splits this BP out into `EpcPropertyData.sap_conservatory`. Mirrors
# the 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