Map landlord-override property type and built form to gov EPC codes 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-16 15:07:09 +00:00
parent 90bed458f4
commit a1ce8ece50
3 changed files with 149 additions and 0 deletions

View file

@ -0,0 +1,56 @@
"""Map a Landlord-Override enum *value* to the gov-EPC API *code* space.
The `property_overrides` fact layer stores resolved overrides as enum-value
strings ("House", "Detached"); the EPC-API cohort certs carry numeric codes
("0", "2") `EpcPropertyData.property_type = str(schema.property_type)`. EPC
Prediction filters `comparable.epc.property_type == target.property_type`, so a
target attribute sourced from overrides must be translated into the code space
or no comparable ever matches (ADR-0031, the wiring-handover "every cohort
comes back empty" gotcha).
Codes are the gov RdSAP/SAP table values in `datatypes/epc/domain/epc_codes.csv`.
This module owns "unresolvable": a value that maps to no code returns None.
"""
from __future__ import annotations
from typing import Optional
# property_type codes (epc_codes.csv, `property_type` rows — stable across the
# RdSAP/SAP schemas that carry each member). "Park home" exists only from
# SAP-17.0 / RdSAP-17.0 onward; the code itself is stable where present.
_PROPERTY_TYPE_CODES: dict[str, str] = {
"House": "0",
"Bungalow": "1",
"Flat": "2",
"Maisonette": "3",
"Park home": "4",
}
# built_form codes (epc_codes.csv, `built_form` rows). "Not Recorded" lodges as
# the non-numeric "NR", but cohort comparables carry `str(int)` for built_form,
# so an "NR" target could never match — and built_form is the SOFT filter, so a
# non-match only widens the cohort. We therefore treat "Not Recorded" (and the
# classifier "Unknown") as "no usable built-form signal" → None.
_BUILT_FORM_CODES: dict[str, str] = {
"Detached": "1",
"Semi-Detached": "2",
"End-Terrace": "3",
"Mid-Terrace": "4",
"Enclosed End-Terrace": "5",
"Enclosed Mid-Terrace": "6",
}
def property_type_to_code(override_value: str) -> Optional[str]:
"""The gov-EPC `property_type` code for a Landlord-Override value, or None
when it has no code ("Unknown", or any unmapped value) which gates the
Property out of prediction, as `property_type` is the hard cohort filter."""
return _PROPERTY_TYPE_CODES.get(override_value)
def built_form_to_code(override_value: str) -> Optional[str]:
"""The gov-EPC `built_form` code for a Landlord-Override value, or None when
it has no usable code ("Unknown", "Not Recorded", or any unmapped value).
built_form is the soft filter, so None simply leaves it unconditioned."""
return _BUILT_FORM_CODES.get(override_value)

View file

View file

@ -0,0 +1,93 @@
"""The Landlord-Override value → gov-EPC code mapping (ADR-0031 wiring).
`property_type` is the HARD cohort filter, so its mapping is exhaustive over
`PropertyType` and the only one that can silently empty a cohort; `built_form`
is the SOFT filter. Both collapse an unresolvable value to None gating lives
downstream, the mapping just reports "no usable code".
"""
from __future__ import annotations
from typing import Optional
import pytest
from domain.epc.built_form_type import BuiltFormType
from domain.epc.override_code_mapping import (
built_form_to_code,
property_type_to_code,
)
from domain.epc.property_type import PropertyType
def test_house_maps_to_gov_code_zero() -> None:
# Act
code = property_type_to_code("House")
# Assert
assert code == "0"
@pytest.mark.parametrize(
("override_value", "expected_code"),
[
(PropertyType.HOUSE.value, "0"),
(PropertyType.BUNGALOW.value, "1"),
(PropertyType.FLAT.value, "2"),
(PropertyType.MAISONETTE.value, "3"),
(PropertyType.PARK_HOME.value, "4"),
],
)
def test_each_resolvable_property_type_maps_to_its_gov_code(
override_value: str, expected_code: str
) -> None:
# Act
code = property_type_to_code(override_value)
# Assert
assert code == expected_code
@pytest.mark.parametrize(
"override_value",
[PropertyType.UNKNOWN.value, "Castle", ""],
)
def test_unresolvable_property_type_has_no_code(override_value: str) -> None:
# Act
code = property_type_to_code(override_value)
# Assert
assert code is None
@pytest.mark.parametrize(
("override_value", "expected_code"),
[
(BuiltFormType.DETACHED.value, "1"),
(BuiltFormType.SEMI_DETACHED.value, "2"),
(BuiltFormType.END_TERRACE.value, "3"),
(BuiltFormType.MID_TERRACE.value, "4"),
(BuiltFormType.ENCLOSED_END_TERRACE.value, "5"),
(BuiltFormType.ENCLOSED_MID_TERRACE.value, "6"),
],
)
def test_each_resolvable_built_form_maps_to_its_gov_code(
override_value: str, expected_code: str
) -> None:
# Act
code = built_form_to_code(override_value)
# Assert
assert code == expected_code
@pytest.mark.parametrize(
"override_value",
[BuiltFormType.UNKNOWN.value, BuiltFormType.NOT_RECORDED.value, "Castle", ""],
)
def test_built_form_without_usable_code_returns_none(override_value: str) -> None:
# Act
code: Optional[str] = built_form_to_code(override_value)
# Assert
assert code is None