mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Carry the EPC certificate number through address2uprn to property
EpcClientService.search_by_postcode already returns the matched certificate number alongside the UPRN, but it was dropped before persistence. Thread it through get_epc_data_with_postcode -> get_uprn_with_epc_df / get_uprn_from_historic_epc (using the historic dataset's lmk_key) -> the address2uprn_certificate_number result column -> PropertyIdentityInsert -> the property table's new certificate_number column (assessment-model PR #362). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
bc1cca77db
commit
3718743801
8 changed files with 52 additions and 20 deletions
|
|
@ -34,22 +34,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)
|
||||
|
|
@ -59,7 +66,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.
|
||||
|
|
@ -88,6 +95,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
|
||||
|
|
@ -98,7 +106,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
|
||||
|
||||
|
|
@ -120,7 +128,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,
|
||||
|
|
@ -393,6 +401,7 @@ def handler(event, context, local=False):
|
|||
"address2uprn_uprn": "invalid postcode",
|
||||
"address2uprn_address": "invalid postcode",
|
||||
"address2uprn_lexiscore": "invalid postcode",
|
||||
"address2uprn_certificate_number": "invalid postcode",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
|
@ -428,7 +437,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,
|
||||
|
|
@ -453,7 +464,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})"
|
||||
)
|
||||
|
|
@ -464,6 +475,7 @@ def handler(event, context, local=False):
|
|||
"address2uprn_uprn": uprn,
|
||||
"address2uprn_address": found_address,
|
||||
"address2uprn_lexiscore": score,
|
||||
"address2uprn_certificate_number": certificate_number,
|
||||
}
|
||||
)
|
||||
else:
|
||||
|
|
@ -476,6 +488,7 @@ def handler(event, context, local=False):
|
|||
"address2uprn_uprn": None,
|
||||
"address2uprn_address": None,
|
||||
"address2uprn_lexiscore": None,
|
||||
"address2uprn_certificate_number": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -490,6 +503,7 @@ def handler(event, context, local=False):
|
|||
"address2uprn_uprn": None,
|
||||
"address2uprn_address": None,
|
||||
"address2uprn_lexiscore": None,
|
||||
"address2uprn_certificate_number": None,
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class PropertyRow(SQLModel, table=True):
|
|||
user_inputted_address: Optional[str] = Field(default=None)
|
||||
user_inputted_postcode: Optional[str] = Field(default=None)
|
||||
lexiscore: Optional[float] = Field(default=None)
|
||||
certificate_number: Optional[str] = Field(default=None)
|
||||
|
||||
# FE-owned columns the modelling pipeline now WRITES to record a run: the old
|
||||
# engine set `has_recommendations` (engine.py); we mirror that, and bump
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ INTERNAL_REF_COL = "Internal Reference"
|
|||
UPRN_COL = "address2uprn_uprn"
|
||||
MATCHED_ADDRESS_COL = "address2uprn_address"
|
||||
LEXISCORE_COL = "address2uprn_lexiscore"
|
||||
CERTIFICATE_NUMBER_COL = "address2uprn_certificate_number"
|
||||
MISSING_SENTINEL = "invalid postcode"
|
||||
UK_POSTCODE_RE = re.compile(r"[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}", re.IGNORECASE)
|
||||
|
||||
|
|
@ -362,6 +363,11 @@ class BulkUploadFinaliserOrchestrator:
|
|||
internal_ref = _normalize(raw.get(INTERNAL_REF_COL)) or None
|
||||
lexiscore = _parse_lexiscore(raw.get(LEXISCORE_COL))
|
||||
|
||||
certificate_number_raw = _normalize(raw.get(CERTIFICATE_NUMBER_COL))
|
||||
certificate_number = (
|
||||
None if _is_missing(certificate_number_raw) else certificate_number_raw
|
||||
)
|
||||
|
||||
return PropertyIdentityInsert(
|
||||
portfolio_id=portfolio_id,
|
||||
uprn=uprn,
|
||||
|
|
@ -371,5 +377,6 @@ class BulkUploadFinaliserOrchestrator:
|
|||
user_inputted_address=user_inputted_address,
|
||||
user_inputted_postcode=user_inputted_postcode,
|
||||
lexiscore=lexiscore,
|
||||
certificate_number=certificate_number,
|
||||
creation_status="READY",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ class PropertyPostgresRepository(PropertyRepository):
|
|||
"user_inputted_address": r.user_inputted_address,
|
||||
"user_inputted_postcode": r.user_inputted_postcode,
|
||||
"lexiscore": r.lexiscore,
|
||||
"certificate_number": r.certificate_number,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ class PropertyIdentityInsert:
|
|||
"""One row inserted into the FE-owned ``property`` table at Finalise (ADR-0013).
|
||||
|
||||
Mirrors the exact column set today's Next.js ``/finalize`` writes: nine fields
|
||||
plus ``creation_status='READY'``. ``address``/``postcode`` are the resolved
|
||||
(matched ?? user-inputted) values and may be ``None``.
|
||||
plus ``creation_status='READY'``, plus ``certificate_number`` (the EPC
|
||||
certificate the address2uprn lookup matched, not written by the old
|
||||
Next.js route). ``address``/``postcode`` are the resolved (matched ??
|
||||
user-inputted) values and may be ``None``.
|
||||
"""
|
||||
|
||||
portfolio_id: int
|
||||
|
|
@ -25,6 +27,7 @@ class PropertyIdentityInsert:
|
|||
user_inputted_address: Optional[str]
|
||||
user_inputted_postcode: Optional[str]
|
||||
lexiscore: Optional[float]
|
||||
certificate_number: Optional[str] = None
|
||||
creation_status: str = "READY"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ def test_finalise_inserts_resolved_rows_and_marks_complete() -> None:
|
|||
"address2uprn_uprn": "100023",
|
||||
"address2uprn_address": "1 SOME STREET, TOWN, SW1A 1AA",
|
||||
"address2uprn_lexiscore": "0.95",
|
||||
"address2uprn_certificate_number": "1234-5678-9012-3456-7890",
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -114,6 +115,7 @@ def test_finalise_inserts_resolved_rows_and_marks_complete() -> None:
|
|||
assert row.postcode == "SW1A 1AA" # extracted from the matched address
|
||||
assert row.user_inputted_address == "1 Some Street"
|
||||
assert row.lexiscore == 0.95
|
||||
assert row.certificate_number == "1234-5678-9012-3456-7890"
|
||||
assert row.creation_status == "READY"
|
||||
# The upload is marked complete via the injected status writer.
|
||||
assert status.calls == [(task_id, "complete")]
|
||||
|
|
@ -128,6 +130,7 @@ def test_finalise_falls_back_to_user_input_when_unmatched() -> None:
|
|||
"address2uprn_uprn": "invalid postcode",
|
||||
"address2uprn_address": "invalid postcode",
|
||||
"address2uprn_lexiscore": "",
|
||||
"address2uprn_certificate_number": "invalid postcode",
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -138,6 +141,7 @@ def test_finalise_falls_back_to_user_input_when_unmatched() -> None:
|
|||
assert row.address == "2 Other Road" # falls back to user input
|
||||
assert row.postcode == "B1 1BB" # falls back to user-inputted postcode
|
||||
assert row.lexiscore is None
|
||||
assert row.certificate_number is None # missing sentinel -> None
|
||||
|
||||
|
||||
def test_finalise_parses_pandas_float_uprn() -> None:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue