diff --git a/infrastructure/postgres/epc_property_table.py b/infrastructure/postgres/epc_property_table.py index c41d797d..0c207e0d 100644 --- a/infrastructure/postgres/epc_property_table.py +++ b/infrastructure/postgres/epc_property_table.py @@ -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, diff --git a/repositories/epc/epc_postgres_repository.py b/repositories/epc/epc_postgres_repository.py index faa86323..d44938e6 100644 --- a/repositories/epc/epc_postgres_repository.py +++ b/repositories/epc/epc_postgres_repository.py @@ -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: diff --git a/tests/repositories/epc/test_epc_round_trip.py b/tests/repositories/epc/test_epc_round_trip.py index 8233bda3..f32e40b1 100644 --- a/tests/repositories/epc/test_epc_round_trip.py +++ b/tests/repositories/epc/test_epc_round_trip.py @@ -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: