mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Final slice of ADR-0012: collapse the per-property read round-trips a batch made (Baseline hydrated ~8 queries x 30 properties one at a time) into a handful of per-table IN queries. - EpcPostgresRepository: extracted a shared `_compose(rows)` from `get` (the windows + floor-dim fetches are now passed in, not fetched inline), so both `get` and the new `get_for_properties(property_ids)` build EpcPropertyData from pre-fetched rows. `get_for_properties` fetches each child table once (`WHERE epc_property_id IN ...`), groups in memory, and composes — load-whole per ADR-0002. - PropertyRepository.get_many(property_ids) -> Properties: one query for the property rows + one bulk EPC hydration, composed in input order. - BaselineOrchestrator / IngestionOrchestrator read the batch via get_many instead of N x get. - Ports + fakes gain the bulk methods. The #1129 round-trip fidelity test stays green (the compose extraction is behaviour-preserving). New tests: bulk hydration correctness + round-trips are constant w.r.t. batch size (one-per-table, proven by query count). 123 pass; pyright strict clean; AAA. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
846 lines
35 KiB
Python
846 lines
35 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from datetime import date
|
|
from typing import Optional, Protocol, TypeVar
|
|
|
|
from sqlmodel import Session, col, delete, select
|
|
|
|
from datatypes.epc.domain.epc import Epc
|
|
from datatypes.epc.domain.epc_property_data import (
|
|
Addendum,
|
|
BuildingPartIdentifier,
|
|
EnergyElement,
|
|
EpcPropertyData,
|
|
InstantaneousWwhrs,
|
|
MainHeatingDetail,
|
|
PhotovoltaicSupply,
|
|
PhotovoltaicSupplyNoneOrNoDetails,
|
|
PvBatteries,
|
|
PvBattery,
|
|
RenewableHeatIncentive,
|
|
SapAlternativeWall,
|
|
SapBuildingPart,
|
|
SapEnergySource,
|
|
SapFlatDetails,
|
|
SapFloorDimension,
|
|
SapHeating,
|
|
SapRoomInRoof,
|
|
SapVentilation,
|
|
SapWindow,
|
|
ShowerOutlet,
|
|
ShowerOutlets,
|
|
WindowsTransmissionDetails,
|
|
WindowTransmissionDetails,
|
|
WindTurbineDetails,
|
|
)
|
|
from infrastructure.postgres.epc_property_table import (
|
|
EpcBuildingPartModel,
|
|
EpcEnergyElementModel,
|
|
EpcFlatDetailsModel,
|
|
EpcFloorDimensionModel,
|
|
EpcMainHeatingDetailModel,
|
|
EpcPropertyEnergyPerformanceModel,
|
|
EpcPropertyModel,
|
|
EpcRenewableHeatIncentiveModel,
|
|
EpcWindowModel,
|
|
)
|
|
from repositories.epc.epc_repository import EpcRepository
|
|
from utilities.private import private
|
|
|
|
_T = TypeVar("_T")
|
|
|
|
|
|
def _require(value: Optional[_T], field: str) -> _T:
|
|
if value is None:
|
|
raise ValueError(f"epc_property row is missing required field {field!r}")
|
|
return value
|
|
|
|
|
|
class _HasEpcPropertyId(Protocol):
|
|
epc_property_id: int
|
|
|
|
|
|
_RowT = TypeVar("_RowT", bound=_HasEpcPropertyId)
|
|
|
|
|
|
def _group_by_epc(rows: Sequence[_RowT]) -> dict[int, list[_RowT]]:
|
|
grouped: dict[int, list[_RowT]] = {}
|
|
for row in rows:
|
|
grouped.setdefault(row.epc_property_id, []).append(row)
|
|
return grouped
|
|
|
|
|
|
class EpcPostgresRepository(EpcRepository):
|
|
"""Maps EpcPropertyData to/from the epc_property parent row + child tables.
|
|
|
|
Round-trip fidelity over the persisted projection is pinned by the Slice-1
|
|
round-trip test (Hestia-Homes/Model#1129). Fields the schema does not yet
|
|
store (see docs/migrations/epc-property-round-trip-fidelity.md §2) reconstruct
|
|
as their dataclass defaults — tracked as follow-up migrations.
|
|
"""
|
|
|
|
def __init__(self, session: Session) -> None:
|
|
self._session = session
|
|
|
|
def save(
|
|
self,
|
|
data: EpcPropertyData,
|
|
property_id: Optional[int] = None,
|
|
portfolio_id: Optional[int] = None,
|
|
) -> int:
|
|
# Idempotent on property_id: a re-run replaces the property's EPC graph
|
|
# rather than duplicating it (ADR-0012). Anonymous saves (no property_id)
|
|
# always insert.
|
|
if property_id is not None:
|
|
self._delete_for_property(property_id)
|
|
parent = EpcPropertyModel.from_epc_property_data(
|
|
data, property_id=property_id, portfolio_id=portfolio_id
|
|
)
|
|
self._session.add(parent)
|
|
self._session.flush()
|
|
epc_property_id = _require(parent.id, "id")
|
|
|
|
self._session.add(
|
|
EpcPropertyEnergyPerformanceModel.from_epc_property_data(
|
|
data, epc_property_id=epc_property_id
|
|
)
|
|
)
|
|
for detail in data.sap_heating.main_heating_details:
|
|
self._session.add(
|
|
EpcMainHeatingDetailModel.from_domain(detail, epc_property_id)
|
|
)
|
|
for part in data.sap_building_parts:
|
|
bp = EpcBuildingPartModel.from_domain(part, epc_property_id)
|
|
self._session.add(bp)
|
|
self._session.flush()
|
|
bp_id = _require(bp.id, "epc_building_part.id")
|
|
for dim in part.sap_floor_dimensions:
|
|
self._session.add(EpcFloorDimensionModel.from_domain(dim, bp_id))
|
|
for window in data.sap_windows:
|
|
self._session.add(EpcWindowModel.from_domain(window, epc_property_id))
|
|
|
|
for element_type, elements in (
|
|
("roof", data.roofs),
|
|
("wall", data.walls),
|
|
("floor", data.floors),
|
|
("main_heating", data.main_heating),
|
|
):
|
|
for el in elements:
|
|
self._session.add(
|
|
EpcEnergyElementModel.from_domain(el, element_type, epc_property_id)
|
|
)
|
|
for el, element_type in (
|
|
(data.window, "window"),
|
|
(data.lighting, "lighting"),
|
|
(data.hot_water, "hot_water"),
|
|
(data.secondary_heating, "secondary_heating"),
|
|
(data.main_heating_controls, "main_heating_controls"),
|
|
):
|
|
if el is not None:
|
|
self._session.add(
|
|
EpcEnergyElementModel.from_domain(el, element_type, epc_property_id)
|
|
)
|
|
|
|
if data.sap_flat_details is not None:
|
|
self._session.add(
|
|
EpcFlatDetailsModel.from_domain(data.sap_flat_details, epc_property_id)
|
|
)
|
|
if data.renewable_heat_incentive is not None:
|
|
self._session.add(
|
|
EpcRenewableHeatIncentiveModel.from_domain(
|
|
data.renewable_heat_incentive, epc_property_id
|
|
)
|
|
)
|
|
return epc_property_id
|
|
|
|
def _delete_for_property(self, property_id: int) -> None:
|
|
"""Remove the property's existing EPC graph (parent + child tables) so a
|
|
re-save replaces rather than duplicates (ADR-0012)."""
|
|
epc_ids = [
|
|
i
|
|
for i in self._session.exec(
|
|
select(EpcPropertyModel.id).where(
|
|
EpcPropertyModel.property_id == property_id
|
|
)
|
|
).all()
|
|
if i is not None
|
|
]
|
|
if not epc_ids:
|
|
return
|
|
part_ids = [
|
|
i
|
|
for i in self._session.exec(
|
|
select(EpcBuildingPartModel.id).where(
|
|
col(EpcBuildingPartModel.epc_property_id).in_(epc_ids)
|
|
)
|
|
).all()
|
|
if i is not None
|
|
]
|
|
if part_ids:
|
|
self._session.exec( # type: ignore[call-overload]
|
|
delete(EpcFloorDimensionModel).where(
|
|
col(EpcFloorDimensionModel.epc_building_part_id).in_(part_ids)
|
|
)
|
|
)
|
|
for child in (
|
|
EpcPropertyEnergyPerformanceModel,
|
|
EpcEnergyElementModel,
|
|
EpcMainHeatingDetailModel,
|
|
EpcBuildingPartModel,
|
|
EpcWindowModel,
|
|
EpcFlatDetailsModel,
|
|
EpcRenewableHeatIncentiveModel,
|
|
):
|
|
self._session.exec( # type: ignore[call-overload]
|
|
delete(child).where(col(child.epc_property_id).in_(epc_ids))
|
|
)
|
|
self._session.exec( # type: ignore[call-overload]
|
|
delete(EpcPropertyModel).where(col(EpcPropertyModel.id).in_(epc_ids))
|
|
)
|
|
|
|
def get_for_property(self, property_id: int) -> Optional[EpcPropertyData]:
|
|
row = self._session.exec(
|
|
select(EpcPropertyModel)
|
|
.where(EpcPropertyModel.property_id == property_id)
|
|
.order_by(EpcPropertyModel.id) # type: ignore[arg-type]
|
|
).first()
|
|
if row is None or row.id is None:
|
|
return None
|
|
return self.get(row.id)
|
|
|
|
def get_for_properties(
|
|
self, property_ids: list[int]
|
|
) -> dict[int, EpcPropertyData]:
|
|
"""Bulk-hydrate a batch's EPCs in a handful of per-table IN queries
|
|
(ADR-0012), not N x per-property. Load-whole per ADR-0002."""
|
|
if not property_ids:
|
|
return {}
|
|
parents = self._session.exec(
|
|
select(EpcPropertyModel)
|
|
.where(col(EpcPropertyModel.property_id).in_(property_ids))
|
|
.order_by(EpcPropertyModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
parent_by_property: dict[int, EpcPropertyModel] = {}
|
|
for parent in parents:
|
|
if parent.property_id is not None and parent.id is not None:
|
|
parent_by_property.setdefault(parent.property_id, parent)
|
|
epc_ids = [p.id for p in parent_by_property.values() if p.id is not None]
|
|
if not epc_ids:
|
|
return {}
|
|
|
|
perf_by = {
|
|
r.epc_property_id: r
|
|
for r in self._session.exec(
|
|
select(EpcPropertyEnergyPerformanceModel).where(
|
|
col(EpcPropertyEnergyPerformanceModel.epc_property_id).in_(epc_ids)
|
|
)
|
|
).all()
|
|
}
|
|
flat_by = {
|
|
r.epc_property_id: r
|
|
for r in self._session.exec(
|
|
select(EpcFlatDetailsModel).where(
|
|
col(EpcFlatDetailsModel.epc_property_id).in_(epc_ids)
|
|
)
|
|
).all()
|
|
}
|
|
rhi_by = {
|
|
r.epc_property_id: r
|
|
for r in self._session.exec(
|
|
select(EpcRenewableHeatIncentiveModel).where(
|
|
col(EpcRenewableHeatIncentiveModel.epc_property_id).in_(epc_ids)
|
|
)
|
|
).all()
|
|
}
|
|
elements_by = _group_by_epc(
|
|
self._session.exec(
|
|
select(EpcEnergyElementModel)
|
|
.where(col(EpcEnergyElementModel.epc_property_id).in_(epc_ids))
|
|
.order_by(EpcEnergyElementModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
)
|
|
heating_by = _group_by_epc(
|
|
self._session.exec(
|
|
select(EpcMainHeatingDetailModel)
|
|
.where(col(EpcMainHeatingDetailModel.epc_property_id).in_(epc_ids))
|
|
.order_by(EpcMainHeatingDetailModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
)
|
|
parts_by = _group_by_epc(
|
|
self._session.exec(
|
|
select(EpcBuildingPartModel)
|
|
.where(col(EpcBuildingPartModel.epc_property_id).in_(epc_ids))
|
|
.order_by(EpcBuildingPartModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
)
|
|
windows_by = _group_by_epc(
|
|
self._session.exec(
|
|
select(EpcWindowModel)
|
|
.where(col(EpcWindowModel.epc_property_id).in_(epc_ids))
|
|
.order_by(EpcWindowModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
)
|
|
part_ids = [
|
|
bp.id
|
|
for parts in parts_by.values()
|
|
for bp in parts
|
|
if bp.id is not None
|
|
]
|
|
floor_dims_by_part = self._floor_dims_by_part(part_ids)
|
|
|
|
result: dict[int, EpcPropertyData] = {}
|
|
for property_id, parent in parent_by_property.items():
|
|
epc_id = _require(parent.id, "id")
|
|
result[property_id] = self._compose(
|
|
p=parent,
|
|
perf=perf_by.get(epc_id),
|
|
elements=elements_by.get(epc_id, []),
|
|
heating_rows=heating_by.get(epc_id, []),
|
|
part_rows=parts_by.get(epc_id, []),
|
|
floor_dims_by_part=floor_dims_by_part,
|
|
window_rows=windows_by.get(epc_id, []),
|
|
flat_row=flat_by.get(epc_id),
|
|
rhi_row=rhi_by.get(epc_id),
|
|
)
|
|
return result
|
|
|
|
def _floor_dims_by_part(
|
|
self, part_ids: list[int]
|
|
) -> dict[int, list[EpcFloorDimensionModel]]:
|
|
if not part_ids:
|
|
return {}
|
|
rows = self._session.exec(
|
|
select(EpcFloorDimensionModel)
|
|
.where(col(EpcFloorDimensionModel.epc_building_part_id).in_(part_ids))
|
|
.order_by(EpcFloorDimensionModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
grouped: dict[int, list[EpcFloorDimensionModel]] = {}
|
|
for row in rows:
|
|
grouped.setdefault(row.epc_building_part_id, []).append(row)
|
|
return grouped
|
|
|
|
def get(self, epc_property_id: int) -> EpcPropertyData:
|
|
p = self._session.get(EpcPropertyModel, epc_property_id)
|
|
if p is None:
|
|
raise ValueError(f"epc_property {epc_property_id} not found")
|
|
perf = self._session.exec(
|
|
select(EpcPropertyEnergyPerformanceModel).where(
|
|
EpcPropertyEnergyPerformanceModel.epc_property_id == epc_property_id
|
|
)
|
|
).first()
|
|
elements = list(
|
|
self._session.exec(
|
|
select(EpcEnergyElementModel)
|
|
.where(EpcEnergyElementModel.epc_property_id == epc_property_id)
|
|
.order_by(EpcEnergyElementModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
)
|
|
heating_rows = list(
|
|
self._session.exec(
|
|
select(EpcMainHeatingDetailModel)
|
|
.where(EpcMainHeatingDetailModel.epc_property_id == epc_property_id)
|
|
.order_by(EpcMainHeatingDetailModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
)
|
|
part_rows = list(
|
|
self._session.exec(
|
|
select(EpcBuildingPartModel)
|
|
.where(EpcBuildingPartModel.epc_property_id == epc_property_id)
|
|
.order_by(EpcBuildingPartModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
)
|
|
flat_row = self._session.exec(
|
|
select(EpcFlatDetailsModel).where(
|
|
EpcFlatDetailsModel.epc_property_id == epc_property_id
|
|
)
|
|
).first()
|
|
rhi_row = self._session.exec(
|
|
select(EpcRenewableHeatIncentiveModel).where(
|
|
EpcRenewableHeatIncentiveModel.epc_property_id == epc_property_id
|
|
)
|
|
).first()
|
|
window_rows = self._windows(epc_property_id)
|
|
floor_dims_by_part = self._floor_dims_by_part(
|
|
[bp.id for bp in part_rows if bp.id is not None]
|
|
)
|
|
return self._compose(
|
|
p=p,
|
|
perf=perf,
|
|
elements=elements,
|
|
heating_rows=heating_rows,
|
|
part_rows=part_rows,
|
|
floor_dims_by_part=floor_dims_by_part,
|
|
window_rows=window_rows,
|
|
flat_row=flat_row,
|
|
rhi_row=rhi_row,
|
|
)
|
|
|
|
def _compose(
|
|
self,
|
|
*,
|
|
p: EpcPropertyModel,
|
|
perf: Optional[EpcPropertyEnergyPerformanceModel],
|
|
elements: list[EpcEnergyElementModel],
|
|
heating_rows: list[EpcMainHeatingDetailModel],
|
|
part_rows: list[EpcBuildingPartModel],
|
|
floor_dims_by_part: dict[int, list[EpcFloorDimensionModel]],
|
|
window_rows: list[EpcWindowModel],
|
|
flat_row: Optional[EpcFlatDetailsModel],
|
|
rhi_row: Optional[EpcRenewableHeatIncentiveModel],
|
|
) -> EpcPropertyData:
|
|
def _elements(element_type: str) -> list[EnergyElement]:
|
|
return [self._to_energy_element(e) for e in elements if e.element_type == element_type]
|
|
|
|
def _single(element_type: str) -> Optional[EnergyElement]:
|
|
found = _elements(element_type)
|
|
return found[0] if found else None
|
|
|
|
return EpcPropertyData(
|
|
dwelling_type=p.dwelling_type,
|
|
inspection_date=date.fromisoformat(p.inspection_date),
|
|
tenure=p.tenure,
|
|
transaction_type=p.transaction_type,
|
|
address_line_1=_require(p.address_line_1, "address_line_1"),
|
|
postcode=_require(p.postcode, "postcode"),
|
|
post_town=_require(p.post_town, "post_town"),
|
|
roofs=_elements("roof"),
|
|
walls=_elements("wall"),
|
|
floors=_elements("floor"),
|
|
main_heating=_elements("main_heating"),
|
|
door_count=p.door_count,
|
|
sap_heating=self._to_sap_heating(p, heating_rows),
|
|
sap_windows=[self._to_window(w) for w in window_rows],
|
|
sap_energy_source=self._to_energy_source(p),
|
|
sap_building_parts=[
|
|
self._to_building_part(
|
|
bp, floor_dims_by_part.get(bp.id, []) if bp.id is not None else []
|
|
)
|
|
for bp in part_rows
|
|
],
|
|
solar_water_heating=p.solar_water_heating,
|
|
has_hot_water_cylinder=p.has_hot_water_cylinder,
|
|
has_fixed_air_conditioning=p.has_fixed_air_conditioning,
|
|
wet_rooms_count=p.wet_rooms_count,
|
|
extensions_count=p.extensions_count,
|
|
heated_rooms_count=p.heated_rooms_count,
|
|
open_chimneys_count=p.open_chimneys_count,
|
|
habitable_rooms_count=p.habitable_rooms_count,
|
|
insulated_door_count=p.insulated_door_count,
|
|
cfl_fixed_lighting_bulbs_count=p.cfl_fixed_lighting_bulbs_count,
|
|
led_fixed_lighting_bulbs_count=p.led_fixed_lighting_bulbs_count,
|
|
incandescent_fixed_lighting_bulbs_count=p.incandescent_fixed_lighting_bulbs_count,
|
|
total_floor_area_m2=p.total_floor_area_m2,
|
|
assessment_type=p.assessment_type,
|
|
sap_version=p.sap_version,
|
|
uprn=p.uprn,
|
|
status=p.status,
|
|
window=_single("window"),
|
|
lighting=_single("lighting"),
|
|
hot_water=_single("hot_water"),
|
|
secondary_heating=_single("secondary_heating"),
|
|
main_heating_controls=_single("main_heating_controls"),
|
|
schema_type=p.schema_type,
|
|
schema_versions_original=p.schema_versions_original,
|
|
report_type=p.report_type,
|
|
report_reference=p.report_reference,
|
|
uprn_source=p.uprn_source,
|
|
address_line_2=p.address_line_2,
|
|
region_code=p.region_code,
|
|
country_code=p.country_code,
|
|
built_form=p.built_form,
|
|
property_type=p.property_type,
|
|
pressure_test=p.pressure_test,
|
|
language_code=p.language_code,
|
|
completion_date=(
|
|
date.fromisoformat(p.completion_date) if p.completion_date else None
|
|
),
|
|
registration_date=(
|
|
date.fromisoformat(p.registration_date)
|
|
if p.registration_date
|
|
else None
|
|
),
|
|
measurement_type=p.measurement_type,
|
|
conservatory_type=p.conservatory_type,
|
|
has_conservatory=p.has_conservatory,
|
|
has_heated_separate_conservatory=p.has_heated_separate_conservatory,
|
|
blocked_chimneys_count=p.blocked_chimneys_count,
|
|
energy_rating_average=p.energy_rating_average,
|
|
current_energy_efficiency_band=(
|
|
Epc(perf.current_energy_efficiency_band)
|
|
if perf and perf.current_energy_efficiency_band
|
|
else None
|
|
),
|
|
environmental_impact_current=(
|
|
perf.environmental_impact_current if perf else None
|
|
),
|
|
heating_cost_current=perf.heating_cost_current if perf else None,
|
|
co2_emissions_current=perf.co2_emissions_current if perf else None,
|
|
energy_consumption_current=(
|
|
perf.energy_consumption_current if perf else None
|
|
),
|
|
energy_rating_current=perf.energy_rating_current if perf else None,
|
|
lighting_cost_current=perf.lighting_cost_current if perf else None,
|
|
hot_water_cost_current=perf.hot_water_cost_current if perf else None,
|
|
insulated_door_u_value=p.insulated_door_u_value,
|
|
mechanical_ventilation=p.mechanical_ventilation,
|
|
percent_draughtproofed=p.percent_draughtproofed,
|
|
heating_cost_potential=perf.heating_cost_potential if perf else None,
|
|
co2_emissions_potential=perf.co2_emissions_potential if perf else None,
|
|
energy_consumption_potential=(
|
|
perf.energy_consumption_potential if perf else None
|
|
),
|
|
energy_rating_potential=perf.energy_rating_potential if perf else None,
|
|
lighting_cost_potential=perf.lighting_cost_potential if perf else None,
|
|
hot_water_cost_potential=perf.hot_water_cost_potential if perf else None,
|
|
environmental_impact_potential=(
|
|
perf.environmental_impact_potential if perf else None
|
|
),
|
|
potential_energy_efficiency_band=(
|
|
Epc(perf.potential_energy_efficiency_band)
|
|
if perf and perf.potential_energy_efficiency_band
|
|
else None
|
|
),
|
|
draughtproofed_door_count=p.draughtproofed_door_count,
|
|
mechanical_vent_duct_type=p.mechanical_vent_duct_type,
|
|
windows_transmission_details=(
|
|
WindowsTransmissionDetails(
|
|
u_value=p.windows_transmission_u_value,
|
|
data_source=_require(
|
|
p.windows_transmission_data_source,
|
|
"windows_transmission_data_source",
|
|
),
|
|
solar_transmittance=_require(
|
|
p.windows_transmission_solar_transmittance,
|
|
"windows_transmission_solar_transmittance",
|
|
),
|
|
)
|
|
if p.windows_transmission_u_value is not None
|
|
else None
|
|
),
|
|
multiple_glazed_proportion=p.multiple_glazed_proportion,
|
|
calculation_software_version=p.calculation_software_version,
|
|
mechanical_vent_duct_placement=p.mechanical_vent_duct_placement,
|
|
mechanical_vent_duct_insulation=p.mechanical_vent_duct_insulation,
|
|
pressure_test_certificate_number=p.pressure_test_certificate_number,
|
|
mechanical_ventilation_index_number=p.mechanical_ventilation_index_number,
|
|
mechanical_vent_measured_installation=p.mechanical_vent_measured_installation,
|
|
co2_emissions_current_per_floor_area=(
|
|
perf.co2_emissions_current_per_floor_area if perf else None
|
|
),
|
|
low_energy_fixed_lighting_bulbs_count=p.low_energy_fixed_lighting_bulbs_count,
|
|
sap_flat_details=(
|
|
self._to_flat_details(flat_row) if flat_row is not None else None
|
|
),
|
|
fixed_lighting_outlets_count=p.fixed_lighting_outlets_count,
|
|
low_energy_fixed_lighting_outlets_count=p.low_energy_fixed_lighting_outlets_count,
|
|
sap_ventilation=self._to_ventilation(p),
|
|
number_of_storeys=p.number_of_storeys,
|
|
any_unheated_rooms=p.any_unheated_rooms,
|
|
waste_water_heat_recovery=p.waste_water_heat_recovery,
|
|
hydro=p.hydro,
|
|
photovoltaic_array=p.photovoltaic_array,
|
|
renewable_heat_incentive=(
|
|
RenewableHeatIncentive(
|
|
space_heating_kwh=rhi_row.space_heating_kwh,
|
|
water_heating_kwh=rhi_row.water_heating_kwh,
|
|
impact_of_loft_insulation_kwh=rhi_row.impact_of_loft_insulation_kwh,
|
|
impact_of_cavity_insulation_kwh=rhi_row.impact_of_cavity_insulation_kwh,
|
|
impact_of_solid_wall_insulation_kwh=rhi_row.impact_of_solid_wall_insulation_kwh,
|
|
)
|
|
if rhi_row is not None
|
|
else None
|
|
),
|
|
mechanical_vent_duct_insulation_level=p.mechanical_vent_duct_insulation_level,
|
|
addendum=(
|
|
Addendum(
|
|
stone_walls=p.addendum_stone_walls,
|
|
system_build=p.addendum_system_build,
|
|
addendum_numbers=p.addendum_numbers,
|
|
)
|
|
if (
|
|
p.addendum_stone_walls is not None
|
|
or p.addendum_system_build is not None
|
|
or p.addendum_numbers is not None
|
|
)
|
|
else None
|
|
),
|
|
)
|
|
|
|
@private
|
|
def _windows(self, epc_property_id: int) -> list[EpcWindowModel]:
|
|
return list(
|
|
self._session.exec(
|
|
select(EpcWindowModel)
|
|
.where(EpcWindowModel.epc_property_id == epc_property_id)
|
|
.order_by(EpcWindowModel.id) # type: ignore[arg-type]
|
|
).all()
|
|
)
|
|
|
|
@private
|
|
def _to_energy_element(self, e: EpcEnergyElementModel) -> EnergyElement:
|
|
return EnergyElement(
|
|
description=e.description,
|
|
energy_efficiency_rating=e.energy_efficiency_rating,
|
|
environmental_efficiency_rating=e.environmental_efficiency_rating,
|
|
)
|
|
|
|
@private
|
|
def _to_sap_heating(
|
|
self, p: EpcPropertyModel, heating_rows: list[EpcMainHeatingDetailModel]
|
|
) -> SapHeating:
|
|
shower_outlets = (
|
|
ShowerOutlets(
|
|
shower_outlet=ShowerOutlet(
|
|
shower_outlet_type=p.heating_shower_outlet_type,
|
|
shower_wwhrs=p.heating_shower_wwhrs,
|
|
)
|
|
)
|
|
if p.heating_shower_outlet_type is not None
|
|
else None
|
|
)
|
|
return SapHeating(
|
|
instantaneous_wwhrs=InstantaneousWwhrs(
|
|
wwhrs_index_number1=p.heating_wwhrs_index_number_1,
|
|
wwhrs_index_number2=p.heating_wwhrs_index_number_2,
|
|
),
|
|
main_heating_details=[self._to_main_heating(m) for m in heating_rows],
|
|
has_fixed_air_conditioning=p.has_fixed_air_conditioning,
|
|
cylinder_size=p.heating_cylinder_size,
|
|
water_heating_code=p.heating_water_heating_code,
|
|
water_heating_fuel=p.heating_water_heating_fuel,
|
|
immersion_heating_type=p.heating_immersion_heating_type,
|
|
shower_outlets=shower_outlets,
|
|
cylinder_insulation_type=p.heating_cylinder_insulation_type,
|
|
cylinder_thermostat=p.heating_cylinder_thermostat,
|
|
secondary_fuel_type=p.heating_secondary_fuel_type,
|
|
secondary_heating_type=p.heating_secondary_heating_type,
|
|
cylinder_insulation_thickness_mm=p.heating_cylinder_insulation_thickness_mm,
|
|
number_baths=p.heating_number_baths,
|
|
number_baths_wwhrs=p.heating_number_baths_wwhrs,
|
|
electric_shower_count=p.heating_electric_shower_count,
|
|
mixer_shower_count=p.heating_mixer_shower_count,
|
|
)
|
|
|
|
@private
|
|
def _to_main_heating(self, m: EpcMainHeatingDetailModel) -> MainHeatingDetail:
|
|
return MainHeatingDetail(
|
|
has_fghrs=m.has_fghrs,
|
|
main_fuel_type=m.main_fuel_type,
|
|
heat_emitter_type=m.heat_emitter_type,
|
|
emitter_temperature=m.emitter_temperature,
|
|
main_heating_control=m.main_heating_control,
|
|
fan_flue_present=m.fan_flue_present,
|
|
boiler_flue_type=m.boiler_flue_type,
|
|
boiler_ignition_type=m.boiler_ignition_type,
|
|
central_heating_pump_age=m.central_heating_pump_age,
|
|
central_heating_pump_age_str=m.central_heating_pump_age_str,
|
|
main_heating_index_number=m.main_heating_index_number,
|
|
sap_main_heating_code=m.sap_main_heating_code,
|
|
main_heating_number=m.main_heating_number,
|
|
main_heating_category=m.main_heating_category,
|
|
main_heating_fraction=m.main_heating_fraction,
|
|
main_heating_data_source=m.main_heating_data_source,
|
|
condensing=m.condensing,
|
|
weather_compensator=m.weather_compensator,
|
|
)
|
|
|
|
@private
|
|
def _to_window(self, w: EpcWindowModel) -> SapWindow:
|
|
return SapWindow(
|
|
frame_material=w.frame_material,
|
|
glazing_gap=w.glazing_gap,
|
|
orientation=w.orientation,
|
|
window_type=w.window_type,
|
|
glazing_type=w.glazing_type,
|
|
window_width=w.window_width,
|
|
window_height=w.window_height,
|
|
draught_proofed=w.draught_proofed,
|
|
window_location=w.window_location,
|
|
window_wall_type=w.window_wall_type,
|
|
permanent_shutters_present=w.permanent_shutters_present,
|
|
frame_factor=w.frame_factor,
|
|
window_transmission_details=(
|
|
WindowTransmissionDetails(
|
|
u_value=w.transmission_u_value,
|
|
data_source=_require(
|
|
w.transmission_data_source, "window.transmission_data_source"
|
|
),
|
|
solar_transmittance=_require(
|
|
w.transmission_solar_transmittance,
|
|
"window.transmission_solar_transmittance",
|
|
),
|
|
)
|
|
if w.transmission_u_value is not None
|
|
else None
|
|
),
|
|
permanent_shutters_insulated=w.permanent_shutters_insulated,
|
|
)
|
|
|
|
@private
|
|
def _to_building_part(
|
|
self, bp: EpcBuildingPartModel, floor_rows: list[EpcFloorDimensionModel]
|
|
) -> SapBuildingPart:
|
|
return SapBuildingPart(
|
|
identifier=BuildingPartIdentifier(bp.identifier),
|
|
construction_age_band=bp.construction_age_band,
|
|
wall_construction=bp.wall_construction,
|
|
wall_insulation_type=bp.wall_insulation_type,
|
|
wall_thickness_measured=bp.wall_thickness_measured,
|
|
party_wall_construction=bp.party_wall_construction,
|
|
sap_floor_dimensions=[self._to_floor_dimension(f) for f in floor_rows],
|
|
building_part_number=bp.building_part_number,
|
|
wall_dry_lined=bp.wall_dry_lined,
|
|
wall_thickness_mm=bp.wall_thickness_mm,
|
|
wall_insulation_thickness=bp.wall_insulation_thickness,
|
|
sap_alternative_wall_1=self._to_alt_wall(bp, 1),
|
|
sap_alternative_wall_2=self._to_alt_wall(bp, 2),
|
|
floor_heat_loss=bp.floor_heat_loss,
|
|
floor_insulation_thickness=bp.floor_insulation_thickness,
|
|
flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness,
|
|
floor_type=bp.floor_type,
|
|
floor_construction_type=bp.floor_construction_type,
|
|
floor_insulation_type_str=bp.floor_insulation_type_str,
|
|
floor_u_value_known=bp.floor_u_value_known,
|
|
roof_construction=bp.roof_construction,
|
|
roof_construction_type=bp.roof_construction_type,
|
|
curtain_wall_age=bp.curtain_wall_age,
|
|
roof_insulation_location=bp.roof_insulation_location,
|
|
roof_insulation_thickness=bp.roof_insulation_thickness,
|
|
sap_room_in_roof=(
|
|
SapRoomInRoof(
|
|
floor_area=bp.room_in_roof_floor_area,
|
|
construction_age_band=_require(
|
|
bp.room_in_roof_construction_age_band,
|
|
"room_in_roof_construction_age_band",
|
|
),
|
|
)
|
|
if bp.room_in_roof_floor_area is not None
|
|
else None
|
|
),
|
|
)
|
|
|
|
@private
|
|
def _to_alt_wall(
|
|
self, bp: EpcBuildingPartModel, n: int
|
|
) -> Optional[SapAlternativeWall]:
|
|
area = bp.alt_wall_1_area if n == 1 else bp.alt_wall_2_area
|
|
if area is None:
|
|
return None
|
|
dry_lined = bp.alt_wall_1_dry_lined if n == 1 else bp.alt_wall_2_dry_lined
|
|
construction = (
|
|
bp.alt_wall_1_construction if n == 1 else bp.alt_wall_2_construction
|
|
)
|
|
insulation_type = (
|
|
bp.alt_wall_1_insulation_type if n == 1 else bp.alt_wall_2_insulation_type
|
|
)
|
|
thickness_measured = (
|
|
bp.alt_wall_1_thickness_measured
|
|
if n == 1
|
|
else bp.alt_wall_2_thickness_measured
|
|
)
|
|
insulation_thickness = (
|
|
bp.alt_wall_1_insulation_thickness
|
|
if n == 1
|
|
else bp.alt_wall_2_insulation_thickness
|
|
)
|
|
return SapAlternativeWall(
|
|
wall_area=area,
|
|
wall_dry_lined=_require(dry_lined, f"alt_wall_{n}_dry_lined"),
|
|
wall_construction=_require(construction, f"alt_wall_{n}_construction"),
|
|
wall_insulation_type=_require(
|
|
insulation_type, f"alt_wall_{n}_insulation_type"
|
|
),
|
|
wall_thickness_measured=_require(
|
|
thickness_measured, f"alt_wall_{n}_thickness_measured"
|
|
),
|
|
wall_insulation_thickness=insulation_thickness,
|
|
)
|
|
|
|
@private
|
|
def _to_floor_dimension(self, f: EpcFloorDimensionModel) -> SapFloorDimension:
|
|
return SapFloorDimension(
|
|
room_height_m=f.room_height_m,
|
|
total_floor_area_m2=f.total_floor_area_m2,
|
|
party_wall_length_m=f.party_wall_length_m,
|
|
heat_loss_perimeter_m=f.heat_loss_perimeter_m,
|
|
floor=f.floor,
|
|
floor_insulation=f.floor_insulation,
|
|
floor_construction=f.floor_construction,
|
|
)
|
|
|
|
@private
|
|
def _to_energy_source(self, p: EpcPropertyModel) -> SapEnergySource:
|
|
return SapEnergySource(
|
|
mains_gas=p.energy_mains_gas,
|
|
meter_type=p.energy_meter_type,
|
|
pv_battery_count=p.energy_pv_battery_count,
|
|
wind_turbines_count=p.energy_wind_turbines_count,
|
|
gas_smart_meter_present=p.energy_gas_smart_meter_present,
|
|
is_dwelling_export_capable=p.energy_is_dwelling_export_capable,
|
|
wind_turbines_terrain_type=p.energy_wind_turbines_terrain_type,
|
|
electricity_smart_meter_present=p.energy_electricity_smart_meter_present,
|
|
pv_connection=p.energy_pv_connection,
|
|
photovoltaic_supply=(
|
|
PhotovoltaicSupply(
|
|
none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails(
|
|
percent_roof_area=p.energy_pv_percent_roof_area
|
|
)
|
|
)
|
|
if p.energy_pv_percent_roof_area is not None
|
|
else None
|
|
),
|
|
wind_turbine_details=(
|
|
WindTurbineDetails(
|
|
hub_height=p.energy_wind_turbine_hub_height,
|
|
rotor_diameter=_require(
|
|
p.energy_wind_turbine_rotor_diameter,
|
|
"energy_wind_turbine_rotor_diameter",
|
|
),
|
|
)
|
|
if p.energy_wind_turbine_hub_height is not None
|
|
else None
|
|
),
|
|
pv_batteries=(
|
|
PvBatteries(
|
|
pv_battery=PvBattery(battery_capacity=p.energy_pv_battery_capacity)
|
|
)
|
|
if p.energy_pv_battery_capacity is not None
|
|
else None
|
|
),
|
|
)
|
|
|
|
@private
|
|
def _to_ventilation(self, p: EpcPropertyModel) -> Optional[SapVentilation]:
|
|
if not p.ventilation_present:
|
|
return None
|
|
return SapVentilation(
|
|
ventilation_type=p.ventilation_type,
|
|
draught_lobby=p.ventilation_draught_lobby,
|
|
pressure_test=p.ventilation_pressure_test,
|
|
open_flues_count=p.ventilation_open_flues_count,
|
|
closed_flues_count=p.ventilation_closed_flues_count,
|
|
boiler_flues_count=p.ventilation_boiler_flues_count,
|
|
other_flues_count=p.ventilation_other_flues_count,
|
|
extract_fans_count=p.ventilation_extract_fans_count,
|
|
passive_vents_count=p.ventilation_passive_vents_count,
|
|
flueless_gas_fires_count=p.ventilation_flueless_gas_fires_count,
|
|
ventilation_in_pcdf_database=p.ventilation_in_pcdf_database,
|
|
sheltered_sides=p.ventilation_sheltered_sides,
|
|
has_suspended_timber_floor=p.ventilation_has_suspended_timber_floor,
|
|
suspended_timber_floor_sealed=p.ventilation_suspended_timber_floor_sealed,
|
|
has_draught_lobby=p.ventilation_has_draught_lobby,
|
|
air_permeability_ap4_m3_h_m2=p.ventilation_air_permeability_ap4_m3_h_m2,
|
|
mechanical_ventilation_kind=p.ventilation_mechanical_ventilation_kind,
|
|
)
|
|
|
|
@private
|
|
def _to_flat_details(self, f: EpcFlatDetailsModel) -> SapFlatDetails:
|
|
return SapFlatDetails(
|
|
level=f.level,
|
|
top_storey=f.top_storey,
|
|
flat_location=f.flat_location,
|
|
heat_loss_corridor=f.heat_loss_corridor,
|
|
storey_count=f.storey_count,
|
|
unheated_corridor_length_m=f.unheated_corridor_length_m,
|
|
)
|