Round-trip the non-separated conservatory through persistence 🟩

Persist SapConservatory as five nullable conservatory_* columns on epc_property
(1:1 with the dwelling) and rebuild it in _compose, so the §6.1 fold survives
save -> reload -> score. Without this the scored (re-hydrated) EPC silently
dropped the conservatory (persist != score) — a latent gap shared with the
21.0.1 path. Adds a deep-equality round-trip test. ADR-0036.

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:05 +00:00
parent 56add97bd9
commit 03a0d9c1ca
3 changed files with 97 additions and 0 deletions

View file

@ -72,6 +72,16 @@ class EpcPropertyModel(SQLModel, table=True):
has_conservatory: Optional[bool] = Field(default=None)
has_heated_separate_conservatory: Optional[bool] = Field(default=None)
conservatory_type: Optional[int] = Field(default=None)
# RdSAP 10 §6.1 — geometry of a NON-SEPARATED conservatory folded into the
# dwelling (`EpcPropertyData.sap_conservatory`). 1:1 with the dwelling, so
# flat nullable columns rather than a child table; all None when there is no
# non-separated conservatory. Must round-trip — scoring reads the reloaded
# picture, and a dropped conservatory silently disregards it (ADR-0036).
conservatory_floor_area_m2: Optional[float] = Field(default=None)
conservatory_glazed_perimeter_m: Optional[float] = Field(default=None)
conservatory_double_glazed: Optional[bool] = Field(default=None)
conservatory_thermally_separated: Optional[bool] = Field(default=None)
conservatory_room_height_storeys: Optional[float] = Field(default=None)
# Counts
door_count: int
@ -247,6 +257,29 @@ class EpcPropertyModel(SQLModel, table=True):
has_conservatory=data.has_conservatory,
has_heated_separate_conservatory=data.has_heated_separate_conservatory,
conservatory_type=data.conservatory_type,
conservatory_floor_area_m2=(
data.sap_conservatory.floor_area_m2
if data.sap_conservatory
else None
),
conservatory_glazed_perimeter_m=(
data.sap_conservatory.glazed_perimeter_m
if data.sap_conservatory
else None
),
conservatory_double_glazed=(
data.sap_conservatory.double_glazed if data.sap_conservatory else None
),
conservatory_thermally_separated=(
data.sap_conservatory.thermally_separated
if data.sap_conservatory
else None
),
conservatory_room_height_storeys=(
data.sap_conservatory.room_height_storeys
if data.sap_conservatory
else None
),
door_count=data.door_count,
wet_rooms_count=data.wet_rooms_count,
extensions_count=data.extensions_count,

View file

@ -21,6 +21,7 @@ from datatypes.epc.domain.epc_property_data import (
RenewableHeatIncentive,
SapAlternativeWall,
SapBuildingPart,
SapConservatory,
SapEnergySource,
SapFlatDetails,
SapFloorDimension,
@ -507,6 +508,7 @@ class EpcPostgresRepository(EpcRepository):
conservatory_type=p.conservatory_type,
has_conservatory=p.has_conservatory,
has_heated_separate_conservatory=p.has_heated_separate_conservatory,
sap_conservatory=self._to_conservatory(p),
blocked_chimneys_count=p.blocked_chimneys_count,
energy_rating_average=p.energy_rating_average,
current_energy_efficiency_band=(
@ -853,6 +855,32 @@ class EpcPostgresRepository(EpcRepository):
),
)
@private
def _to_conservatory(self, p: EpcPropertyModel) -> Optional[SapConservatory]:
# RdSAP 10 §6.1 — rebuild the non-separated conservatory the mapper
# split out of the fabric parts. Presence is signalled by the floor
# area (the §6.1 fold always sets all five geometry columns together);
# all None when there is no non-separated conservatory (ADR-0036).
if p.conservatory_floor_area_m2 is None:
return None
return SapConservatory(
floor_area_m2=p.conservatory_floor_area_m2,
glazed_perimeter_m=_require(
p.conservatory_glazed_perimeter_m, "conservatory_glazed_perimeter_m"
),
double_glazed=_require(
p.conservatory_double_glazed, "conservatory_double_glazed"
),
thermally_separated=_require(
p.conservatory_thermally_separated,
"conservatory_thermally_separated",
),
room_height_storeys=_require(
p.conservatory_room_height_storeys,
"conservatory_room_height_storeys",
),
)
@private
def _to_ventilation(self, p: EpcPropertyModel) -> Optional[SapVentilation]:
if not p.ventilation_present:

View file

@ -50,6 +50,42 @@ def test_epc_property_data_round_trips(schema_dir: str, db_engine: Engine) -> No
assert reloaded == original
def test_non_separated_conservatory_round_trips(db_engine: Engine) -> None:
# RdSAP 10 §6.1 — a non-separated conservatory (conservatory_type=4) maps to
# `EpcPropertyData.sap_conservatory` (the glazed BP is split out of the
# fabric parts). Persistence must round-trip it: scoring reads the reloaded
# picture, so a dropped `sap_conservatory` silently disregards the
# conservatory (persist != score). We inject the conservatory onto the
# known-clean 21.0.1 sample so the ONLY thing that can break deep-equality
# is `sap_conservatory` itself.
# Arrange — known-clean base + a non-separated double-glazed conservatory.
raw: dict[str, Any] = json.loads(
(_JSON_SAMPLES / "RdSAP-Schema-21.0.1" / "epc.json").read_text()
)
raw["conservatory_type"] = 4
raw["sap_building_parts"].append(
{
"floor_area": 12.0,
"room_height": 1,
"double_glazed": "Y",
"glazed_perimeter": 9.0,
}
)
original = EpcPropertyDataMapper.from_api_response(raw)
assert original.sap_conservatory is not None, "mapper must split the conservatory"
# Act
with Session(db_engine) as session:
epc_property_id = EpcPostgresRepository(session).save(original)
session.commit()
with Session(db_engine) as session:
reloaded = EpcPostgresRepository(session).get(epc_property_id)
# Assert — the conservatory survives the round-trip, deep-equal.
assert reloaded == original
def test_building_part_wall_insulation_thickness_preserves_int(
db_engine: Engine,
) -> None: