Spaces:
Sleeping
Sleeping
first code
Browse files- .idea/.gitignore +10 -0
- .idea/copilot.data.migration.ask2agent.xml +6 -0
- .idea/dataSources.xml +12 -0
- .idea/epc_only_data_model.iml +8 -0
- .idea/inspectionProfiles/profiles_settings.xml +6 -0
- .idea/misc.xml +7 -0
- .idea/modules.xml +8 -0
- .idea/vcs.xml +6 -0
- Dockerfile +51 -0
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-310.pyc +0 -0
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/__pycache__/handler.cpython-310.pyc +0 -0
- app/__pycache__/handler.cpython-312.pyc +0 -0
- app/__pycache__/main.cpython-310.pyc +0 -0
- app/__pycache__/main.cpython-312.pyc +0 -0
- app/handler.py +102 -0
- app/main.py +82 -0
- requirements.txt +28 -0
- src/features/__pycache__/build_features.cpython-310.pyc +0 -0
- src/features/__pycache__/build_features.cpython-312.pyc +0 -0
- src/features/__pycache__/construction_age_band_sap.cpython-310.pyc +0 -0
- src/features/__pycache__/construction_age_band_sap.cpython-312.pyc +0 -0
- src/features/__pycache__/energy_system.cpython-310.pyc +0 -0
- src/features/__pycache__/energy_system.cpython-312.pyc +0 -0
- src/features/__pycache__/floor.cpython-310.pyc +0 -0
- src/features/__pycache__/floor.cpython-312.pyc +0 -0
- src/features/__pycache__/roofs.cpython-310.pyc +0 -0
- src/features/__pycache__/roofs.cpython-312.pyc +0 -0
- src/features/__pycache__/walls.cpython-310.pyc +0 -0
- src/features/__pycache__/walls.cpython-312.pyc +0 -0
- src/features/build_features.py +161 -0
- src/features/construction_age_band_sap.py +379 -0
- src/features/effici.egg-info/PKG-INFO +52 -0
- src/features/effici.egg-info/SOURCES.txt +11 -0
- src/features/effici.egg-info/dependency_links.txt +1 -0
- src/features/effici.egg-info/top_level.txt +3 -0
- src/features/energy_system.py +603 -0
- src/features/floor.py +818 -0
- src/features/roofs.py +516 -0
- src/features/walls.py +376 -0
- src/models/EpcEnergyPipeline.py +62 -0
- src/models/__pycache__/EpcEnergyPipeline.cpython-310.pyc +0 -0
- 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
|
|
|