updated rdsap option; seperated s3 location in infrastrucutre; added open ai api

This commit is contained in:
Jun-te Kim 2026-05-22 14:00:33 +00:00
parent 754e6609fd
commit bdf703ea00
26 changed files with 679 additions and 12 deletions

View file

@ -98,7 +98,9 @@ class MainHeatingDetail:
boiler_flue_type: Optional[int] = None # TODO: make enum? boiler_flue_type: Optional[int] = None # TODO: make enum?
boiler_ignition_type: Optional[int] = None # TODO: make enum? boiler_ignition_type: Optional[int] = None # TODO: make enum?
central_heating_pump_age: Optional[int] = None central_heating_pump_age: Optional[int] = None
central_heating_pump_age_str: Optional[str] = None # str from site notes e.g. "Unknown", "Pre 2013" central_heating_pump_age_str: Optional[str] = (
None # str from site notes e.g. "Unknown", "Pre 2013"
)
main_heating_index_number: Optional[int] = None main_heating_index_number: Optional[int] = None
sap_main_heating_code: Optional[int] = None # TODO: make enum? sap_main_heating_code: Optional[int] = None # TODO: make enum?
main_heating_number: Optional[int] = None main_heating_number: Optional[int] = None
@ -123,7 +125,7 @@ class ShowerOutlets:
@dataclass @dataclass
class SapHeating: class SapHeating:
instantaneous_wwhrs: InstantaneousWwhrs instantaneous_wwhrs: Optional[InstantaneousWwhrs]
main_heating_details: List[MainHeatingDetail] main_heating_details: List[MainHeatingDetail]
has_fixed_air_conditioning: bool has_fixed_air_conditioning: bool
cylinder_size: Optional[Union[int, str]] = ( cylinder_size: Optional[Union[int, str]] = (
@ -136,7 +138,9 @@ class SapHeating:
cylinder_insulation_type: Optional[Union[int, str]] = None cylinder_insulation_type: Optional[Union[int, str]] = None
cylinder_thermostat: Optional[str] = None cylinder_thermostat: Optional[str] = None
secondary_fuel_type: Optional[int] = None secondary_fuel_type: Optional[int] = None
secondary_heating_type: Optional[Union[int, str]] = None # int from API; str from site notes secondary_heating_type: Optional[Union[int, str]] = (
None # int from API; str from site notes
)
cylinder_insulation_thickness_mm: Optional[int] = None cylinder_insulation_thickness_mm: Optional[int] = None
# SAP10 hot-water demand inputs from sap_heating. # SAP10 hot-water demand inputs from sap_heating.
number_baths: Optional[int] = None number_baths: Optional[int] = None
@ -159,7 +163,9 @@ class SapHeating:
class SapVentilation: class SapVentilation:
ventilation_type: Optional[str] = None ventilation_type: Optional[str] = None
draught_lobby: Optional[bool] = None draught_lobby: Optional[bool] = None
pressure_test: Optional[str] = None # str from site notes e.g. "No test"; int in API via mechanical_ventilation pressure_test: Optional[str] = (
None # str from site notes e.g. "No test"; int in API via mechanical_ventilation
)
open_flues_count: Optional[int] = None open_flues_count: Optional[int] = None
closed_flues_count: Optional[int] = None closed_flues_count: Optional[int] = None
boiler_flues_count: Optional[int] = None boiler_flues_count: Optional[int] = None
@ -467,8 +473,12 @@ class SapBuildingPart:
None # TODO: make enum/mapping? None # TODO: make enum/mapping?
) )
floor_type: Optional[str] = None # str from site notes e.g. "Ground Floor" floor_type: Optional[str] = None # str from site notes e.g. "Ground Floor"
floor_construction_type: Optional[str] = None # str from site notes; distinct from floor_construction: int in SapFloorDimension floor_construction_type: Optional[str] = (
floor_insulation_type_str: Optional[str] = None # str from site notes e.g. "As Built" None # str from site notes; distinct from floor_construction: int in SapFloorDimension
)
floor_insulation_type_str: Optional[str] = (
None # str from site notes e.g. "As Built"
)
floor_u_value_known: Optional[bool] = None floor_u_value_known: Optional[bool] = None
roof_construction: Optional[int] = None roof_construction: Optional[int] = None

View file

@ -37,7 +37,7 @@ class SapHeating:
cylinder_size: int cylinder_size: int
water_heating_code: int water_heating_code: int
water_heating_fuel: int water_heating_fuel: int
instantaneous_wwhrs: InstantaneousWwhrs instantaneous_wwhrs: Optional[InstantaneousWwhrs]
main_heating_details: List[MainHeatingDetail] main_heating_details: List[MainHeatingDetail]
immersion_heating_type: Union[int, str] immersion_heating_type: Union[int, str]
cylinder_insulation_type: int cylinder_insulation_type: int

View file

@ -41,7 +41,7 @@ class SapHeating:
cylinder_size: int cylinder_size: int
water_heating_code: int water_heating_code: int
water_heating_fuel: int water_heating_fuel: int
instantaneous_wwhrs: InstantaneousWwhrs instantaneous_wwhrs: Optional[InstantaneousWwhrs]
main_heating_details: List[MainHeatingDetail] main_heating_details: List[MainHeatingDetail]
immersion_heating_type: Union[int, str] immersion_heating_type: Union[int, str]
cylinder_insulation_type: int cylinder_insulation_type: int

View file

@ -41,7 +41,7 @@ class SapHeating:
cylinder_size: int cylinder_size: int
water_heating_code: int water_heating_code: int
water_heating_fuel: int water_heating_fuel: int
instantaneous_wwhrs: InstantaneousWwhrs instantaneous_wwhrs: Optional[InstantaneousWwhrs]
main_heating_details: List[MainHeatingDetail] main_heating_details: List[MainHeatingDetail]
immersion_heating_type: Union[int, str] immersion_heating_type: Union[int, str]
has_fixed_air_conditioning: str has_fixed_air_conditioning: str
@ -86,6 +86,7 @@ class SapFloorDimension:
@dataclass @dataclass
class SapRoomInRoof: class SapRoomInRoof:
"""Room-in-roof details. floor_area is a Measurement object in schema 18.0.""" """Room-in-roof details. floor_area is a Measurement object in schema 18.0."""
floor_area: Measurement floor_area: Measurement
insulation: str insulation: str
roof_room_connected: str roof_room_connected: str

View file

@ -41,7 +41,7 @@ class SapHeating:
cylinder_size: int cylinder_size: int
water_heating_code: int water_heating_code: int
water_heating_fuel: int water_heating_fuel: int
instantaneous_wwhrs: InstantaneousWwhrs instantaneous_wwhrs: Optional[InstantaneousWwhrs]
main_heating_details: List[MainHeatingDetail] main_heating_details: List[MainHeatingDetail]
immersion_heating_type: Union[int, str] immersion_heating_type: Union[int, str]
has_fixed_air_conditioning: str has_fixed_air_conditioning: str

View file

@ -49,7 +49,7 @@ class SapHeating:
cylinder_size: int cylinder_size: int
water_heating_code: int water_heating_code: int
water_heating_fuel: int water_heating_fuel: int
instantaneous_wwhrs: InstantaneousWwhrs instantaneous_wwhrs: Optional[InstantaneousWwhrs]
main_heating_details: List[MainHeatingDetail] main_heating_details: List[MainHeatingDetail]
immersion_heating_type: Union[int, str] immersion_heating_type: Union[int, str]
has_fixed_air_conditioning: str has_fixed_air_conditioning: str
@ -103,6 +103,7 @@ class SapFloorDimension:
@dataclass @dataclass
class SapRoomInRoof: class SapRoomInRoof:
"""Room-in-roof details. floor_area is a plain number in schema 20.0.0 (not a Measurement object).""" """Room-in-roof details. floor_area is a plain number in schema 20.0.0 (not a Measurement object)."""
floor_area: Union[int, float] floor_area: Union[int, float]
insulation: str insulation: str
roof_room_connected: str roof_room_connected: str

View file

@ -33,6 +33,7 @@ class ShowerOutlets:
@dataclass @dataclass
class InstantaneousWwhrs: class InstantaneousWwhrs:
"""Changed in 21.0.0: references WWHRS product index numbers instead of room counts.""" """Changed in 21.0.0: references WWHRS product index numbers instead of room counts."""
wwhrs_index_number1: Optional[int] = None wwhrs_index_number1: Optional[int] = None
wwhrs_index_number2: Optional[int] = None wwhrs_index_number2: Optional[int] = None
@ -61,6 +62,7 @@ class SapHeating:
cylinder_size: int cylinder_size: int
water_heating_code: int water_heating_code: int
water_heating_fuel: int water_heating_fuel: int
instantaneous_wwhrs: Optional[InstantaneousWwhrs]
main_heating_details: List[MainHeatingDetail] main_heating_details: List[MainHeatingDetail]
immersion_heating_type: Union[int, str] immersion_heating_type: Union[int, str]
has_fixed_air_conditioning: str has_fixed_air_conditioning: str
@ -113,6 +115,7 @@ class PhotovoltaicArray:
`PhotovoltaicSupply`. The Union type on SapEnergySource.photovoltaic_supply `PhotovoltaicSupply`. The Union type on SapEnergySource.photovoltaic_supply
accepts either shape. accepts either shape.
""" """
peak_power: float peak_power: float
pitch: int pitch: int
orientation: int orientation: int
@ -179,6 +182,7 @@ class RoomInRoofType1:
full enum not yet mapped). `gable_wall_length_*` is the run of the full enum not yet mapped). `gable_wall_length_*` is the run of the
external gable in metres. Heights are NOT lodged here the cascade external gable in metres. Heights are NOT lodged here the cascade
applies the §3.9.1 default storey height (2.45 m).""" applies the §3.9.1 default storey height (2.45 m)."""
gable_wall_type_1: Optional[int] = None gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None gable_wall_length_1: Optional[float] = None
@ -189,6 +193,7 @@ class RoomInRoofType1:
class RoomInRoofDetails: class RoomInRoofDetails:
"""RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling """RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling
detail. See `rdsap_schema_21_0_1.RoomInRoofDetails`.""" detail. See `rdsap_schema_21_0_1.RoomInRoofDetails`."""
gable_wall_type_1: Optional[int] = None gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None gable_wall_length_1: Optional[float] = None
@ -204,6 +209,7 @@ class RoomInRoofDetails:
@dataclass @dataclass
class SapRoomInRoof: class SapRoomInRoof:
"""Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0.""" """Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0."""
floor_area: Union[int, float] floor_area: Union[int, float]
construction_age_band: str construction_age_band: str
room_in_roof_type_1: Optional[RoomInRoofType1] = None room_in_roof_type_1: Optional[RoomInRoofType1] = None

View file

@ -50,7 +50,7 @@ class MainHeatingDetail:
main_heating_data_source: int main_heating_data_source: int
emitter_temperature: Optional[Union[int, str]] = None emitter_temperature: Optional[Union[int, str]] = None
boiler_flue_type: Optional[int] = None boiler_flue_type: Optional[int] = None
fan_flue_present: Optional[str] = None # TODO: make bool fan_flue_present: Optional[str] = None # TODO: make bool
boiler_ignition_type: Optional[int] = None boiler_ignition_type: Optional[int] = None
central_heating_pump_age: Optional[int] = None central_heating_pump_age: Optional[int] = None
main_heating_index_number: Optional[int] = None main_heating_index_number: Optional[int] = None
@ -62,6 +62,7 @@ class SapHeating:
cylinder_size: int cylinder_size: int
water_heating_code: int water_heating_code: int
water_heating_fuel: int water_heating_fuel: int
instantaneous_wwhrs: Optional[InstantaneousWwhrs]
main_heating_details: List[MainHeatingDetail] main_heating_details: List[MainHeatingDetail]
immersion_heating_type: Union[int, str] immersion_heating_type: Union[int, str]
has_fixed_air_conditioning: str has_fixed_air_conditioning: str
@ -118,6 +119,7 @@ class PhotovoltaicSupplyNoneOrNoDetails:
@dataclass @dataclass
class SchemaPhotovoltaicArray: class SchemaPhotovoltaicArray:
"""One measured PV array under `photovoltaic_supply.pv_arrays`.""" """One measured PV array under `photovoltaic_supply.pv_arrays`."""
peak_power: Optional[float] = None peak_power: Optional[float] = None
pitch: Optional[int] = None pitch: Optional[int] = None
orientation: Optional[int] = None orientation: Optional[int] = None
@ -143,6 +145,7 @@ class PhotovoltaicArray:
`PhotovoltaicSupply`. The Union type on SapEnergySource.photovoltaic_supply `PhotovoltaicSupply`. The Union type on SapEnergySource.photovoltaic_supply
accepts either shape. Some certs wrap the scalars in Measurement dicts. accepts either shape. Some certs wrap the scalars in Measurement dicts.
""" """
peak_power: Union[Measurement, int, float] peak_power: Union[Measurement, int, float]
pitch: Union[Measurement, int] pitch: Union[Measurement, int]
orientation: Union[Measurement, int] orientation: Union[Measurement, int]
@ -211,6 +214,7 @@ class RoomInRoofType1:
full enum not yet mapped). `gable_wall_length_*` is the run of the full enum not yet mapped). `gable_wall_length_*` is the run of the
external gable in metres. Heights are NOT lodged here the cascade external gable in metres. Heights are NOT lodged here the cascade
applies the §3.9.1 default storey height (2.45 m).""" applies the §3.9.1 default storey height (2.45 m)."""
gable_wall_type_1: Optional[int] = None gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None gable_wall_length_1: Optional[float] = None
@ -225,6 +229,7 @@ class RoomInRoofDetails:
by `EpcPropertyDataMapper.from_api_response` to populate by `EpcPropertyDataMapper.from_api_response` to populate
`SapRoomInRoof.detailed_surfaces` with `gable_wall_external` / `SapRoomInRoof.detailed_surfaces` with `gable_wall_external` /
`flat_ceiling` entries the cascade's Detailed-RR branch consumes.""" `flat_ceiling` entries the cascade's Detailed-RR branch consumes."""
gable_wall_type_1: Optional[int] = None gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None gable_wall_length_1: Optional[float] = None

View file

@ -0,0 +1,4 @@
from domain.epc.epc_record import EpcRecord
from domain.epc.property_type import PropertyType
__all__ = ["EpcRecord", "PropertyType"]

21
domain/epc/epc_record.py Normal file
View file

@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from domain.epc.property_type import PropertyType
@dataclass(frozen=True)
class EpcRecord:
"""A streamlined record of EPC property data.
A focused subset of the full ``EpcPropertyData``: a property's identity
plus its typed property type. Grow this with further fields as the
domain needs them.
"""
address_line_1: str
postcode: str
uprn: Optional[int]
property_type: PropertyType

View file

@ -0,0 +1,13 @@
from infrastructure.epc.epc_client import EpcClient
from infrastructure.epc.exceptions import (
EpcApiError,
EpcNotFoundError,
EpcRateLimitError,
)
__all__ = [
"EpcApiError",
"EpcClient",
"EpcNotFoundError",
"EpcRateLimitError",
]

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.search import EpcSearchResult
class EpcClient(ABC):
"""Interface for retrieving EPC (Energy Performance Certificate) data.
Implementations fetch from a data source and return domain objects;
callers depend only on this interface, not on a concrete transport.
"""
@abstractmethod
def search_by_postcode(self, postcode: str) -> list[EpcSearchResult]:
"""Return the EPC certificates registered at ``postcode``.
Returns an empty list when the postcode has no certificates.
"""
...
@abstractmethod
def get_by_certificate_number(
self, certificate_number: str
) -> EpcPropertyData:
"""Return the full EPC record for a certificate number.
Raises EpcNotFoundError when no such certificate exists.
"""
...
@abstractmethod
def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]:
"""Return the most recent EPC record for ``uprn``.
Returns None when the UPRN has no certificates.
"""
...

View file

@ -0,0 +1,17 @@
from typing import Optional
class EpcApiError(Exception):
"""Base for all EPC client errors."""
class EpcNotFoundError(EpcApiError):
"""Raised when the API returns 404 for a resource that must exist."""
class EpcRateLimitError(EpcApiError):
"""Raised when the API returns 429 and all retries are exhausted."""
def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
super().__init__(message)
self.retry_after = retry_after

View file

@ -0,0 +1,6 @@
from infrastructure.epc.gov_uk.gov_uk_epc_client import GovUkEpcClient
from infrastructure.epc.gov_uk.gov_uk_property_type import (
property_type_from_gov_uk_code,
)
__all__ = ["GovUkEpcClient", "property_type_from_gov_uk_code"]

View file

@ -0,0 +1,34 @@
import time
from typing import Callable, Optional, TypeVar
from infrastructure.epc.exceptions import EpcRateLimitError
T = TypeVar("T")
def call_with_retry(
fn: Callable[[], T],
max_retries: int = 5,
backoff_base: float = 1.0,
backoff_multiplier: float = 2.0,
max_backoff: float = 60.0,
) -> T:
"""Call ``fn``, retrying on EpcRateLimitError with exponential backoff.
Honours the API's ``Retry-After`` header when present, otherwise backs off
``backoff_base * backoff_multiplier ** attempt`` (capped at ``max_backoff``).
"""
last_exc: Optional[EpcRateLimitError] = None
for attempt in range(max_retries + 1):
try:
return fn()
except EpcRateLimitError as exc:
last_exc = exc
if attempt < max_retries:
if exc.retry_after is not None:
delay = exc.retry_after
else:
delay = backoff_base * (backoff_multiplier**attempt)
time.sleep(min(delay, max_backoff))
assert last_exc is not None
raise last_exc

View file

@ -0,0 +1,132 @@
# Spec: https://raw.githubusercontent.com/communitiesuk/epb-data-warehouse/main/api/api.yml
from __future__ import annotations
from typing import Any, Optional
import httpx
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from datatypes.epc.search import EpcSearchResult
from infrastructure.epc.epc_client import EpcClient
from infrastructure.epc.exceptions import (
EpcApiError,
EpcNotFoundError,
EpcRateLimitError,
)
from infrastructure.epc.gov_uk._retry import call_with_retry
class GovUkEpcClient(EpcClient):
"""EpcClient backed by the live gov.uk EPC API.
Endpoint: https://api.get-energy-performance-data.communities.gov.uk
"""
BASE_URL = "https://api.get-energy-performance-data.communities.gov.uk"
REQUEST_TIMEOUT = 10.0
def __init__(self, auth_token: str) -> None:
self._headers = {
"Authorization": f"Bearer {auth_token}",
"Accept": "application/json",
}
def search_by_postcode(self, postcode: str) -> list[EpcSearchResult]:
normalised = self._normalise_postcode(postcode)
return call_with_retry(lambda: self._search(postcode=normalised))
def get_by_certificate_number(
self, certificate_number: str
) -> EpcPropertyData:
raw = call_with_retry(lambda: self._fetch_certificate(certificate_number))
return EpcPropertyDataMapper.from_api_response(raw)
def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]:
results = call_with_retry(lambda: self._search(uprn=uprn))
if not results:
return None
latest = max(results, key=lambda r: r.registration_date)
return self.get_by_certificate_number(latest.certificate_number)
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
@staticmethod
def _normalise_postcode(postcode: str) -> str:
"""Return the postcode with all spaces removed and uppercased."""
return postcode.replace(" ", "").upper()
@staticmethod
def _parse_retry_after(resp: httpx.Response) -> Optional[float]:
header = resp.headers.get("Retry-After")
if header is None:
return None
try:
return float(header)
except (TypeError, ValueError):
return None
def _fetch_certificate(self, certificate_number: str) -> dict[str, Any]:
resp = httpx.get(
f"{self.BASE_URL}/api/certificate",
params={"certificate_number": certificate_number},
headers=self._headers,
timeout=self.REQUEST_TIMEOUT,
)
if resp.status_code == 404:
raise EpcNotFoundError(certificate_number)
if resp.status_code == 429:
raise EpcRateLimitError(
"Rate limited by EPC API",
retry_after=self._parse_retry_after(resp),
)
if not resp.is_success:
raise EpcApiError(f"EPC API error {resp.status_code}: {resp.text}")
return resp.json()["data"]
def _search(
self,
postcode: Optional[str] = None,
uprn: Optional[int] = None,
) -> list[EpcSearchResult]:
params: dict[str, str | int] = {}
if postcode:
params["postcode"] = postcode
if uprn is not None:
params["uprn"] = uprn
resp = httpx.get(
f"{self.BASE_URL}/api/domestic/search",
params=params,
headers=self._headers,
timeout=self.REQUEST_TIMEOUT,
)
if resp.status_code == 404:
return []
if resp.status_code == 429:
raise EpcRateLimitError(
"Rate limited by EPC API",
retry_after=self._parse_retry_after(resp),
)
if not resp.is_success:
raise EpcApiError(f"EPC API error {resp.status_code}: {resp.text}")
rows = resp.json().get("data", [])
return [self._parse_search_result(row) for row in rows]
@staticmethod
def _parse_search_result(row: dict[str, Any]) -> EpcSearchResult:
return EpcSearchResult(
certificate_number=row["certificateNumber"],
address_line_1=row["addressLine1"],
address_line_2=row.get("addressLine2"),
address_line_3=row.get("addressLine3"),
address_line_4=row.get("addressLine4"),
postcode=row["postcode"],
post_town=row["postTown"],
uprn=row.get("uprn"),
current_energy_efficiency_band=row["currentEnergyEfficiencyBand"],
registration_date=row["registrationDate"],
)

View file

@ -0,0 +1,25 @@
from domain.epc.property_type import PropertyType
# GOV.UK EPC API ``property_type`` integer codes mapped to the domain type.
# This translation is GOV.UK-specific and lives in the infrastructure layer so
# the domain ``PropertyType`` stays free of any source encoding.
_PROPERTY_TYPE_BY_GOV_UK_CODE: dict[int, PropertyType] = {
0: PropertyType.HOUSE,
1: PropertyType.BUNGALOW,
2: PropertyType.FLAT,
3: PropertyType.MAISONETTE,
4: PropertyType.PARK_HOME,
}
def property_type_from_gov_uk_code(code: int) -> PropertyType:
"""Translate a GOV.UK EPC ``property_type`` code to the domain PropertyType.
Raises ValueError for a code GOV.UK has not been mapped here yet.
"""
try:
return _PROPERTY_TYPE_BY_GOV_UK_CODE[code]
except KeyError:
raise ValueError(
f"Unknown GOV.UK EPC property type code: {code}"
) from None

View file

@ -0,0 +1,5 @@
from infrastructure.epc.historical_open_data_communities.historical_open_data_communities_epc_client import (
HistoricalOpenDataCommunitiesEpcClient,
)
__all__ = ["HistoricalOpenDataCommunitiesEpcClient"]

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Optional
from domain.epc.epc_record import EpcRecord
class HistoricalOpenDataCommunitiesEpcClient:
"""EPC client backed by Open Data Communities' historical EPC data.
Stub not yet implemented. Every method raises NotImplementedError for
now. Unlike GovUkEpcClient it returns the domain ``EpcRecord`` directly;
once the ``EpcClient`` port is migrated to return ``EpcRecord``, this
adapter should implement it.
"""
def search_by_postcode(self, postcode: str) -> list[EpcRecord]:
raise NotImplementedError
def get_by_certificate_number(self, certificate_number: str) -> EpcRecord:
raise NotImplementedError
def get_by_uprn(self, uprn: int) -> Optional[EpcRecord]:
raise NotImplementedError

View file

View file

@ -0,0 +1,2 @@
class OpenAiClientError(Exception):
"""Base for all OpenAI client errors."""

View file

@ -0,0 +1,60 @@
from __future__ import annotations
import os
from typing import Optional
from openai import OpenAI
from openai.types.chat import ChatCompletionMessageParam
from infrastructure.openai.exceptions import OpenAiClientError
class OpenAiChatClient:
"""Thin wrapper over the OpenAI Chat Completions API.
Sends a single prompt and returns the assistant's reply as plain text.
"""
DEFAULT_MODEL = "gpt-4o-mini"
def __init__(
self,
api_key: Optional[str] = None,
model: Optional[str] = None,
) -> None:
key = api_key or os.environ.get("OPENAI_API_KEY")
if not key:
raise OpenAiClientError(
"No OpenAI API key provided. "
"Pass api_key or set the OPENAI_API_KEY environment variable."
)
self._client = OpenAI(api_key=key)
self._model = model or self.DEFAULT_MODEL
def generate(
self,
prompt: str,
system_prompt: Optional[str] = None,
) -> str:
"""Send a prompt to the model and return its reply text.
Args:
prompt: The user message to send.
system_prompt: Optional instruction that sets the model's behaviour.
Raises:
OpenAiClientError: If the model returns an empty response.
"""
messages: list[ChatCompletionMessageParam] = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
response = self._client.chat.completions.create(
model=self._model,
messages=messages,
)
content = response.choices[0].message.content
if content is None:
raise OpenAiClientError("OpenAI returned an empty response.")
return content

View file

View file

@ -0,0 +1,49 @@
import json
import pathlib
import pytest
from infrastructure.epc.gov_uk.gov_uk_epc_client import GovUkEpcClient
SAMPLES_DIR = pathlib.Path("backend/epc_api/json_samples")
@pytest.fixture
def rdsap_21_0_0_cert():
return json.loads((SAMPLES_DIR / "RdSAP-Schema-21.0.0/epc.json").read_text())
@pytest.fixture
def rdsap_21_0_1_cert():
return json.loads((SAMPLES_DIR / "RdSAP-Schema-21.0.1/epc.json").read_text())
@pytest.fixture
def epc_client():
return GovUkEpcClient(auth_token="test-token")
def make_search_row(
cert_num="CERT-001",
address_line_1="1 Test Street",
postcode="SW1A 1AA",
post_town="London",
uprn=100023336956,
band="D",
registration_date="2024-01-01",
address_line_2=None,
address_line_3=None,
address_line_4=None,
):
return {
"certificateNumber": cert_num,
"addressLine1": address_line_1,
"addressLine2": address_line_2,
"addressLine3": address_line_3,
"addressLine4": address_line_4,
"postcode": postcode,
"postTown": post_town,
"uprn": uprn,
"currentEnergyEfficiencyBand": band,
"registrationDate": registration_date,
}

View file

@ -0,0 +1,211 @@
from unittest.mock import MagicMock, call, patch
import pytest
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.search import EpcSearchResult
from infrastructure.epc.exceptions import EpcNotFoundError
from tests.infrastructure.epc.gov_uk.conftest import make_search_row
_SLEEP = "infrastructure.epc.gov_uk._retry.time.sleep"
def _mock_response(status_code=200, json_data=None, headers=None):
resp = MagicMock()
resp.status_code = status_code
resp.is_success = 200 <= status_code < 300
resp.json.return_value = json_data or {}
resp.text = str(json_data)
resp.headers = headers or {}
return resp
# ---------------------------------------------------------------------------
# Test 1: get_by_certificate_number happy path
# ---------------------------------------------------------------------------
def test_get_by_certificate_number_returns_epc_property_data(
epc_client, rdsap_21_0_1_cert
):
cert_response = {"data": rdsap_21_0_1_cert}
with patch("httpx.get", return_value=_mock_response(200, cert_response)):
result = epc_client.get_by_certificate_number("CERT-001")
assert isinstance(result, EpcPropertyData)
# ---------------------------------------------------------------------------
# Test 2: get_by_certificate_number 404 -> EpcNotFoundError
# ---------------------------------------------------------------------------
def test_get_by_certificate_number_404_raises_not_found(epc_client):
with patch("httpx.get", return_value=_mock_response(404)):
with pytest.raises(EpcNotFoundError):
epc_client.get_by_certificate_number("BAD-CERT")
# ---------------------------------------------------------------------------
# Test 3: 429 retried, succeeds on 3rd attempt
# ---------------------------------------------------------------------------
def test_get_by_certificate_number_retries_on_429_and_succeeds(
epc_client, rdsap_21_0_1_cert
):
cert_response = {"data": rdsap_21_0_1_cert}
responses = [
_mock_response(429),
_mock_response(429),
_mock_response(200, cert_response),
]
with patch("httpx.get", side_effect=responses), patch(_SLEEP):
result = epc_client.get_by_certificate_number("CERT-001")
assert isinstance(result, EpcPropertyData)
# ---------------------------------------------------------------------------
# Test 3b: 429 with Retry-After header -> sleeps for that value
# ---------------------------------------------------------------------------
def test_429_retry_after_header_drives_sleep_duration(
epc_client, rdsap_21_0_1_cert
):
cert_response = {"data": rdsap_21_0_1_cert}
responses = [
_mock_response(429, headers={"Retry-After": "7"}),
_mock_response(200, cert_response),
]
with patch("httpx.get", side_effect=responses), patch(_SLEEP) as mock_sleep:
epc_client.get_by_certificate_number("CERT-001")
mock_sleep.assert_called_once_with(7.0)
# ---------------------------------------------------------------------------
# Test 3c: 429 without Retry-After -> falls back to exponential backoff
# ---------------------------------------------------------------------------
def test_429_without_retry_after_uses_exponential_backoff(
epc_client, rdsap_21_0_1_cert
):
cert_response = {"data": rdsap_21_0_1_cert}
responses = [
_mock_response(429),
_mock_response(429),
_mock_response(200, cert_response),
]
with patch("httpx.get", side_effect=responses), patch(_SLEEP) as mock_sleep:
epc_client.get_by_certificate_number("CERT-001")
assert mock_sleep.call_args_list == [call(1.0), call(2.0)]
# ---------------------------------------------------------------------------
# Test 3d: malformed Retry-After header -> falls back to exponential backoff
# ---------------------------------------------------------------------------
def test_429_malformed_retry_after_falls_back_to_backoff(
epc_client, rdsap_21_0_1_cert
):
cert_response = {"data": rdsap_21_0_1_cert}
responses = [
_mock_response(429, headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}),
_mock_response(200, cert_response),
]
with patch("httpx.get", side_effect=responses), patch(_SLEEP) as mock_sleep:
epc_client.get_by_certificate_number("CERT-001")
mock_sleep.assert_called_once_with(1.0)
# ---------------------------------------------------------------------------
# Test 3e: Retry-After capped by max_backoff to avoid hostile/buggy values
# ---------------------------------------------------------------------------
def test_429_retry_after_capped_by_max_backoff(epc_client, rdsap_21_0_1_cert):
cert_response = {"data": rdsap_21_0_1_cert}
responses = [
_mock_response(429, headers={"Retry-After": "9999"}),
_mock_response(200, cert_response),
]
with patch("httpx.get", side_effect=responses), patch(_SLEEP) as mock_sleep:
epc_client.get_by_certificate_number("CERT-001")
mock_sleep.assert_called_once_with(60.0)
# ---------------------------------------------------------------------------
# Test 4: get_by_uprn empty search -> None
# ---------------------------------------------------------------------------
def test_get_by_uprn_returns_none_when_no_results(epc_client):
with patch("httpx.get", return_value=_mock_response(200, {"data": []})):
result = epc_client.get_by_uprn(100023336956)
assert result is None
# ---------------------------------------------------------------------------
# Test 5: get_by_uprn multiple results -> fetches latest by registration_date
# ---------------------------------------------------------------------------
def test_get_by_uprn_picks_most_recent_certificate(epc_client, rdsap_21_0_1_cert):
search_rows = [
make_search_row(cert_num="CERT-OLD", registration_date="2022-01-01"),
make_search_row(cert_num="CERT-NEW", registration_date="2024-06-01"),
make_search_row(cert_num="CERT-MID", registration_date="2023-03-15"),
]
cert_response = {"data": rdsap_21_0_1_cert}
def fake_get(url, params=None, **kwargs):
if "search" in url:
return _mock_response(200, {"data": search_rows})
return _mock_response(200, cert_response)
with patch("httpx.get", side_effect=fake_get) as mock_get:
result = epc_client.get_by_uprn(100023336956)
assert isinstance(result, EpcPropertyData)
# Second call must be for the most recent cert
cert_call = mock_get.call_args_list[1]
assert cert_call.kwargs["params"]["certificate_number"] == "CERT-NEW"
# ---------------------------------------------------------------------------
# Test 6: search_by_postcode returns list[EpcSearchResult]
# ---------------------------------------------------------------------------
def test_search_by_postcode_returns_results(epc_client):
rows = [
make_search_row(cert_num="CERT-A", address_line_1="1 High Street"),
make_search_row(cert_num="CERT-B", address_line_1="2 High Street"),
]
with patch("httpx.get", return_value=_mock_response(200, {"data": rows})):
results = epc_client.search_by_postcode("SW1A 1AA")
assert len(results) == 2
assert all(isinstance(r, EpcSearchResult) for r in results)
assert results[0].certificate_number == "CERT-A"
assert results[1].address_line_1 == "2 High Street"
# ---------------------------------------------------------------------------
# Test 7: search_by_postcode 404 -> empty list
# ---------------------------------------------------------------------------
def test_search_by_postcode_404_returns_empty_list(epc_client):
with patch("httpx.get", return_value=_mock_response(404)):
results = epc_client.search_by_postcode("ZZ9 9ZZ")
assert results == []