zcemg08 commited on
Commit
d11b44e
·
1 Parent(s): 87cab48

first code

Browse files
Files changed (44) hide show
  1. .idea/.gitignore +10 -0
  2. .idea/copilot.data.migration.ask2agent.xml +6 -0
  3. .idea/dataSources.xml +12 -0
  4. .idea/epc_only_data_model.iml +8 -0
  5. .idea/inspectionProfiles/profiles_settings.xml +6 -0
  6. .idea/misc.xml +7 -0
  7. .idea/modules.xml +8 -0
  8. .idea/vcs.xml +6 -0
  9. Dockerfile +51 -0
  10. app/__init__.py +0 -0
  11. app/__pycache__/__init__.cpython-310.pyc +0 -0
  12. app/__pycache__/__init__.cpython-312.pyc +0 -0
  13. app/__pycache__/handler.cpython-310.pyc +0 -0
  14. app/__pycache__/handler.cpython-312.pyc +0 -0
  15. app/__pycache__/main.cpython-310.pyc +0 -0
  16. app/__pycache__/main.cpython-312.pyc +0 -0
  17. app/handler.py +102 -0
  18. app/main.py +82 -0
  19. requirements.txt +28 -0
  20. src/features/__pycache__/build_features.cpython-310.pyc +0 -0
  21. src/features/__pycache__/build_features.cpython-312.pyc +0 -0
  22. src/features/__pycache__/construction_age_band_sap.cpython-310.pyc +0 -0
  23. src/features/__pycache__/construction_age_band_sap.cpython-312.pyc +0 -0
  24. src/features/__pycache__/energy_system.cpython-310.pyc +0 -0
  25. src/features/__pycache__/energy_system.cpython-312.pyc +0 -0
  26. src/features/__pycache__/floor.cpython-310.pyc +0 -0
  27. src/features/__pycache__/floor.cpython-312.pyc +0 -0
  28. src/features/__pycache__/roofs.cpython-310.pyc +0 -0
  29. src/features/__pycache__/roofs.cpython-312.pyc +0 -0
  30. src/features/__pycache__/walls.cpython-310.pyc +0 -0
  31. src/features/__pycache__/walls.cpython-312.pyc +0 -0
  32. src/features/build_features.py +161 -0
  33. src/features/construction_age_band_sap.py +379 -0
  34. src/features/effici.egg-info/PKG-INFO +52 -0
  35. src/features/effici.egg-info/SOURCES.txt +11 -0
  36. src/features/effici.egg-info/dependency_links.txt +1 -0
  37. src/features/effici.egg-info/top_level.txt +3 -0
  38. src/features/energy_system.py +603 -0
  39. src/features/floor.py +818 -0
  40. src/features/roofs.py +516 -0
  41. src/features/walls.py +376 -0
  42. src/models/EpcEnergyPipeline.py +62 -0
  43. src/models/__pycache__/EpcEnergyPipeline.cpython-310.pyc +0 -0
  44. src/models/__pycache__/EpcEnergyPipeline.cpython-312.pyc +0 -0
.idea/.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Ignored default folder with query files
5
+ /queries/
6
+ # Datasource local storage ignored files
7
+ /dataSources/
8
+ /dataSources.local.xml
9
+ # Editor-based HTTP Client requests
10
+ /httpRequests/
.idea/copilot.data.migration.ask2agent.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Ask2AgentMigrationStateService">
4
+ <option name="migrationStatus" value="COMPLETED" />
5
+ </component>
6
+ </project>
.idea/dataSources.xml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="DataSourceManagerImpl" format="xml" multifile-model="true">
4
+ <data-source source="LOCAL" name="epc_houses.sqlite" uuid="cca81896-f0bd-4f3b-8734-bd047c28bed3">
5
+ <driver-ref>sqlite.xerial</driver-ref>
6
+ <synchronize>true</synchronize>
7
+ <jdbc-driver>org.sqlite.JDBC</jdbc-driver>
8
+ <jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/epc_houses.sqlite</jdbc-url>
9
+ <working-dir>$ProjectFileDir$</working-dir>
10
+ </data-source>
11
+ </component>
12
+ </project>
.idea/epc_only_data_model.iml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$" />
5
+ <orderEntry type="jdk" jdkName="Python 3.12" jdkType="Python SDK" />
6
+ <orderEntry type="sourceFolder" forTests="false" />
7
+ </component>
8
+ </module>
.idea/inspectionProfiles/profiles_settings.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
.idea/misc.xml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Black">
4
+ <option name="sdkName" value="Python 3.12" />
5
+ </component>
6
+ <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12" project-jdk-type="Python SDK" />
7
+ </project>
.idea/modules.xml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/epc_only_data_model.iml" filepath="$PROJECT_DIR$/.idea/epc_only_data_model.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ </component>
6
+ </project>
Dockerfile ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ------------------------------------------------------------
2
+ # Effici EPC Energy Prediction API
3
+ # ------------------------------------------------------------
4
+ FROM python:3.12-slim
5
+
6
+ # Prevent Python from writing .pyc files
7
+ ENV PYTHONDONTWRITEBYTECODE=1
8
+ ENV PYTHONUNBUFFERED=1
9
+
10
+ # Set working directory
11
+ WORKDIR /app
12
+
13
+ # ------------------------------------------------------------
14
+ # System dependencies (minimal)
15
+ # ------------------------------------------------------------
16
+ RUN apt-get update && apt-get install -y \
17
+ build-essential \
18
+ curl \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ # ------------------------------------------------------------
22
+ # Install Python dependencies
23
+ # ------------------------------------------------------------
24
+ COPY requirements.txt .
25
+ RUN pip install --no-cache-dir --upgrade pip \
26
+ && pip install --no-cache-dir -r requirements.txt
27
+
28
+ # ------------------------------------------------------------
29
+ # Copy application code
30
+ # ------------------------------------------------------------
31
+ COPY app ./app
32
+ COPY src ./src
33
+
34
+ # Optional: copy .env for local docker testing
35
+ # (Do NOT do this in production images)
36
+ # COPY .env .
37
+
38
+ # ------------------------------------------------------------
39
+ # Environment variables (runtime configurable)
40
+ # ------------------------------------------------------------
41
+ ENV PORT=8000
42
+
43
+ # ------------------------------------------------------------
44
+ # Expose port
45
+ # ------------------------------------------------------------
46
+ EXPOSE 8000
47
+
48
+ # ------------------------------------------------------------
49
+ # Start FastAPI
50
+ # ------------------------------------------------------------
51
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
app/__init__.py ADDED
File without changes
app/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (145 Bytes). View file
 
app/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (149 Bytes). View file
 
app/__pycache__/handler.cpython-310.pyc ADDED
Binary file (2.74 kB). View file
 
app/__pycache__/handler.cpython-312.pyc ADDED
Binary file (4.05 kB). View file
 
app/__pycache__/main.cpython-310.pyc ADDED
Binary file (2.64 kB). View file
 
app/__pycache__/main.cpython-312.pyc ADDED
Binary file (3.52 kB). View file
 
app/handler.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys
2
+ import mlflow
3
+ import mlflow.pyfunc
4
+ import pandas as pd
5
+ from dotenv import load_dotenv
6
+
7
+ # Load .env BEFORE anything else
8
+ load_dotenv()
9
+
10
+ # Ensure the project root (which contains 'src') is in sys.path
11
+ # project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
12
+ # if project_root not in sys.path:
13
+ # sys.path.insert(0, project_root)
14
+
15
+
16
+ class FastApiHandler:
17
+ """Handler for rent price prediction using MLflow pipeline model."""
18
+
19
+ def __init__(
20
+ self,
21
+ ):
22
+ self.model = None
23
+ self.model_uri = None
24
+
25
+ self._configure_gcp_credentials()
26
+ self.load_model() # Load once at startup
27
+
28
+ # -----------------------------------------------------------
29
+ # Configure Google Cloud authentication
30
+ # -----------------------------------------------------------
31
+ @staticmethod
32
+ def _configure_gcp_credentials():
33
+ """Loads GCP credentials from HF ENV or system ENV."""
34
+
35
+ # Hugging Face Spaces: JSON secret
36
+ creds_json = os.getenv("GOOGLE_APPLICATION_CREDENTIALS_JSON")
37
+
38
+ if creds_json:
39
+ print("🔐 Configuring GCP credentials from ENV JSON...")
40
+ with open("/tmp/gcp_creds.json", "w") as f:
41
+ f.write(creds_json)
42
+ os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/tmp/gcp_creds.json"
43
+
44
+ # Local dev or Docker with .env
45
+ elif os.getenv("GOOGLE_APPLICATION_CREDENTIALS"):
46
+ print("🔐 Using GOOGLE_APPLICATION_CREDENTIALS from environment")
47
+
48
+ else:
49
+ print("⚠️ WARNING: No GCP credentials provided! GCS model loading may fail.")
50
+
51
+ # -----------------------------------------------------------
52
+ # Load the MLflow model
53
+ # -----------------------------------------------------------
54
+ def load_model(self):
55
+ self.model_uri = os.getenv("MODEL_URI")
56
+ if not self.model_uri:
57
+ raise RuntimeError("MODEL_URI env var not set")
58
+
59
+ print(f"🔗 Loading MLflow model: {self.model_uri}")
60
+ self.model = mlflow.pyfunc.load_model(self.model_uri)
61
+ print("✅ Model loaded successfully")
62
+
63
+ # -----------------------------------------------------------
64
+ # Predict
65
+ # -----------------------------------------------------------
66
+ def predict(self, model_params: dict) -> float:
67
+ if self.model is None:
68
+ raise RuntimeError("Model not loaded")
69
+
70
+ df = pd.DataFrame([model_params])
71
+ preds = self.model.predict(df)
72
+ return float(preds[0])
73
+
74
+
75
+ def explain_prediction(self, model_params: dict) -> dict:
76
+ if self.model is None:
77
+ raise RuntimeError("Model not loaded")
78
+
79
+ df = pd.DataFrame([model_params])
80
+
81
+ # 🔥 Unwrap the custom RentPricePipeline
82
+ python_model = self.model.unwrap_python_model()
83
+
84
+ explanation = python_model.explain_predictions(df)
85
+ return explanation
86
+
87
+ # -----------------------------------------------------------
88
+ # FastAPI-compatible handler
89
+ # -----------------------------------------------------------
90
+ def handle(self, params: dict) -> dict:
91
+ if "model_params" not in params:
92
+ return {"error": "Missing 'model_params' in request"}
93
+
94
+ try:
95
+ prediction = self.predict(params["model_params"])
96
+ except Exception as e:
97
+ return {"error": str(e)}
98
+
99
+ return {
100
+ "prediction": prediction,
101
+ "inputs": params["model_params"]
102
+ }
app/main.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from pydantic import BaseModel, Field
3
+ from typing import Optional
4
+ from app.handler import FastApiHandler
5
+
6
+ app = FastAPI(title="Effici EPC Energy Prediction API")
7
+
8
+ handler = None
9
+
10
+
11
+ # ---------- EPC Request Schema ----------
12
+ class EPCPredictRequest(BaseModel):
13
+ model_params: dict = Field(
14
+ ...,
15
+ json_schema_extra={
16
+ "example": {
17
+ "PROPERTY_TYPE": "Flat",
18
+ "BUILT_FORM": "Enclosed End-Terrace",
19
+ "CONSTRUCTION_AGE_BAND": "England and Wales: 1996-2002",
20
+
21
+ "TOTAL_FLOOR_AREA": 70.12,
22
+ "FLOOR_HEIGHT": 2.32,
23
+ "FLAT_TOP_STOREY": "N",
24
+ "FLAT_STOREY_COUNT": 23.0,
25
+
26
+ "WINDOWS_DESCRIPTION": "Fully triple glazed",
27
+ "WALLS_DESCRIPTION": "System built, as built, insulated (assumed)",
28
+ "ROOF_DESCRIPTION": "(another dwelling above)",
29
+ "FLOOR_DESCRIPTION": "(other premises below)",
30
+
31
+ "MAINHEAT_DESCRIPTION": "Air source heat pump, warm air, electric",
32
+ "MAINHEAT_ENERGY_EFF": "Average",
33
+
34
+ "SECONDHEAT_DESCRIPTION": "Room heaters, electric",
35
+
36
+ "HOTWATER_DESCRIPTION": "Electric immersion, standard tariff, no cylinderstat",
37
+ "HOT_WATER_ENERGY_EFF": "Very Poor",
38
+
39
+ "LIGHTING_DESCRIPTION": "No low energy lighting",
40
+
41
+ "MECHANICAL_VENTILATION": "natural",
42
+ "PHOTO_SUPPLY": 0.0
43
+ }
44
+ },
45
+ )
46
+
47
+
48
+
49
+ # ---------- Startup ----------
50
+ @app.on_event("startup")
51
+ def load_model_once():
52
+ global handler
53
+ handler = FastApiHandler()
54
+ print("✅ EPC MLflow model loaded at startup")
55
+
56
+
57
+ # ---------- Routes ----------
58
+ @app.get("/")
59
+ def root():
60
+ return {
61
+ "message": "🏠 Effici EPC Energy Prediction API is running",
62
+ }
63
+
64
+
65
+ @app.post("/predict")
66
+ def predict(req: EPCPredictRequest):
67
+ try:
68
+ result = handler.handle(req.dict())
69
+ if "error" in result:
70
+ raise HTTPException(status_code=400, detail=result["error"])
71
+ return result
72
+ except Exception as e:
73
+ raise HTTPException(status_code=400, detail=str(e))
74
+
75
+
76
+ @app.post("/explain")
77
+ def explain(req: EPCPredictRequest):
78
+ try:
79
+ explanation = handler.explain_prediction(req.model_params)
80
+ return explanation
81
+ except Exception as e:
82
+ raise HTTPException(status_code=400, detail=str(e))
requirements.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Core serving ---
2
+ fastapi==0.115.6
3
+ uvicorn[standard]==0.34.0
4
+ python-dotenv==1.0.1
5
+
6
+ # --- MLflow model loading ---
7
+ mlflow==3.8.1
8
+ cloudpickle==3.1.2
9
+
10
+ # --- Numerical stack (MUST match training) ---
11
+ numpy==2.0.2
12
+ pandas==2.2.2
13
+ scipy==1.16.3
14
+ scikit-learn==1.6.1
15
+
16
+ # --- Model ---
17
+ catboost==1.2.8
18
+
19
+ # --- EPC / SAP feature engineering ---
20
+ openpyxl==3.1.5
21
+ xlrd==2.0.2
22
+
23
+ # --- Explainability (required for /explain endpoint) ---
24
+ shap==0.50.0
25
+
26
+ # --- GCP access ---
27
+ google-cloud-storage==2.16.0
28
+
src/features/__pycache__/build_features.cpython-310.pyc ADDED
Binary file (3.92 kB). View file
 
src/features/__pycache__/build_features.cpython-312.pyc ADDED
Binary file (6.03 kB). View file
 
src/features/__pycache__/construction_age_band_sap.cpython-310.pyc ADDED
Binary file (7.21 kB). View file
 
src/features/__pycache__/construction_age_band_sap.cpython-312.pyc ADDED
Binary file (11.8 kB). View file
 
src/features/__pycache__/energy_system.cpython-310.pyc ADDED
Binary file (11.2 kB). View file
 
src/features/__pycache__/energy_system.cpython-312.pyc ADDED
Binary file (20.4 kB). View file
 
src/features/__pycache__/floor.cpython-310.pyc ADDED
Binary file (16.7 kB). View file
 
src/features/__pycache__/floor.cpython-312.pyc ADDED
Binary file (28.8 kB). View file
 
src/features/__pycache__/roofs.cpython-310.pyc ADDED
Binary file (8.17 kB). View file
 
src/features/__pycache__/roofs.cpython-312.pyc ADDED
Binary file (15.6 kB). View file
 
src/features/__pycache__/walls.cpython-310.pyc ADDED
Binary file (6.46 kB). View file
 
src/features/__pycache__/walls.cpython-312.pyc ADDED
Binary file (8.59 kB). View file
 
src/features/build_features.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from src.features.construction_age_band_sap import normalize_construction_age_band, windows_feature_engineering_vectorised
6
+ from src.features.energy_system import energy_system_feature_engineering_vectorised
7
+ from src.features.floor import floor_feature_engineering_fast
8
+ from src.features.walls import wall_feature_engineering
9
+ from src.features.roofs import roof_feature_engineering
10
+
11
+
12
+ def build_age_band_lookup(series: pd.Series):
13
+ """
14
+ Build lookup dict:
15
+ raw EPC CONSTRUCTION_AGE_BAND -> (sap_band_letter, sap_band_label)
16
+ """
17
+ unique_vals = series.dropna().unique()
18
+
19
+ lookup = {}
20
+ for v in unique_vals:
21
+ letter, label = normalize_construction_age_band(v)
22
+ lookup[v] = (letter, label)
23
+
24
+ return lookup
25
+
26
+
27
+ def age_band_to_sap_letter(df: pd.DataFrame)-> pd.DataFrame:
28
+
29
+ df = df.copy()
30
+ lookup = build_age_band_lookup(df["CONSTRUCTION_AGE_BAND"])
31
+ age_df = (
32
+ pd.DataFrame.from_dict(
33
+ lookup,
34
+ orient="index",
35
+ columns=["sap_band_letter", "sap_band_label"]
36
+ )
37
+ )
38
+ df = df.join(age_df, on="CONSTRUCTION_AGE_BAND")
39
+
40
+ return df
41
+
42
+
43
+ EFF_MAP = {
44
+ "very poor": 0.60,
45
+ "poor": 0.68,
46
+ "average": 0.75,
47
+ "good": 0.85,
48
+ "very good": 0.92
49
+ }
50
+
51
+
52
+ DHW_EFF_MAP = {
53
+ "very poor": 0.65,
54
+ "poor": 0.72,
55
+ "average": 0.78,
56
+ "good": 0.85,
57
+ "very good": 0.90
58
+ }
59
+
60
+ energy_system_columns = [
61
+ "MAIN_HEATING_SYSTEM","SECONDARY_HEATING_SYSTEM",
62
+ "MAIN_FUEL_TYPE","DHW_SUPPLY_SYSTEM","VENTILATION_SYSTEM",
63
+ "LIGHTING_FRACTION_LOW_ENERGY","PV_KWP","MAINHEAT_EFF_NUM","ROOF_MM_S9",
64
+ "HOT_WATER_ENERGY_NUM"
65
+ ]
66
+
67
+ envelop_columns = [
68
+ "FLOOR_U_VALUE","FLOOR_INSULATION_TYPE","FLOOR_BOUNDARY_TYPE",
69
+ "WALL_U_VALUE","WALL_TYPE","WALL_INSULATION",
70
+ "ROOF_U_VALUE","ROOF_CLASS","ROOF_INSULATION_TYPE",
71
+ "glazing_area_m2","glazing_type"
72
+ ]
73
+
74
+ general_details = [
75
+ "PROPERTY_TYPE","TOTAL_FLOOR_AREA",
76
+ "BUILT_FORM","sap_band_letter","FLOOR_HEIGHT"
77
+ ]
78
+
79
+ features = energy_system_columns + envelop_columns + general_details
80
+
81
+
82
+ cat_cols = [
83
+ "MAIN_HEATING_SYSTEM","SECONDARY_HEATING_SYSTEM",
84
+ "MAIN_FUEL_TYPE","DHW_SUPPLY_SYSTEM","VENTILATION_SYSTEM",
85
+ "FLOOR_INSULATION_TYPE","FLOOR_BOUNDARY_TYPE",
86
+ "WALL_TYPE","WALL_INSULATION",
87
+ "ROOF_CLASS","ROOF_INSULATION_TYPE",
88
+ "glazing_type",
89
+ "PROPERTY_TYPE","BUILT_FORM","sap_band_letter"
90
+ ]
91
+
92
+
93
+
94
+ @dataclass
95
+ class SAPTables:
96
+ s3: pd.DataFrame
97
+ walls_u: pd.DataFrame
98
+ s9: pd.DataFrame
99
+ s10: pd.DataFrame
100
+
101
+ @classmethod
102
+ def from_local_dir(cls, base_dir: str) -> "SAPTables":
103
+ base = Path(base_dir)
104
+
105
+ return cls(
106
+ s3=pd.read_excel(base / "S3_sap.xlsx"),
107
+ walls_u=pd.read_excel(base / "external_wall_u_values2.xlsx"),
108
+ s9=pd.read_excel(base / "SAP_Table_ROOF_S9.xlsx"),
109
+ s10=pd.read_excel(base / "SAP_Table_ROOF_S10.xlsx"),
110
+ )
111
+
112
+
113
+
114
+ class EPCFeatureEngineer:
115
+ def __init__(self, sap: SAPTables):
116
+ self.sap = sap
117
+
118
+ def transform(self, df: pd.DataFrame) -> pd.DataFrame:
119
+ df = df.copy()
120
+
121
+ df.replace("", pd.NA, inplace=True)
122
+
123
+ df["FLOOR_HEIGHT"] = df["FLOOR_HEIGHT"].fillna(2.5)
124
+
125
+ # SAP age bands
126
+ df = age_band_to_sap_letter(df)
127
+
128
+ # Envelope
129
+ df = windows_feature_engineering_vectorised(df)
130
+ df = energy_system_feature_engineering_vectorised(df)
131
+ df = floor_feature_engineering_fast(df, self.sap.s3)
132
+ df = wall_feature_engineering(df, self.sap.walls_u)
133
+ df = roof_feature_engineering(df, self.sap.s9, self.sap.s10)
134
+
135
+ # Heating efficiency
136
+ df["MAINHEAT_EFF_NUM"] = (
137
+ df["MAINHEAT_ENERGY_EFF"]
138
+ .str.lower()
139
+ .map(EFF_MAP)
140
+ .fillna(0.75)
141
+ )
142
+
143
+ # Hot water efficiency
144
+ df["HOT_WATER_ENERGY_NUM"] = (
145
+ df["HOT_WATER_ENERGY_EFF"]
146
+ .str.lower()
147
+ .map(DHW_EFF_MAP)
148
+ )
149
+
150
+ df.loc[
151
+ df["HOT_WATER_ENERGY_NUM"].isna() &
152
+ df["DHW_SUPPLY_SYSTEM"].notna(),
153
+ "HOT_WATER_ENERGY_NUM"
154
+ ] = 0.78
155
+
156
+ df["HOT_WATER_ENERGY_NUM"] = df["HOT_WATER_ENERGY_NUM"].fillna(0.75)
157
+
158
+ # Categoricals
159
+ df[cat_cols] = df[cat_cols].fillna("UNKNOWN").astype(str)
160
+
161
+ return df[features]
src/features/construction_age_band_sap.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import pandas as pd
3
+ import numpy as np
4
+
5
+ """Feature engineering for SAP S1 construction age band from EPC data. p115"""
6
+
7
+ SAP_AGE_BANDS = [
8
+ ("A", "pre-1900", 0, 1899),
9
+ ("B", "1900-1929", 1900, 1929),
10
+ ("C", "1930-1949", 1930, 1949),
11
+ ("D", "1950-1966", 1950, 1966),
12
+ ("E", "1967-1975", 1967, 1975),
13
+ ("F", "1976-1982", 1976, 1982),
14
+ ("G", "1983-1990", 1983, 1990),
15
+ ("H", "1991-1995", 1991, 1995),
16
+ ("I", "1996-2002", 1996, 2002),
17
+ ("J", "2003-2006", 2003, 2006),
18
+ ("K", "2007-2011", 2007, 2011),
19
+ ("L", "2012+", 2012, 2100)
20
+ ]
21
+
22
+ def assign_sap_band(year):
23
+ """Map numeric year to SAP S1 band."""
24
+ for letter, label, y1, y2 in SAP_AGE_BANDS:
25
+ if y1 <= year <= y2:
26
+ return letter, label
27
+ return None, None
28
+
29
+
30
+ def normalize_construction_age_band(raw_val):
31
+ """
32
+ Convert messy EPC input into SAP S1 age band (A–L)
33
+ and human-readable label ('1930-1949').
34
+ """
35
+ if pd.isna(raw_val):
36
+ return None, None
37
+
38
+ s = str(raw_val).lower().strip()
39
+ s = s.replace("england and wales:", "").strip()
40
+
41
+ # discard invalids
42
+ if s in ["no data!", "invalid!", "", "none"]:
43
+ return None, None
44
+
45
+ # before XXXX
46
+ m = re.findall(r"before\s+(\d{4})", s)
47
+ if m:
48
+ year = int(m[0]) - 1
49
+ return assign_sap_band(year)
50
+
51
+ # XXXX onwards
52
+ m = re.findall(r"(\d{4})\s*(onward|onwards)", s)
53
+ if m:
54
+ year = int(m[0][0])
55
+ return assign_sap_band(year)
56
+
57
+ # ranges like 1950-1966
58
+ m = re.findall(r"(\d{4})\D+(\d{4})", s)
59
+ if m:
60
+ y1, y2 = map(int, m[0])
61
+ mid = (y1 + y2) // 2
62
+ return assign_sap_band(mid)
63
+
64
+ # standalone year
65
+ m = re.findall(r"(\d{4})", s)
66
+ if m:
67
+ year = int(m[0])
68
+ return assign_sap_band(year)
69
+
70
+ return None, None
71
+
72
+
73
+ def glazing_area_estimate(tfa, sap_band_letter, property_type):
74
+ """
75
+ Compute glazing area based on:
76
+ - ML paper's Table 2
77
+ - SAP age band (A-L)
78
+ - Property type (House/Bungalow vs Flat/Maisonette)
79
+
80
+ Inputs:
81
+ tfa : Total Floor Area (float)
82
+ sap_band_letter : 'A' ... 'L' (SAP band)
83
+ property_type : 'House', 'Bungalow', 'Flat', 'Maisonette'
84
+ """
85
+
86
+ if pd.isna(tfa) or tfa <= 0:
87
+ return None
88
+ if sap_band_letter is None:
89
+ return None
90
+ if property_type is None:
91
+ return None
92
+
93
+ # Normalise dwelling class
94
+ house_group = ["house", "bungalow"]
95
+ flat_group = ["flat", "maisonette"]
96
+
97
+ pt = str(property_type).lower()
98
+ is_house = pt in house_group
99
+ is_flat = pt in flat_group
100
+
101
+ # Map SAP A–L to glazing formula groups
102
+ # Based on paper Table 2
103
+ if sap_band_letter in ["A", "B", "C"]:
104
+ group = "pre_1900_1949"
105
+ elif sap_band_letter == "D":
106
+ group = "1950_1966"
107
+ elif sap_band_letter == "E":
108
+ group = "1967_1975"
109
+ elif sap_band_letter == "F":
110
+ group = "1976_1982"
111
+ elif sap_band_letter == "G":
112
+ group = "1983_1990"
113
+ elif sap_band_letter == "H":
114
+ group = "1991_1995"
115
+ elif sap_band_letter == "I":
116
+ group = "1996_2002"
117
+ else:
118
+ # J, K, L
119
+ group = "after_2003"
120
+
121
+ # Apply formulas (from ML paper)
122
+ if is_house:
123
+ if group == "pre_1900_1949":
124
+ return 0.1220 * tfa + 6.875
125
+ elif group == "1950_1966":
126
+ return 0.1294 * tfa + 5.515
127
+ elif group == "1967_1975":
128
+ return 0.1239 * tfa + 7.332
129
+ elif group == "1976_1982":
130
+ return 0.1252 * tfa + 5.520
131
+ elif group == "1983_1990":
132
+ return 0.1356 * tfa + 5.242
133
+ elif group == "1991_1995":
134
+ return 0.0948 * tfa + 6.534
135
+ elif group == "1996_2002":
136
+ return 0.1382 * tfa - 0.027
137
+ elif group == "after_2003":
138
+ return 0.1435 * tfa - 0.403
139
+
140
+ elif is_flat:
141
+ if group == "pre_1900_1949":
142
+ return 0.0801 * tfa + 5.580
143
+ elif group == "1950_1966":
144
+ return 0.0341 * tfa + 8.562
145
+ elif group == "1967_1975":
146
+ return 0.0717 * tfa + 6.560
147
+ elif group == "1976_1982":
148
+ return 0.1199 * tfa + 1.975
149
+ elif group == "1983_1990":
150
+ return 0.0510 * tfa + 4.554
151
+ elif group == "1991_1995":
152
+ return 0.0813 * tfa + 3.744
153
+ elif group == "1996_2002":
154
+ return 0.1148 * tfa + 0.392
155
+ elif group == "after_2003":
156
+ return 0.1148 * tfa + 0.392 # same in paper
157
+
158
+ return None
159
+
160
+
161
+ def classify_glazing_type(text):
162
+ """
163
+ Classify EPC glazing description into SAP/ML-compatible glazing types.
164
+ """
165
+
166
+ if text is None or not isinstance(text, str):
167
+ return None
168
+
169
+ t = text.lower().strip()
170
+
171
+ # --- high performance (best class) ---
172
+ if "high performance" in t:
173
+ return "high_performance"
174
+
175
+ # --- triple glazing ---
176
+ if "triple" in t:
177
+ return "triple"
178
+
179
+ # --- secondary glazing ---
180
+ if "secondary" in t:
181
+ if any(x in t for x in ["partial", "some"]):
182
+ return "mixed"
183
+ return "secondary"
184
+
185
+ # --- double glazing ---
186
+ if "double" in t:
187
+ if any(x in t for x in ["partial", "some", "mostly"]):
188
+ return "mixed"
189
+ return "double"
190
+
191
+ # --- single glazing ---
192
+ if "single" in t:
193
+ return "single"
194
+
195
+ # --- fallback ---
196
+ return None
197
+
198
+
199
+ def windows_feature_engineering(df:pd.DataFrame)-> pd.DataFrame:
200
+ """
201
+ Apply construction age band and glazing area feature engineering
202
+ to the given dataframe.
203
+ """
204
+ df = df.copy()
205
+
206
+ df["glazing_area_m2"] = df.apply(
207
+ lambda row: glazing_area_estimate(
208
+ tfa=row["TOTAL_FLOOR_AREA"],
209
+ sap_band_letter=row["sap_band_letter"],
210
+ property_type=row["PROPERTY_TYPE"]
211
+ ),
212
+ axis=1
213
+ )
214
+
215
+ df["glazing_type"] = df["WINDOWS_DESCRIPTION"].apply(
216
+ classify_glazing_type
217
+ )
218
+
219
+ return df
220
+
221
+ # Example of usage:
222
+ GLAZING_COEFFS = {
223
+ "house": {
224
+ "pre_1900_1949": (0.1220, 6.875),
225
+ "1950_1966": (0.1294, 5.515),
226
+ "1967_1975": (0.1239, 7.332),
227
+ "1976_1982": (0.1252, 5.520),
228
+ "1983_1990": (0.1356, 5.242),
229
+ "1991_1995": (0.0948, 6.534),
230
+ "1996_2002": (0.1382, -0.027),
231
+ "after_2003": (0.1435, -0.403),
232
+ },
233
+ "flat": {
234
+ "pre_1900_1949": (0.0801, 5.580),
235
+ "1950_1966": (0.0341, 8.562),
236
+ "1967_1975": (0.0717, 6.560),
237
+ "1976_1982": (0.1199, 1.975),
238
+ "1983_1990": (0.0510, 4.554),
239
+ "1991_1995": (0.0813, 3.744),
240
+ "1996_2002": (0.1148, 0.392),
241
+ "after_2003": (0.1148, 0.392),
242
+ }
243
+ }
244
+
245
+
246
+ SAP_TO_GLAZING_GROUP = {
247
+ "A": "pre_1900_1949",
248
+ "B": "pre_1900_1949",
249
+ "C": "pre_1900_1949",
250
+ "D": "1950_1966",
251
+ "E": "1967_1975",
252
+ "F": "1976_1982",
253
+ "G": "1983_1990",
254
+ "H": "1991_1995",
255
+ "I": "1996_2002",
256
+ "J": "after_2003",
257
+ "K": "after_2003",
258
+ "L": "after_2003",
259
+ }
260
+
261
+
262
+
263
+
264
+ def compute_glazing_area_vectorised(df: pd.DataFrame) -> pd.Series:
265
+ """
266
+ Memory-safe vectorised glazing area calculation.
267
+ """
268
+
269
+ # --- base masks ---
270
+ tfa = df["TOTAL_FLOOR_AREA"].astype(float)
271
+ valid = tfa.gt(0)
272
+
273
+ pt = df["PROPERTY_TYPE"].str.lower()
274
+ is_house = pt.isin(["house", "bungalow"])
275
+ is_flat = pt.isin(["flat", "maisonette"])
276
+
277
+ glazing_group = df["sap_band_letter"].map(SAP_TO_GLAZING_GROUP)
278
+
279
+ # initialise result
280
+ glazing = np.full(len(df), np.nan, dtype="float64")
281
+
282
+ # --- compute for houses ---
283
+ for group, (a, b) in GLAZING_COEFFS["house"].items():
284
+ mask = valid & is_house & (glazing_group == group)
285
+ glazing[mask] = a * tfa[mask] + b
286
+
287
+ # --- compute for flats ---
288
+ for group, (a, b) in GLAZING_COEFFS["flat"].items():
289
+ mask = valid & is_flat & (glazing_group == group)
290
+ glazing[mask] = a * tfa[mask] + b
291
+
292
+ return pd.Series(glazing, index=df.index, name="glazing_area_m2")
293
+
294
+
295
+
296
+ def classify_glazing_type_vectorised_sap(series: pd.Series) -> pd.Series:
297
+ """
298
+ SAP-compliant glazing classification.
299
+ Priority: Triple > High Performance > Double > Secondary > Single
300
+ Highest performance mentioned wins.
301
+ """
302
+
303
+ s = (
304
+ series
305
+ .fillna("")
306
+ .str.lower()
307
+ .str.replace(r"\s+", " ", regex=True)
308
+ .str.strip()
309
+ )
310
+
311
+ out = pd.Series(pd.NA, index=series.index, dtype="object")
312
+
313
+ # --- 0. Junk / undefined ---
314
+ junk = (
315
+ s.eq("")
316
+ | s.eq("fully")
317
+ | s.str.contains("unknown")
318
+ | s.str.contains("complex")
319
+ | s.str.contains("other premises")
320
+ | s.str.fullmatch(r"(mostly|some)")
321
+ )
322
+ out[junk] = pd.NA
323
+
324
+ # --- 1. TRIPLE ---
325
+ mask = (
326
+ s.str.contains("triple")
327
+ | s.str.contains("gwydrau triphlyg")
328
+ ) & out.isna()
329
+ out[mask] = "triple"
330
+
331
+ # --- 2. HIGH PERFORMANCE ---
332
+ mask = (
333
+ s.str.contains("high performance")
334
+ | s.str.contains("perfformiad uchel")
335
+ ) & out.isna()
336
+ out[mask] = "high_performance"
337
+
338
+ # --- 3. DOUBLE (includes multiple glazing) ---
339
+ mask = (
340
+ (
341
+ s.str.contains("double")
342
+ | s.str.contains("multiple")
343
+ | s.str.contains("gwydrau dwbl")
344
+ )
345
+ & out.isna()
346
+ )
347
+ out[mask] = "double"
348
+
349
+ # --- 4. SECONDARY ---
350
+ mask = s.str.contains("secondary") & out.isna()
351
+ out[mask] = "secondary"
352
+
353
+ # --- 5. SINGLE ---
354
+ mask = (
355
+ (
356
+ s.str.contains("single")
357
+ | s.str.contains("gwydrau sengl")
358
+ )
359
+ & out.isna()
360
+ )
361
+ out[mask] = "single"
362
+
363
+ return out
364
+
365
+
366
+ def windows_feature_engineering_vectorised(df: pd.DataFrame) -> pd.DataFrame:
367
+ """
368
+ Apply construction age band and glazing area feature engineering
369
+ to the given dataframe, using vectorised operations.
370
+ """
371
+ df = df.copy()
372
+
373
+ df["glazing_area_m2"] = compute_glazing_area_vectorised(df)
374
+
375
+ df["glazing_type"] = classify_glazing_type_vectorised_sap(
376
+ df["WINDOWS_DESCRIPTION"]
377
+ )
378
+
379
+ return df
src/features/effici.egg-info/PKG-INFO ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: effici
3
+ Version: 0.1.0
4
+ Summary: Energy modeling & EPC features
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ ---
9
+
10
+ # Project Description
11
+
12
+ This project automates building energy modeling with EnergyPlus: it modifies model parameters using eppy, runs simulations, and extracts energy consumption results from the SQL output using pandas.
13
+
14
+ ## Main Steps
15
+
16
+ 1. **IDF File Modification**
17
+ - Change building parameters (e.g. heating setpoint, wall conductivity).
18
+ - Add `Output:SQLite` and `Output:Meter` objects for required reports.
19
+
20
+ 2. **Run EnergyPlus Simulation**
21
+ - Use the modified IDF and weather data.
22
+
23
+ 3. **Result Analysis**
24
+ - Read the SQL output and extract annual electricity and gas consumption.
25
+
26
+ ## Requirements
27
+
28
+ - Python 3.10+
29
+ - EnergyPlus 25.1.0
30
+ - eppy
31
+ - pandas
32
+ - sqlite3
33
+
34
+ ## Quick Start
35
+
36
+ 1. Install dependencies:
37
+ 2. Make sure EnergyPlus is installed and example/weather files are available.
38
+ 3. Run simulation preparation and execution:
39
+ 4. Analyze results:
40
+
41
+ ## Project Structure
42
+
43
+ - `src/run_simulation.py` — prepares and runs the EnergyPlus simulation
44
+ - `src/analyze_results.py` — analyzes results from the SQL output
45
+ - `src/config.py` — project paths and parameters
46
+ - `src/utils.py` — helper functions
47
+ - `src/simulation_output/` — EnergyPlus output files
48
+ - `src/test_building.idf` — modified EnergyPlus input file
49
+
50
+ ## Results
51
+
52
+ The console displays the building's annual electricity and gas consumption (if gas is used in the model).
src/features/effici.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ src/features/construction_age_band_sap.py
4
+ src/features/roofs.py
5
+ src/features/walls.py
6
+ src/features/effici.egg-info/PKG-INFO
7
+ src/features/effici.egg-info/SOURCES.txt
8
+ src/features/effici.egg-info/dependency_links.txt
9
+ src/features/effici.egg-info/top_level.txt
10
+ tests/test_analysis.py
11
+ tests/test_simulation.py
src/features/effici.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
src/features/effici.egg-info/top_level.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ construction_age_band_sap
2
+ roofs
3
+ walls
src/features/energy_system.py ADDED
@@ -0,0 +1,603 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ def classify_main_heating_system(text):
6
+ if text is None or not isinstance(text, str):
7
+ return "other"
8
+
9
+ t = text.lower()
10
+
11
+ if "community" in t:
12
+ return "community_heating"
13
+
14
+ if "heat pump" in t:
15
+ return "heat_pump"
16
+
17
+ if "boiler" in t:
18
+ return "boiler"
19
+
20
+ if "warm air" in t or "electricaire" in t:
21
+ return "warm_air"
22
+
23
+ if "storage heater" in t or "electric storage" in t:
24
+ return "storage_heater"
25
+
26
+ if "room heaters" in t:
27
+ return "room_heater"
28
+
29
+ if "electric" in t and "heater" in t:
30
+ return "direct_electric"
31
+
32
+ if "sap05" in t:
33
+ return "other"
34
+
35
+ return "other"
36
+
37
+
38
+ def classify_secondary_heating(text):
39
+ if text is None or not isinstance(text, str):
40
+ return "none"
41
+
42
+ t = text.lower()
43
+
44
+ # --- explicit none / missing ---
45
+ if t in ["none", "dim"] or "no system" in t:
46
+ return "none"
47
+
48
+ # --- solid fuels ---
49
+ if any(x in t for x in [
50
+ "coal", "anthracite", "wood", "pellet", "chips", "smokeless"
51
+ ]):
52
+ return "solid_fuel"
53
+
54
+ # --- oil ---
55
+ if "oil" in t:
56
+ return "oil_room_heater"
57
+
58
+ # --- gas & LPG ---
59
+ if any(x in t for x in [
60
+ "mains gas", "lpg", "lng", "bottled gas"
61
+ ]):
62
+ return "gas_room_heater"
63
+
64
+ # --- electric ---
65
+ if "electric" in t:
66
+ return "direct_electric"
67
+
68
+ # --- SAP placeholders ---
69
+ if "sap05" in t:
70
+ return "other"
71
+
72
+ return "other"
73
+
74
+
75
+ def classify_main_fuel_type(text):
76
+ """
77
+ Classify EPC MAINHEAT_DESCRIPTION into SAP-compatible main fuel types.
78
+ """
79
+
80
+ if text is None or not isinstance(text, str):
81
+ return "other"
82
+
83
+ t = text.lower()
84
+
85
+ # --- 1. Community heating ---
86
+ if "community" in t:
87
+ return "heat_network"
88
+
89
+ # --- 2. Heat pumps (always electricity in EPC) ---
90
+ if "heat pump" in t:
91
+ return "electricity"
92
+
93
+ # --- 3. Electricity ---
94
+ if any(x in t for x in [
95
+ "electric",
96
+ "electricaire",
97
+ "storage heater",
98
+ "electric underfloor",
99
+ "electric ceiling",
100
+ ]):
101
+ return "electricity"
102
+
103
+ # --- 4. Mains gas ---
104
+ if "mains gas" in t or "nwy prif" in t:
105
+ return "mains_gas"
106
+
107
+ # --- 5. LPG ---
108
+ if any(x in t for x in [
109
+ "lpg",
110
+ "bottled lpg",
111
+ "bottled gas",
112
+ ]):
113
+ return "lpg"
114
+
115
+ # --- 6. Oil ---
116
+ if "oil" in t:
117
+ return "oil"
118
+
119
+ # --- 7. Biomass ---
120
+ if any(x in t for x in [
121
+ "biomass",
122
+ "wood pellets",
123
+ "wood chips",
124
+ ]):
125
+ return "biomass"
126
+
127
+ # --- 8. Solid fuels ---
128
+ if any(x in t for x in [
129
+ "coal",
130
+ "anthracite",
131
+ "smokeless",
132
+ "wood logs",
133
+ "dual fuel",
134
+ ]):
135
+ return "solid_fuel"
136
+
137
+ return "other"
138
+
139
+
140
+
141
+ def classify_dhw_system(text):
142
+ """
143
+ Classify EPC HOTWATER_DESCRIPTION into SAP / ML compatible DHW system types.
144
+ """
145
+
146
+ if text is None or not isinstance(text, str):
147
+ return "other"
148
+
149
+ t = text.lower()
150
+
151
+ # --- SAP placeholders / missing ---
152
+ if "sap05" in t or "no system present" in t:
153
+ return "other"
154
+
155
+ # --- Community DHW ---
156
+ if "community" in t:
157
+ return "community"
158
+
159
+ # --- Heat pump DHW ---
160
+ if "heat pump" in t:
161
+ return "heat_pump"
162
+
163
+ # --- Solar-assisted DHW ---
164
+ # (still fundamentally main heating or immersion, but solar flag dominates)
165
+ if "solar" in t:
166
+ return "solar_assisted"
167
+
168
+ # --- Gas instantaneous / multipoint ---
169
+ if any(x in t for x in [
170
+ "gas instantaneous",
171
+ "gas multipoint",
172
+ "single-point gas",
173
+ ]):
174
+ return "gas_instantaneous"
175
+
176
+ # --- Electric instantaneous (point of use) ---
177
+ if "electric instantaneous" in t:
178
+ return "direct_electric"
179
+
180
+ # --- Electric immersion (storage) ---
181
+ if "electric immersion" in t:
182
+ return "electric_storage"
183
+
184
+ # --- From main / secondary heating system ---
185
+ if any(x in t for x in [
186
+ "from main system",
187
+ "from secondary system",
188
+ "boiler/circulator",
189
+ "range cooker",
190
+ "o'r brif system",
191
+ "og’r brif system",
192
+ "second main heating system",
193
+ ]):
194
+ return "main_heating"
195
+
196
+ return "other"
197
+
198
+
199
+ def classify_ventilation_system(text):
200
+ """
201
+ SAP / RdSAP 2012 ventilation system classification.
202
+ """
203
+
204
+ if text is None or not isinstance(text, str):
205
+ return "natural"
206
+
207
+ t = text.lower()
208
+
209
+ if "heat recovery" in t or "mvhr" in t:
210
+ return "mvhr"
211
+
212
+ if "positive input" in t:
213
+ return "piv"
214
+
215
+ if "supply and extract" in t:
216
+ return "mech_supply_extract"
217
+
218
+ if "extract" in t:
219
+ return "mech_extract"
220
+
221
+ if "mechanical" in t:
222
+ return "mech_extract"
223
+
224
+ # includes 'natural' and 'NO DATA!'
225
+ return "natural"
226
+
227
+
228
+ def extract_low_energy_lighting_fraction(text):
229
+ """
230
+ Extract fraction of low-energy lighting from EPC LIGHTING_DESCRIPTION.
231
+ Returns float in [0,1] or None if unknown.
232
+ """
233
+
234
+ if text is None or not isinstance(text, str):
235
+ return None
236
+
237
+ t = text.lower()
238
+
239
+ # --- Explicit none ---
240
+ if "no low energy lighting" in t:
241
+ return 0.0
242
+
243
+ # --- All outlets ---
244
+ if "all fixed outlets" in t or "ym mhob" in t:
245
+ return 1.0
246
+
247
+ # --- Percentage extraction (robust) ---
248
+ m = re.search(r"(\d+(\.\d+)?)\s*%", t)
249
+ if m:
250
+ pct = float(m.group(1))
251
+ return max(0.0, min(pct / 100.0, 1.0))
252
+
253
+ # --- Qualitative EPC fallbacks ---
254
+ if "excellent lighting efficiency" in t or "excelent lighting efficiency" in t:
255
+ return 1.0
256
+
257
+ if "good lighting efficiency" in t:
258
+ return 0.8
259
+
260
+ if "below average lighting efficiency" in t:
261
+ return 0.2
262
+
263
+ # --- SAP placeholders ---
264
+ if "sap05" in t:
265
+ return None
266
+
267
+ return None
268
+
269
+
270
+ def estimate_pv_kwp_from_row(row):
271
+ """
272
+ Estimate installed PV capacity (kWp) from a single EPC row
273
+ using SAP S11(b)-compliant logic.
274
+
275
+ Required EPC fields in `row`:
276
+ - TOTAL_FLOOR_AREA
277
+ - PROPERTY_TYPE
278
+ - PHOTO_SUPPLY
279
+ - FLAT_STOREY_COUNT
280
+ - ROOF_DESCRIPTION
281
+ """
282
+
283
+ # -------------------------------
284
+ # 0. Guard clauses
285
+ # -------------------------------
286
+ tfa = row.get("TOTAL_FLOOR_AREA")
287
+ photo_supply = row.get("PHOTO_SUPPLY")
288
+
289
+ if (
290
+ tfa is None or
291
+ photo_supply is None or
292
+ tfa <= 0 or
293
+ photo_supply <= 0
294
+ ):
295
+ return 0.0
296
+
297
+ property_type = str(row.get("PROPERTY_TYPE", "")).lower()
298
+ roof_desc = str(row.get("ROOF_DESCRIPTION", "")).lower()
299
+
300
+ # -------------------------------
301
+ # 1. Horizontal roof projection
302
+ # -------------------------------
303
+ if property_type == "flat":
304
+ storeys = row.get("FLAT_STOREY_COUNT")
305
+ if storeys is None or storeys <= 0:
306
+ return 0.0 # cannot apportion roof vertically
307
+ roof_projection = tfa / storeys
308
+ else:
309
+ # House, bungalow, maisonette
310
+ roof_projection = tfa / 2.0
311
+
312
+ # -------------------------------
313
+ # 2. Roof pitch inference (geometry only)
314
+ # -------------------------------
315
+ if "flat" in roof_desc:
316
+ roof_is_pitched = False
317
+ elif any(x in roof_desc for x in ["pitched", "rafters", "roof room"]):
318
+ roof_is_pitched = True
319
+ else:
320
+ # fallback by property type
321
+ roof_is_pitched = property_type in ["house", "bungalow", "maisonette"]
322
+
323
+ pitch_factor = (
324
+ 1.0 / np.cos(np.deg2rad(35))
325
+ if roof_is_pitched
326
+ else 1.0
327
+ )
328
+
329
+ # -------------------------------
330
+ # 3. PV-covered area (SAP S11)
331
+ # -------------------------------
332
+ pv_area = (
333
+ roof_projection
334
+ * (photo_supply / 100.0)
335
+ * pitch_factor
336
+ )
337
+
338
+ # -------------------------------
339
+ # 4. Convert area → capacity
340
+ # -------------------------------
341
+ pv_kwp = 0.12 * pv_area
342
+
343
+ return pv_kwp
344
+
345
+
346
+ def energy_system_feature_engineering(df):
347
+
348
+ df = df.copy()
349
+ df["MAIN_HEATING_SYSTEM"] = df["MAINHEAT_DESCRIPTION"].apply(classify_main_heating_system)
350
+ df["SECONDARY_HEATING_SYSTEM"] = df["SECONDHEAT_DESCRIPTION"].apply(classify_secondary_heating)
351
+ df["MAIN_FUEL_TYPE"] = df["MAINHEAT_DESCRIPTION"].apply(classify_main_fuel_type)
352
+ df["DHW_SUPPLY_SYSTEM"] = df["HOTWATER_DESCRIPTION"].apply(classify_dhw_system)
353
+ df["VENTILATION_SYSTEM"] = df["MECHANICAL_VENTILATION"].apply(classify_ventilation_system)
354
+ df["LIGHTENING_TYPE"] = df["LIGHTING_DESCRIPTION"].apply(extract_low_energy_lighting_fraction)
355
+ df["PV_KWP"] = df.apply(estimate_pv_kwp_from_row, axis=1)
356
+
357
+ return df
358
+
359
+
360
+ def classify_main_heating_system_vectorised(series: pd.Series) -> pd.Series:
361
+ s = series.fillna("").str.lower()
362
+
363
+ out = pd.Series("other", index=s.index)
364
+
365
+ # IMPORTANT: apply in the SAME ORDER as scalar version
366
+ mask = out.eq("other") & s.str.contains("community")
367
+ out[mask] = "community_heating"
368
+
369
+ mask = out.eq("other") & s.str.contains("heat pump")
370
+ out[mask] = "heat_pump"
371
+
372
+ mask = out.eq("other") & s.str.contains("boiler")
373
+ out[mask] = "boiler"
374
+
375
+ mask = out.eq("other") & s.str.contains("warm air|electricaire")
376
+ out[mask] = "warm_air"
377
+
378
+ mask = out.eq("other") & s.str.contains("storage heater|electric storage")
379
+ out[mask] = "storage_heater"
380
+
381
+ mask = out.eq("other") & s.str.contains("room heaters")
382
+ out[mask] = "room_heater"
383
+
384
+ mask = out.eq("other") & s.str.contains("electric") & s.str.contains("heater")
385
+ out[mask] = "direct_electric"
386
+
387
+ # sap05 and everything else remain "other"
388
+ return out
389
+
390
+
391
+ def classify_secondary_heating_vectorised(series: pd.Series) -> pd.Series:
392
+ s = series.fillna("").str.lower().str.strip()
393
+
394
+ # SAP default: no secondary heating
395
+ out = pd.Series("none", index=s.index)
396
+
397
+ # Solid fuels (incl. bioethanol, B30K)
398
+ mask = out.eq("none") & s.str.contains(
399
+ r"coal|anthracite|wood|pellet|chips|smokeless|bioethanol|b30k"
400
+ )
401
+ out[mask] = "solid_fuel"
402
+
403
+ # Oil
404
+ mask = out.eq("none") & s.str.contains("oil")
405
+ out[mask] = "oil_room_heater"
406
+
407
+ # Gas & LPG (English + Welsh)
408
+ mask = out.eq("none") & s.str.contains(
409
+ r"mains gas|lpg|lng|bottled gas|nwy prif"
410
+ )
411
+ out[mask] = "gas_room_heater"
412
+
413
+ # Electric
414
+ mask = out.eq("none") & s.str.contains("electric")
415
+ out[mask] = "direct_electric"
416
+
417
+ # Everything else stays "none" by design
418
+ return out
419
+
420
+
421
+ def classify_main_fuel_type_vectorised(series: pd.Series) -> pd.Series:
422
+ s = series.fillna("").str.lower()
423
+
424
+ out = pd.Series("other", index=s.index)
425
+
426
+ # 1. Community heating (highest priority)
427
+ m = s.str.contains("community")
428
+ out[m] = "heat_network"
429
+
430
+ # 2. Heat pumps → electricity
431
+ m = s.str.contains("heat pump") & (out == "other")
432
+ out[m] = "electricity"
433
+
434
+ # 3. Electricity (direct / storage / underfloor)
435
+ m = s.str.contains(
436
+ "electric|electricaire|storage heater|electric underfloor|electric ceiling"
437
+ ) & (out == "other")
438
+ out[m] = "electricity"
439
+
440
+ # 4. Mains gas
441
+ m = s.str.contains("mains gas|nwy prif") & (out == "other")
442
+ out[m] = "mains_gas"
443
+
444
+ # 5. LPG
445
+ m = s.str.contains("lpg|bottled lpg|bottled gas") & (out == "other")
446
+ out[m] = "lpg"
447
+
448
+ # 6. Oil
449
+ m = s.str.contains("oil") & (out == "other")
450
+ out[m] = "oil"
451
+
452
+ # 7. Biomass
453
+ m = s.str.contains("biomass|wood pellets|wood chips") & (out == "other")
454
+ out[m] = "biomass"
455
+
456
+ # 8. Solid fuels
457
+ m = s.str.contains("coal|anthracite|smokeless|wood logs|dual fuel") & (out == "other")
458
+ out[m] = "solid_fuel"
459
+
460
+ return out
461
+
462
+
463
+ def classify_dhw_system_vectorised(series: pd.Series) -> pd.Series:
464
+ s = series.fillna("").str.lower()
465
+
466
+ out = pd.Series("other", index=s.index)
467
+
468
+ # 0. SAP placeholders / missing
469
+ m = s.str.contains("sap05|no system present")
470
+ out[m] = "other"
471
+
472
+ # 1. Community DHW
473
+ m = s.str.contains("community") & (out == "other")
474
+ out[m] = "community"
475
+
476
+ # 2. Heat pump DHW
477
+ m = s.str.contains("heat pump") & (out == "other")
478
+ out[m] = "heat_pump"
479
+
480
+ # 3. Solar-assisted DHW (dominant flag)
481
+ m = s.str.contains("solar") & (out == "other")
482
+ out[m] = "solar_assisted"
483
+
484
+ # 4. Gas instantaneous / multipoint
485
+ m = s.str.contains(
486
+ "gas instantaneous|gas multipoint|single-point gas"
487
+ ) & (out == "other")
488
+ out[m] = "gas_instantaneous"
489
+
490
+ # 5. Electric instantaneous (point-of-use)
491
+ m = s.str.contains("electric instantaneous") & (out == "other")
492
+ out[m] = "direct_electric"
493
+
494
+ # 6. Electric immersion (storage)
495
+ m = s.str.contains("electric immersion") & (out == "other")
496
+ out[m] = "electric_storage"
497
+
498
+ # 7. From main / secondary heating system (fallback)
499
+ m = s.str.contains(
500
+ "from main system|from secondary system|boiler/circulator|range cooker|"
501
+ "o'r brif system|og’r brif system|second main heating system"
502
+ ) & (out == "other")
503
+ out[m] = "main_heating"
504
+
505
+ return out
506
+
507
+
508
+ def classify_ventilation_system_vectorised(series: pd.Series) -> pd.Series:
509
+ s = series.fillna("").str.lower()
510
+
511
+ out = pd.Series("natural", index=s.index)
512
+
513
+ # 1. MVHR (explicit, must exclude "without heat recovery")
514
+ m = (
515
+ (
516
+ s.str.contains("mvhr") |
517
+ (s.str.contains("heat recovery") & ~s.str.contains("without heat recovery"))
518
+ )
519
+ & (out == "natural")
520
+ )
521
+ out[m] = "mvhr"
522
+
523
+ # 2. Positive input ventilation
524
+ m = s.str.contains("positive input") & (out == "natural")
525
+ out[m] = "piv"
526
+
527
+ # 3. Mechanical supply & extract
528
+ m = s.str.contains("supply and extract") & (out == "natural")
529
+ out[m] = "mech_supply_extract"
530
+
531
+ # 4. Mechanical extract (fallback)
532
+ m = s.str.contains("extract|mechanical") & (out == "natural")
533
+ out[m] = "mech_extract"
534
+
535
+ return out
536
+
537
+
538
+ def extract_low_energy_lighting_fraction_vectorised(series: pd.Series) -> pd.Series:
539
+ s = series.fillna("").str.lower()
540
+
541
+ out = pd.Series(np.nan, index=s.index)
542
+
543
+ # Explicit none
544
+ out[s.str.contains("no low energy lighting")] = 0.0
545
+
546
+ # All outlets
547
+ out[s.str.contains("all fixed outlets|ym mhob")] = 1.0
548
+
549
+ # Qualitative descriptors (handle misspelling)
550
+ out[s.str.contains("excellent lighting efficiency|excelent lighting efficiency")] = 1.0
551
+ out[s.str.contains("good lighting efficiency")] = 0.8
552
+ out[s.str.contains("below average lighting efficiency")] = 0.2
553
+
554
+ # Percentage extraction (overrides qualitative if present)
555
+ pct = s.str.extract(r"(\d+(?:\.\d+)?)\s*%", expand=False).astype(float)
556
+ out[pct.notna()] = (pct / 100).clip(0, 1)
557
+
558
+ return out
559
+
560
+
561
+ def estimate_pv_kwp_vectorised(df: pd.DataFrame) -> pd.Series:
562
+ tfa = df["TOTAL_FLOOR_AREA"]
563
+ photo = df["PHOTO_SUPPLY"]
564
+
565
+ valid = (tfa > 0) & (photo > 0)
566
+
567
+ property_type = df["PROPERTY_TYPE"].fillna("").str.lower()
568
+ roof_desc = df["ROOF_DESCRIPTION"].fillna("").str.lower()
569
+
570
+ roof_projection = pd.Series(0.0, index=df.index)
571
+
572
+ # Flats
573
+ flats = property_type.eq("flat")
574
+ roof_projection[flats] = tfa[flats] / df.loc[flats, "FLAT_STOREY_COUNT"].replace(0, np.nan)
575
+
576
+ # Houses / bungalows / maisonettes
577
+ roof_projection[~flats] = tfa[~flats] / 2.0
578
+
579
+ roof_is_pitched = (
580
+ roof_desc.str.contains("pitched|rafters|roof room") |
581
+ (~roof_desc.str.contains("flat") & property_type.isin(["house", "bungalow", "maisonette"]))
582
+ )
583
+
584
+ pitch_factor = np.where(roof_is_pitched, 1 / np.cos(np.deg2rad(35)), 1.0)
585
+
586
+ pv_area = roof_projection * (photo / 100.0) * pitch_factor
587
+ pv_kwp = 0.12 * pv_area
588
+
589
+ return pv_kwp.where(valid, 0.0).fillna(0.0)
590
+
591
+
592
+ def energy_system_feature_engineering_vectorised(df: pd.DataFrame) -> pd.DataFrame:
593
+ df = df.copy()
594
+
595
+ df["MAIN_HEATING_SYSTEM"] = classify_main_heating_system_vectorised(df["MAINHEAT_DESCRIPTION"])
596
+ df["SECONDARY_HEATING_SYSTEM"] = classify_secondary_heating_vectorised(df["SECONDHEAT_DESCRIPTION"])
597
+ df["MAIN_FUEL_TYPE"] = classify_main_fuel_type_vectorised(df["MAINHEAT_DESCRIPTION"])
598
+ df["DHW_SUPPLY_SYSTEM"] = classify_dhw_system_vectorised(df["HOTWATER_DESCRIPTION"])
599
+ df["VENTILATION_SYSTEM"] = classify_ventilation_system_vectorised(df["MECHANICAL_VENTILATION"])
600
+ df["LIGHTING_FRACTION_LOW_ENERGY"] = extract_low_energy_lighting_fraction_vectorised(df["LIGHTING_DESCRIPTION"])
601
+ df["PV_KWP"] = estimate_pv_kwp_vectorised(df)
602
+
603
+ return df
src/features/floor.py ADDED
@@ -0,0 +1,818 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import math
3
+ import pandas as pd
4
+ import re
5
+ import math
6
+ from functools import lru_cache
7
+ from typing import Optional, Tuple
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+
13
+ def extract_mm(text):
14
+ if pd.isna(text):
15
+ return None
16
+ m = re.findall(r"(\d+)\s*mm", str(text).lower())
17
+ return int(m[0]) if m else None
18
+
19
+
20
+ def extract_measured_u(text):
21
+ if pd.isna(text):
22
+ return None
23
+ t = str(text).lower()
24
+ if "average thermal transmittance" not in t:
25
+ return None
26
+ nums = re.findall(r"([0-9]*\.?[0-9]+)", t)
27
+ if not nums:
28
+ return None
29
+ u = float(nums[0])
30
+ return None if u < 0.05 else u # treat 0.00 etc. as missing
31
+
32
+
33
+ def floor_ins_thickness_s11(sap_band_letter):
34
+ # From Table S11 (England & Wales column)
35
+ # A,B,C,D,E,F: none; G: 25; H: 75; I:100; J:100; K:100; L:100
36
+ m = {
37
+ "A": 0, "B": 0,
38
+ "C": 0, "D": 0, "E": 0, "F": 0,
39
+ "G": 0,
40
+ "H": 0,
41
+ "I": 25,
42
+ "J": 75,
43
+ "K": 100,
44
+ "L": 100,
45
+ }
46
+ return m.get(sap_band_letter, 0)
47
+
48
+
49
+ def estimate_B(a, p = None):
50
+ if p is None:
51
+ return 0.5 * math.sqrt(a)
52
+ else:
53
+ return 2 * a/p
54
+
55
+
56
+ def classify_floor_boundary(desc):
57
+ if pd.isna(desc):
58
+ return "ground"
59
+
60
+ t = str(desc).lower()
61
+
62
+ # --- 1. Measured U-value always wins ---
63
+ if "average thermal transmittance" in t:
64
+ return "measured_u_value"
65
+
66
+ # --- 2. No heat loss ---
67
+ if any(x in t for x in [
68
+ "another dwelling below",
69
+ "other premises below",
70
+ "same dwelling below",
71
+ "eiddo arall islaw"
72
+ ]):
73
+ return "another_dwelling_below"
74
+
75
+ # --- 3. Partially heated space below (S5.7) ---
76
+ if "partially heated" in t:
77
+ return "partially_heated_below"
78
+
79
+ # --- 4. Exposed to outside air (S5.6) ---
80
+ if "to external air" in t or "external air" in t:
81
+ return "exposed"
82
+
83
+ # --- 5. Semi-exposed: unheated enclosed space (S5.6) ---
84
+ if "to unheated space" in t or "unheated space" in t or "garage" in t:
85
+ return "semi_exposed"
86
+
87
+ # --- 6. Default: ground floor (S5.5) ---
88
+ return "ground"
89
+
90
+
91
+
92
+ def effective_floor_insulation_mm(desc, sap_band_letter):
93
+ """
94
+ SAP S11 rule:
95
+ - if retrofitted insulation → max(50 mm, table value)
96
+ - otherwise → table value
97
+ """
98
+ base_mm = floor_ins_thickness_s11(sap_band_letter)
99
+ t = str(desc).lower()
100
+
101
+ if "insulated" in t:
102
+ return max(50, base_mm)
103
+
104
+ return base_mm
105
+
106
+
107
+ def classify_wall_type_s3(desc: str) -> str:
108
+ if desc is None or not isinstance(desc, str):
109
+ return "solid brick" # safe SAP fallback
110
+
111
+ t = desc.lower()
112
+
113
+ # --- Welsh solid brick ---
114
+ if "briciau solet" in t:
115
+ return "solid brick"
116
+
117
+ # --- Stone / solid masonry ---
118
+ if any(x in t for x in [
119
+ "stone",
120
+ "sandstone",
121
+ "limestone",
122
+ "granite",
123
+ "whinstone",
124
+ "whin",
125
+ ]):
126
+ return "stone"
127
+
128
+ # --- Cob ---
129
+ if "cob" in t:
130
+ return "cob"
131
+
132
+ # --- Solid brick ---
133
+ if "solid brick" in t:
134
+ return "solid brick"
135
+
136
+ # --- Cavity ---
137
+ if "cavity" in t:
138
+ return "cavity"
139
+
140
+ # --- Timber frame ---
141
+ if "timber frame" in t:
142
+ return "timber frame"
143
+
144
+ # --- System build ---
145
+ if "system built" in t:
146
+ return "system build"
147
+
148
+ # --- Park home ---
149
+ if "park home" in t:
150
+ return "park home"
151
+
152
+ # --- Basement walls (SAP treats as solid masonry) ---
153
+ if "basement wall" in t:
154
+ return "stone"
155
+
156
+ # --- Fallback (SAP-safe) ---
157
+ return "solid brick"
158
+
159
+
160
+ def w_look_up_s3(desc, sap_band_letter, s3):
161
+ wall_type = classify_wall_type_s3(desc)
162
+
163
+ row = s3[
164
+ (s3["Wall Type"] == wall_type) &
165
+ (s3["sap_band"] == sap_band_letter)
166
+ ]
167
+
168
+ if row.empty:
169
+ raise ValueError(
170
+ f"No S3 wall thickness for wall_type={wall_type}, age={sap_band_letter}"
171
+ )
172
+
173
+ return float(row["thickness_mm"].iloc[0])/ 1000.0
174
+
175
+
176
+
177
+
178
+ # ---------- S5.5 solid ground floor ----------
179
+ def u_solid_ground_floor(
180
+ desc_floor,
181
+ desc_wall,
182
+ area,
183
+ sap_band_letter,
184
+ s3,
185
+ p=None,
186
+ lg=1.5
187
+ ):
188
+ """
189
+ SAP RdSAP 2012 S5.5 – Solid ground floor (ISO 13370)
190
+ """
191
+
192
+ Rsi = 0.17
193
+ Rse = 0.04
194
+
195
+ # wall thickness from Table S3
196
+ w = w_look_up_s3(desc_wall, sap_band_letter, s3)
197
+
198
+ # insulation thickness
199
+ dins_mm = effective_floor_insulation_mm(desc_floor, sap_band_letter)
200
+ Rf = 0.001 * dins_mm / 0.035 if dins_mm > 0 else 0.0
201
+
202
+ dt = w + lg * (Rsi + Rf + Rse)
203
+
204
+ # geometric factor
205
+ if p is None:
206
+ # assume square plan: P = 4√A → B = √A / 2
207
+ B = math.sqrt(area) / 2
208
+ else:
209
+ B = 2 * area / p
210
+
211
+ if dt < B:
212
+ return (2 * lg * math.log(math.pi * B / dt + 1.0)) / (math.pi * B + dt)
213
+ else:
214
+ return lg / (0.457 * B + dt)
215
+
216
+
217
+ def u_suspended_ground_floor(
218
+ desc_floor,
219
+ desc_wall,
220
+ area,
221
+ sap_band_letter,
222
+ s3,
223
+ p=None,
224
+ lg=1.5
225
+ ):
226
+ """
227
+ SAP RdSAP 2012 S5.5 – Suspended ground floor
228
+ """
229
+
230
+ Rsi = 0.17
231
+ Uw = 1.5
232
+ h = 0.3
233
+ v = 5.0
234
+ fw = 0.05
235
+ e = 0.003
236
+
237
+ # wall thickness from S3
238
+ w = w_look_up_s3(desc_wall, sap_band_letter, s3)
239
+
240
+ # insulation resistance
241
+ dins_mm = effective_floor_insulation_mm(desc_floor, sap_band_letter)
242
+ if dins_mm > 0:
243
+ Rf = (0.001 * dins_mm / 0.035) + 0.2
244
+ else:
245
+ Rf = 0.2
246
+
247
+ dg = w + lg * (Rsi + 0.04)
248
+
249
+ # geometry
250
+ if p is None:
251
+ B = math.sqrt(area) / 2
252
+ else:
253
+ B = 2 * area / p
254
+
255
+ Ug = (2 * lg * math.log(math.pi * B / dg + 1.0)) / (math.pi * B + dg)
256
+
257
+ Ux = (2 * h * Uw / B) + (1450 * e * v * fw / B)
258
+
259
+ return 1.0 / (2 * Rsi + Rf + 1.0 / (Ug + Ux))
260
+
261
+
262
+ def bin_floor_insulation_s12(desc, mm):
263
+ """
264
+ SAP RdSAP 2012 Table S12 insulation binning.
265
+ Used ONLY for exposed / semi-exposed floors.
266
+ """
267
+
268
+ t = str(desc).lower()
269
+
270
+ # Explicitly uninsulated
271
+ if "no insulation" in t or "uninsulated" in t or "average thermal transmittance" in t:
272
+ return "as_built"
273
+
274
+
275
+ # Measured thickness → bin
276
+ if mm is not None:
277
+ if mm >= 150:
278
+ return "150mm"
279
+ elif mm >= 100:
280
+ return "100mm"
281
+ elif mm >= 50:
282
+ return "50mm"
283
+ else:
284
+ return "as_built"
285
+
286
+ # Insulated but unknown thickness
287
+ if "insulated" in t:
288
+ return "50mm"
289
+
290
+ # Default
291
+ return "as_built"
292
+
293
+
294
+ def lookup_s12_u_value(sap_band_letter, insulation_class):
295
+ """
296
+ SAP RdSAP 2012 Table S12 (England & Wales).
297
+ """
298
+
299
+ table = {
300
+ # A–G
301
+ "A": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
302
+ "B": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
303
+ "C": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
304
+ "D": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
305
+ "E": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
306
+ "F": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
307
+ "G": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
308
+
309
+ # H–I
310
+ "H": {"as_built": 0.51, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
311
+ "I": {"as_built": 0.51, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
312
+
313
+ # J
314
+ "J": {"as_built": 0.25, "50mm": 0.25, "100mm": 0.25, "150mm": 0.22},
315
+
316
+ # K
317
+ "K": {"as_built": 0.22, "50mm": 0.22, "100mm": 0.22, "150mm": 0.22},
318
+
319
+ # L
320
+ "L": {"as_built": 0.22, "50mm": 0.22, "100mm": 0.22, "150mm": 0.22},
321
+ }
322
+
323
+ return table[sap_band_letter][insulation_class]
324
+
325
+
326
+
327
+ def floor_u_value_s12(desc, sap_band_letter):
328
+ """
329
+ SAP RdSAP 2012 S5.6 – Exposed / Semi-exposed floors
330
+ Uses Table S12 only.
331
+ """
332
+
333
+ mm = extract_mm(desc)
334
+ ins_class = bin_floor_insulation_s12(desc, mm)
335
+
336
+ return lookup_s12_u_value(sap_band_letter, ins_class)
337
+
338
+
339
+
340
+ def floors_u_rule(row,s3):
341
+
342
+ boundary = classify_floor_boundary(row["FLOOR_DESCRIPTION"])
343
+ if boundary == "another_dwelling_below":
344
+ return 0.0
345
+ if boundary == "partially_heated_below":
346
+ return 0.7
347
+ if boundary in ["exposed", "semi_exposed"]:
348
+ return floor_u_value_s12(
349
+ desc = row["FLOOR_DESCRIPTION"],
350
+ sap_band_letter=row["sap_band_letter"]
351
+ )
352
+ if boundary == "ground":
353
+ if "suspended" in str(row["FLOOR_DESCRIPTION"]).lower():
354
+ return u_suspended_ground_floor(
355
+ desc_floor=row["FLOOR_DESCRIPTION"],
356
+ desc_wall=row["WALLS_DESCRIPTION"],
357
+ area=row["TOTAL_FLOOR_AREA"],
358
+ sap_band_letter=row["sap_band_letter"],
359
+ s3=s3
360
+ )
361
+ else:
362
+ return u_solid_ground_floor(
363
+ desc_floor=row["FLOOR_DESCRIPTION"],
364
+ desc_wall=row["WALLS_DESCRIPTION"],
365
+ area=row["TOTAL_FLOOR_AREA"],
366
+ sap_band_letter=row["sap_band_letter"],
367
+ s3=s3
368
+ )
369
+ if boundary == "measured_u_value":
370
+ return extract_measured_u(row["FLOOR_DESCRIPTION"])
371
+ return None
372
+
373
+
374
+ def floors_insulation_type(row):
375
+ mm = extract_mm(row["FLOOR_DESCRIPTION"])
376
+ desc = row["FLOOR_DESCRIPTION"]
377
+ return bin_floor_insulation_s12(desc,mm)
378
+
379
+
380
+ def floor_feature_engineering(df: pd.DataFrame, s3: pd.DataFrame) -> pd.DataFrame:
381
+ df = df.copy()
382
+
383
+ df["FLOOR_U_VALUE"] = df.apply(lambda row: floors_u_rule(row,s3), axis=1)
384
+ df["FLOOR_INSULATION_TYPE"] = df.apply(floors_insulation_type, axis=1)
385
+ df["FLOOR_BOUNDARY_TYPE"] = df["FLOOR_DESCRIPTION"].apply(classify_floor_boundary)
386
+
387
+ return df
388
+
389
+
390
+ # ============================================================
391
+ # FAST + PHYSICS-PRESERVING FLOOR FEATURE ENGINEERING
392
+ # (no area binning; caches SAP-dependent parameters; vectorized math)
393
+ # ============================================================
394
+
395
+
396
+ # -----------------------------
397
+ # Helpers: parsing (vectorized)
398
+ # -----------------------------
399
+ _MM_RE = re.compile(r"(\d+)\s*mm", flags=re.IGNORECASE)
400
+ _U_RE = re.compile(r"([0-9]*\.?[0-9]+)", flags=re.IGNORECASE)
401
+
402
+ def extract_mm_vectorised(series: pd.Series) -> pd.Series:
403
+ """Extract first '<int> mm' -> float mm; else NaN."""
404
+ s = series.fillna("").astype(str).str.lower()
405
+ mm = s.str.extract(r"(\d+)\s*mm", expand=False)
406
+ return pd.to_numeric(mm, errors="coerce")
407
+
408
+
409
+ def extract_measured_u_vectorised(series: pd.Series) -> pd.Series:
410
+ """
411
+ Robust vectorized extraction of measured floor U-values from EPC text.
412
+ Handles '=', ':', encoding junk, and keeps small non-zero values.
413
+ """
414
+
415
+ s = (
416
+ series.fillna("")
417
+ .astype(str)
418
+ .str.lower()
419
+ .str.replace("¦", "", regex=False)
420
+ .str.replace("?", "", regex=False)
421
+ .str.replace(",", ".", regex=False)
422
+ )
423
+
424
+ mask = s.str.contains("average thermal transmittance")
425
+
426
+ # Allow '=', ':' or whitespace before number
427
+ num = s.where(mask).str.extract(
428
+ r"average thermal transmittance\s*[:=]?\s*([0-9]*\.?[0-9]+)",
429
+ expand=False
430
+ )
431
+
432
+ u = pd.to_numeric(num, errors="coerce")
433
+
434
+ # Only treat true placeholders as missing
435
+ u = u.where(~(u.abs() < 1e-9), np.nan)
436
+
437
+ return u
438
+
439
+
440
+
441
+ def classify_floor_boundary_vectorised(floor_desc: pd.Series) -> pd.Series:
442
+ """
443
+ Vectorized boundary classification.
444
+ """
445
+ s = floor_desc.fillna("").astype(str).str.lower()
446
+
447
+ out = pd.Series("ground", index=floor_desc.index, dtype="object")
448
+
449
+ # 1) measured U wins
450
+ measured = s.str.contains("average thermal transmittance")
451
+ out[measured] = "measured_u_value"
452
+
453
+ # 2) no heat loss: another dwelling below
454
+ below = s.str.contains(
455
+ "another dwelling below|other premises below|same dwelling below|eiddo arall islaw"
456
+ )
457
+ out[below & ~measured] = "another_dwelling_below"
458
+
459
+ # 3) partially heated
460
+ ph = s.str.contains("partially heated")
461
+ out[ph & ~measured & ~below] = "partially_heated_below"
462
+
463
+ # 4) exposed
464
+ exposed = s.str.contains(r"to external air|external air")
465
+ out[exposed & ~measured & ~below & ~ph] = "exposed"
466
+
467
+ # 5) semi-exposed
468
+ semi = s.str.contains(r"to unheated space|unheated space|garage")
469
+ out[semi & ~measured & ~below & ~ph & ~exposed] = "semi_exposed"
470
+
471
+ # default already ground
472
+ return out
473
+
474
+
475
+ # -----------------------------------------
476
+ # SAP S11: base insulation thickness by band
477
+ # -----------------------------------------
478
+ _S11_BASE_MM = {
479
+ "A": 0, "B": 0, "C": 0, "D": 0, "E": 0, "F": 0, "G": 0,
480
+ "H": 0,
481
+ "I": 25,
482
+ "J": 75,
483
+ "K": 100,
484
+ "L": 100,
485
+ }
486
+
487
+ def effective_floor_insulation_mm_vectorised(floor_desc: pd.Series, sap_band_letter: pd.Series) -> pd.Series:
488
+ """
489
+ SAP S11 rule:
490
+ - base_mm from S11 map
491
+ - if 'insulated' in description => max(50, base_mm)
492
+ Returns float mm.
493
+ """
494
+ band = sap_band_letter.fillna("").astype(str).str.strip().str.upper()
495
+ base = band.map(_S11_BASE_MM).fillna(0).astype(float)
496
+
497
+ s = floor_desc.fillna("").astype(str).str.lower()
498
+ insulated = s.str.contains("insulated")
499
+ eff = base.copy()
500
+ eff[insulated] = np.maximum(50.0, base[insulated])
501
+ return eff
502
+
503
+
504
+ # -----------------------------
505
+ # Wall type for S3 thickness map
506
+ # (SAP-safe categorization)
507
+ # -----------------------------
508
+ def classify_wall_type_s3_vectorised(walls_desc: pd.Series) -> pd.Series:
509
+ s = walls_desc.fillna("").astype(str).str.lower()
510
+
511
+ out = pd.Series("solid brick", index=walls_desc.index, dtype="object")
512
+
513
+ out[s.str.contains("briciau solet")] = "solid brick"
514
+
515
+ stone = s.str.contains("stone|sandstone|limestone|granite|whinstone|\\bwhin\\b")
516
+ out[stone] = "stone"
517
+
518
+ out[s.str.contains("cob")] = "cob"
519
+ out[s.str.contains("solid brick")] = "solid brick"
520
+ out[s.str.contains("cavity")] = "cavity"
521
+ out[s.str.contains("timber frame")] = "timber frame"
522
+ out[s.str.contains("system built")] = "system build"
523
+ out[s.str.contains("park home")] = "park home"
524
+ out[s.str.contains("basement wall")] = "stone"
525
+
526
+ return out
527
+
528
+
529
+ # -----------------------------
530
+ # Table S12 lookup (fast dict)
531
+ # -----------------------------
532
+ _S12_TABLE = {
533
+ # A–G
534
+ "A": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
535
+ "B": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
536
+ "C": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
537
+ "D": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
538
+ "E": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
539
+ "F": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
540
+ "G": {"as_built": 1.20, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
541
+ # H–I
542
+ "H": {"as_built": 0.51, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
543
+ "I": {"as_built": 0.51, "50mm": 0.50, "100mm": 0.30, "150mm": 0.22},
544
+ # J
545
+ "J": {"as_built": 0.25, "50mm": 0.25, "100mm": 0.25, "150mm": 0.22},
546
+ # K–L
547
+ "K": {"as_built": 0.22, "50mm": 0.22, "100mm": 0.22, "150mm": 0.22},
548
+ "L": {"as_built": 0.22, "50mm": 0.22, "100mm": 0.22, "150mm": 0.22},
549
+ }
550
+
551
+ def bin_floor_insulation_s12_vectorised(floor_desc: pd.Series, mm_measured: pd.Series) -> pd.Series:
552
+ """
553
+ SAP RdSAP Table S12 binning (for exposed/semi-exposed).
554
+ Returns class: as_built, 50mm, 100mm, 150mm
555
+ """
556
+ s = floor_desc.fillna("").astype(str).str.lower()
557
+
558
+ # start as as_built
559
+ out = pd.Series("as_built", index=floor_desc.index, dtype="object")
560
+
561
+ explicit_unins = s.str.contains("no insulation|uninsulated|average thermal transmittance")
562
+ out[explicit_unins] = "as_built"
563
+
564
+ # measured thickness bins
565
+ mm = mm_measured
566
+ out[(mm >= 50) & (mm < 100) & ~explicit_unins] = "50mm"
567
+ out[(mm >= 100) & (mm < 150) & ~explicit_unins] = "100mm"
568
+ out[(mm >= 150) & ~explicit_unins] = "150mm"
569
+
570
+ # insulated but unknown thickness -> assume 50mm
571
+ insulated_unknown = s.str.contains("insulated") & mm.isna() & ~explicit_unins
572
+ out[insulated_unknown] = "50mm"
573
+
574
+ return out
575
+
576
+ def lookup_s12_u_vectorised(sap_band_letter: pd.Series, ins_class: pd.Series) -> pd.Series:
577
+ band = sap_band_letter.fillna("").astype(str).str.strip().str.upper()
578
+ # map (band, class) -> value via dict of dicts
579
+ # faster: create a combined key
580
+ keys = list(_S12_TABLE.keys())
581
+ # We'll do row-wise via small map, but without apply on full DF:
582
+ # Convert to numpy and loop in Python is OK here because only exposed/semi_exposed subset is used.
583
+ out = np.full(len(band), np.nan, dtype=float)
584
+ b = band.to_numpy()
585
+ c = ins_class.to_numpy()
586
+
587
+ for i in range(len(out)):
588
+ bi = b[i]
589
+ ci = c[i]
590
+ if bi in _S12_TABLE and ci in _S12_TABLE[bi]:
591
+ out[i] = _S12_TABLE[bi][ci]
592
+ return pd.Series(out, index=sap_band_letter.index)
593
+
594
+
595
+ # -----------------------------
596
+ # S3 thickness mapping (prebuilt)
597
+ # -----------------------------
598
+ def build_s3_thickness_map(s3: pd.DataFrame) -> dict[tuple[str, str], float]:
599
+ """
600
+ Expect s3 columns: ['Wall Type','sap_band','thickness_mm'].
601
+ Returns meters.
602
+ """
603
+ tmp = s3.copy()
604
+ tmp["Wall Type"] = tmp["Wall Type"].astype(str).str.strip().str.lower()
605
+ tmp["sap_band"] = tmp["sap_band"].astype(str).str.strip().str.upper()
606
+ # meters
607
+ tmp["thickness_m"] = tmp["thickness_mm"].astype(float) / 1000.0
608
+ return {(r["Wall Type"], r["sap_band"]): r["thickness_m"] for _, r in tmp.iterrows()}
609
+
610
+
611
+ # -----------------------------
612
+ # Physics core: vectorized formulas
613
+ # -----------------------------
614
+ def _u_solid_ground_floor_vectorised(B: np.ndarray, dt: np.ndarray, lg: float = 1.5) -> np.ndarray:
615
+ """
616
+ Vectorized SAP/RdSAP 2012 S5.5 solid ground floor equation.
617
+ B, dt arrays in meters.
618
+ """
619
+ # two regimes: dt < B else
620
+ out = np.empty_like(B, dtype=float)
621
+
622
+ mask = dt < B
623
+ # (2*lg*ln(pi*B/dt + 1)) / (pi*B + dt)
624
+ out[mask] = (2.0 * lg * np.log(np.pi * B[mask] / dt[mask] + 1.0)) / (np.pi * B[mask] + dt[mask])
625
+
626
+ # lg / (0.457*B + dt)
627
+ out[~mask] = lg / (0.457 * B[~mask] + dt[~mask])
628
+ return out
629
+
630
+ def _u_suspended_ground_floor_vectorised(B: np.ndarray, dg: np.ndarray, Rf: np.ndarray, lg: float = 1.5) -> np.ndarray:
631
+ """
632
+ Vectorized SAP/RdSAP 2012 S5.5 suspended ground floor equation.
633
+ Uses constants from your function.
634
+ """
635
+ Rsi = 0.17
636
+ Uw = 1.5
637
+ h = 0.3
638
+ v = 5.0
639
+ fw = 0.05
640
+ e = 0.003
641
+
642
+ Ug = (2.0 * lg * np.log(np.pi * B / dg + 1.0)) / (np.pi * B + dg)
643
+ Ux = (2.0 * h * Uw / B) + (1450.0 * e * v * fw / B)
644
+
645
+ return 1.0 / (2.0 * Rsi + Rf + 1.0 / (Ug + Ux))
646
+
647
+
648
+ # -----------------------------
649
+ # Cached SAP-dependent parameters (NO area binning)
650
+ # -----------------------------
651
+ def build_floor_param_caches(s3: pd.DataFrame):
652
+ """
653
+ Returns two cached functions:
654
+ - solid_dt(wall_type_s3, sap_band, ins_mm) -> dt
655
+ - susp_params(wall_type_s3, sap_band, ins_mm) -> (dg, Rf)
656
+ """
657
+ thickness_map = build_s3_thickness_map(s3)
658
+
659
+ @lru_cache(maxsize=None)
660
+ def solid_dt(wall_type: str, sap_band: str, ins_mm: int, lg: float = 1.5) -> float:
661
+ # constants
662
+ Rsi = 0.17
663
+ Rse = 0.04
664
+ w = thickness_map.get((wall_type.lower(), sap_band.upper()))
665
+ if w is None:
666
+ # SAP-safe fallback
667
+ w = thickness_map.get(("solid brick", sap_band.upper()), 0.22)
668
+
669
+ # insulation resistance
670
+ if ins_mm > 0:
671
+ Rf = 0.001 * ins_mm / 0.035
672
+ else:
673
+ Rf = 0.0
674
+
675
+ dt = w + lg * (Rsi + Rf + Rse)
676
+ return float(dt)
677
+
678
+ @lru_cache(maxsize=None)
679
+ def susp_dg_rf(wall_type: str, sap_band: str, ins_mm: int, lg: float = 1.5) -> Tuple[float, float]:
680
+ Rsi = 0.17
681
+ w = thickness_map.get((wall_type.lower(), sap_band.upper()))
682
+ if w is None:
683
+ w = thickness_map.get(("solid brick", sap_band.upper()), 0.22)
684
+
685
+ # insulation resistance
686
+ if ins_mm > 0:
687
+ Rf = (0.001 * ins_mm / 0.035) + 0.2
688
+ else:
689
+ Rf = 0.2
690
+
691
+ dg = w + lg * (Rsi + 0.04)
692
+ return float(dg), float(Rf)
693
+
694
+ return solid_dt, susp_dg_rf
695
+
696
+
697
+ # -----------------------------
698
+ # Main pipeline (fast)
699
+ # -----------------------------
700
+ def floor_feature_engineering_fast(df: pd.DataFrame, s3: pd.DataFrame) -> pd.DataFrame:
701
+ """
702
+ Fast floor feature engineering:
703
+ - vectorized boundary classification
704
+ - measured U extracted vectorized
705
+ - exposed/semi_exposed uses S12 vectorized + small loop only over subset
706
+ - ground floors: preserves full area resolution:
707
+ * precompute B = sqrt(area)/2 (continuous)
708
+ * cache dt/dg/Rf parameters by (wall_type, band, insulation_mm)
709
+ * compute U with vectorized numpy formulas
710
+ Requirements: columns
711
+ - FLOOR_DESCRIPTION
712
+ - WALLS_DESCRIPTION
713
+ - TOTAL_FLOOR_AREA
714
+ - sap_band_letter
715
+ """
716
+ df = df.copy()
717
+
718
+ # Ensure band normalized
719
+ df["sap_band_letter"] = df["sap_band_letter"].astype(str).str.strip().str.upper()
720
+
721
+ # 0) Precompute B (continuous, no binning)
722
+ area = pd.to_numeric(df["TOTAL_FLOOR_AREA"], errors="coerce")
723
+ df["FLOOR_B"] = np.sqrt(area) / 2.0 # SAP square-plan assumption
724
+
725
+ # 1) Boundary type (vectorized)
726
+ df["FLOOR_BOUNDARY_TYPE"] = classify_floor_boundary_vectorised(df["FLOOR_DESCRIPTION"])
727
+
728
+ # 2) Measured U (vectorized)
729
+ measured_u = extract_measured_u_vectorised(df["FLOOR_DESCRIPTION"])
730
+
731
+ # 3) Insulation thickness:
732
+ # - For ground floors we use effective S11 rule (vectorized)
733
+ # - For exposed/semi-exposed we need measured mm for S12 binning (vectorized)
734
+ mm_measured = extract_mm_vectorised(df["FLOOR_DESCRIPTION"])
735
+ eff_mm = effective_floor_insulation_mm_vectorised(df["FLOOR_DESCRIPTION"], df["sap_band_letter"])
736
+
737
+ # 4) FLOOR_INSULATION_TYPE (your current approach uses S12 binning)
738
+ df["FLOOR_INSULATION_TYPE"] = bin_floor_insulation_s12_vectorised(df["FLOOR_DESCRIPTION"], mm_measured)
739
+
740
+ # 5) Wall type for S3 thickness (vectorized)
741
+ df["WALL_TYPE_S3"] = classify_wall_type_s3_vectorised(df["WALLS_DESCRIPTION"])
742
+
743
+ # 6) Build cached parameter functions
744
+ solid_dt_cached, susp_dg_rf_cached = build_floor_param_caches(s3)
745
+
746
+ # 7) Assemble FLOOR_U_VALUE (vectorized masks)
747
+ u = pd.Series(np.nan, index=df.index, dtype=float)
748
+ boundary = df["FLOOR_BOUNDARY_TYPE"]
749
+ band = df["sap_band_letter"]
750
+
751
+ # a) another dwelling below
752
+ u[boundary == "another_dwelling_below"] = 0.0
753
+
754
+ # b) partially heated below
755
+ u[boundary == "partially_heated_below"] = 0.7
756
+
757
+ # c) measured u
758
+ u[boundary == "measured_u_value"] = measured_u[boundary == "measured_u_value"]
759
+
760
+ # d) exposed / semi-exposed -> S12
761
+ exp_mask = boundary.isin(["exposed", "semi_exposed"])
762
+ if exp_mask.any():
763
+ ins_class = df.loc[exp_mask, "FLOOR_INSULATION_TYPE"]
764
+ u.loc[exp_mask] = lookup_s12_u_vectorised(band[exp_mask], ins_class).values
765
+
766
+ # e) ground floors -> ISO13370-ish SAP formulas (continuous area kept)
767
+ ground_mask = boundary == "ground"
768
+ if ground_mask.any():
769
+ floor_desc = df.loc[ground_mask, "FLOOR_DESCRIPTION"].fillna("").astype(str).str.lower()
770
+ is_suspended = floor_desc.str.contains("suspended")
771
+
772
+ gm_idx = df.index[ground_mask]
773
+ solid_idx = gm_idx[~is_suspended.to_numpy()]
774
+ susp_idx = gm_idx[is_suspended.to_numpy()]
775
+
776
+ # ---- SOLID GROUND ----
777
+ if len(solid_idx) > 0:
778
+ B = df.loc[solid_idx, "FLOOR_B"].to_numpy(dtype=float)
779
+ wall_t = df.loc[solid_idx, "WALL_TYPE_S3"].astype(str).to_numpy()
780
+ sb = df.loc[solid_idx, "sap_band_letter"].astype(str).to_numpy()
781
+ mm = eff_mm.loc[solid_idx].fillna(0).astype(int).to_numpy()
782
+
783
+ # cache dt per row (small Python loop, but only computing cache keys;
784
+ # dt computation itself is cached & cheap, and number of unique keys is small)
785
+ dt = np.empty(len(solid_idx), dtype=float)
786
+ for i in range(len(solid_idx)):
787
+ dt[i] = solid_dt_cached(wall_t[i], sb[i], int(mm[i]))
788
+
789
+ u.loc[solid_idx] = _u_solid_ground_floor_vectorised(B, dt)
790
+
791
+ # ---- SUSPENDED GROUND ----
792
+ if len(susp_idx) > 0:
793
+ B = df.loc[susp_idx, "FLOOR_B"].to_numpy(dtype=float)
794
+ wall_t = df.loc[susp_idx, "WALL_TYPE_S3"].astype(str).to_numpy()
795
+ sb = df.loc[susp_idx, "sap_band_letter"].astype(str).to_numpy()
796
+ mm = eff_mm.loc[susp_idx].fillna(0).astype(int).to_numpy()
797
+
798
+ dg = np.empty(len(susp_idx), dtype=float)
799
+ Rf = np.empty(len(susp_idx), dtype=float)
800
+ for i in range(len(susp_idx)):
801
+ dgi, Rfi = susp_dg_rf_cached(wall_t[i], sb[i], int(mm[i]))
802
+ dg[i] = dgi
803
+ Rf[i] = Rfi
804
+
805
+ u.loc[susp_idx] = _u_suspended_ground_floor_vectorised(B, dg, Rf)
806
+
807
+ df["FLOOR_U_VALUE"] = u
808
+
809
+ return df
810
+
811
+
812
+ # ============================================================
813
+ # Usage example:
814
+ # s3 = pd.read_csv(...) or pd.read_excel(...) with columns:
815
+ # Wall Type | sap_band | thickness_mm
816
+ # df_total = floor_feature_engineering_fast(df_total, s3)
817
+ # ============================================================
818
+
src/features/roofs.py ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import re
3
+ import numpy as np
4
+
5
+
6
+ def extract_roof_mm(text):
7
+ """Return insulation thickness in mm, or None."""
8
+ if pd.isna(text):
9
+ return None
10
+
11
+ t = str(text).lower()
12
+
13
+ # ignore U-value rows
14
+ if "average thermal transmittance" in t:
15
+ return None
16
+
17
+ # match 300 mm, 300mm, 300+ mm, 300 + mm, 300+mm
18
+ match = re.findall(r"(\d+)\s*\+?\s*mm", t)
19
+ if not match:
20
+ return None
21
+
22
+ return int(match[0])
23
+
24
+
25
+ def classify_roof_type(text):
26
+ if "pitched" in str(text).lower():
27
+ return "pitched"
28
+ elif "flat" in str(text).lower():
29
+ return "flat"
30
+ elif "roof" in str(text).lower():
31
+ return "roof"
32
+ elif "above" in str(text).lower():
33
+ return "above"
34
+ elif "average thermal transmittance" in str(text).lower():
35
+ return "measured_u"
36
+ else:
37
+ return "UKN"
38
+
39
+
40
+ def normalize_mm_to_s9(mm):
41
+ """
42
+ Normalize insulation thickness to SAP S9 valid categories.
43
+ Input:
44
+ mm : int, float, or None
45
+ Output:
46
+ int (SAP mm category) or None
47
+ SAP S9 valid values:
48
+ [0, 12, 25, 50, 75, 100, 150, 200, 250, 270, 300, 350, 400]
49
+ Rules:
50
+ - None → 0
51
+ - mm <= 0 → 0
52
+ - mm >= 400 → 400
53
+ - otherwise → nearest LOWER category
54
+ """
55
+
56
+ SAP_S9_VALUES = [0, 12, 25, 50, 75, 100, 150, 200, 250, 270, 300, 350, 400]
57
+
58
+ # Case: no value → treat as uninsulated
59
+ if mm is None or (isinstance(mm, float) and np.isnan(mm)):
60
+ return 0
61
+
62
+ # Convert to number
63
+ mm = float(mm)
64
+
65
+ # Negative or zero → uninsulated
66
+ if mm <= 0:
67
+ return 0
68
+
69
+ # ≥400 mm → use 400 category
70
+ if mm >= 400:
71
+ return 400
72
+
73
+ # Find largest S9 category <= mm
74
+ eligible = [v for v in SAP_S9_VALUES if v <= mm]
75
+ return eligible[-1] if eligible else 0
76
+
77
+
78
+
79
+ def classify_pitched_roof_category(text: str) -> str:
80
+ """
81
+ Returns the appropriate U-value category: S9 (Measured) or S10 (Assumed/Other).
82
+ """
83
+ text_lower = str(text).lower()
84
+
85
+ # --- 1. S10 Triggers (Take precedence over measurement) ---
86
+ if "rafters" in text_lower:
87
+ return "S10_RAFTERS"
88
+
89
+ # CORRECTED LOGIC: Each string must be checked against text_lower
90
+ if ("assumed" in text_lower or
91
+ "unknown loft insulation" in text_lower or
92
+ "invalid input code" in text_lower):
93
+ return "S10_JOISTS_UNKNOWN"
94
+
95
+ # --- 2. S9 Triggers (Known/Measured Thickness) ---
96
+ # Check for explicit 'no insulation' (observed) or '0 mm'
97
+ if "no insulation" in text_lower or re.search(r"\b0\s*mm\b", text_lower):
98
+ return "S9_NONE"
99
+
100
+ # Check for any quantifiable number (mm) or a comparison (e.g., 300+)
101
+ # This must come *before* the general 'pitched' check.
102
+ mm_match = re.search(r'(\d+|\d+\+|\>\=\d+)', text_lower)
103
+ if mm_match:
104
+ return "S9_MEASURED"
105
+
106
+ # --- 3. Default to S10 (General unquantified cases) ---
107
+ # Catches descriptions like "pitched, loft insulation" or just "pitched"
108
+ if "pitched" in text_lower:
109
+ return "S10_JOISTS_UNKNOWN"
110
+
111
+ return "NON_PITCHED_OR_UKN"
112
+
113
+
114
+ def extract_pitch_u_value(text, sap_band_letter, S9_table, S10_table):
115
+ """
116
+ Compute U-value for pitched roof using S9/S10 tables + description text.
117
+ """
118
+
119
+ category = classify_pitched_roof_category(text)
120
+
121
+ # ---- S9: No insulation (assumed) ----
122
+ if category == "S9_NONE":
123
+ return 2.3
124
+
125
+ # ---- S9: Measured insulation thickness ----
126
+ elif category == "S9_MEASURED":
127
+ mm = extract_roof_mm(text)
128
+ mm = normalize_mm_to_s9(mm)
129
+ value = S9_table.loc[S9_table["mm"] == mm, "slates_tiles"]
130
+ return float(value.iloc[0]) if not value.empty else None
131
+
132
+ # ---- S10: Rafters present ----
133
+ elif category == "S10_RAFTERS":
134
+ # Older buildings (A–D) default to uninsulated
135
+ if sap_band_letter in ["A", "B", "C", "D"]:
136
+ return 2.3
137
+ value = S10_table.loc[S10_table["age_band"] == sap_band_letter, "Pitched_rafters"]
138
+ return float(value.iloc[0]) if not value.empty else None
139
+
140
+ # ---- S10: Unknown pitched roof form ----
141
+ else:
142
+ if sap_band_letter in ["A", "B", "C", "D"]:
143
+ return 2.3
144
+ value = S10_table.loc[S10_table["age_band"] == sap_band_letter, "Pitched_unknown"]
145
+ return float(value.iloc[0]) if not value.empty else None
146
+
147
+
148
+
149
+ def get_flat_roof_u_value(is_top_floor, sap_band_letter, s10):
150
+
151
+ # 1. Not top floor → no heat loss
152
+ if str(is_top_floor).strip().upper() == "N":
153
+ return 0.0
154
+
155
+ # 2. Missing age band → can't compute
156
+ if pd.isna(sap_band_letter):
157
+ return None
158
+
159
+ band = str(sap_band_letter).strip().upper()
160
+
161
+ # Normalize S10 band column
162
+ s10_bands = s10["age_band"].astype(str).str.strip().str.upper()
163
+
164
+ # 3. Bands A–D → map to merged row "A, B, C, D"
165
+ if band in ["A", "B", "C", "D"]:
166
+ row = s10.loc[s10_bands == "A, B, C, D", "Flat_roof"]
167
+ if not row.empty:
168
+ return float(row.iloc[0])
169
+ else:
170
+ return 2.3 # SAP fallback
171
+
172
+ # 4. E–L: direct match
173
+ row = s10.loc[s10_bands == band, "Flat_roof"]
174
+ if not row.empty:
175
+ return float(row.iloc[0])
176
+
177
+ # 5. SAP fallback for band L if missing in table
178
+ if band == "L":
179
+ return 0.18 # known SAP S10 value
180
+
181
+ return None
182
+
183
+
184
+ def extract_measured_u(description):
185
+ if pd.isna(description):
186
+ return None
187
+
188
+ text = str(description).lower()
189
+
190
+ if "average thermal transmittance" not in text:
191
+ return None
192
+
193
+ # match integer OR float
194
+ match = re.search(r"(\d+(?:\.\d+)?)", text)
195
+ if match:
196
+ return float(match.group(1))
197
+
198
+ return None
199
+
200
+
201
+ def get_room_in_roof_u_value(sap_band_letter, s10):
202
+ # Check for "room in roof" in the description
203
+ if sap_band_letter in ["A", "B", "C", "D"]:
204
+ return 2.3
205
+ else:
206
+ # Look up the U-value in the s10 DataFrame
207
+ row = s10[s10["age_band"] == sap_band_letter]
208
+ if not row.empty:
209
+ u_value = row["Room_in_roof"].values[0]
210
+ return u_value
211
+ return None
212
+
213
+
214
+ def calculate_overall_roof_u_value(row,s9,s10):
215
+ roof_class = classify_roof_type(row["ROOF_DESCRIPTION"])
216
+ if roof_class == "pitched":
217
+ return extract_pitch_u_value(row["ROOF_DESCRIPTION"], row["sap_band_letter"], s9, s10)
218
+ elif roof_class == "flat":
219
+ return get_flat_roof_u_value(row["FLAT_TOP_STOREY"], row["sap_band_letter"], s10)
220
+ elif roof_class == "measured_u":
221
+ return extract_measured_u(row["ROOF_DESCRIPTION"])
222
+ elif roof_class == "roof":
223
+ return get_room_in_roof_u_value(row["sap_band_letter"], s10)
224
+ elif roof_class == "above":
225
+ return 0.0
226
+ else:
227
+ return None
228
+
229
+
230
+
231
+ def extract_roof_insulation(row):
232
+ desc = row["ROOF_DESCRIPTION"]
233
+ flat_top = row.get("FLAT_TOP_STOREY")
234
+ t = str(desc).lower()
235
+
236
+ # -------------------------------
237
+ # 0. ABOVE overrides everything
238
+ # -------------------------------
239
+ if "above" in t or (flat_top is not None and str(flat_top).upper() == "N"):
240
+ return "above"
241
+
242
+ # -------------------------------
243
+ # 1. Measured U-value
244
+ # -------------------------------
245
+ if "average thermal transmittance" in t:
246
+ return "measured"
247
+
248
+ # -------------------------------
249
+ # 2. explicit no insulation
250
+ # -------------------------------
251
+ if "no insulation" in t:
252
+ return "none"
253
+
254
+ # -------------------------------
255
+ # 3. insulation at rafters
256
+ # -------------------------------
257
+ if "insulated at rafters" in t:
258
+ return "rafters"
259
+
260
+ # -------------------------------
261
+ # 4. numerical mm thickness
262
+ # -------------------------------
263
+ mm = extract_roof_mm(desc)
264
+ if mm is not None:
265
+ if mm == 0:
266
+ return "none"
267
+ return "loft_insulation"
268
+
269
+ # -------------------------------
270
+ # 5. generic loft insulation
271
+ # (no mm, still should count)
272
+ # -------------------------------
273
+ if "loft insulation" in t:
274
+ return "loft_insulation"
275
+
276
+ # -------------------------------
277
+ # 6. UNKNOWN loft insulation
278
+ # -------------------------------
279
+ if "unknown" in t and "loft" in t:
280
+ return "unknown_loft"
281
+
282
+ # -------------------------------
283
+ # 7. thatched roofs
284
+ # -------------------------------
285
+ if "thatched" in t:
286
+ return "thatched"
287
+
288
+ # roof room variants with thatch
289
+ if "roof room" in t and "thatched" in t:
290
+ return "roof_room_thatched"
291
+
292
+ # -------------------------------
293
+ # 8. limited insulation
294
+ # -------------------------------
295
+ if "limited" in t:
296
+ return "limited"
297
+
298
+ # -------------------------------
299
+ # 9. generic insulated (not rafters)
300
+ # -------------------------------
301
+ if "insulated" in t:
302
+ return "insulated"
303
+
304
+ # -------------------------------
305
+ # 10. roof room (no specific mm)
306
+ # -------------------------------
307
+ if "roof room" in t:
308
+ return "roof_room"
309
+
310
+ # -------------------------------
311
+ # fallback
312
+ # -------------------------------
313
+ return "unknown"
314
+
315
+
316
+
317
+ def build_roof_lookup(roof_desc: pd.Series) -> pd.DataFrame:
318
+ """
319
+ Parse ROOF_DESCRIPTION once.
320
+ Returns a lookup table keyed by ROOF_DESCRIPTION.
321
+ """
322
+
323
+ s = roof_desc.fillna("").astype(str).str.lower()
324
+
325
+ out = pd.DataFrame({
326
+ "ROOF_DESCRIPTION": roof_desc,
327
+ "ROOF_CLASS": pd.NA, # pitched / flat / above / room / measured
328
+ "ROOF_MM_RAW": pd.NA,
329
+ "ROOF_MM_S9": pd.NA,
330
+ "ROOF_PITCH_CATEGORY": pd.NA, # S9_MEASURED / S9_NONE / S10_RAFTERS / S10_UNKNOWN
331
+ "ROOF_MEASURED_U": pd.NA,
332
+ "ROOF_INSULATION_TYPE": pd.NA
333
+ }).drop_duplicates("ROOF_DESCRIPTION")
334
+
335
+ # ---------------------------
336
+ # ROOF CLASS (priority order)
337
+ # ---------------------------
338
+ out.loc[s.str.contains("average thermal transmittance"), "ROOF_CLASS"] = "measured"
339
+ out.loc[s.str.contains("above"), "ROOF_CLASS"] = "above"
340
+ out.loc[s.str.contains("roof room"), "ROOF_CLASS"] = "room"
341
+ out.loc[s.str.contains("flat"), "ROOF_CLASS"] = "flat"
342
+ out.loc[s.str.contains("pitched"), "ROOF_CLASS"] = "pitched"
343
+
344
+ # ---------------------------
345
+ # MEASURED U-VALUE
346
+ # ---------------------------
347
+ m = (
348
+ s.where(s.str.contains("average thermal transmittance"))
349
+ .str.extract(r"(\d+(?:\.\d+)?)", expand=False)
350
+ )
351
+ out.loc[out["ROOF_CLASS"] == "measured", "ROOF_MEASURED_U"] = pd.to_numeric(m, errors="coerce")
352
+
353
+ # ---------------------------
354
+ # RAW MM EXTRACTION
355
+ # ---------------------------
356
+ mm = s.str.extract(r"(\d+)\s*\+?\s*mm", expand=False)
357
+ out["ROOF_MM_RAW"] = pd.to_numeric(mm, errors="coerce")
358
+
359
+ # ---------------------------
360
+ # NORMALISE TO SAP S9 MM
361
+ # ---------------------------
362
+ SAP_S9_VALUES = np.array([0, 12, 25, 50, 75, 100, 150, 200, 250, 270, 300, 350, 400])
363
+
364
+ def to_s9(mm):
365
+ if pd.isna(mm) or mm <= 0:
366
+ return 0
367
+ if mm >= 400:
368
+ return 400
369
+ return SAP_S9_VALUES[SAP_S9_VALUES <= mm].max()
370
+
371
+ out["ROOF_MM_S9"] = out["ROOF_MM_RAW"].map(to_s9)
372
+
373
+ # ---------------------------
374
+ # PITCHED ROOF CATEGORY
375
+ # ---------------------------
376
+ pitched = out["ROOF_CLASS"] == "pitched"
377
+
378
+ out.loc[pitched & s.str.contains("rafters"), "ROOF_PITCH_CATEGORY"] = "S10_RAFTERS"
379
+ out.loc[pitched & s.str.contains("no insulation"), "ROOF_PITCH_CATEGORY"] = "S9_NONE"
380
+ out.loc[pitched & out["ROOF_MM_RAW"].notna(), "ROOF_PITCH_CATEGORY"] = "S9_MEASURED"
381
+
382
+ out.loc[
383
+ pitched &
384
+ out["ROOF_PITCH_CATEGORY"].isna() &
385
+ s.str.contains("assumed|unknown|invalid"),
386
+ "ROOF_PITCH_CATEGORY"
387
+ ] = "S10_UNKNOWN"
388
+
389
+ out.loc[
390
+ pitched & out["ROOF_PITCH_CATEGORY"].isna(),
391
+ "ROOF_PITCH_CATEGORY"
392
+ ] = "S10_UNKNOWN"
393
+
394
+ # ---------------------------
395
+ # INSULATION TYPE (semantic)
396
+ # ---------------------------
397
+ out.loc[s.str.contains("rafters"), "ROOF_INSULATION_TYPE"] = "rafters"
398
+ out.loc[s.str.contains("no insulation"), "ROOF_INSULATION_TYPE"] = "none"
399
+ out.loc[s.str.contains("thatched"), "ROOF_INSULATION_TYPE"] = "thatched"
400
+ out.loc[s.str.contains("loft"), "ROOF_INSULATION_TYPE"] = "loft"
401
+ out.loc[out["ROOF_MM_RAW"].notna(), "ROOF_INSULATION_TYPE"] = "loft"
402
+
403
+ return out
404
+
405
+
406
+ def build_roof_u_dicts(s9: pd.DataFrame, s10: pd.DataFrame):
407
+
408
+ S9_U = dict(zip(s9["mm"], s9["slates_tiles"]))
409
+
410
+ S10_PITCHED = dict(zip(s10["age_band"], s10["Pitched_unknown"]))
411
+ S10_RAFTERS = dict(zip(s10["age_band"], s10["Pitched_rafters"]))
412
+ S10_FLAT = dict(zip(s10["age_band"], s10["Flat_roof"]))
413
+ S10_ROOM = dict(zip(s10["age_band"], s10["Room_in_roof"]))
414
+
415
+ return S9_U, S10_PITCHED, S10_RAFTERS, S10_FLAT, S10_ROOM
416
+
417
+
418
+ def roof_feature_engineering(
419
+ df: pd.DataFrame,
420
+ s9: pd.DataFrame,
421
+ s10: pd.DataFrame
422
+ ) -> pd.DataFrame:
423
+
424
+ df = df.copy()
425
+
426
+ # ----------------------------------
427
+ # 1. Parse roof descriptions ONCE
428
+ # ----------------------------------
429
+ roof_lookup = build_roof_lookup(df["ROOF_DESCRIPTION"]).set_index("ROOF_DESCRIPTION")
430
+
431
+ df["ROOF_CLASS"] = df["ROOF_DESCRIPTION"].map(roof_lookup["ROOF_CLASS"])
432
+ df["ROOF_MM_S9"] = df["ROOF_DESCRIPTION"].map(roof_lookup["ROOF_MM_S9"])
433
+ df["ROOF_PITCH_CATEGORY"] = df["ROOF_DESCRIPTION"].map(roof_lookup["ROOF_PITCH_CATEGORY"])
434
+ df["ROOF_MEASURED_U"] = df["ROOF_DESCRIPTION"].map(roof_lookup["ROOF_MEASURED_U"])
435
+ df["ROOF_INSULATION_TYPE"] = df["ROOF_DESCRIPTION"].map(roof_lookup["ROOF_INSULATION_TYPE"])
436
+
437
+ # ----------------------------------
438
+ # 2. SAP lookup dicts
439
+ # ----------------------------------
440
+ S9_U, S10_PITCHED, S10_RAFTERS, S10_FLAT, S10_ROOM = build_roof_u_dicts(s9, s10)
441
+
442
+ band = df["sap_band_letter"]
443
+
444
+ # ----------------------------------
445
+ # 3. Vectorised U-value logic
446
+ # ----------------------------------
447
+ u = pd.Series(np.nan, index=df.index)
448
+
449
+ # ABOVE
450
+ u[df["ROOF_CLASS"] == "above"] = 0.0
451
+
452
+ # MEASURED overrides everything
453
+ # u[df["ROOF_MEASURED_U"].notna()] = df.loc[
454
+ # df["ROOF_MEASURED_U"].notna(), "ROOF_MEASURED_U"
455
+ # ]
456
+ mask = df["ROOF_MEASURED_U"].notna()
457
+ u.loc[mask] = df.loc[mask, "ROOF_MEASURED_U"].astype(float)
458
+
459
+ # FLAT (top storey only)
460
+ mask = (
461
+ (df["ROOF_CLASS"] == "flat") &
462
+ (
463
+ df["FLAT_TOP_STOREY"].isna() |
464
+ (df["FLAT_TOP_STOREY"].astype(str).str.upper() == "Y")
465
+ )
466
+ )
467
+ u[mask] = band[mask].map(S10_FLAT)
468
+
469
+ # FLAT roofs with another dwelling above → no heat loss
470
+ mask = (
471
+ (df["ROOF_CLASS"] == "flat") &
472
+ (df["FLAT_TOP_STOREY"].astype(str).str.upper() == "N")
473
+ )
474
+ u[mask] = 0.0
475
+
476
+ # ROOM IN ROOF
477
+ mask = df["ROOF_CLASS"] == "room"
478
+ u[mask] = band[mask].map(S10_ROOM)
479
+
480
+ # PITCHED – S9 MEASURED
481
+ mask = (
482
+ (df["ROOF_CLASS"] == "pitched") &
483
+ (df["ROOF_PITCH_CATEGORY"] == "S9_MEASURED")
484
+ )
485
+ u[mask] = df.loc[mask, "ROOF_MM_S9"].map(S9_U)
486
+
487
+ # 🔥 FIX: PITCHED – NO INSULATION (S9_NONE)
488
+ mask = (
489
+ (df["ROOF_CLASS"] == "pitched") &
490
+ (df["ROOF_PITCH_CATEGORY"] == "S9_NONE")
491
+ )
492
+ u[mask] = 2.3
493
+
494
+ # PITCHED – RAFTERS
495
+ mask = (
496
+ (df["ROOF_CLASS"] == "pitched") &
497
+ (df["ROOF_PITCH_CATEGORY"] == "S10_RAFTERS")
498
+ )
499
+ u[mask] = band[mask].map(S10_RAFTERS)
500
+
501
+ # PITCHED – UNKNOWN
502
+ mask = (
503
+ (df["ROOF_CLASS"] == "pitched") &
504
+ (df["ROOF_PITCH_CATEGORY"] == "S10_UNKNOWN")
505
+ )
506
+ u[mask] = band[mask].map(S10_PITCHED)
507
+
508
+ # ----------------------------------
509
+ # 4. SAP fallback for A–D
510
+ # ----------------------------------
511
+ fallback = band.isin(["A", "B", "C", "D"]) & u.isna()
512
+ u[fallback] = 2.3
513
+
514
+ df["ROOF_U_VALUE"] = u
515
+
516
+ return df
src/features/walls.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import pandas as pd
3
+ import numpy as np
4
+
5
+
6
+ def extract_wall_u_from_text(text: str | float | None) -> float | None:
7
+ """
8
+ Extract numeric U-value from WALLS_DESCRIPTION when it contains
9
+ 'Average thermal transmittance ...', which corresponds to the
10
+ average external wall U-value in SAP/RdSAP EPCs.
11
+
12
+ If no numeric value is found or it looks invalid (< 0.05),
13
+ return None and let Stage 2 handle it.
14
+ """
15
+ if pd.isna(text):
16
+ return None
17
+
18
+ s = str(text).lower()
19
+ if "average thermal transmittance" not in s:
20
+ return None
21
+
22
+ # Find first number in the string (handles '0.30', '1.4', etc.)
23
+ nums = re.findall(r"([0-9]*\.?[0-9]+)", s)
24
+ if not nums:
25
+ return None
26
+
27
+ u = float(nums[0])
28
+
29
+ # EPC sometimes has '0.00' for missing; treat as unknown
30
+ if u < 0.05:
31
+ return None
32
+
33
+ return u
34
+
35
+
36
+
37
+ def classify_wall_type(text):
38
+ """
39
+ Classify EPC WALLS_DESCRIPTION into BASE wall construction type only.
40
+
41
+ This function encodes *construction identity*, NOT insulation state
42
+ and NOT performance. It is suitable for retrofit inference.
43
+
44
+ Final categories:
45
+ - solid
46
+ - cavity
47
+ - filled cavity
48
+ - timber frame
49
+ - system build
50
+ - cob
51
+ - unknown
52
+ """
53
+ if pd.isna(text):
54
+ return "unknown"
55
+
56
+ t = text.lower().strip()
57
+
58
+ # --------------------------------------------------------
59
+ # 0. Direct U-value entry → unknown construction
60
+ # --------------------------------------------------------
61
+ if "average thermal transmittance" in t:
62
+ return "unknown"
63
+
64
+ # --------------------------------------------------------
65
+ # 1. Cob (distinct SAP category)
66
+ # --------------------------------------------------------
67
+ if "cob" in t:
68
+ return "cob"
69
+
70
+ # --------------------------------------------------------
71
+ # 2. Solid masonry (brick / stone)
72
+ # --------------------------------------------------------
73
+ if (
74
+ "briciau solet" in t or
75
+ any(x in t for x in [
76
+ "solid brick",
77
+ "solid stone",
78
+ "sandstone",
79
+ "limestone",
80
+ "granite",
81
+ "whinstone",
82
+ "whin"
83
+ ])
84
+ ):
85
+ return "solid"
86
+
87
+ # --------------------------------------------------------
88
+ # 3. Timber frame
89
+ # --------------------------------------------------------
90
+ if "timber frame" in t:
91
+ return "timber frame"
92
+
93
+ # --------------------------------------------------------
94
+ # 4. System build (explicit SAP construction class)
95
+ # --------------------------------------------------------
96
+ if "system build" in t or "system built" in t:
97
+ return "system built"
98
+
99
+ # --------------------------------------------------------
100
+ # 5. Cavity walls
101
+ # --------------------------------------------------------
102
+ if "cavity" in t:
103
+ if "filled cavity" in t:
104
+ return "filled cavity"
105
+ else:
106
+ return "unfilled cavity"
107
+
108
+ # --------------------------------------------------------
109
+ # 6. Basement / retaining walls (not envelope)
110
+ # --------------------------------------------------------
111
+ if "basement wall" in t or "retaining wall" in t:
112
+ return "unknown"
113
+
114
+ return "unknown"
115
+
116
+
117
+
118
+ def extract_wall_insulation(text):
119
+ """
120
+ Extract wall insulation depth category from WALLS_DESCRIPTION.
121
+
122
+ Returns one of:
123
+ - "as built"
124
+ - "50 mm"
125
+ - "100 mm"
126
+ - "150 mm"
127
+ - "200 mm"
128
+ - None (measured U-value only)
129
+
130
+ Insulation state ONLY. No construction identity.
131
+ """
132
+ if pd.isna(text):
133
+ return None
134
+
135
+ t = text.lower()
136
+
137
+ # --------------------------------------------------------
138
+ # 0. Direct U-value entry → no insulation category
139
+ # --------------------------------------------------------
140
+ if "average thermal transmittance" in t:
141
+ return None
142
+
143
+ # --------------------------------------------------------
144
+ # 1. Explicit thickness (must come FIRST)
145
+ # --------------------------------------------------------
146
+ if "200 mm" in t:
147
+ return "200 mm"
148
+ if "150 mm" in t:
149
+ return "150 mm"
150
+ if "100 mm" in t:
151
+ return "100 mm"
152
+ if "50 mm" in t:
153
+ return "50 mm"
154
+
155
+ # --------------------------------------------------------
156
+ # 2. Generic insulation statements
157
+ # --------------------------------------------------------
158
+ if "internal insulation" in t or "external insulation" in t:
159
+ return "50 mm"
160
+
161
+ if "partial insulation" in t or "insulated" in t:
162
+ return "50 mm"
163
+
164
+ # --------------------------------------------------------
165
+ # 3. Explicit no insulation
166
+ # --------------------------------------------------------
167
+ if "no insulation" in t or "as built" in t:
168
+ return "as built"
169
+
170
+ # --------------------------------------------------------
171
+ # 4. Default
172
+ # --------------------------------------------------------
173
+ return "as built"
174
+
175
+
176
+ def lookup_wall_u_value(row, walls_u_values):
177
+ wall_type = classify_wall_type(row["WALLS_DESCRIPTION"])
178
+ raw_age = row["sap_band_label"]
179
+
180
+ # If EPC gives numeric U-value → use it
181
+ numeric_u = extract_wall_u_from_text(row["WALLS_DESCRIPTION"])
182
+ if numeric_u is not None:
183
+ return numeric_u
184
+
185
+ # --------------------------------------------------------
186
+ # INTERNAL SAP AGE-BAND MAPPING (TABLE S1 – England & Wales)
187
+ # --------------------------------------------------------
188
+ AGE_BAND_MAP = {
189
+ "pre-1900": "before 1900",
190
+ "before 1900": "before 1900",
191
+
192
+ "1900-1929": "1900–1929",
193
+ "1930-1949": "1930–1949",
194
+ "1950-1966": "1950–1966",
195
+ "1967-1975": "1967–1975",
196
+ "1976-1982": "1976–1982",
197
+ "1983-1990": "1983–1990",
198
+ "1991-1995": "1991–1995",
199
+ "1996-2002": "1996–2002",
200
+
201
+ # Also catch accidental unicode/duplicate variations
202
+ "1996–2002": "1996–2002",
203
+
204
+ "2003-2006": "2003–2006",
205
+ "2007-2011": "2007–2011",
206
+
207
+ "2012+": "2012 onwards",
208
+ "2012 onwards": "2012 onwards",
209
+ }
210
+
211
+ # --------------------------------------------------------
212
+ # Convert incoming SAP band → exact column name in U-value table
213
+ # --------------------------------------------------------
214
+ age_label = AGE_BAND_MAP.get(raw_age, None)
215
+
216
+ # If mapping fails, return NaN (should be extremely rare)
217
+ if age_label is None:
218
+ return np.nan
219
+
220
+ # If wall type is None → cannot assign table U-value
221
+ if wall_type is None:
222
+ return np.nan
223
+
224
+ # --------------------------------------------------------
225
+ # U-value lookup (exact match required)
226
+ # --------------------------------------------------------
227
+ if age_label in walls_u_values.columns:
228
+ vals = walls_u_values.loc[
229
+ walls_u_values["External wall type"] == wall_type,
230
+ age_label
231
+ ]
232
+
233
+ if len(vals) > 0:
234
+ return vals.values[0]
235
+
236
+ return np.nan
237
+
238
+
239
+
240
+ def prepare_wall_u_table(walls_u_values: pd.DataFrame) -> pd.DataFrame:
241
+ return walls_u_values.melt(
242
+ id_vars="External wall type",
243
+ var_name="WALL_AGE_LABEL",
244
+ value_name="WALL_U_TABLE"
245
+ )
246
+
247
+
248
+ def build_wall_lookup(walls_desc: pd.Series) -> pd.DataFrame:
249
+ """
250
+ Parse each unique WALLS_DESCRIPTION once.
251
+ """
252
+ uniq = walls_desc.dropna().unique()
253
+
254
+ rows = []
255
+ for desc in uniq:
256
+ rows.append({
257
+ "WALLS_DESCRIPTION": desc,
258
+ "WALL_TYPE": classify_wall_type(desc),
259
+ "WALL_INSULATION": extract_wall_insulation(desc),
260
+ "WALL_U_MEASURED": extract_wall_u_from_text(desc),
261
+ })
262
+
263
+ return pd.DataFrame(rows)
264
+
265
+
266
+ AGE_BAND_MAP = {
267
+ "pre-1900": "before 1900",
268
+ "before 1900": "before 1900",
269
+ "1900-1929": "1900–1929",
270
+ "1930-1949": "1930–1949",
271
+ "1950-1966": "1950–1966",
272
+ "1967-1975": "1967–1975",
273
+ "1976-1982": "1976–1982",
274
+ "1983-1990": "1983–1990",
275
+ "1991-1995": "1991–1995",
276
+ "1996-2002": "1996–2002",
277
+ "1996–2002": "1996–2002",
278
+ "2003-2006": "2003–2006",
279
+ "2007-2011": "2007–2011",
280
+ "2012+": "2012 onwards",
281
+ "2012 onwards": "2012 onwards",
282
+ }
283
+
284
+
285
+ def merge_wall_type_for_sap(wall_type: str, insulation: str) -> str:
286
+ """
287
+ Merge wall base type + insulation into SAP external wall type label.
288
+ Used ONLY for SAP U-value lookup.
289
+ """
290
+
291
+ if wall_type is None:
292
+ return None
293
+
294
+ if insulation in (None, "as built"):
295
+ return f"{wall_type}- as built"
296
+
297
+ return f"{wall_type}- {insulation} insulation"
298
+
299
+
300
+ def wall_feature_engineering(
301
+ df: pd.DataFrame,
302
+ walls_u_values: pd.DataFrame,
303
+ ) -> pd.DataFrame:
304
+ """
305
+ Wall feature engineering using dictionary-based lookups only.
306
+ No DataFrame merges (memory-safe and consistent with multi-key logic).
307
+
308
+ Steps:
309
+ 1. Parse WALLS_DESCRIPTION → wall semantics
310
+ 2. Normalise SAP age band
311
+ 3. Lookup SAP wall U-values via (WALL_TYPE, WALL_AGE_LABEL)
312
+ 4. Final U-value resolution: measured > SAP table
313
+ """
314
+
315
+ df = df.copy()
316
+
317
+ # ------------------------------------------------------------
318
+ # 1. Parse wall descriptions ONCE (dictionary lookup)
319
+ # ------------------------------------------------------------
320
+ # build_wall_lookup must return a DataFrame with:
321
+ # ["WALLS_DESCRIPTION", "WALL_TYPE", "WALL_INSULATION", "WALL_U_MEASURED"]
322
+ wall_lookup_df = build_wall_lookup(df["WALLS_DESCRIPTION"])
323
+
324
+ wall_lookup_dict = {
325
+ desc: (
326
+ row["WALL_TYPE"],
327
+ row["WALL_INSULATION"],
328
+ row["WALL_U_MEASURED"],
329
+ )
330
+ for desc, row in wall_lookup_df.set_index("WALLS_DESCRIPTION").iterrows()
331
+ }
332
+
333
+ parsed = df["WALLS_DESCRIPTION"].map(wall_lookup_dict)
334
+
335
+ df["WALL_TYPE"] = parsed.str[0]
336
+ df["WALL_INSULATION"] = parsed.str[1]
337
+ df["WALL_U_MEASURED"] = parsed.str[2]
338
+
339
+ # ------------------------------------------------------------
340
+ # 2. Normalise SAP age band (pure map, no join)
341
+ # ------------------------------------------------------------
342
+ df["WALL_AGE_LABEL"] = df["sap_band_label"].map(AGE_BAND_MAP)
343
+
344
+ # ------------------------------------------------------------
345
+ # 3. SAP wall U-value lookup via dictionary
346
+ # ------------------------------------------------------------
347
+ # Prepare long SAP table once
348
+ walls_u_long = prepare_wall_u_table(walls_u_values)
349
+
350
+ wall_u_dict = {
351
+ (row["External wall type"], row["WALL_AGE_LABEL"]): row["WALL_U_TABLE"]
352
+ for _, row in walls_u_long.iterrows()
353
+ }
354
+
355
+ # wall_keys = zip(df["WALL_TYPE"], df["WALL_AGE_LABEL"]) old version
356
+ # Merge wall type + insulation for SAP key (vectorised)
357
+ df["WALL_TYPE_SAP"] = [
358
+ merge_wall_type_for_sap(wt, ins)
359
+ for wt, ins in zip(df["WALL_TYPE"], df["WALL_INSULATION"])
360
+ ]
361
+
362
+ wall_keys = zip(df["WALL_TYPE_SAP"], df["WALL_AGE_LABEL"])
363
+
364
+ df["WALL_U_TABLE"] = [wall_u_dict.get(k) for k in wall_keys]
365
+
366
+ # ------------------------------------------------------------
367
+ # 4. Final U-value resolution (SAP rule)
368
+ # ------------------------------------------------------------
369
+ df["WALL_U_VALUE"] = df["WALL_U_MEASURED"].combine_first(df["WALL_U_TABLE"])
370
+
371
+ # ------------------------------------------------------------
372
+ # 5. Optional clean-up
373
+ # ------------------------------------------------------------
374
+ df.drop(columns=["WALL_U_TABLE"], inplace=True, errors="ignore")
375
+
376
+ return df
src/models/EpcEnergyPipeline.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import mlflow.pyfunc
2
+ import pandas as pd
3
+ import shap
4
+ from catboost import CatBoostRegressor
5
+ import numpy as np
6
+ from src.features.build_features import SAPTables, EPCFeatureEngineer
7
+
8
+
9
+ class EpcEnergyPipeline(mlflow.pyfunc.PythonModel):
10
+ """
11
+ MLflow-wrapped EPC energy model:
12
+ - loads CatBoost model
13
+ - computes SAP-aligned features
14
+ - predicts energy / CO2 / EPC score
15
+ """
16
+
17
+ def __init__(self, cb_model_path=None,sap_tables=None):
18
+ self.cb_model_path = cb_model_path
19
+ self.sap_tables = sap_tables
20
+ self.explainer = None
21
+
22
+
23
+ def load_context(self, context):
24
+ model_path = context.artifacts.get("catboost_model", self.cb_model_path)
25
+ sap_dir = context.artifacts.get("sap_tables", self.sap_tables)
26
+
27
+ self.model = CatBoostRegressor()
28
+ self.model.load_model(model_path)
29
+
30
+ self.sap = SAPTables.from_local_dir(sap_dir)
31
+ self.feature_engineer = EPCFeatureEngineer(self.sap)
32
+
33
+ self.explainer = shap.TreeExplainer(self.model)
34
+
35
+
36
+ def build_features(self, model_input):
37
+ features = self.feature_engineer.transform(model_input)
38
+ return features
39
+
40
+
41
+ def predict(self, context, model_input):
42
+ if not isinstance(model_input, pd.DataFrame):
43
+ model_input = pd.DataFrame(model_input)
44
+ enriched = self.build_features(model_input)
45
+ return np.expm1(self.model.predict(enriched))
46
+
47
+
48
+ def explain_predictions(self, model_input):
49
+ if not isinstance(model_input, pd.DataFrame):
50
+ model_input = pd.DataFrame(model_input)
51
+
52
+ X = self.build_features(model_input)
53
+ shap_values = self.explainer(X)
54
+ preds = self.model.predict(X)
55
+
56
+ return {
57
+ "prediction": float(preds[0]),
58
+ "base_value": float(self.explainer.expected_value),
59
+ "shap_values": shap_values.values.tolist(),
60
+ "feature_names": X.columns.tolist(),
61
+ "data": X.to_dict(orient="records"),
62
+ }
src/models/__pycache__/EpcEnergyPipeline.cpython-310.pyc ADDED
Binary file (2.27 kB). View file
 
src/models/__pycache__/EpcEnergyPipeline.cpython-312.pyc ADDED
Binary file (3.64 kB). View file