mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Updating roof recommendations to allow room roof recommendations to be included in non-invasive recommendations
This commit is contained in:
parent
adc8f1a104
commit
316a42bacb
8 changed files with 154 additions and 39 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue