diff --git a/.idea/Model.iml b/.idea/Model.iml index ed9033de..4413bb06 100644 --- a/.idea/Model.iml +++ b/.idea/Model.iml @@ -7,7 +7,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 3ab974fc..6f308057 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 83a57d07..f110b27a 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -21,7 +21,7 @@ from backend.app.db.models.portfolio import rating_lookup from backend.app.dependencies import validate_token from backend.app.plan.schemas import PlanTriggerRequest from backend.app.plan.utils import ( - create_recommendation_scoring_data, filter_materials, get_cleaned, insert_temp_recommendation_id + create_recommendation_scoring_data, prepare_materials, get_cleaned, insert_temp_recommendation_id ) from backend.app.utils import epc_to_sap_lower_bound, read_csv_from_s3, read_parquet_from_s3 @@ -114,7 +114,7 @@ async def trigger_plan(body: PlanTriggerRequest): # the same data logger.info("Reading in materials and cleaned datasets") materials = get_materials(session) - materials_by_type = filter_materials(materials) + materials = prepare_materials(materials) cleaned = get_cleaned() logger.info("Getting components and epc recommendations") @@ -126,13 +126,14 @@ async def trigger_plan(body: PlanTriggerRequest): recommendations_scoring_data = [] for p in input_properties: + # Property recommendations p.get_components(cleaned) property_recommendations = [] # Floor recommendations - floor_recommender = FloorRecommendations(property_instance=p, materials=materials_by_type["floor"]) + floor_recommender = FloorRecommendations(property_instance=p, materials=materials) floor_recommender.recommend() if floor_recommender.recommendations: diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py index e2bf9d86..e3723b24 100644 --- a/backend/app/plan/utils.py +++ b/backend/app/plan/utils.py @@ -1,6 +1,5 @@ import pandas as pd from backend.Property import Property -from collections import defaultdict from utils.s3 import read_from_s3 from recommendations.recommendation_utils import get_wall_u_value, get_floor_u_value, get_roof_u_value @@ -10,22 +9,13 @@ from backend.app.config import get_settings import msgpack -def filter_materials(materials): - materials_by_type = defaultdict(list) - - mapping = { - "walls": ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"], - "floor": ["suspended_floor_insulation", "solid_floor_insulation", "exposed_floor_insulation"], - "ventilation": ["mechanical_ventilation"], - "roof": ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"] - } - - materials = [row2dict(material) for material in materials] - - for component, types in mapping.items(): - materials_by_type[component] = [part for part in materials if part["type"] in types] - - return dict(materials_by_type) +def prepare_materials(materials): + """ + This function will prepare the materials for recommendations + :param materials: list of materials, as retrieved from the database + :return: + """ + return [row2dict(material) for material in materials] def insert_temp_recommendation_id(property_recommendations): @@ -173,7 +163,7 @@ def create_recommendation_scoring_data( parts = recommendation["parts"] if len(parts) != 1: raise ValueError("More than one part for roof insulation - investiage me") - + scoring_dict["roof_insulation_thickness_ENDING"] = str(parts[0]["depths"][0]) scoring_dict["ROOF_ENERGY_EFF_ENDING"] = "Very Good" else: diff --git a/etl/costs/app.py b/etl/costs/app.py index 0117a66e..1ecbbb5f 100644 --- a/etl/costs/app.py +++ b/etl/costs/app.py @@ -1,12 +1,12 @@ import os import dotenv -import json import pandas as pd import numpy as np from pathlib import Path from sqlalchemy.orm import Session from sqlalchemy import create_engine from backend.app.db.models.materials import Material +from recommendations.recommendation_utils import calculate_r_value_per_mm DATA_DIRECTORY = Path(__file__).parent / "local_data" / "Hestia Materials.xlsx" # Environment file is at the same level as this file @@ -90,6 +90,14 @@ def app(): costs["depth"] = costs["depth"].fillna(0) costs["depth"] = costs["depth"].astype(str) + costs["r_value_per_mm"] = costs.apply( + lambda row: calculate_r_value_per_mm(float(row["depth"]), row["thermal_conductivity"]), axis=1 + ) + costs["r_value_unit"] = "square_meter_kelvin_per_watt" + + for col in ["material_cost", "labour_cost", "labour_hours_per_unit", "plant_cost"]: + costs[col] = costs[col].fillna(0) + # Push the costs to the database push_costs_to_db(db_engine, costs) diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py index 5b194e0d..641272a3 100644 --- a/recommendations/FloorRecommendations.py +++ b/recommendations/FloorRecommendations.py @@ -1,5 +1,8 @@ import math from typing import List + +import pandas as pd + from BaseUtility import Definitions from datatypes.enums import QuantityUnits from backend.Property import Property @@ -8,6 +11,7 @@ from recommendations.recommendation_utils import ( get_recommended_part, get_floor_u_value ) from recommendations.rdsap_tables import FLOOR_LEVEL_MAP +from recommendations.Costs import Costs class FloorRecommendations(Definitions): @@ -30,25 +34,41 @@ class FloorRecommendations(Definitions): materials: List, ): self.property = property_instance + self.costs = Costs(self.property) # For audit purposes, when estimating u values we'll store it self.estimated_u_value = None # Will contains a list of recommended measures self.recommendations = [] - self.materials = materials - - self.suspended_floor_insulation_parts = [ - part for part in self.materials if part["type"] == "suspended_floor_insulation" - ] - self.solid_floor_insulation_parts = [ - part for part in self.materials if part["type"] == "solid_floor_insulation" + self.suspended_floor_insulation_materials = [ + part for part in materials if part["type"] == "suspended_floor_insulation" ] - self.exposed_floor_insulation_parts = [ - part for part in self.materials if part["type"] == "exposed_floor_insulation" + self.suspended_floor_non_insulation_materials = [ + part for part in materials if part["type"] in [ + "suspended_floor_demolition", "suspended_floor_redecoration", "suspended_floor_vapour_barrier" + ] ] + self.solid_floor_insulation_materials = [ + part for part in materials if part["type"] == "solid_floor_insulation" + ] + + self.solid_floor_non_insulation_materials = [ + part for part in materials if part["type"] in [ + "solid_floor_demolition", "solid_floor_preparation", "solid_floor_vapour_barrier", + "solid_floor_redecoration" + ] + ] + + self.exposed_floor_insulation_materials = [ + part for part in materials if part["type"] == "exposed_floor_insulation" + ] + + # TODO: To be completed + self.exposed_floor_non_insulation_materials = [] + def recommend(self): u_value = self.property.floor["thermal_transmittance"] @@ -98,7 +118,11 @@ class FloorRecommendations(Definitions): if self.property.floor["is_suspended"]: # Given the U-value, we recommend underfloor insulation - self.recommend_floor_insulation(u_value=u_value, parts=self.suspended_floor_insulation_parts) + self.recommend_floor_insulation( + u_value=u_value, + insulation_materials=self.suspended_floor_insulation_materials, + non_insulation_materials=self.suspended_floor_non_insulation_materials + ) return if self.property.floor["is_solid"]: @@ -113,20 +137,23 @@ class FloorRecommendations(Definitions): raise NotImplementedError("Implement me!") @staticmethod - def _make_floor_description(part, depth): - return f"Install {depth}{part['depth_unit']} {part['description']} insulation" + def _make_floor_description(material): + return f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} insulation" - def recommend_floor_insulation(self, u_value, parts): + def recommend_floor_insulation(self, u_value, insulation_materials, non_insulation_materials): """ This method is tasked with estimating the impact of performing suspended floor insulation :return: """ - lowest_selected_u_value = None - for part in parts: - for depth, cost_per_unit in zip(part["depths"], part["cost"]): + insulation_materials = pd.DataFrame(insulation_materials) - part_u_value = r_value_per_mm_to_u_value(depth, part["r_value_per_mm"]) + lowest_selected_u_value = None + for _, insulation_material_group in insulation_materials.groupby("description"): + + for _, material in insulation_material_group.iterrows(): + + part_u_value = r_value_per_mm_to_u_value(material["depth"], material["r_value_per_mm"]) _, new_u_value = calculate_u_value_uplift(u_value, part_u_value) new_u_value = math.ceil(new_u_value * 100.0) / 100.0 @@ -137,26 +164,37 @@ class FloorRecommendations(Definitions): if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE: lowest_selected_u_value = update_lowest_selected_u_value(lowest_selected_u_value, new_u_value) - quantity = self.property.insulation_floor_area - estimated_cost = cost_per_unit * quantity + if material["type"] == "suspended_floor_insulation": + cost_result = self.costs.suspended_floor_insulation( + insulation_floor_area=self.property.insulation_floor_area, + material=material.to_dict(), + non_insulation_materials=non_insulation_materials + ) + elif material["type"] == "solid_floor_insulation": + cost_result = self.costs.solid_floor_insulation( + insulation_floor_area=self.property.insulation_floor_area, + material=material.to_dict(), + non_insulation_materials=non_insulation_materials + ) + else: + raise NotImplementedError("Implement me!") self.recommendations.append( { "parts": [ get_recommended_part( - part=part, - selected_depth=depth, - quantity=quantity, + part=material.to_dict(), + quantity=self.property.insulation_floor_area, quantity_unit=QuantityUnits.m2.value, - selected_total_cost=estimated_cost + cost_result=cost_result ), ], "type": "floor_insulation", - "description": self._make_floor_description(part, depth), + "description": self._make_floor_description(material), "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, - "cost": estimated_cost, + **cost_result } ) diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index 217f313f..5bd77a2a 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -109,22 +109,21 @@ def update_lowest_selected_u_value(lowest_selected_u_value, new_u_value): return lowest_selected_u_value -def get_recommended_part(part, selected_depth, selected_total_cost, quantity, quantity_unit): +def get_recommended_part(part, cost_result, quantity, quantity_unit): """ Utility function to return a recommended part with the selected depth. :param part: part to be recommended - :param selected_depth: depth of the selected part - :param selected_total_cost: Total cost of the selected part + :param cost_result: Total cost of the selected part, as returned by the Cost class :param quantity: Quantity of the selected part :param quantity_unit: Unit of the quantity :return: """ recommended_part = deepcopy(part) - recommended_part["depths"] = [selected_depth] - recommended_part["estimated_cost"] = selected_total_cost recommended_part["quantity"] = quantity recommended_part["quantity_unit"] = quantity_unit + recommended_part.update(cost_result) + return recommended_part @@ -563,6 +562,9 @@ def calculate_r_value_per_mm(thickness_mm, thermal_conductivity_w_mK): :return: """ + if thermal_conductivity_w_mK is None: + return None + r_value_m2k_w = (thickness_mm / 1000) / thermal_conductivity_w_mK # Calculate R-value per mm