Updating roof recommendations to allow room roof recommendations to be included in non-invasive recommendations

This commit is contained in:
Khalim Conn-Kowlessar 2024-09-09 20:11:28 +01:00
parent adc8f1a104
commit 316a42bacb
8 changed files with 154 additions and 39 deletions

View file

@ -71,6 +71,10 @@ def get_latest_assessment_by_uprn(session: Session, uprn: int) -> Optional[Energ
:param uprn: The unique property reference number
:return: The latest EnergyAssessment object or None if not found
"""
if not uprn:
return EnergyAssessment.empty_response()
try:
# Query the EnergyAssessment model, filter by uprn, order by inspection_date in descending order
latest_assessment = session.query(EnergyAssessment).filter_by(uprn=uprn).order_by(

View file

@ -11,7 +11,8 @@ from backend.app.db.models.portfolio import (
from sqlalchemy.orm.exc import NoResultFound
def create_property(session: Session, portfolio_id: int, address: str, postcode: str, uprn: str) -> (int, bool):
def create_property(session: Session, portfolio_id: int, address: str, postcode: str, uprn: str,
energy_assessment: dict) -> (int, bool):
"""
This function will create a record for the property in the database if it does not exist.
If it does exist, it will just update the updated_at field.
@ -39,13 +40,17 @@ def create_property(session: Session, portfolio_id: int, address: str, postcode:
except NoResultFound:
# Property doesn't exist, create a new one
status = PortfolioStatus.ASSESSMENT.value if len(energy_assessment["epc"]) == 0 \
else PortfolioStatus.SURVEY.value
new_property = PropertyModel(
address=address,
postcode=postcode,
portfolio_id=portfolio_id,
uprn=uprn,
creation_status=PropertyCreationStatus.LOADING,
status=PortfolioStatus.ASSESSMENT.value,
status=status,
has_pre_condition_report=False,
has_recommendations=False
)

View file

@ -11,6 +11,7 @@ Base = declarative_base()
class PortfolioStatus(enum.Enum):
SCOPING = "scoping"
ASSESSMENT = "assessment"
SURVEY = "survey"
TENDERING = "tendering"
PROJECT_UNDERWAY = "project underway"
COMPLETION_ON_TRACK = "completion; status: on track"

View file

@ -235,10 +235,14 @@ def create_epc_records(epc_searcher: SearchEpc, energy_assessment: dict):
EnergyAssessment.empty_response() method
"""
newest_epc = epc_searcher.newest_epc.copy()
if newest_epc["uprn"] == "" and epc_searcher.uprn:
newest_epc["uprn"] = epc_searcher.uprn
if not energy_assessment["epc"]:
energy_assessment_is_newer = False
return {
'original_epc': epc_searcher.newest_epc.copy(),
'original_epc': newest_epc,
'full_sap_epc': epc_searcher.full_sap_epc.copy(),
'old_data': epc_searcher.older_epcs.copy(),
}, energy_assessment_is_newer
@ -249,22 +253,22 @@ def create_epc_records(epc_searcher: SearchEpc, energy_assessment: dict):
# We insert county into the epc, since right now this isn't something that we pull out from the energy
# assessment
for col in ["county", "constituency", "constituency-label", "local-authority", "local-authority-label"]:
epc[col] = epc_searcher.newest_epc[col]
epc[col] = newest_epc[col]
# We check if the energy assessment is newer than the newest EPC
if pd.to_datetime(energy_assessment_date) > pd.to_datetime(epc_searcher.newest_epc["inspection-date"]):
if pd.to_datetime(energy_assessment_date) > pd.to_datetime(newest_epc["inspection-date"]):
# In this case, our energy assessment is newer than the EPCs available for this property
energy_assessment_is_newer = True
return {
"original_epc": epc,
"full_sap_epc": epc_searcher.full_sap_epc.copy(),
"old_data": epc_searcher.older_epcs.copy() + [epc_searcher.newest_epc.copy()]
"old_data": epc_searcher.older_epcs.copy() + [newest_epc]
}, energy_assessment_is_newer
# We check if the EPC we have produced is contained in the set of EPCs done for the property
# We do this based on inspection-date and SAP
epc_in_historicals = [
x for x in epc_searcher.older_epcs + [epc_searcher.newest_epc]
x for x in epc_searcher.older_epcs + [newest_epc]
if x["inspection-date"] == energy_assessment_date and
x["current-energy-efficiency"] == epc["current-energy-efficiency"]
]
@ -273,7 +277,7 @@ def create_epc_records(epc_searcher: SearchEpc, energy_assessment: dict):
if epc_in_historicals:
# Then the EPC we have produced is already in the set of EPCs, and our EPC is older than the newest
return {
"original_epc": epc_searcher.newest_epc.copy(),
"original_epc": newest_epc,
"full_sap_epc": epc_searcher.full_sap_epc.copy(),
"old_data": epc_searcher.older_epcs.copy()
}, energy_assessment_is_newer
@ -281,7 +285,7 @@ def create_epc_records(epc_searcher: SearchEpc, energy_assessment: dict):
# In this case, our EPC is older than the newest publically avaible one, but is not contained in
# the historicals, so it can't have been lodged, so we include it in the old data
return {
'original_epc': epc_searcher.newest_epc.copy(),
'original_epc': newest_epc,
'full_sap_epc': epc_searcher.full_sap_epc.copy(),
'old_data': epc_searcher.older_epcs.copy() + [epc],
}, energy_assessment_is_newer
@ -412,7 +416,8 @@ async def trigger_plan(body: PlanTriggerRequest):
# Create a record in db
property_id, is_new = create_property(
session, body.portfolio_id, epc_searcher.address_clean, epc_searcher.postcode_clean, epc_searcher.uprn
session, body.portfolio_id, epc_searcher.address_clean, epc_searcher.postcode_clean, epc_searcher.uprn,
energy_assessment
)
if not is_new and not body.multi_plan:
continue
@ -799,6 +804,15 @@ async def trigger_plan(body: PlanTriggerRequest):
if ventilation_rec:
selected_recommendations.add(ventilation_rec["recommendation_id"])
# If we have a trickle vents recommendation, we also switch it on. We don't just check the solution
trickle_vents_rec = next(
(r[0] for r in recommendations[p.id] if r[0]["type"] == "trickle_vents"),
None
)
# If a matching recommendation was found, add its ID to the selected recommendations
if trickle_vents_rec:
selected_recommendations.add(trickle_vents_rec["recommendation_id"])
# We'll use the set of selected recommendations to filter the recommendations to upload
final_recommendations = [
[

View file

@ -13,7 +13,7 @@ def app():
"surveyor": "JAFFERSONS ENERGY CONSULTANTS",
"project_code": "VEC001",
}
# 5 Grove Mansions
# These are the recommendations based on the on-site survey of the property.
non_intrusive_recommendations = [
{
@ -22,17 +22,17 @@ def app():
"recommendations": [
{
"type": "draught_proofing",
"cost": 123,
"cost": 100,
"survey": True,
"sap_points": 1
},
{
"type": "mixed_glazing", "cost": 12345, "survey": True,
"type": "mixed_glazing", "cost": 14632, "survey": True,
"description": "Install double glazing to north facing windows and secondary glazing to the "
"remaining windows at the front of the building",
"sap_points": 3
},
{"type": "trickle_vents", "cost": 500, "survey": True},
{"type": "trickle_vents", "cost": 1000, "survey": True},
{"type": "suspended_floor_insulation", "cost": None, "survey": True, "sap_points": 2},
{"type": "internal_wall_insulation", "cost": None, "survey": True, "sap_points": 5},
]
@ -41,14 +41,14 @@ def app():
# 8 Grove Mansions
"uprn": 10024087855,
"recommendations": [
{"type": "draught_proofing", "cost": 123, "survey": True, "sap_points": 2},
{"type": "draught_proofing", "cost": 100, "survey": True, "sap_points": 2},
{
"type": "mixed_glazing", "cost": 12345, "survey": True,
"type": "mixed_glazing", "cost": 7814, "survey": True,
"description": "Install double glazing to north facing windows and secondary glazing to the "
"remaining windows at the front of the building",
"sap_points": 4
},
{"type": "trickle_vents", "cost": 500, "survey": True},
{"type": "trickle_vents", "cost": 700, "survey": True},
{"type": "low_energy_lighting", "cost": None, "survey": True, "sap_points": 0},
{"type": "internal_wall_insulation", "cost": None, "survey": True, 'sap_points': 5},
]
@ -57,14 +57,14 @@ def app():
# 9 Grove Mansions
"uprn": 121016128,
"recommendations": [
{"type": "draught_proofing", "cost": 123, "survey": True, "sap_points": 1},
{"type": "draught_proofing", "cost": 100, "survey": True, "sap_points": 1},
{
"type": "mixed_glazing", "cost": 12345, "survey": True,
"type": "mixed_glazing", "cost": 9740, "survey": True,
"description": "Install double glazing to north facing windows and secondary glazing to the "
"remaining windows at the front of the building",
"sap_points": 3
},
{"type": "trickle_vents", "cost": 500, "survey": True},
{"type": "trickle_vents", "cost": 1000, "survey": True},
{"type": "low_energy_lighting", "cost": None, "survey": True, "sap_points": 1},
{"type": "suspended_floor_insulation", "cost": None, "sap_points": 1},
{"type": "internal_wall_insulation", "cost": None, "survey": True, "sap_points": 6},
@ -75,12 +75,12 @@ def app():
"uprn": 121016124,
"recommendations": [
{
"type": "mixed_glazing", "cost": 12345, "survey": True,
"type": "mixed_glazing", "cost": 12662, "survey": True,
"description": "Install double glazing to north facing windows and secondary glazing to the "
"remaining windows at the front of the building",
"sap_points": 5
},
{"type": "trickle_vents", "cost": 500, "survey": True},
{"type": "trickle_vents", "cost": 1300, "survey": True},
{"type": "low_energy_lighting", "cost": None, "survey": True, "sap_points": 2},
{"type": "internal_wall_insulation", "cost": None, "survey": True, "sap_points": 8},
]
@ -89,14 +89,14 @@ def app():
# 14 Grove Mansions
"uprn": 121016117,
"recommendations": [
{"type": "draught_proofing", "cost": 123, "survey": True, "sap_points": 1},
{"type": "draught_proofing", "cost": 100, "survey": True, "sap_points": 1},
{
"type": "mixed_glazing", "cost": 12345, "survey": True,
"type": "mixed_glazing", "cost": 10736, "survey": True,
"description": "Install double glazing to north facing windows and secondary glazing to the "
"remaining windows at the front of the building",
"sap_points": 4
},
{"type": "trickle_vents", "cost": 500, "survey": True},
{"type": "trickle_vents", "cost": 1000, "survey": True},
{"type": "low_energy_lighting", "cost": None, "survey": True, "sap_points": 1},
{"type": "internal_wall_insulation", "cost": None, "survey": True, "sap_points": 6},
]
@ -113,6 +113,7 @@ def app():
]
asset_list = [
# These are properties where we've done a survey
{
"uprn": 121016121, "address": "", "postcode": ""
},
@ -131,6 +132,63 @@ def app():
{
"uprn": 10024087902, "address": "", "postcode": ""
},
# These properties we just model with default data
# Flat 1
{
"uprn": 121016113, "address": "", "postcode": ""
},
# Flat 10
{
"uprn": 121016114, "address": "", "postcode": ""
},
# Flat 11
{
"uprn": 121016115, "address": "", "postcode": ""
},
# Flat 12
{
"uprn": 121016116, "address": "", "postcode": ""
},
# Flat 15
{
"uprn": 121016118, "address": "", "postcode": ""
},
# Flat 16
{
"uprn": 121016119, "address": "", "postcode": ""
},
# Flat 17
{
"address": "Flat 17 Grove Mansions", "postcode": "SW4 9SL"
},
# Flat 18
{
"uprn": 10024087901, "address": "", "postcode": ""
},
# Flat 3
{
"uprn": 121016122, "address": "", "postcode": ""
},
# Flat 4
{
"uprn": 121016123, "address": "", "postcode": ""
},
# Flat 6
{
"uprn": 121016125, "address": "", "postcode": ""
},
# Flat 7
{
"uprn": 10024087854, "address": "", "postcode": ""
},
# Flat 7A
{
"uprn": 10024087840, "address": "", "postcode": ""
},
# Flat 8A
{
"uprn": 10024087841, "address": "", "postcode": ""
},
]
asset_list = pd.DataFrame(asset_list)
@ -185,7 +243,8 @@ def app():
"trickle_vents",
"low_energy_lighting",
"suspended_floor_insulation",
"internal_wall_insulation"
"internal_wall_insulation",
"room_roof_insulation"
],
"budget": None,
"scenario_name": "Do when void",

View file

@ -9,6 +9,9 @@ class LightingRecommendations:
# worth more than 2 points, but this is unlikely in the context of other upgrades that can be made to the property
SAP_LIMIT = 2
# If more than 50% of the lighting is LEDs already, the limit is 1 SAP point
SAP_LOWER_LIMIT = 1
def __init__(self, property_instance: Property, materials: List):
"""
:param property_instance: Instance of the Property class, for the home associated to property_id
@ -128,6 +131,7 @@ class LightingRecommendations:
"description_simulation": {
"lighting-energy-eff": "Very Good",
"lighting-description": "Low energy lighting in all fixed outlets",
"low-energy-lighting": 100,
},
**cost_result,
"survey": leds_recommendation_config.get("survey", False)

View file

@ -529,7 +529,12 @@ class Recommendations:
# For the moment, we cap the number of SAP points that can be achieved by LEDs at 2
if rec["type"] == "low_energy_lighting":
property_phase_impact["sap"] = min(property_phase_impact["sap"], LightingRecommendations.SAP_LIMIT)
if property_instance.data["low-energy-lighting"] < 50:
lighting_sap_limit = LightingRecommendations.SAP_LIMIT
else:
lighting_sap_limit = LightingRecommendations.SAP_LOWER_LIMIT
property_phase_impact["sap"] = min(property_phase_impact["sap"], lighting_sap_limit)
property_phase_impact["carbon"] = min(
property_phase_impact["carbon"], rec["co2_equivalent_savings"]
)

View file

@ -155,23 +155,44 @@ class RoofRecommendations:
)
self.estimated_u_value = u_value
if (u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE) or (
"loft_insulation" not in measures
if (u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE) or all(
m not in measures for m in MEASURE_MAP["roof_insulation"]
):
# The Roof is already compliant
return
if (self.property.roof["is_pitched"] and "loft_insulation" in measures) or (
self.property.roof["is_flat"] and "flat_roof_insulation" in measures
non_invasive_recommendations = self.property.non_invasive_recommendations
# We firstly handle non-intrusive recommendations, which may override the normal roof insulation recommendations
if ("loft_insulation" in [x["type"] for x in non_invasive_recommendations]) or (
self.property.roof["is_pitched"] and "loft_insulation" in measures
):
insulation_thickness = 0 if "loft_insulation" not in measures else self.insulation_thickness
self.recommend_roof_insulation(u_value, insulation_thickness, self.property.roof, phase)
self.recommend_roof_insulation(
u_value=u_value,
insulation_thickness=self.insulation_thickness,
phase=phase,
is_flat=False,
is_pitched=True
)
return
if (
(self.property.roof["is_flat"] and "flat_roof_insulation" in measures) or
"flat_roof_insulation" in [x["type"] for x in non_invasive_recommendations]
):
self.recommend_roof_insulation(
u_value=u_value,
insulation_thickness=0,
phase=phase,
is_flat=True,
is_pitched=False
)
return
# There are cases where the property might have a room roof as the second roof, but we have a recommendation for
# it, so we allow this override
if self.property.roof["is_roof_room"] and ("room_roof_insulation" in measures) or (
"room_roof_insulation" in [x["type"] for x in self.property.non_invasive_recommendations]
"room_roof_insulation" in [x["type"] for x in non_invasive_recommendations]
):
self.recommend_room_roof_insulation(u_value, phase)
return
@ -195,7 +216,7 @@ class RoofRecommendations:
raise ValueError("Invalid material type")
def recommend_roof_insulation(
self, u_value, insulation_thickness, roof, phase
self, u_value, insulation_thickness, phase, is_pitched, is_flat
):
"""
@ -218,7 +239,9 @@ class RoofRecommendations:
:param u_value: U-value of the roof before any retrofit measures have been installed
:param insulation_thickness: Existing Insulation thickness of the loft
:param roof: dictionary describing the make-up of the roof
:param phase: Phase of the recommendation
:param is_pitched: Is the roof pitched
:param is_flat: Is the roof flat
:return:
"""
@ -226,10 +249,10 @@ class RoofRecommendations:
# Therefore the price is 100mm + whatever thickness is rolled on top, rolled at a 90 degree angle
# from the base layer
if roof["is_pitched"]:
if is_pitched:
insulation_materials = self.loft_insulation_materials
non_insulation_materials = self.loft_non_insulation_materials
elif roof["is_flat"]:
elif is_flat:
insulation_materials = self.flat_roof_insulation_materials
non_insulation_materials = self.flat_roof_non_insulation_materials
else:
@ -251,7 +274,7 @@ class RoofRecommendations:
# Note: This requirement is only for loft insulation
if (
(material["depth"] + insulation_thickness) < self.MINIMUM_RECOMMENDED_LOFT_INSULATION
) and roof["is_pitched"]:
) and is_pitched:
continue
part_u_value = r_value_per_mm_to_u_value(material["depth"], material["r_value_per_mm"])