mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
restructuing property class to move more property level attributes to the Property class
This commit is contained in:
parent
13c1e50126
commit
e2633dfa5b
11 changed files with 107 additions and 202 deletions
2
.idea/Model.iml
generated
2
.idea/Model.iml
generated
|
|
@ -7,7 +7,7 @@
|
|||
<sourceFolder url="file://$MODULE_DIR$/open_uprn" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/recommendations" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.10 (model_data)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.10 (backend)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyNamespacePackagesService">
|
||||
|
|
|
|||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (model_data)" project-jdk-type="Python SDK" />
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (backend)" project-jdk-type="Python SDK" />
|
||||
<component name="PythonCompatibilityInspectionAdvertiser">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from utils.s3 import read_dataframe_from_s3_parquet
|
|||
from epc_api.client import EpcClient
|
||||
from BaseUtility import Definitions
|
||||
from recommendations.rdsap_tables import england_wales_age_band_lookup
|
||||
from recommendations.recommendation_utils import estimate_floors, estimate_perimeter, get_wall_type
|
||||
|
||||
ENVIRONMENT = os.environ.get('ENVIRONMENT', 'dev')
|
||||
EPC_AUTH_TOKEN = os.environ.get('EPC_AUTH_TOKEN')
|
||||
|
|
@ -41,7 +42,6 @@ class Property(Definitions):
|
|||
lighting = None
|
||||
|
||||
coordinates = None
|
||||
age_band = None
|
||||
|
||||
def __init__(self, id, postcode, address1, epc_client=None, data=None):
|
||||
self.id = id
|
||||
|
|
@ -54,6 +54,10 @@ class Property(Definitions):
|
|||
self.restricted_measures = False
|
||||
self.year_built = None
|
||||
self.number_of_rooms = None
|
||||
self.age_band = None
|
||||
self.number_of_floors = None
|
||||
self.perimeter = None
|
||||
self.wall_type = None
|
||||
|
||||
self.energy = None
|
||||
self.ventilation = None
|
||||
|
|
@ -263,6 +267,10 @@ class Property(Definitions):
|
|||
self.set_floor_area()
|
||||
self.set_age_band()
|
||||
|
||||
self.set_number_floors()
|
||||
self.set_perimeter()
|
||||
self.set_wall_type()
|
||||
|
||||
for description, attribute in cleaned.items():
|
||||
|
||||
if self.data[description] in self.DATA_ANOMALY_MATCHES:
|
||||
|
|
@ -467,11 +475,9 @@ class Property(Definitions):
|
|||
"""
|
||||
This method is placeholder
|
||||
It implements our floor area model to produce an estimate of the property's insulatable wall area
|
||||
While we do not have the
|
||||
"""
|
||||
|
||||
import random
|
||||
self.insulation_wall_area = random.uniform(60, 100)
|
||||
|
||||
def set_floor_area(self):
|
||||
"""
|
||||
Sets the floor area based on the EPC data
|
||||
|
|
@ -508,3 +514,44 @@ class Property(Definitions):
|
|||
|
||||
# Pull out spatial features
|
||||
self.set_spatial(spatial)
|
||||
|
||||
def set_number_floors(self):
|
||||
"""
|
||||
This method sets the number of floors of the property, using a simple approach based on an estimate for
|
||||
average room size, number of rooms and total floor area
|
||||
:return:
|
||||
"""
|
||||
|
||||
total_floor_area = float(self.data["total-floor-area"])
|
||||
number_of_rooms = float(self.data["number-habitable-rooms"])
|
||||
|
||||
if self.data["property-type"] == "House":
|
||||
self.number_of_floors = estimate_floors(total_floor_area, number_of_rooms)
|
||||
elif self.data["property-type"] == "Flat":
|
||||
self.number_of_floors = 1
|
||||
else:
|
||||
raise NotImplementedError("Implement me")
|
||||
|
||||
def set_perimeter(self):
|
||||
"""
|
||||
This method sets the perimeter of the property, using a simple approach based on average room
|
||||
size, number of rooms and total floor area
|
||||
:return:
|
||||
"""
|
||||
|
||||
if not self.number_of_floors:
|
||||
raise ValueError("Number of floors not set, run set_number_floors")
|
||||
|
||||
total_floor_area = float(self.data["total-floor-area"])
|
||||
number_of_rooms = float(self.data["number-habitable-rooms"])
|
||||
|
||||
self.perimeter = estimate_perimeter(
|
||||
total_floor_area / self.number_of_floors, number_of_rooms / self.number_of_floors
|
||||
)
|
||||
|
||||
def set_wall_type(self):
|
||||
"""
|
||||
This method sets the wall type of the property, using a simple approach based on the wall description
|
||||
:return:
|
||||
"""
|
||||
self.wall_type = get_wall_type(**self.walls)
|
||||
|
|
|
|||
|
|
@ -151,7 +151,6 @@ async def trigger_plan(body: PlanTriggerRequest):
|
|||
|
||||
if not property_recommendations:
|
||||
continue
|
||||
fewf
|
||||
|
||||
recommendations[p.id] = property_recommendations
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from etl.epc.settings import (
|
|||
EARLIEST_EPC_DATE,
|
||||
FULLY_GLAZED_DESCRIPTIONS,
|
||||
AVERAGE_FIXED_FEATURES,
|
||||
FLOOR_LEVEL_MAP,
|
||||
BUILT_FORM_REMAP,
|
||||
COLUMNS_TO_MERGE_ON,
|
||||
COMPONENT_FEATURES,
|
||||
|
|
@ -17,6 +16,7 @@ from etl.epc.settings import (
|
|||
MAX_SAP_SCORE,
|
||||
fill_na_map,
|
||||
)
|
||||
from recommendations.rdsap_tables import FLOOR_LEVEL_MAP
|
||||
|
||||
from typing import List
|
||||
|
||||
|
|
|
|||
|
|
@ -133,28 +133,6 @@ RDSAP_RESPONSE = "CURRENT_ENERGY_EFFICIENCY"
|
|||
HEAT_DEMAND_RESPONSE = "ENERGY_CONSUMPTION_CURRENT"
|
||||
CARBON_RESPONSE = "CO2_EMISSIONS_CURRENT"
|
||||
|
||||
|
||||
def ordinal(n):
|
||||
if 10 <= n % 100 <= 20:
|
||||
suffix = "th"
|
||||
else:
|
||||
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
|
||||
|
||||
return str(n) + suffix
|
||||
|
||||
|
||||
FLOOR_LEVEL_MAP = {
|
||||
"Basement": -1,
|
||||
"Ground": 0,
|
||||
"ground floor": 0,
|
||||
"20+": 20,
|
||||
"21st or above": 21,
|
||||
**{str(i).zfill(2): i for i in range(0, 21)},
|
||||
**{ordinal(i): i for i in range(-1, 21)},
|
||||
**{str(i): i for i in range(-1, 21)},
|
||||
**{i: i for i in range(-1, 21)},
|
||||
}
|
||||
|
||||
BUILT_FORM_REMAP = {
|
||||
"Enclosed End-Terrace": "End-Terrace",
|
||||
"Enclosed Mid-Terrace": "Mid-Terrace",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ from datatypes.enums import QuantityUnits
|
|||
from backend.Property import Property
|
||||
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, estimate_perimeter, get_wall_type,
|
||||
get_floor_u_value
|
||||
get_recommended_part, get_floor_u_value
|
||||
)
|
||||
from recommendations.rdsap_tables import FLOOR_LEVEL_MAP
|
||||
|
||||
|
||||
class FloorRecommendations(Definitions):
|
||||
|
|
@ -24,18 +24,6 @@ class FloorRecommendations(Definitions):
|
|||
|
||||
PART_L_YEAR_CUTOFF = 2002
|
||||
|
||||
# TODO: This is a placeholder methodology which isn't particularly scalable as more
|
||||
# unusual floor descriptions are introduced
|
||||
FLOOR_LEVELS = {
|
||||
"Ground": 0,
|
||||
# We don't know what floor level, we just make sure it's not 0
|
||||
"mid floor": 1,
|
||||
"4th": 4,
|
||||
# We set
|
||||
"00": 0,
|
||||
"3rd": 3
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
property_instance: Property,
|
||||
|
|
@ -60,10 +48,9 @@ class FloorRecommendations(Definitions):
|
|||
def recommend(self):
|
||||
u_value = self.property.floor["thermal_transmittance"]
|
||||
is_suspended = self.property.floor["is_suspended"]
|
||||
insulation_thickness = self.property.floor["insulation_thickness"]
|
||||
is_solid = self.property.floor["is_solid"]
|
||||
floor_level = (
|
||||
self.FLOOR_LEVELS[self.property.data["floor-level"]] if
|
||||
FLOOR_LEVEL_MAP[self.property.data["floor-level"]] if
|
||||
self.property.data["floor-level"] not in self.DATA_ANOMALY_MATCHES else None
|
||||
)
|
||||
property_type = self.property.data["property-type"]
|
||||
|
|
@ -91,27 +78,13 @@ class FloorRecommendations(Definitions):
|
|||
# The floor is already compliant
|
||||
return
|
||||
|
||||
total_floor_area = float(self.property.data["total-floor-area"])
|
||||
number_of_rooms = float(self.property.data["number-habitable-rooms"])
|
||||
|
||||
if self.property.data["property-type"] == "House":
|
||||
num_floors = self._estimate_floors(total_floor_area, number_of_rooms)
|
||||
elif self.property.data["property-type"] == "Flat":
|
||||
num_floors = 1
|
||||
else:
|
||||
raise NotImplementedError("Implement me")
|
||||
|
||||
estimated_perimeter = estimate_perimeter(total_floor_area / num_floors, number_of_rooms / num_floors)
|
||||
|
||||
wall_type = get_wall_type(**self.property.walls)
|
||||
|
||||
u_value = get_floor_u_value(
|
||||
floor_type="suspended" if is_suspended else "solid",
|
||||
area=total_floor_area,
|
||||
perimeter=estimated_perimeter,
|
||||
area=float(self.property.data["total-floor-area"]),
|
||||
perimeter=self.property.perimeter,
|
||||
age_band=self.property.age_band,
|
||||
insulation_thickness=insulation_thickness,
|
||||
wall_type=wall_type
|
||||
insulation_thickness=self.property.floor["insulation_thickness"],
|
||||
wall_type=self.property.wall_type
|
||||
)
|
||||
self.estimated_u_value = u_value
|
||||
|
||||
|
|
@ -170,22 +143,3 @@ class FloorRecommendations(Definitions):
|
|||
"cost": estimated_cost,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _estimate_floors(floor_area, num_rooms):
|
||||
"""
|
||||
Simple utility funciton, which assuming a 15m squared room, estimates the number of floors in a property
|
||||
:param floor_area: Gross floor area of a property
|
||||
:param num_rooms: Number of rooms in a property
|
||||
:return: Number of floors in a property
|
||||
"""
|
||||
# Estimate total room area
|
||||
total_room_area = num_rooms * 15
|
||||
|
||||
# Estimate the number of floors
|
||||
floors = floor_area / total_room_area
|
||||
|
||||
# Round up to the nearest whole number
|
||||
floors = round(floors)
|
||||
|
||||
return floors
|
||||
|
|
|
|||
|
|
@ -462,3 +462,30 @@ s11_list = [
|
|||
]
|
||||
|
||||
table_s11 = pd.DataFrame(s11_list)
|
||||
|
||||
|
||||
########################################################################################################################
|
||||
#
|
||||
# this map is used to clean the floor value field we see in EPCs
|
||||
#
|
||||
########################################################################################################################
|
||||
def ordinal(n):
|
||||
if 10 <= n % 100 <= 20:
|
||||
suffix = "th"
|
||||
else:
|
||||
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
|
||||
|
||||
return str(n) + suffix
|
||||
|
||||
|
||||
FLOOR_LEVEL_MAP = {
|
||||
"Basement": -1,
|
||||
"Ground": 0,
|
||||
"ground floor": 0,
|
||||
"20+": 20,
|
||||
"21st or above": 21,
|
||||
**{str(i).zfill(2): i for i in range(0, 21)},
|
||||
**{ordinal(i): i for i in range(-1, 21)},
|
||||
**{str(i): i for i in range(-1, 21)},
|
||||
**{i: i for i in range(-1, 21)},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -476,3 +476,22 @@ def get_wall_type(
|
|||
return "park home"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def estimate_floors(floor_area, num_rooms):
|
||||
"""
|
||||
Simple utility funciton, which assuming a 15m squared room, estimates the number of floors in a property
|
||||
:param floor_area: Gross floor area of a property
|
||||
:param num_rooms: Number of rooms in a property
|
||||
:return: Number of floors in a property
|
||||
"""
|
||||
# Estimate total room area
|
||||
total_room_area = num_rooms * 15
|
||||
|
||||
# Estimate the number of floors
|
||||
floors = floor_area / total_room_area
|
||||
|
||||
# Round up to the nearest whole number
|
||||
floors = round(floors)
|
||||
|
||||
return floors
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
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']
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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]:
|
||||
"""
|
||||
Classify a list of new values into pre-established deciles.
|
||||
|
||||
This function is an alternative to UvalueEstimations.classify_decile_newvalues that does not depend on pandas,
|
||||
making it suitable for use in environments where pandas may not be available (such as AWS Lambda).
|
||||
|
||||
:param decile_boundaries: A list of decile boundaries. These define the ranges of the deciles.
|
||||
:param decile_labels: A list of labels for the deciles. These are the classifications to be assigned to the values.
|
||||
:param new_values: A list of new values to be classified into the deciles.
|
||||
|
||||
:return: A list of classifications for the new values. Each classification corresponds to the decile in which
|
||||
the respective new value falls. If a value falls outside the range of the deciles, its classification is
|
||||
None.
|
||||
"""
|
||||
classifications = []
|
||||
|
||||
# For each new value...
|
||||
for value in new_values:
|
||||
# If the value is outside the range of the deciles, classify it as None
|
||||
if value < decile_boundaries[0] or value > decile_boundaries[-1]:
|
||||
classifications.append(None)
|
||||
else:
|
||||
# Use bisect_left to find the decile in which the value falls
|
||||
i = bisect_left(decile_boundaries, value)
|
||||
# If the value falls exactly on a decile boundary, classify it in the lower decile
|
||||
if i:
|
||||
i -= 1
|
||||
# Append the classification to the list of classifications
|
||||
classifications.append(decile_labels[i])
|
||||
return classifications
|
||||
|
||||
Loading…
Add table
Reference in a new issue