diff --git a/.idea/Model.iml b/.idea/Model.iml index 80d3522c..ac61a988 100644 --- a/.idea/Model.iml +++ b/.idea/Model.iml @@ -6,7 +6,7 @@ - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index ca0e1cd9..242c02bb 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,6 @@ - + diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 777e2df4..dbc01935 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -6,6 +6,10 @@ from backend.app.config import get_settings from model_data.Property import Property from epc_api.client import EpcClient from utils.logger import setup_logger +from recommendations.FloorRecommendations import FloorRecommendations +from recommendations.WallRecommendations import WallRecommendations +from utils.uvalue_estimates import classify_decile_newvalues + # TODO: This is placeholder until data is stored in DB from backend.app.plan.temp_cleaned_data import cleaned from backend.app.plan.uvalue_estimates_walls import uvalue_estimates_walls @@ -51,6 +55,17 @@ in_conservation_area_data = [ {'uprn': 200003489276, 'is_in_conservation_area': 'in_conservation_area'} ] +# TODO: db +floors_decile_data = { + 'decile_labels': ['Decile 1', 'Decile 2', 'Decile 3', 'Decile 4', 'Decile 5', 'Decile 6', 'Decile 7', 'Decile 8', + 'Decile 9', 'Decile 10'], 'decile_boundaries': [6., 50., 56., 69., 77.6, 87., 98., 112., + 127., 150., 2279.]} + +walls_decile_data = { + 'decile_labels': ['Decile 1', 'Decile 2', 'Decile 3', 'Decile 4', 'Decile 5', 'Decile 6', 'Decile 7', 'Decile 8', + 'Decile 9', 'Decile 10'], 'decile_boundaries': [6., 49., 51., 55., 64., 71., 76., 83., 96., + 120., 2279.]} + @router.post("/trigger") async def trigger_plan(body: PlanTriggerRequest): @@ -85,7 +100,20 @@ async def trigger_plan(body: PlanTriggerRequest): ) p.set_is_in_conservation_area(in_conservation_area) + logger.info("Getting components and properties recommendations") + + for p in input_properties: + # For each property, classiy floor area decide + total_floor_area_group_decile = classify_decile_newvalues( + decile_boundaries=floors_decile_data["decile_boundaries"], + decile_labels=floors_decile_data["decile_labels"], + new_values=[float(p.data["total-floor-area"])], + )[0] + print("hey") + print(total_floor_area_group_decile) + for p in input_properties: p.get_components(cleaned) + # floor_recommendations = FloorRecommendations(property_instance=p, uvalue_estimates=uvalue_estimates_floors) return {"message": "Plan complete"} diff --git a/model_data/app.py b/model_data/app.py index de66428b..fd97ddb8 100644 --- a/model_data/app.py +++ b/model_data/app.py @@ -365,3 +365,5 @@ def app(): uvalue_estimates = UvalueEstimations(data=data) uvalue_estimates.get_estimates(cleaner=cleaner) # TODO: Store these to a db + + uvalue_estimates.floors_decile_data diff --git a/model_data/tests/test_utils.py b/model_data/tests/test_utils.py index 26a9bd7d..224471b8 100644 --- a/model_data/tests/test_utils.py +++ b/model_data/tests/test_utils.py @@ -1,53 +1,7 @@ -import logging -from io import StringIO -from unittest.mock import patch from model_data.utils import is_percentage_or_number, correct_spelling -from utils.logger import setup_logger -class TestLogger: - def test_setup_logger_default(self): - log_stream = StringIO() - handler = logging.StreamHandler(log_stream) - logger = setup_logger() - logger.addHandler(handler) - - # log something - logger.info("Hello World!") - - log_stream.seek(0) - # assert that log was written - assert log_stream.read() == "Hello World!\n" - # remove the handler after use - logger.removeHandler(handler) - - @patch('logging.FileHandler') - def test_setup_logger_file(self, mock_file_handler): - # setup the logger - logger = setup_logger(log_file='test.log', overwrite_handler=True) - - # assert FileHandler was called correctly - mock_file_handler.assert_called_once_with('test.log') - - # clean up after use - for handler in logger.handlers[:]: - handler.close() - logger.removeHandler(handler) - - def test_setup_logger_loglevel(self): - log_stream = StringIO() - handler = logging.StreamHandler(log_stream) - logger = setup_logger(level=logging.DEBUG) - logger.addHandler(handler) - - # log something - logger.debug("Hello World!") - - log_stream.seek(0) - # assert that log was written - assert log_stream.read() == "Hello World!\n" - # remove the handler after use - logger.removeHandler(handler) +class TestUtils: def test_is_percentage_or_number(self): assert is_percentage_or_number("88") diff --git a/model_data/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py similarity index 96% rename from model_data/recommendations/FloorRecommendations.py rename to recommendations/FloorRecommendations.py index 1d15ad00..acbc0ddf 100644 --- a/model_data/recommendations/FloorRecommendations.py +++ b/recommendations/FloorRecommendations.py @@ -1,9 +1,8 @@ import math from model_data.BaseUtility import BaseUtility from model_data.Property import Property -from model_data.analysis.UvalueEstimations import UvalueEstimations from model_data.rdsap_tables import default_wall_thickness, age_band_data -from model_data.recommendations.recommendation_utils import ( +from recommendations.recommendation_utils import ( r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns, update_lowest_selected_u_value, get_recommended_part ) @@ -92,7 +91,7 @@ class FloorRecommendations(BaseUtility): "4th": 4 } - def __init__(self, property_instance: Property, uvalue_estimates: UvalueEstimations): + def __init__(self, property_instance: Property, uvalue_estimates): self.property = property_instance self.uvalue_estimates = uvalue_estimates # For audit purposes, when estimating u values we'll store it @@ -253,7 +252,7 @@ class FloorRecommendations(BaseUtility): # Given the U-value, we recommend solid floor insulation options which are usually solid foam self.recommend_floor_insulation(u_value=u_value, parts=solid_floor_insulation_parts) - def _get_floors_uvalue_estimate(self): + def _get_floors_uvalue_estimate(self, total_floor_area_group_decile): """ Wrapper function which contains the methodology to extract a property's walls u-value estimate @@ -261,11 +260,11 @@ class FloorRecommendations(BaseUtility): :return: """ - total_floor_area_group_decile = self.uvalue_estimates.classify_decile_newvalues( - decile_boundaries=self.uvalue_estimates.floors_decile_data["decile_boundaries"], - decile_labels=self.uvalue_estimates.floors_decile_data["decile_labels"], - new_values=[float(self.property.data["total-floor-area"])], - )[0] + # total_floor_area_group_decile = UvalueEstimations.classify_decile_newvalues( + # decile_boundaries=self.uvalue_estimates.floors_decile_data["decile_boundaries"], + # decile_labels=self.uvalue_estimates.floors_decile_data["decile_labels"], + # new_values=[float(self.property.data["total-floor-area"])], + # )[0] u_value_estimate = self.uvalue_estimates.floors[ (self.uvalue_estimates.floors["local-authority"] == self.property.data["local-authority"]) & diff --git a/model_data/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py similarity index 99% rename from model_data/recommendations/WallRecommendations.py rename to recommendations/WallRecommendations.py index aaeb777a..be618433 100644 --- a/model_data/recommendations/WallRecommendations.py +++ b/recommendations/WallRecommendations.py @@ -2,9 +2,8 @@ import itertools import math from model_data.Property import Property -from model_data.analysis.UvalueEstimations import UvalueEstimations from model_data.BaseUtility import BaseUtility -from model_data.recommendations.recommendation_utils import ( +from recommendations.recommendation_utils import ( r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns, update_lowest_selected_u_value, get_recommended_part ) @@ -217,7 +216,7 @@ class WallRecommendations(BaseUtility): "solid_brick": 2, } - def __init__(self, property_instance: Property, uvalue_estimates: UvalueEstimations): + def __init__(self, property_instance: Property, uvalue_estimates): self.property = property_instance self.uvalue_estimates = uvalue_estimates # For audit purposes, when estimating u values we'll store it diff --git a/model_data/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py similarity index 100% rename from model_data/recommendations/recommendation_utils.py rename to recommendations/recommendation_utils.py diff --git a/model_data/tests/test_floor_recommendations.py b/recommendations/tests/test_floor_recommendations.py similarity index 100% rename from model_data/tests/test_floor_recommendations.py rename to recommendations/tests/test_floor_recommendations.py diff --git a/model_data/tests/test_wall_recommendations.py b/recommendations/tests/test_wall_recommendations.py similarity index 100% rename from model_data/tests/test_wall_recommendations.py rename to recommendations/tests/test_wall_recommendations.py diff --git a/utils/tests/test_logger.py b/utils/tests/test_logger.py new file mode 100644 index 00000000..c7e64dc5 --- /dev/null +++ b/utils/tests/test_logger.py @@ -0,0 +1,49 @@ +import logging +from io import StringIO +from unittest.mock import patch +from utils.logger import setup_logger + + +class TestLogger: + def test_setup_logger_default(self): + log_stream = StringIO() + handler = logging.StreamHandler(log_stream) + logger = setup_logger() + logger.addHandler(handler) + + # log something + logger.info("Hello World!") + + log_stream.seek(0) + # assert that log was written + assert log_stream.read() == "Hello World!\n" + # remove the handler after use + logger.removeHandler(handler) + + @patch('logging.FileHandler') + def test_setup_logger_file(self, mock_file_handler): + # setup the logger + logger = setup_logger(log_file='test.log', overwrite_handler=True) + + # assert FileHandler was called correctly + mock_file_handler.assert_called_once_with('test.log') + + # clean up after use + for handler in logger.handlers[:]: + handler.close() + logger.removeHandler(handler) + + def test_setup_logger_loglevel(self): + log_stream = StringIO() + handler = logging.StreamHandler(log_stream) + logger = setup_logger(level=logging.DEBUG) + logger.addHandler(handler) + + # log something + logger.debug("Hello World!") + + log_stream.seek(0) + # assert that log was written + assert log_stream.read() == "Hello World!\n" + # remove the handler after use + logger.removeHandler(handler) diff --git a/utils/tests/test_uvalue_estimates.py b/utils/tests/test_uvalue_estimates.py new file mode 100644 index 00000000..1b29994c --- /dev/null +++ b/utils/tests/test_uvalue_estimates.py @@ -0,0 +1,80 @@ +from utils.uvalue_estimates import classify_decile_newvalues + + +def test_classify_decile_newvalues_edge_cases(): + decile_labels = [f"Decile {i + 1}" for i in range(10)] + decile_boundaries = list(range(11)) + + # Test with values at the exact boundaries + assert classify_decile_newvalues(decile_boundaries, decile_labels, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) == ['Decile 1', + 'Decile 2', + 'Decile 3', + 'Decile 4', + 'Decile 5', + 'Decile 6', + 'Decile 7', + 'Decile 8', + 'Decile 9', + 'Decile 10'] + + # Test with values at the exact boundaries, but in reverse order + assert classify_decile_newvalues(decile_boundaries, decile_labels, [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]) == ['Decile 10', + 'Decile 9', + 'Decile 8', + 'Decile 7', + 'Decile 6', + 'Decile 5', + 'Decile 4', + 'Decile 3', + 'Decile 2', + 'Decile 1'] + + # Test with values just below the boundaries + assert classify_decile_newvalues(decile_boundaries, decile_labels, [x - 0.5 for x in range(2, 12)]) == ['Decile 1', + 'Decile 2', + 'Decile 3', + 'Decile 4', + 'Decile 5', + 'Decile 6', + 'Decile 7', + 'Decile 8', + 'Decile 9', + 'Decile 10'] + + # Test with values just above the boundaries + assert classify_decile_newvalues(decile_boundaries, decile_labels, [x + 0.5 for x in range(1, 11)]) == ['Decile 2', + 'Decile 3', + 'Decile 4', + 'Decile 5', + 'Decile 6', + 'Decile 7', + 'Decile 8', + 'Decile 9', + 'Decile 10', + None] + + # Test with empty list + assert classify_decile_newvalues(decile_boundaries, decile_labels, []) == [] + + # Test with a single value + assert classify_decile_newvalues(decile_boundaries, decile_labels, [5.5]) == ['Decile 6'] + + # Test with all values the same + assert classify_decile_newvalues(decile_boundaries, decile_labels, [5, 5, 5, 5, 5]) == ['Decile 5', 'Decile 5', + 'Decile 5', 'Decile 5', + 'Decile 5'] + + # Test with values out of order + assert classify_decile_newvalues(decile_boundaries, decile_labels, [10, 5, 1, 7, 3]) == ['Decile 10', 'Decile 5', + 'Decile 1', 'Decile 7', + 'Decile 3'] + + # Test with negative decile boundaries + decile_boundaries = list(range(-10, 1)) + assert classify_decile_newvalues(decile_boundaries, decile_labels, [-9, -5, -1]) == ['Decile 2', 'Decile 6', + 'Decile 10'] + + # Test with floating point decile boundaries + decile_boundaries = [x / 10 for x in range(11)] + assert classify_decile_newvalues(decile_boundaries, decile_labels, [0.35, 0.55, 0.75]) == ['Decile 4', 'Decile 6', + 'Decile 8'] diff --git a/utils/uvalue_estimates.py b/utils/uvalue_estimates.py new file mode 100644 index 00000000..37dd2b38 --- /dev/null +++ b/utils/uvalue_estimates.py @@ -0,0 +1,27 @@ +from typing import List + +from bisect import bisect_left + + +def classify_decile_newvalues( + decile_boundaries: List[float], decile_labels: List[str], new_values: List[float] +) -> List[str]: + """ + This is a complementary function to UvalueEstimations.classify_decile_newvalues, which does not use pandas + so that we can use this function inside of our lamdbas, without having to import pandas. + :param decile_boundaries: + :param decile_labels: + :param new_values: + :return: + """ + + classifications = [] + for value in new_values: + if value < decile_boundaries[0] or value > decile_boundaries[-1]: + classifications.append(None) + else: + i = bisect_left(decile_boundaries, value) + if i: + i -= 1 + classifications.append(decile_labels[i]) + return classifications