From a1ce8ece5042ab668b256aaaae210352a7041e32 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 16 Jun 2026 15:07:09 +0000 Subject: [PATCH] =?UTF-8?q?Map=20landlord-override=20property=20type=20and?= =?UTF-8?q?=20built=20form=20to=20gov=20EPC=20codes=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/override_code_mapping.py | 56 +++++++++++ tests/domain/epc/__init__.py | 0 .../domain/epc/test_override_code_mapping.py | 93 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 domain/epc/override_code_mapping.py create mode 100644 tests/domain/epc/__init__.py create mode 100644 tests/domain/epc/test_override_code_mapping.py diff --git a/domain/epc/override_code_mapping.py b/domain/epc/override_code_mapping.py new file mode 100644 index 00000000..986e3aa0 --- /dev/null +++ b/domain/epc/override_code_mapping.py @@ -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) diff --git a/tests/domain/epc/__init__.py b/tests/domain/epc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/domain/epc/test_override_code_mapping.py b/tests/domain/epc/test_override_code_mapping.py new file mode 100644 index 00000000..b22ad0a5 --- /dev/null +++ b/tests/domain/epc/test_override_code_mapping.py @@ -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