diff --git a/infrastructure/postgres/property_baseline_performance_table.py b/infrastructure/postgres/property_baseline_performance_table.py index 89019478b..ecbb3722e 100644 --- a/infrastructure/postgres/property_baseline_performance_table.py +++ b/infrastructure/postgres/property_baseline_performance_table.py @@ -48,12 +48,16 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): # Postgres won't coerce to the enum. Bind the native enums explicitly # (``create_type=False``: the types already exist). Values stay plain # strings on the domain side (``Epc(...)`` / the RebaselineReason Literal). - lodged_sap_score: int - lodged_epc_band: str = Field( - sa_column=Column(SAEnum(*_EPC_BANDS, name="epc", create_type=False), nullable=False) + # The four lodged_* columns are nullable as a unit: a predicted Property has no + # lodged cert, so it has no Lodged Performance (ADR-0004 amendment, #1361). They + # are written all-set or all-NULL; lodged_sap_score is the read discriminator. + lodged_sap_score: Optional[int] = None + lodged_epc_band: Optional[str] = Field( + default=None, + sa_column=Column(SAEnum(*_EPC_BANDS, name="epc", create_type=False), nullable=True), ) - lodged_co2_emissions_t_per_yr: float - lodged_primary_energy_intensity_kwh_per_m2_yr: int + lodged_co2_emissions_t_per_yr: Optional[float] = None + lodged_primary_energy_intensity_kwh_per_m2_yr: Optional[int] = None effective_sap_score: int effective_epc_band: str = Field( @@ -102,12 +106,17 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): def from_domain( cls, baseline: PropertyBaselinePerformance, property_id: int ) -> "PropertyBaselinePerformanceModel": + # A predicted Property has no Lodged Performance — the four lodged_* columns + # are all NULL then (ADR-0004 amendment, #1361). + lodged = baseline.lodged model = cls( property_id=property_id, - lodged_sap_score=baseline.lodged.sap_score, - lodged_epc_band=baseline.lodged.epc_band.value, - lodged_co2_emissions_t_per_yr=baseline.lodged.co2_emissions, - lodged_primary_energy_intensity_kwh_per_m2_yr=baseline.lodged.primary_energy_intensity, + lodged_sap_score=lodged.sap_score if lodged is not None else None, + lodged_epc_band=lodged.epc_band.value if lodged is not None else None, + lodged_co2_emissions_t_per_yr=lodged.co2_emissions if lodged is not None else None, + lodged_primary_energy_intensity_kwh_per_m2_yr=( + lodged.primary_energy_intensity if lodged is not None else None + ), effective_sap_score=baseline.effective.sap_score, effective_epc_band=baseline.effective.epc_band.value, effective_co2_emissions_t_per_yr=baseline.effective.co2_emissions, @@ -139,12 +148,7 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): def to_domain(self) -> PropertyBaselinePerformance: return PropertyBaselinePerformance( - lodged=Performance( - sap_score=self.lodged_sap_score, - epc_band=Epc(self.lodged_epc_band), - co2_emissions=self.lodged_co2_emissions_t_per_yr, - primary_energy_intensity=self.lodged_primary_energy_intensity_kwh_per_m2_yr, - ), + lodged=self._read_lodged(), effective=Performance( sap_score=self.effective_sap_score, epc_band=Epc(self.effective_epc_band), @@ -157,6 +161,23 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): bill=self._read_bill(), ) + def _read_lodged(self) -> Optional[Performance]: + """The Lodged half, or None for a predicted Property whose four lodged_* + columns are NULL (ADR-0004 amendment, #1361). They are written as a unit + (`from_domain`), so lodged_sap_score is the discriminator and the other + three are non-null alongside it.""" + if self.lodged_sap_score is None: + return None + assert self.lodged_epc_band is not None + assert self.lodged_co2_emissions_t_per_yr is not None + assert self.lodged_primary_energy_intensity_kwh_per_m2_yr is not None + return Performance( + sap_score=self.lodged_sap_score, + epc_band=Epc(self.lodged_epc_band), + co2_emissions=self.lodged_co2_emissions_t_per_yr, + primary_energy_intensity=self.lodged_primary_energy_intensity_kwh_per_m2_yr, + ) + def _read_bill(self) -> Optional[Bill]: """Reconstruct the Bill from the ``bill_*`` columns. The total is the not-None discriminator: a persisted bill always sets it, so its absence diff --git a/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py b/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py index de6de8f4d..7edb0a151 100644 --- a/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py +++ b/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py @@ -83,6 +83,38 @@ def test_resaving_baseline_for_a_property_replaces_rather_than_duplicating( assert loaded == rerun +def _predicted_baseline() -> PropertyBaselinePerformance: + """A predicted Property's baseline: no Lodged Performance (no cert to read one + off), only the Effective half established (ADR-0004 amendment, #1361).""" + return PropertyBaselinePerformance( + lodged=None, + effective=Performance( + sap_score=57, epc_band=Epc.D, co2_emissions=1.5, primary_energy_intensity=160 + ), + rebaseline_reason="physical_state_changed", + space_heating_kwh=4200.0, + water_heating_kwh=1600.0, + ) + + +def test_predicted_baseline_round_trips_with_no_lodged_half(db_engine: Engine) -> None: + # Arrange — a predicted Property has no Lodged Performance; the four lodged_* + # columns persist as NULL and reconstruct as a None lodged half. + baseline = _predicted_baseline() + with Session(db_engine) as session: + PropertyBaselinePostgresRepository(session).save(baseline, property_id=13) + session.commit() + + # Act + with Session(db_engine) as session: + loaded = PropertyBaselinePostgresRepository(session).get_for_property(13) + + # Assert — the lodged half round-trips as None; the Effective half is intact. + assert loaded == baseline + assert loaded is not None + assert loaded.lodged is None + + def test_get_for_property_returns_none_when_absent(db_engine: Engine) -> None: # Arrange / Act with Session(db_engine) as session: