mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge pull request #558 from Hestia-Homes/eco-eligiblity-bug
Implemented tasks and subtasks into engine + Adding EPC caching
This commit is contained in:
commit
8da9f27c92
20 changed files with 676 additions and 1501 deletions
1264
asset_list/app.py
1264
asset_list/app.py
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,6 @@ import time
|
||||||
import random
|
import random
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from adhoc.investigation import newest_epc
|
|
||||||
from backend.SearchEpc import SearchEpc
|
from backend.SearchEpc import SearchEpc
|
||||||
from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc
|
from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
|
||||||
|
|
@ -917,7 +917,7 @@ class SearchEpc:
|
||||||
|
|
||||||
return agg[key].values[0]
|
return agg[key].values[0]
|
||||||
|
|
||||||
def find_property(self, skip_os=False):
|
def find_property(self, skip_os=False, api_data=None):
|
||||||
"""
|
"""
|
||||||
This method will attempt to identify a property. It will, at first, use the EPC api to try and
|
This method will attempt to identify a property. It will, at first, use the EPC api to try and
|
||||||
find the EPC for the property and the associated UPRN. If this fails, it will use the Ordnance Survey API to
|
find the EPC for the property and the associated UPRN. If this fails, it will use the Ordnance Survey API to
|
||||||
|
|
@ -928,10 +928,17 @@ class SearchEpc:
|
||||||
as a final check to see if there is any EPC data.
|
as a final check to see if there is any EPC data.
|
||||||
|
|
||||||
If there is no EPC data, the epc data will be estimated based on the surrounding properties
|
If there is no EPC data, the epc data will be estimated based on the surrounding properties
|
||||||
|
|
||||||
|
:param skip_os: If True, the ordnance survey api will be skipped and only the EPC api will be used
|
||||||
|
:param api_data: If provided, this data will be used instead of querying the EPC api
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Step 1: use the epc api to find the property and uprn
|
# Step 1: use the epc api to find the property and uprn
|
||||||
response = self.get_epc()
|
if api_data:
|
||||||
|
self.data = api_data
|
||||||
|
response = {"status": 200}
|
||||||
|
else:
|
||||||
|
response = self.get_epc()
|
||||||
|
|
||||||
if response["status"] == 200:
|
if response["status"] == 200:
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from pydantic_settings import BaseSettings
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
API_KEY: str
|
API_KEY: str
|
||||||
API_KEY_NAME: str = "X-API-KEY"
|
API_KEY_NAME: str = "X-API-KEY"
|
||||||
|
|
@ -43,7 +42,8 @@ class Settings(BaseSettings):
|
||||||
AWS_DEFAULT_REGION: Optional[str] = None
|
AWS_DEFAULT_REGION: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = "backend.env"
|
env_file = "backend/.env"
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def get_settings():
|
def get_settings():
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ db_string = connection_string.format(
|
||||||
|
|
||||||
db_engine = create_engine(db_string, pool_size=5, max_overflow=5)
|
db_engine = create_engine(db_string, pool_size=5, max_overflow=5)
|
||||||
|
|
||||||
|
|
||||||
def get_db_session():
|
def get_db_session():
|
||||||
if db_engine is None:
|
if db_engine is None:
|
||||||
raise RuntimeError("Database is not configured. Set DATABASE_URL in environment variables.")
|
raise RuntimeError("Database is not configured. Set DATABASE_URL in environment variables.")
|
||||||
|
|
|
||||||
12
backend/app/db/functions/__init__.py
Normal file
12
backend/app/db/functions/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from .epc_functions import *
|
||||||
|
from .address_functions import *
|
||||||
|
from .portfolio_functions import *
|
||||||
|
from .energy_assessment_functions import *
|
||||||
|
from .property_functions import *
|
||||||
|
from .recommendations_functions import *
|
||||||
|
from .solar_functions import *
|
||||||
|
from .funding_functions import *
|
||||||
|
from .materials_functions import *
|
||||||
|
from .inspections_functions import *
|
||||||
|
from .non_intrusive_surveys import *
|
||||||
|
from .whlg_functions import *
|
||||||
125
backend/app/db/functions/epc_functions.py
Normal file
125
backend/app/db/functions/epc_functions.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from backend.app.db.models.epc import EpcStore
|
||||||
|
|
||||||
|
|
||||||
|
class EpcStoreService:
|
||||||
|
"""
|
||||||
|
Service layer for EPC data lookup and persistence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FRESHNESS_DAYS = 30
|
||||||
|
|
||||||
|
# status labels
|
||||||
|
FRESH = "fresh"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
MISSING = "missing"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_epc_for_uprn(cls, session: Session, uprn: int):
|
||||||
|
"""
|
||||||
|
Query EPC data for a given UPRN and return a dict describing:
|
||||||
|
- epc_api: only if within last 30 days
|
||||||
|
- epc_page: only if epc_api exists
|
||||||
|
- status: 'fresh', 'expired', or 'missing'
|
||||||
|
"""
|
||||||
|
|
||||||
|
record = session.query(EpcStore).filter(EpcStore.uprn == uprn).first()
|
||||||
|
|
||||||
|
if not record:
|
||||||
|
return {"status": cls.MISSING, "epc_api": None, "epc_page": None}
|
||||||
|
|
||||||
|
if not record.epc_api_created_at:
|
||||||
|
# API data missing → treat as missing even if page data exists
|
||||||
|
return {"status": cls.MISSING, "epc_api": None, "epc_page": None}
|
||||||
|
|
||||||
|
# check freshness
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=EpcStoreService.FRESHNESS_DAYS)
|
||||||
|
|
||||||
|
if record.epc_api_created_at.date() < cutoff.date():
|
||||||
|
return {"status": cls.EXPIRED, "epc_api": None, "epc_page": None}
|
||||||
|
|
||||||
|
# Fresh API → include page only if present
|
||||||
|
return {
|
||||||
|
"status": cls.FRESH,
|
||||||
|
"epc_api": record.epc_api,
|
||||||
|
"epc_page": record.epc_page if record.epc_page else None,
|
||||||
|
"epc_page_rrn": record.epc_page_rrn,
|
||||||
|
"epc_api_created_at": record.epc_api_created_at,
|
||||||
|
"epc_page_created_at": record.epc_page_created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_insert_needed(cls, epc_cache, epc_estimated, uprn):
|
||||||
|
"""
|
||||||
|
Check if an insert is needed based on existing data.
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
no_existing_epc_cache = epc_cache.get("epc_api") is None
|
||||||
|
existing_cache_expired = (
|
||||||
|
epc_cache.get("status") == cls.EXPIRED
|
||||||
|
)
|
||||||
|
|
||||||
|
needs_insert = bool((no_existing_epc_cache or existing_cache_expired) and not epc_estimated and uprn)
|
||||||
|
|
||||||
|
return needs_insert
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def upsert_epc_data(
|
||||||
|
session: Session,
|
||||||
|
uprn: int,
|
||||||
|
epc_api: dict | None,
|
||||||
|
epc_page: str | None,
|
||||||
|
epc_page_rrn: str | None,
|
||||||
|
epc_api_created_at: datetime | None = None,
|
||||||
|
epc_page_created_at: datetime | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Insert or update EPC data for a UPRN.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- If record exists → update it
|
||||||
|
- If record does not exist → create new
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = session.query(EpcStore).filter(EpcStore.uprn == uprn).first()
|
||||||
|
|
||||||
|
if record:
|
||||||
|
# update path
|
||||||
|
if epc_api is not None:
|
||||||
|
record.epc_api = epc_api
|
||||||
|
if epc_api_created_at is None:
|
||||||
|
epc_api_created_at = datetime.now(timezone.utc)
|
||||||
|
record.epc_api_created_at = epc_api_created_at
|
||||||
|
|
||||||
|
# update page data only if BOTH:
|
||||||
|
# 1) the caller passed page data
|
||||||
|
# 2) epc_api is not None (page only allowed when API exists)
|
||||||
|
if epc_page is not None and epc_api is not None:
|
||||||
|
record.epc_page = epc_page
|
||||||
|
record.epc_page_rrn = epc_page_rrn
|
||||||
|
if epc_page_created_at is None:
|
||||||
|
epc_page_created_at = datetime.now(timezone.utc)
|
||||||
|
record.epc_page_created_at = epc_page_created_at
|
||||||
|
else:
|
||||||
|
# insert path
|
||||||
|
record = EpcStore(
|
||||||
|
uprn=uprn,
|
||||||
|
epc_api=epc_api,
|
||||||
|
epc_api_created_at=epc_api_created_at,
|
||||||
|
epc_page=epc_page if epc_api is not None else None,
|
||||||
|
epc_page_rrn=epc_page_rrn if epc_api is not None else None,
|
||||||
|
epc_page_created_at=epc_page_created_at if epc_api is not None else None,
|
||||||
|
)
|
||||||
|
session.add(record)
|
||||||
|
|
||||||
|
session.flush()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
session.rollback()
|
||||||
|
raise e
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# ---- Standard Library ----
|
# ---- Standard Library ----
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
@ -27,20 +25,22 @@ class SubTaskInterface:
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# CREATE SUBTASK
|
# CREATE SUBTASK
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
def create_subtask(self, task_id: UUID, inputs: Optional[Dict[str, Any]] = None):
|
def create_subtask(self, task_id: UUID, inputs: Optional[Dict[str, Any]] = None, status=None):
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
task = session.get(Task, task_id)
|
task = session.get(Task, task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise ValueError(f"Task {task_id} not found")
|
raise ValueError(f"Task {task_id} not found")
|
||||||
|
|
||||||
|
# We treat waiting as the default status
|
||||||
|
status = "waiting" if status is None else status
|
||||||
|
|
||||||
subtask = SubTask(
|
subtask = SubTask(
|
||||||
taskId=task_id,
|
task_id=task_id,
|
||||||
inputs=json.dumps(inputs) if inputs else None,
|
inputs=json.dumps(inputs) if inputs else None,
|
||||||
status="waiting",
|
status=status,
|
||||||
jobStarted=None,
|
job_started=None,
|
||||||
jobCompleted=None,
|
job_completed=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(subtask)
|
session.add(subtask)
|
||||||
|
|
@ -49,12 +49,21 @@ class SubTaskInterface:
|
||||||
|
|
||||||
# Recalculate parent task progress
|
# Recalculate parent task progress
|
||||||
self._update_task_progress(session, task_id)
|
self._update_task_progress(session, task_id)
|
||||||
return subtask
|
return subtask.id
|
||||||
|
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# UPDATE STATUS (in progress, complete, failed)
|
# UPDATE STATUS (in progress, complete, failed)
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
def update_subtask_status(self, subtask_id: UUID, status: str):
|
def update_subtask_status(
|
||||||
|
self, subtask_id: UUID, status: str, outputs=None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update the status of a subtask, and recalculate the parent task progress.
|
||||||
|
:param subtask_id: UUID of the subtask to update
|
||||||
|
:param status: New status (in progress, complete, failed)
|
||||||
|
:param outputs: Optional outputs to set
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
|
|
@ -65,21 +74,23 @@ class SubTaskInterface:
|
||||||
normalized = status.lower()
|
normalized = status.lower()
|
||||||
|
|
||||||
# When job really starts
|
# When job really starts
|
||||||
if normalized == "in progress" and subtask.jobStarted is None:
|
if normalized == "in progress" and subtask.job_started is None:
|
||||||
subtask.jobStarted = now
|
subtask.job_started = now
|
||||||
|
|
||||||
# Completed or failed
|
# Completed or failed
|
||||||
if normalized in ("complete", "failed"):
|
if normalized in ("complete", "failed"):
|
||||||
subtask.jobCompleted = now
|
subtask.job_completed = now
|
||||||
|
|
||||||
subtask.status = normalized
|
subtask.status = normalized
|
||||||
subtask.updatedAt = now
|
subtask.updated_at = now
|
||||||
|
if outputs is not None:
|
||||||
|
subtask.outputs = json.dumps(outputs)
|
||||||
|
|
||||||
session.add(subtask)
|
session.add(subtask)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Recalculate task status
|
# Recalculate task status
|
||||||
self._update_task_progress(session, subtask.taskId)
|
self._update_task_progress(session, subtask.task_id)
|
||||||
|
|
||||||
session.refresh(subtask)
|
session.refresh(subtask)
|
||||||
return subtask
|
return subtask
|
||||||
|
|
@ -87,7 +98,8 @@ class SubTaskInterface:
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# UPDATE OUTPUTS
|
# UPDATE OUTPUTS
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
def update_subtask_output(self, subtask_id: UUID, outputs: Dict[str, Any]):
|
@staticmethod
|
||||||
|
def update_subtask_output(subtask_id: UUID, outputs: Dict[str, Any]):
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
|
|
@ -96,7 +108,7 @@ class SubTaskInterface:
|
||||||
raise ValueError(f"SubTask {subtask_id} not found")
|
raise ValueError(f"SubTask {subtask_id} not found")
|
||||||
|
|
||||||
subtask.outputs = json.dumps(outputs)
|
subtask.outputs = json.dumps(outputs)
|
||||||
subtask.updatedAt = now
|
subtask.updated_at = now
|
||||||
|
|
||||||
session.add(subtask)
|
session.add(subtask)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -106,7 +118,8 @@ class SubTaskInterface:
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# UPDATE CLOUD LOGS URL
|
# UPDATE CLOUD LOGS URL
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
def update_subtask_logs(self, subtask_id: UUID, cloud_logs_url: str):
|
@staticmethod
|
||||||
|
def update_subtask_logs(subtask_id: UUID, cloud_logs_url: str):
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
|
|
@ -114,8 +127,8 @@ class SubTaskInterface:
|
||||||
if not subtask:
|
if not subtask:
|
||||||
raise ValueError(f"SubTask {subtask_id} not found")
|
raise ValueError(f"SubTask {subtask_id} not found")
|
||||||
|
|
||||||
subtask.cloudLogsURL = cloud_logs_url
|
subtask.cloud_logs_url = cloud_logs_url
|
||||||
subtask.updatedAt = now
|
subtask.updated_at = now
|
||||||
|
|
||||||
session.add(subtask)
|
session.add(subtask)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -125,8 +138,8 @@ class SubTaskInterface:
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# SET BOTH OUTPUT + LOGS
|
# SET BOTH OUTPUT + LOGS
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
def set_subtask_result(
|
def set_subtask_result(
|
||||||
self,
|
|
||||||
subtask_id: UUID,
|
subtask_id: UUID,
|
||||||
outputs: Optional[Dict[str, Any]] = None,
|
outputs: Optional[Dict[str, Any]] = None,
|
||||||
cloud_logs_url: Optional[str] = None,
|
cloud_logs_url: Optional[str] = None,
|
||||||
|
|
@ -142,9 +155,9 @@ class SubTaskInterface:
|
||||||
subtask.outputs = json.dumps(outputs)
|
subtask.outputs = json.dumps(outputs)
|
||||||
|
|
||||||
if cloud_logs_url is not None:
|
if cloud_logs_url is not None:
|
||||||
subtask.cloudLogsURL = cloud_logs_url
|
subtask.cloud_logs_url = cloud_logs_url
|
||||||
|
|
||||||
subtask.updatedAt = now
|
subtask.updated_at = now
|
||||||
session.add(subtask)
|
session.add(subtask)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(subtask)
|
session.refresh(subtask)
|
||||||
|
|
@ -153,13 +166,14 @@ class SubTaskInterface:
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# TASK PROGRESS CALCULATION
|
# TASK PROGRESS CALCULATION
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
def _update_task_progress(self, session: Session, task_id: UUID):
|
@staticmethod
|
||||||
|
def _update_task_progress(session: Session, task_id: UUID):
|
||||||
task = session.get(Task, task_id)
|
task = session.get(Task, task_id)
|
||||||
if not task:
|
if not task:
|
||||||
return
|
return
|
||||||
|
|
||||||
subtasks = session.exec(
|
subtasks = session.exec(
|
||||||
select(SubTask).where(SubTask.taskId == task_id)
|
select(SubTask).where(SubTask.task_id == task_id)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
statuses = [s.status.lower() for s in subtasks]
|
statuses = [s.status.lower() for s in subtasks]
|
||||||
|
|
@ -167,24 +181,24 @@ class SubTaskInterface:
|
||||||
|
|
||||||
if "failed" in statuses:
|
if "failed" in statuses:
|
||||||
task.status = "failed"
|
task.status = "failed"
|
||||||
task.jobCompleted = now
|
task.job_completed = now
|
||||||
|
|
||||||
elif all(s == "complete" for s in statuses):
|
elif all(s == "complete" for s in statuses):
|
||||||
task.status = "complete"
|
task.status = "complete"
|
||||||
task.jobCompleted = now
|
task.job_completed = now
|
||||||
|
|
||||||
elif "in progress" in statuses:
|
elif "in progress" in statuses:
|
||||||
task.status = "in progress"
|
task.status = "in progress"
|
||||||
if task.jobStarted is None:
|
if task.job_started is None:
|
||||||
task.jobStarted = now
|
task.job_started = now
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# All waiting
|
# All waiting
|
||||||
task.status = "waiting"
|
task.status = "waiting"
|
||||||
task.jobStarted = None
|
task.job_started = None
|
||||||
task.jobCompleted = None
|
task.job_completed = None
|
||||||
|
|
||||||
task.updatedAt = now
|
task.updated_at = now
|
||||||
session.add(task)
|
session.add(task)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
@ -212,18 +226,18 @@ class SubTaskInterface:
|
||||||
|
|
||||||
# Set logs
|
# Set logs
|
||||||
if cloud_logs_url is not None:
|
if cloud_logs_url is not None:
|
||||||
subtask.cloudLogsURL = cloud_logs_url
|
subtask.cloud_logs_url = cloud_logs_url
|
||||||
|
|
||||||
# Status + timestamps
|
# Status + timestamps
|
||||||
subtask.status = normalized
|
subtask.status = normalized
|
||||||
subtask.jobCompleted = now
|
subtask.job_completed = now
|
||||||
subtask.updatedAt = now
|
subtask.updated_at = now
|
||||||
|
|
||||||
session.add(subtask)
|
session.add(subtask)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Update parent task (complete/failed)
|
# Update parent task (complete/failed)
|
||||||
self._update_task_progress(session, subtask.taskId)
|
self._update_task_progress(session, subtask.task_id)
|
||||||
|
|
||||||
session.refresh(subtask)
|
session.refresh(subtask)
|
||||||
return subtask
|
return subtask
|
||||||
|
|
@ -237,38 +251,49 @@ class TasksInterface:
|
||||||
High-level operations for Task records.
|
High-level operations for Task records.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def create_task(
|
def create_task(
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_source: str,
|
task_source: str,
|
||||||
service: Optional[str] = None,
|
service: Optional[str] = None,
|
||||||
inputs: Optional[Dict[str, Any]] = None,
|
inputs: Optional[Dict[str, Any]] = None,
|
||||||
|
task_only: bool = False,
|
||||||
):
|
):
|
||||||
now = datetime.now(timezone.utc)
|
"""
|
||||||
|
Create a new Task record, and an initial SubTask in waiting state. Can also be used to create just
|
||||||
|
a task, without a subtask
|
||||||
|
:param task_source: Text indicating source of task creation (e.g. file path + function name)
|
||||||
|
:param service: Optional service name
|
||||||
|
:param inputs: Inputs of the job being run
|
||||||
|
:param task_only: If True, only create the Task record, without a SubTask
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
task = Task(
|
task = Task(
|
||||||
taskSource=task_source,
|
task_source=task_source,
|
||||||
service=service,
|
service=service,
|
||||||
status="waiting",
|
status="waiting",
|
||||||
jobStarted=None,
|
job_started=None,
|
||||||
jobCompleted=None,
|
job_completed=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(task)
|
session.add(task)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(task)
|
session.refresh(task)
|
||||||
|
|
||||||
|
if task_only:
|
||||||
|
return task.id, None
|
||||||
|
|
||||||
# Create first subtask in waiting state
|
# Create first subtask in waiting state
|
||||||
subtask_interface = SubTaskInterface()
|
subtask_interface = SubTaskInterface()
|
||||||
subtask = subtask_interface.create_subtask(
|
subtask_id = subtask_interface.create_subtask(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
inputs=inputs,
|
inputs=inputs,
|
||||||
)
|
)
|
||||||
|
|
||||||
return task.id, subtask.id
|
return task.id, subtask_id
|
||||||
|
|
||||||
def update_task_status(self, task_id: UUID, status: str):
|
@staticmethod
|
||||||
|
def update_task_status(task_id: UUID, status: str):
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
with get_db_session() as session:
|
with get_db_session() as session:
|
||||||
|
|
@ -278,14 +303,14 @@ class TasksInterface:
|
||||||
|
|
||||||
normalized = status.lower()
|
normalized = status.lower()
|
||||||
|
|
||||||
if normalized == "in progress" and task.jobStarted is None:
|
if normalized == "in progress" and task.job_started is None:
|
||||||
task.jobStarted = now
|
task.job_started = now
|
||||||
|
|
||||||
if normalized == "complete":
|
if normalized == "complete":
|
||||||
task.jobCompleted = now
|
task.job_completed = now
|
||||||
|
|
||||||
task.status = normalized
|
task.status = normalized
|
||||||
task.updatedAt = now
|
task.updated_at = now
|
||||||
|
|
||||||
session.add(task)
|
session.add(task)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
|
||||||
29
backend/app/db/models/epc.py
Normal file
29
backend/app/db/models/epc.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
JSON,
|
||||||
|
TIMESTAMP,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class EpcStore(Base):
|
||||||
|
"""
|
||||||
|
Stores EPC data retrieved from the EPC API and EPC web pages.
|
||||||
|
"""
|
||||||
|
__tablename__ = "epc_store"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
uprn = Column(Integer)
|
||||||
|
epc_api_created_at = Column(TIMESTAMP(timezone=False))
|
||||||
|
epc_api = Column(JSON, nullable=False)
|
||||||
|
epc_page_created_at = Column(TIMESTAMP(timezone=False))
|
||||||
|
epc_page = Column(String)
|
||||||
|
epc_page_rrn = Column(String)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<EpcStore(id={self.id}, uprn='{self.uprn}')>"
|
||||||
|
|
@ -20,6 +20,7 @@ class MaterialType(enum.Enum):
|
||||||
room_roof_insulation = "room_roof_insulation"
|
room_roof_insulation = "room_roof_insulation"
|
||||||
windows_glazing = "windows_glazing"
|
windows_glazing = "windows_glazing"
|
||||||
secondary_glazing = "secondary_glazing"
|
secondary_glazing = "secondary_glazing"
|
||||||
|
double_glazing = "double_glazing"
|
||||||
cavity_wall_extraction = "cavity_wall_extraction"
|
cavity_wall_extraction = "cavity_wall_extraction"
|
||||||
|
|
||||||
iwi_wall_demolition = "iwi_wall_demolition"
|
iwi_wall_demolition = "iwi_wall_demolition"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import datetime
|
||||||
from sqlalchemy import Column, Integer, Text, Boolean, Float, DateTime, Enum, ForeignKey, CheckConstraint
|
from sqlalchemy import Column, Integer, Text, Boolean, Float, DateTime, Enum, ForeignKey, CheckConstraint
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from backend.app.db.models.users import UserModel # noqa
|
from backend.app.db.models.users import UserModel # noqa
|
||||||
|
from backend.app.db.models.materials import MaterialType
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
@ -225,3 +226,18 @@ class PortfolioUsers(Base):
|
||||||
role = Column(Text, nullable=False)
|
role = Column(Text, nullable=False)
|
||||||
created_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
|
created_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
|
||||||
updated_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
|
updated_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyInstalledMeasures(Base):
|
||||||
|
"""
|
||||||
|
This model keeps a record of the installed measures for each property, at the UPRN level
|
||||||
|
"""
|
||||||
|
__tablename__ = 'property_installed_measures'
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
uprn = Column(Integer, nullable=False)
|
||||||
|
measure_type = Column(
|
||||||
|
Enum(MaterialType, values_callable=lambda x: [e.value for e in x], create_constraint=False),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
|
||||||
|
installed_at = Column(DateTime, nullable=False, default=datetime.datetime.now(pytz.utc))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
from __future__ import annotations
|
from typing import Optional
|
||||||
|
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
|
@ -10,64 +8,29 @@ from sqlmodel import SQLModel, Field, Relationship
|
||||||
class Task(SQLModel, table=True):
|
class Task(SQLModel, table=True):
|
||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
id: UUID = Field(
|
id: UUID = Field(default_factory=uuid4, primary_key=True, index=True, )
|
||||||
default_factory=uuid4,
|
task_source: str
|
||||||
primary_key=True,
|
job_started: Optional[datetime] = None
|
||||||
index=True,
|
job_completed: Optional[datetime] = None
|
||||||
)
|
|
||||||
|
|
||||||
taskSource: str = Field(alias="task_source")
|
|
||||||
|
|
||||||
jobStarted: Optional[datetime] = Field(
|
|
||||||
default=None, alias="job_started"
|
|
||||||
)
|
|
||||||
jobCompleted: Optional[datetime] = Field(
|
|
||||||
default=None, alias="job_completed"
|
|
||||||
)
|
|
||||||
|
|
||||||
status: str = Field(default="In Progress")
|
status: str = Field(default="In Progress")
|
||||||
service: Optional[str] = None
|
service: Optional[str] = None
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
updatedAt: datetime = Field(
|
sub_tasks: list["SubTask"] = Relationship(back_populates="task")
|
||||||
default_factory=datetime.utcnow,
|
|
||||||
alias="updated_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationship
|
|
||||||
subTasks: List["SubTask"] = Relationship(back_populates="task")
|
|
||||||
|
|
||||||
|
|
||||||
class SubTask(SQLModel, table=True):
|
class SubTask(SQLModel, table=True):
|
||||||
__tablename__ = "sub_task"
|
__tablename__ = "sub_task"
|
||||||
|
|
||||||
id: UUID = Field(
|
id: UUID = Field(default_factory=uuid4, primary_key=True, index=True, )
|
||||||
default_factory=uuid4,
|
|
||||||
primary_key=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
taskId: UUID = Field(
|
|
||||||
foreign_key="tasks.id",
|
|
||||||
alias="task_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
jobStarted: Optional[datetime] = Field(
|
|
||||||
default=None, alias="job_started"
|
|
||||||
)
|
|
||||||
jobCompleted: Optional[datetime] = Field(
|
|
||||||
default=None, alias="job_completed"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
task_id: UUID = Field(foreign_key="tasks.id")
|
||||||
|
job_started: Optional[datetime] = None
|
||||||
|
job_completed: Optional[datetime] = None
|
||||||
status: str = Field(default="In Progress")
|
status: str = Field(default="In Progress")
|
||||||
|
|
||||||
inputs: Optional[str] = None
|
inputs: Optional[str] = None
|
||||||
outputs: Optional[str] = None
|
outputs: Optional[str] = None
|
||||||
cloudLogsURL: Optional[str] = Field(alias="cloud_logs_url")
|
cloud_logs_url: Optional[str] = None
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
updatedAt: datetime = Field(
|
task: Optional["Task"] = Relationship(back_populates="sub_tasks")
|
||||||
default_factory=datetime.utcnow,
|
|
||||||
alias="updated_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationship
|
|
||||||
task: Optional[Task] = Relationship(back_populates="subTasks")
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from utils.logger import setup_logger
|
||||||
from backend.app.db.connection import db_engine
|
from backend.app.db.connection import db_engine
|
||||||
|
|
||||||
from backend.app.db.functions.recommendations_functions import create_scenario
|
from backend.app.db.functions.recommendations_functions import create_scenario
|
||||||
|
from backend.app.db.functions.tasks.Tasks import TasksInterface, SubTaskInterface
|
||||||
|
|
||||||
logger = setup_logger()
|
logger = setup_logger()
|
||||||
|
|
||||||
|
|
@ -81,12 +82,34 @@ async def trigger_plan_entrypoint(body: PlanTriggerRequest):
|
||||||
# Insert the scenario ID into the data payload
|
# Insert the scenario ID into the data payload
|
||||||
data["scenario_id"] = scenario_id
|
data["scenario_id"] = scenario_id
|
||||||
|
|
||||||
|
# Create a main task
|
||||||
|
task_id, _ = TasksInterface.create_task(
|
||||||
|
task_source="backend/plan/router.py:trigger_plan_entrypoint",
|
||||||
|
service="plan_engine",
|
||||||
|
inputs=data,
|
||||||
|
task_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
subtask_interface = SubTaskInterface()
|
||||||
for i in range(total_chunks):
|
for i in range(total_chunks):
|
||||||
# Create an entry in the request logs table
|
# Create an entry in the request logs table
|
||||||
index_start = i * chunk_size
|
index_start = i * chunk_size
|
||||||
index_end = min((i + 1) * chunk_size, total_rows)
|
index_end = min((i + 1) * chunk_size, total_rows)
|
||||||
|
|
||||||
message_payload = {**data, "index_start": index_start, "index_end": index_end}
|
message_payload = {
|
||||||
|
**data, "index_start": index_start, "index_end": index_end,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create a subtask for this chunk
|
||||||
|
subtask_id = subtask_interface.create_subtask(
|
||||||
|
task_id=task_id,
|
||||||
|
inputs=message_payload
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add task and subtask to message
|
||||||
|
message_payload["task_id"] = str(task_id)
|
||||||
|
message_payload["subtask_id"] = str(subtask_id)
|
||||||
|
|
||||||
message_body = json.dumps(message_payload)
|
message_body = json.dumps(message_payload)
|
||||||
|
|
||||||
response = sqs_client.send_message(
|
response = sqs_client.send_message(
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,10 @@ class PlanTriggerRequest(BaseModel):
|
||||||
index_start: Optional[int] = None
|
index_start: Optional[int] = None
|
||||||
index_end: Optional[int] = None
|
index_end: Optional[int] = None
|
||||||
|
|
||||||
|
# Task and subtask IDs
|
||||||
|
task_id: Optional[str] = None
|
||||||
|
subtask_id: Optional[str] = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def check_indexes(self):
|
def check_indexes(self):
|
||||||
if (self.index_start is None) != (self.index_end is None):
|
if (self.index_start is None) != (self.index_end is None):
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import msgpack
|
import msgpack
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Any
|
||||||
from utils.s3 import read_from_s3
|
from utils.s3 import read_from_s3
|
||||||
from backend.app.config import get_settings
|
from backend.app.config import get_settings
|
||||||
from backend.app.plan.data_classes import PropertyRequestData
|
from backend.app.plan.data_classes import PropertyRequestData
|
||||||
from typing import Any
|
from backend.app.db.functions.tasks.Tasks import SubTaskInterface
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
from utils.logger import setup_logger
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
|
|
@ -211,8 +213,13 @@ def parse_eco_packages(config: dict[str, Any], prepared_epc) -> tuple[list[str],
|
||||||
return measures, mapped["target_sap"], mapped["plan_type"], already_installed
|
return measures, mapped["target_sap"], mapped["plan_type"], already_installed
|
||||||
|
|
||||||
|
|
||||||
def handle_error(session, msg, status=500):
|
def handle_error(session, msg, e, subtask_id, status=500):
|
||||||
# When the pipeline fails, handles error process
|
# When the pipeline fails, handles error process
|
||||||
|
SubTaskInterface().update_subtask_status(
|
||||||
|
subtask_id=UUID(subtask_id),
|
||||||
|
status="failed",
|
||||||
|
outputs=str(e)
|
||||||
|
)
|
||||||
logger.error(msg, exc_info=True)
|
logger.error(msg, exc_info=True)
|
||||||
session.rollback()
|
session.rollback()
|
||||||
return Response(status_code=status, content=msg)
|
return Response(status_code=status, content=msg)
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,20 @@ from datetime import datetime
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from etl.epc.Record import EPCRecord
|
from uuid import UUID
|
||||||
|
|
||||||
|
from backend.Funding import Funding
|
||||||
from backend.SearchEpc import SearchEpc
|
from backend.SearchEpc import SearchEpc
|
||||||
|
|
||||||
|
from etl.epc.Record import EPCRecord
|
||||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
from backend.app.config import get_settings, get_prediction_buckets
|
from backend.app.config import get_settings, get_prediction_buckets
|
||||||
from backend.app.db.connection import db_engine
|
from backend.app.db.connection import db_engine
|
||||||
from backend.app.db.functions.materials_functions import get_materials
|
import backend.app.db.functions as db_funcs
|
||||||
from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations
|
from backend.app.db.functions.tasks.Tasks import SubTaskInterface
|
||||||
from backend.app.db.functions.property_functions import (
|
|
||||||
create_property_details_epc, create_property_targets, update_property_data,
|
|
||||||
update_or_create_property_spatial_details, ensure_property_exists
|
|
||||||
)
|
|
||||||
from backend.app.db.functions.recommendations_functions import (
|
|
||||||
create_plan, upload_recommendations, create_scenario
|
|
||||||
)
|
|
||||||
from backend.app.db.functions.funding_functions import upload_funding
|
|
||||||
from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn
|
|
||||||
from backend.app.db.functions.address_functions import get_associated_uprns
|
|
||||||
|
|
||||||
from backend.app.db.models.portfolio import rating_lookup
|
from backend.app.db.models.portfolio import rating_lookup
|
||||||
from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES
|
from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES
|
||||||
|
|
@ -33,9 +27,6 @@ from backend.app.plan.utils import (
|
||||||
)
|
)
|
||||||
from backend.app.utils import sap_to_epc
|
from backend.app.utils import sap_to_epc
|
||||||
import backend.app.assumptions as assumptions
|
import backend.app.assumptions as assumptions
|
||||||
from backend.app.db.functions.inspections_functions import (
|
|
||||||
extract_inspection_data, bulk_upsert_inspections_pg
|
|
||||||
)
|
|
||||||
|
|
||||||
from backend.ml_models.api import ModelApi
|
from backend.ml_models.api import ModelApi
|
||||||
from backend.Property import Property
|
from backend.Property import Property
|
||||||
|
|
@ -45,18 +36,18 @@ from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||||||
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
from recommendations.optimiser.GainOptimiser import GainOptimiser
|
||||||
import recommendations.optimiser.optimiser_functions as optimiser_functions
|
import recommendations.optimiser.optimiser_functions as optimiser_functions
|
||||||
from recommendations.Recommendations import Recommendations
|
from recommendations.Recommendations import Recommendations
|
||||||
from utils.logger import setup_logger
|
|
||||||
from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3
|
|
||||||
from backend.ml_models.Valuation import PropertyValuation
|
from backend.ml_models.Valuation import PropertyValuation
|
||||||
|
|
||||||
from etl.bill_savings.KwhData import KwhData
|
from etl.bill_savings.KwhData import KwhData
|
||||||
from etl.spatial.OpenUprnClient import OpenUprnClient
|
from etl.spatial.OpenUprnClient import OpenUprnClient
|
||||||
from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc
|
from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc
|
||||||
|
|
||||||
from backend.Funding import Funding
|
|
||||||
from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths
|
from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths
|
||||||
from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value
|
from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value
|
||||||
|
|
||||||
|
from utils.logger import setup_logger
|
||||||
|
from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3
|
||||||
|
|
||||||
logger = setup_logger()
|
logger = setup_logger()
|
||||||
|
|
||||||
BATCH_SIZE = 5
|
BATCH_SIZE = 5
|
||||||
|
|
@ -392,6 +383,26 @@ def parse_heating_system(config):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_duplicate_uprns(plan_input):
|
||||||
|
"""
|
||||||
|
Simple function to check if the input data contains duplicated UPRNS.
|
||||||
|
If there are duplicates, an exception will be rasied
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
# Check for duplicate UPRNS
|
||||||
|
input_uprns = [x.get("uprn") for x in plan_input if "uprn" in x and x.get("uprn")]
|
||||||
|
|
||||||
|
if input_uprns:
|
||||||
|
# Check for dupes
|
||||||
|
if len(input_uprns) != len(set(input_uprns)):
|
||||||
|
# Find the duplicate UPRNs
|
||||||
|
duplicates = set([x for x in input_uprns if input_uprns.count(x) > 1])
|
||||||
|
# de-dupe input_uprns
|
||||||
|
raise ValueError(f"Duplicate UPRNs in the input data: {duplicates}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def model_engine(body: PlanTriggerRequest):
|
async def model_engine(body: PlanTriggerRequest):
|
||||||
logger.info("Model Engine triggered with body: %s", json.loads(body.model_dump_json()))
|
logger.info("Model Engine triggered with body: %s", json.loads(body.model_dump_json()))
|
||||||
|
|
||||||
|
|
@ -421,6 +432,9 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
)
|
)
|
||||||
# Where the EPC has been estimated, that is because a UPRN wasn't avaialble and so we remove UPRN
|
# Where the EPC has been estimated, that is because a UPRN wasn't avaialble and so we remove UPRN
|
||||||
# This will be reflexted
|
# This will be reflexted
|
||||||
|
if "estimated" not in plan_input.columns:
|
||||||
|
plan_input["estimated"] = False
|
||||||
|
|
||||||
plan_input["uprn"] = np.where(
|
plan_input["uprn"] = np.where(
|
||||||
plan_input["estimated"].isin([1, True]) & (
|
plan_input["estimated"].isin([1, True]) & (
|
||||||
(plan_input["uprn"] < 0) | pd.isnull(plan_input["uprn"])
|
(plan_input["uprn"] < 0) | pd.isnull(plan_input["uprn"])
|
||||||
|
|
@ -480,16 +494,8 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
if body.index_start is not None and body.index_end is not None:
|
if body.index_start is not None and body.index_end is not None:
|
||||||
plan_input = plan_input[body.index_start:body.index_end]
|
plan_input = plan_input[body.index_start:body.index_end]
|
||||||
|
|
||||||
# Check for duplicate UPRNS
|
# Confirm no duplicate UPRNS
|
||||||
input_uprns = [x.get("uprn") for x in plan_input if "uprn" in x and x.get("uprn")]
|
check_duplicate_uprns(plan_input)
|
||||||
|
|
||||||
if input_uprns:
|
|
||||||
# Check for dupes
|
|
||||||
if len(input_uprns) != len(set(input_uprns)):
|
|
||||||
# Find the duplicate UPRNs
|
|
||||||
duplicates = set([x for x in input_uprns if input_uprns.count(x) > 1])
|
|
||||||
# de-dupe input_uprns
|
|
||||||
raise ValueError(f"Duplicate UPRNs in the input data: {duplicates}")
|
|
||||||
|
|
||||||
# If we have patches or overrides, we should read them in here
|
# If we have patches or overrides, we should read them in here
|
||||||
patches, already_installed, non_invasive_recommendations, valuation_data = get_request_property_data(body)
|
patches, already_installed, non_invasive_recommendations, valuation_data = get_request_property_data(body)
|
||||||
|
|
@ -515,10 +521,18 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
if uprn:
|
if uprn:
|
||||||
uprn = int(float(uprn))
|
uprn = int(float(uprn))
|
||||||
|
|
||||||
|
epc_api_data, epc_page, rrn, epc_cache = None, None, None, {}
|
||||||
|
if uprn:
|
||||||
|
# if we have a UPRN, we check if we already have EPC data associated with this UPRN
|
||||||
|
epc_cache = db_funcs.epc_functions.EpcStoreService.get_epc_for_uprn(session, uprn)
|
||||||
|
|
||||||
|
if epc_cache["status"] == db_funcs.epc_functions.EpcStoreService.FRESH:
|
||||||
|
epc_api_data, epc_page, rrn = epc_cache["epc_api"], epc_cache["epc_page"], epc_cache["epc_page_rrn"]
|
||||||
|
|
||||||
address1 = config.get("address", None)
|
address1 = config.get("address", None)
|
||||||
# Handle domna address list format
|
# Handle domna address list format
|
||||||
if pd.isnull(address1) and body.file_format == "domna_asset_list":
|
if pd.isnull(address1) and body.file_format == "domna_asset_list":
|
||||||
address1 = config.get("domna_full_address", None)
|
address1 = config.get("domna_address_1", None)
|
||||||
|
|
||||||
address1 = str(int(address1)) if isinstance(address1, float) else str(address1)
|
address1 = str(int(address1)) if isinstance(address1, float) else str(address1)
|
||||||
full_address = config["domna_full_address"] if body.file_format == "domna_asset_list" else None
|
full_address = config["domna_full_address"] if body.file_format == "domna_asset_list" else None
|
||||||
|
|
@ -528,7 +542,7 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
if (body.event_type == "remote_assessment") and config.get("property_type") == "Flat":
|
if (body.event_type == "remote_assessment") and config.get("property_type") == "Flat":
|
||||||
# We're running a remote assessment for a flat - we go and grab the associated
|
# We're running a remote assessment for a flat - we go and grab the associated
|
||||||
# UPRNS for other units in the same building
|
# UPRNS for other units in the same building
|
||||||
associated_uprns = get_associated_uprns(
|
associated_uprns = db_funcs.address_functions.get_associated_uprns(
|
||||||
session, postcode=config["postcode"], uprn=uprn
|
session, postcode=config["postcode"], uprn=uprn
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -545,16 +559,20 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
epc_searcher.ordnance_survey_client.built_form = config.get("built_form", None)
|
epc_searcher.ordnance_survey_client.built_form = config.get("built_form", None)
|
||||||
epc_searcher.ordnance_survey_client.property_type = config.get("property_type", None)
|
epc_searcher.ordnance_survey_client.property_type = config.get("property_type", None)
|
||||||
# For the moment, our OS API access is unavailable, so we skip and interpolate
|
# For the moment, our OS API access is unavailable, so we skip and interpolate
|
||||||
epc_searcher.find_property(skip_os=True)
|
|
||||||
|
epc_searcher.find_property(skip_os=True, api_data=epc_api_data)
|
||||||
|
|
||||||
if epc_searcher.newest_epc.get("estimated") and body.file_format == "domna_asset_list" and (
|
if epc_searcher.newest_epc.get("estimated") and body.file_format == "domna_asset_list" and (
|
||||||
epc_searcher.newest_epc["uprn"] < 0
|
epc_searcher.newest_epc["uprn"] < 0
|
||||||
):
|
):
|
||||||
epc_searcher.newest_epc["uprn-source"] = epc_searcher.UPRN_SOURCE_SIMULATED
|
epc_searcher.newest_epc["uprn-source"] = epc_searcher.UPRN_SOURCE_SIMULATED
|
||||||
|
|
||||||
# We check for an energy assessment we have performed on this property:
|
# We check for an energy assessment we have performed on this property:
|
||||||
energy_assessment = get_latest_assessment_by_uprn(session, uprn if uprn is not None else epc_searcher.uprn)
|
energy_assessment = db_funcs.energy_assessment_functions.get_latest_assessment_by_uprn(
|
||||||
|
session, uprn if uprn is not None else epc_searcher.uprn
|
||||||
|
)
|
||||||
|
|
||||||
property_id, is_new = ensure_property_exists(
|
property_id, is_new = db_funcs.property_functions.ensure_property_exists(
|
||||||
session, body, epc_searcher, energy_assessment, landlord_property_id=config.get("landlord_property_id")
|
session, body, epc_searcher, energy_assessment, landlord_property_id=config.get("landlord_property_id")
|
||||||
)
|
)
|
||||||
if not property_id:
|
if not property_id:
|
||||||
|
|
@ -570,7 +588,7 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_new:
|
if is_new:
|
||||||
create_property_targets(
|
db_funcs.property_functions.create_property_targets(
|
||||||
session,
|
session,
|
||||||
property_id=property_id,
|
property_id=property_id,
|
||||||
portfolio_id=body.portfolio_id,
|
portfolio_id=body.portfolio_id,
|
||||||
|
|
@ -599,18 +617,19 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
patch = req_data.patch
|
patch = req_data.patch
|
||||||
|
|
||||||
# if we have a remote assment data type, we pull the additional data and include it
|
# if we have a remote assment data type, we pull the additional data and include it
|
||||||
|
epc_page_source = {}
|
||||||
if (body.event_type == "remote_assessment") and not (epc_searcher.newest_epc.get("estimated")):
|
if (body.event_type == "remote_assessment") and not (epc_searcher.newest_epc.get("estimated")):
|
||||||
logger.info("Retrieving find my epc data")
|
logger.info("Retrieving find my epc data")
|
||||||
try:
|
try:
|
||||||
property_non_invasive_recommendations, patch = RetrieveFindMyEpc.get_from_epc(
|
property_non_invasive_recommendations, patch, epc_page_source = RetrieveFindMyEpc.get_from_epc(
|
||||||
epc_searcher.newest_epc
|
epc_searcher.newest_epc, epc_page, rrn=rrn
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to retrieve without cleaning address {e}")
|
logger.error(f"Failed to retrieve without cleaning address {e}")
|
||||||
for k in ["address", "address1"]:
|
for k in ["address", "address1"]:
|
||||||
epc_searcher.newest_epc[k] = epc_searcher.address_clean
|
epc_searcher.newest_epc[k] = epc_searcher.address_clean
|
||||||
property_non_invasive_recommendations, patch = RetrieveFindMyEpc.get_from_epc(
|
property_non_invasive_recommendations, patch, epc_page_source = RetrieveFindMyEpc.get_from_epc(
|
||||||
epc_searcher.newest_epc
|
epc_searcher.newest_epc, epc_page, rrn=rrn
|
||||||
)
|
)
|
||||||
|
|
||||||
# If we have a property type, this means when we pull the epc data, we might need to make a patch
|
# If we have a property type, this means when we pull the epc data, we might need to make a patch
|
||||||
|
|
@ -627,7 +646,7 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
eco_packages[property_id] = parse_eco_packages(config, prepared_epc)
|
eco_packages[property_id] = parse_eco_packages(config, prepared_epc)
|
||||||
|
|
||||||
# Final step - extract inspections data, if we have it - we inject into property for usage
|
# Final step - extract inspections data, if we have it - we inject into property for usage
|
||||||
property_inspections = extract_inspection_data(config)
|
property_inspections = db_funcs.inspections_functions.extract_inspection_data(config)
|
||||||
if property_inspections:
|
if property_inspections:
|
||||||
inspections_map[property_id] = property_inspections
|
inspections_map[property_id] = property_inspections
|
||||||
|
|
||||||
|
|
@ -647,6 +666,24 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If we have:
|
||||||
|
# 1) No EPC API data
|
||||||
|
# 2) A real EPC
|
||||||
|
# 3) A UPRN (meaning that a UPRN could be fetched against that property)
|
||||||
|
# We store this data
|
||||||
|
|
||||||
|
if db_funcs.epc_functions.EpcStoreService.check_insert_needed(
|
||||||
|
epc_cache, epc_searcher.newest_epc.get("estimated"), epc_searcher.uprn
|
||||||
|
):
|
||||||
|
# We store the EPC data we have found for this property
|
||||||
|
db_funcs.epc_functions.EpcStoreService.upsert_epc_data(
|
||||||
|
session=session,
|
||||||
|
uprn=epc_searcher.uprn,
|
||||||
|
epc_api=epc_searcher.data,
|
||||||
|
epc_page=epc_page_source.get("page_source"),
|
||||||
|
epc_page_rrn=epc_page_source.get("rrn"),
|
||||||
|
)
|
||||||
|
|
||||||
if not input_properties:
|
if not input_properties:
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
@ -654,7 +691,7 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
# aginst each property if
|
# aginst each property if
|
||||||
if inspections_map:
|
if inspections_map:
|
||||||
logger.info("Inserting inspections data")
|
logger.info("Inserting inspections data")
|
||||||
bulk_upsert_inspections_pg(session, inspections_map)
|
db_funcs.inspections_functions.bulk_upsert_inspections_pg(session, inspections_map)
|
||||||
|
|
||||||
# Set up model api and warm up the lambdas
|
# Set up model api and warm up the lambdas
|
||||||
model_api = ModelApi(
|
model_api = ModelApi(
|
||||||
|
|
@ -671,7 +708,7 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
# consistent requests to the backend for
|
# consistent requests to the backend for
|
||||||
# the same data
|
# the same data
|
||||||
logger.info("Reading in materials and cleaned datasets")
|
logger.info("Reading in materials and cleaned datasets")
|
||||||
materials = get_materials(session)
|
materials = db_funcs.materials_functions.get_materials(session)
|
||||||
cleaned = get_cleaned()
|
cleaned = get_cleaned()
|
||||||
project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes = get_funding_data()
|
project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes = get_funding_data()
|
||||||
|
|
||||||
|
|
@ -1096,7 +1133,7 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
# We don't need to create a new scenario, we just use the existing one
|
# We don't need to create a new scenario, we just use the existing one
|
||||||
scenario_id = body.scenario_id
|
scenario_id = body.scenario_id
|
||||||
else:
|
else:
|
||||||
engine_scenario = create_scenario(
|
engine_scenario = db_funcs.recommendations_functions.create_scenario(
|
||||||
session=session,
|
session=session,
|
||||||
scenario={
|
scenario={
|
||||||
"name": body.scenario_name,
|
"name": body.scenario_name,
|
||||||
|
|
@ -1140,24 +1177,26 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
)
|
)
|
||||||
property_value_increase_ranges[p.id] = valuations
|
property_value_increase_ranges[p.id] = valuations
|
||||||
|
|
||||||
|
# TODO - this is not right, especially if the existing run failed
|
||||||
if p.is_new:
|
if p.is_new:
|
||||||
property_details_epc = p.get_property_details_epc(
|
property_details_epc = p.get_property_details_epc(
|
||||||
portfolio_id=body.portfolio_id, rating_lookup=rating_lookup,
|
portfolio_id=body.portfolio_id, rating_lookup=rating_lookup,
|
||||||
)
|
)
|
||||||
create_property_details_epc(session, property_details_epc)
|
db_funcs.property_functions.create_property_details_epc(session, property_details_epc)
|
||||||
|
|
||||||
update_or_create_property_spatial_details(session, p.uprn, p.spatial)
|
db_funcs.property_functions.update_or_create_property_spatial_details(
|
||||||
|
session, p.uprn, p.spatial
|
||||||
|
)
|
||||||
|
|
||||||
property_data = p.get_full_property_data(current_valuation=valuations["current_value"])
|
property_data = p.get_full_property_data(current_valuation=valuations["current_value"])
|
||||||
|
|
||||||
update_property_data(
|
db_funcs.property_functions.update_property_data(
|
||||||
session, property_id=p.id, portfolio_id=body.portfolio_id, property_data=property_data
|
session, property_id=p.id, portfolio_id=body.portfolio_id, property_data=property_data
|
||||||
)
|
)
|
||||||
|
|
||||||
if not recommendations_to_upload:
|
if not recommendations_to_upload:
|
||||||
continue
|
continue
|
||||||
|
new_plan_id = db_funcs.recommendations_functions.create_plan(session, {
|
||||||
new_plan_id = create_plan(session, {
|
|
||||||
"portfolio_id": body.portfolio_id,
|
"portfolio_id": body.portfolio_id,
|
||||||
"property_id": p.id,
|
"property_id": p.id,
|
||||||
"scenario_id": scenario_id,
|
"scenario_id": scenario_id,
|
||||||
|
|
@ -1175,11 +1214,10 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
"plan_type": eco_packages.get(p.id, (None, None, None))[2]
|
"plan_type": eco_packages.get(p.id, (None, None, None))[2]
|
||||||
})
|
})
|
||||||
|
|
||||||
upload_recommendations(
|
db_funcs.recommendations_functions.upload_recommendations(
|
||||||
session, recommendations_to_upload, p.id, new_plan_id
|
session, recommendations_to_upload, p.id, new_plan_id
|
||||||
)
|
)
|
||||||
|
db_funcs.funding_functions.upload_funding(session, p, new_plan_id, recommendations_to_upload)
|
||||||
upload_funding(session, p, new_plan_id, recommendations_to_upload)
|
|
||||||
|
|
||||||
if valuations["current_value"] > 0:
|
if valuations["current_value"] > 0:
|
||||||
property_valuation_increases.append(
|
property_valuation_increases.append(
|
||||||
|
|
@ -1218,7 +1256,7 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
property_value_increase_ranges=property_value_increase_ranges
|
property_value_increase_ranges=property_value_increase_ranges
|
||||||
)
|
)
|
||||||
|
|
||||||
aggregate_portfolio_recommendations(
|
db_funcs.portfolio_functions.aggregate_portfolio_recommendations(
|
||||||
session,
|
session,
|
||||||
portfolio_id=body.portfolio_id,
|
portfolio_id=body.portfolio_id,
|
||||||
scenario_id=scenario_id,
|
scenario_id=scenario_id,
|
||||||
|
|
@ -1230,17 +1268,20 @@ async def model_engine(body: PlanTriggerRequest):
|
||||||
# Commit final changes
|
# Commit final changes
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
except IntegrityError:
|
except IntegrityError as e:
|
||||||
return handle_error(session, "Database integrity error.", 500)
|
return handle_error(session, "Database integrity error.", e, body.subtask_id, 500)
|
||||||
except OperationalError:
|
except OperationalError as e:
|
||||||
return handle_error(session, "Database operational error.", 500)
|
return handle_error(session, "Database operational error.", e, body.subtask_id, 500)
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
return handle_error(session, "Bad request: malformed data.", 400)
|
return handle_error(session, "Bad request: malformed data.", e, body.subtask_id, 400)
|
||||||
except Exception as e: # General exception handling
|
except Exception as e: # General exception handling
|
||||||
return handle_error(session, "An unexpected error occurred.", 500)
|
return handle_error(session, "An unexpected error occurred.", e, body.subtask_id, 500)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
# Mark the subtask as successful
|
||||||
|
SubTaskInterface().update_subtask_status(subtask_id=UUID(body.subtask_id), status="failed")
|
||||||
|
|
||||||
logger.info("Model Engine completed successfully")
|
logger.info("Model Engine completed successfully")
|
||||||
|
|
||||||
return Response(status_code=200)
|
return Response(status_code=200)
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[
|
||||||
epc_data = epc_data[~pd.isnull(epc_data["UPRN"])]
|
epc_data = epc_data[~pd.isnull(epc_data["UPRN"])]
|
||||||
|
|
||||||
sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2015-01-01"].drop_duplicates("UPRN").sample(
|
sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2015-01-01"].drop_duplicates("UPRN").sample(
|
||||||
5000).reset_index(drop=True)
|
10000).reset_index(drop=True)
|
||||||
|
|
||||||
# TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type
|
# TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type
|
||||||
# TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used
|
# TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import json
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from asset_list.utils import get_data_for_property
|
from asset_list.utils import get_data_for_property
|
||||||
|
|
@ -52,8 +53,6 @@ n_postcodes = property_list["Post Code"].nunique()
|
||||||
postcode_summary = property_list.groupby("Post Code")["UPRN"].count().reset_index()
|
postcode_summary = property_list.groupby("Post Code")["UPRN"].count().reset_index()
|
||||||
postcode_summary["UPRN"].mean()
|
postcode_summary["UPRN"].mean()
|
||||||
|
|
||||||
test_match = property_list.merge(sustainability_data, left_on="UPRN", right_on="Org Ref")
|
|
||||||
|
|
||||||
|
|
||||||
def classify_floor_area(x):
|
def classify_floor_area(x):
|
||||||
if x <= 72:
|
if x <= 72:
|
||||||
|
|
@ -70,12 +69,187 @@ sustainability_data["Floor Area Band"] = sustainability_data["Total Floor Area (
|
||||||
lambda x: classify_floor_area(x)
|
lambda x: classify_floor_area(x)
|
||||||
)
|
)
|
||||||
|
|
||||||
archetypes = sustainability_data[
|
# Archetype reductions
|
||||||
["Type", "Attachment", "Construction Years", "Wall Construction", "Wall Insulation",
|
|
||||||
"Roof Construction", "Roof Insulation", "Floor Construction", "Floor Insulation",
|
# Roof insulation category
|
||||||
"Glazing", "Heating", "Boiler Efficiency", "Main Fuel", "Controls Adequacy",
|
# 1) Split roof insulation into > 100mm loft and <= 100mm loft
|
||||||
"Floor Area Band"]
|
sustainability_data["Roof Insulation Category"] = sustainability_data["Roof Insulation"].copy()
|
||||||
].drop_duplicates()
|
sustainability_data["Roof Insulation Category"] = np.where(
|
||||||
|
sustainability_data["Roof Insulation Category"].isin(
|
||||||
|
['mm200', 'mm300', 'mm250', 'mm150', 'mm270', 'mm400', 'mm350'],
|
||||||
|
),
|
||||||
|
"LI > 100mm",
|
||||||
|
sustainability_data["Roof Insulation Category"],
|
||||||
|
)
|
||||||
|
|
||||||
|
sustainability_data["Roof Insulation Category"] = np.where(
|
||||||
|
sustainability_data["Roof Insulation Category"].isin(
|
||||||
|
['mm100', 'mm50', 'mm75', 'mm25'],
|
||||||
|
),
|
||||||
|
"LI <= 100mm",
|
||||||
|
sustainability_data["Roof Insulation Category"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) Group all of the glazed together (e.g. double glazed, secondary glazed, triple glazed)
|
||||||
|
sustainability_data["Glazing Type"] = sustainability_data["Glazing"].copy()
|
||||||
|
sustainability_data["Glazing Type"] = np.where(
|
||||||
|
sustainability_data["Glazing Type"].isin(
|
||||||
|
['Double 2002 or later', 'Double before 2002', 'Double but age unknown', 'DoubleKnownData']
|
||||||
|
),
|
||||||
|
"Double Glazed",
|
||||||
|
sustainability_data["Glazing Type"],
|
||||||
|
)
|
||||||
|
sustainability_data["Glazing Type"] = np.where(
|
||||||
|
sustainability_data["Glazing Type"].isin(['Triple', 'TripleKnownData']),
|
||||||
|
"Triple Glazed",
|
||||||
|
sustainability_data["Glazing Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) Group up boiler efficiency A, B-D, E - G? or someting like this
|
||||||
|
sustainability_data["Boiler Efficiency Group"] = sustainability_data["Boiler Efficiency"].copy()
|
||||||
|
sustainability_data["Boiler Efficiency Group"] = np.where(
|
||||||
|
sustainability_data["Boiler Efficiency Group"].isin(['B', 'C', 'D']),
|
||||||
|
"B-D",
|
||||||
|
sustainability_data["Boiler Efficiency Group"],
|
||||||
|
)
|
||||||
|
sustainability_data["Boiler Efficiency Group"] = np.where(
|
||||||
|
sustainability_data["Boiler Efficiency Group"].isin(['E', 'F', 'G']),
|
||||||
|
"E-G",
|
||||||
|
sustainability_data["Boiler Efficiency Group"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4) Group up main fuel into gas, electric, oil, other?
|
||||||
|
sustainability_data["Main Fuel Group"] = sustainability_data["Main Fuel"].copy()
|
||||||
|
sustainability_data["Main Fuel Group"] = np.where(
|
||||||
|
sustainability_data["Main Fuel Group"].isin(
|
||||||
|
["SmokelessCoal", "BiomassCommunity", "B30DCommunity"]
|
||||||
|
),
|
||||||
|
"Other Fuel",
|
||||||
|
sustainability_data["Main Fuel Group"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5) Wall Construction - group up Sandstone and Granite into one category
|
||||||
|
sustainability_data["Wall Construction"] = np.where(
|
||||||
|
sustainability_data["Wall Construction"].isin(["Sandstone", "Granite"]),
|
||||||
|
"Sandstone/Granite",
|
||||||
|
sustainability_data["Wall Construction"]
|
||||||
|
)
|
||||||
|
|
||||||
|
sustainability_data["Wall Construction"] = np.where(
|
||||||
|
sustainability_data["Wall Construction"].isin(["Timber Frame", "System", "Solid Brick"]),
|
||||||
|
"Solid",
|
||||||
|
sustainability_data["Wall Construction"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6) Reduce or remove floor construction
|
||||||
|
sustainability_data["Floor Construction"] = np.where(
|
||||||
|
sustainability_data["Floor Construction"].isin(["SuspendedTimber", "SuspendedNotTimber"]),
|
||||||
|
"Suspended Floor",
|
||||||
|
sustainability_data["Floor Construction"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7) Reduce wall insulation
|
||||||
|
sustainability_data["Wall Insulation"] = np.where(
|
||||||
|
sustainability_data["Wall Insulation"].isin(
|
||||||
|
["FilledCavityPlusInternal", "FilledCavityPlusExternal", "FilledCavity", "External", "Internal"]
|
||||||
|
),
|
||||||
|
"Insulated",
|
||||||
|
sustainability_data["Wall Insulation"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8) Fill floor insulation
|
||||||
|
sustainability_data["Floor Insulation"] = sustainability_data["Floor Insulation"].fillna("Unknown")
|
||||||
|
|
||||||
|
# 9) Reduce Age bands
|
||||||
|
sustainability_data["Construction Years"] = np.where(
|
||||||
|
sustainability_data["Construction Years"].isin(["2003-2006", "2007-2011", "2012 onwards"]),
|
||||||
|
"2003 onwards",
|
||||||
|
sustainability_data["Construction Years"],
|
||||||
|
)
|
||||||
|
|
||||||
|
sustainability_data["Construction Years"] = np.where(
|
||||||
|
sustainability_data["Construction Years"].isin(["Before 1900", "1900-1929"]),
|
||||||
|
"Before 1929",
|
||||||
|
sustainability_data["Construction Years"],
|
||||||
|
)
|
||||||
|
|
||||||
|
sustainability_data["Construction Years"] = np.where(
|
||||||
|
sustainability_data["Construction Years"].isin(["1983-1990", "1991-1995"]),
|
||||||
|
"1983-1995",
|
||||||
|
sustainability_data["Construction Years"],
|
||||||
|
)
|
||||||
|
|
||||||
|
sustainability_data["Construction Years"] = np.where(
|
||||||
|
sustainability_data["Construction Years"].isin(["1950-1966", "1967-1975", "1976-1982"]),
|
||||||
|
"1950-1982",
|
||||||
|
sustainability_data["Construction Years"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Roof
|
||||||
|
sustainability_data["Roof Construction"] = np.where(
|
||||||
|
sustainability_data["Roof Construction"].isin(
|
||||||
|
["PitchedNormalLoftAccess", "PitchedThatched", "PitchedNormalNoLoftAccess", "PitchedWithSlopingCeiling"]
|
||||||
|
),
|
||||||
|
"Pitched Roof",
|
||||||
|
sustainability_data["Roof Construction"]
|
||||||
|
)
|
||||||
|
|
||||||
|
archetype_variables = [
|
||||||
|
"Type", "Attachment", "Construction Years", "Wall Construction", "Wall Insulation",
|
||||||
|
"Roof Construction", "Roof Insulation Category", "Floor Construction", "Floor Insulation",
|
||||||
|
"Glazing Type", "Heating", "Boiler Efficiency Group", "Main Fuel Group", "Controls Adequacy",
|
||||||
|
"Floor Area Band"
|
||||||
|
]
|
||||||
|
|
||||||
|
archetypes = sustainability_data[archetype_variables + ["UPRN"]].dropna().groupby(archetype_variables)[
|
||||||
|
"UPRN"].nunique().reset_index().rename(columns={"UPRN": "Count"}).sort_values(by="Count",
|
||||||
|
ascending=False).reset_index(
|
||||||
|
drop=True)
|
||||||
|
|
||||||
|
# We take a sample that represents 95% of the properties
|
||||||
|
archetypes["Cumulative Count"] = archetypes["Count"].cumsum()
|
||||||
|
archetypes["Cumulative Proportion"] = archetypes["Cumulative Count"] / archetypes["Count"].sum()
|
||||||
|
|
||||||
|
archetypes_85 = archetypes[archetypes["Cumulative Proportion"] <= 0.80]
|
||||||
|
archetypes_85["Archetypes_85_reference"] = archetypes_85.index + 1
|
||||||
|
archetypes_85["Archetypes_85_reference"] = "Archetype_Sample_" + archetypes_85["Archetypes_85_reference"].astype(str)
|
||||||
|
|
||||||
|
# We now take a sample of the properties that represent 85% of the total properties
|
||||||
|
sustainability_data = sustainability_data.merge(
|
||||||
|
archetypes_85,
|
||||||
|
on=archetype_variables,
|
||||||
|
how="inner"
|
||||||
|
)
|
||||||
|
# We take 1 random property, by archetype 85 reference
|
||||||
|
modelling_sample = sustainability_data.groupby("Archetypes_85_reference").apply(
|
||||||
|
lambda x: x.sample(1, random_state=42)
|
||||||
|
).reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Checking distributions
|
||||||
|
def compare_distributions(full_df, sample_df, column):
|
||||||
|
full_dist = full_df[column].value_counts(normalize=True)
|
||||||
|
sample_dist = sample_df[column].value_counts(normalize=True)
|
||||||
|
comparison = pd.concat([full_dist, sample_dist], axis=1, keys=['Full', 'Sample']).fillna(0)
|
||||||
|
return comparison
|
||||||
|
|
||||||
|
|
||||||
|
for col in archetype_variables:
|
||||||
|
print(f"--- {col} ---")
|
||||||
|
print(compare_distributions(sustainability_data, modelling_sample, col))
|
||||||
|
|
||||||
|
# Save this CSV as input
|
||||||
|
modelling_sample.to_excel(
|
||||||
|
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/modelling_sample.xlsx",
|
||||||
|
)
|
||||||
|
# Save the archetype definitions
|
||||||
|
archetypes_85.to_excel(
|
||||||
|
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/archetypes_85.xlsx",
|
||||||
|
)
|
||||||
|
# Save the full archetypes
|
||||||
|
archetypes.to_excel(
|
||||||
|
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/full_archetypes.xlsx",
|
||||||
|
)
|
||||||
|
|
||||||
# Maps the property types to the format recognised by the EPC api
|
# Maps the property types to the format recognised by the EPC api
|
||||||
property_type_map = {}
|
property_type_map = {}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ DATA_ANOMALY_MATCHES = {
|
||||||
# certificate retrieval process is successfully completed. Mandatory data items cannot be applied
|
# certificate retrieval process is successfully completed. Mandatory data items cannot be applied
|
||||||
# retrospectively to energy certificates lodged before the date of the change.
|
# retrospectively to energy certificates lodged before the date of the change.
|
||||||
"Not recorded",
|
"Not recorded",
|
||||||
|
"Not Recorded",
|
||||||
# The data also contains DECs with an operational rating of ‘9999’ (a ‘default’ DEC). The production of a
|
# The data also contains DECs with an operational rating of ‘9999’ (a ‘default’ DEC). The production of a
|
||||||
# ‘default’ DEC value was allowed to enable building occupiers, with poor quality or no energy data,
|
# ‘default’ DEC value was allowed to enable building occupiers, with poor quality or no energy data,
|
||||||
# the opportunity to comply with the regulations. From April 2011 the ability to lodge a ‘default’ DEC was no
|
# the opportunity to comply with the regulations. From April 2011 the ability to lodge a ‘default’ DEC was no
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,16 @@ class RetrieveFindMyEpc:
|
||||||
'Chrome/111.0.0.0 Safari/537.36'
|
'Chrome/111.0.0.0 Safari/537.36'
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, address: str, postcode: str):
|
def __init__(self, address: str, postcode: str, rrn: str = None):
|
||||||
"""
|
"""
|
||||||
This class is tasked with retrieving the latest EPC data from the find my epc website
|
This class is tasked with retrieving the latest EPC data from the find my epc website
|
||||||
:param address: The address of the property
|
:param address: The address of the property
|
||||||
:param postcode: The postcode of the property
|
:param postcode: The postcode of the property
|
||||||
|
:param rrn: The RRN of the EPC (if known)
|
||||||
"""
|
"""
|
||||||
self.address = address
|
self.address = address
|
||||||
self.postcode = postcode
|
self.postcode = postcode
|
||||||
|
self.rrn = rrn
|
||||||
|
|
||||||
self.address_cleaned = self.address.replace(",", "").replace(" ", "").lower()
|
self.address_cleaned = self.address.replace(",", "").replace(" ", "").lower()
|
||||||
self.walls = []
|
self.walls = []
|
||||||
|
|
@ -286,54 +288,12 @@ class RetrieveFindMyEpc:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
postcode_input = self.postcode.replace(" ", "+")
|
if self.rrn:
|
||||||
postcode_search = self.SEARCH_POSTCODE_URL.format(postcode_input=postcode_input)
|
# We build the URL directly
|
||||||
postcode_response = requests.get(postcode_search, headers=self.HEADERS)
|
epc_certificate = self.rrn
|
||||||
|
chosen_epc = f"{self.BASE_ENERGY_URL}/energy-certificate/{epc_certificate}"
|
||||||
postcode_res = BeautifulSoup(postcode_response.text, features="html.parser")
|
else:
|
||||||
rows = postcode_res.find_all('tr', class_='govuk-table__row')
|
chosen_epc, epc_certificate = self._find_epc_page()
|
||||||
|
|
||||||
extracted_table = []
|
|
||||||
for row in rows:
|
|
||||||
# Extract the address and URL
|
|
||||||
address_tag = row.find('a', class_='govuk-link')
|
|
||||||
if address_tag is None:
|
|
||||||
continue
|
|
||||||
extracted_address = None
|
|
||||||
extracted_address_url = None
|
|
||||||
if address_tag:
|
|
||||||
extracted_address = address_tag.text.strip()
|
|
||||||
extracted_address_url = address_tag['href']
|
|
||||||
|
|
||||||
extracted_address_cleaned = extracted_address.replace(",", "").replace(" ", "").lower()
|
|
||||||
if not extracted_address_cleaned.startswith(self.address_cleaned):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# If the address is a match, we can extract the data
|
|
||||||
|
|
||||||
# Extract the expiry date
|
|
||||||
expiry_date_tag = row.find('td', class_='govuk-table__cell date')
|
|
||||||
expiry_date = None
|
|
||||||
if expiry_date_tag is not None:
|
|
||||||
expiry_date = expiry_date_tag.parent.find('span').text.strip()
|
|
||||||
|
|
||||||
extracted_table.append(
|
|
||||||
{
|
|
||||||
"extracted_address": extracted_address,
|
|
||||||
"extracted_address_url": extracted_address_url,
|
|
||||||
"expiry_date": datetime.strptime(expiry_date, '%d %B %Y'),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not extracted_table:
|
|
||||||
raise ValueError("No EPC found")
|
|
||||||
|
|
||||||
if len(extracted_table) > 1:
|
|
||||||
# We take the one with the most recent expiry date
|
|
||||||
extracted_table = sorted(extracted_table, key=lambda x: x['expiry_date'], reverse=True)
|
|
||||||
|
|
||||||
chosen_epc = self.BASE_ENERGY_URL + extracted_table[0]['extracted_address_url']
|
|
||||||
epc_certificate = chosen_epc.split('/')[-1]
|
|
||||||
|
|
||||||
address_response = requests.get(chosen_epc, headers=self.HEADERS)
|
address_response = requests.get(chosen_epc, headers=self.HEADERS)
|
||||||
address_res = BeautifulSoup(address_response.text, features="html.parser")
|
address_res = BeautifulSoup(address_response.text, features="html.parser")
|
||||||
|
|
@ -371,9 +331,12 @@ class RetrieveFindMyEpc:
|
||||||
|
|
||||||
return all_find_my_epc_data
|
return all_find_my_epc_data
|
||||||
|
|
||||||
def retrieve_newest_find_my_epc_data(self, sap_2012_date=None, return_page=False):
|
def _find_epc_page(self):
|
||||||
"""
|
"""
|
||||||
For a post code and address, we pull out all the required data from the find my epc website
|
This function is used to find the EPC page source for a given address and postcode.
|
||||||
|
It is done by fetching the page, associating to the postcode and then matching the
|
||||||
|
addresses on the page to the address we have been given.
|
||||||
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
postcode_input = self.postcode.replace(" ", "+")
|
postcode_input = self.postcode.replace(" ", "+")
|
||||||
|
|
@ -398,6 +361,7 @@ class RetrieveFindMyEpc:
|
||||||
extracted_address_cleaned = (
|
extracted_address_cleaned = (
|
||||||
extracted_address.replace(",", "").replace(" ", "").lower()
|
extracted_address.replace(",", "").replace(" ", "").lower()
|
||||||
)
|
)
|
||||||
|
|
||||||
if not extracted_address_cleaned.startswith(self.address_cleaned):
|
if not extracted_address_cleaned.startswith(self.address_cleaned):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -427,8 +391,28 @@ class RetrieveFindMyEpc:
|
||||||
chosen_epc = self.BASE_ENERGY_URL + extracted_table[0]['extracted_address_url']
|
chosen_epc = self.BASE_ENERGY_URL + extracted_table[0]['extracted_address_url']
|
||||||
epc_certificate = chosen_epc.split('/')[-1]
|
epc_certificate = chosen_epc.split('/')[-1]
|
||||||
|
|
||||||
address_response = requests.get(chosen_epc, headers=self.HEADERS)
|
return chosen_epc, epc_certificate
|
||||||
address_res = BeautifulSoup(address_response.text, features="html.parser")
|
|
||||||
|
def retrieve_newest_find_my_epc_data(self, sap_2012_date=None, return_page=False, epc_page_source=None, rrn=None):
|
||||||
|
"""
|
||||||
|
For a post code and address, we pull out all the required data from the find my epc website
|
||||||
|
"""
|
||||||
|
|
||||||
|
if epc_page_source is None and rrn is None:
|
||||||
|
chosen_epc, rrn = self._find_epc_page()
|
||||||
|
address_response = requests.get(chosen_epc, headers=self.HEADERS)
|
||||||
|
epc_page_source = address_response.text
|
||||||
|
address_res = BeautifulSoup(address_response.text, features="html.parser")
|
||||||
|
elif self.rrn:
|
||||||
|
epc_certificate = self.rrn
|
||||||
|
chosen_epc = f"{self.BASE_ENERGY_URL}/energy-certificate/{epc_certificate}"
|
||||||
|
address_response = requests.get(chosen_epc, headers=self.HEADERS)
|
||||||
|
epc_page_source = address_response.text
|
||||||
|
address_res = BeautifulSoup(address_response.text, features="html.parser")
|
||||||
|
else:
|
||||||
|
if rrn is None:
|
||||||
|
raise ValueError("rrn must be provided if epc_page_source is provided")
|
||||||
|
address_res = BeautifulSoup(epc_page_source, features="html.parser")
|
||||||
|
|
||||||
# Key data we want to retrieve:
|
# Key data we want to retrieve:
|
||||||
# 1) Rating
|
# 1) Rating
|
||||||
|
|
@ -563,8 +547,21 @@ class RetrieveFindMyEpc:
|
||||||
# 5) Pull out the EPC data
|
# 5) Pull out the EPC data
|
||||||
epc_data = self.extract_epc_data(address_res)
|
epc_data = self.extract_epc_data(address_res)
|
||||||
|
|
||||||
|
# Pull out the address information which can be found in the box with the class "epc-address"
|
||||||
|
# We split it up on break tags
|
||||||
|
addr = address_res.find("p", class_="epc-address").get_text(separator="\n").strip()
|
||||||
|
lines = addr.split("\n")
|
||||||
|
if len(lines) > 2:
|
||||||
|
address1 = lines[0]
|
||||||
|
address2 = lines[1]
|
||||||
|
postcode = lines[-1]
|
||||||
|
else:
|
||||||
|
address1 = lines[0]
|
||||||
|
address2 = ""
|
||||||
|
postcode = lines[-1]
|
||||||
|
|
||||||
resulting_data = {
|
resulting_data = {
|
||||||
'epc_certificate': epc_certificate,
|
'epc_certificate': rrn,
|
||||||
'current_epc_rating': current_rating.split(' ')[-6],
|
'current_epc_rating': current_rating.split(' ')[-6],
|
||||||
'current_epc_efficiency': current_sap,
|
'current_epc_efficiency': current_sap,
|
||||||
'potential_epc_rating': potential_rating.split(' ')[-6],
|
'potential_epc_rating': potential_rating.split(' ')[-6],
|
||||||
|
|
@ -575,11 +572,16 @@ class RetrieveFindMyEpc:
|
||||||
"epc_data": epc_data,
|
"epc_data": epc_data,
|
||||||
**assessment_data,
|
**assessment_data,
|
||||||
**low_carbon_energy_sources,
|
**low_carbon_energy_sources,
|
||||||
|
"page_source": epc_page_source,
|
||||||
|
# Add in address a postcode from the page - covers use cases where we are given RRN
|
||||||
|
"address1": address1,
|
||||||
|
"address2": address2,
|
||||||
|
"postcode": postcode,
|
||||||
}
|
}
|
||||||
|
|
||||||
if return_page:
|
if return_page:
|
||||||
# We return the page text as well, which can be parsed again later
|
# We return the page text as well, which can be parsed again later
|
||||||
return resulting_data, postcode_response.text
|
return resulting_data, epc_page_source
|
||||||
|
|
||||||
return resulting_data
|
return resulting_data
|
||||||
|
|
||||||
|
|
@ -721,11 +723,15 @@ class RetrieveFindMyEpc:
|
||||||
return formatted_recommendations
|
return formatted_recommendations
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_from_epc(cls, epc):
|
def get_from_epc(cls, epc, epc_page_source=None, rrn=None):
|
||||||
|
|
||||||
|
if epc_page_source is not None and rrn is None:
|
||||||
|
raise ValueError("rrn must be provided if epc_page_source is provided")
|
||||||
|
|
||||||
# Attempt both methods:
|
# Attempt both methods:
|
||||||
try:
|
try:
|
||||||
searcher = cls(address=epc["address"], postcode=epc["postcode"])
|
searcher = cls(address=epc["address"], postcode=epc["postcode"])
|
||||||
find_epc_data = searcher.retrieve_newest_find_my_epc_data()
|
find_epc_data = searcher.retrieve_newest_find_my_epc_data(epc_page_source=epc_page_source, rrn=rrn)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving find my epc data: {e}")
|
logger.error(f"Error retrieving find my epc data: {e}")
|
||||||
|
|
||||||
|
|
@ -733,7 +739,7 @@ class RetrieveFindMyEpc:
|
||||||
address1 = ",".join(epc["address"].split(",")[:-1])
|
address1 = ",".join(epc["address"].split(",")[:-1])
|
||||||
try:
|
try:
|
||||||
searcher = cls(address=address1, postcode=epc["postcode"])
|
searcher = cls(address=address1, postcode=epc["postcode"])
|
||||||
find_epc_data = searcher.retrieve_newest_find_my_epc_data()
|
find_epc_data = searcher.retrieve_newest_find_my_epc_data(epc_page_source=epc_page_source, rrn=rrn)
|
||||||
logger.info("Successfully retrieved find my epc data using trimmed address")
|
logger.info("Successfully retrieved find my epc data using trimmed address")
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.error(f"Error retrieving find my epc data using trimmed address: {e2}")
|
logger.error(f"Error retrieving find my epc data using trimmed address: {e2}")
|
||||||
|
|
@ -746,7 +752,7 @@ class RetrieveFindMyEpc:
|
||||||
address1 = epc["address1"]
|
address1 = epc["address1"]
|
||||||
# We attempt with the backup add
|
# We attempt with the backup add
|
||||||
searcher = cls(address=address1, postcode=epc["postcode"])
|
searcher = cls(address=address1, postcode=epc["postcode"])
|
||||||
find_epc_data = searcher.retrieve_newest_find_my_epc_data()
|
find_epc_data = searcher.retrieve_newest_find_my_epc_data(epc_page_source=epc_page_source, rrn=rrn)
|
||||||
logger.info("Successfully retrieved find my epc data using backup address")
|
logger.info("Successfully retrieved find my epc data using backup address")
|
||||||
|
|
||||||
non_invasive_recommendations = {
|
non_invasive_recommendations = {
|
||||||
|
|
@ -765,4 +771,9 @@ class RetrieveFindMyEpc:
|
||||||
**find_epc_data["epc_data"],
|
**find_epc_data["epc_data"],
|
||||||
}
|
}
|
||||||
|
|
||||||
return non_invasive_recommendations, patch
|
page_source = {
|
||||||
|
"rrn": find_epc_data["epc_certificate"],
|
||||||
|
"page_source": find_epc_data["page_source"]
|
||||||
|
}
|
||||||
|
|
||||||
|
return non_invasive_recommendations, patch, page_source
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue