Merge branch 'feat/address2uprn-certificate-number' into feature/uprn-confirmation-before-finalise

# Conflicts:
#	backend/address2UPRN/main.py
This commit is contained in:
Jun-te Kim 2026-07-07 15:17:47 +00:00
commit 4e0134dd27
3 changed files with 37 additions and 18 deletions

View file

@ -38,22 +38,29 @@ def get_epc_data_with_postcode(postcode: str) -> pd.DataFrame:
service = EpcClientService(auth_token=token)
results = service.search_by_postcode(postcode)
return pd.DataFrame(
[{"address": r.address_line_1, "uprn": r.uprn} for r in results]
[
{
"address": r.address_line_1,
"uprn": r.uprn,
"certificate_number": r.certificate_number,
}
for r in results
]
)
def get_uprn_from_historic_epc(
user_inputed_address: str,
postcode: str,
) -> Optional[tuple[str, str, float]]:
) -> Optional[tuple[str, str, float, Optional[str]]]:
"""Resolve a UPRN via historic EPC S3 data.
Returns (uprn, address, lexiscore) when the historic dataset agrees on a
single rank-1 UPRN, None otherwise (no stored data, zero score, or
ambiguous top rank). The score gate is `unambiguous_uprn`'s own (score > 0);
the 0.7 heuristic used for the new-EPC source isn't applied here because
historic addresses use a more verbose format that systematically depresses
lexiscores.
Returns (uprn, address, lexiscore, certificate_number) when the historic
dataset agrees on a single rank-1 UPRN, None otherwise (no stored data,
zero score, or ambiguous top rank). The score gate is `unambiguous_uprn`'s
own (score > 0); the 0.7 heuristic used for the new-EPC source isn't
applied here because historic addresses use a more verbose format that
systematically depresses lexiscores.
"""
repo = HistoricEpcS3Repository.with_default_s3_client()
return HistoricEpcResolver(repo).resolve_uprn(user_inputed_address, postcode)
@ -63,7 +70,7 @@ def get_uprn_with_epc_df(
user_inputed_address: str,
epc_df: pd.DataFrame,
verbose: bool = False,
) -> Optional[str | tuple[str, str, float]]:
) -> Optional[str | tuple[str, str, float, Optional[str]]]:
"""
Return uprn (str) using a pre-fetched EPC dataframe.
This avoids calling the API multiple times for the same postcode.
@ -92,6 +99,7 @@ def get_uprn_with_epc_df(
address = top_rank_df["address"].values[0]
score = float(top_rank_df["lexiscore"].values[0])
certificate_number = top_rank_df["certificate_number"].values[0]
logger.info(f"Address found to be: {address}, with lexiscore {score}")
# Safe to return the agreed UPRN
@ -102,7 +110,7 @@ def get_uprn_with_epc_df(
return None
if verbose:
return (found_uprn, address, score)
return (found_uprn, address, score, certificate_number)
else:
return found_uprn
@ -124,7 +132,7 @@ def get_uprn(
"""
df = get_epc_data_with_postcode(postcode=postcode)
result: Optional[tuple[str, str, float]] = get_uprn_with_epc_df(
result: Optional[tuple[str, str, float, Optional[str]]] = get_uprn_with_epc_df(
user_inputed_address=user_inputed_address,
epc_df=df,
verbose=True,
@ -397,6 +405,7 @@ def handler(event, context, local=False):
"address2uprn_uprn": "invalid postcode",
"address2uprn_address": "invalid postcode",
"address2uprn_lexiscore": "invalid postcode",
"address2uprn_certificate_number": "invalid postcode",
"address2uprn_status": "invalid_postcode",
}
)
@ -439,7 +448,9 @@ def handler(event, context, local=False):
continue
# Get UPRN using the pre-fetched EPC data with all return options
result: Optional[tuple[str, str, float]] = get_uprn_with_epc_df(
result: Optional[
tuple[str, str, float, Optional[str]]
] = get_uprn_with_epc_df(
user_inputed_address=address2uprn_user_input,
epc_df=epc_df,
verbose=True,
@ -468,7 +479,7 @@ def handler(event, context, local=False):
# Parse result tuple if successful
if result:
uprn, found_address, score = result
uprn, found_address, score, certificate_number = result
logger.info(
f"Found UPRN for {address2uprn_user_input} in {postcode}: {uprn} (score: {score})"
)
@ -478,6 +489,7 @@ def handler(event, context, local=False):
"uprn": uprn,
"address": found_address,
"lexiscore": score,
"certificate_number": certificate_number,
"norm_address": norm_address,
"error": None,
}
@ -492,6 +504,7 @@ def handler(event, context, local=False):
"uprn": None,
"address": None,
"lexiscore": None,
"certificate_number": None,
"norm_address": norm_address,
"error": None,
}
@ -508,6 +521,7 @@ def handler(event, context, local=False):
"uprn": None,
"address": None,
"lexiscore": None,
"certificate_number": None,
"norm_address": "",
"error": str(e),
}
@ -526,6 +540,9 @@ def handler(event, context, local=False):
"address2uprn_uprn": final_uprn,
"address2uprn_address": rec["address"] if final_uprn else None,
"address2uprn_lexiscore": rec["lexiscore"],
"address2uprn_certificate_number": (
rec["certificate_number"] if final_uprn else None
),
"address2uprn_status": (
"error" if rec["error"] is not None else status
),

View file

@ -49,9 +49,10 @@ class HistoricEpcResolver:
def resolve_uprn(
self, user_address: str, postcode: str
) -> Optional[tuple[str, str, float]]:
"""``(uprn, matched_address, lexiscore)`` for an unambiguous rank-1
match, else None (no data / ambiguous tie / zero score)."""
) -> Optional[tuple[str, str, float, Optional[str]]]:
"""``(uprn, matched_address, lexiscore, certificate_number)`` for an
unambiguous rank-1 match, else None (no data / ambiguous tie / zero
score). ``certificate_number`` is the historic dataset's ``lmk_key``."""
matches: HistoricEpcMatches = self.match(user_address, postcode)
uprn: Optional[str] = matches.unambiguous_uprn()
if not uprn or uprn == "nan":
@ -59,4 +60,4 @@ class HistoricEpcResolver:
top: Optional[ScoredHistoricEpc] = matches.top()
if top is None:
return None
return uprn, top.record.address, top.lexiscore
return uprn, top.record.address, top.lexiscore, top.record.lmk_key

View file

@ -73,10 +73,11 @@ def test_resolve_uprn_returns_unambiguous_match():
# Assert
assert result is not None
uprn, address, score = result
uprn, address, score, certificate_number = result
assert uprn == "100"
assert address == "47 GORDON ROAD"
assert score > 0
assert certificate_number == ""
def test_resolve_uprn_is_none_when_postcode_has_no_data():