mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +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_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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue