mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
community heating fuel + CHP fraction, alt-wall is_sheltered, wall insulation thermal conductivity, pv_diverter_present, measured cylinder volume, AP50 air permeability — all calculator-read, all silently dropped on save. FE columns now live; assert deep-equal round-trip and drop their coverage-guard allow-list entries so the guard enforces reconstruction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
242 lines
9.6 KiB
Python
242 lines
9.6 KiB
Python
"""Persistence round-trip fidelity for EPC Property Data (Slice 1, #1129).
|
|
|
|
The load-bearing risk of the ara_first_run rebuild: an EpcPropertyData mapped to
|
|
the epc_property tables, saved, reloaded and mapped back must reconstruct the
|
|
original object exactly. A failure here is either a missing column (a migration
|
|
the FE repo must make) or a mapper gap — either way we want it to fail loudly,
|
|
inside First Run, rather than be deferred to a later Refresh.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from sqlalchemy import Engine
|
|
from sqlmodel import Session
|
|
|
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
|
from repositories.epc.epc_postgres_repository import EpcPostgresRepository
|
|
|
|
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
|
|
|
|
|
|
def _load_epc(schema_dir: str) -> EpcPropertyData:
|
|
raw: dict[str, Any] = json.loads(
|
|
(_JSON_SAMPLES / schema_dir / "epc.json").read_text()
|
|
)
|
|
return EpcPropertyDataMapper.from_api_response(raw)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"schema_dir",
|
|
["RdSAP-Schema-21.0.0", "RdSAP-Schema-21.0.1"],
|
|
)
|
|
def test_epc_property_data_round_trips(schema_dir: str, db_engine: Engine) -> None:
|
|
# Arrange
|
|
original = _load_epc(schema_dir)
|
|
|
|
# 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
|
|
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_photovoltaic_arrays_round_trip(db_engine: Engine) -> None:
|
|
# SAP 10.2 Appendix M — a dwelling's solar PV arrays generate electricity that
|
|
# offsets demand, worth a large slice of the SAP score (≈12 points on an
|
|
# electrically-heated dwelling). `sap_energy_source.photovoltaic_arrays` had
|
|
# no table, so every array was dropped on save (persist != score). We inject
|
|
# two arrays (distinct, so ORDER is observable) onto a clean PV-free fixture
|
|
# so the ONLY thing that can break deep-equality is the array list itself.
|
|
from dataclasses import replace
|
|
|
|
from datatypes.epc.domain.epc_property_data import PhotovoltaicArray
|
|
|
|
# Arrange — a green fixture with no PV, plus two ordered arrays.
|
|
original = _load_epc("RdSAP-Schema-21.0.1")
|
|
assert original.sap_energy_source.photovoltaic_arrays is None, (
|
|
"fixture must start PV-free so the array list is the only variable"
|
|
)
|
|
arrays = [
|
|
PhotovoltaicArray(peak_power=3.24, pitch=3, overshading=1, orientation=3),
|
|
PhotovoltaicArray(peak_power=1.5, pitch=2, overshading=2, orientation=None),
|
|
]
|
|
original = replace(
|
|
original,
|
|
sap_energy_source=replace(
|
|
original.sap_energy_source, photovoltaic_arrays=arrays
|
|
),
|
|
)
|
|
|
|
# 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 — both arrays survive in order, deep-equal.
|
|
assert reloaded == original
|
|
|
|
|
|
def test_calculator_read_fields_round_trip(db_engine: Engine) -> None:
|
|
# Seven fields the SAP calculator reads (cert_to_inputs / heat_transmission)
|
|
# that had no DB column, so they were silently dropped on save (persist !=
|
|
# score). FE columns now exist; inject all seven onto a clean fixture and
|
|
# prove they survive the round-trip deep-equal.
|
|
from dataclasses import replace
|
|
|
|
# Arrange
|
|
original = _load_epc("RdSAP-Schema-21.0.1")
|
|
bp0 = original.sap_building_parts[0]
|
|
assert bp0.sap_alternative_wall_1 is not None, "fixture must carry an alt wall"
|
|
bp0 = replace(
|
|
bp0,
|
|
sap_alternative_wall_1=replace(bp0.sap_alternative_wall_1, is_sheltered=True),
|
|
wall_insulation_thermal_conductivity=35, # Union[int,str] → JSONB int
|
|
)
|
|
heating = original.sap_heating
|
|
mh0 = replace(
|
|
heating.main_heating_details[0],
|
|
community_heating_boiler_fuel_type=35,
|
|
community_heating_chp_fraction=0.35,
|
|
)
|
|
heating = replace(
|
|
heating,
|
|
main_heating_details=[mh0, *heating.main_heating_details[1:]],
|
|
cylinder_volume_measured_l=180,
|
|
)
|
|
original = replace(
|
|
original,
|
|
sap_building_parts=[bp0, *original.sap_building_parts[1:]],
|
|
sap_heating=heating,
|
|
sap_energy_source=replace(original.sap_energy_source, pv_diverter_present=True),
|
|
sap_ventilation=replace(
|
|
original.sap_ventilation, air_permeability_ap50_m3_h_m2=7.5
|
|
),
|
|
)
|
|
|
|
# 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 — all seven calculator-read fields survive, deep-equal.
|
|
assert reloaded == original
|
|
|
|
|
|
def test_floor_dimension_heat_loss_flags_round_trip(db_engine: Engine) -> None:
|
|
# SAP 10.2 §3.3 — `is_exposed_floor` and `is_above_partially_heated_space`
|
|
# are heat-loss flags on a `SapFloorDimension`: the calculator routes a floor
|
|
# over outside air / over a partially-heated space through a different
|
|
# U-value path. They had no `epc_floor_dimension` column, so a True flag
|
|
# round-tripped back to the `False` default and silently flipped the floor's
|
|
# heat loss (persist != score). We force both True on a clean fixture so the
|
|
# ONLY thing that can break deep-equality is these two flags.
|
|
from dataclasses import replace
|
|
|
|
# Arrange — a green fixture with a floor dimension, both flags forced True.
|
|
original = _load_epc("RdSAP-Schema-21.0.0")
|
|
assert original.sap_building_parts, "fixture must have a building part"
|
|
bp0 = original.sap_building_parts[0]
|
|
assert bp0.sap_floor_dimensions, "fixture building part must have a floor dimension"
|
|
dim0 = replace(
|
|
bp0.sap_floor_dimensions[0],
|
|
is_exposed_floor=True,
|
|
is_above_partially_heated_space=True,
|
|
)
|
|
bp0 = replace(bp0, sap_floor_dimensions=[dim0, *bp0.sap_floor_dimensions[1:]])
|
|
original = replace(
|
|
original, sap_building_parts=[bp0, *original.sap_building_parts[1:]]
|
|
)
|
|
|
|
# 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 — both flags survive the round-trip, deep-equal.
|
|
assert reloaded == original
|
|
|
|
|
|
def test_building_part_wall_insulation_thickness_preserves_int(
|
|
db_engine: Engine,
|
|
) -> None:
|
|
# SAP 10.2 §5.7/Table 8: when the API lodges
|
|
# `wall_insulation_thickness == "measured"`, the mapper resolves the
|
|
# value to an int mm. The `epc_building_part.wall_insulation_thickness`
|
|
# column must therefore preserve int vs str on round-trip (JSONB), like
|
|
# its `roof_insulation_thickness` sibling — a plain str column would
|
|
# round-trip the int 100 back as "100" and corrupt the Table 8 lookup.
|
|
from dataclasses import replace
|
|
|
|
# Arrange — take a green fixture and force the measured-int case.
|
|
original = _load_epc("RdSAP-Schema-21.0.0")
|
|
assert original.sap_building_parts, "fixture must have a building part"
|
|
bp0 = replace(original.sap_building_parts[0], wall_insulation_thickness=100)
|
|
original = replace(
|
|
original,
|
|
sap_building_parts=[bp0, *original.sap_building_parts[1:]],
|
|
)
|
|
|
|
# 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 int survives as an int, not the string "100".
|
|
assert reloaded is not None
|
|
value = reloaded.sap_building_parts[0].wall_insulation_thickness
|
|
assert value == 100
|
|
assert isinstance(value, int)
|