mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
parent
56add97bd9
commit
03a0d9c1ca
3 changed files with 97 additions and 0 deletions
|
|
@ -72,6 +72,16 @@ class EpcPropertyModel(SQLModel, table=True):
|
||||||
has_conservatory: Optional[bool] = Field(default=None)
|
has_conservatory: Optional[bool] = Field(default=None)
|
||||||
has_heated_separate_conservatory: Optional[bool] = Field(default=None)
|
has_heated_separate_conservatory: Optional[bool] = Field(default=None)
|
||||||
conservatory_type: Optional[int] = 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
|
# Counts
|
||||||
door_count: int
|
door_count: int
|
||||||
|
|
@ -247,6 +257,29 @@ class EpcPropertyModel(SQLModel, table=True):
|
||||||
has_conservatory=data.has_conservatory,
|
has_conservatory=data.has_conservatory,
|
||||||
has_heated_separate_conservatory=data.has_heated_separate_conservatory,
|
has_heated_separate_conservatory=data.has_heated_separate_conservatory,
|
||||||
conservatory_type=data.conservatory_type,
|
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,
|
door_count=data.door_count,
|
||||||
wet_rooms_count=data.wet_rooms_count,
|
wet_rooms_count=data.wet_rooms_count,
|
||||||
extensions_count=data.extensions_count,
|
extensions_count=data.extensions_count,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from datatypes.epc.domain.epc_property_data import (
|
||||||
RenewableHeatIncentive,
|
RenewableHeatIncentive,
|
||||||
SapAlternativeWall,
|
SapAlternativeWall,
|
||||||
SapBuildingPart,
|
SapBuildingPart,
|
||||||
|
SapConservatory,
|
||||||
SapEnergySource,
|
SapEnergySource,
|
||||||
SapFlatDetails,
|
SapFlatDetails,
|
||||||
SapFloorDimension,
|
SapFloorDimension,
|
||||||
|
|
@ -507,6 +508,7 @@ class EpcPostgresRepository(EpcRepository):
|
||||||
conservatory_type=p.conservatory_type,
|
conservatory_type=p.conservatory_type,
|
||||||
has_conservatory=p.has_conservatory,
|
has_conservatory=p.has_conservatory,
|
||||||
has_heated_separate_conservatory=p.has_heated_separate_conservatory,
|
has_heated_separate_conservatory=p.has_heated_separate_conservatory,
|
||||||
|
sap_conservatory=self._to_conservatory(p),
|
||||||
blocked_chimneys_count=p.blocked_chimneys_count,
|
blocked_chimneys_count=p.blocked_chimneys_count,
|
||||||
energy_rating_average=p.energy_rating_average,
|
energy_rating_average=p.energy_rating_average,
|
||||||
current_energy_efficiency_band=(
|
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
|
@private
|
||||||
def _to_ventilation(self, p: EpcPropertyModel) -> Optional[SapVentilation]:
|
def _to_ventilation(self, p: EpcPropertyModel) -> Optional[SapVentilation]:
|
||||||
if not p.ventilation_present:
|
if not p.ventilation_present:
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,42 @@ def test_epc_property_data_round_trips(schema_dir: str, db_engine: Engine) -> No
|
||||||
assert reloaded == original
|
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(
|
def test_building_part_wall_insulation_thickness_preserves_int(
|
||||||
db_engine: Engine,
|
db_engine: Engine,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue