diff --git a/.idea/Model.iml b/.idea/Model.iml
index 09f2e496..c6561970 100644
--- a/.idea/Model.iml
+++ b/.idea/Model.iml
@@ -7,7 +7,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index fb10c6b0..50cad4ca 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -3,7 +3,7 @@
-
+
diff --git a/backend/onboarders/__init__.py b/backend/onboarders/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/onboarders/epc_descriptions.py b/backend/onboarders/epc_descriptions.py
index d2237880..c6fe9de9 100644
--- a/backend/onboarders/epc_descriptions.py
+++ b/backend/onboarders/epc_descriptions.py
@@ -1,3 +1,4 @@
+import pandas as pd
import re
from collections.abc import Mapping
from enum import Enum
@@ -211,12 +212,6 @@ class EpcEfficiency(Enum):
NA = "N/A"
-EfficiencyRule = Union[
- EpcEfficiency,
- Callable[[EpcConstructionAgeBand], EpcEfficiency],
-]
-
-
def cavity_filled_efficiency(age_band: EpcConstructionAgeBand) -> EpcEfficiency:
""""
Maps cavity filled to efficiency based on construction age band.
@@ -343,6 +338,16 @@ WALL_DESCRIPTION_EFFICIENCIES: Mapping[EpcWallDescriptions, WallEfficiencyRule]
# Cob (special case)
EpcWallDescriptions.cob_as_built_average: EpcEfficiency.AVERAGE,
EpcWallDescriptions.cob_as_built_good: EpcEfficiency.GOOD,
+
+ # Unknown mappings which are unhandled
+ EpcWallDescriptions.cavity_as_built_unknown: EpcEfficiency.NA,
+ EpcWallDescriptions.solid_brick_as_built_unknown: EpcEfficiency.NA,
+ EpcWallDescriptions.system_as_built_unknown: EpcEfficiency.NA,
+ EpcWallDescriptions.timber_frame_as_built_unknown: EpcEfficiency.NA,
+ EpcWallDescriptions.granite_as_built_unknown: EpcEfficiency.NA,
+ EpcWallDescriptions.sandstone_as_built_unknown: EpcEfficiency.NA,
+ EpcWallDescriptions.cob_as_built_unknown: EpcEfficiency.NA,
+
}
@@ -676,3 +681,37 @@ ROOF_DESCRIPTION_EFFICIENCIES: Mapping[EpcRoofDescriptions, RoofEfficiencyRule]
EpcRoofDescriptions.sloping_pitched_no_insulation: EpcEfficiency.VERY_POOR,
}
+
+
+def resolve_roof_efficiency(
+ description: EpcRoofDescriptions,
+ age_band: EpcConstructionAgeBand | None,
+ insulation_thickness: int | None,
+) -> EpcEfficiency:
+ """
+ Resolve roof efficiency from description + age band + insulation thickness.
+ """
+
+ # Unknown / holding descriptions → efficiency unknown
+ if description in description.unknown_descriptions:
+ return EpcEfficiency.NA
+
+ rule = ROOF_DESCRIPTION_EFFICIENCIES.get(description)
+
+ if rule is None:
+ return EpcEfficiency.NA
+
+ # Fixed efficiency
+ if isinstance(rule, EpcEfficiency):
+ return rule
+
+ # Callable rule
+ if age_band is None or pd.isnull(age_band):
+ return EpcEfficiency.NA
+
+ try:
+ # Try (thickness, age_band)
+ return rule(insulation_thickness, age_band)
+ except TypeError:
+ # Fallback to (age_band)
+ return rule(age_band)
diff --git a/backend/onboarders/mappings/as_built_roof_classifiers.py b/backend/onboarders/mappings/as_built_roof_classifiers.py
new file mode 100644
index 00000000..7c672ce5
--- /dev/null
+++ b/backend/onboarders/mappings/as_built_roof_classifiers.py
@@ -0,0 +1,55 @@
+from backend.onboarders.epc_descriptions import EpcConstructionAgeBand, EpcRoofDescriptions
+
+
+def classify_flat_roof(age_band: EpcConstructionAgeBand) -> EpcRoofDescriptions:
+ """
+ For a flat, as built roof, these are the breakdowns:
+
+ 2023 onwards → Flat, insulated
+ 2003–2022 → Flat, insulated
+ 1983–2002 → Flat, insulated
+ 1976–1982 → Flat, limited insulation
+ 1967–1975 → Flat, limited insulation
+ 1950–1966 and earlier → Flat, no insulation
+ :param age_band: Input age band
+ :return: EpcRoofDescriptions
+ """
+
+ year = age_band.start_year()
+
+ if year >= 1983:
+ return EpcRoofDescriptions.flat_insulated
+
+ if year >= 1967:
+ return EpcRoofDescriptions.flat_limited_insulation
+
+ return EpcRoofDescriptions.flat_no_insulation
+
+
+def classify_sloping_ceiling_roof(age_band: EpcConstructionAgeBand) -> EpcRoofDescriptions:
+ """
+ For a sloping ceiling, as built roof, these are the breakdowns:
+ 2023 onwards → Sloping pitched, insulated
+ 2003–2022 → Sloping pitched, insulated
+ 1983–2002 → Sloping pitched, insulated
+ 1976–1982 → Sloping pitched, limited insulation
+ 1967–1975 and earlier → Sloping pitched, no insulation
+ :param age_band: Input age band
+ :return: EpcRoofDescriptions
+ """
+ year = age_band.start_year()
+
+ if year >= 1983:
+ return EpcRoofDescriptions.sloping_pitched_insulated
+
+ if year >= 1976:
+ return EpcRoofDescriptions.sloping_pitched_limited_insulation
+
+ return EpcRoofDescriptions.sloping_pitched_no_insulation
+
+
+AS_BUILT_ROOF_CLASSIFIERS = {
+ # Only need to apply this to flat and sloping ceiling roofs
+ "Flat": classify_flat_roof,
+ "PitchedWithSlopingCeiling": classify_sloping_ceiling_roof,
+}
diff --git a/backend/onboarders/mappings/as_built_wall_classifiers.py b/backend/onboarders/mappings/as_built_wall_classifiers.py
index e69de29b..f907a533 100644
--- a/backend/onboarders/mappings/as_built_wall_classifiers.py
+++ b/backend/onboarders/mappings/as_built_wall_classifiers.py
@@ -0,0 +1,112 @@
+from backend.onboarders.epc_descriptions import EpcConstructionAgeBand, EpcWallDescriptions
+
+
+def map_cavity_wall_insulation(age_band: EpcConstructionAgeBand):
+ if age_band.start_year() < 1976:
+ return EpcWallDescriptions.cavity_no_insulation_assumed
+
+ if age_band == EpcConstructionAgeBand.from_1976_to_1982:
+ return EpcWallDescriptions.cavity_partial_insulated_assumed
+
+ if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
+ return EpcWallDescriptions.cavity_insulated_assumed
+
+ raise NotImplementedError(f"Age band {age_band} not handled for cavity wall as built insulation mapping")
+
+
+def map_solid_wall_insulation(age_band: EpcConstructionAgeBand):
+ if age_band.start_year() < 1976:
+ return EpcWallDescriptions.solid_brick_no_insulation_assumed
+
+ if age_band == EpcConstructionAgeBand.from_1976_to_1982:
+ return EpcWallDescriptions.solid_brick_partial_insulated_assumed
+
+ if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
+ return EpcWallDescriptions.solid_brick_insulated_assumed
+
+ raise NotImplementedError(
+ f"Age band {age_band.value} not handled for solid wall insulation mapping"
+ )
+
+
+def map_timber_frame_wall_insulation(age_band: EpcConstructionAgeBand):
+ if age_band.start_year() < 1950:
+ return EpcWallDescriptions.timber_frame_no_insulation_assumed
+
+ if age_band.start_year() < 1976:
+ return EpcWallDescriptions.timber_frame_partial_insulated_assumed
+
+ if age_band in EpcConstructionAgeBand.from_year_onwards(1976):
+ return EpcWallDescriptions.timber_frame_insulated_assumed
+
+ raise NotImplementedError(
+ f"Age band {age_band.value} not handled for timber frame wall insulation mapping"
+ )
+
+
+def map_system_build_wall_insulation(age_band: EpcConstructionAgeBand):
+ if age_band.start_year() < 1976:
+ return EpcWallDescriptions.system_no_insulation_assumed
+
+ if age_band == EpcConstructionAgeBand.from_1976_to_1982:
+ return EpcWallDescriptions.system_partial_insulated_assumed
+
+ if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
+ return EpcWallDescriptions.system_insulated_assumed
+
+ raise NotImplementedError(
+ f"Age band {age_band.value} not handled for system build wall insulation mapping"
+ )
+
+
+def map_granite_wall_insulation(age_band: EpcConstructionAgeBand):
+ if age_band.start_year() < 1976:
+ return EpcWallDescriptions.granite_whinstone_no_insulation_assumed
+
+ if age_band == EpcConstructionAgeBand.from_1976_to_1982:
+ return EpcWallDescriptions.granite_whinstone_partial_insulated_assumed
+
+ if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
+ return EpcWallDescriptions.granite_whinestone_insulated_assumed
+
+ raise NotImplementedError(
+ f"Age band {age_band.value} not handled for granite wall insulation mapping"
+ )
+
+
+def map_sandstone_wall_insulation(age_band: EpcConstructionAgeBand):
+ if age_band.start_year() < 1976:
+ return EpcWallDescriptions.sandstone_limestone_no_insulation_assumed
+
+ if age_band == EpcConstructionAgeBand.from_1976_to_1982:
+ return EpcWallDescriptions.sandstone_limestone_partial_insulated_assumed
+
+ if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
+ return EpcWallDescriptions.sandstone_limestone_insulated_assumed
+
+ raise NotImplementedError(
+ f"Age band {age_band.value} not handled for sandstone wall insulation mapping"
+ )
+
+
+def map_cob_wall_insulation(age_band: EpcConstructionAgeBand):
+ if age_band.start_year() < 1983:
+ return EpcWallDescriptions.cob_as_built_average
+
+ if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
+ return EpcWallDescriptions.cob_as_built_good
+
+ raise NotImplementedError(
+ f"Age band {age_band.value} not handled for cob wall insulation mapping"
+ )
+
+
+AS_BUILT_WALL_CLASSIFIERS = {
+ "Cavity": map_cavity_wall_insulation,
+ "Solid Brick": map_solid_wall_insulation,
+ "Timber Frame": map_timber_frame_wall_insulation,
+ "System": map_system_build_wall_insulation,
+ "Granite": map_granite_wall_insulation,
+ "Sandstone": map_sandstone_wall_insulation,
+ "Cob": map_cob_wall_insulation,
+}
diff --git a/backend/onboarders/parity.py b/backend/onboarders/parity.py
index c3b4184d..69a64a89 100644
--- a/backend/onboarders/parity.py
+++ b/backend/onboarders/parity.py
@@ -1,3 +1,4 @@
+import re
from numpy import nan
from tqdm import tqdm
import pandas as pd
@@ -5,8 +6,9 @@ from backend.onboarders.mappings.property_type import parity_map as property_map
from backend.onboarders.mappings.age_band import parity_map as age_band_map
from backend.onboarders.mappings.built_form import parity_map as built_form_map
from backend.onboarders.epc_descriptions import EpcWallDescriptions, EpcConstructionAgeBand, EpcEfficiency, \
- WALL_DESCRIPTION_EFFICIENCIES
-from onboarders.epc_descriptions import EpcRoofDescriptions
+ WALL_DESCRIPTION_EFFICIENCIES, EpcRoofDescriptions, resolve_roof_efficiency
+from backend.onboarders.mappings.as_built_wall_classifiers import AS_BUILT_WALL_CLASSIFIERS
+from backend.onboarders.mappings.as_built_roof_classifiers import AS_BUILT_ROOF_CLASSIFIERS
tqdm.pandas()
@@ -97,117 +99,6 @@ wall_mapping = {
('Cob', 'AsBuilt'): None,
}
-
-def map_cavity_wall_insulation(age_band: EpcConstructionAgeBand):
- if age_band.start_year() < 1976:
- return EpcWallDescriptions.cavity_no_insulation_assumed
-
- if age_band == EpcConstructionAgeBand.from_1976_to_1982:
- return EpcWallDescriptions.cavity_partial_insulated_assumed
-
- if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
- return EpcWallDescriptions.cavity_insulated_assumed
-
- raise NotImplementedError(f"Age band {age_band} not handled for cavity wall as built insulation mapping")
-
-
-def map_solid_wall_insulation(age_band: EpcConstructionAgeBand):
- if age_band.start_year() < 1976:
- return EpcWallDescriptions.solid_brick_no_insulation_assumed
-
- if age_band == EpcConstructionAgeBand.from_1976_to_1982:
- return EpcWallDescriptions.solid_brick_partial_insulated_assumed
-
- if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
- return EpcWallDescriptions.solid_brick_insulated_assumed
-
- raise NotImplementedError(
- f"Age band {age_band.value} not handled for solid wall insulation mapping"
- )
-
-
-def map_timber_frame_wall_insulation(age_band: EpcConstructionAgeBand):
- if age_band.start_year() < 1950:
- return EpcWallDescriptions.timber_frame_no_insulation_assumed
-
- if age_band.start_year() < 1976:
- return EpcWallDescriptions.timber_frame_partial_insulated_assumed
-
- if age_band in EpcConstructionAgeBand.from_year_onwards(1976):
- return EpcWallDescriptions.timber_frame_insulated_assumed
-
- raise NotImplementedError(
- f"Age band {age_band.value} not handled for timber frame wall insulation mapping"
- )
-
-
-def map_system_build_wall_insulation(age_band: EpcConstructionAgeBand):
- if age_band.start_year() < 1976:
- return EpcWallDescriptions.system_no_insulation_assumed
-
- if age_band == EpcConstructionAgeBand.from_1976_to_1982:
- return EpcWallDescriptions.system_partial_insulated_assumed
-
- if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
- return EpcWallDescriptions.system_insulated_assumed
-
- raise NotImplementedError(
- f"Age band {age_band.value} not handled for system build wall insulation mapping"
- )
-
-
-def map_granite_wall_insulation(age_band: EpcConstructionAgeBand):
- if age_band.start_year() < 1976:
- return EpcWallDescriptions.granite_whinstone_no_insulation_assumed
-
- if age_band == EpcConstructionAgeBand.from_1976_to_1982:
- return EpcWallDescriptions.granite_whinstone_partial_insulated_assumed
-
- if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
- return EpcWallDescriptions.granite_whinestone_insulated_assumed
-
- raise NotImplementedError(
- f"Age band {age_band.value} not handled for granite wall insulation mapping"
- )
-
-
-def map_sandstone_wall_insulation(age_band: EpcConstructionAgeBand):
- if age_band.start_year() < 1976:
- return EpcWallDescriptions.sandstone_limestone_no_insulation_assumed
-
- if age_band == EpcConstructionAgeBand.from_1976_to_1982:
- return EpcWallDescriptions.sandstone_limestone_partial_insulated_assumed
-
- if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
- return EpcWallDescriptions.sandstone_limestone_insulated_assumed
-
- raise NotImplementedError(
- f"Age band {age_band.value} not handled for sandstone wall insulation mapping"
- )
-
-
-def map_cob_wall_insulation(age_band: EpcConstructionAgeBand):
- if age_band.start_year() < 1983:
- return EpcWallDescriptions.cob_as_built_average
-
- if age_band in EpcConstructionAgeBand.from_year_onwards(1983):
- return EpcWallDescriptions.cob_as_built_good
-
- raise NotImplementedError(
- f"Age band {age_band.value} not handled for cob wall insulation mapping"
- )
-
-
-AS_BUILT_WALL_CLASSIFIERS = {
- "Cavity": map_cavity_wall_insulation,
- "Solid Brick": map_solid_wall_insulation,
- "Timber Frame": map_timber_frame_wall_insulation,
- "System": map_system_build_wall_insulation,
- "Granite": map_granite_wall_insulation,
- "Sandstone": map_sandstone_wall_insulation,
- "Cob": map_cob_wall_insulation,
-}
-
WALL_UNKNOWN_AGE_FALLBACK = {
"Cavity": EpcWallDescriptions.cavity_as_built_unknown,
"Solid Brick": EpcWallDescriptions.solid_brick_as_built_unknown,
@@ -378,60 +269,6 @@ roof_mapping = {
('PitchedWithSlopingCeiling', 'Unknown'): None, #
}
-
-def classify_flat_roof(age_band: EpcConstructionAgeBand) -> EpcRoofDescriptions:
- """
- For a flat, as built roof, these are the breakdowns:
-
- 2023 onwards → Flat, insulated
- 2003–2022 → Flat, insulated
- 1983–2002 → Flat, insulated
- 1976–1982 → Flat, limited insulation
- 1967–1975 → Flat, limited insulation
- 1950–1966 and earlier → Flat, no insulation
- :param age_band: Input age band
- :return: EpcRoofDescriptions
- """
-
- year = age_band.start_year()
-
- if year >= 1983:
- return EpcRoofDescriptions.flat_insulated
-
- if year >= 1967:
- return EpcRoofDescriptions.flat_limited_insulation
-
- return EpcRoofDescriptions.flat_no_insulation
-
-
-def classify_sloping_ceiling_roof(age_band: EpcConstructionAgeBand) -> EpcRoofDescriptions:
- """
- For a sloping ceiling, as built roof, these are the breakdowns:
- 2023 onwards → Sloping pitched, insulated
- 2003–2022 → Sloping pitched, insulated
- 1983–2002 → Sloping pitched, insulated
- 1976–1982 → Sloping pitched, limited insulation
- 1967–1975 and earlier → Sloping pitched, no insulation
- :param age_band: Input age band
- :return: EpcRoofDescriptions
- """
- year = age_band.start_year()
-
- if year >= 1983:
- return EpcRoofDescriptions.sloping_pitched_insulated
-
- if year >= 1976:
- return EpcRoofDescriptions.sloping_pitched_limited_insulation
-
- return EpcRoofDescriptions.sloping_pitched_no_insulation
-
-
-AS_BUILT_ROOF_CLASSIFIERS = {
- # Only need to apply this to flat and sloping ceiling roofs
- "Flat": classify_flat_roof,
- "PitchedWithSlopingCeiling": classify_sloping_ceiling_roof,
-}
-
ROOF_UNKNOWN_AGE_FALLBACK = {
"Flat": EpcRoofDescriptions.flat_as_built_unknown,
"PitchedWithSlopingCeiling": EpcRoofDescriptions.sloping_pitched_as_built_unknown,
@@ -478,13 +315,45 @@ data["landlord_roof_description"] = data.progress_apply(
assert data["landlord_roof_description"].isnull().sum() == 0, (
"Some roof descriptions could not be resolved"
)
-# TODO: 1) Map energy efficiency
-# TODO: 2) Flag sloped ceilings
+
+
+def extract_insulation_thickness(value: str | None) -> int | None:
+ """
+ Extract insulation thickness in mm from a string like 'mm150'.
+ Returns None if not present or not parseable.
+ """
+ if value is None or pd.isnull(value):
+ return None
+
+ match = re.search(r"(\d+)", str(value))
+ if not match:
+ return None
+
+ return int(match.group(1))
+
+
+data["roof_insulation_thickness_mm"] = data["Roof Insulation"].apply(
+ extract_insulation_thickness
+)
+
+data["landlord_roof_efficiency"] = data.progress_apply(
+ lambda row: resolve_roof_efficiency(
+ description=row.landlord_roof_description,
+ age_band=row.construction_age_band,
+ insulation_thickness=row.roof_insulation_thickness_mm,
+ ),
+ axis=1,
+)
+
+assert data["landlord_roof_efficiency"].isnull().sum() == 0
+
+# Flag sloping ceiling
+data["has_sloping_ceiling"] = data["Roof Construction"].apply(
+ lambda x: x == "PitchedWithSlopingCeiling"
+)
# Variables we want to map
-# 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type',
-# 'Attachment', 'Construction Years',
-# 'Roof Construction', 'Roof Insulation',
+# 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode',
# 'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating',
# 'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN',
# 'Total Floor Area (m2)'
diff --git a/backend/onboarders/tests/test_roof_remapping.py b/backend/onboarders/tests/test_roof_remapping.py
index e69de29b..a08471f9 100644
--- a/backend/onboarders/tests/test_roof_remapping.py
+++ b/backend/onboarders/tests/test_roof_remapping.py
@@ -0,0 +1,175 @@
+import pytest
+
+from backend.onboarders.epc_descriptions import (
+ EpcConstructionAgeBand,
+ EpcRoofDescriptions,
+ EpcEfficiency,
+ resolve_roof_efficiency,
+)
+
+from backend.onboarders.mappings.as_built_roof_classifiers import (
+ classify_flat_roof,
+ classify_sloping_ceiling_roof,
+)
+
+
+# ---------------------------------------------------------------------
+# As-built roof description classification
+# ---------------------------------------------------------------------
+
+@pytest.mark.parametrize(
+ "age_band, expected",
+ [
+ (EpcConstructionAgeBand.before_1900, EpcRoofDescriptions.flat_no_insulation),
+ (EpcConstructionAgeBand.from_1950_to_1966, EpcRoofDescriptions.flat_no_insulation),
+ (EpcConstructionAgeBand.from_1967_to_1975, EpcRoofDescriptions.flat_limited_insulation),
+ (EpcConstructionAgeBand.from_1976_to_1982, EpcRoofDescriptions.flat_limited_insulation),
+ (EpcConstructionAgeBand.from_1983_to_1990, EpcRoofDescriptions.flat_insulated),
+ (EpcConstructionAgeBand.from_2007_to_2011, EpcRoofDescriptions.flat_insulated),
+ (EpcConstructionAgeBand.from_2023_onwards, EpcRoofDescriptions.flat_insulated),
+ ],
+)
+def test_classify_flat_roof(age_band, expected):
+ assert classify_flat_roof(age_band) == expected
+
+
+@pytest.mark.parametrize(
+ "age_band, expected",
+ [
+ (EpcConstructionAgeBand.before_1900, EpcRoofDescriptions.sloping_pitched_no_insulation),
+ (EpcConstructionAgeBand.from_1967_to_1975, EpcRoofDescriptions.sloping_pitched_no_insulation),
+ (EpcConstructionAgeBand.from_1976_to_1982, EpcRoofDescriptions.sloping_pitched_limited_insulation),
+ (EpcConstructionAgeBand.from_1983_to_1990, EpcRoofDescriptions.sloping_pitched_insulated),
+ (EpcConstructionAgeBand.from_2012_to_2022, EpcRoofDescriptions.sloping_pitched_insulated),
+ (EpcConstructionAgeBand.from_2023_onwards, EpcRoofDescriptions.sloping_pitched_insulated),
+ ],
+)
+def test_classify_sloping_ceiling_roof(age_band, expected):
+ assert classify_sloping_ceiling_roof(age_band) == expected
+
+
+# ---------------------------------------------------------------------
+# Roof efficiency — fixed & age-band driven
+# ---------------------------------------------------------------------
+
+@pytest.mark.parametrize(
+ "description, age_band, expected",
+ [
+ # Flat roof, no insulation
+ (EpcRoofDescriptions.flat_no_insulation, EpcConstructionAgeBand.before_1900, EpcEfficiency.VERY_POOR),
+
+ # Flat roof, limited insulation (age-band driven)
+ (EpcRoofDescriptions.flat_limited_insulation, EpcConstructionAgeBand.from_1976_to_1982, EpcEfficiency.POOR),
+ (
+ EpcRoofDescriptions.flat_limited_insulation, EpcConstructionAgeBand.from_1967_to_1975,
+ EpcEfficiency.VERY_POOR),
+
+ # Flat roof, insulated (age-band driven)
+ (EpcRoofDescriptions.flat_insulated, EpcConstructionAgeBand.from_1983_to_1990, EpcEfficiency.AVERAGE),
+ (EpcRoofDescriptions.flat_insulated, EpcConstructionAgeBand.from_2003_to_2006, EpcEfficiency.GOOD),
+ (EpcRoofDescriptions.flat_insulated, EpcConstructionAgeBand.from_2023_onwards, EpcEfficiency.VERY_GOOD),
+
+ # Pitched, insulated assumed (loft)
+ (EpcRoofDescriptions.pitched_insulated_assumed, EpcConstructionAgeBand.from_1996_to_2002, EpcEfficiency.GOOD),
+ (EpcRoofDescriptions.pitched_insulated_assumed, EpcConstructionAgeBand.from_2007_to_2011,
+ EpcEfficiency.VERY_GOOD),
+ ],
+)
+def test_roof_efficiency_age_band_only(description, age_band, expected):
+ assert resolve_roof_efficiency(
+ description=description,
+ age_band=age_band,
+ insulation_thickness=None,
+ ) == expected
+
+
+# ---------------------------------------------------------------------
+# Roof efficiency — insulation thickness driven
+# ---------------------------------------------------------------------
+
+@pytest.mark.parametrize(
+ "description, thickness, expected",
+ [
+ # Loft insulation
+ (EpcRoofDescriptions.loft_12mm_insulation, 12, EpcEfficiency.VERY_POOR),
+ (EpcRoofDescriptions.loft_25mm_insulation, 25, EpcEfficiency.POOR),
+ (EpcRoofDescriptions.loft_75mm_insulation, 75, EpcEfficiency.AVERAGE),
+ (EpcRoofDescriptions.loft_150mm_insulation, 150, EpcEfficiency.GOOD),
+ (EpcRoofDescriptions.loft_300mm_insulation, 300, EpcEfficiency.VERY_GOOD),
+
+ # Flat insulated — thickness overrides age band
+ (EpcRoofDescriptions.flat_insulated, 50, EpcEfficiency.POOR),
+ (EpcRoofDescriptions.flat_insulated, 100, EpcEfficiency.AVERAGE),
+ (EpcRoofDescriptions.flat_insulated, 200, EpcEfficiency.GOOD),
+ (EpcRoofDescriptions.flat_insulated, 300, EpcEfficiency.VERY_GOOD),
+
+ # Sloping ceiling
+ (EpcRoofDescriptions.sloping_pitched_insulated, 75, EpcEfficiency.AVERAGE),
+ (EpcRoofDescriptions.sloping_pitched_insulated, 150, EpcEfficiency.GOOD),
+ (EpcRoofDescriptions.sloping_pitched_insulated, 350, EpcEfficiency.VERY_GOOD),
+ ],
+)
+def test_roof_efficiency_thickness_based(description, thickness, expected):
+ assert resolve_roof_efficiency(
+ description=description,
+ age_band=EpcConstructionAgeBand.before_1900, # should be ignored
+ insulation_thickness=thickness,
+ ) == expected
+
+
+# ---------------------------------------------------------------------
+# Thatched roofs
+# ---------------------------------------------------------------------
+
+@pytest.mark.parametrize(
+ "description, age_band, expected",
+ [
+ (EpcRoofDescriptions.thatched, EpcConstructionAgeBand.before_1900, EpcEfficiency.AVERAGE),
+ (EpcRoofDescriptions.thatched, EpcConstructionAgeBand.from_2003_to_2006, EpcEfficiency.GOOD),
+ (EpcRoofDescriptions.thatched, EpcConstructionAgeBand.from_2023_onwards, EpcEfficiency.VERY_GOOD),
+ ],
+)
+def test_thatched_efficiency_age_band(description, age_band, expected):
+ assert resolve_roof_efficiency(
+ description=description,
+ age_band=age_band,
+ insulation_thickness=None,
+ ) == expected
+
+
+@pytest.mark.parametrize(
+ "thickness, expected",
+ [
+ (12, EpcEfficiency.AVERAGE),
+ (50, EpcEfficiency.GOOD),
+ (150, EpcEfficiency.GOOD),
+ (200, EpcEfficiency.VERY_GOOD),
+ ],
+)
+def test_thatched_efficiency_thickness(thickness, expected):
+ assert resolve_roof_efficiency(
+ description=EpcRoofDescriptions.thatched_with_additional_insulation,
+ age_band=EpcConstructionAgeBand.before_1900,
+ insulation_thickness=thickness,
+ ) == expected
+
+
+# ---------------------------------------------------------------------
+# Unknown / holding descriptions
+# ---------------------------------------------------------------------
+
+@pytest.mark.parametrize(
+ "description",
+ [
+ EpcRoofDescriptions.flat_as_built_unknown,
+ EpcRoofDescriptions.loft_as_built_unknown,
+ EpcRoofDescriptions.thatched_as_built_unknown,
+ EpcRoofDescriptions.sloping_pitched_as_built_unknown,
+ ],
+)
+def test_unknown_roof_descriptions_return_na(description):
+ assert resolve_roof_efficiency(
+ description=description,
+ age_band=None,
+ insulation_thickness=None,
+ ) == EpcEfficiency.NA
diff --git a/backend/onboarders/tests/test_wall_remapping.py b/backend/onboarders/tests/test_wall_remapping.py
index e69de29b..eaac5afb 100644
--- a/backend/onboarders/tests/test_wall_remapping.py
+++ b/backend/onboarders/tests/test_wall_remapping.py
@@ -0,0 +1,163 @@
+import pytest
+
+from backend.onboarders.epc_descriptions import (
+ EpcConstructionAgeBand,
+ EpcWallDescriptions,
+ EpcEfficiency,
+ resolve_wall_efficiency,
+)
+
+from backend.onboarders.mappings.as_built_wall_classifiers import (
+ map_cavity_wall_insulation,
+ map_solid_wall_insulation,
+ map_timber_frame_wall_insulation,
+ map_system_build_wall_insulation,
+ map_granite_wall_insulation,
+ map_sandstone_wall_insulation,
+ map_cob_wall_insulation,
+)
+
+
+# ---------------------------------------------------------------------
+# As-built wall description classification
+# ---------------------------------------------------------------------
+
+@pytest.mark.parametrize(
+ "age_band, expected",
+ [
+ (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.cavity_no_insulation_assumed),
+ (EpcConstructionAgeBand.from_1950_to_1966, EpcWallDescriptions.cavity_no_insulation_assumed),
+ (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.cavity_partial_insulated_assumed),
+ (EpcConstructionAgeBand.from_1983_to_1990, EpcWallDescriptions.cavity_insulated_assumed),
+ (EpcConstructionAgeBand.from_2023_onwards, EpcWallDescriptions.cavity_insulated_assumed),
+ ],
+)
+def test_map_cavity_wall_insulation(age_band, expected):
+ assert map_cavity_wall_insulation(age_band) == expected
+
+
+@pytest.mark.parametrize(
+ "age_band, expected",
+ [
+ (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.solid_brick_no_insulation_assumed),
+ (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.solid_brick_partial_insulated_assumed),
+ (EpcConstructionAgeBand.from_1996_to_2002, EpcWallDescriptions.solid_brick_insulated_assumed),
+ ],
+)
+def test_map_solid_wall_insulation(age_band, expected):
+ assert map_solid_wall_insulation(age_band) == expected
+
+
+@pytest.mark.parametrize(
+ "age_band, expected",
+ [
+ (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.timber_frame_no_insulation_assumed),
+ (EpcConstructionAgeBand.from_1950_to_1966, EpcWallDescriptions.timber_frame_partial_insulated_assumed),
+ (EpcConstructionAgeBand.from_1983_to_1990, EpcWallDescriptions.timber_frame_insulated_assumed),
+ ],
+)
+def test_map_timber_frame_wall_insulation(age_band, expected):
+ assert map_timber_frame_wall_insulation(age_band) == expected
+
+
+@pytest.mark.parametrize(
+ "age_band, expected",
+ [
+ (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.system_no_insulation_assumed),
+ (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.system_partial_insulated_assumed),
+ (EpcConstructionAgeBand.from_2003_to_2006, EpcWallDescriptions.system_insulated_assumed),
+ ],
+)
+def test_map_system_wall_insulation(age_band, expected):
+ assert map_system_build_wall_insulation(age_band) == expected
+
+
+@pytest.mark.parametrize(
+ "age_band, expected",
+ [
+ (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.granite_whinstone_no_insulation_assumed),
+ (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.granite_whinstone_partial_insulated_assumed),
+ (EpcConstructionAgeBand.from_2012_to_2022, EpcWallDescriptions.granite_whinestone_insulated_assumed),
+ ],
+)
+def test_map_granite_wall_insulation(age_band, expected):
+ assert map_granite_wall_insulation(age_band) == expected
+
+
+@pytest.mark.parametrize(
+ "age_band, expected",
+ [
+ (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.sandstone_limestone_no_insulation_assumed),
+ (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.sandstone_limestone_partial_insulated_assumed),
+ (EpcConstructionAgeBand.from_2007_to_2011, EpcWallDescriptions.sandstone_limestone_insulated_assumed),
+ ],
+)
+def test_map_sandstone_wall_insulation(age_band, expected):
+ assert map_sandstone_wall_insulation(age_band) == expected
+
+
+@pytest.mark.parametrize(
+ "age_band, expected",
+ [
+ (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.cob_as_built_average),
+ (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.cob_as_built_average),
+ (EpcConstructionAgeBand.from_1983_to_1990, EpcWallDescriptions.cob_as_built_good),
+ ],
+)
+def test_map_cob_wall_insulation(age_band, expected):
+ assert map_cob_wall_insulation(age_band) == expected
+
+
+# ---------------------------------------------------------------------
+# Wall efficiency resolution
+# ---------------------------------------------------------------------
+
+@pytest.mark.parametrize(
+ "description, age_band, expected",
+ [
+ # Fixed efficiencies
+ (EpcWallDescriptions.cavity_no_insulation_assumed, None, EpcEfficiency.POOR),
+ (EpcWallDescriptions.cavity_partial_insulated_assumed, None, EpcEfficiency.AVERAGE),
+ (EpcWallDescriptions.cavity_insulated_assumed, None, EpcEfficiency.GOOD),
+
+ # Function-based efficiencies
+ (
+ EpcWallDescriptions.cavity_filled_cavity,
+ EpcConstructionAgeBand.from_2023_onwards,
+ EpcEfficiency.VERY_GOOD,
+ ),
+ (
+ EpcWallDescriptions.cavity_filled_cavity,
+ EpcConstructionAgeBand.from_1991_to_1995,
+ EpcEfficiency.GOOD,
+ ),
+ (
+ EpcWallDescriptions.solid_brick_internal_insulation,
+ EpcConstructionAgeBand.from_2003_to_2006,
+ EpcEfficiency.VERY_GOOD,
+ ),
+ (
+ EpcWallDescriptions.solid_brick_internal_insulation,
+ EpcConstructionAgeBand.from_1950_to_1966,
+ EpcEfficiency.GOOD,
+ ),
+ ],
+)
+def test_resolve_wall_efficiency(description, age_band, expected):
+ assert resolve_wall_efficiency(description, age_band) == expected
+
+
+@pytest.mark.parametrize(
+ "description",
+ [
+ EpcWallDescriptions.cavity_as_built_unknown,
+ EpcWallDescriptions.solid_brick_as_built_unknown,
+ EpcWallDescriptions.system_as_built_unknown,
+ EpcWallDescriptions.timber_frame_as_built_unknown,
+ EpcWallDescriptions.granite_as_built_unknown,
+ EpcWallDescriptions.sandstone_as_built_unknown,
+ EpcWallDescriptions.cob_as_built_unknown,
+ ],
+)
+def test_unknown_wall_descriptions_return_na(description):
+ assert resolve_wall_efficiency(description, None) == EpcEfficiency.NA
diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf
index 5a67b793..b97a2f4d 100644
--- a/infrastructure/terraform/main.tf
+++ b/infrastructure/terraform/main.tf
@@ -86,7 +86,7 @@ resource "aws_db_instance" "default" {
# Temporary to enfore immediate change
apply_immediately = true
# Set up storage type to gp3 for better performance
- storage_type = "gp3"
+ storage_type = "gp3"
}
# Set up the bucket that recieve the csv uploads of epc to be retrofit
@@ -244,7 +244,7 @@ module "lambda_heating_cost_prediction_ecr" {
}
module "lambda_hot_water_cost_prediction_ecr" {
- ecr_name = "hot-water-cost-prediction-${var.stage}"
+ ecr_name = "hot-water-fcost-prediction-${var.stage}"
source = "./modules/ecr"
}
diff --git a/pytest.ini b/pytest.ini
index 1422657b..fe2c7d67 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,4 +1,4 @@
[pytest]
pythonpath = .
addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial
-testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests
+testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/onboarders/tests