Model/backend/address2UPRN/scoring.py
Jun-te Kim dbcdf29bd9 refactor(address2uprn): name the match/decision return types; rename helper
Address PR review (dancafc):
- introduce UprnMatch NamedTuple (datatypes/address_match.py) for the
  (uprn, address, lexiscore, certificate_number) return, replacing the bare
  4-tuple in get_uprn_from_epc_df / get_uprn_from_historic_epc /
  HistoricEpcResolver.resolve_uprn. Tuple-compatible, so unpacking is unchanged.
- rename get_uprn_with_epc_df -> get_uprn_from_epc_df (+ callers).
- type resolve_group_ambiguity via a GroupDecision NamedTuple and trim its
  docstring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:00:00 +00:00

93 lines
2.8 KiB
Python

from collections import defaultdict
from typing import NamedTuple, Optional
import pandas as pd
from backend.utils.addressMatch import AddressMatch
class GroupDecision(NamedTuple):
"""One row's outcome after cross-row ambiguity resolution (ADR-0057)."""
uprn: Optional[str]
status: str # "matched" | "ambiguous_duplicate" | "unmatched"
def resolve_group_ambiguity(
matches: list[tuple[Optional[str], str]],
) -> list[GroupDecision]:
"""Resolve cross-row UPRN ambiguity within one postcode group (ADR-0057).
``matches`` is ``(uprn, normalised_address)`` per row. A UPRN that is the
best match for two rows with *different* normalised addresses is withheld
on both (a coarse EPC record absorbing several real addresses, e.g. flats in
a block); a UPRN shared only by identical addresses is a genuine re-listing
and kept. Returns a ``GroupDecision`` per row, in input order.
"""
distinct_addresses: dict[str, set[str]] = defaultdict(set)
for uprn, norm_address in matches:
if uprn:
distinct_addresses[uprn].add(norm_address)
resolved: list[GroupDecision] = []
for uprn, _norm_address in matches:
if not uprn:
resolved.append(GroupDecision(None, "unmatched"))
elif len(distinct_addresses[uprn]) > 1:
resolved.append(GroupDecision(None, "ambiguous_duplicate"))
else:
resolved.append(GroupDecision(uprn, "matched"))
return resolved
def all_uprns_match(
df: pd.DataFrame,
target_uprn: str,
column: str = "uprn",
) -> bool:
if column not in df.columns:
return False
uprns = df[column].dropna().astype(str).str.strip().unique()
if len(uprns) == 0:
return False
return len(uprns) == 1 and uprns[0] == str(target_uprn)
def rank_address_similarity(
address_list_df: pd.DataFrame,
user_address: str,
address_column: str = "address",
uprn_column: str = "uprn",
) -> pd.DataFrame:
"""
Annotate EPC results with lexicographical similarity scores and ranks.
Returns a DataFrame sorted by descending lexiscore.
DOES NOT choose or return a UPRN.
"""
if address_column not in address_list_df.columns:
raise ValueError(f"Missing column: {address_column}")
if uprn_column not in address_list_df.columns:
raise ValueError(f"Missing column: {uprn_column}")
out = address_list_df.copy()
user_norm = AddressMatch.normalise_address(user_address)
out["lexiscore"] = out[address_column].apply(
lambda x: AddressMatch.levenshtein(user_norm, x)
)
out[uprn_column] = out[uprn_column].astype(str).str.replace(r"\.0$", "", regex=True)
out["lexirank"] = out["lexiscore"].rank(method="dense", ascending=False).astype(int)
return out.sort_values(
["lexirank", "lexiscore"],
ascending=[True, False],
)