From 74ce1627ec934c1f023c4e025bf0a89f99ac7f82 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 17 Jan 2026 13:00:00 +0000 Subject: [PATCH 01/74] peabody specific --- .../k_deck_stats.py | 63 +++++++++-- .../n_fixing_already_installed_bug.py | 3 +- .../o_rerunning_iwi_jobs.py | 41 +++++++ sfr/principal_pitch/2_export_data.py | 101 ++++++++++++++++-- 4 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py b/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py index cd7fba63..b6fc0f8f 100644 --- a/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py +++ b/etl/customers/peabody/Nov 2025 Consulting Project/k_deck_stats.py @@ -114,14 +114,16 @@ from backend.app.db.models.recommendations import Recommendation, Plan, PlanReco from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel from collections import defaultdict -PORTFOLIO_ID = 434 # Peabody +PORTFOLIO_ID = 435 # Peabody SCENARIOS = [ - 904, - 905 + 908, + 909, + 910, ] scenario_names = { - 904: "EPC C - no solid floor, ashp 3.0", - 905: "EPC B - no solid floor, ashp 3.0", + 908: "EPC C - no solid floor, ashp 3.0", + 909: "EPC C - no solid floor, no EWI or IWI, ashp 3.0", + 910: "EPC B - no solid floor, no EWI, ashp 3.0" } @@ -232,9 +234,58 @@ properties_data, plans_data, recommendations_data = get_data( recommendations_df = pd.DataFrame(recommendations_data) properties_df = pd.DataFrame(properties_data) +plans_df = pd.DataFrame(plans_data) -solar_pv_recommendations = recommendations_df[recommendations_df["measure_type"] == "solar_pv"] +s_id = 910 +ps_w_a_plan = plans_df[plans_df["scenario_id"] == s_id].copy() +# Take the newest by scenario id +ps_w_a_plan = ps_w_a_plan.sort_values("created_at", ascending=False).drop_duplicates( + subset=["property_id"] +) +z = ps_w_a_plan[ + ps_w_a_plan["cost_of_works"] > 0 + ].copy() +z2 = properties_df[properties_df["property_id"].isin(z["property_id"].values)] +# '', 'hot_water_cost_current', +# 'lighting_cost_current', 'appliances_cost_current', +# 'gas_standing_charge', 'electricity_standing_charge' +z2["total_bills"] = z2["heating_cost_current"] + z2["hot_water_cost_current"] + z2["lighting_cost_current"] + z2[ + "appliances_cost_current" +] + z2["gas_standing_charge"] + z2["electricity_standing_charge"] + +from tqdm import tqdm + +# For a property ID, find a property where the no EWI/IWI approach is more expensive than the EWI approach +pids = properties_df["property_id"].unique() +for pid in tqdm(pids): + + if pid in [603272, 550550, 574493]: + continue + + # get the plans + property_plan = plans_df[plans_df["property_id"] == int(pid)] + # Take the newest plan by scenario id + property_plan = property_plan.sort_values("created_at", ascending=False).drop_duplicates( + subset=["scenario_id"] + ) + a = property_plan[property_plan["scenario_id"] == 909].squeeze() # no EWI/IWI + b = property_plan[property_plan["scenario_id"] == 908].squeeze() # EWI + if (a["cost_of_works"] > b["cost_of_works"]) and ( + a["post_epc_rating"].value == "C") and (b["cost_of_works"] > 5000): + bah + +solar_pv_recommendations = recommendations_df[ + recommendations_df["measure_type"] == "solar_pv" + ] + +solid_wall_recommendation = recommendations_df[ + recommendations_df["scenario_id"].isin([908]) & + recommendations_df["measure_type"].isin(["internal_wall_insulation"]) & + recommendations_df["default"] + ] average_savings = solar_pv_recommendations.groupby("scenario_id")["energy_cost_savings"].mean().reset_index() +# Add on scenarion names +average_savings["scenario_name"] = average_savings["scenario_id"].map(scenario_names) # Check tenures initial_asset_data = pd.read_excel( diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py b/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py index 4bd11a1b..d07f1ac1 100644 --- a/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py +++ b/etl/customers/peabody/Nov 2025 Consulting Project/n_fixing_already_installed_bug.py @@ -11,7 +11,6 @@ from etl.customers.cambridge.surveys import current_epc with db_session() as session: # We need installed measures, where the measure type is ewi or iwi installed_measures = session.query(InstalledMeasure).filter( - InstalledMeasure.measure_type.in_(["cavity_wall_insulation"]) ).all() # Get the uprns installed_uprns = [x.uprn for x in installed_measures] @@ -32,7 +31,7 @@ needing_retry = sal[sal["epc_os_uprn"].isin(installed_uprns)] # Store needing_retry.to_excel( "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final " - "SAL/properties_needing_retry_20260115 - cavity wall insulation.xlsx", + "SAL/properties_needing_retry_20260115 - all already installed.xlsx", sheet_name="Standardised Asset List", index=False ) diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py b/etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py new file mode 100644 index 00000000..39eccb0b --- /dev/null +++ b/etl/customers/peabody/Nov 2025 Consulting Project/o_rerunning_iwi_jobs.py @@ -0,0 +1,41 @@ +# get all properties that have an IWI recommendation +import pandas as pd + +r1 = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/EPC B - no " + "solid floor, no EWI, ashp 3.0 - 20250113 final.xlsx" +) + +r2 = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/EPC C - no " + "solid floor, ashp 3.0 - 20250113 final.xlsx" +) + +r3 = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/EPC C - no " + "solid floor, no EWI or IWI, ashp 3.0 - 20250113 final.xlsx" +) + +s1 = r1[~pd.isnull(r1["internal_wall_insulation"])] +s2 = r2[~pd.isnull(r2["internal_wall_insulation"])] + +# Combined uprns +uprns = s1["uprn"].tolist() + s2["uprn"].tolist() +uprns = list(set(uprns)) + +# Create SAL of these uprns +sal = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final SAL/20260113 - " + "final asset list.xlsx", + sheet_name="Standardised Asset List" +) + +needing_retry = sal[sal["epc_os_uprn"].isin(uprns)] + +# Store +needing_retry.to_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final " + "SAL/properties_needing_retry_20260115 - internal wall insulation.xlsx", + sheet_name="Standardised Asset List", + index=False +) diff --git a/sfr/principal_pitch/2_export_data.py b/sfr/principal_pitch/2_export_data.py index f12eb85d..2184d074 100644 --- a/sfr/principal_pitch/2_export_data.py +++ b/sfr/principal_pitch/2_export_data.py @@ -11,6 +11,7 @@ from backend.app.db.models.recommendations import Recommendation, Plan, PlanReco from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel, PropertyDetailsSpatial from backend.app.db.functions.materials_functions import get_materials from collections import defaultdict +from sqlalchemy import func # PORTFOLIO_ID = 206 # SCENARIOS = [389] @@ -57,9 +58,44 @@ def get_data(portfolio_id, scenario_ids): # -------------------- # Plans # -------------------- - plans_query = session.query(Plan).filter( - Plan.scenario_id.in_(scenario_ids) - ).all() + latest_plans_subq = ( + session.query( + Plan.scenario_id, + Plan.property_id, + func.max(Plan.created_at).label("latest_created_at") + ) + .filter(Plan.scenario_id.in_(scenario_ids)) + .group_by( + Plan.scenario_id, + Plan.property_id + ) + .subquery() + ) + + # plans_query = session.query(Plan).filter( + # Plan.scenario_id.in_(scenario_ids) + # ).all() + + plans_query = ( + session.query(Plan) + .join( + latest_plans_subq, + (Plan.scenario_id == latest_plans_subq.c.scenario_id) & + (Plan.property_id == latest_plans_subq.c.property_id) & + (Plan.created_at == latest_plans_subq.c.latest_created_at) + ) + .all() + ) + + # plans_query = ( + # session.query(Plan) + # .join( + # latest_plans_subq, + # (Plan.scenario_id == latest_plans_subq.c.scenario_id) & + # (Plan.created_at == latest_plans_subq.c.latest_created_at) + # ) + # .all() + # ) plans_data = [ {col.name: getattr(plan, col.name) for col in Plan.__table__.columns} @@ -73,7 +109,8 @@ def get_data(portfolio_id, scenario_ids): # -------------------- recommendations_query = session.query( Recommendation, - Plan.scenario_id + Plan.scenario_id, + PlanRecommendations.plan_id ).join( PlanRecommendations, Recommendation.id == PlanRecommendations.recommendation_id @@ -216,6 +253,7 @@ for scenario_id in SCENARIOS: [ "landlord_property_id", "property_id", "uprn", "address", "postcode", "property_type", "walls", "roof", "heating", "windows", "current_epc_rating", "current_sap_points", "total_floor_area", "number_of_rooms", + "id" ] ].merge( recommendations_measures_pivot, how="left", on="property_id" @@ -223,17 +261,42 @@ for scenario_id in SCENARIOS: post_install_sap, how="left", on="property_id" ) - df = df.drop(columns=["property_id"]) + # df = df.drop(columns=["property_id"]) df["sap_points"] = df["sap_points"].fillna(0) df["predicted_post_works_sap"] = df["current_sap_points"] + df["sap_points"] - df["predicted_post_works_sap"] = df["predicted_post_works_sap"].round() + df["predicted_post_works_sap"] = df["predicted_post_works_sap"] df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply(lambda x: sap_to_epc(x)) df["uprn"] = df["uprn"].astype(str) + relevant_plans = plans_df[plans_df["scenario_id"] == scenario_id] + df2 = df.merge( + relevant_plans[["property_id", "post_sap_points", "post_epc_rating"]], how="left", on="property_id", + suffixes=("", "_plan") + ) + print(df2["predicted_post_works_epc"].value_counts()) + print(df2["post_epc_rating"].value_counts()) + + z = df2[ + (df2["predicted_post_works_epc"] != "D") & + (df2["post_epc_rating"].astype(str) == "Epc.D") + ] + + df2["predicted_post_works_epc"].value_counts() + df2["post_epc_rating"].astype(str).value_counts() + + df2[df2["total_retrofit_cost"] > 0].shape + + getting_works = df[df["total_retrofit_cost"] > 0] + getting_works["predicted_post_works_epc"].value_counts() + + 32565 / getting_works.shape[0] + + df[df["predicted_post_works_sap"] == ""] + # Create excel to store to filename = ("/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting " - f"Project/Final SAL/{scenario_names[scenario_id]} - 20250113 final.xlsx") + f"Project/Final SAL/scenarios/{scenario_names[scenario_id]} - 20250114 final.xlsx") with pd.ExcelWriter(filename) as writer: df.to_excel(writer, sheet_name="properties", index=False) @@ -388,3 +451,27 @@ asset_list.to_excel( condition_cost_comparison = asset_list[ ["condition_score", "decoration_sum_min ", "decoration_sum_max", "domna_condition_cost"] ] + +# Testing +plans_df.head() + +example = pd.read_excel( + "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/Final " + "SAL/scenarios/EPC C - no solid floor, no EWI or IWI, ashp 3.0 - 20250114 final.xlsx" +) + +plans_df2 = plans_df.merge( + properties_df[["property_id", "landlord_property_id"]], + left_on="property_id", + right_on="property_id", + how="left" +) + +plans_df2 = plans_df2[plans_df2["scenario_id"] == 909] + +dupes = plans_df2[plans_df2["property_id"].duplicated()] + +# merge on plans +example = example.merge( + plans_df, how="left", +) From 2b071e6afd70334015394a0eec7e0df594c68fd8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 20 Jan 2026 10:54:22 +0000 Subject: [PATCH 02/74] added initial test for recommendation impact calculation with adjustment --- backend/onboarders/parity.py | 18 +- recommendations/Recommendations.py | 127 ++- recommendations/tests/test_recommendations.py | 843 ++++++++++++++++++ 3 files changed, 960 insertions(+), 28 deletions(-) create mode 100644 recommendations/tests/test_recommendations.py diff --git a/backend/onboarders/parity.py b/backend/onboarders/parity.py index f41ebeaf..27244777 100644 --- a/backend/onboarders/parity.py +++ b/backend/onboarders/parity.py @@ -65,7 +65,7 @@ data["Wall Insulation"].value_counts() data["Wall Construction"].value_counts() as_built_map = { - "Cavity": {"insulated_age_bands":[], "partial_insulated_age_bands": []}, + "Cavity": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, "Solid Brick": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, "System": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, "Timber Frame": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, @@ -74,6 +74,7 @@ as_built_map = { "Cob": {"insulated_age_bands": [], "partial_insulated_age_bands": []}, } + def map_wall_construction(wall_constuction, wall_insulation, construction_age_band): if wall_insulation == "AsBuilt": # Deduce based on wall construction and age band @@ -83,13 +84,10 @@ def map_wall_construction(wall_constuction, wall_insulation, construction_age_ba # We check if the age band is in insulated or partial insulated, and if neither, we assume uninsulated - - - # Variables we want to map -'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type', - 'Attachment', 'Construction Years', 'Wall Construction', - 'Wall Insulation', 'Roof Construction', 'Roof Insulation', - 'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating', - 'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN', - 'Total Floor Area (m2)' \ No newline at end of file +# 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type', +# 'Attachment', 'Construction Years', 'Wall Construction', +# 'Wall Insulation', 'Roof Construction', 'Roof Insulation', +# 'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating', +# 'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN', +# 'Total Floor Area (m2)' diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index ab13134d..d3467919 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -486,6 +486,34 @@ class Recommendations: return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction + @staticmethod + def _check_veniltation_out_of_bounds(sap_impact, ventilation_sap_limit): + return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0) + + @staticmethod + def _adjust_ventilation_sap(sap_impact, ventilation_sap_limit): + if sap_impact >= 0: + return -1 + if sap_impact < ventilation_sap_limit: + return ventilation_sap_limit + + @staticmethod + def _filter_phase_adjustment(phase_adjustments): + """ + Utility function to select the entry from the dictionary, by phase, with the largest + phase adjustment + :param phase_adjustments: List of phase adjustments, in the form + [{"recommendation_id": str, "phase": int, "adjustment_amount": float}] + :return: + """ + filtered_adjustments = [] + phase_adjustments = sorted(phase_adjustments, key=lambda x: x["phase"]) + for phase, adjustments in groupby(phase_adjustments, key=lambda x: x["phase"]): + adjustments = list(adjustments) + adjustments.sort(key=lambda x: x["sap_adjustment"], reverse=True) + filtered_adjustments.append(adjustments[0]) + return filtered_adjustments + @classmethod def calculate_recommendation_impact( cls, @@ -493,6 +521,7 @@ class Recommendations: all_predictions, recommendations, representative_recommendations, + debug=False ): """ @@ -507,6 +536,9 @@ class Recommendations: :param all_predictions: dictionary of predictions from the model apis :param recommendations: dictionary of recommendations for the property :param representative_recommendations: dictionary of representative recommendations for the property + :param debug: boolean, indicating if the function is running in debug mode. The only difference is that + adjustments are returned for testing + :return: """ @@ -533,7 +565,9 @@ class Recommendations: rec["phase"] for recs in property_recommendations for rec in recs ) - impact_summary = [] + # We keep a history of adjustments we have made, so that we ensure that we adjust future + # phases for SAP + impact_summary, adjustments = [], [] for recommendations_by_type in property_recommendations: for rec in recommendations_by_type: if rec["type"] in ["trickle_vents", "draught_proofing", "extension_cavity_wall_insulation"]: @@ -610,6 +644,16 @@ class Recommendations: current_phase_sap = rec["sap_points"] + previous_phase_values["sap"] else: current_phase_sap = phase_energy_efficiency_metrics["sap_change"] + # If we have an adjustment, we apply it here. We de-dupe, taking the + # largest adjustment by phase - though, they should all be the same + phase_adjustments = [a for a in adjustments if a["phase"] < rec["phase"]] + if phase_adjustments: + phase_adjustments = cls._filter_phase_adjustment(phase_adjustments) + total_adjustment = sum( + a["sap_adjustment"] for a in phase_adjustments + ) + # Take the max, by phase, subtract from the current phase sap + current_phase_sap -= total_adjustment current_phase_values = { "sap": current_phase_sap, @@ -687,26 +731,46 @@ class Recommendations: if rec["type"] == "mechanical_ventilation": # ventilation is capped by having no greater and a -4 impact ventilation_sap_limit = -4 - - def _check_veniltation_out_of_bounds(sap_impact): - return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0) - - def _adjust_ventilation_sap(sap_impact): - if sap_impact >= 0: - return -1 - if sap_impact < ventilation_sap_limit: - return ventilation_sap_limit - - ventilation_out_of_bounds = _check_veniltation_out_of_bounds(property_phase_impact["sap"]) + ventilation_out_of_bounds = cls._check_veniltation_out_of_bounds( + property_phase_impact["sap"], ventilation_sap_limit + ) if ventilation_out_of_bounds: previous_modelled_sap = previous_phase_values.get("sap_prediction", 0) proposed_sap_impact = current_phase_sap - previous_modelled_sap - proposal_out_of_bounds = _check_veniltation_out_of_bounds(proposed_sap_impact) + proposal_out_of_bounds = cls._check_veniltation_out_of_bounds( + proposed_sap_impact, ventilation_sap_limit + ) if proposal_out_of_bounds: - property_phase_impact["sap"] = _adjust_ventilation_sap(proposed_sap_impact) - else: - property_phase_impact["sap"] = proposed_sap_impact + proposed_sap_impact = cls._adjust_ventilation_sap( + proposed_sap_impact, ventilation_sap_limit + ) + + # We keep track of the adjustment + # In this case, if the SAP impact has increased, then the adustment should be negative + # otherwise it should be positive + # When we add the total adjustment, it's an addition + # Example + # Before: 60, impact -2 => 58 + # After: 60, impact -1 (So the impact is bigger) => 59 + # So in this case, we need to make sure we add 1 to all future predictions so + # the adjustment should be positive + # Before: 60, impact 1 => 61 + # After: 60, impact -1 => 59 + # So in this case, we need to make sure we subtract 1 to all future predictions so + # the adjustment should be negative + # Both cases are reflected in sap adjustment + sap_adjustment = proposed_sap_impact - float(property_phase_impact["sap"]) + + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + "sap_adjustment": sap_adjustment, + } + ) + + property_phase_impact["sap"] = proposed_sap_impact # Update the current phase values current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] @@ -720,16 +784,40 @@ class Recommendations: property_instance.data["roof-energy-eff"], property_instance.roof["insulation_thickness"] ) if li_sap_limit is not None: - property_phase_impact["sap"] = min(property_phase_impact["sap"], li_sap_limit) + new_value = min(property_phase_impact["sap"], li_sap_limit) + # If we've made an adjustment, keep track of it + if new_value != property_phase_impact["sap"]: + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + # If we've made an adjustment, it will be negative + "sap_adjustment": property_phase_impact["sap"] - new_value, + } + ) + property_phase_impact["sap"] = new_value # Update the current phase values current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] if rec["type"] == "solar_pv": # We use the SAP points in the recommendation as a minimum - property_phase_impact["sap"] = ( + proposed_impact = ( rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else property_phase_impact["sap"] ) + + # SAP adjustments should be negative + if proposed_impact != property_phase_impact["sap"]: + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + # If we've made an adjustment, it will be positive + "sap_adjustment": proposed_impact - property_phase_impact["sap"], + } + ) + property_phase_impact["sap"] = proposed_impact + # Update the current phase values current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] @@ -757,6 +845,9 @@ class Recommendations: } ) + if debug: + return property_recommendations, impact_summary, adjustments + return property_recommendations, impact_summary @staticmethod diff --git a/recommendations/tests/test_recommendations.py b/recommendations/tests/test_recommendations.py new file mode 100644 index 00000000..c933ad25 --- /dev/null +++ b/recommendations/tests/test_recommendations.py @@ -0,0 +1,843 @@ +import datetime +import pandas as pd +from pandas import Timestamp +import numpy as np +from numpy import nan +from unittest.mock import Mock + +from recommendations.Recommendations import Recommendations + + +def test__filter_phase_adjustment(): + eg1 = [ + {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1.7}, + {'recommendation_id': '1_phase=0', 'phase': 0, 'sap_adjustment': 1.7}, + {'recommendation_id': '2_phase=0', 'phase': 0, 'sap_adjustment': 1.7} + ] + + res1 = Recommendations._filter_phase_adjustment(eg1) + + assert res1 == [{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1.7}] + + eg2 = [ + {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1}, + {'recommendation_id': '1_phase=0', 'phase': 1, 'sap_adjustment': 2}, + {'recommendation_id': '2_phase=0', 'phase': 2, 'sap_adjustment': 3} + ] + + res2 = Recommendations._filter_phase_adjustment(eg2) + + assert res2 == [ + {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1}, + {'recommendation_id': '1_phase=0', 'phase': 1, 'sap_adjustment': 2}, + {'recommendation_id': '2_phase=0', 'phase': 2, 'sap_adjustment': 3} + ] + + eg3 = [ + {'recommendation_id': 'third', 'phase': 3, 'sap_adjustment': 1}, + {'recommendation_id': 'first', 'phase': 1, 'sap_adjustment': 2}, + {'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3} + ] + + res3 = Recommendations._filter_phase_adjustment(eg3) + + assert res3 == [ + {'recommendation_id': 'first', 'phase': 1, 'sap_adjustment': 2}, + {'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3}, + {'recommendation_id': 'third', 'phase': 3, 'sap_adjustment': 1}, + ] + + eg4 = [ + {'recommendation_id': 'third_0', 'phase': 3, 'sap_adjustment': 1}, + {'recommendation_id': 'third_1', 'phase': 3, 'sap_adjustment': 2}, + {'recommendation_id': 'first_0', 'phase': 1, 'sap_adjustment': 2}, + {'recommendation_id': 'first_1', 'phase': 1, 'sap_adjustment': 2}, + {'recommendation_id': 'first_2', 'phase': 1, 'sap_adjustment': 100}, + {'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3} + ] + + res4 = Recommendations._filter_phase_adjustment(eg4) + + assert res4 == [ + {'recommendation_id': 'first_2', 'phase': 1, 'sap_adjustment': 100}, + {'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3}, + {'recommendation_id': 'third_1', 'phase': 3, 'sap_adjustment': 2}, + ] + + +def test_calculate_recommendation_impact(): + all_predictions = { + "sap_change_predictions": pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 66.3, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 67.3, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 68.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 70.1, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 67.3, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '10_phase=5', 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 83.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 79.4, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5}] + ), + "heat_demand_predictions": pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 256.6, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 256.6, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 256.6, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 263.1, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 259.0, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 250.5, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 245.7, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 199.7, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 250.5, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 139.5, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 139.5, 'property_id': '614626', + 'recommendation_id': '10_phase=5', 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 139.5, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 82.5, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 182.6, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5} + ] + + ), + "carbon_change_predictions": pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 2.1, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 2.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 2.1, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 1.3, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, + {'id': '614626+10_phase=5', 'predictions': 1.3, 'property_id': '614626', + 'recommendation_id': '10_phase=5', + 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 1.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', + 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '12_phase=5', + 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '13_phase=5', + 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '14_phase=5', + 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '15_phase=5', + 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '16_phase=5', + 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '17_phase=5', + 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '18_phase=5', + 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '19_phase=5', + 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '20_phase=5', + 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '21_phase=5', + 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '22_phase=5', + 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '23_phase=5', + 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '24_phase=5', + 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '25_phase=5', + 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '26_phase=5', + 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '27_phase=5', + 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '28_phase=5', + 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 0.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', + 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '30_phase=5', + 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '31_phase=5', + 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '32_phase=5', + 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '33_phase=5', + 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '34_phase=5', + 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '35_phase=5', + 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '36_phase=5', + 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '37_phase=5', + 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '38_phase=5', + 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '39_phase=5', + 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', + 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', + 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', + 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', + 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '44_phase=5', + 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '45_phase=5', + 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '46_phase=5', + 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '47_phase=5', + 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '48_phase=5', + 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '49_phase=5', + 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '50_phase=5', + 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '51_phase=5', + 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '52_phase=5', + 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '53_phase=5', + 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '54_phase=5', + 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 1.6, 'property_id': '614626', + 'recommendation_id': '55_phase=5', + 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '56_phase=5', + 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '57_phase=5', + 'phase': 5} + ] + ), + "hotwater_kwh_predictions": pd.DataFrame([]), + "heating_kwh_predictions": pd.DataFrame([]), + } + + # Mock the property - we need id and some of the data + p = Mock( + id=614626, + data={ + "current-energy-efficiency": 65, + "co2-emissions-current": 2.4, + "energy-consumption-current": 284, + "roof-energy-eff": "Good", + "lighting-energy-eff": "Good" + }, + roof={ + 'original_description': 'Pitched, 250 mm loft insulation', + 'clean_description': 'Pitched, 250 mm loft insulation', 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, + 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': False, + 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '250' + }, + lighting={ + 'original_description': 'Low energy lighting in 50% of fixed outlets', + 'clean_description': 'Low energy lighting in 50% of fixed outlets', 'low_energy_proportion': 0.5 + } + ) + + recommendations = { + 614626: [ + [ + { + 'phase': 0, 'parts': [ + {'id': 3362, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 300.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': Timestamp('2025-08-15 16:31:52.995292'), 'is_active': True, + 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, + 'total_cost': 21.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, + 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None, + 'quantity': 54.125488565924286, 'quantity_unit': 'm2', 'total': 1029.0, 'contingency': 102.9, + 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1}], 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'description': 'Install 300mm of Fibre loft insulation in your loft', + 'starting_u_value': np.float64(0.17), 'new_u_value': np.float64(0.14), 'sap_points': 0, + 'already_installed': False, 'simulation_config': {'roof_insulation_thickness_ending': '300', + 'roof_thermal_transmittance_ending': np.float64( + 0.14), + 'roof_energy_eff_ending': 'Very Good'}, + 'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation', + 'roof-energy-eff': 'Very Good'}, 'total': 1029.0, 'contingency': 102.9, + 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False, + 'innovation_rate': 0.0, + 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977)}, + ], + [ + { + 'phase': 1, 'parts': [{'id': 3337, 'type': 'mechanical_ventilation', + 'description': 'Decentralised mechanical extract ventilation', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', + 'r_value_per_mm': nan, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'CRG', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), + 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, + 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, + 'total_cost': 280.0, 'notes': None, 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None, + 'total': 560.0, 'quantity': 2, 'quantity_unit': 'part'}], + 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', + 'description': 'Install 2 Decentralised mechanical extract ventilation units', + 'starting_u_value': None, + 'new_u_value': None, 'already_installed': False, 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, 'total': 560.0, 'labour_hours': 8, 'labour_days': 1.0, + 'simulation_config': {'mechanical_ventilation_ending': 'mechanical, extract only'}, + 'description_simulation': {'mechanical-ventilation': 'mechanical, extract only'}, + 'innovation_rate': 0.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0} + ], + [ + {'phase': 2, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'description': 'Install low energy lighting in 3 outlets', 'starting_u_value': None, + 'new_u_value': None, + 'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'description_simulation': {'lighting-energy-eff': 'Very Good', + 'lighting-description': 'Low energy lighting in all fixed outlets', + 'low-energy-lighting': 100}, 'total': 10.5, 'contingency': 2.73, + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'innovation_rate': 0.0, + 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)} + ], + [ + {'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, 'parts': [], + 'description': 'Upgrade heating controls to Room thermostat, programmer and TRVs', 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, 'vat': 11.666666666666664, + 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(1.0), 'already_installed': False, + 'simulation_config': {'trvs_ending': 'trvs', 'mainheatc_energy_eff_ending': 'Good'}, + 'description_simulation': {'mainheatcont-description': 'Programmer, room thermostat and TRVS', + 'mainheatc-energy-eff': 'Good'}, 'innovation_rate': 0.0, + 'recommendation_id': '5_phase=3', 'efficiency': 70, + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)}, + {'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control', 'parts': [], + 'description': 'Upgrade heating controls to Smart Thermostats, room sensors and smart radiator ' + 'valves (time & temperature zone control)', + 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1, + 'subtotal': 571.32, + 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(1.8), 'already_installed': False, + 'simulation_config': {'thermostatic_control_ending': 'time and temperature zone control', + 'switch_system_ending': None, 'mainheatc_energy_eff_ending': 'Very Good'}, + 'description_simulation': {'mainheatcont-description': 'Time and temperature zone control', + 'mainheatc-energy-eff': 'Very Good'}, 'innovation_rate': 0.0, + 'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(13.300000000000011)}, + {'phase': 3, 'parts': [], 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'description': 'Install a 5KW air source heat pump, and upgrade heating controls to Smart ' + 'Thermostats, room sensors and smart radiator valves (time & temperature zone ' + 'control). Ensure you have a single tariff', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8), + 'already_installed': False, + 'simulation_config': {'mainheat_energy_eff_ending': 'Good', 'hot_water_energy_eff_ending': 'Average', + 'has_boiler_ending': False, 'has_air_source_heat_pump_ending': True, + 'has_electric_ending': True, 'has_mains_gas_ending': False, + 'fuel_type_ending': 'electricity', + 'thermostatic_control_ending': 'time and temperature zone control', + 'switch_system_ending': None, 'mainheatc_energy_eff_ending': 'Very Good'}, + 'description_simulation': {'mainheat-description': 'Air source heat pump, radiators, electric', + 'mainheat-energy-eff': 'Good', 'hot-water-energy-eff': 'Average', + 'hotwater-description': 'From main system', + 'main-fuel': 'electricity (not community)', + 'mainheatcont-description': 'Time and temperature zone control', + 'mainheatc-energy-eff': 'Very Good'}, 'total': 17144.924, + 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08, + 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3', + 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003), + 'heat_demand': np.float64(59.30000000000001)} + ], + [ + {'phase': 4, 'parts': [], 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'description': 'Remove the secondary heating system', 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'labour_days': np.float64(1.0), 'simulation_config': {'secondheat_description_ending': 'None'}, + 'description_simulation': {'secondheat-description': 'None'}, 'innovation_rate': 0.0, + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0)} + ], + [ + { + 'phase': 5, 'parts': [ + {'id': 3516, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': nan, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'Coactivation', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5892.21, 'notes': '445W panels', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': 5.34, 'size_unit': 'kWp', 'includes_scaffolding': True, + 'includes_battery': False, 'battery_size': None, 'panel_size': 445}], 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'description': 'Trina Vertex S3 445W solar panels - 5.34 kWp ' + 'system', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'simulation_config': {'photo_supply_ending': np.float64(80.0)}, + 'initial_ac_kwh_per_year': np.float64(4844.465553999999), + 'description_simulation': {'photo-supply': np.float64(80.0)}, + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + ] + } + + representative_recommendations = { + 614626: [ + { + 'phase': 0, 'parts': [ + {'id': 3362, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 300.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': Timestamp('2025-08-15 16:31:52.995292'), 'is_active': True, + 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, + 'total_cost': 21.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, + 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None, + 'quantity': 54.125488565924286, 'quantity_unit': 'm2', 'total': 1029.0, 'contingency': 102.9, + 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1}], 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'description': 'Install 300mm of Fibre loft insulation in your loft', + 'starting_u_value': np.float64(0.17), 'new_u_value': np.float64(0.14), 'sap_points': 0, + 'already_installed': False, 'simulation_config': {'roof_insulation_thickness_ending': '300', + 'roof_thermal_transmittance_ending': np.float64( + 0.14), + 'roof_energy_eff_ending': 'Very Good'}, + 'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation', + 'roof-energy-eff': 'Very Good'}, 'total': 1029.0, 'contingency': 102.9, + 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False, + 'innovation_rate': 0.0, + 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977) + }, + { + 'phase': 1, 'parts': [ + {'id': 3337, 'type': 'mechanical_ventilation', + 'description': 'Decentralised mechanical extract ventilation', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'CRG', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 280.0, 'notes': None, 'is_installer_quote': True, + 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None, 'total': 560.0, 'quantity': 2, 'quantity_unit': 'part'}], + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + 'description': 'Install 2 Decentralised mechanical ' + 'extract ventilation units', + 'starting_u_value': None, 'new_u_value': None, + 'already_installed': False, + 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, + 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, 'total': 560.0, + 'labour_hours': 8, 'labour_days': 1.0, + 'simulation_config': { + 'mechanical_ventilation_ending': 'mechanical, ' + 'extract only'}, + 'description_simulation': { + 'mechanical-ventilation': 'mechanical, ' + 'extract only'}, + 'innovation_rate': 0.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0}, + { + 'phase': 2, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'description': 'Install low energy lighting in 3 outlets', 'starting_u_value': None, + 'new_u_value': None, 'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'description_simulation': {'lighting-energy-eff': 'Very Good', + 'lighting-description': 'Low energy lighting in all fixed outlets', + 'low-energy-lighting': 100}, 'total': 10.5, 'contingency': 2.73, + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'innovation_rate': 0.0, 'recommendation_id': '4_phase=2', 'efficiency': 10.5, + 'heat_demand': np.float64(4.100000000000023) + }, + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, 'parts': [], + 'description': 'Upgrade heating controls to Room thermostat, programmer and TRVs', 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(1.0), 'already_installed': False, + 'simulation_config': {'trvs_ending': 'trvs', 'mainheatc_energy_eff_ending': 'Good'}, + 'description_simulation': {'mainheatcont-description': 'Programmer, room thermostat and TRVS', + 'mainheatc-energy-eff': 'Good'}, 'innovation_rate': 0.0, + 'recommendation_id': '5_phase=3', 'efficiency': 70, + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5) + }, + { + 'phase': 4, 'parts': [], 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'description': 'Remove the secondary heating system', 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'labour_days': np.float64(1.0), 'simulation_config': {'secondheat_description_ending': 'None'}, + 'description_simulation': {'secondheat-description': 'None'}, 'innovation_rate': 0.0, + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0)}, + { + 'phase': 5, 'parts': [ + {'id': 3516, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': nan, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'Coactivation', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5892.21, 'notes': '445W panels', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': 5.34, 'size_unit': 'kWp', 'includes_scaffolding': True, + 'includes_battery': False, 'battery_size': None, 'panel_size': 445}], 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'description': 'Trina Vertex S3 445W solar panels - 5.34 kWp ' + 'system', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'simulation_config': {'photo_supply_ending': np.float64(80.0)}, + 'initial_ac_kwh_per_year': np.float64(4844.465553999999), + 'description_simulation': {'photo-supply': np.float64(80.0)}, + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + } + + recommendations_with_impact, impact_summary, adjustments = ( + Recommendations.calculate_recommendation_impact( + property_instance=p, + all_predictions=all_predictions, + recommendations=recommendations, + representative_recommendations=representative_recommendations, + debug=True + ) + ) + + # We expect an adjustment to be made for loft insulation, reducing the impact by + # 1.7 + assert adjustments == [{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}] + + # We expect that adjustment to flow through to the final recommendation so that the solar recommendation has + # a 1.7 sap point reduction in impact + + assert float(impact_summary[-1]["sap"]) == 82.1 + assert float(impact_summary[-1]["sap_prediction"]) == 83.8 + + assert impact_summary[-1] == { + 'phase': 5, 'representative': True, 'recommendation_id': '29_phase=5', 'measure_type': 'solar_pv', + 'sap': np.float64(82.1), 'carbon': np.float64(0.8), 'heat_demand': np.float64(82.5), + 'sap_prediction': np.float64(83.8) + } From 694bb0b569d806485d98531f97afd709369dadaf Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 20 Jan 2026 15:57:56 +0000 Subject: [PATCH 03/74] Define classes --- backend/condition/domain/asset_condition.py | 11 +++++++++++ backend/condition/domain/element.py | 4 ++++ backend/condition/domain/mapping/lbwf_mapper.py | 9 +++++++++ backend/condition/domain/mapping/mapper.py | 11 +++++++++++ .../parsing/records/lbwf/lbwf_asset_condition.py | 15 ++++++++------- .../condition/parsing/records/lbwf/lbwf_house.py | 4 ++-- 6 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 backend/condition/domain/asset_condition.py create mode 100644 backend/condition/domain/element.py create mode 100644 backend/condition/domain/mapping/lbwf_mapper.py create mode 100644 backend/condition/domain/mapping/mapper.py diff --git a/backend/condition/domain/asset_condition.py b/backend/condition/domain/asset_condition.py new file mode 100644 index 00000000..df87fd9f --- /dev/null +++ b/backend/condition/domain/asset_condition.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Optional + +from backend.condition.domain.element import Element + +@dataclass +class AssetCondition: + uprn: int + element: Element + condition_description: str + renewal_year: Optional[int] = None \ No newline at end of file diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py new file mode 100644 index 00000000..89246f9c --- /dev/null +++ b/backend/condition/domain/element.py @@ -0,0 +1,4 @@ +from enum import Enum + +class Element(Enum): + pass \ No newline at end of file diff --git a/backend/condition/domain/mapping/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf_mapper.py new file mode 100644 index 00000000..154f3db4 --- /dev/null +++ b/backend/condition/domain/mapping/lbwf_mapper.py @@ -0,0 +1,9 @@ +from typing import Any, List +from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.mapping.mapper import Mapper + + +class LbwfMapper(Mapper): + + def map_asset_conditions(self, client_data: List[Any]) -> List[AssetCondition]: + raise NotImplementedError \ No newline at end of file diff --git a/backend/condition/domain/mapping/mapper.py b/backend/condition/domain/mapping/mapper.py new file mode 100644 index 00000000..b314e01c --- /dev/null +++ b/backend/condition/domain/mapping/mapper.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod +from typing import Any, List + +from backend.condition.domain.asset_condition import AssetCondition + +class Mapper(ABC): + + @abstractmethod + def map_asset_conditions(self, client_data: List[Any]) -> List[AssetCondition]: + #TODO: client_data should be properly typed + pass \ No newline at end of file diff --git a/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py b/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py index dffd1e53..2b4c4992 100644 --- a/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py +++ b/backend/condition/parsing/records/lbwf/lbwf_asset_condition.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import date +from typing import Optional @dataclass @@ -16,11 +17,11 @@ class LbwfAssetCondition: element_code_description: str attribute_code: str attribute_code_description: str - element_date_value: str | None = None - element_numerical_value: int | None = None - element_text_value: str | None = None - quantity: int | None = None - install_date: date | None = None - remaining_life: int | None = None - element_comments: str | None = None + element_date_value: Optional[str] = None + element_numerical_value: Optional[int] = None + element_text_value: Optional[str] = None + quantity: Optional[int] = None + install_date: Optional[date] = None + remaining_life: Optional[int] = None + element_comments: Optional[str] = None diff --git a/backend/condition/parsing/records/lbwf/lbwf_house.py b/backend/condition/parsing/records/lbwf/lbwf_house.py index 6db16862..3b472fbe 100644 --- a/backend/condition/parsing/records/lbwf/lbwf_house.py +++ b/backend/condition/parsing/records/lbwf/lbwf_house.py @@ -8,8 +8,8 @@ class LbwfHouse: uprn: int reference: int address: str - epc: str # TODO: make enum - shdf: bool + epc: str # TODO: make enum? + shdf: str house: str fail_decency: int assets: List[LbwfAssetCondition] \ No newline at end of file From 32a3695ba218089fd288650a96fa8ead44dc2c70 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 20 Jan 2026 16:15:48 +0000 Subject: [PATCH 04/74] refactoring the recommendation impact code, with new tests --- backend/tests/test_integration.py | 1334 ++++++------- recommendations/Recommendations.py | 114 +- recommendations/tests/test_data/__init__.py | 0 recommendations/tests/test_recommendations.py | 1647 +++++++++++------ tox.ini | 1 + 5 files changed, 1818 insertions(+), 1278 deletions(-) create mode 100644 recommendations/tests/test_data/__init__.py diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py index cdc27abd..0abca0e8 100644 --- a/backend/tests/test_integration.py +++ b/backend/tests/test_integration.py @@ -1,574 +1,330 @@ -# import ast -# import json -from copy import deepcopy -# from dataclasses import replace -# from datetime import datetime - -import random -from tqdm import tqdm -# import pandas as pd -import numpy as np -from etl.epc.Record import EPCRecord -# from backend.SearchEpc import SearchEpc -# from sqlalchemy.exc import IntegrityError, OperationalError -# from sqlalchemy.orm import sessionmaker -# from starlette.responses import Response - -# from backend.app.config import get_settings, get_prediction_buckets -# from backend.app.db.connection import db_engine -# from backend.app.db.functions.materials_functions import get_materials -# from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations -# from backend.app.db.functions.property_functions import ( -# create_property, create_property_details_epc, create_property_targets, update_property_data, -# update_or_create_property_spatial_details -# ) -# from backend.app.db.functions.recommendations_functions import ( -# create_plan, upload_recommendations, create_scenario -# ) -# from backend.app.db.functions.funding_functions import upload_funding -# from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn -# from backend.app.db.models.portfolio import rating_lookup -from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES -# from backend.app.plan.utils import get_cleaned -# from backend.app.utils import sap_to_epc -import backend.app.assumptions as assumptions - -from backend.ml_models.api import ModelApi -from backend.Property import Property -from backend.apis.GoogleSolarApi import GoogleSolarApi - -from recommendations.optimiser.CostOptimiser import CostOptimiser -from recommendations.optimiser.GainOptimiser import GainOptimiser -import recommendations.optimiser.optimiser_functions as optimiser_functions -from recommendations.Recommendations import Recommendations -# from utils.logger import setup_logger -# from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3 -# from backend.ml_models.Valuation import PropertyValuation +# # import ast +# # import json +# from copy import deepcopy +# # from dataclasses import replace +# # from datetime import datetime # -# from etl.bill_savings.KwhData import KwhData -# from etl.spatial.OpenUprnClient import OpenUprnClient -# from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc - -from backend.Funding import Funding -from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths -from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value - -# Input data (temp) -import pickle - -import pandas as pd - -with open("local_data_for_deletion.pkl", 'rb') as f: - local_data = pickle.load(f) - -cleaning_data = local_data["cleaning_data"] -materials = local_data["materials"] -cleaned = local_data["cleaned"] -project_scores_matrix = local_data["project_scores_matrix"] -partial_project_scores_matrix = local_data["partial_project_scores_matrix"] -whlg_eligible_postcodes = local_data["whlg_eligible_postcodes"] - -with open("kwh_client_for_deletion.pkl", "rb") as f: - kwh_client = pickle.load(f) - -epc_data = pd.read_csv( - "/Users/khalimconn-kowlessar/Downloads/domestic-E06000002-Middlesbrough/certificates.csv", - low_memory=False -) - -# TODO: Store this for cleaning -costs_by_floor_area = epc_data[ - pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2024-01-01" - ][["TOTAL_FLOOR_AREA", "CURRENT_ENERGY_EFFICIENCY", "LIGHTING_COST_CURRENT", "HEATING_COST_CURRENT", - "HOT_WATER_COST_CURRENT"]].copy() - -epc_data = epc_data[ - (epc_data["MAINHEAT_DESCRIPTION"].str.contains("SAP05:") == False) & - (~epc_data["LIGHTING_COST_CURRENT"].isin([None, ""])) & - (~pd.isnull(epc_data["LIGHTING_COST_CURRENT"])) - ] - -costs_by_floor_area.columns = [c.lower().replace("_", "-") for c in costs_by_floor_area.columns] -for c in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: - costs_by_floor_area[c + "_scaled"] = costs_by_floor_area[c] / costs_by_floor_area["total-floor-area"] - -costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[ - ["lighting-cost-current_scaled", "heating-cost-current_scaled", "hot-water-cost-current_scaled"] -].mean().reset_index() - -epc_data = epc_data[~pd.isnull(epc_data["UPRN"])] - -sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2008-01-01"].drop_duplicates("UPRN").sample( - 50000).reset_index(drop=True) - -# TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type -# TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used -# in the google solar api but is it really needed? I don't think it's super accurate. It might be better to -# just use an average energy consumption by floor area for UK households? -# Load the input properties -input_properties = [] -for row_id, config in tqdm(sample_epc_data.iterrows(), total=len(sample_epc_data)): - epc = { - k.lower().replace("_", "-"): v if not pd.isnull(v) else None for k, v in config.items() - } - # Avoid the data load inside of EPCRecord - something we should pull out - for x in ["number-habitable-rooms", "floor-height", "number-heated-rooms"]: - if pd.isnull(epc[x]): - if x == "floor-height": - epc[x] = 2.4 - if x == "number-habitable-rooms": - epc[x] = 3 - if x == "number-heated-rooms": - epc[x] = 3 - - epc_records = {'original_epc': epc, 'full_sap_epc': {}, 'old_data': []} - - prepared_epc = EPCRecord( - epc_records=epc_records, - run_mode="newdata", - cleaning_data=cleaning_data, - ) - - input_properties.append( - Property( - id=row_id, - is_new=True, - address=epc["address"], - postcode=epc["postcode"], - epc_record=prepared_epc, - already_installed={}, - property_valuation={}, - non_invasive_recommendations=[], - energy_assessment=None, - **Property.extract_kwargs(config), # TODO: Depraecate this - ) - ) - -# For each property, insert the default solar configuration -for p in tqdm(input_properties): - solar_api = GoogleSolarApi( - api_key=None, solar_materials=[m for m in materials if m["type"] == "solar_pv"], max_retries=5 - ) - panel_performance = solar_api.default_panel_performance(property_instance=p) - p.set_solar_panel_configuration( - solar_panel_configuration={ - "insights_data": None, "panel_performance": panel_performance, "unit_share_of_energy": 1 - }, - ) - -# We mock kwh preds -mocked_kwh_predictions = {"heating_kwh_predictions": [], "hotwater_kwh_predictions": []} -for p in tqdm(input_properties): - mocked_kwh_predictions["heating_kwh_predictions"].append({ - "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] - }) - mocked_kwh_predictions["hotwater_kwh_predictions"].append({ - "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] - }) -mocked_kwh_predictions["heating_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["heating_kwh_predictions"]) -mocked_kwh_predictions["hotwater_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["hotwater_kwh_predictions"]) - -# TODO: We might want to implement this generally, via an ETL process -for x in cleaned["mainheat-description"]: - x["has_wood_chips"] = False -for p in input_properties: - for col in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: - if pd.isnull(p.data[col]): - min_diff = abs( - (costs_by_floor_area["current-energy-efficiency"] - p.data["current-energy-efficiency"]) - ).min() - df = costs_by_floor_area[ - abs((costs_by_floor_area["current-energy-efficiency"] - p.data[ - "current-energy-efficiency"])) == min_diff - ] - if df.shape[0] > 1: - df = df.head(1) - p.data[col] = (df[col + "_scaled"] * p.data["total-floor-area"]).values[0] - -[ - p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) for p in - input_properties -] +# import random +# from tqdm import tqdm +# # import pandas as pd +# import numpy as np +# from etl.epc.Record import EPCRecord +# # from backend.SearchEpc import SearchEpc +# # from sqlalchemy.exc import IntegrityError, OperationalError +# # from sqlalchemy.orm import sessionmaker +# # from starlette.responses import Response +# +# # from backend.app.config import get_settings, get_prediction_buckets +# # from backend.app.db.connection import db_engine +# # from backend.app.db.functions.materials_functions import get_materials +# # from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations +# # from backend.app.db.functions.property_functions import ( +# # create_property, create_property_details_epc, create_property_targets, update_property_data, +# # update_or_create_property_spatial_details +# # ) +# # from backend.app.db.functions.recommendations_functions import ( +# # create_plan, upload_recommendations, create_scenario +# # ) +# # from backend.app.db.functions.funding_functions import upload_funding +# # from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn +# # from backend.app.db.models.portfolio import rating_lookup +# from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES +# # from backend.app.plan.utils import get_cleaned +# # from backend.app.utils import sap_to_epc +# import backend.app.assumptions as assumptions +# +# from backend.ml_models.api import ModelApi +# from backend.Property import Property +# from backend.apis.GoogleSolarApi import GoogleSolarApi +# +# from recommendations.optimiser.CostOptimiser import CostOptimiser +# from recommendations.optimiser.GainOptimiser import GainOptimiser +# import recommendations.optimiser.optimiser_functions as optimiser_functions +# from recommendations.Recommendations import Recommendations +# # from utils.logger import setup_logger +# # from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3 +# # from backend.ml_models.Valuation import PropertyValuation +# # +# # from etl.bill_savings.KwhData import KwhData +# # from etl.spatial.OpenUprnClient import OpenUprnClient +# # from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc +# +# from backend.Funding import Funding +# from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths +# from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value +# +# # Input data (temp) +# import pickle +# +# import pandas as pd +# +# with open("local_data_for_deletion.pkl", 'rb') as f: +# local_data = pickle.load(f) +# +# cleaning_data = local_data["cleaning_data"] +# materials = local_data["materials"] +# cleaned = local_data["cleaned"] +# project_scores_matrix = local_data["project_scores_matrix"] +# partial_project_scores_matrix = local_data["partial_project_scores_matrix"] +# whlg_eligible_postcodes = local_data["whlg_eligible_postcodes"] +# +# with open("kwh_client_for_deletion.pkl", "rb") as f: +# kwh_client = pickle.load(f) +# +# epc_data = pd.read_csv( +# "/Users/khalimconn-kowlessar/Downloads/domestic-E06000002-Middlesbrough/certificates.csv", +# low_memory=False +# ) +# +# # TODO: Store this for cleaning +# costs_by_floor_area = epc_data[ +# pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2024-01-01" +# ][["TOTAL_FLOOR_AREA", "CURRENT_ENERGY_EFFICIENCY", "LIGHTING_COST_CURRENT", "HEATING_COST_CURRENT", +# "HOT_WATER_COST_CURRENT"]].copy() +# +# epc_data = epc_data[ +# (epc_data["MAINHEAT_DESCRIPTION"].str.contains("SAP05:") == False) & +# (~epc_data["LIGHTING_COST_CURRENT"].isin([None, ""])) & +# (~pd.isnull(epc_data["LIGHTING_COST_CURRENT"])) +# ] +# +# costs_by_floor_area.columns = [c.lower().replace("_", "-") for c in costs_by_floor_area.columns] +# for c in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: +# costs_by_floor_area[c + "_scaled"] = costs_by_floor_area[c] / costs_by_floor_area["total-floor-area"] +# +# costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[ +# ["lighting-cost-current_scaled", "heating-cost-current_scaled", "hot-water-cost-current_scaled"] +# ].mean().reset_index() +# +# epc_data = epc_data[~pd.isnull(epc_data["UPRN"])] +# +# sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2008-01-01"].drop_duplicates("UPRN").sample( +# 50000).reset_index(drop=True) +# +# # TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type +# # TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used +# # in the google solar api but is it really needed? I don't think it's super accurate. It might be better to +# # just use an average energy consumption by floor area for UK households? +# # Load the input properties +# input_properties = [] +# for row_id, config in tqdm(sample_epc_data.iterrows(), total=len(sample_epc_data)): +# epc = { +# k.lower().replace("_", "-"): v if not pd.isnull(v) else None for k, v in config.items() +# } +# # Avoid the data load inside of EPCRecord - something we should pull out +# for x in ["number-habitable-rooms", "floor-height", "number-heated-rooms"]: +# if pd.isnull(epc[x]): +# if x == "floor-height": +# epc[x] = 2.4 +# if x == "number-habitable-rooms": +# epc[x] = 3 +# if x == "number-heated-rooms": +# epc[x] = 3 +# +# epc_records = {'original_epc': epc, 'full_sap_epc': {}, 'old_data': []} +# +# prepared_epc = EPCRecord( +# epc_records=epc_records, +# run_mode="newdata", +# cleaning_data=cleaning_data, +# ) +# +# input_properties.append( +# Property( +# id=row_id, +# is_new=True, +# address=epc["address"], +# postcode=epc["postcode"], +# epc_record=prepared_epc, +# already_installed={}, +# property_valuation={}, +# non_invasive_recommendations=[], +# energy_assessment=None, +# **Property.extract_kwargs(config), # TODO: Depraecate this +# ) +# ) +# +# # For each property, insert the default solar configuration +# for p in tqdm(input_properties): +# solar_api = GoogleSolarApi( +# api_key=None, solar_materials=[m for m in materials if m["type"] == "solar_pv"], max_retries=5 +# ) +# panel_performance = solar_api.default_panel_performance(property_instance=p) +# p.set_solar_panel_configuration( +# solar_panel_configuration={ +# "insights_data": None, "panel_performance": panel_performance, "unit_share_of_energy": 1 +# }, +# ) +# +# # We mock kwh preds +# mocked_kwh_predictions = {"heating_kwh_predictions": [], "hotwater_kwh_predictions": []} +# for p in tqdm(input_properties): +# mocked_kwh_predictions["heating_kwh_predictions"].append({ +# "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] +# }) +# mocked_kwh_predictions["hotwater_kwh_predictions"].append({ +# "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] +# }) +# mocked_kwh_predictions["heating_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["heating_kwh_predictions"]) +# mocked_kwh_predictions["hotwater_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["hotwater_kwh_predictions"]) +# +# # TODO: We might want to implement this generally, via an ETL process +# for x in cleaned["mainheat-description"]: +# x["has_wood_chips"] = False # for p in input_properties: -# p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) - -# Run the recommendations -recommendations = {} -recommendations_scoring_data = [] -representative_recommendations = {} -for p in tqdm(input_properties): - if p.data["property-type"] == "House" and pd.isnull(p.data["built-form"]): - p.data["built-form"] = "Semi-Detached" - recommender = Recommendations( - property_instance=p, - materials=materials, - exclusions=[], - inclusions=[], - default_u_values=True - ) - property_recommendations, property_representative_recommendations = recommender.recommend() - - if not property_recommendations: - continue - - recommendations[p.id] = property_recommendations - representative_recommendations[p.id] = property_representative_recommendations - - p.create_base_difference_epc_record(cleaned_lookup=cleaned) - p.adjust_difference_record_with_recommendations( - property_recommendations, property_representative_recommendations - ) - - recommendations_scoring_data.extend(p.recommendations_scoring_data) - -recommendations_scoring_data = pd.DataFrame(recommendations_scoring_data) -recommendations_scoring_data = recommendations_scoring_data.drop( - columns=[ - "rdsap_change", "heat_demand_change", "carbon_change", "sap_ending", "heat_demand_ending", - "carbon_ending" - ] -) - -model_predictions_mocked = { - "sap_change_predictions": None, - "heat_demand_predictions": None, - "carbon_change_predictions": None, - "heating_kwh_predictions": None, - "hotwater_kwh_predictions": None, -} - -for k in model_predictions_mocked.keys(): - model_predictions_mocked[k] = recommendations_scoring_data[["id"]].copy() - model_predictions_mocked[k][['property_id', 'recommendation_id']] = ( - model_predictions_mocked[k]['id'].str.split('+', expand=True) - ) - model_predictions_mocked[k]['phase'] = model_predictions_mocked[k]['recommendation_id'].apply( - ModelApi.extract_phase) - - if k in ["heating_kwh_predictions", "hotwater_kwh_predictions"]: - model_predictions_mocked[k]["predictions"] = random.choices(range(100, 3000), - k=len(recommendations_scoring_data)) - continue - - model_predictions_mocked[k] = model_predictions_mocked[k].sort_values(["property_id", "phase"], ascending=True) - preds = [] - for p_id in model_predictions_mocked[k]["property_id"].unique(): - # We add some amount each time - p = [p for p in input_properties if str(p.id) == p_id][0] - if k == "sap_change_predictions": - start = p.data["current-energy-efficiency"] - elif k == "heat_demand_predictions": - start = p.data["energy-consumption-current"] - else: - start = p.data["co2-emissions-current"] - df = model_predictions_mocked[k][model_predictions_mocked[k]["property_id"] == p_id].copy() - # Add some amount each time - to_add = random.choices(range(0, 15), k=len(df)) - to_add = np.cumsum(to_add) - df["predictions"] = start + to_add - preds.append(df) - preds = pd.concat(preds) - model_predictions_mocked[k] = preds - -for property_id in tqdm(recommendations.keys(), total=len(recommendations)): - property_instance = [p for p in input_properties if p.id == property_id][0] - - recommendations_with_impact, impact_summary = ( - Recommendations.calculate_recommendation_impact( - property_instance=property_instance, - all_predictions=model_predictions_mocked, - recommendations=recommendations, - representative_recommendations=representative_recommendations - ) - ) - - # We use the impact_summary to update the simulation_epcs with the new SAP, heat demand, carbon, cost etc - # at each phase - property_instance.update_simulation_epcs(impact_summary) - recommendations[property_id] = recommendations_with_impact - -for property_id in tqdm([p.id for p in input_properties]): - property_recommendations = recommendations.get(property_id, []) - property_instance = [p for p in input_properties if p.id == property_id][0] - - property_current_energy_bill = ( - Recommendations.calculate_recommendation_tenant_savings( - property_instance=property_instance, - kwh_simulation_predictions=model_predictions_mocked, - property_recommendations=property_recommendations, - ashp_cop=2.8 - ) - ) - property_instance.current_energy_bill = property_current_energy_bill - -body = PlanTriggerRequest( - **{'budget': None, 'goal': 'Increasing EPC', 'housing_type': 'Social', 'goal_value': 'B', 'portfolio_id': 0, - 'trigger_file_path': '', 'already_installed_file_path': '', - 'patches_file_path': None, 'non_invasive_recommendations_file_path': None, - 'valuation_file_path': '', - 'required_measures': [], 'scenario_name': 'EPC B', 'scenario_id': None, - 'multi_plan': True, 'optimise': True, 'default_u_values': True, 'ashp_cop': 2.8, - 'event_type': 'remote_assessment', 'simulate_sap_10': False, 'file_type': None, 'file_format': None, - 'sheet_name': None, 'sheet_count': None, 'index_start': None, 'index_end': None} -) - -eco_packages = {} -# For testing -for p in input_properties: - eco_packages[p.id] = (None, None, None) - -for p in tqdm(input_properties): - if not recommendations.get(p.id): - continue - - # Temp allow to skip - if not isinstance(recommendations.get(p.id)[0], list): - continue - - # we need to double unlist because we have a list of lists - property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} - property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] - measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures] - - # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore - # its inclusion - needs_ventilation = any( - x in property_measure_types for x in assumptions.measures_needing_ventilation - ) and not p.has_ventilation - - if not measures_to_optimise: - # Nothing to do, we just reshape the recommendations - recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( - p.id, recommendations, set() - ) - continue - - fixed_gain = optimiser_functions.calculate_fixed_gain( - property_required_measures, recommendations, p, needs_ventilation - ) - gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain, eco_packages=eco_packages) - - # funding = Funding( - # tenure=body.housing_type, - # project_scores_matrix=project_scores_matrix, - # partial_project_scores_matrix=partial_project_scores_matrix, - # whlg_eligible_postcodes=whlg_eligible_postcodes, - # eco4_social_cavity_abs_rate=13, - # eco4_social_solid_abs_rate=17, - # eco4_private_cavity_abs_rate=13, - # eco4_private_solid_abs_rate=17, - # gbis_social_cavity_abs_rate=21, - # gbis_social_solid_abs_rate=25, - # gbis_private_cavity_abs_rate=21, - # gbis_private_solid_abs_rate=28, - # ) - # - # li_thickness = convert_thickness_to_numeric( - # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] - # ) - # current_wall_u_value = p.walls["thermal_transmittance"] - # if current_wall_u_value is None: - # current_wall_u_value = get_wall_u_value( - # clean_description=p.walls["clean_description"], - # age_band=p.age_band, - # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], - # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], - # ) - - # We insert the innovation uplift - measures_to_optimise_with_uplift = deepcopy(measures_to_optimise) - - # TODO: Turn this into a function and store the innovaiton uplift - for group in measures_to_optimise_with_uplift: - for r in group: - (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - r["uplift_project_score"]) = ( - 0, 0, 0, 0 - ) - - # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", - # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: - # ( - # r["partial_project_score"], - # r["partial_project_funding"], - # r["innovation_uplift"], - # r["uplift_project_score"], - # ) = ( - # 0, 0, 0, 0 - # ) - # continue - # - # ( - # r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - # r["uplift_project_score"] - # ) = funding.get_innovation_uplift( - # measure=r, - # starting_sap=int(p.data["current-energy-efficiency"]), - # floor_area=p.floor_area, - # is_cavity=p.walls["is_cavity_wall"], - # current_wall_uvalue=current_wall_u_value, - # is_partial="partial" in p.walls["clean_description"].lower(), - # existing_li_thickness=li_thickness, - # mainheating=p.main_heating, - # main_fuel=p.main_fuel, - # mainheat_energy_eff=p.data["mainheat-energy-eff"], - # ) - - if r["already_installed"]: - # if already installed, we zero out the uplift and funding - (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - r["uplift_project_score"]) = ( - 0, 0, 0, 0 - ) - - input_measures = optimiser_functions.prepare_input_measures( - measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True, - property_eco_packages=eco_packages.get(p.id) - ) - - # When the goal is Increasing EPC, we can run the funding optimiser - if body.goal == "Switch off": - - solutions = optimise_with_funding_paths( - p=p, - input_measures=input_measures, - housing_type=body.housing_type, - budget=body.budget, - target_gain=gain, - funding=funding, - work_package=eco_packages[p.id][2] - ) - - # If the solution isn't eligible, we can't really consider it - solutions = solutions[ - (solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none") - ] - - if solutions["meets_upgrade_target"].any(): - # If we have a solution that meets the upgrade target, we select that one - optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0] - else: - # Pick the cheapest - optimal_solution = solutions.iloc[0] - - # This is the list of measures that we will recommend - scheme = optimal_solution["scheme"] - - # We create this full list of selected measures, which is used in the next section for setting - # default measures - solution = deepcopy(optimal_solution["items"]) + deepcopy(optimal_solution["unfunded_items"]) - funded_measures = deepcopy(optimal_solution["items"]) if scheme != "none" else [] - - # This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£) - project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ - optimal_solution["partial_project_funding"] - # This is the total amount of funding associated to the uplift (£) - total_uplift = optimal_solution["total_uplift"] - # This is the funding scheme selected - # This is the full project ABS - full_project_score = optimal_solution["project_score"] - # This is the partial project ABS - partial_project_score = optimal_solution["partial_project_score"] - # This is the uplift score ABS - uplift_project_score = optimal_solution["total_uplift_score"] - else: - # We optimise and then we determine eligibility for funding, based on the measures selected - optimiser = ( - GainOptimiser( - input_measures, max_cost=body.budget, max_gain=gain, allow_slack=False - ) if body.budget else CostOptimiser(input_measures, min_gain=gain) - ) - optimiser.setup() - optimiser.solve() - solution = optimiser.solution - - recommendation_types = [] - for measures in input_measures: - for measure in measures: - recommendation_types.append(measure["type"]) - recommendation_types = set(recommendation_types) - - has_wall_insulation_recommendation = any( - (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in - WALL_INSULATION_MEASURES - ) - has_roof_insulation_recommendation = any( - (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in - ROOF_INSULATION_MEASURES - ) - - # funding.check_funding( - # measures=solution, - # starting_sap=int(p.data["current-energy-efficiency"]), - # ending_sap=int(p.data["current-energy-efficiency"]) + sum([x["gain"] for x in solution]), - # floor_area=p.floor_area, - # mainheat_description=p.main_heating["clean_description"], - # heating_control_description=p.main_heating_controls["clean_description"], - # is_cavity=p.walls["is_cavity_wall"], - # current_wall_uvalue=current_wall_u_value, - # is_partial="partial" in p.walls["clean_description"].lower(), - # existing_li_thickness=li_thickness, - # mainheating=p.main_heating, - # main_fuel=p.main_fuel, - # mainheat_energy_eff=p.data["mainheat-energy-eff"], - # has_wall_insulation_recommendation=has_wall_insulation_recommendation, - # has_roof_insulation_recommendation=has_roof_insulation_recommendation, - # ) - - # Determine the scheme - scheme = "none" - # if funding.eco4_eligible: - # scheme = "eco4" - # if scheme == "none" and funding.gbis_eligible: - # scheme = "gbis" - - funded_measures = [] - # funded_measures = solution if scheme in ["gbis", "eco4"] else [] - # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs - project_funding = 0 - # total_uplift = funding.eco4_uplift - total_uplift = 0 - # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs - full_project_score = 0 - # partial_project_score = funding.partial_project_abs - partial_project_score = 0 - # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift - uplift_project_score = 0 - - selected = {r["id"] for r in solution} - - if property_required_measures: - solution = optimiser_functions.add_required_measures( - property_id=p.id, property_required_measures=property_required_measures, - recommendations=recommendations, selected=selected, - ) - - # Add best practice measures (ventilation/trickle vents) - selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) - # Final flattening - recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( - p.id, recommendations, selected - ) - - # TODO: functionise - for measure in funded_measures: - if "+mechanical_ventilation" in measure["type"]: - measure["type"] = measure["type"].split("+mechanical_ventilation")[0] - - p.insert_funding( - scheme=scheme, - funded_measures=funded_measures, - project_funding=project_funding, - total_uplift=total_uplift, - full_project_score=full_project_score, - partial_project_score=partial_project_score, - uplift_project_score=uplift_project_score - ) - +# for col in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: +# if pd.isnull(p.data[col]): +# min_diff = abs( +# (costs_by_floor_area["current-energy-efficiency"] - p.data["current-energy-efficiency"]) +# ).min() +# df = costs_by_floor_area[ +# abs((costs_by_floor_area["current-energy-efficiency"] - p.data[ +# "current-energy-efficiency"])) == min_diff +# ] +# if df.shape[0] > 1: +# df = df.head(1) +# p.data[col] = (df[col + "_scaled"] * p.data["total-floor-area"]).values[0] +# +# [ +# p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) for p in +# input_properties +# ] +# # for p in input_properties: +# # p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) +# +# # Run the recommendations +# recommendations = {} +# recommendations_scoring_data = [] +# representative_recommendations = {} +# for p in tqdm(input_properties): +# if p.data["property-type"] == "House" and pd.isnull(p.data["built-form"]): +# p.data["built-form"] = "Semi-Detached" +# recommender = Recommendations( +# property_instance=p, +# materials=materials, +# exclusions=[], +# inclusions=[], +# default_u_values=True +# ) +# property_recommendations, property_representative_recommendations = recommender.recommend() +# +# if not property_recommendations: +# continue +# +# recommendations[p.id] = property_recommendations +# representative_recommendations[p.id] = property_representative_recommendations +# +# p.create_base_difference_epc_record(cleaned_lookup=cleaned) +# p.adjust_difference_record_with_recommendations( +# property_recommendations, property_representative_recommendations +# ) +# +# recommendations_scoring_data.extend(p.recommendations_scoring_data) +# +# recommendations_scoring_data = pd.DataFrame(recommendations_scoring_data) +# recommendations_scoring_data = recommendations_scoring_data.drop( +# columns=[ +# "rdsap_change", "heat_demand_change", "carbon_change", "sap_ending", "heat_demand_ending", +# "carbon_ending" +# ] +# ) +# +# model_predictions_mocked = { +# "sap_change_predictions": None, +# "heat_demand_predictions": None, +# "carbon_change_predictions": None, +# "heating_kwh_predictions": None, +# "hotwater_kwh_predictions": None, +# } +# +# for k in model_predictions_mocked.keys(): +# model_predictions_mocked[k] = recommendations_scoring_data[["id"]].copy() +# model_predictions_mocked[k][['property_id', 'recommendation_id']] = ( +# model_predictions_mocked[k]['id'].str.split('+', expand=True) +# ) +# model_predictions_mocked[k]['phase'] = model_predictions_mocked[k]['recommendation_id'].apply( +# ModelApi.extract_phase) +# +# if k in ["heating_kwh_predictions", "hotwater_kwh_predictions"]: +# model_predictions_mocked[k]["predictions"] = random.choices(range(100, 3000), +# k=len(recommendations_scoring_data)) +# continue +# +# model_predictions_mocked[k] = model_predictions_mocked[k].sort_values(["property_id", "phase"], ascending=True) +# preds = [] +# for p_id in model_predictions_mocked[k]["property_id"].unique(): +# # We add some amount each time +# p = [p for p in input_properties if str(p.id) == p_id][0] +# if k == "sap_change_predictions": +# start = p.data["current-energy-efficiency"] +# elif k == "heat_demand_predictions": +# start = p.data["energy-consumption-current"] +# else: +# start = p.data["co2-emissions-current"] +# df = model_predictions_mocked[k][model_predictions_mocked[k]["property_id"] == p_id].copy() +# # Add some amount each time +# to_add = random.choices(range(0, 15), k=len(df)) +# to_add = np.cumsum(to_add) +# df["predictions"] = start + to_add +# preds.append(df) +# preds = pd.concat(preds) +# model_predictions_mocked[k] = preds +# +# for property_id in tqdm(recommendations.keys(), total=len(recommendations)): +# property_instance = [p for p in input_properties if p.id == property_id][0] +# +# recommendations_with_impact, impact_summary = ( +# Recommendations.calculate_recommendation_impact( +# property_instance=property_instance, +# all_predictions=model_predictions_mocked, +# recommendations=recommendations, +# representative_recommendations=representative_recommendations +# ) +# ) +# +# # We use the impact_summary to update the simulation_epcs with the new SAP, heat demand, carbon, cost etc +# # at each phase +# property_instance.update_simulation_epcs(impact_summary) +# recommendations[property_id] = recommendations_with_impact +# +# for property_id in tqdm([p.id for p in input_properties]): +# property_recommendations = recommendations.get(property_id, []) +# property_instance = [p for p in input_properties if p.id == property_id][0] +# +# property_current_energy_bill = ( +# Recommendations.calculate_recommendation_tenant_savings( +# property_instance=property_instance, +# kwh_simulation_predictions=model_predictions_mocked, +# property_recommendations=property_recommendations, +# ashp_cop=2.8 +# ) +# ) +# property_instance.current_energy_bill = property_current_energy_bill +# +# body = PlanTriggerRequest( +# **{'budget': None, 'goal': 'Increasing EPC', 'housing_type': 'Social', 'goal_value': 'B', 'portfolio_id': 0, +# 'trigger_file_path': '', 'already_installed_file_path': '', +# 'patches_file_path': None, 'non_invasive_recommendations_file_path': None, +# 'valuation_file_path': '', +# 'required_measures': [], 'scenario_name': 'EPC B', 'scenario_id': None, +# 'multi_plan': True, 'optimise': True, 'default_u_values': True, 'ashp_cop': 2.8, +# 'event_type': 'remote_assessment', 'simulate_sap_10': False, 'file_type': None, 'file_format': None, +# 'sheet_name': None, 'sheet_count': None, 'index_start': None, 'index_end': None} +# ) +# +# eco_packages = {} +# # For testing +# for p in input_properties: +# eco_packages[p.id] = (None, None, None) +# # for p in tqdm(input_properties): # if not recommendations.get(p.id): # continue # +# # Temp allow to skip +# if not isinstance(recommendations.get(p.id)[0], list): +# continue +# # # we need to double unlist because we have a list of lists # property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} # property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] @@ -590,34 +346,34 @@ for p in tqdm(input_properties): # fixed_gain = optimiser_functions.calculate_fixed_gain( # property_required_measures, recommendations, p, needs_ventilation # ) -# gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) +# gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain, eco_packages=eco_packages) # -# funding = Funding( -# tenure="Social", -# project_scores_matrix=project_scores_matrix, -# partial_project_scores_matrix=partial_project_scores_matrix, -# whlg_eligible_postcodes=whlg_eligible_postcodes, -# eco4_social_cavity_abs_rate=12.5, -# eco4_social_solid_abs_rate=17, -# eco4_private_cavity_abs_rate=12.5, -# eco4_private_solid_abs_rate=17, -# gbis_social_cavity_abs_rate=21, -# gbis_social_solid_abs_rate=25, -# gbis_private_cavity_abs_rate=21, -# gbis_private_solid_abs_rate=28, -# ) -# -# li_thickness = convert_thickness_to_numeric( -# p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] -# ) -# current_wall_u_value = p.walls["thermal_transmittance"] -# if current_wall_u_value is None: -# current_wall_u_value = get_wall_u_value( -# clean_description=p.walls["clean_description"], -# age_band=p.age_band, -# is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], -# is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], -# ) +# # funding = Funding( +# # tenure=body.housing_type, +# # project_scores_matrix=project_scores_matrix, +# # partial_project_scores_matrix=partial_project_scores_matrix, +# # whlg_eligible_postcodes=whlg_eligible_postcodes, +# # eco4_social_cavity_abs_rate=13, +# # eco4_social_solid_abs_rate=17, +# # eco4_private_cavity_abs_rate=13, +# # eco4_private_solid_abs_rate=17, +# # gbis_social_cavity_abs_rate=21, +# # gbis_social_solid_abs_rate=25, +# # gbis_private_cavity_abs_rate=21, +# # gbis_private_solid_abs_rate=28, +# # ) +# # +# # li_thickness = convert_thickness_to_numeric( +# # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] +# # ) +# # current_wall_u_value = p.walls["thermal_transmittance"] +# # if current_wall_u_value is None: +# # current_wall_u_value = get_wall_u_value( +# # clean_description=p.walls["clean_description"], +# # age_band=p.age_band, +# # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], +# # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], +# # ) # # # We insert the innovation uplift # measures_to_optimise_with_uplift = deepcopy(measures_to_optimise) @@ -625,41 +381,53 @@ for p in tqdm(input_properties): # # TODO: Turn this into a function and store the innovaiton uplift # for group in measures_to_optimise_with_uplift: # for r in group: -# -# if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", -# "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: -# ( -# r["partial_project_score"], -# r["partial_project_funding"], -# r["innovation_uplift"], -# r["uplift_project_score"], -# ) = ( -# 0, 0, 0, 0 -# ) -# continue -# -# ( -# r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], -# r["uplift_project_score"] -# ) = funding.get_innovation_uplift( -# measure=r, -# starting_sap=p.data["current-energy-efficiency"], -# floor_area=p.floor_area, -# is_cavity=p.walls["is_cavity_wall"], -# current_wall_uvalue=current_wall_u_value, -# is_partial="partial" in p.walls["clean_description"].lower(), -# existing_li_thickness=li_thickness, -# mainheating=p.main_heating, -# main_fuel=p.main_fuel, -# mainheat_energy_eff=p.data["mainheat-energy-eff"], +# (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# r["uplift_project_score"]) = ( +# 0, 0, 0, 0 # ) # +# # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", +# # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: +# # ( +# # r["partial_project_score"], +# # r["partial_project_funding"], +# # r["innovation_uplift"], +# # r["uplift_project_score"], +# # ) = ( +# # 0, 0, 0, 0 +# # ) +# # continue +# # +# # ( +# # r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# # r["uplift_project_score"] +# # ) = funding.get_innovation_uplift( +# # measure=r, +# # starting_sap=int(p.data["current-energy-efficiency"]), +# # floor_area=p.floor_area, +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # ) +# +# if r["already_installed"]: +# # if already installed, we zero out the uplift and funding +# (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# r["uplift_project_score"]) = ( +# 0, 0, 0, 0 +# ) +# # input_measures = optimiser_functions.prepare_input_measures( -# measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True +# measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True, +# property_eco_packages=eco_packages.get(p.id) # ) # # # When the goal is Increasing EPC, we can run the funding optimiser -# if body.goal == "Increasing EPC": +# if body.goal == "Switch off": # # solutions = optimise_with_funding_paths( # p=p, @@ -667,20 +435,14 @@ for p in tqdm(input_properties): # housing_type=body.housing_type, # budget=body.budget, # target_gain=gain, -# funding=funding +# funding=funding, +# work_package=eco_packages[p.id][2] # ) # -# # Given the solutions we select the optimal one -# solutions["cost_less_full_project_funding"] = np.where( -# solutions["scheme"] == "eco4", -# solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"], -# solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"] -# ) -# -# solutions["cost_less_full_project_funding"] = ( -# solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"] -# ) -# solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True) +# # If the solution isn't eligible, we can't really consider it +# solutions = solutions[ +# (solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none") +# ] # # if solutions["meets_upgrade_target"].any(): # # If we have a solution that meets the upgrade target, we select that one @@ -691,9 +453,13 @@ for p in tqdm(input_properties): # # # This is the list of measures that we will recommend # scheme = optimal_solution["scheme"] -# funded_measures = optimal_solution["items"] if scheme != "none" else [] -# solution = optimal_solution["items"] + optimal_solution["unfunded_items"] -# # This is the total amount of funding that the project will produce (including uplifts) (£) +# +# # We create this full list of selected measures, which is used in the next section for setting +# # default measures +# solution = deepcopy(optimal_solution["items"]) + deepcopy(optimal_solution["unfunded_items"]) +# funded_measures = deepcopy(optimal_solution["items"]) if scheme != "none" else [] +# +# # This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£) # project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ # optimal_solution["partial_project_funding"] # # This is the total amount of funding associated to the uplift (£) @@ -731,37 +497,43 @@ for p in tqdm(input_properties): # ROOF_INSULATION_MEASURES # ) # -# funding.check_funding( -# measures=solution, -# starting_sap=p.data["current-energy-efficiency"], -# ending_sap=p.data["current-energy-efficiency"] + sum([x["gain"] for x in solution]), -# floor_area=p.floor_area, -# mainheat_description=p.main_heating["clean_description"], -# heating_control_description=p.main_heating_controls["clean_description"], -# is_cavity=p.walls["is_cavity_wall"], -# current_wall_uvalue=current_wall_u_value, -# is_partial="partial" in p.walls["clean_description"].lower(), -# existing_li_thickness=li_thickness, -# mainheating=p.main_heating, -# main_fuel=p.main_fuel, -# mainheat_energy_eff=p.data["mainheat-energy-eff"], -# has_wall_insulation_recommendation=has_wall_insulation_recommendation, -# has_roof_insulation_recommendation=has_roof_insulation_recommendation, -# ) +# # funding.check_funding( +# # measures=solution, +# # starting_sap=int(p.data["current-energy-efficiency"]), +# # ending_sap=int(p.data["current-energy-efficiency"]) + sum([x["gain"] for x in solution]), +# # floor_area=p.floor_area, +# # mainheat_description=p.main_heating["clean_description"], +# # heating_control_description=p.main_heating_controls["clean_description"], +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # has_wall_insulation_recommendation=has_wall_insulation_recommendation, +# # has_roof_insulation_recommendation=has_roof_insulation_recommendation, +# # ) # # # Determine the scheme # scheme = "none" -# if funding.eco4_eligible: -# scheme = "eco4" -# if scheme == "none" and funding.gbis_eligible: -# scheme = "gbis" +# # if funding.eco4_eligible: +# # scheme = "eco4" +# # if scheme == "none" and funding.gbis_eligible: +# # scheme = "gbis" # -# funded_measures = solution if scheme in ["gbis", "eco4"] else [] -# project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs -# total_uplift = funding.eco4_uplift -# full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs -# partial_project_score = funding.partial_project_abs -# uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift +# funded_measures = [] +# # funded_measures = solution if scheme in ["gbis", "eco4"] else [] +# # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs +# project_funding = 0 +# # total_uplift = funding.eco4_uplift +# total_uplift = 0 +# # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs +# full_project_score = 0 +# # partial_project_score = funding.partial_project_abs +# partial_project_score = 0 +# # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift +# uplift_project_score = 0 # # selected = {r["id"] for r in solution} # @@ -773,10 +545,10 @@ for p in tqdm(input_properties): # # # Add best practice measures (ventilation/trickle vents) # selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) -# # Final flattening - Don't do this! -# # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( -# # p.id, recommendations, selected -# # ) +# # Final flattening +# recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( +# p.id, recommendations, selected +# ) # # # TODO: functionise # for measure in funded_measures: @@ -792,3 +564,231 @@ for p in tqdm(input_properties): # partial_project_score=partial_project_score, # uplift_project_score=uplift_project_score # ) +# +# # for p in tqdm(input_properties): +# # if not recommendations.get(p.id): +# # continue +# # +# # # we need to double unlist because we have a list of lists +# # property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} +# # property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] +# # measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures] +# # +# # # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore +# # # its inclusion +# # needs_ventilation = any( +# # x in property_measure_types for x in assumptions.measures_needing_ventilation +# # ) and not p.has_ventilation +# # +# # if not measures_to_optimise: +# # # Nothing to do, we just reshape the recommendations +# # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( +# # p.id, recommendations, set() +# # ) +# # continue +# # +# # fixed_gain = optimiser_functions.calculate_fixed_gain( +# # property_required_measures, recommendations, p, needs_ventilation +# # ) +# # gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) +# # +# # funding = Funding( +# # tenure="Social", +# # project_scores_matrix=project_scores_matrix, +# # partial_project_scores_matrix=partial_project_scores_matrix, +# # whlg_eligible_postcodes=whlg_eligible_postcodes, +# # eco4_social_cavity_abs_rate=12.5, +# # eco4_social_solid_abs_rate=17, +# # eco4_private_cavity_abs_rate=12.5, +# # eco4_private_solid_abs_rate=17, +# # gbis_social_cavity_abs_rate=21, +# # gbis_social_solid_abs_rate=25, +# # gbis_private_cavity_abs_rate=21, +# # gbis_private_solid_abs_rate=28, +# # ) +# # +# # li_thickness = convert_thickness_to_numeric( +# # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] +# # ) +# # current_wall_u_value = p.walls["thermal_transmittance"] +# # if current_wall_u_value is None: +# # current_wall_u_value = get_wall_u_value( +# # clean_description=p.walls["clean_description"], +# # age_band=p.age_band, +# # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], +# # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], +# # ) +# # +# # # We insert the innovation uplift +# # measures_to_optimise_with_uplift = deepcopy(measures_to_optimise) +# # +# # # TODO: Turn this into a function and store the innovaiton uplift +# # for group in measures_to_optimise_with_uplift: +# # for r in group: +# # +# # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", +# # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: +# # ( +# # r["partial_project_score"], +# # r["partial_project_funding"], +# # r["innovation_uplift"], +# # r["uplift_project_score"], +# # ) = ( +# # 0, 0, 0, 0 +# # ) +# # continue +# # +# # ( +# # r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# # r["uplift_project_score"] +# # ) = funding.get_innovation_uplift( +# # measure=r, +# # starting_sap=p.data["current-energy-efficiency"], +# # floor_area=p.floor_area, +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # ) +# # +# # input_measures = optimiser_functions.prepare_input_measures( +# # measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True +# # ) +# # +# # # When the goal is Increasing EPC, we can run the funding optimiser +# # if body.goal == "Increasing EPC": +# # +# # solutions = optimise_with_funding_paths( +# # p=p, +# # input_measures=input_measures, +# # housing_type=body.housing_type, +# # budget=body.budget, +# # target_gain=gain, +# # funding=funding +# # ) +# # +# # # Given the solutions we select the optimal one +# # solutions["cost_less_full_project_funding"] = np.where( +# # solutions["scheme"] == "eco4", +# # solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"], +# # solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"] +# # ) +# # +# # solutions["cost_less_full_project_funding"] = ( +# # solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"] +# # ) +# # solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True) +# # +# # if solutions["meets_upgrade_target"].any(): +# # # If we have a solution that meets the upgrade target, we select that one +# # optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0] +# # else: +# # # Pick the cheapest +# # optimal_solution = solutions.iloc[0] +# # +# # # This is the list of measures that we will recommend +# # scheme = optimal_solution["scheme"] +# # funded_measures = optimal_solution["items"] if scheme != "none" else [] +# # solution = optimal_solution["items"] + optimal_solution["unfunded_items"] +# # # This is the total amount of funding that the project will produce (including uplifts) (£) +# # project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ +# # optimal_solution["partial_project_funding"] +# # # This is the total amount of funding associated to the uplift (£) +# # total_uplift = optimal_solution["total_uplift"] +# # # This is the funding scheme selected +# # # This is the full project ABS +# # full_project_score = optimal_solution["project_score"] +# # # This is the partial project ABS +# # partial_project_score = optimal_solution["partial_project_score"] +# # # This is the uplift score ABS +# # uplift_project_score = optimal_solution["total_uplift_score"] +# # else: +# # # We optimise and then we determine eligibility for funding, based on the measures selected +# # optimiser = ( +# # GainOptimiser( +# # input_measures, max_cost=body.budget, max_gain=gain, allow_slack=False +# # ) if body.budget else CostOptimiser(input_measures, min_gain=gain) +# # ) +# # optimiser.setup() +# # optimiser.solve() +# # solution = optimiser.solution +# # +# # recommendation_types = [] +# # for measures in input_measures: +# # for measure in measures: +# # recommendation_types.append(measure["type"]) +# # recommendation_types = set(recommendation_types) +# # +# # has_wall_insulation_recommendation = any( +# # (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in +# # WALL_INSULATION_MEASURES +# # ) +# # has_roof_insulation_recommendation = any( +# # (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in +# # ROOF_INSULATION_MEASURES +# # ) +# # +# # funding.check_funding( +# # measures=solution, +# # starting_sap=p.data["current-energy-efficiency"], +# # ending_sap=p.data["current-energy-efficiency"] + sum([x["gain"] for x in solution]), +# # floor_area=p.floor_area, +# # mainheat_description=p.main_heating["clean_description"], +# # heating_control_description=p.main_heating_controls["clean_description"], +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # has_wall_insulation_recommendation=has_wall_insulation_recommendation, +# # has_roof_insulation_recommendation=has_roof_insulation_recommendation, +# # ) +# # +# # # Determine the scheme +# # scheme = "none" +# # if funding.eco4_eligible: +# # scheme = "eco4" +# # if scheme == "none" and funding.gbis_eligible: +# # scheme = "gbis" +# # +# # funded_measures = solution if scheme in ["gbis", "eco4"] else [] +# # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs +# # total_uplift = funding.eco4_uplift +# # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs +# # partial_project_score = funding.partial_project_abs +# # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift +# # +# # selected = {r["id"] for r in solution} +# # +# # if property_required_measures: +# # solution = optimiser_functions.add_required_measures( +# # property_id=p.id, property_required_measures=property_required_measures, +# # recommendations=recommendations, selected=selected, +# # ) +# # +# # # Add best practice measures (ventilation/trickle vents) +# # selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) +# # # Final flattening - Don't do this! +# # # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( +# # # p.id, recommendations, selected +# # # ) +# # +# # # TODO: functionise +# # for measure in funded_measures: +# # if "+mechanical_ventilation" in measure["type"]: +# # measure["type"] = measure["type"].split("+mechanical_ventilation")[0] +# # +# # p.insert_funding( +# # scheme=scheme, +# # funded_measures=funded_measures, +# # project_funding=project_funding, +# # total_uplift=total_uplift, +# # full_project_score=full_project_score, +# # partial_project_score=partial_project_score, +# # uplift_project_score=uplift_project_score +# # ) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index d3467919..2795d7ff 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -1,7 +1,7 @@ import pandas as pd import numpy as np from backend.Property import Property -from typing import List +from typing import List, Mapping from itertools import groupby from recommendations.FloorRecommendations import FloorRecommendations from recommendations.WallRecommendations import WallRecommendations @@ -31,6 +31,18 @@ class Recommendations: High level recommendations class, which sits above the measure specific recommendation classes """ + # Used in calculation of recommendation impact - increasing variables are features where + # a higher value indicates an improvement. Decreasing is the opposite + INCREASING_VARIABLES = ["sap"] + DECREASING_VARIABLES = ["carbon", "heat_demand"] + + # If the recommendation is mechanical ventilation, we don't apply the rule that the new value should be higher + MV_INCREASING_VARIABLES = ["carbon", "heat_demand"] + MV_DECREASING_VARIABLES = ["sap"] + + # List of models we expect predictions for, when calculation recommendation impact + PREDICTION_PREFIXES = ["sap_change", "heat_demand", "carbon_change"] + def __init__( self, property_instance: Property, @@ -514,15 +526,50 @@ class Recommendations: filtered_adjustments.append(adjustments[0]) return filtered_adjustments + @classmethod + def _filter_predictions_for_property( + cls, + all_predictions: Mapping[str, pd.DataFrame], + property_id: str, + ) -> dict: + """ + Utility function to filter predictions for a specific property + :param all_predictions: Dictionary of all predictions from the model apis + :param property_id: The property id to filter for + :return: + """ + + return { + f"{prefix}_predictions": ( + all_predictions[f"{prefix}_predictions"] + .loc[ + all_predictions[f"{prefix}_predictions"]["property_id"] == property_id + ] + .copy() + ) + for prefix in cls.PREDICTION_PREFIXES + } + + @classmethod + def get_monotonic_variables(cls, rec_type: str) -> tuple[List[str], List[str]]: + """ + Utility function to get the monotonic variables for a specific recommendation type + :param rec_type: The recommendation type + :return: + """ + if rec_type == "mechanical_ventilation": + return cls.MV_INCREASING_VARIABLES, cls.MV_DECREASING_VARIABLES + return cls.INCREASING_VARIABLES, cls.DECREASING_VARIABLES + @classmethod def calculate_recommendation_impact( cls, - property_instance, - all_predictions, - recommendations, - representative_recommendations, - debug=False - ): + property_instance: Property, + all_predictions: Mapping[str, any], + recommendations: Mapping[int, List], + representative_recommendations: Mapping[int, List], + debug: bool = False + ) -> (Mapping[int, List], List[Mapping[str, any]]): """ Given predictions from the model apis, with method will update the recommendations with the predicted @@ -539,31 +586,20 @@ class Recommendations: :param debug: boolean, indicating if the function is running in debug mode. The only difference is that adjustments are returned for testing - :return: + :return: Updated recommendations with predicted impact, and a list of impacts by phase """ + property_predictions = cls._filter_predictions_for_property( + all_predictions, str(property_instance.id) + ) - property_predictions = { - prefix + "_predictions": all_predictions[prefix + "_predictions"][ - all_predictions[prefix + "_predictions"]["property_id"] == str(property_instance.id) - ].copy() for prefix in ["sap_change", "heat_demand", "carbon_change"] - } - + # shallow copy intentional - we're going to modify the internals property_recommendations = recommendations[property_instance.id].copy() representative_recs = representative_recommendations[property_instance.id].copy() representative_ids = [r["recommendation_id"] for r in representative_recs] - increasing_variables = ["sap"] - decreasing_variables = ["carbon", "heat_demand"] - - # If the recommendation is mechanical ventilation, we don't apply the rule that the new value should be higher - mv_increasing_variables = ["carbon", "heat_demand"] - mv_decreasing_variables = ["sap"] - # We allow for negative phase - starting_phase = min( - rec["phase"] for recs in property_recommendations for rec in recs - ) + starting_phase = min(rec["phase"] for recs in property_recommendations for rec in recs) # We keep a history of adjustments we have made, so that we ensure that we adjust future # phases for SAP @@ -602,7 +638,7 @@ class Recommendations: prefix: property_predictions[prefix + "_predictions"][ property_predictions[prefix + "_predictions"]["recommendation_id"] == str( rec["recommendation_id"] - )]["predictions"].values[0] for prefix in ["sap_change", "heat_demand", "carbon_change"] + )]["predictions"].values[0] for prefix in cls.PREDICTION_PREFIXES } # We structure this so that depending on the phase, we capture the previous phase impacts and @@ -669,12 +705,7 @@ class Recommendations: # However, if the recommendation is mechanical ventilation, this can have a negative SAP impact so # we don't apply this rule - if rec["type"] == "mechanical_ventilation": - phase_increasing_variables = mv_increasing_variables - phase_decreasing_variables = mv_decreasing_variables - else: - phase_increasing_variables = increasing_variables - phase_decreasing_variables = decreasing_variables + phase_increasing_variables, phase_decreasing_variables = cls.get_monotonic_variables(rec["type"]) for v in phase_increasing_variables: current_phase_values[v] = ( @@ -718,7 +749,21 @@ class Recommendations: property_instance.lighting["low_energy_proportion"] ) - property_phase_impact["sap"] = min(property_phase_impact["sap"], lighting_sap_limit) + # add an adjustment + proposed_sap_impact = min(property_phase_impact["sap"], lighting_sap_limit) + if proposed_sap_impact != property_phase_impact["sap"]: + # Store the sap adjustment. The proposed sap impact will always be less + # than the current sap impact, so the adjustment is always positive + # as we subtract it from the future phases + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + "sap_adjustment": property_phase_impact["sap"] - proposed_sap_impact, + } + ) + + property_phase_impact["sap"] = proposed_sap_impact property_phase_impact["carbon"] = min( property_phase_impact["carbon"], rec["co2_equivalent_savings"] ) @@ -812,8 +857,9 @@ class Recommendations: { "recommendation_id": rec["recommendation_id"], "phase": rec["phase"], - # If we've made an adjustment, it will be positive - "sap_adjustment": proposed_impact - property_phase_impact["sap"], + # If we've made an adjustment, we will be increasing the number of SAP + # points. Since, we subtract adjustments, this number should be negative + "sap_adjustment": property_phase_impact["sap"] - proposed_impact, } ) property_phase_impact["sap"] = proposed_impact diff --git a/recommendations/tests/test_data/__init__.py b/recommendations/tests/test_data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/recommendations/tests/test_recommendations.py b/recommendations/tests/test_recommendations.py index c933ad25..7a7930bf 100644 --- a/recommendations/tests/test_recommendations.py +++ b/recommendations/tests/test_recommendations.py @@ -1,72 +1,385 @@ +import pytest import datetime import pandas as pd -from pandas import Timestamp import numpy as np +from pandas import Timestamp from numpy import nan from unittest.mock import Mock from recommendations.Recommendations import Recommendations -def test__filter_phase_adjustment(): - eg1 = [ - {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1.7}, - {'recommendation_id': '1_phase=0', 'phase': 0, 'sap_adjustment': 1.7}, - {'recommendation_id': '2_phase=0', 'phase': 0, 'sap_adjustment': 1.7} - ] - - res1 = Recommendations._filter_phase_adjustment(eg1) - - assert res1 == [{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1.7}] - - eg2 = [ - {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1}, - {'recommendation_id': '1_phase=0', 'phase': 1, 'sap_adjustment': 2}, - {'recommendation_id': '2_phase=0', 'phase': 2, 'sap_adjustment': 3} - ] - - res2 = Recommendations._filter_phase_adjustment(eg2) - - assert res2 == [ - {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1}, - {'recommendation_id': '1_phase=0', 'phase': 1, 'sap_adjustment': 2}, - {'recommendation_id': '2_phase=0', 'phase': 2, 'sap_adjustment': 3} - ] - - eg3 = [ - {'recommendation_id': 'third', 'phase': 3, 'sap_adjustment': 1}, - {'recommendation_id': 'first', 'phase': 1, 'sap_adjustment': 2}, - {'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3} - ] - - res3 = Recommendations._filter_phase_adjustment(eg3) - - assert res3 == [ - {'recommendation_id': 'first', 'phase': 1, 'sap_adjustment': 2}, - {'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3}, - {'recommendation_id': 'third', 'phase': 3, 'sap_adjustment': 1}, - ] - - eg4 = [ - {'recommendation_id': 'third_0', 'phase': 3, 'sap_adjustment': 1}, - {'recommendation_id': 'third_1', 'phase': 3, 'sap_adjustment': 2}, - {'recommendation_id': 'first_0', 'phase': 1, 'sap_adjustment': 2}, - {'recommendation_id': 'first_1', 'phase': 1, 'sap_adjustment': 2}, - {'recommendation_id': 'first_2', 'phase': 1, 'sap_adjustment': 100}, - {'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3} - ] - - res4 = Recommendations._filter_phase_adjustment(eg4) - - assert res4 == [ - {'recommendation_id': 'first_2', 'phase': 1, 'sap_adjustment': 100}, - {'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3}, - {'recommendation_id': 'third_1', 'phase': 3, 'sap_adjustment': 2}, - ] +@pytest.fixture +def heat_demand_predictions(): + return pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 256.6, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 256.6, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 256.6, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 263.1, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 259.0, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 250.5, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 245.7, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 199.7, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 250.5, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 139.5, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 139.5, 'property_id': '614626', + 'recommendation_id': '10_phase=5', 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 139.5, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 102.5, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 82.5, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 114.3, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 155.1, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 133.6, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 130.0, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 182.6, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 169.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5} + ] + ) -def test_calculate_recommendation_impact(): - all_predictions = { +@pytest.fixture +def carbon_predictions(): + return pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 2.2, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 2.1, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 2.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 2.1, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 1.3, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, + {'id': '614626+10_phase=5', 'predictions': 1.3, 'property_id': '614626', + 'recommendation_id': '10_phase=5', + 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 1.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', + 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '12_phase=5', + 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '13_phase=5', + 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '14_phase=5', + 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '15_phase=5', + 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '16_phase=5', + 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '17_phase=5', + 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '18_phase=5', + 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '19_phase=5', + 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '20_phase=5', + 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '21_phase=5', + 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '22_phase=5', + 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '23_phase=5', + 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '24_phase=5', + 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '25_phase=5', + 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '26_phase=5', + 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '27_phase=5', + 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 0.9, 'property_id': '614626', + 'recommendation_id': '28_phase=5', + 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 0.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', + 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '30_phase=5', + 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '31_phase=5', + 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '32_phase=5', + 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '33_phase=5', + 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '34_phase=5', + 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '35_phase=5', + 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 1.0, 'property_id': '614626', + 'recommendation_id': '36_phase=5', + 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '37_phase=5', + 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '38_phase=5', + 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '39_phase=5', + 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', + 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', + 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', + 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 1.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', + 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '44_phase=5', + 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '45_phase=5', + 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 1.2, 'property_id': '614626', + 'recommendation_id': '46_phase=5', + 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '47_phase=5', + 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '48_phase=5', + 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '49_phase=5', + 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '50_phase=5', + 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '51_phase=5', + 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '52_phase=5', + 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '53_phase=5', + 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 1.1, 'property_id': '614626', + 'recommendation_id': '54_phase=5', + 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 1.6, 'property_id': '614626', + 'recommendation_id': '55_phase=5', + 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '56_phase=5', + 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 1.5, 'property_id': '614626', + 'recommendation_id': '57_phase=5', + 'phase': 5} + ] + ) + + +@pytest.fixture +def property_instance(): + return Mock( + id=614626, + data={ + "current-energy-efficiency": 65, + "co2-emissions-current": 2.4, + "energy-consumption-current": 284, + "roof-energy-eff": "Good", + "lighting-energy-eff": "Good", + }, + roof={ + "is_loft": True, + "insulation_thickness": "250", + "is_valid": True, + }, + lighting={ + "low_energy_proportion": 0.5 + } + ) + + +@pytest.mark.parametrize( + "input_data, expected", + [ + ( + [ + {"recommendation_id": "a", "phase": 0, "sap_adjustment": 1.7}, + {"recommendation_id": "b", "phase": 0, "sap_adjustment": 1.7}, + ], + [{"recommendation_id": "a", "phase": 0, "sap_adjustment": 1.7}], + ), + ( + [ + {"recommendation_id": "a", "phase": 1, "sap_adjustment": 2}, + {"recommendation_id": "b", "phase": 2, "sap_adjustment": 3}, + ], + [ + {"recommendation_id": "a", "phase": 1, "sap_adjustment": 2}, + {"recommendation_id": "b", "phase": 2, "sap_adjustment": 3}, + ], + ), + ], +) +def test_filter_phase_adjustment(input_data, expected): + assert Recommendations._filter_phase_adjustment(input_data) == expected + + +def test_calculate_recommendation_impact(property_instance, heat_demand_predictions, carbon_predictions): + ####### + # Case 3 + ####### + # Here, the solar impact falls below our threshold and so we expect a solar adjustment to increase the impact + # above the minimum threshold + + all_predictions3 = { "sap_change_predictions": pd.DataFrame( [ {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626', @@ -195,367 +508,363 @@ def test_calculate_recommendation_impact(): {'id': '614626+57_phase=5', 'predictions': 81.2, 'property_id': '614626', 'recommendation_id': '57_phase=5', 'phase': 5}] ), - "heat_demand_predictions": pd.DataFrame( - [ - {'id': '614626+0_phase=0', 'predictions': 256.6, 'property_id': '614626', - 'recommendation_id': '0_phase=0', - 'phase': 0}, - {'id': '614626+1_phase=0', 'predictions': 256.6, 'property_id': '614626', - 'recommendation_id': '1_phase=0', - 'phase': 0}, - {'id': '614626+2_phase=0', 'predictions': 256.6, 'property_id': '614626', - 'recommendation_id': '2_phase=0', - 'phase': 0}, - {'id': '614626+3_phase=1', 'predictions': 263.1, 'property_id': '614626', - 'recommendation_id': '3_phase=1', - 'phase': 1}, - {'id': '614626+4_phase=2', 'predictions': 259.0, 'property_id': '614626', - 'recommendation_id': '4_phase=2', - 'phase': 2}, - {'id': '614626+5_phase=3', 'predictions': 250.5, 'property_id': '614626', - 'recommendation_id': '5_phase=3', - 'phase': 3}, - {'id': '614626+6_phase=3', 'predictions': 245.7, 'property_id': '614626', - 'recommendation_id': '6_phase=3', - 'phase': 3}, - {'id': '614626+7_phase=3', 'predictions': 199.7, 'property_id': '614626', - 'recommendation_id': '7_phase=3', - 'phase': 3}, - {'id': '614626+8_phase=4', 'predictions': 250.5, 'property_id': '614626', - 'recommendation_id': '8_phase=4', - 'phase': 4}, - {'id': '614626+9_phase=5', 'predictions': 139.5, 'property_id': '614626', - 'recommendation_id': '9_phase=5', - 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 139.5, 'property_id': '614626', - 'recommendation_id': '10_phase=5', 'phase': 5}, - {'id': '614626+11_phase=5', 'predictions': 139.5, 'property_id': '614626', - 'recommendation_id': '11_phase=5', 'phase': 5}, - {'id': '614626+12_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '12_phase=5', 'phase': 5}, - {'id': '614626+13_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '13_phase=5', 'phase': 5}, - {'id': '614626+14_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '14_phase=5', 'phase': 5}, - {'id': '614626+15_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '15_phase=5', 'phase': 5}, - {'id': '614626+16_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '16_phase=5', 'phase': 5}, - {'id': '614626+17_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '17_phase=5', 'phase': 5}, - {'id': '614626+18_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '18_phase=5', 'phase': 5}, - {'id': '614626+19_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '19_phase=5', 'phase': 5}, - {'id': '614626+20_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '20_phase=5', 'phase': 5}, - {'id': '614626+21_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '21_phase=5', 'phase': 5}, - {'id': '614626+22_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '22_phase=5', 'phase': 5}, - {'id': '614626+23_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '23_phase=5', 'phase': 5}, - {'id': '614626+24_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '24_phase=5', 'phase': 5}, - {'id': '614626+25_phase=5', 'predictions': 102.5, 'property_id': '614626', - 'recommendation_id': '25_phase=5', 'phase': 5}, - {'id': '614626+26_phase=5', 'predictions': 102.5, 'property_id': '614626', - 'recommendation_id': '26_phase=5', 'phase': 5}, - {'id': '614626+27_phase=5', 'predictions': 102.5, 'property_id': '614626', - 'recommendation_id': '27_phase=5', 'phase': 5}, - {'id': '614626+28_phase=5', 'predictions': 102.5, 'property_id': '614626', - 'recommendation_id': '28_phase=5', 'phase': 5}, - {'id': '614626+29_phase=5', 'predictions': 82.5, 'property_id': '614626', - 'recommendation_id': '29_phase=5', 'phase': 5}, - {'id': '614626+30_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '30_phase=5', 'phase': 5}, - {'id': '614626+31_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '31_phase=5', 'phase': 5}, - {'id': '614626+32_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '32_phase=5', 'phase': 5}, - {'id': '614626+33_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '33_phase=5', 'phase': 5}, - {'id': '614626+34_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '34_phase=5', 'phase': 5}, - {'id': '614626+35_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '35_phase=5', 'phase': 5}, - {'id': '614626+36_phase=5', 'predictions': 114.3, 'property_id': '614626', - 'recommendation_id': '36_phase=5', 'phase': 5}, - {'id': '614626+37_phase=5', 'predictions': 169.2, 'property_id': '614626', - 'recommendation_id': '37_phase=5', 'phase': 5}, - {'id': '614626+38_phase=5', 'predictions': 169.2, 'property_id': '614626', - 'recommendation_id': '38_phase=5', 'phase': 5}, - {'id': '614626+39_phase=5', 'predictions': 169.2, 'property_id': '614626', - 'recommendation_id': '39_phase=5', 'phase': 5}, - {'id': '614626+40_phase=5', 'predictions': 155.1, 'property_id': '614626', - 'recommendation_id': '40_phase=5', 'phase': 5}, - {'id': '614626+41_phase=5', 'predictions': 155.1, 'property_id': '614626', - 'recommendation_id': '41_phase=5', 'phase': 5}, - {'id': '614626+42_phase=5', 'predictions': 155.1, 'property_id': '614626', - 'recommendation_id': '42_phase=5', 'phase': 5}, - {'id': '614626+43_phase=5', 'predictions': 155.1, 'property_id': '614626', - 'recommendation_id': '43_phase=5', 'phase': 5}, - {'id': '614626+44_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '44_phase=5', 'phase': 5}, - {'id': '614626+45_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '45_phase=5', 'phase': 5}, - {'id': '614626+46_phase=5', 'predictions': 133.6, 'property_id': '614626', - 'recommendation_id': '46_phase=5', 'phase': 5}, - {'id': '614626+47_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '47_phase=5', 'phase': 5}, - {'id': '614626+48_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '48_phase=5', 'phase': 5}, - {'id': '614626+49_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '49_phase=5', 'phase': 5}, - {'id': '614626+50_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '50_phase=5', 'phase': 5}, - {'id': '614626+51_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '51_phase=5', 'phase': 5}, - {'id': '614626+52_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '52_phase=5', 'phase': 5}, - {'id': '614626+53_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '53_phase=5', 'phase': 5}, - {'id': '614626+54_phase=5', 'predictions': 130.0, 'property_id': '614626', - 'recommendation_id': '54_phase=5', 'phase': 5}, - {'id': '614626+55_phase=5', 'predictions': 182.6, 'property_id': '614626', - 'recommendation_id': '55_phase=5', 'phase': 5}, - {'id': '614626+56_phase=5', 'predictions': 169.2, 'property_id': '614626', - 'recommendation_id': '56_phase=5', 'phase': 5}, - {'id': '614626+57_phase=5', 'predictions': 169.2, 'property_id': '614626', - 'recommendation_id': '57_phase=5', 'phase': 5} - ] - - ), - "carbon_change_predictions": pd.DataFrame( - [ - {'id': '614626+0_phase=0', 'predictions': 2.2, 'property_id': '614626', - 'recommendation_id': '0_phase=0', - 'phase': 0}, - {'id': '614626+1_phase=0', 'predictions': 2.2, 'property_id': '614626', - 'recommendation_id': '1_phase=0', - 'phase': 0}, - {'id': '614626+2_phase=0', 'predictions': 2.2, 'property_id': '614626', - 'recommendation_id': '2_phase=0', - 'phase': 0}, - {'id': '614626+3_phase=1', 'predictions': 2.2, 'property_id': '614626', - 'recommendation_id': '3_phase=1', - 'phase': 1}, - {'id': '614626+4_phase=2', 'predictions': 2.2, 'property_id': '614626', - 'recommendation_id': '4_phase=2', - 'phase': 2}, - {'id': '614626+5_phase=3', 'predictions': 2.1, 'property_id': '614626', - 'recommendation_id': '5_phase=3', - 'phase': 3}, - {'id': '614626+6_phase=3', 'predictions': 2.1, 'property_id': '614626', - 'recommendation_id': '6_phase=3', - 'phase': 3}, - {'id': '614626+7_phase=3', 'predictions': 1.4, 'property_id': '614626', - 'recommendation_id': '7_phase=3', - 'phase': 3}, - {'id': '614626+8_phase=4', 'predictions': 2.1, 'property_id': '614626', - 'recommendation_id': '8_phase=4', - 'phase': 4}, - {'id': '614626+9_phase=5', 'predictions': 1.3, 'property_id': '614626', - 'recommendation_id': '9_phase=5', - 'phase': 5}, - {'id': '614626+10_phase=5', 'predictions': 1.3, 'property_id': '614626', - 'recommendation_id': '10_phase=5', - 'phase': 5}, - {'id': '614626+11_phase=5', 'predictions': 1.3, 'property_id': '614626', - 'recommendation_id': '11_phase=5', - 'phase': 5}, - {'id': '614626+12_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '12_phase=5', - 'phase': 5}, - {'id': '614626+13_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '13_phase=5', - 'phase': 5}, - {'id': '614626+14_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '14_phase=5', - 'phase': 5}, - {'id': '614626+15_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '15_phase=5', - 'phase': 5}, - {'id': '614626+16_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '16_phase=5', - 'phase': 5}, - {'id': '614626+17_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '17_phase=5', - 'phase': 5}, - {'id': '614626+18_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '18_phase=5', - 'phase': 5}, - {'id': '614626+19_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '19_phase=5', - 'phase': 5}, - {'id': '614626+20_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '20_phase=5', - 'phase': 5}, - {'id': '614626+21_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '21_phase=5', - 'phase': 5}, - {'id': '614626+22_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '22_phase=5', - 'phase': 5}, - {'id': '614626+23_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '23_phase=5', - 'phase': 5}, - {'id': '614626+24_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '24_phase=5', - 'phase': 5}, - {'id': '614626+25_phase=5', 'predictions': 0.9, 'property_id': '614626', - 'recommendation_id': '25_phase=5', - 'phase': 5}, - {'id': '614626+26_phase=5', 'predictions': 0.9, 'property_id': '614626', - 'recommendation_id': '26_phase=5', - 'phase': 5}, - {'id': '614626+27_phase=5', 'predictions': 0.9, 'property_id': '614626', - 'recommendation_id': '27_phase=5', - 'phase': 5}, - {'id': '614626+28_phase=5', 'predictions': 0.9, 'property_id': '614626', - 'recommendation_id': '28_phase=5', - 'phase': 5}, - {'id': '614626+29_phase=5', 'predictions': 0.8, 'property_id': '614626', - 'recommendation_id': '29_phase=5', - 'phase': 5}, - {'id': '614626+30_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '30_phase=5', - 'phase': 5}, - {'id': '614626+31_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '31_phase=5', - 'phase': 5}, - {'id': '614626+32_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '32_phase=5', - 'phase': 5}, - {'id': '614626+33_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '33_phase=5', - 'phase': 5}, - {'id': '614626+34_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '34_phase=5', - 'phase': 5}, - {'id': '614626+35_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '35_phase=5', - 'phase': 5}, - {'id': '614626+36_phase=5', 'predictions': 1.0, 'property_id': '614626', - 'recommendation_id': '36_phase=5', - 'phase': 5}, - {'id': '614626+37_phase=5', 'predictions': 1.5, 'property_id': '614626', - 'recommendation_id': '37_phase=5', - 'phase': 5}, - {'id': '614626+38_phase=5', 'predictions': 1.5, 'property_id': '614626', - 'recommendation_id': '38_phase=5', - 'phase': 5}, - {'id': '614626+39_phase=5', 'predictions': 1.5, 'property_id': '614626', - 'recommendation_id': '39_phase=5', - 'phase': 5}, - {'id': '614626+40_phase=5', 'predictions': 1.4, 'property_id': '614626', - 'recommendation_id': '40_phase=5', - 'phase': 5}, - {'id': '614626+41_phase=5', 'predictions': 1.4, 'property_id': '614626', - 'recommendation_id': '41_phase=5', - 'phase': 5}, - {'id': '614626+42_phase=5', 'predictions': 1.4, 'property_id': '614626', - 'recommendation_id': '42_phase=5', - 'phase': 5}, - {'id': '614626+43_phase=5', 'predictions': 1.4, 'property_id': '614626', - 'recommendation_id': '43_phase=5', - 'phase': 5}, - {'id': '614626+44_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '44_phase=5', - 'phase': 5}, - {'id': '614626+45_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '45_phase=5', - 'phase': 5}, - {'id': '614626+46_phase=5', 'predictions': 1.2, 'property_id': '614626', - 'recommendation_id': '46_phase=5', - 'phase': 5}, - {'id': '614626+47_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '47_phase=5', - 'phase': 5}, - {'id': '614626+48_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '48_phase=5', - 'phase': 5}, - {'id': '614626+49_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '49_phase=5', - 'phase': 5}, - {'id': '614626+50_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '50_phase=5', - 'phase': 5}, - {'id': '614626+51_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '51_phase=5', - 'phase': 5}, - {'id': '614626+52_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '52_phase=5', - 'phase': 5}, - {'id': '614626+53_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '53_phase=5', - 'phase': 5}, - {'id': '614626+54_phase=5', 'predictions': 1.1, 'property_id': '614626', - 'recommendation_id': '54_phase=5', - 'phase': 5}, - {'id': '614626+55_phase=5', 'predictions': 1.6, 'property_id': '614626', - 'recommendation_id': '55_phase=5', - 'phase': 5}, - {'id': '614626+56_phase=5', 'predictions': 1.5, 'property_id': '614626', - 'recommendation_id': '56_phase=5', - 'phase': 5}, - {'id': '614626+57_phase=5', 'predictions': 1.5, 'property_id': '614626', - 'recommendation_id': '57_phase=5', - 'phase': 5} - ] - ), + "heat_demand_predictions": heat_demand_predictions, + "carbon_change_predictions": carbon_predictions, "hotwater_kwh_predictions": pd.DataFrame([]), "heating_kwh_predictions": pd.DataFrame([]), } - # Mock the property - we need id and some of the data - p = Mock( - id=614626, - data={ - "current-energy-efficiency": 65, - "co2-emissions-current": 2.4, - "energy-consumption-current": 284, - "roof-energy-eff": "Good", - "lighting-energy-eff": "Good" - }, - roof={ - 'original_description': 'Pitched, 250 mm loft insulation', - 'clean_description': 'Pitched, 250 mm loft insulation', 'thermal_transmittance': None, - 'thermal_transmittance_unit': None, 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, - 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': False, - 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '250' - }, - lighting={ - 'original_description': 'Low energy lighting in 50% of fixed outlets', - 'clean_description': 'Low energy lighting in 50% of fixed outlets', 'low_energy_proportion': 0.5 - } + recommendations3 = { + 614626: [ + [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'survey': False, + 'recommendation_id': '0_phase=0', + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977)}, + ], + [ + { + 'phase': 1, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), + 'kwh_savings': 0, + 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, + 'innovation_rate': 0.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0} + ], + [ + { + 'phase': 2, + 'type': 'low_energy_lighting', + 'measure_type': 'low_energy_lighting', + 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'total': 10.5, 'contingency': 2.73, + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)} + ], + [ + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, + 'labour_hours': 0.5, 'labour_days': 1, + 'sap_points': np.float64(1.0), 'already_installed': False, + 'innovation_rate': 0.0, + 'recommendation_id': '5_phase=3', 'efficiency': 70, + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5) + }, + { + 'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control', + 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1, + 'subtotal': 571.32, + 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), + 'sap_points': np.float64(1.8), 'already_installed': False, + 'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(13.300000000000011) + }, + { + 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'sap_points': np.float64(3.8), + 'already_installed': False, + 'total': 17144.924, + 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08, + 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3', + 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003), + 'heat_demand': np.float64(59.30000000000001) + } + ], + [ + {'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'labour_days': np.float64(1.0), 'innovation_rate': 0.0, + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0)} + ], + [ + { + 'phase': 5, + 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'initial_ac_kwh_per_year': np.float64(4844.465553999999), + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + ] + } + + representative_recommendations3 = { + 614626: [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'already_installed': False, + 'total': 1029.0, 'contingency': 102.9, + 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False, + 'innovation_rate': 0.0, + 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977) + }, + { + 'phase': 1, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + 'starting_u_value': None, 'new_u_value': None, + 'already_installed': False, + 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, + 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, 'total': 560.0, + 'labour_hours': 8, 'labour_days': 1.0, + 'innovation_rate': 0.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0}, + { + 'phase': 2, + 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'total': 10.5, 'contingency': 2.73, + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'innovation_rate': 0.0, 'recommendation_id': '4_phase=2', 'efficiency': 10.5, + 'heat_demand': np.float64(4.100000000000023) + }, + { + 'type': 'heating', 'measure_type': + 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(1.0), 'already_installed': False, + 'innovation_rate': 0.0, + 'recommendation_id': '5_phase=3', 'efficiency': 70, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(8.5) + }, + { + 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'labour_days': np.float64(1.0), + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0)}, + { + 'phase': 5, + 'type': 'solar_pv', + 'measure_type': 'solar_pv', + + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + } + + recommendations_with_impact3, impact_summary3, adjustments3 = ( + Recommendations.calculate_recommendation_impact( + property_instance=property_instance, + all_predictions=all_predictions3, + recommendations=recommendations3, + representative_recommendations=representative_recommendations3, + debug=True + ) ) + # We expect adjustments for loft insulation, lighting and solar + + assert adjustments3 == [ + {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}, + {'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)}, + {'recommendation_id': '29_phase=5', 'phase': 5, 'sap_adjustment': np.float64(-2.5)} + ] + + # Check the impact has slowed through to solar - the final on the impact summary. The 5 + # point prediction isn't associated to the prediction from the model so the adjustment + # should be + + df = all_predictions3["sap_change_predictions"] + raw_prediction = 83.8 + # We expect 1.7 decrease from loft, 4 decrease from lighting, and 2.5 increase from solar + # for a total of a 3.2 decrease + expected_adjusted_prediction = raw_prediction - 3.2 + + assert impact_summary3[-1]["sap"] == expected_adjusted_prediction + + +def test_loft_adjustment_flows_to_solar(property_instance, heat_demand_predictions, carbon_predictions): + ######################## + # Case 1 + ######################## + # Just an adjustment to loft insulation + + sap_change_predictions = pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 66.3, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 67.3, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 68.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 70.1, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 67.3, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '10_phase=5', 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 86.7, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 83.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 86.4, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 83.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 85.5, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 85.4, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 79.4, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 81.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5}] + ) + + all_predictions = { + "sap_change_predictions": sap_change_predictions, + "heat_demand_predictions": heat_demand_predictions, + "carbon_change_predictions": carbon_predictions, + "hotwater_kwh_predictions": pd.DataFrame([]), + "heating_kwh_predictions": pd.DataFrame([]), + } + recommendations = { 614626: [ [ { - 'phase': 0, 'parts': [ - {'id': 3362, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 300.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', - 'created_at': Timestamp('2025-08-15 16:31:52.995292'), 'is_active': True, - 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, - 'total_cost': 21.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, - 'size': None, - 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None, - 'quantity': 54.125488565924286, 'quantity_unit': 'm2', 'total': 1029.0, 'contingency': 102.9, - 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1}], 'type': 'loft_insulation', + 'phase': 0, 'type': 'loft_insulation', 'measure_type': 'loft_insulation', - 'description': 'Install 300mm of Fibre loft insulation in your loft', - 'starting_u_value': np.float64(0.17), 'new_u_value': np.float64(0.14), 'sap_points': 0, - 'already_installed': False, 'simulation_config': {'roof_insulation_thickness_ending': '300', - 'roof_thermal_transmittance_ending': np.float64( - 0.14), - 'roof_energy_eff_ending': 'Very Good'}, - 'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation', - 'roof-energy-eff': 'Very Good'}, 'total': 1029.0, 'contingency': 102.9, + 'sap_points': 0, + 'already_installed': False, 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False, 'innovation_rate': 0.0, 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), @@ -564,126 +873,75 @@ def test_calculate_recommendation_impact(): ], [ { - 'phase': 1, 'parts': [{'id': 3337, 'type': 'mechanical_ventilation', - 'description': 'Decentralised mechanical extract ventilation', 'depth': 0.0, - 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', - 'r_value_per_mm': nan, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'CRG', - 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), - 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, - 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, - 'total_cost': 280.0, 'notes': None, 'is_installer_quote': True, - 'innovation_rate': 0.0, 'size': None, 'size_unit': None, - 'includes_scaffolding': False, 'includes_battery': False, - 'battery_size': None, - 'total': 560.0, 'quantity': 2, 'quantity_unit': 'part'}], + 'phase': 1, 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', - 'description': 'Install 2 Decentralised mechanical extract ventilation units', - 'starting_u_value': None, - 'new_u_value': None, 'already_installed': False, 'sap_points': np.float64(-1.4000000000000057), + 'already_installed': False, + 'sap_points': np.float64(-1.4000000000000057), 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, 'co2_equivalent_savings': np.float64(0.0), 'energy_cost_savings': 0, 'total': 560.0, 'labour_hours': 8, 'labour_days': 1.0, - 'simulation_config': {'mechanical_ventilation_ending': 'mechanical, extract only'}, - 'description_simulation': {'mechanical-ventilation': 'mechanical, extract only'}, 'innovation_rate': 0.0, 'recommendation_id': '3_phase=1', 'efficiency': 0} ], [ - {'phase': 2, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', - 'description': 'Install low energy lighting in 3 outlets', 'starting_u_value': None, + {'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', 'new_u_value': None, 'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25, 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), - 'description_simulation': {'lighting-energy-eff': 'Very Good', - 'lighting-description': 'Low energy lighting in all fixed outlets', - 'low-energy-lighting': 100}, 'total': 10.5, 'contingency': 2.73, 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, 'innovation_rate': 0.0, 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)} ], [ - {'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, 'parts': [], - 'description': 'Upgrade heating controls to Room thermostat, programmer and TRVs', 'total': 70, - 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, 'vat': 11.666666666666664, - 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None, - 'sap_points': np.float64(1.0), 'already_installed': False, - 'simulation_config': {'trvs_ending': 'trvs', 'mainheatc_energy_eff_ending': 'Good'}, - 'description_simulation': {'mainheatcont-description': 'Programmer, room thermostat and TRVS', - 'mainheatc-energy-eff': 'Good'}, 'innovation_rate': 0.0, - 'recommendation_id': '5_phase=3', 'efficiency': 70, - 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)}, - {'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control', 'parts': [], - 'description': 'Upgrade heating controls to Smart Thermostats, room sensors and smart radiator ' - 'valves (time & temperature zone control)', - 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1, - 'subtotal': 571.32, - 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), 'starting_u_value': None, - 'new_u_value': None, 'sap_points': np.float64(1.8), 'already_installed': False, - 'simulation_config': {'thermostatic_control_ending': 'time and temperature zone control', - 'switch_system_ending': None, 'mainheatc_energy_eff_ending': 'Very Good'}, - 'description_simulation': {'mainheatcont-description': 'Time and temperature zone control', - 'mainheatc-energy-eff': 'Very Good'}, 'innovation_rate': 0.0, - 'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001, - 'co2_equivalent_savings': np.float64(0.10000000000000009), - 'heat_demand': np.float64(13.300000000000011)}, - {'phase': 3, 'parts': [], 'type': 'heating', 'measure_type': 'air_source_heat_pump', - 'description': 'Install a 5KW air source heat pump, and upgrade heating controls to Smart ' - 'Thermostats, room sensors and smart radiator valves (time & temperature zone ' - 'control). Ensure you have a single tariff', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8), - 'already_installed': False, - 'simulation_config': {'mainheat_energy_eff_ending': 'Good', 'hot_water_energy_eff_ending': 'Average', - 'has_boiler_ending': False, 'has_air_source_heat_pump_ending': True, - 'has_electric_ending': True, 'has_mains_gas_ending': False, - 'fuel_type_ending': 'electricity', - 'thermostatic_control_ending': 'time and temperature zone control', - 'switch_system_ending': None, 'mainheatc_energy_eff_ending': 'Very Good'}, - 'description_simulation': {'mainheat-description': 'Air source heat pump, radiators, electric', - 'mainheat-energy-eff': 'Good', 'hot-water-energy-eff': 'Average', - 'hotwater-description': 'From main system', - 'main-fuel': 'electricity (not community)', - 'mainheatcont-description': 'Time and temperature zone control', - 'mainheatc-energy-eff': 'Very Good'}, 'total': 17144.924, - 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08, - 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3', - 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003), - 'heat_demand': np.float64(59.30000000000001)} + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, + 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(1.0), 'already_installed': False, + 'recommendation_id': '5_phase=3', 'efficiency': 70, + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5) + }, + { + 'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control', + 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1, + 'subtotal': 571.32, + 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(1.8), 'already_installed': False, + 'recommendation_id': '6_phase=3', + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(13.300000000000011) + }, + { + 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8), + 'already_installed': False, + 'total': 17144.924, + 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08, + 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3', + 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003), + 'heat_demand': np.float64(59.30000000000001) + } ], [ - {'phase': 4, 'parts': [], 'type': 'secondary_heating', 'measure_type': 'secondary_heating', - 'description': 'Remove the secondary heating system', 'starting_u_value': None, 'new_u_value': None, + {'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, - 'labour_days': np.float64(1.0), 'simulation_config': {'secondheat_description_ending': 'None'}, - 'description_simulation': {'secondheat-description': 'None'}, 'innovation_rate': 0.0, + 'labour_days': np.float64(1.0), 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), 'heat_demand': np.float64(0.0)} ], [ { - 'phase': 5, 'parts': [ - {'id': 3516, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0, - 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': nan, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, - 'thermal_conductivity_unit': None, 'link': 'Coactivation', - 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, 'total_cost': 5892.21, 'notes': '445W panels', 'is_installer_quote': True, - 'innovation_rate': 0.0, 'size': 5.34, 'size_unit': 'kWp', 'includes_scaffolding': True, - 'includes_battery': False, 'battery_size': None, 'panel_size': 445}], 'type': 'solar_pv', + 'phase': 5, 'type': 'solar_pv', 'measure_type': 'solar_pv', - 'description': 'Trina Vertex S3 445W solar panels - 5.34 kWp ' - 'system', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(16.0), 'already_installed': False, 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, 'labour_days': 2, 'has_battery': False, - 'simulation_config': {'photo_supply_ending': np.float64(80.0)}, 'initial_ac_kwh_per_year': np.float64(4844.465553999999), - 'description_simulation': {'photo-supply': np.float64(80.0)}, 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', 'efficiency': np.float64(368.263125) } @@ -694,28 +952,10 @@ def test_calculate_recommendation_impact(): representative_recommendations = { 614626: [ { - 'phase': 0, 'parts': [ - {'id': 3362, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 300.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', - 'created_at': Timestamp('2025-08-15 16:31:52.995292'), 'is_active': True, - 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, - 'total_cost': 21.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, - 'size': None, - 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None, - 'quantity': 54.125488565924286, 'quantity_unit': 'm2', 'total': 1029.0, 'contingency': 102.9, - 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1}], 'type': 'loft_insulation', + 'phase': 0, 'type': 'loft_insulation', 'measure_type': 'loft_insulation', - 'description': 'Install 300mm of Fibre loft insulation in your loft', 'starting_u_value': np.float64(0.17), 'new_u_value': np.float64(0.14), 'sap_points': 0, - 'already_installed': False, 'simulation_config': {'roof_insulation_thickness_ending': '300', - 'roof_thermal_transmittance_ending': np.float64( - 0.14), - 'roof_energy_eff_ending': 'Very Good'}, - 'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation', - 'roof-energy-eff': 'Very Good'}, 'total': 1029.0, 'contingency': 102.9, + 'already_installed': False, 'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False, 'innovation_rate': 0.0, 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), @@ -723,22 +963,9 @@ def test_calculate_recommendation_impact(): 'heat_demand': np.float64(27.399999999999977) }, { - 'phase': 1, 'parts': [ - {'id': 3337, 'type': 'mechanical_ventilation', - 'description': 'Decentralised mechanical extract ventilation', 'depth': 0.0, 'depth_unit': None, - 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, - 'thermal_conductivity_unit': None, 'link': 'CRG', - 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, 'total_cost': 280.0, 'notes': None, 'is_installer_quote': True, - 'innovation_rate': 0.0, - 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, - 'battery_size': None, 'total': 560.0, 'quantity': 2, 'quantity_unit': 'part'}], + 'phase': 1, 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', - 'description': 'Install 2 Decentralised mechanical ' - 'extract ventilation units', 'starting_u_value': None, 'new_u_value': None, 'already_installed': False, 'sap_points': np.float64(-1.4000000000000057), @@ -746,79 +973,48 @@ def test_calculate_recommendation_impact(): 'co2_equivalent_savings': np.float64(0.0), 'energy_cost_savings': 0, 'total': 560.0, 'labour_hours': 8, 'labour_days': 1.0, - 'simulation_config': { - 'mechanical_ventilation_ending': 'mechanical, ' - 'extract only'}, - 'description_simulation': { - 'mechanical-ventilation': 'mechanical, ' - 'extract only'}, - 'innovation_rate': 0.0, 'recommendation_id': '3_phase=1', 'efficiency': 0}, { - 'phase': 2, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', - 'description': 'Install low energy lighting in 3 outlets', 'starting_u_value': None, + 'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'starting_u_value': None, 'new_u_value': None, 'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25, 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), - 'description_simulation': {'lighting-energy-eff': 'Very Good', - 'lighting-description': 'Low energy lighting in all fixed outlets', - 'low-energy-lighting': 100}, 'total': 10.5, 'contingency': 2.73, 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, 'innovation_rate': 0.0, 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023) }, { - 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, 'parts': [], - 'description': 'Upgrade heating controls to Room thermostat, programmer and TRVs', 'total': 70, + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, 'vat': 11.666666666666664, 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(1.0), 'already_installed': False, - 'simulation_config': {'trvs_ending': 'trvs', 'mainheatc_energy_eff_ending': 'Good'}, - 'description_simulation': {'mainheatcont-description': 'Programmer, room thermostat and TRVS', - 'mainheatc-energy-eff': 'Good'}, 'innovation_rate': 0.0, 'recommendation_id': '5_phase=3', 'efficiency': 70, 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5) }, { - 'phase': 4, 'parts': [], 'type': 'secondary_heating', 'measure_type': 'secondary_heating', - 'description': 'Remove the secondary heating system', 'starting_u_value': None, 'new_u_value': None, + 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, - 'labour_days': np.float64(1.0), 'simulation_config': {'secondheat_description_ending': 'None'}, - 'description_simulation': {'secondheat-description': 'None'}, 'innovation_rate': 0.0, 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), 'heat_demand': np.float64(0.0)}, { - 'phase': 5, 'parts': [ - {'id': 3516, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0, - 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': nan, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, - 'thermal_conductivity_unit': None, 'link': 'Coactivation', - 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, 'total_cost': 5892.21, 'notes': '445W panels', 'is_installer_quote': True, - 'innovation_rate': 0.0, 'size': 5.34, 'size_unit': 'kWp', 'includes_scaffolding': True, - 'includes_battery': False, 'battery_size': None, 'panel_size': 445}], 'type': 'solar_pv', + 'phase': 5, 'type': 'solar_pv', 'measure_type': 'solar_pv', - 'description': 'Trina Vertex S3 445W solar panels - 5.34 kWp ' - 'system', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(16.0), 'already_installed': False, 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, 'labour_days': 2, 'has_battery': False, - 'simulation_config': {'photo_supply_ending': np.float64(80.0)}, - 'initial_ac_kwh_per_year': np.float64(4844.465553999999), - 'description_simulation': {'photo-supply': np.float64(80.0)}, - 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', - 'efficiency': np.float64(368.263125) + 'recommendation_id': '29_phase=5', } ] } recommendations_with_impact, impact_summary, adjustments = ( Recommendations.calculate_recommendation_impact( - property_instance=p, + property_instance=property_instance, all_predictions=all_predictions, recommendations=recommendations, representative_recommendations=representative_recommendations, @@ -833,11 +1029,308 @@ def test_calculate_recommendation_impact(): # We expect that adjustment to flow through to the final recommendation so that the solar recommendation has # a 1.7 sap point reduction in impact - assert float(impact_summary[-1]["sap"]) == 82.1 - assert float(impact_summary[-1]["sap_prediction"]) == 83.8 + final_impact_summary = impact_summary[-1] + assert float(final_impact_summary["sap"]) == 82.1 + assert float(final_impact_summary["sap_prediction"]) == 83.8 + assert final_impact_summary["measure_type"] == "solar_pv" + assert recommendations_with_impact[0][0]["sap_points"] == 0 - assert impact_summary[-1] == { - 'phase': 5, 'representative': True, 'recommendation_id': '29_phase=5', 'measure_type': 'solar_pv', - 'sap': np.float64(82.1), 'carbon': np.float64(0.8), 'heat_demand': np.float64(82.5), - 'sap_prediction': np.float64(83.8) + +def test_lighting_and_loft_adjustment_combined(property_instance, heat_demand_predictions, carbon_predictions): + ######################## + # Case 2 + ######################## + # Example case with both a loft insulation and lighting adjustment + # lighting now has a SAP point impact of 5 - the affected recommendation is + # recommendation_id=4_phase=2 + all_predictions2 = { + "sap_change_predictions": pd.DataFrame( + [ + {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '0_phase=0', + 'phase': 0}, + {'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '1_phase=0', + 'phase': 0}, + {'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626', + 'recommendation_id': '2_phase=0', + 'phase': 0}, + {'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626', + 'recommendation_id': '3_phase=1', + 'phase': 1}, + {'id': '614626+4_phase=2', 'predictions': 71.3, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 72.3, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 73.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 75.1, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 72.3, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 90.3, 'property_id': '614626', + 'recommendation_id': '9_phase=5', + 'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626', + 'recommendation_id': '10_phase=5', 'phase': 5}, + {'id': '614626+11_phase=5', 'predictions': 90.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 88.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 84.4, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5}] + ), + "heat_demand_predictions": heat_demand_predictions, + "carbon_change_predictions": carbon_predictions, + "hotwater_kwh_predictions": pd.DataFrame([]), + "heating_kwh_predictions": pd.DataFrame([]), } + + recommendations2 = { + 614626: [ + [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'survey': False, + 'innovation_rate': 0.0, + 'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587), + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977)}, + ], + [ + { + 'phase': 1, + 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', + 'starting_u_value': None, + 'new_u_value': None, 'already_installed': False, 'sap_points': np.float64(-1.4000000000000057), + 'heat_demand': np.float64(-6.5), 'kwh_savings': 0, 'co2_equivalent_savings': np.float64(0.0), + 'energy_cost_savings': 0, 'total': 560.0, 'labour_hours': 8, 'labour_days': 1.0, + 'recommendation_id': '3_phase=1', + } + ], + [ + { + 'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'new_u_value': None, + 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'total': 10.5, 'contingency': 2.73, + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'innovation_rate': 0.0, + 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)} + ], + [ + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, + 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(1.0), 'already_installed': False, + 'recommendation_id': '5_phase=3', + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)}, + { + 'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control', + 'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1, + 'subtotal': 571.32, + 'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), 'starting_u_value': None, + 'new_u_value': None, 'sap_points': np.float64(1.8), 'already_installed': False, + 'innovation_rate': 0.0, + 'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(13.300000000000011)}, + { + 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8), + 'already_installed': False, + 'total': 17144.924, + 'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08, + 'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3', + 'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003), + 'heat_demand': np.float64(59.30000000000001)} + ], + [ + { + 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0, + 'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0, + 'labour_days': np.float64(1.0), 'innovation_rate': 0.0, + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0) + } + ], + [ + { + 'phase': 5, 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'starting_u_value': None, 'new_u_value': None, + 'sap_points': np.float64(16.0), 'already_installed': False, + 'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315, + 'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48, + 'labour_days': 2, 'has_battery': False, + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + ] + } + + representative_recommendations2 = { + 614626: [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'survey': False, + 'recommendation_id': '0_phase=0', + }, + { + 'phase': 1, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + 'sap_points': np.float64(-1.4000000000000057), + 'recommendation_id': '3_phase=1' + }, + { + 'phase': 2, + 'type': 'low_energy_lighting', + 'measure_type': 'low_energy_lighting', + 'sap_points': 5, + 'survey': True, + 'recommendation_id': '4_phase=2', + }, + { + 'type': 'heating', + 'measure_type': 'roomstat_programmer_trvs', + 'phase': 3, + 'sap_points': np.float64(1.0), + 'recommendation_id': '5_phase=3', + }, + { + 'phase': 4, + 'type': 'secondary_heating', + 'measure_type': 'secondary_heating', + 'sap_points': np.float64(0.0), + 'recommendation_id': '8_phase=4', + }, + { + 'phase': 5, + 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'sap_points': np.float64(16.0), + 'recommendation_id': '29_phase=5', + } + ] + } + + recommendations_with_impact2, impact_summary2, adjustments2 = ( + Recommendations.calculate_recommendation_impact( + property_instance=property_instance, + all_predictions=all_predictions2, + recommendations=recommendations2, + representative_recommendations=representative_recommendations2, + debug=True + ) + ) + + assert adjustments2 == [ + {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}, + {'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)} + ] diff --git a/tox.ini b/tox.ini index 19a4ad9a..e330f564 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ passenv = EPC_AUTH_TOKEN description = Install dependencies and run tests deps = -rbackend/engine/requirements.txt + -rbackend/app/requirements/requirements.txt -rtest.requirements.txt commands = pytest From 2dda567e2de530f192e529912c4d453dc0243aea Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 20 Jan 2026 16:36:55 +0000 Subject: [PATCH 05/74] refactor to break recommendaiton impact into functions --- recommendations/Recommendations.py | 543 ++++++++++++++++------------- 1 file changed, 304 insertions(+), 239 deletions(-) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 2795d7ff..33558fae 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -561,6 +561,262 @@ class Recommendations: return cls.MV_INCREASING_VARIABLES, cls.MV_DECREASING_VARIABLES return cls.INCREASING_VARIABLES, cls.DECREASING_VARIABLES + @staticmethod + def _get_previous_phase_values( + rec_phase: int, + starting_phase: int, + impact_summary: list[dict], + property_instance: Property, + ) -> dict: + if rec_phase == starting_phase: + return { + "sap": float(property_instance.data["current-energy-efficiency"]), + "carbon": float(property_instance.data["co2-emissions-current"]), + "heat_demand": float(property_instance.data["energy-consumption-current"]), + } + + previous_phase_reps = [ + x for x in impact_summary + if x["phase"] == rec_phase - 1 and x["representative"] + ] + + if len(previous_phase_reps) == 1: + return previous_phase_reps[0] + + # Median fallback (including zero-length case) + keys = ("sap", "carbon", "heat_demand") + return { + key: np.median([item[key] for item in previous_phase_reps]) + for key in keys + } + + @classmethod + def _get_phase_predictions( + cls, + property_predictions: dict, + recommendation_id: str, + ) -> dict: + return { + prefix: ( + property_predictions[f"{prefix}_predictions"] + .loc[ + property_predictions[f"{prefix}_predictions"]["recommendation_id"] + == str(recommendation_id), + "predictions", + ] + .values[0] + ) + for prefix in cls.PREDICTION_PREFIXES + } + + @classmethod + def _resolve_current_phase_sap( + cls, + rec: Mapping[str, any], + previous_phase_values: Mapping[str, any], + phase_energy_efficiency_metrics: Mapping[str, any], + adjustments: list[dict], + ) -> float: + if rec.get("survey", False): + return rec["sap_points"] + previous_phase_values["sap"] + + sap = phase_energy_efficiency_metrics["sap_change"] + + prior_adjustments = [a for a in adjustments if a["phase"] < rec["phase"]] + if not prior_adjustments: + return sap + + filtered = cls._filter_phase_adjustment(prior_adjustments) + return sap - sum(a["sap_adjustment"] for a in filtered) + + @classmethod + def _compute_phase_impact( + cls, + rec_type: str, + previous_phase_values: dict, + current_phase_values: dict, + ) -> dict: + """ + Utility function for computing the impact of a recommendation phase, enforcing monotonicity + + :param rec_type: string, the recommendation type + :param previous_phase_values: dict, the previous phase values + :param current_phase_values: dict, the current phase values + :return: dict, the impact of the phase + """ + phase_increasing, phase_decreasing = cls.get_monotonic_variables(rec_type) + + # Enforce monotonicity + for v in phase_increasing: + current_phase_values[v] = max(current_phase_values[v], previous_phase_values[v]) + + for v in phase_decreasing: + current_phase_values[v] = min(current_phase_values[v], previous_phase_values[v]) + + # Compute impact + impact = { + "sap": current_phase_values["sap"] - previous_phase_values["sap"], + "carbon": previous_phase_values["carbon"] - current_phase_values["carbon"], + "heat_demand": previous_phase_values["heat_demand"] - current_phase_values["heat_demand"], + } + + # Clamp values + for metric in impact: + if rec_type != "mechanical_ventilation": + impact[metric] = max(0, impact[metric]) + if metric == "sap": + impact[metric] = round(impact[metric], 2) + else: + impact[metric] = min(0, impact[metric]) + + return impact + + @classmethod + def _apply_measure_specific_rules( + cls, + rec: dict, + property_phase_impact: dict, + previous_phase_values: dict, + current_phase_values: dict, + adjustments: list, + property_instance, + ): + # For the moment, we cap the number of SAP points that can be achieved by LEDs at 2 + if rec["type"] == "low_energy_lighting": + lighting_sap_limit = LightingRecommendations.get_sap_limit( + property_instance.data["lighting-energy-eff"], + property_instance.lighting["low_energy_proportion"] + ) + + # add an adjustment + proposed_sap_impact = min(property_phase_impact["sap"], lighting_sap_limit) + if proposed_sap_impact != property_phase_impact["sap"]: + # Store the sap adjustment. The proposed sap impact will always be less + # than the current sap impact, so the adjustment is always positive + # as we subtract it from the future phases + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + "sap_adjustment": property_phase_impact["sap"] - proposed_sap_impact, + } + ) + + property_phase_impact["sap"] = proposed_sap_impact + property_phase_impact["carbon"] = min( + property_phase_impact["carbon"], rec["co2_equivalent_savings"] + ) + + # Update the current phase values + current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + current_phase_values["carbon"] = previous_phase_values["carbon"] - property_phase_impact["carbon"] + elif rec["type"] == "mechanical_ventilation": + # ventilation is capped by having no greater and a -4 impact + ventilation_sap_limit = -4 + ventilation_out_of_bounds = cls._check_veniltation_out_of_bounds( + property_phase_impact["sap"], ventilation_sap_limit + ) + + if ventilation_out_of_bounds: + previous_modelled_sap = previous_phase_values.get("sap_prediction", 0) + proposed_sap_impact = current_phase_values["sap"] - previous_modelled_sap + proposal_out_of_bounds = cls._check_veniltation_out_of_bounds( + proposed_sap_impact, ventilation_sap_limit + ) + if proposal_out_of_bounds: + proposed_sap_impact = cls._adjust_ventilation_sap( + proposed_sap_impact, ventilation_sap_limit + ) + + # We keep track of the adjustment + # In this case, if the SAP impact has increased, then the adustment should be negative + # otherwise it should be positive + # When we add the total adjustment, it's an addition + # Example + # Before: 60, impact -2 => 58 + # After: 60, impact -1 (So the impact is bigger) => 59 + # So in this case, we need to make sure we add 1 to all future predictions so + # the adjustment should be positive + # Before: 60, impact 1 => 61 + # After: 60, impact -1 => 59 + # So in this case, we need to make sure we subtract 1 to all future predictions so + # the adjustment should be negative + # Both cases are reflected in sap adjustment + sap_adjustment = proposed_sap_impact - float(property_phase_impact["sap"]) + + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + "sap_adjustment": sap_adjustment, + } + ) + + property_phase_impact["sap"] = proposed_sap_impact + + # Update the current phase values + current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + elif rec["type"] == "loft_insulation": + # When we have a loft insulation recommendation, where there is an extension and the existing + # amount of loft insulation is already good, we limit the SAP points + # By limiting here, we don't change the value in current_phase_values. This means that the + # future recommendations won't have an impact that is too large + li_sap_limit = RoofRecommendations.get_loft_insulation_sap_limit( + property_instance.data["roof-energy-eff"], property_instance.roof["insulation_thickness"] + ) + if li_sap_limit is not None: + new_value = min(property_phase_impact["sap"], li_sap_limit) + # If we've made an adjustment, keep track of it + if new_value != property_phase_impact["sap"]: + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + # If we've made an adjustment, it will be negative + "sap_adjustment": property_phase_impact["sap"] - new_value, + } + ) + property_phase_impact["sap"] = new_value + # Update the current phase values + current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + elif rec["type"] == "solar_pv": + # We use the SAP points in the recommendation as a minimum + proposed_impact = ( + rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else + property_phase_impact["sap"] + ) + + # SAP adjustments should be negative + if proposed_impact != property_phase_impact["sap"]: + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + # If we've made an adjustment, we will be increasing the number of SAP + # points. Since, we subtract adjustments, this number should be negative + "sap_adjustment": property_phase_impact["sap"] - proposed_impact, + } + ) + property_phase_impact["sap"] = proposed_impact + + # Update the current phase values + current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + + return property_phase_impact, current_phase_values, adjustments + + @staticmethod + def _validate_recommendation_updates(rec: Mapping[str, any]): + """ + Utility function to validate that the recommendation updates have been applied correctly + :param rec: updated recommendation + :return: + """ + if ( + (rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or + (rec["heat_demand"] is None) + ): + raise ValueError("sap points, co2 or heat demand is missing") + @classmethod def calculate_recommendation_impact( cls, @@ -575,6 +831,9 @@ class Recommendations: Given predictions from the model apis, with method will update the recommendations with the predicted impact of the recommendation on the property + This algorithm is structured as a large loop, but this is due to the fact that it's sequential in nature - + each phase depends on the previous, with adjustments and constraints being allied along the way + This function will return two objects: 1) Updated recommendations with the predicted impact of the recommendation 2) A list of impacts by phase, which will be used for the kwh model scoring @@ -595,8 +854,9 @@ class Recommendations: # shallow copy intentional - we're going to modify the internals property_recommendations = recommendations[property_instance.id].copy() - representative_recs = representative_recommendations[property_instance.id].copy() - representative_ids = [r["recommendation_id"] for r in representative_recs] + representative_ids = [ + r["recommendation_id"] for r in representative_recommendations[property_instance.id] + ] # We allow for negative phase starting_phase = min(rec["phase"] for recs in property_recommendations for rec in recs) @@ -606,20 +866,19 @@ class Recommendations: impact_summary, adjustments = [], [] for recommendations_by_type in property_recommendations: for rec in recommendations_by_type: - if rec["type"] in ["trickle_vents", "draught_proofing", "extension_cavity_wall_insulation"]: - # We don't have a percieved sap impact of mechanical ventilation or trickle vents, and we don't - # have the capacity to score draught proofing + # --- Special-case: non-modelled measures ------------------------- + if rec["type"] in { + "trickle_vents", + "draught_proofing", + "extension_cavity_wall_insulation", + }: if rec["type"] == "extension_cavity_wall_insulation": - - previous_phase = [x for x in impact_summary if x["phase"] == (rec["phase"] - 1)] - if previous_phase: - sap = previous_phase[0]["sap"] - carbon = previous_phase[0]["carbon"] - heat_demand = previous_phase[0]["heat_demand"] - else: - sap = float(property_instance.data["current-energy-efficiency"]) - carbon = float(property_instance.data["co2-emissions-current"]) - heat_demand = float(property_instance.data["energy-consumption-current"]) + previous = cls._get_previous_phase_values( + rec_phase=rec["phase"], + starting_phase=starting_phase, + impact_summary=impact_summary, + property_instance=property_instance, + ) impact_summary.append( { @@ -627,72 +886,29 @@ class Recommendations: "representative": rec["recommendation_id"] in representative_ids, "recommendation_id": rec["recommendation_id"], "measure_type": rec["measure_type"], - "sap": sap + rec["sap_points"], - "carbon": carbon - rec["co2_equivalent_savings"], - "heat_demand": heat_demand - rec["heat_demand"], + "sap": previous["sap"] + rec["sap_points"], + "carbon": previous["carbon"] - rec["co2_equivalent_savings"], + "heat_demand": previous["heat_demand"] - rec["heat_demand"], } ) continue - phase_energy_efficiency_metrics = { - prefix: property_predictions[prefix + "_predictions"][ - property_predictions[prefix + "_predictions"]["recommendation_id"] == str( - rec["recommendation_id"] - )]["predictions"].values[0] for prefix in cls.PREDICTION_PREFIXES - } + phase_energy_efficiency_metrics = cls._get_phase_predictions( + property_predictions=property_predictions, + recommendation_id=rec["recommendation_id"], + ) - # We structure this so that depending on the phase, we capture the previous phase impacts and - # then just have one piece of code to calculate the difference - if rec["phase"] == starting_phase: - # These are just the starting values, from the EPC. When we score the ML models, - # heating_cost_starting and heating_cost_ending are just the values in the EPC. However, with - # heating_cost_ending, we expect that the EPC will predict a heating cost based on what would happen - # if we implemented the recommendation today, so our starting value is the EPC - - previous_phase_values = { - "sap": float(property_instance.data["current-energy-efficiency"]), - # For carbon, even though we generally use the updated figure which includes the carbon - # associated to appliances, for this scoring process we use the EPC carbon value. This means - # that we don't overestimate the impact since the model uses the EPC carbon value - "carbon": float(property_instance.data["co2-emissions-current"]), - "heat_demand": float(property_instance.data["energy-consumption-current"]), - } - - else: - - previous_phase_values_multiple = [ - x for x in impact_summary if x["phase"] == (rec["phase"] - 1) and x["representative"] - ] - if len(previous_phase_values_multiple) != 1: - # Take an average of each of the previous phases - keys_to_median = ["sap", "carbon", "heat_demand"] - - previous_phase_values = {} - for key in keys_to_median: - values = [item[key] for item in previous_phase_values_multiple] - previous_phase_values[key] = np.median(values) - - else: - previous_phase_values = previous_phase_values_multiple[0] - - # We extract the values for the current phase - if rec.get("survey", False): - current_phase_sap = rec["sap_points"] + previous_phase_values["sap"] - else: - current_phase_sap = phase_energy_efficiency_metrics["sap_change"] - # If we have an adjustment, we apply it here. We de-dupe, taking the - # largest adjustment by phase - though, they should all be the same - phase_adjustments = [a for a in adjustments if a["phase"] < rec["phase"]] - if phase_adjustments: - phase_adjustments = cls._filter_phase_adjustment(phase_adjustments) - total_adjustment = sum( - a["sap_adjustment"] for a in phase_adjustments - ) - # Take the max, by phase, subtract from the current phase sap - current_phase_sap -= total_adjustment + previous_phase_values = cls._get_previous_phase_values( + rec_phase=rec["phase"], + starting_phase=starting_phase, + impact_summary=impact_summary, + property_instance=property_instance + ) current_phase_values = { - "sap": current_phase_sap, + "sap": cls._resolve_current_phase_sap( + rec, previous_phase_values, phase_energy_efficiency_metrics, adjustments + ), "carbon": phase_energy_efficiency_metrics["carbon_change"], "heat_demand": phase_energy_efficiency_metrics["heat_demand"], } @@ -705,167 +921,20 @@ class Recommendations: # However, if the recommendation is mechanical ventilation, this can have a negative SAP impact so # we don't apply this rule - phase_increasing_variables, phase_decreasing_variables = cls.get_monotonic_variables(rec["type"]) + property_phase_impact = cls._compute_phase_impact( + rec_type=rec["type"], + previous_phase_values=previous_phase_values, + current_phase_values=current_phase_values, + ) - for v in phase_increasing_variables: - current_phase_values[v] = ( - current_phase_values[v] if current_phase_values[v] > previous_phase_values[v] else - previous_phase_values[v] - ) - for v in previous_phase_values: - if v in phase_decreasing_variables: - current_phase_values[v] = ( - current_phase_values[v] if current_phase_values[v] < previous_phase_values[v] else - previous_phase_values[v] - ) - - property_phase_impact = { - # Increasing - "sap": current_phase_values["sap"] - previous_phase_values["sap"], - # Decreasing - "carbon": previous_phase_values["carbon"] - current_phase_values["carbon"], - # Decreasing - "heat_demand": previous_phase_values["heat_demand"] - current_phase_values["heat_demand"], - } - - # Prevent from being negative - apart from ventilation - for metric in ["sap", "carbon", "heat_demand"]: - if rec["type"] != "mechanical_ventilation": - property_phase_impact[metric] = ( - 0 if property_phase_impact[metric] < 0 else property_phase_impact[metric] - ) - if metric == "sap": - property_phase_impact[metric] = round(property_phase_impact[metric], 2) - else: - # We prevent mechanical ventilation from being positive - property_phase_impact[metric] = ( - 0 if property_phase_impact[metric] > 0 else property_phase_impact[metric] - ) - - # For the moment, we cap the number of SAP points that can be achieved by LEDs at 2 - if rec["type"] == "low_energy_lighting": - lighting_sap_limit = LightingRecommendations.get_sap_limit( - property_instance.data["lighting-energy-eff"], - property_instance.lighting["low_energy_proportion"] - ) - - # add an adjustment - proposed_sap_impact = min(property_phase_impact["sap"], lighting_sap_limit) - if proposed_sap_impact != property_phase_impact["sap"]: - # Store the sap adjustment. The proposed sap impact will always be less - # than the current sap impact, so the adjustment is always positive - # as we subtract it from the future phases - adjustments.append( - { - "recommendation_id": rec["recommendation_id"], - "phase": rec["phase"], - "sap_adjustment": property_phase_impact["sap"] - proposed_sap_impact, - } - ) - - property_phase_impact["sap"] = proposed_sap_impact - property_phase_impact["carbon"] = min( - property_phase_impact["carbon"], rec["co2_equivalent_savings"] - ) - - # Update the current phase values - current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] - current_phase_values["carbon"] = previous_phase_values["carbon"] - property_phase_impact["carbon"] - - # We also ensure that mechanical ventilation doesn't have an ovely strong negative SAP impact - if rec["type"] == "mechanical_ventilation": - # ventilation is capped by having no greater and a -4 impact - ventilation_sap_limit = -4 - ventilation_out_of_bounds = cls._check_veniltation_out_of_bounds( - property_phase_impact["sap"], ventilation_sap_limit - ) - - if ventilation_out_of_bounds: - previous_modelled_sap = previous_phase_values.get("sap_prediction", 0) - proposed_sap_impact = current_phase_sap - previous_modelled_sap - proposal_out_of_bounds = cls._check_veniltation_out_of_bounds( - proposed_sap_impact, ventilation_sap_limit - ) - if proposal_out_of_bounds: - proposed_sap_impact = cls._adjust_ventilation_sap( - proposed_sap_impact, ventilation_sap_limit - ) - - # We keep track of the adjustment - # In this case, if the SAP impact has increased, then the adustment should be negative - # otherwise it should be positive - # When we add the total adjustment, it's an addition - # Example - # Before: 60, impact -2 => 58 - # After: 60, impact -1 (So the impact is bigger) => 59 - # So in this case, we need to make sure we add 1 to all future predictions so - # the adjustment should be positive - # Before: 60, impact 1 => 61 - # After: 60, impact -1 => 59 - # So in this case, we need to make sure we subtract 1 to all future predictions so - # the adjustment should be negative - # Both cases are reflected in sap adjustment - sap_adjustment = proposed_sap_impact - float(property_phase_impact["sap"]) - - adjustments.append( - { - "recommendation_id": rec["recommendation_id"], - "phase": rec["phase"], - "sap_adjustment": sap_adjustment, - } - ) - - property_phase_impact["sap"] = proposed_sap_impact - - # Update the current phase values - current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] - - if rec["type"] == "loft_insulation": - # When we have a loft insulation recommendation, where there is an extension and the existing - # amount of loft insulation is already good, we limit the SAP points - # By limiting here, we don't change the value in current_phase_values. This means that the - # future recommendations won't have an impact that is too large - li_sap_limit = RoofRecommendations.get_loft_insulation_sap_limit( - property_instance.data["roof-energy-eff"], property_instance.roof["insulation_thickness"] - ) - if li_sap_limit is not None: - new_value = min(property_phase_impact["sap"], li_sap_limit) - # If we've made an adjustment, keep track of it - if new_value != property_phase_impact["sap"]: - adjustments.append( - { - "recommendation_id": rec["recommendation_id"], - "phase": rec["phase"], - # If we've made an adjustment, it will be negative - "sap_adjustment": property_phase_impact["sap"] - new_value, - } - ) - property_phase_impact["sap"] = new_value - # Update the current phase values - current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] - - if rec["type"] == "solar_pv": - # We use the SAP points in the recommendation as a minimum - proposed_impact = ( - rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else - property_phase_impact["sap"] - ) - - # SAP adjustments should be negative - if proposed_impact != property_phase_impact["sap"]: - adjustments.append( - { - "recommendation_id": rec["recommendation_id"], - "phase": rec["phase"], - # If we've made an adjustment, we will be increasing the number of SAP - # points. Since, we subtract adjustments, this number should be negative - "sap_adjustment": property_phase_impact["sap"] - proposed_impact, - } - ) - property_phase_impact["sap"] = proposed_impact - - # Update the current phase values - current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"] + property_phase_impact, current_phase_values, adjustments = cls._apply_measure_specific_rules( + rec=rec, + property_phase_impact=property_phase_impact, + previous_phase_values=previous_phase_values, + current_phase_values=current_phase_values, + adjustments=adjustments, + property_instance=property_instance + ) # Insert this information into the recommendation. if not rec.get("survey", False): @@ -874,11 +943,7 @@ class Recommendations: rec["co2_equivalent_savings"] = property_phase_impact["carbon"] rec["heat_demand"] = property_phase_impact["heat_demand"] - if ( - (rec["sap_points"] is None) and (rec["co2_equivalent_savings"] is None) or - (rec["heat_demand"] is None) - ): - raise ValueError("sap points, co2 or heat demand is missing") + cls._validate_recommendation_updates(rec) impact_summary.append( { From c8abc19e599440fbe1faed11b37014e3a6e8b187 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 20 Jan 2026 17:18:51 +0000 Subject: [PATCH 06/74] =?UTF-8?q?Map=20LbwfHouse=20to=20AssetCondition=20l?= =?UTF-8?q?ist=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/domain/asset_condition.py | 9 +- backend/condition/domain/element.py | 125 ++++++++- .../condition/domain/mapping/lbwf_mapper.py | 8 +- .../tests/mapping/test_lbwf_mapper.py | 239 ++++++++++++++++++ .../tests/parsing/test_lbwf_parser.py | 2 +- 5 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 backend/condition/tests/mapping/test_lbwf_mapper.py diff --git a/backend/condition/domain/asset_condition.py b/backend/condition/domain/asset_condition.py index df87fd9f..2b7946c2 100644 --- a/backend/condition/domain/asset_condition.py +++ b/backend/condition/domain/asset_condition.py @@ -6,6 +6,9 @@ from backend.condition.domain.element import Element @dataclass class AssetCondition: uprn: int - element: Element - condition_description: str - renewal_year: Optional[int] = None \ No newline at end of file + element: Element # TODO: should HHSRS elements be handled differently? + condition_description: str # TODO: this probably needs to be some sort of enum so it's searchable/filterable on the frontend + quantity: int + renewal_year: Optional[int] = None + source: Optional[str] = None + # TODO: add install_date diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index 89246f9c..021c8492 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -1,4 +1,123 @@ -from enum import Enum +from enum import StrEnum -class Element(Enum): - pass \ No newline at end of file + +class Element(StrEnum): + AHR_CAT = "Accessible Housing Register Category" + ASBESTOS = "Asbestos Present" + ASSETSAREA = "Assets Area for Decent Homes and Investment" + DECNTHMINC = "Include for Decent Homes Reporting - LBWF Stock" + EICINSFREQ = "EICR - Elec Install Conditions Report Inspection Frequency" + EXTBALCONY = "Private Balconies in External Area" + EXTBKSDDR1 = "Back and Side Doors 1 in External Area" + EXTBKSDDR2 = "Back and Side Doors 2 in External Area" + EXTBPOINTG = "Brickwork Pointing in External Area" + EXTCHIMNEY = "Chimneys in External Area" + EXTDWNPTYP = "Downpipes in External Area" + EXTDRPKERB = "Drop Kerb in External Area" + EXTEXTDECS = "External Decorations in External Area" + EXTFASOFBR = "Fascia / Soffit / Bargeboard in External Area" + EXTGARDOOR = "Garage Door in External Area" + EXTGARROOF = "Garage Roof in External Area" + EXTGARSTDR = "Garage and Store Doors in External Area" + EXTGARSTRF = "Garage and Store Roofs in External Area" + EXTGARSTWD = "Garage and Store Windows in External Area" + EXTGARWDWS = "Garage Windows in External Area" + EXTGUTRTYP = "Gutters in External Area" + EXTHARDSTD = "Hardstanding in External Area" + EXTINTDWNP = "Internal Downpipes in External Area" + EXTLINTELS = "Lintels in External Area" + EXTOUTBOH = "Overhaul of Outbuilding in External Area" + EXTPARKING = "Parking in External Area" + EXTPCHCNPY = "Porch and / or Canopy in External Area" + EXTPTFRDR1 = "Patio and French Doors 1 in External Area" + EXTROOF1 = "Roof Covering 1 in External Area" + EXTROOF2 = "Roof Covering 2 in External Area" + EXTROOF3 = "Roof Covering 3 in External Area" + EXTRFSTR1 = "Roof Structure 1 in External Area" + EXTRFSTR2 = "Roof Structure 2 in External Area" + EXTRFSTR3 = "Roof Structure 3 in External Area" + EXTSTOREY = "Number of Storeys within the Property or Block" + EXTSTRDOOR = "Store Door in External Area" + EXTSTRINSP = "Structural Defects in External Area" + EXTSTRROOF = "Store Roof in External Area" + EXTSTRWDWS = "Store Windows in External Area" + EXTWALLFN1 = "Wall Finish 1 in External Area" + EXTWALLFN2 = "Wall Finish 2 in External Area" + EXTWALLINS = "Wall Insulation Improvement in External Area" + EXTWALLSPL = "Wall Spalling in External Area" + EXTWALLSTR = "Wall Structure in External Area" + EXTWNDWS1 = "Windows 1 in External Area" + EXTWNDWS2 = "Windows 2 in External Area" + FFHHDAMP = "Fitness for Human Habitation - Serious problem with damp" + FFHHDRNWC = "Fitness for Human Habitation - Problems with the drainage or the lavatories" + FFHHHCWAT = "Fitness for Human Habitation - Problem with the supply of hot and cold water" + FFHHNEGLC = "Fitness for Human Habitation - Building neglected and is in a bad condition" + FFHHNONAT = "Fitness for Human Habitation - Not enough natural light" + FFHHNOVEN = "Fitness for Human Habitation - Not enough ventilation" + FFHHPRPCK = "Fitness for Human Habitation - Difficult to prepare and cook food or wash up" + FFHHUNLAY = "Fitness for Human Habitation - Unsafe layout" + FFHHUNSTA = "Fitness for Human Habitation - Building is unstable" + FRARISKRTG = "Fire Risk Assessment Rating" + FRAEVACSTR = "Fire Risk Assessment Evacuation Strategy" + FRATYPE = "Fire Risk Assessment Type" + FLVL = "Floor Level of Front Door" + HHSRSASB = "Asbestos (and MMF)" + HHSRSBIOC = "Biocides" + HHSRSCO = "Carbon monoxide" + HHSRSCOLD = "Excess cold" + HHSRSCLOW = "Collision hazards and low headroom" + HHSRSCROWD = "Crowding and space" + HHSRSDAMP = "Damp and mould growth" + HHSRSDOMES = "Domestic hygeine, Pests and Refuse" + HHSRSELEC = "Electrical hazards" + HHSRSENTRP = "Collision and entrapment" + HHSRSENTRY = "Entry by intruders" + HHSRSEXPLO = "Explosions" + HHSRSFBATH = "Falls associated with baths etc" + HHSRSFBETW = "Falling between levels" + HHSRSFIRE = "Fire" + HHSRSFLAME = "Flames, hot surfaces etc" + HHSRSFLEVE = "Falling on level surfaces etc" + HHSRSFOOD = "Food safety" + HHSRSFSTAI = "Falling on stairs etc" + HHSRSFUEL = "Uncombusted fuel gas" + HHSRSHEAT = "Excess heat" + HHSRSLEAD = "Lead" + HHSRSLIGHT = "Lighting" + HHSRSNO2 = "Nitrogen dioxide" + HHSRSNOISE = "Noise" + HHSRSORGAN = "Volatile organic compounds" + HHSRSPERS = "Personal hygeine, Sanitation and Drainage" + HHSRSPOSI = "Position and operability of amenities etc" + HHSRSRADIA = "Radiation" + HHSRSSO2 = "Sulphur dioxide and smoke" + HHSRSSTRUC = "Structural collapse and falling elements" + HHSRSWATER = "Water supply" + INTACCRAMP = "Access Ramp 1:12 Gradient to Property" + INTADDWCW = "Additional WCs and / or WHBs in Property" + INTBTHADEQ = "Adequacy of Bathroom Location in Property" + INTBTHREML = "Source of Bathroom Remaining Life in Property" + INTBTHRLOC = "Location of Bathroom in Property" + INTBOILERF = "Boiler Fuel in Property" + INTCHEXTNT = "Extent of Central Heating in Property" + INTCKRLOC = "Adequacy of Cooker Location in Property" + INTCOMHTG = "Community Heating in Property" + INTELECTRC = "Electrics Required in Property" + INTFLRLVL = "Floor Level Location for Property" + INTFRDOOR = "Type and Location of Front Door in Property" + INTFRDRFRR = "Front Door Fire Rating in Property" + INTGASAVAI = "Gas Available in Property" + INTHEATREC = "Heat Recovery Units in Property" + INTHTDISYS = "Heating Distribution System in Property" + INTHTIMP = "Heating Improvement Required in Property" + INTKITADEQ = "Adequacy of Kitchen and Type in Property" + INTKITREML = "Source of Kitchen Remaining Life in Property" + INTLOFTINS = "Size in mm of Loft Insulation Thickness in Property" + INTNSEINSL = "Adequacy of Noise Insulation in Property" + INTPROGHTG = "Programmable Heating in Property" + INTSMKDET = "Smoke Detectors in Property" + INTSTEPSFD = "Number of Steps to Front Door for Property" + INTTNTINST = "Tenant Installed Kitchen in Property" + INTWDWTYPE = "Windows in Property" + INTWTRHTNG = "Type of Water Heating in Property" + QUALITYSTD = "Quality standard" diff --git a/backend/condition/domain/mapping/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf_mapper.py index 154f3db4..0bbdc0d6 100644 --- a/backend/condition/domain/mapping/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf_mapper.py @@ -1,9 +1,15 @@ -from typing import Any, List +from typing import Any, List, Optional from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.element import Element from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.parsing.records.lbwf.lbwf_asset_condition import LbwfAssetCondition class LbwfMapper(Mapper): def map_asset_conditions(self, client_data: List[Any]) -> List[AssetCondition]: + raise NotImplementedError + + @staticmethod + def _map_element(lbwf_asset: LbwfAssetCondition) -> Optional[Element]: raise NotImplementedError \ No newline at end of file diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py new file mode 100644 index 00000000..e26b9928 --- /dev/null +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -0,0 +1,239 @@ +from typing import List +import pytest +from datetime import date + +from backend.condition.domain.mapping.lbwf_mapper import LbwfMapper +from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse +from backend.condition.parsing.records.lbwf.lbwf_asset_condition import LbwfAssetCondition +from backend.condition.domain.element import Element +from backend.condition.domain.asset_condition import AssetCondition + +def test_lbwf_mapper_maps_house(): + # arrange + lbwf_house = LbwfHouse( + uprn=1, + reference=100, + address="123 Fake Street, London, A10 1AB", + epc="F", + shdf="NO", + house="HOUSE", + fail_decency=2025, + assets=[ + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="AHR_CAT", + element_code_description="Accessible Housing Register Category", + attribute_code="F", + attribute_code_description="General Needs", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments=None, + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="FLVL", + element_code_description="Floor Level of Front Door", + attribute_code="0G", + attribute_code_description="Ground Floor", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments=None, + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="ASBESTOS", + element_code_description="Asbestos Present", + attribute_code="YES", + attribute_code_description="Yes", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=None, + install_date=None, + remaining_life=None, + element_comments="Source of Data = ACT", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="INTBTHRLOC", + element_code_description="Location of Bathroom in Property", + attribute_code="ENTRANCE", + attribute_code_description="Bathroom on Entrance Level in Property", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments="Source of Data = Codeman", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="INTCHEXTNT", + element_code_description="Extent of Central Heating in Property", + attribute_code="NONE", + attribute_code_description="No Central Heating in Property", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments="Source of Data = Codeman", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="HHSRSFIRE", + element_code_description="Fire", + attribute_code="TYPRISK", + attribute_code_description="Category 4 - Typical Risk", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=None, + remaining_life=None, + element_comments="Source of Data = Morgan Sindall", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="EXTWALLFN1", + element_code_description="Wall Finish 1 in External Area", + attribute_code="RENDERPBBL", + attribute_code_description="Render or Pebbledash Wall Finish 1 in External Area", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=date(2009,4,1), + remaining_life=26, + element_comments="Source of Data = Codeman", + ), + ] + ) + mapper = LbwfMapper() + + current_year = 2026 + + expected_assets: List[AssetCondition] = [ + AssetCondition( + uprn=1, + element=Element.AHR_CAT, + condition_description="General Needs", + quantity=1, + renewal_year=None, + source=None + ), + AssetCondition( + uprn=1, + element=Element.FLVL, + condition_description="Ground Floor", + quantity=1, + renewal_year=None, + source=None + ), + AssetCondition( + uprn=1, + element=Element.ASBESTOS, + condition_description="Yes", + quantity=None, + renewal_year=None, + source="Source of Data = ACT" + ), + AssetCondition( + uprn=1, + element=Element.INTBTHRLOC, + condition_description="Bathroom on Entrance Level in Property", + quantity=1, + renewal_year=None, + source="Source of Data = Codeman" + ), + AssetCondition( + uprn=1, + element=Element.INTCHEXTNT, + condition_description="No Central Heating in Property", + quantity=1, + renewal_year=None, + source="Source of Data = Codeman" + ), + AssetCondition( + uprn=1, + element=Element.HHSRSFIRE, + condition_description="Category 4 - Typical Risk", + quantity=1, + renewal_year=None, + source="Source of Data = Morgan Sindall" + ), + AssetCondition( + uprn=1, + element=Element.EXTWALLFN1, + condition_description="Render or Pebbledash Wall Finish 1 in External Area", + quantity=1, + renewal_year=2052, + source="Source of Data = Codeman" + ), + + ] + + # act + actual_assets: List[AssetCondition] = mapper.map_asset_conditions(lbwf_house) + + # assert + assert actual_assets == expected_assets \ No newline at end of file diff --git a/backend/condition/tests/parsing/test_lbwf_parser.py b/backend/condition/tests/parsing/test_lbwf_parser.py index 7556b845..beb81a03 100644 --- a/backend/condition/tests/parsing/test_lbwf_parser.py +++ b/backend/condition/tests/parsing/test_lbwf_parser.py @@ -112,7 +112,7 @@ def lbwf_homes_xlsx_bytes() -> BytesIO: return stream -def test_lbwf_parser_passes_houses(lbwf_homes_xlsx_bytes): +def test_lbwf_parser_parses_houses(lbwf_homes_xlsx_bytes): # arrange parser = LbwfParser() From 90fbc593f990cd4ec61264ad413f876db114c7f8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 20 Jan 2026 19:41:54 +0000 Subject: [PATCH 07/74] handling fixed cost exceeding our budget, creating negative budget --- recommendations/Recommendations.py | 34 ++-- .../optimiser/funding_optimiser.py | 11 +- recommendations/tests/test_recommendations.py | 150 +++++++++++++++++- tox.ini | 2 +- 4 files changed, 181 insertions(+), 16 deletions(-) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 33558fae..c6fea3b6 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -1,7 +1,7 @@ import pandas as pd import numpy as np from backend.Property import Property -from typing import List, Mapping +from typing import List, Mapping, Any from itertools import groupby from recommendations.FloorRecommendations import FloorRecommendations from recommendations.WallRecommendations import WallRecommendations @@ -499,23 +499,26 @@ class Recommendations: return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction @staticmethod - def _check_veniltation_out_of_bounds(sap_impact, ventilation_sap_limit): + def _check_ventilation_out_of_bounds(sap_impact, ventilation_sap_limit): return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0) @staticmethod def _adjust_ventilation_sap(sap_impact, ventilation_sap_limit): + if sap_impact >= 0: return -1 if sap_impact < ventilation_sap_limit: return ventilation_sap_limit + return sap_impact + @staticmethod def _filter_phase_adjustment(phase_adjustments): """ Utility function to select the entry from the dictionary, by phase, with the largest phase adjustment :param phase_adjustments: List of phase adjustments, in the form - [{"recommendation_id": str, "phase": int, "adjustment_amount": float}] + [{"recommendation_id": str, "phase": int, "sap_adjustment": float}] :return: """ filtered_adjustments = [] @@ -583,6 +586,15 @@ class Recommendations: if len(previous_phase_reps) == 1: return previous_phase_reps[0] + # It's unlikely that this will occur but this fallback will ensure that we don't + # run the next step and run a median of nothing, which will return None + if not previous_phase_reps: + return { + "sap": float(property_instance.data["current-energy-efficiency"]), + "carbon": float(property_instance.data["co2-emissions-current"]), + "heat_demand": float(property_instance.data["energy-consumption-current"]), + } + # Median fallback (including zero-length case) keys = ("sap", "carbon", "heat_demand") return { @@ -612,9 +624,9 @@ class Recommendations: @classmethod def _resolve_current_phase_sap( cls, - rec: Mapping[str, any], - previous_phase_values: Mapping[str, any], - phase_energy_efficiency_metrics: Mapping[str, any], + rec: Mapping[str, Any], + previous_phase_values: Mapping[str, Any], + phase_energy_efficiency_metrics: Mapping[str, Any], adjustments: list[dict], ) -> float: if rec.get("survey", False): @@ -713,14 +725,14 @@ class Recommendations: elif rec["type"] == "mechanical_ventilation": # ventilation is capped by having no greater and a -4 impact ventilation_sap_limit = -4 - ventilation_out_of_bounds = cls._check_veniltation_out_of_bounds( + ventilation_out_of_bounds = cls._check_ventilation_out_of_bounds( property_phase_impact["sap"], ventilation_sap_limit ) if ventilation_out_of_bounds: previous_modelled_sap = previous_phase_values.get("sap_prediction", 0) proposed_sap_impact = current_phase_values["sap"] - previous_modelled_sap - proposal_out_of_bounds = cls._check_veniltation_out_of_bounds( + proposal_out_of_bounds = cls._check_ventilation_out_of_bounds( proposed_sap_impact, ventilation_sap_limit ) if proposal_out_of_bounds: @@ -805,7 +817,7 @@ class Recommendations: return property_phase_impact, current_phase_values, adjustments @staticmethod - def _validate_recommendation_updates(rec: Mapping[str, any]): + def _validate_recommendation_updates(rec: Mapping[str, Any]): """ Utility function to validate that the recommendation updates have been applied correctly :param rec: updated recommendation @@ -821,11 +833,11 @@ class Recommendations: def calculate_recommendation_impact( cls, property_instance: Property, - all_predictions: Mapping[str, any], + all_predictions: Mapping[str, Any], recommendations: Mapping[int, List], representative_recommendations: Mapping[int, List], debug: bool = False - ) -> (Mapping[int, List], List[Mapping[str, any]]): + ) -> (Mapping[int, List], List[Mapping[str, Any]]): """ Given predictions from the model apis, with method will update the recommendations with the predicted diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py index f9e471ce..a2f138ed 100644 --- a/recommendations/optimiser/funding_optimiser.py +++ b/recommendations/optimiser/funding_optimiser.py @@ -711,9 +711,12 @@ def optimise_with_scenarios( if kept: remaining_measures.append(kept) + remaining_budget = budget - fabric_cost if budget is not None else None + remaining_budget = 0 if remaining_budget < 0 else remaining_budget + picked_extra, extra_cost, extra_gain = run_optimizer( remaining_measures, - budget=budget - fabric_cost if budget is not None else None, + budget=remaining_budget, sub_target_gain=( target_gain - fabric_gain if target_gain is not None @@ -769,6 +772,12 @@ def optimise_with_scenarios( fixed_cost, fixed_gain = sum_cost_gain(fixed_items) + if budget is not None: + # If we have a budget, we cannot exceed it via our fixed cost. If we do, + # this is not a viable solution + if fixed_cost > budget: + continue + # Remaining measures (all other groups) remaining_measures = [ grp for gi, grp in enumerate(optimisation_measures) diff --git a/recommendations/tests/test_recommendations.py b/recommendations/tests/test_recommendations.py index 7a7930bf..a9915422 100644 --- a/recommendations/tests/test_recommendations.py +++ b/recommendations/tests/test_recommendations.py @@ -1,9 +1,6 @@ import pytest -import datetime import pandas as pd import numpy as np -from pandas import Timestamp -from numpy import nan from unittest.mock import Mock from recommendations.Recommendations import Recommendations @@ -372,6 +369,153 @@ def test_filter_phase_adjustment(input_data, expected): assert Recommendations._filter_phase_adjustment(input_data) == expected +@pytest.mark.parametrize( + "sap_impact, limit, expected", + [ + (1.0, -4, True), # positive SAP not allowed + (0.0, -4, True), # zero not allowed + (-1.0, -4, False), # valid range + (-3.9, -4, False), # valid range + (-4.0, -4, False), # exact lower bound allowed + (-4.1, -4, True), # below lower bound + ], +) +def test_check_ventilation_out_of_bounds(sap_impact, limit, expected): + assert Recommendations._check_ventilation_out_of_bounds( + sap_impact, limit + ) is expected + + +@pytest.mark.parametrize( + "sap_impact, limit, expected", + [ + (1.2, -4, -1), # positive → capped to -1 + (0.0, -4, -1), # zero → capped to -1 + (-5.0, -4, -4), # below limit → clamp + (-3.0, -4, -3.0), # already valid → unchanged + ], +) +def test_adjust_ventilation_sap(sap_impact, limit, expected): + assert Recommendations._adjust_ventilation_sap( + sap_impact, limit + ) == expected + + +def test_get_previous_phase_values_starting_phase(property_instance): + result = Recommendations._get_previous_phase_values( + rec_phase=0, + starting_phase=0, + impact_summary=[], + property_instance=property_instance, + ) + + assert result == { + "sap": 65.0, + "carbon": 2.4, + "heat_demand": 284.0, + } + + +def test_get_previous_phase_values_single_rep(property_instance): + impact_summary = [ + { + "phase": 0, + "representative": True, + "sap": 66, + "carbon": 2.2, + "heat_demand": 260, + } + ] + + result = Recommendations._get_previous_phase_values( + rec_phase=1, + starting_phase=0, + impact_summary=impact_summary, + property_instance=property_instance, + ) + + assert result["sap"] == 66 + assert result["carbon"] == 2.2 + assert result["heat_demand"] == 260 + + +def test_get_previous_phase_values_median(property_instance): + impact_summary = [ + {"phase": 1, "representative": True, "sap": 70, "carbon": 2.0, "heat_demand": 250}, + {"phase": 1, "representative": True, "sap": 74, "carbon": 1.6, "heat_demand": 230}, + ] + + result = Recommendations._get_previous_phase_values( + rec_phase=2, + starting_phase=0, + impact_summary=impact_summary, + property_instance=property_instance, + ) + + assert result["sap"] == np.median([70, 74]) + assert result["carbon"] == np.median([2.0, 1.6]) + assert result["heat_demand"] == np.median([250, 230]) + + +def test_compute_phase_impact_standard(): + previous = {"sap": 65, "carbon": 2.4, "heat_demand": 284} + current = {"sap": 64, "carbon": 2.6, "heat_demand": 300} + + impact = Recommendations._compute_phase_impact( + rec_type="loft_insulation", + previous_phase_values=previous, + current_phase_values=current, + ) + + # monotonicity enforced + assert impact["sap"] == 0 + assert impact["carbon"] == 0 + assert impact["heat_demand"] == 0 + + +def test_compute_phase_impact_mechanical_ventilation(): + previous = {"sap": 65, "carbon": 2.4, "heat_demand": 284} + current = {"sap": 63, "carbon": 2.4, "heat_demand": 284} + + impact = Recommendations._compute_phase_impact( + rec_type="mechanical_ventilation", + previous_phase_values=previous, + current_phase_values=current, + ) + + assert impact["sap"] == -2 + + +def test_resolve_current_phase_sap_with_adjustments(): + rec = {"phase": 3, "survey": False} + previous = {"sap": 65} + phase_metrics = {"sap_change": 70} + adjustments = [ + {"phase": 1, "sap_adjustment": 1.5}, + {"phase": 2, "sap_adjustment": 2.0}, + ] + + sap = Recommendations._resolve_current_phase_sap( + rec=rec, + previous_phase_values=previous, + phase_energy_efficiency_metrics=phase_metrics, + adjustments=adjustments, + ) + + assert sap == 70 - (1.5 + 2.0) + + +def test_validate_recommendation_updates_raises(): + rec = { + "sap_points": None, + "co2_equivalent_savings": None, + "heat_demand": None, + } + + with pytest.raises(ValueError): + Recommendations._validate_recommendation_updates(rec) + + def test_calculate_recommendation_impact(property_instance, heat_demand_predictions, carbon_predictions): ####### # Case 3 diff --git a/tox.ini b/tox.ini index e330f564..858a3f93 100644 --- a/tox.ini +++ b/tox.ini @@ -9,5 +9,5 @@ deps = -rbackend/engine/requirements.txt -rbackend/app/requirements/requirements.txt -rtest.requirements.txt -commands = pytest +commands = pytest {posargs} From 0fc09aa14254b9274ee61fc584e7dea73473458d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 20 Jan 2026 22:39:00 +0000 Subject: [PATCH 08/74] fixing tests --- .../test_data/test_floor_attributes_cases.py | 2 +- .../test_hot_water_attributes_cases.py | 2 +- .../tests/test_fireplace_recommendations.py | 41 ++++++++++++------- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py b/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py index 080f59be..c678c741 100644 --- a/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py +++ b/etl/epc_clean/tests/test_data/test_floor_attributes_cases.py @@ -378,7 +378,7 @@ clean_floor_cases = [ }, { # This example gets remapped to another dwelling below - "description": "Above unheated space or full exposed", + "original_description": "Above unheated space or full exposed", 'thermal_transmittance': 0, 'thermal_transmittance_unit': 'w/m-¦k', 'is_assumed': False, 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': True, 'insulation_thickness': None diff --git a/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py b/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py index 18b97232..e9ae6561 100644 --- a/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py +++ b/etl/epc_clean/tests/test_data/test_hot_water_attributes_cases.py @@ -226,7 +226,7 @@ hotwater_cases = [ {'original_description': 'Single-point gas water heater, standard tariff', 'heater_type': 'single-point gas', 'system_type': "water heater", 'thermostat_characteristics': None, 'heating_scope': None, 'energy_recovery': None, 'tariff_type': 'standard tariff', 'extra_features': None, - 'chp_systems': None, 'distribution_system': None, 'no_system_present': None, 'appliance': None + 'chp_systems': None, 'distribution_system': None, 'no_system_present': None, 'appliance': None, "assumed": False } ] diff --git a/recommendations/tests/test_fireplace_recommendations.py b/recommendations/tests/test_fireplace_recommendations.py index 7eb55b21..72e2ba8d 100644 --- a/recommendations/tests/test_fireplace_recommendations.py +++ b/recommendations/tests/test_fireplace_recommendations.py @@ -1,11 +1,28 @@ +import pytest +import datetime from backend.Property import Property from recommendations.FireplaceRecommendations import FireplaceRecommendations from etl.epc.Record import EPCRecord +@pytest.fixture +def fireplace_materials(): + return [ + {'id': 3591, 'type': 'sealing_fireplace', 'description': 'Sealing of an open fireplace', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 185.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None} + ] + + class TestFirepaceRecommendations: - def test_no_fireplaces(self): + def test_no_fireplaces(self, fireplace_materials): epc_record = EPCRecord() epc_record.prepared_epc = { "number-open-fireplaces": 0, @@ -13,9 +30,7 @@ class TestFirepaceRecommendations: property_instance = Property(id=0, address="fake", postcode="fake", epc_record=epc_record) - recommender = FireplaceRecommendations( - property_instance=property_instance - ) + recommender = FireplaceRecommendations(property_instance=property_instance, materials=fireplace_materials) assert recommender.recommendation is None @@ -23,16 +38,15 @@ class TestFirepaceRecommendations: assert recommender.recommendation is None - def test_one_fireplace(self): + def test_one_fireplace(self, fireplace_materials): epc_record = EPCRecord() epc_record.prepared_epc = { "number-open-fireplaces": 1, } property_instance = Property(id=0, address="fake", postcode="fake", epc_record=epc_record) + property_instance.already_installed = [] - recommender = FireplaceRecommendations( - property_instance=property_instance - ) + recommender = FireplaceRecommendations(property_instance=property_instance, materials=fireplace_materials) assert recommender.recommendation is None @@ -40,18 +54,17 @@ class TestFirepaceRecommendations: assert recommender.recommendation assert recommender.recommendation[0]["type"] == "sealing_open_fireplace" - assert recommender.recommendation[0]["total"] == 235 + assert recommender.recommendation[0]["total"] == 185 - def test_multiple_fireplaces(self): + def test_multiple_fireplaces(self, fireplace_materials): epc_record = EPCRecord() epc_record.prepared_epc = { "number-open-fireplaces": 3, } property_instance = Property(id=0, address="fake", postcode="fake", epc_record=epc_record) + property_instance.already_installed = [] - recommender = FireplaceRecommendations( - property_instance=property_instance - ) + recommender = FireplaceRecommendations(property_instance=property_instance, materials=fireplace_materials) assert recommender.recommendation is None @@ -59,4 +72,4 @@ class TestFirepaceRecommendations: assert recommender.recommendation assert recommender.recommendation[0]["type"] == "sealing_open_fireplace" - assert recommender.recommendation[0]["total"] == 235 * 3 + assert recommender.recommendation[0]["total"] == 185 * 3 From 349cf437ed10b7c6af94c9a87f6b39a1806cd4ff Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 20 Jan 2026 23:48:22 +0000 Subject: [PATCH 09/74] debugging windows recommendation tests --- backend/tests/test_funding.py | 2 +- etl/epc_clean/tests/test_floor_attributes.py | 7 +- recommendations/tests/test_data/materials.py | 1233 +++++++++++++---- .../tests/test_window_recommendations.py | 290 ++-- 4 files changed, 1125 insertions(+), 407 deletions(-) diff --git a/backend/tests/test_funding.py b/backend/tests/test_funding.py index 8646ab27..ff264159 100644 --- a/backend/tests/test_funding.py +++ b/backend/tests/test_funding.py @@ -1395,7 +1395,7 @@ def test_private_epc_e_solar_with_heating_and_minimum_insulation_produces_uplift assert funding.eco4_funding and funding.eco4_funding > 0 -def test_existing_gshp_to_ashp(): +def test_existing_gshp_to_ashp(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): r = {'phase': 3, 'parts': [], 'type': 'heating', 'measure_type': 'air_source_heat_pump', 'description': 'Install a 5KW air source heat pump, and upgrade heating controls to Smart Thermostats, ' 'room sensors and smart radiator valves (time & temperature zone control). Ensure you have a ' diff --git a/etl/epc_clean/tests/test_floor_attributes.py b/etl/epc_clean/tests/test_floor_attributes.py index fc60c343..887cb689 100644 --- a/etl/epc_clean/tests/test_floor_attributes.py +++ b/etl/epc_clean/tests/test_floor_attributes.py @@ -15,7 +15,12 @@ class TestCleanFloor: empty = FloorAttributes('') assert empty.nodata output = empty.process() - assert output == {"no_data": True} + assert output == { + 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': True, + 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': True, + 'is_solid': False, 'another_property_below': False, 'insulation_thickness': 'none', + 'no_data': True + } # Test initialization with a description that contains none of the keywords with pytest.raises(ValueError): diff --git a/recommendations/tests/test_data/materials.py b/recommendations/tests/test_data/materials.py index 13b1ea08..3110ebe7 100644 --- a/recommendations/tests/test_data/materials.py +++ b/recommendations/tests/test_data/materials.py @@ -1,351 +1,1010 @@ import datetime materials = [ - { - 'id': 2484, - 'type': 'room_roof_insulation', - 'description': 'Room in roof insulation', - 'depth': 100, - 'depth_unit': 'mm', - 'cost_unit': 'gbp_per_m2', - 'r_value_per_mm': 0.038, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': '2025-03-16 15:26:22.379', - 'cost': None, - 'is_active': True, - 'prime_material_cost': None, - 'material_cost': 0.0, - 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, - 'total_cost': 210.0, - 'notes': None, - 'is_installer_quote': True - }, - {'id': 1997, 'type': 'cavity_wall_insulation', 'description': 'Imperial Bead cavity wall insulation', 'depth': 75.0, + {'id': 3325, 'type': 'cavity_wall_insulation', 'description': 'Instabead EPS Bead insulation', 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 14.21, - 'notes': None, 'is_installer_quote': True}, - {'id': 1998, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', 'depth': 0.0, - 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 18.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3326, 'type': 'cavity_wall_insulation', 'description': 'Mineral wool insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.025, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.04, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 16.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3327, 'type': 'cavity_wall_extraction', 'description': 'Extraction of existing insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'SCIS', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, + 'link': 'Instagroup', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, 'total_cost': 535.5, 'notes': None, 'is_installer_quote': True}, - {'id': 2015, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 100.0, + 'plant_cost': 0.0, 'total_cost': 25.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3328, 'type': 'cavity_wall_insulation', 'description': 'Instabead EPS Bead insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 21.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3329, 'type': 'cavity_wall_insulation', 'description': 'Instabead EPS Bead insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J & J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 16.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3332, 'type': 'mechanical_ventilation', 'description': 'Decentralised mechanical extract ventilation', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Instagroup', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 480.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3333, 'type': 'trickle_vent', 'description': 'Trickle vent', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 45.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3334, 'type': 'door_undercut', 'description': 'Door undercut', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 45.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3335, 'type': 'door_undercut', 'description': 'Door undercut', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'CRG', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 45.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3336, 'type': 'trickle_vent', 'description': 'Trickle vent', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'CRG', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 45.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3337, 'type': 'mechanical_ventilation', 'description': 'Decentralised mechanical extract ventilation', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'CRG', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 280.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3358, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 200.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.09, 'plant_cost': 0.0, 'total_cost': 14.95, - 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True}, - {'id': 2016, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 200.0, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 14.0, + 'notes': 'This is the cost if there is more than 100mm insulation in place', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None}, + {'id': 3359, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 300.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 15.525, - 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True}, - {'id': 2017, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 270.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 15.0, + 'notes': 'This is the cost if there is less than 100mm existing insulation', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None}, + {'id': 3360, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 100.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 16.1, - 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True}, - {'id': 2018, 'type': 'loft_insulation', 'description': 'Knauf Loft Roll 44 glass fibre roll', 'depth': 300.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 17.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3361, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 200.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 16.53, - 'notes': 'This is a placeholder cost until SCIS gives us a breakdown by thickness', 'is_installer_quote': True}, - {'id': 2039, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt', 'depth': 95.0, 'depth_unit': 'mm', - 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.032, 'thermal_conductivity_unit': None, 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1, 'plant_cost': 0.0, 'total_cost': 244.8, - 'notes': 'We are awaiting further breakdown of costs by thickness and finishes', 'is_installer_quote': False}, - {'id': 2074, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 50.0, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 19.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3362, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 300.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 21.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3363, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 400.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 22.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3364, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 100.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 14.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3365, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 200.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 16.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3366, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 300.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 18.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3367, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', 'depth': 400.0, 'depth_unit': 'mm', + 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.044, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 19.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3425, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 75.0, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2075, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 75.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3426, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 93.75, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2076, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 100.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3427, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 112.5, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2077, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 125.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3428, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 125.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 112.5, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2078, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 150.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3429, 'type': 'suspended_floor_insulation', 'description': 'Q-bot underfloor insulation', 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, 'total_cost': 150.0, - 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True}, - {'id': 2079, 'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0, + 'notes': 'Linearly interpolated based on Qbot costs', 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3430, 'type': 'suspended_floor_insulation', 'description': 'Underfloor mineral wool insulation', + 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': None, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3431, 'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'SPONs', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, + 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, 'total_cost': 3.32, 'notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and therefore ' 'there is no need for a skip', - 'is_installer_quote': False}, {'id': 2080, 'type': 'solid_floor_preparation', - 'description': 'clean surface of concrete to receive new damp-proof membrane', - 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, - 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None, - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 4.36, - 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 4.36, 'notes': None, - 'is_installer_quote': False}, {'id': 2081, 'type': 'solid_floor_preparation', - 'description': 'Clean out crack to form a ' - '20mm×20mm groove and fill with ' - 'cement: mortar mixed with bonding ' - 'agent', - 'depth': 0.0, 'depth_unit': None, 'cost': None, - 'cost_unit': None, 'r_value_per_mm': None, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': None, - 'thermal_conductivity_unit': None, 'link': None, - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, - 52, 584553), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 6.91, 'labour_cost': 18.99, - 'labour_hours_per_unit': 0.61, 'plant_cost': 0.16, - 'total_cost': 26.06, - 'notes': 'This step is the assessment and repair ' - 'of any damage to the concrete floor such ' - 'as filling cracks or levelling uneven ' - 'areas', - 'is_installer_quote': False}, - {'id': 2082, 'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3432, 'type': 'solid_floor_preparation', + 'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 4.36, + 'notes': None, 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3433, 'type': 'solid_floor_preparation', + 'description': 'Clean out crack to form a 20mm×20mm groove and fill with cement: mortar mixed with bonding agent', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'SPONs', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, + 'link': None, 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 6.91, 'labour_cost': 18.99, 'labour_hours_per_unit': 0.61, + 'plant_cost': 0.16, 'total_cost': 26.06, + 'notes': 'This step is the assessment and repair of any damage to the concrete floor such as filling cracks or ' + 'levelling uneven areas', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3434, 'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, 'labour_hours_per_unit': 0.02, - 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None, 'is_installer_quote': False}, - {'id': 2083, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 25.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 3.88, 'labour_cost': 3.24, 'labour_hours_per_unit': 0.14, 'plant_cost': 0.0, 'total_cost': 7.12, - 'notes': None, 'is_installer_quote': False}, - {'id': 2084, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 50.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 6.62, 'labour_cost': 3.71, 'labour_hours_per_unit': 0.16, 'plant_cost': 0.0, 'total_cost': 10.33, - 'notes': None, 'is_installer_quote': False}, - {'id': 2085, 'type': 'solid_floor_insulation', 'description': 'Kay-Cel Expanded Polystyrene Board', 'depth': 75.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 9.3, 'labour_cost': 4.17, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 13.47, - 'notes': None, 'is_installer_quote': False}, {'id': 2086, 'type': 'solid_floor_insulation', - 'description': 'Kingspan Thermafloor TF70 High Performance Rigid ' - 'Floor Insulation', - 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 10.36, 'labour_cost': 4.06, - 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, - 'total_cost': 14.42, 'notes': None, 'is_installer_quote': False}, - {'id': 2087, 'type': 'solid_floor_insulation', - 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 19.41, - 'notes': None, 'is_installer_quote': False}, {'id': 2088, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation ' - 'Board', - 'depth': 30.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), - 'is_active': True, 'prime_material_cost': 6.16, - 'material_cost': 16.73, 'labour_cost': 28.34, - 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 45.07, - 'notes': None, 'is_installer_quote': False}, - {'id': 2089, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 50.0, 'depth_unit': 'mm', - 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': 8.46, - 'material_cost': 19.1, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, 'total_cost': 47.44, - 'notes': None, 'is_installer_quote': False}, {'id': 2090, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation ' - 'Board', - 'depth': 60.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'https://londonbuildingsupplies.co.uk/products/60mm--ecotherm-eco-versal-general-purpose-pir-insulation-board---2.4m-x-1.2m-x-60mm.html', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 24.081198, 'labour_cost': 28.34, - 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, - 'total_cost': 52.421196, - 'notes': "This material isn't in SPONs but checking online, " - "is around 92% of the cost of the 100mm", - 'is_installer_quote': False}, - {'id': 2091, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 70.0, 'depth_unit': 'mm', - 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'https://londonbuildingsupplies.co.uk/products/70mm--ecotherm-eco-versal-general-purpose-pir-insulation' - '-board---2.4m-x-1.2m-x-70mm.html', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 27.089088, 'labour_cost': 28.34, 'labour_hours_per_unit': 1.2, 'plant_cost': 0.0, - 'total_cost': 55.42909, - 'notes': "This material isn't in SPONs but checking online, is around 104% of the cost of the 100mm (more " - "expensive than 100mm)", - 'is_installer_quote': False}, {'id': 2092, 'type': 'solid_floor_insulation', - 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', - 'depth': 100.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', - 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SPONs', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), - 'is_active': True, 'prime_material_cost': 15.12, 'material_cost': 25.96, - 'labour_cost': 30.7, 'labour_hours_per_unit': 1.3, 'plant_cost': 0.0, - 'total_cost': 56.66, 'notes': None, 'is_installer_quote': False}, - {'id': 2093, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam', - 'depth': 50.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.032258064, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.031, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 11.07, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0, - 'total_cost': 21.73, - 'notes': "In Spons, the thermal conductivity is 0.033 however the datasheet indicates it's 0.32: " - "https://ravagobuildingsolutions.com/uk/wp-content/uploads/sites/30/2022/08/ravatherm-xps-x-500-sl-tds" - "-version-1-20210901.pdf", - 'is_installer_quote': False}, - {'id': 2094, 'type': 'solid_floor_insulation', 'description': 'Ravatherm XPS X 500 SL Polystyrene Foam', - 'depth': 75.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 16.28, 'labour_cost': 10.66, 'labour_hours_per_unit': 0.46, 'plant_cost': 0.0, - 'total_cost': 26.94, 'notes': None, 'is_installer_quote': False}, {'id': 2095, 'type': 'solid_floor_redecoration', - 'description': 'Screeded beds; protection to ' - 'compressible formwork ' - 'exceeding 600mm wide', - 'depth': 0.0, 'depth_unit': None, 'cost': None, - 'cost_unit': None, 'r_value_per_mm': None, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': None, - 'thermal_conductivity_unit': None, - 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, - 42, 52, 584553), - 'is_active': True, 'prime_material_cost': 9.6, - 'material_cost': 9.89, 'labour_cost': 2.67, - 'labour_hours_per_unit': 0.15, - 'plant_cost': 0.0, 'total_cost': 12.56, - 'notes': 'This is the screed layer, ' - 'placed on top of the insulation', - 'is_installer_quote': False}, - {'id': 2096, 'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, 'depth_unit': None, + 'plant_cost': 0.0, 'total_cost': 1.69, 'notes': None, 'is_installer_quote': False, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3447, 'type': 'solid_floor_redecoration', + 'description': 'Screeded beds; protection to compressible formwork exceeding 600mm wide', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': 9.6, 'material_cost': 9.89, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, + 'plant_cost': 0.0, 'total_cost': 12.56, 'notes': 'This is the screed layer, placed on top of the insulation', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3448, 'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, 'plant_cost': 0.0, 'total_cost': 6.59, 'notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; Gradus woven ' 'polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, therefore we need just ' 'labour rates', - 'is_installer_quote': False}, {'id': 2097, 'type': 'solid_floor_redecoration', - 'description': 'Fitting existing softwood skirting or architrave to new frames; ' - '150mm high', - 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, - 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'SPONs', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.01, 'labour_cost': 4.87, - 'labour_hours_per_unit': 0.12, 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None, - 'is_installer_quote': False}, {'id': 2132, 'type': 'external_wall_insulation', - 'description': 'EWI Pro EPS external wall ' - 'insulation system with Brick Slip ' - 'finish', - 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', - 'r_value_per_mm': 0.02631579, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.038, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, - 52, 584553), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, - 'total_cost': 298.35, - 'notes': 'This is the quoted value from SCIS', - 'is_installer_quote': True}, - {'id': 2133, 'type': 'low_energy_lighting_installation', 'description': 'Installation of fittings and cost of bub', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3449, 'type': 'solid_floor_redecoration', + 'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'SPONs', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12, + 'plant_cost': 0.0, 'total_cost': 4.88, 'notes': None, 'is_installer_quote': False, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3484, 'type': 'external_wall_insulation', + 'description': 'EWI Pro EPS external wall insulation system with Brick Slip finish', 'depth': 150.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.038, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 298.35, + 'notes': 'This is the quoted value from SCIS', 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, + 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3486, 'type': 'low_energy_lighting_installation', 'description': 'Installation of fittings and cost of bub', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'https://www.checkatrade.com/blog/cost-guides/cost-install-downlights/ ' - 'https://www.hamuch.com/cost/led-spot-light#:~:text=It%20costs%20an%20average%20of,' - 'will%20drive%20up%20the%20cost.', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, - 'material_cost': 20.0, 'labour_cost': 15.0, 'labour_hours_per_unit': 0.8, 'plant_cost': 0.0, 'total_cost': 35.0, + 'link': 'JJC', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.05, + 'plant_cost': 0.0, 'total_cost': 3.5, 'notes': 'We estimate the unit economics from the checkatrade article. We assume that the average job consists ' 'of installing 6 lights based on the hamuch article. We use the median value of 400 for a job of 6 ' 'lights', - 'is_installer_quote': False}, - {'id': 2147, 'type': 'flat_roof_insulation', 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3500, 'type': 'flat_roof_insulation', 'description': 'Ecotherm Eco-Versal General Purpose Insulation Board', 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', - 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, 'prime_material_cost': None, + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 195.0, 'notes': 'Rough estimate based on a quote from Nic on 30th May, but the cost is just a rough estimate', - 'is_installer_quote': True}, - {'id': 2149, 'type': 'windows_glazing', 'description': 'REHAU PVCu Casement Windows', 'depth': 0.0, + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3502, 'type': 'windows_glazing', 'description': 'REHAU PVCu Casement Windows', 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, - 'link': 'SCIS', 'created_at': datetime.datetime(2024, 9, 24, 13, 42, 52, 584553), 'is_active': True, + 'link': 'SCIS', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, 'total_cost': 1140.0, 'notes': None, 'is_installer_quote': True} + 'plant_cost': 0.0, 'total_cost': 1140.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3504, 'type': 'room_roof_insulation', 'description': 'Room in roof insulation', 'depth': 100.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SCIS', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 130.0, + 'notes': 'Assumed u-value products', 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': None, + 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3505, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4728.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 1.74, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3506, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4998.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 2.175, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3507, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5292.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 2.61, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3508, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5562.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 3.045, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3509, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5832.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 3.48, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3510, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6102.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 3.915, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3511, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6720.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 4.35, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3512, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6990.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 4.785, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3513, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 7260.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 5.22, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3514, 'type': 'solar_pv', 'description': 'InstaGen 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 7530.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.45, 'size': 5.655, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3515, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Coactivation', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5692.21, 'notes': '445W panels', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': 4.45, 'size_unit': 'kWp', 'includes_scaffolding': True, 'includes_battery': False, + 'battery_size': None}, + {'id': 3516, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Coactivation', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5892.21, 'notes': '445W panels', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': 5.34, 'size_unit': 'kWp', 'includes_scaffolding': True, 'includes_battery': False, + 'battery_size': None}, + {'id': 3517, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4440.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 1.74, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3518, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4625.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 2.18, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3519, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4810.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 2.61, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3520, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 4995.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 3.05, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3521, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5195.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 3.48, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3522, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5395.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 3.92, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3523, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 5575.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 4.35, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3524, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6140.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 4.79, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3525, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6370.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 5.22, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3526, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6560.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 5.66, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3527, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 6810.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 6.09, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3528, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 7060.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 6.53, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3529, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 7310.0, + 'notes': '435W panels', 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': 6.96, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3530, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5985.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 3.48, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3531, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6185.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 3.92, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3532, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6385.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 4.35, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3533, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6950.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 4.79, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3534, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7180.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 5.22, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3535, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7370.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 5.66, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3536, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7620.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.09, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3537, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7870.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.53, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3538, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 5.8Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 8120.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.96, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 5.8}, + {'id': 3539, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6595.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 3.48, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3540, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6795.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 3.92, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3541, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6995.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 4.35, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3542, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7560.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 4.79, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3543, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7790.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 5.22, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3544, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 7990.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 5.66, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3545, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 8240.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.09, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3546, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 8490.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.53, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3547, 'type': 'solar_pv', 'description': 'UKSol 435W solar panels with 10Kw Growatt battery', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 8740.0, 'notes': '435W panels', 'is_installer_quote': True, + 'innovation_rate': 0.25, 'size': 6.96, 'size_unit': 'kWp', 'includes_scaffolding': False, 'includes_battery': True, + 'battery_size': 10.0}, + {'id': 3548, 'type': 'solar_pv', 'description': '4 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 3940.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1.6, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3549, 'type': 'solar_pv', 'description': '5 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4000.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.0, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3550, 'type': 'solar_pv', 'description': '6 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4060.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.4, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3551, 'type': 'solar_pv', 'description': '7 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4120.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.8, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3552, 'type': 'solar_pv', 'description': '8 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4220.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.2, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3553, 'type': 'solar_pv', 'description': '9 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4320.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.6, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3554, 'type': 'solar_pv', 'description': '10 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4420.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 4.0, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3555, 'type': 'solar_pv', 'description': '11 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4540.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 4.4, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3556, 'type': 'solar_pv', 'description': '12 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4640.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 4.8, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3557, 'type': 'solar_pv', 'description': '13 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4740.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 5.2, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3558, 'type': 'solar_pv', 'description': '14 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 4900.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 5.6, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3559, 'type': 'solar_pv', 'description': '15 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5020.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 6.0, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3560, 'type': 'solar_pv', 'description': '16 panel system, 400W solar panels', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5150.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 6.4, 'size_unit': 'kWp', 'includes_scaffolding': False, + 'includes_battery': False, 'battery_size': None}, + {'id': 3561, 'type': 'solar_pv', 'description': '8 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5010.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.2, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3562, 'type': 'solar_pv', 'description': '9 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5110.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.6, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3563, 'type': 'solar_pv', 'description': '10 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5210.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.0, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3564, 'type': 'solar_pv', 'description': '11 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5330.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.4, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3565, 'type': 'solar_pv', 'description': '12 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5430.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.8, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3566, 'type': 'solar_pv', 'description': '13 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5530.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.2, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3567, 'type': 'solar_pv', 'description': '14 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5690.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.6, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3568, 'type': 'solar_pv', 'description': '15 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5810.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.0, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3569, 'type': 'solar_pv', 'description': '16 panel system, 400W solar panels, 5.8kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5960.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.4, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 5.8}, + {'id': 3570, 'type': 'solar_pv', 'description': '8 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5620.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.2, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3571, 'type': 'solar_pv', 'description': '9 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5720.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 3.6, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3572, 'type': 'solar_pv', 'description': '10 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5820.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.0, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3573, 'type': 'solar_pv', 'description': '11 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 5940.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.4, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3574, 'type': 'solar_pv', 'description': '12 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6040.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 4.8, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3575, 'type': 'solar_pv', 'description': '13 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6140.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.2, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3576, 'type': 'solar_pv', 'description': '14 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6290.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 5.6, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3577, 'type': 'solar_pv', 'description': '15 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6440.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.0, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3578, 'type': 'solar_pv', 'description': '16 panel system, 400W solar panels, 10kw Growatt battery', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 6600.0, 'notes': 'Assumed 400W panels but not specified', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': 6.4, 'size_unit': 'kWp', + 'includes_scaffolding': False, 'includes_battery': True, 'battery_size': 10.0}, + {'id': 3579, 'type': 'solar_battery', 'description': 'Battery add on', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'J&J Crump', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 3769.89, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 5.0, 'size_unit': 'kW', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3580, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 50 high heat retention electric storage heaters', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1029.3, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 500.0, 'size_unit': 'watt', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3581, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 70 high heat retention electric storage heaters', 'depth': 0.0, 'depth_unit': None, + 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1095.5, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 700.0, 'size_unit': 'watt', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3582, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 100 high heat retention electric storage heaters', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 1189.95, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': 1000.0, 'size_unit': 'watt', 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None}, {'id': 3583, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 125 high heat retention electric storage heaters', + 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, + 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, + 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1292.73, 'notes': None, + 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1250.0, 'size_unit': 'watt', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3584, 'type': 'high_heat_retention_storage_heaters', + 'description': 'Quantum Dimplex 150 high heat retention electric storage heaters', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 1372.8, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': 1500.0, 'size_unit': 'watt', 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None}, + {'id': 3585, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 900.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3586, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1320.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3587, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Instagroup', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1440.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3588, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 850.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 1.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3589, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1100.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 2.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3590, 'type': 'scaffolding', 'description': 'Scaffolding', 'depth': 0.0, 'depth_unit': None, 'cost': None, + 'cost_unit': None, 'r_value_per_mm': None, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': None, 'thermal_conductivity_unit': None, 'link': 'Warm Front', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, 'total_cost': 1550.0, + 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, 'size': 3.0, 'size_unit': 'storey', + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3591, 'type': 'sealing_fireplace', 'description': 'Sealing of an open fireplace', 'depth': 0.0, + 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None, + 'link': 'Warm Front', 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, + 'plant_cost': 0.0, 'total_cost': 185.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0, + 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3439, 'type': 'solid_floor_insulation', + 'description': 'Kingspan Thermafloor TF70 High Performance Rigid Floor Insulation', 'depth': 75.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.045454547, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.022, + 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'SPONs', + 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, 'prime_material_cost': None, + 'material_cost': 15.35, 'labour_cost': 4.06, 'labour_hours_per_unit': 0.18, 'plant_cost': 0.0, 'total_cost': 80.0, + 'notes': 'Has been updated based on checkatrade: ' + 'https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost/', + 'is_installer_quote': False, 'innovation_rate': 0.0, 'size': None, 'size_unit': None, + 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None}, + {'id': 3390, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish', 'depth': 95.0, + 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, + 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032, 'thermal_conductivity_unit': None, + 'link': None, 'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), 'is_active': True, + 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1, + 'plant_cost': 0.0, 'total_cost': 195.0, + 'notes': 'This is a manual override based on the costing from Eco Approach 14/01/2026', 'is_installer_quote': True, + 'innovation_rate': 0.0, 'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, + 'battery_size': None} ] diff --git a/recommendations/tests/test_window_recommendations.py b/recommendations/tests/test_window_recommendations.py index 51a3118e..56e775c7 100644 --- a/recommendations/tests/test_window_recommendations.py +++ b/recommendations/tests/test_window_recommendations.py @@ -1,5 +1,6 @@ import pytest import pickle +import numpy as np from recommendations.WindowsRecommendations import WindowsRecommendations from backend.Property import Property from recommendations.tests.test_data.materials import materials @@ -245,12 +246,16 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_6.windows = {'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': None, - 'glazing_type': 'single', - 'no_data': False} + property_6.windows = { + 'original_description': 'Single glazed', 'clean_description': 'Single glazed', + 'has_glazing': False, 'glazing_coverage': None, + 'glazing_type': 'single', + 'no_data': False + } property_6.number_of_windows = 7 property_6.restricted_measures = True property_6.is_heritage = True + property_6.already_installed = [] recommender6 = WindowsRecommendations(property_instance=property_6, materials=materials) @@ -258,26 +263,18 @@ class TestWindowRecommendations: recommender6.recommend() - assert recommender6.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', 'measure_type': 'secondary_glazing', - 'description': 'Install secondary glazing to all windows. Secondary glazing recommended due to ' - 'herigate building status', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'already_installed': False, - 'total': 7980.0, 'labour_hours': 0.0, 'labour_days': 0.0, 'is_secondary_glazing': True, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Full secondary glazing', - 'glazed-type': 'secondary glazing' - }, - 'simulation_config': { - 'has_glazing_ending': True, 'glazing_coverage_ending': 'full', - 'glazing_type_ending': 'secondary', 'multi_glaze_proportion_ending': 100, - 'windows_energy_eff_ending': 'Good', 'glazed_type_ending': 'secondary glazing' - }, - "survey": None - }, - ] + assert len(recommender6.recommendation) == 1 + assert recommender6.recommendation[0]["total"] == np.float64(7980.0) + assert recommender6.recommendation[0]["phase"] == 0 + assert recommender6.recommendation[0]["contingency"] == np.float64(1197.0) + assert recommender6.recommendation[0]["description"] == ( + 'Install secondary glazing to all windows. Secondary glazing recommended due to herigate building status' + ) + assert recommender6.recommendation[0]["simulation_config"] == { + 'has_glazing_ending': True, 'glazing_coverage_ending': 'full', 'glazing_type_ending': 'secondary', + 'multi_glaze_proportion_ending': 100, 'windows_energy_eff_ending': 'Good', + 'glazed_type_ending': 'secondary glazing' + } def test_full_triple_glazed(self): epc_record = EPCRecord() @@ -292,10 +289,14 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_7.windows = {'original_description': 'Fully triple glazed', 'has_glazing': True, - 'glazing_coverage': 'full', - 'glazing_type': 'triple', 'no_data': False} + property_7.windows = { + 'original_description': 'Fully triple glazed', 'clean_description': 'Fully triple glazed', + 'has_glazing': True, + 'glazing_coverage': 'full', + 'glazing_type': 'triple', 'no_data': False + } property_7.number_of_windows = 7 + property_7.already_installed = [] recommender7 = WindowsRecommendations(property_instance=property_7, materials=materials) @@ -394,7 +395,9 @@ class TestWindowRecommendations: epc_record=epc_record ) property_9.windows = { - 'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': None, + 'original_description': 'Single glazed', + 'clean_description': 'Single glazed', + 'has_glazing': False, 'glazing_coverage': None, 'glazing_type': 'single', 'no_data': False } @@ -403,6 +406,7 @@ class TestWindowRecommendations: property_9.number_of_windows = 7 property_9.restricted_measures = False property_9.is_heritage = False + property_9.already_installed = [] recommender9 = WindowsRecommendations(property_instance=property_9, materials=materials) @@ -410,26 +414,10 @@ class TestWindowRecommendations: recommender9.recommend() - assert recommender9.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', 'measure_type': 'double_glazing', - 'description': 'Install double glazing to all windows', 'starting_u_value': None, 'new_u_value': None, - 'sap_points': None, 'already_installed': False, 'total': 7980.0, 'labour_hours': 0.0, - 'labour_days': 0.0, 'is_secondary_glazing': False, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Fully double glazed', - 'glazed-type': 'double glazing installed during or after 2002' - }, - 'simulation_config': { - 'has_glazing_ending': True, 'glazing_coverage_ending': 'full', - 'glazing_type_ending': 'double', 'multi_glaze_proportion_ending': 100, - 'windows_energy_eff_ending': 'Good', - 'glazed_type_ending': 'double glazing installed during or after 2002' - }, - "survey": None - } - ] + assert recommender9.recommendation[0]["total"] == np.float64(7980.0) + assert recommender9.recommendation[0]["phase"] == 0 + assert recommender9.recommendation[0]["description"] == 'Install double glazing to all windows' + assert recommender9.recommendation[0]["contingency"] == np.float64(1197.0) # We now simulate the outcome windows_rec = recommender9.recommendation.copy() @@ -537,8 +525,10 @@ class TestWindowRecommendations: 'mainheatc_energy_eff_ending': 'Average', 'lighting_energy_eff_starting': 'Very Good', 'lighting_energy_eff_ending': 'Very Good', 'number_habitable_rooms_starting': 4.0, 'number_habitable_rooms_ending': 4.0, 'number_heated_rooms_starting': 4.0, - 'number_heated_rooms_ending': 4.0, 'days_to_starting': 3642, 'days_to_ending': 3642, - 'estimated_perimeter_starting': 23.430749027719962, 'estimated_perimeter_ending': 23.430749027719962 + 'number_heated_rooms_ending': 4.0, 'is_post_sap10_starting': False, 'is_post_sap10_ending': False, + 'lodgement_date_starting': '2024-07-21', 'lodgement_date_ending': '2024-07-21', 'days_to_starting': 3642, + 'days_to_ending': 3642, 'estimated_perimeter_starting': 23.430749027719962, + 'estimated_perimeter_ending': 23.430749027719962 } assert starting_record == expected_base_difference_record @@ -553,104 +543,168 @@ class TestWindowRecommendations: assert len(simulated_data) == 1 expected_simulated_outcome = { - 'uprn': 200001041444, 'rdsap_change': 0, 'heat_demand_change': 0, 'carbon_change': 0.0, + 'uprn': 200001041444, 'rdsap_change': 0, 'heat_demand_change': 0, + 'carbon_change': 0.0, 'potential_energy_efficiency': 82.0, 'environment_impact_potential': 79.0, - 'energy_consumption_potential': 155.0, 'co2_emissions_potential': 1.7, 'property_type': 'House', - 'built_form': 'Semi-Detached', 'constituency': 'E14000909', 'number_habitable_rooms': 4.0, - 'number_heated_rooms': 4.0, 'construction_age_band': 'England and Wales: before 1900', + 'energy_consumption_potential': 155.0, 'co2_emissions_potential': 1.7, + 'property_type': 'House', + 'built_form': 'Semi-Detached', 'constituency': 'E14000909', + 'number_habitable_rooms': 4.0, + 'number_heated_rooms': 4.0, + 'construction_age_band': 'England and Wales: before 1900', 'fixed_lighting_outlets_count': 7.0, 'walls_thermal_transmittance': 1.7, - 'walls_thermal_transmittance_unit': 'Unknown', 'is_cavity_wall': False, 'is_filled_cavity': False, + 'walls_thermal_transmittance_unit': 'Unknown', 'is_cavity_wall': False, + 'is_filled_cavity': False, 'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False, - 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'walls_is_assumed': True, - 'is_sandstone_or_limestone': False, 'is_park_home': False, 'walls_insulation_thickness': 'none', - 'external_insulation': False, 'internal_insulation': False, 'floor_thermal_transmittance': 0.96, - 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': True, - 'another_property_below': False, 'floor_insulation_thickness': 'none', 'roof_thermal_transmittance': 2.3, - 'is_pitched': True, 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, - 'is_at_rafters': False, 'has_dwelling_above': False, 'roof_insulation_thickness': 'none', - 'heater_type': 'Unknown', 'system_type': 'from main system', 'thermostat_characteristics': 'Unknown', - 'heating_scope': 'Unknown', 'energy_recovery': 'Unknown', 'hotwater_tariff_type': 'Unknown', - 'extra_features': 'Unknown', 'chp_systems': 'Unknown', 'distribution_system': 'Unknown', - 'no_system_present': 'Unknown', 'appliance': 'Unknown', 'has_radiators': True, 'has_fan_coil_units': False, - 'has_pipes_in_screed_above_insulation': False, 'has_pipes_in_insulated_timber_floor': False, - 'has_pipes_in_concrete_slab': False, 'has_boiler': True, 'has_air_source_heat_pump': False, - 'has_room_heaters': False, 'has_electric_storage_heaters': False, 'has_warm_air': False, + 'is_granite_or_whinstone': False, + 'is_as_built': True, 'is_cob': False, 'walls_is_assumed': True, + 'is_sandstone_or_limestone': False, + 'is_park_home': False, 'walls_insulation_thickness': 'none', + 'external_insulation': False, + 'internal_insulation': False, 'floor_thermal_transmittance': 0.96, + 'is_to_unheated_space': False, + 'is_to_external_air': False, 'is_suspended': False, 'is_solid': True, + 'another_property_below': False, + 'floor_insulation_thickness': 'none', 'roof_thermal_transmittance': 2.3, + 'is_pitched': True, + 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, + 'is_at_rafters': False, + 'has_dwelling_above': False, 'roof_insulation_thickness': 'none', + 'heater_type': 'Unknown', + 'system_type': 'from main system', 'thermostat_characteristics': 'Unknown', + 'heating_scope': 'Unknown', + 'energy_recovery': 'Unknown', 'hotwater_tariff_type': 'Unknown', + 'extra_features': 'Unknown', + 'chp_systems': 'Unknown', 'distribution_system': 'Unknown', + 'no_system_present': 'Unknown', + 'appliance': 'Unknown', 'has_radiators': True, 'has_fan_coil_units': False, + 'has_pipes_in_screed_above_insulation': False, + 'has_pipes_in_insulated_timber_floor': False, + 'has_pipes_in_concrete_slab': False, 'has_boiler': True, + 'has_air_source_heat_pump': False, + 'has_room_heaters': False, 'has_electric_storage_heaters': False, + 'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False, - 'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False, + 'has_community_scheme': False, 'has_ground_source_heat_pump': False, + 'has_no_system_present': False, 'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, - 'has_electric_heat_pump': False, 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, - 'has_exhaust_source_heat_pump': False, 'has_community_heat_pump': False, 'has_electric': False, - 'has_mains_gas': True, 'has_wood_logs': False, 'has_coal': False, 'has_oil': False, - 'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': False, - 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False, 'has_electricaire': False, - 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False, - 'thermostatic_control': 'room thermostat', 'charging_system': 'Unknown', 'switch_system': 'programmer', + 'has_electric_heat_pump': False, + 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, + 'has_exhaust_source_heat_pump': False, + 'has_community_heat_pump': False, 'has_electric': False, 'has_mains_gas': True, + 'has_wood_logs': False, + 'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, + 'has_anthracite': False, + 'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, + 'has_lpg': False, 'has_b30k': False, + 'has_electricaire': False, 'has_assumed_for_most_rooms': False, + 'has_underfloor_heating': False, + 'thermostatic_control': 'room thermostat', 'charging_system': 'Unknown', + 'switch_system': 'programmer', 'no_control': 'Unknown', 'dhw_control': 'Unknown', 'community_heating': 'Unknown', - 'multiple_room_thermostats': False, 'auxiliary_systems': 'Unknown', 'trvs': 'Unknown', + 'multiple_room_thermostats': False, 'auxiliary_systems': 'Unknown', + 'trvs': 'Unknown', 'rate_control': 'Unknown', 'glazing_type': 'single', 'fuel_type': 'mains gas', 'main-fuel_tariff_type': 'Unknown', 'is_community': False, - 'no_individual_heating_or_community_network': False, 'complex_fuel_type': 'Unknown', - 'walls_thermal_transmittance_ending': 1.7, 'walls_thermal_transmittance_unit_ending': 'Unknown', - 'is_filled_cavity_ending': False, 'is_as_built_ending': True, 'walls_is_assumed_ending': True, + 'no_individual_heating_or_community_network': False, + 'complex_fuel_type': 'Unknown', + 'walls_thermal_transmittance_ending': 1.7, + 'walls_thermal_transmittance_unit_ending': 'Unknown', + 'is_filled_cavity_ending': False, 'is_as_built_ending': True, + 'walls_is_assumed_ending': True, 'is_park_home_ending': False, 'walls_insulation_thickness_ending': 'none', 'external_insulation_ending': False, 'internal_insulation_ending': False, - 'floor_thermal_transmittance_ending': 0.96, 'floor_insulation_thickness_ending': 'none', + 'floor_thermal_transmittance_ending': 0.96, + 'floor_insulation_thickness_ending': 'none', 'roof_thermal_transmittance_ending': 2.3, 'is_at_rafters_ending': False, 'roof_insulation_thickness_ending': 'none', 'heater_type_ending': 'Unknown', - 'system_type_ending': 'from main system', 'thermostat_characteristics_ending': 'Unknown', + 'system_type_ending': 'from main system', + 'thermostat_characteristics_ending': 'Unknown', 'heating_scope_ending': 'Unknown', 'energy_recovery_ending': 'Unknown', 'hotwater_tariff_type_ending': 'Unknown', 'extra_features_ending': 'Unknown', - 'chp_systems_ending': 'Unknown', 'distribution_system_ending': 'Unknown', - 'no_system_present_ending': 'Unknown', 'appliance_ending': 'Unknown', 'has_radiators_ending': True, - 'has_fan_coil_units_ending': False, 'has_pipes_in_screed_above_insulation_ending': False, - 'has_pipes_in_insulated_timber_floor_ending': False, 'has_pipes_in_concrete_slab_ending': False, - 'has_boiler_ending': True, 'has_air_source_heat_pump_ending': False, 'has_room_heaters_ending': False, + 'chp_systems_ending': 'Unknown', + 'distribution_system_ending': 'Unknown', 'no_system_present_ending': 'Unknown', + 'appliance_ending': 'Unknown', + 'has_radiators_ending': True, 'has_fan_coil_units_ending': False, + 'has_pipes_in_screed_above_insulation_ending': False, + 'has_pipes_in_insulated_timber_floor_ending': False, + 'has_pipes_in_concrete_slab_ending': False, 'has_boiler_ending': True, + 'has_air_source_heat_pump_ending': False, 'has_room_heaters_ending': False, 'has_electric_storage_heaters_ending': False, 'has_warm_air_ending': False, - 'has_electric_underfloor_heating_ending': False, 'has_electric_ceiling_heating_ending': False, + 'has_electric_underfloor_heating_ending': False, + 'has_electric_ceiling_heating_ending': False, 'has_community_scheme_ending': False, 'has_ground_source_heat_pump_ending': False, - 'has_no_system_present_ending': False, 'has_portable_electric_heaters_ending': False, - 'has_water_source_heat_pump_ending': False, 'has_electric_heat_pump_ending': False, - 'has_micro-cogeneration_ending': False, 'has_solar_assisted_heat_pump_ending': False, - 'has_exhaust_source_heat_pump_ending': False, 'has_community_heat_pump_ending': False, - 'has_electric_ending': False, 'has_mains_gas_ending': True, 'has_wood_logs_ending': False, - 'has_coal_ending': False, 'has_oil_ending': False, 'has_wood_pellets_ending': False, + 'has_no_system_present_ending': False, + 'has_portable_electric_heaters_ending': False, + 'has_water_source_heat_pump_ending': False, + 'has_electric_heat_pump_ending': False, + 'has_micro-cogeneration_ending': False, + 'has_solar_assisted_heat_pump_ending': False, + 'has_exhaust_source_heat_pump_ending': False, + 'has_community_heat_pump_ending': False, + 'has_electric_ending': False, 'has_mains_gas_ending': True, + 'has_wood_logs_ending': False, + 'has_coal_ending': False, 'has_oil_ending': False, + 'has_wood_pellets_ending': False, 'has_anthracite_ending': False, 'has_dual_fuel_mineral_and_wood_ending': False, - 'has_smokeless_fuel_ending': False, 'has_lpg_ending': False, 'has_b30k_ending': False, + 'has_smokeless_fuel_ending': False, 'has_lpg_ending': False, + 'has_b30k_ending': False, 'has_electricaire_ending': False, 'has_assumed_for_most_rooms_ending': False, - 'has_underfloor_heating_ending': False, 'thermostatic_control_ending': 'room thermostat', - 'charging_system_ending': 'Unknown', 'switch_system_ending': 'programmer', 'no_control_ending': 'Unknown', + 'has_underfloor_heating_ending': False, + 'thermostatic_control_ending': 'room thermostat', + 'charging_system_ending': 'Unknown', 'switch_system_ending': 'programmer', + 'no_control_ending': 'Unknown', 'dhw_control_ending': 'Unknown', 'community_heating_ending': 'Unknown', - 'multiple_room_thermostats_ending': False, 'auxiliary_systems_ending': 'Unknown', 'trvs_ending': 'Unknown', - 'rate_control_ending': 'Unknown', 'glazing_type_ending': 'double', 'fuel_type_ending': 'mains gas', + 'multiple_room_thermostats_ending': False, 'auxiliary_systems_ending': 'Unknown', + 'trvs_ending': 'Unknown', + 'rate_control_ending': 'Unknown', 'glazing_type_ending': 'double', + 'fuel_type_ending': 'mains gas', 'main-fuel_tariff_type_ending': 'Unknown', 'is_community_ending': False, - 'no_individual_heating_or_community_network_ending': False, 'complex_fuel_type_ending': 'Unknown', - 'sap_starting': 47, 'sap_ending': 47, 'heat_demand_starting': 478, 'heat_demand_ending': 478, - 'carbon_starting': 5.1, 'carbon_ending': 5.1, 'lighting_cost_starting': 91.0, 'lighting_cost_ending': 91.0, - 'heating_cost_starting': 1677.0, 'heating_cost_ending': 1677.0, 'hot_water_cost_starting': 161.0, + 'no_individual_heating_or_community_network_ending': False, + 'complex_fuel_type_ending': 'Unknown', + 'sap_starting': 47, 'sap_ending': 47, 'heat_demand_starting': 478, + 'heat_demand_ending': 478, + 'carbon_starting': 5.1, 'carbon_ending': 5.1, 'lighting_cost_starting': 91.0, + 'lighting_cost_ending': 91.0, + 'heating_cost_starting': 1677.0, 'heating_cost_ending': 1677.0, + 'hot_water_cost_starting': 161.0, 'hot_water_cost_ending': 161.0, 'mechanical_ventilation_starting': 'natural', - 'mechanical_ventilation_ending': 'natural', 'secondheat_description_starting': 'None', + 'mechanical_ventilation_ending': 'natural', + 'secondheat_description_starting': 'None', 'secondheat_description_ending': 'None', 'glazed_type_starting': 'not defined', 'glazed_type_ending': 'double glazing installed during or after 2002', - 'multi_glaze_proportion_starting': 0.0, 'multi_glaze_proportion_ending': 100, - 'low_energy_lighting_starting': 100.0, 'low_energy_lighting_ending': 100.0, - 'number_open_fireplaces_starting': 0.0, 'number_open_fireplaces_ending': 0.0, - 'solar_water_heating_flag_starting': 'N', 'solar_water_heating_flag_ending': 'N', - 'photo_supply_starting': 0.0, 'photo_supply_ending': 0.0, 'transaction_type_starting': 'rental', - 'transaction_type_ending': 'rental', 'energy_tariff_starting': 'dual', 'energy_tariff_ending': 'dual', - 'extension_count_starting': 3.0, 'extension_count_ending': 3.0, 'total_floor_area_starting': 61.0, - 'total_floor_area_ending': 61.0, 'floor_height_starting': 2.37, 'floor_height_ending': 2.37, - 'hot_water_energy_eff_starting': 'Good', 'hot_water_energy_eff_ending': 'Good', + 'multi_glaze_proportion_starting': 0.0, + 'multi_glaze_proportion_ending': 100, 'low_energy_lighting_starting': 100.0, + 'low_energy_lighting_ending': 100.0, 'number_open_fireplaces_starting': 0.0, + 'number_open_fireplaces_ending': 0.0, 'solar_water_heating_flag_starting': 'N', + 'solar_water_heating_flag_ending': 'N', 'photo_supply_starting': 0.0, + 'photo_supply_ending': 0.0, + 'transaction_type_starting': 'rental', 'transaction_type_ending': 'rental', + 'energy_tariff_starting': 'dual', + 'energy_tariff_ending': 'dual', 'extension_count_starting': 3.0, + 'extension_count_ending': 3.0, + 'total_floor_area_starting': 61.0, 'total_floor_area_ending': 61.0, + 'floor_height_starting': 2.37, + 'floor_height_ending': 2.37, 'hot_water_energy_eff_starting': 'Good', + 'hot_water_energy_eff_ending': 'Good', 'floor_energy_eff_starting': 'NO_RATING', 'floor_energy_eff_ending': 'NO_RATING', 'windows_energy_eff_starting': 'Very Poor', 'windows_energy_eff_ending': 'Good', 'walls_energy_eff_starting': 'Very Poor', 'walls_energy_eff_ending': 'Very Poor', - 'sheating_energy_eff_starting': 'NO_RATING', 'sheating_energy_eff_ending': 'NO_RATING', + 'sheating_energy_eff_starting': 'NO_RATING', + 'sheating_energy_eff_ending': 'NO_RATING', 'roof_energy_eff_starting': 'Very Poor', 'roof_energy_eff_ending': 'Very Poor', 'mainheat_energy_eff_starting': 'Good', 'mainheat_energy_eff_ending': 'Good', - 'mainheatc_energy_eff_starting': 'Average', 'mainheatc_energy_eff_ending': 'Average', - 'lighting_energy_eff_starting': 'Very Good', 'lighting_energy_eff_ending': 'Very Good', + 'mainheatc_energy_eff_starting': 'Average', + 'mainheatc_energy_eff_ending': 'Average', + 'lighting_energy_eff_starting': 'Very Good', + 'lighting_energy_eff_ending': 'Very Good', 'number_habitable_rooms_starting': 4.0, 'number_habitable_rooms_ending': 4.0, - 'number_heated_rooms_starting': 4.0, 'number_heated_rooms_ending': 4.0, 'days_to_starting': 3642, - 'days_to_ending': 3713, 'estimated_perimeter_starting': 23.430749027719962, + 'number_heated_rooms_starting': 4.0, 'number_heated_rooms_ending': 4.0, + 'is_post_sap10_starting': False, + 'is_post_sap10_ending': False, 'lodgement_date_starting': '2024-07-21', + 'lodgement_date_ending': '2024-07-21', + 'days_to_starting': 3642, 'days_to_ending': 4189, + 'estimated_perimeter_starting': 23.430749027719962, 'estimated_perimeter_ending': 23.430749027719962, 'has_glazing_ending': True, 'glazing_coverage_ending': 'full', 'id': '1+1' } From 1a90a04eb7e8035d63804fbc0f33126c80b50504 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 20 Jan 2026 23:50:14 +0000 Subject: [PATCH 10/74] debugging windows recommendation tests --- .../tests/test_window_recommendations.py | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/recommendations/tests/test_window_recommendations.py b/recommendations/tests/test_window_recommendations.py index 56e775c7..ba0e0c16 100644 --- a/recommendations/tests/test_window_recommendations.py +++ b/recommendations/tests/test_window_recommendations.py @@ -45,11 +45,13 @@ class TestWindowRecommendations: epc_record=epc_record ) property_1.windows = { - 'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': 'full', + 'original_description': 'Single glazed', 'clean_description': 'Single glazed', + 'has_glazing': False, 'glazing_coverage': 'full', 'glazing_type': 'single', 'no_data': False } property_1.number_of_windows = 7 + property_1.already_installed = [] recommender = WindowsRecommendations(property_instance=property_1, materials=materials) @@ -59,25 +61,16 @@ class TestWindowRecommendations: # The home is going from single glazing (v poor energy eff) -> double glazing (average energy eff) - assert recommender.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', "measure_type": "double_glazing", - 'description': 'Install double glazing to all windows', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'already_installed': False, - 'total': 7980.0, 'labour_hours': 0.0, 'labour_days': 0.0, 'is_secondary_glazing': False, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Fully double glazed', - 'glazed-type': 'double glazing installed during or after 2002' - }, - 'simulation_config': { - 'has_glazing_ending': True, 'glazing_type_ending': 'double', - 'multi_glaze_proportion_ending': 100, 'windows_energy_eff_ending': 'Good', - 'glazed_type_ending': 'double glazing installed during or after 2002' - }, - "survey": None - } - ] + assert len(recommender.recommendation) == 1 + assert recommender.recommendation[0]["total"] == np.float64(7980.0) + assert recommender.recommendation[0]["phase"] == 0 + assert recommender.recommendation[0]["description"] == 'Install double glazing to all windows' + assert recommender.recommendation[0]["contingency"] == np.float64(1197.0) + assert recommender.recommendation[0]["simulation_config"] == { + 'has_glazing_ending': True, 'glazing_type_ending': 'double', + 'multi_glaze_proportion_ending': 100, 'windows_energy_eff_ending': 'Good', + 'glazed_type_ending': 'double glazing installed during or after 2002' + } def test_partial_double_glazed(self): """ @@ -322,10 +315,14 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_8.windows = {'original_description': 'Mostly triple glazing', 'has_glazing': True, - 'glazing_coverage': 'most', - 'glazing_type': 'triple', 'no_data': False} + property_8.windows = { + 'original_description': 'Mostly triple glazing', 'clean_description': 'Mostly triple glazing', + 'has_glazing': True, + 'glazing_coverage': 'most', + 'glazing_type': 'triple', 'no_data': False + } property_8.number_of_windows = 7 + property_8.already_installed = [] recommender8 = WindowsRecommendations(property_instance=property_8, materials=materials) From 243b0ff2d467b4a1eb712300b7a5045e3cc57639 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 20 Jan 2026 23:55:26 +0000 Subject: [PATCH 11/74] fixed windows recommendations tests --- .../tests/test_window_recommendations.py | 104 +++++++++--------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/recommendations/tests/test_window_recommendations.py b/recommendations/tests/test_window_recommendations.py index ba0e0c16..c6f383ba 100644 --- a/recommendations/tests/test_window_recommendations.py +++ b/recommendations/tests/test_window_recommendations.py @@ -91,10 +91,15 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_2.windows = {'original_description': 'Mostly double glazing', 'has_glazing': True, - 'glazing_coverage': 'most', - 'glazing_type': 'double', 'no_data': False} + property_2.windows = { + 'original_description': 'Mostly double glazing', + 'clean_description': 'Mostly double glazing', + 'has_glazing': True, + 'glazing_coverage': 'most', + 'glazing_type': 'double', 'no_data': False + } property_2.number_of_windows = 7 + property_2.already_installed = [] recommender2 = WindowsRecommendations(property_instance=property_2, materials=materials) @@ -102,26 +107,16 @@ class TestWindowRecommendations: recommender2.recommend() - assert recommender2.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', "measure_type": "double_glazing", - 'description': 'Install double glazing to the remaining windows', 'starting_u_value': None, - 'new_u_value': None, 'sap_points': None, 'already_installed': False, 'total': 5700.0, - 'labour_hours': 0.0, - 'labour_days': 0.0, 'is_secondary_glazing': False, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Fully double glazed', - 'glazed-type': 'double glazing installed during or after 2002' - }, - 'simulation_config': { - 'glazing_coverage_ending': 'full', 'multi_glaze_proportion_ending': 100, - 'windows_energy_eff_ending': 'Good', 'glazing_type_ending': 'double', - 'glazed_type_ending': 'double glazing installed during or after 2002' - }, - "survey": None - } - ] + assert len(recommender2.recommendation) == 1 + assert recommender2.recommendation[0]["total"] == np.float64(5700.0) + assert recommender2.recommendation[0]["phase"] == 0 + assert recommender2.recommendation[0]["description"] == 'Install double glazing to all windows' + assert recommender2.recommendation[0]["contingency"] == np.float64(855.0) + assert recommender2.recommendation[0]["simulation_config"] == { + 'glazing_coverage_ending': 'full', 'multi_glaze_proportion_ending': 100, + 'windows_energy_eff_ending': 'Good', 'glazing_type_ending': 'double', + 'glazed_type_ending': 'double glazing installed during or after 2002' + } def test_fully_double_glazed(self): """ @@ -140,10 +135,14 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_3.windows = {'original_description': 'Fully double glazed', 'has_glazing': True, - 'glazing_coverage': 'full', - 'glazing_type': 'double', 'no_data': False} + property_3.windows = { + 'original_description': 'Fully double glazed', 'clean_description': 'Fully double glazed', + 'has_glazing': True, + 'glazing_coverage': 'full', + 'glazing_type': 'double', 'no_data': False + } property_3.number_of_windows = 7 + property_3.already_installed = [] recommender3 = WindowsRecommendations(property_instance=property_3, materials=materials) @@ -166,10 +165,15 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_4.windows = {'original_description': 'Full secondary glazing', 'has_glazing': True, - 'glazing_coverage': 'full', - 'glazing_type': 'secondary', 'no_data': False} + property_4.windows = { + 'original_description': 'Full secondary glazing', + 'clean_description': 'Full secondary glazing', + 'has_glazing': True, + 'glazing_coverage': 'full', + 'glazing_type': 'secondary', 'no_data': False + } property_4.number_of_windows = 7 + property_4.already_installed = [] recommender4 = WindowsRecommendations(property_instance=property_4, materials=materials) @@ -193,10 +197,15 @@ class TestWindowRecommendations: address='1', epc_record=epc_record ) - property_5.windows = {'original_description': 'Partial secondary glazing', 'has_glazing': True, - 'glazing_coverage': 'partial', - 'glazing_type': 'secondary', 'no_data': False} + property_5.windows = { + 'original_description': 'Partial secondary glazing', + 'clean_description': 'Partial secondary glazing', + 'has_glazing': True, + 'glazing_coverage': 'partial', + 'glazing_type': 'secondary', 'no_data': False + } property_5.number_of_windows = 7 + property_5.already_installed = [] recommender5 = WindowsRecommendations(property_instance=property_5, materials=materials) @@ -204,25 +213,15 @@ class TestWindowRecommendations: recommender5.recommend() - assert recommender5.recommendation == [ - { - 'phase': 0, 'parts': [], 'type': 'windows_glazing', 'measure_type': 'secondary_glazing', - 'description': 'Install secondary glazing to the remaining windows', 'starting_u_value': None, - 'new_u_value': None, 'sap_points': None, 'already_installed': False, 'total': 4560.0, - 'labour_hours': 0.0, 'labour_days': 0.0, 'is_secondary_glazing': True, - 'description_simulation': { - 'multi-glaze-proportion': 100, 'windows-energy-eff': 'Good', - 'windows-description': 'Full secondary glazing', - 'glazed-type': 'secondary glazing' - }, - 'simulation_config': { - 'glazing_coverage_ending': 'full', 'multi_glaze_proportion_ending': 100, - 'windows_energy_eff_ending': 'Good', 'glazing_type_ending': 'secondary', - 'glazed_type_ending': 'secondary glazing' - }, - "survey": None - } - ] + assert len(recommender5.recommendation) == 1 + assert recommender5.recommendation[0]["total"] == np.float64(4560.0) + assert recommender5.recommendation[0]["phase"] == 0 + assert recommender5.recommendation[0]["description"] == 'Install double glazing to all windows' + assert recommender5.recommendation[0]["contingency"] == np.float64(684.0) + assert recommender5.recommendation[0]["simulation_config"] == { + 'glazing_coverage_ending': 'full', 'glazing_type_ending': 'multiple', 'multi_glaze_proportion_ending': 100, + 'windows_energy_eff_ending': 'Average', 'glazed_type_ending': 'secondary glazing' + } def test_single_glazed_restricted_measures(self): epc_record = EPCRecord() @@ -316,7 +315,8 @@ class TestWindowRecommendations: epc_record=epc_record ) property_8.windows = { - 'original_description': 'Mostly triple glazing', 'clean_description': 'Mostly triple glazing', + 'original_description': 'Mostly triple glazing', + 'clean_description': 'Mostly triple glazing', 'has_glazing': True, 'glazing_coverage': 'most', 'glazing_type': 'triple', 'no_data': False From cc96b138718ed5dec24062c69e133d00071b503e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 21 Jan 2026 10:54:51 +0000 Subject: [PATCH 12/74] fixing wall recommendation tests --- .../tests/test_wall_recommendations.py | 137 +++++++++++------- 1 file changed, 88 insertions(+), 49 deletions(-) diff --git a/recommendations/tests/test_wall_recommendations.py b/recommendations/tests/test_wall_recommendations.py index a4093e58..4a224734 100644 --- a/recommendations/tests/test_wall_recommendations.py +++ b/recommendations/tests/test_wall_recommendations.py @@ -3,6 +3,7 @@ import pytest import pickle import numpy as np from unittest.mock import Mock, MagicMock + from recommendations.WallRecommendations import WallRecommendations from backend.Property import Property from recommendations.recommendation_utils import is_diminishing_returns @@ -10,23 +11,8 @@ from recommendations.tests.test_data.materials import materials from etl.epc.Record import EPCRecord -# import inspect -# file_path = inspect.getfile(lambda: None) -# with open( -# os.path.abspath(os.path.dirname(file_path)) + "/recommendations/tests/test_data/input_properties.pkl", "rb" -# ) as f: -# input_properties = pickle.load(f) - - class TestWallRecommendations: - @pytest.fixture - def input_properties(self): - with open( - os.path.abspath(os.path.dirname(__file__)) + "/test_data/input_properties.pkl", "rb" - ) as f: - return pickle.load(f) - @pytest.fixture def mock_wall_rec_instance(self): # Creating a mock instance of WallRecommendations with the necessary attributes @@ -40,17 +26,30 @@ class TestWallRecommendations: ) return mock_wall_rec_instance - def test_init(self, input_properties): - input_properties[0].insulation_wall_area = 100 + def test_init(self): + p = Mock( + id=1, + insulation_wall_area=100, + walls={ + 'original_description': 'Average thermal transmittance 0.16 W/m-¦K', 'thermal_transmittance': 0.16, + 'thermal_transmittance_unit': 'w/m-¦k', 'is_cavity_wall': False, 'is_filled_cavity': False, + 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False, + 'is_sandstone_or_limestone': False, 'insulation_thickness': None, 'external_insulation': False, + 'internal_insulation': False + }, + already_installed=[], + data={"county": "Greater London Authority"} + ) obj = WallRecommendations( - property_instance=input_properties[0], + property_instance=p, materials=materials ) assert obj assert obj.property - def test_uvalue_0_16(self, input_properties): + def test_uvalue_0_16(self): """ This tests the wall description Average thermal transmittance 0.16 W/m-¦K The important data for this recommendation is: @@ -59,13 +58,29 @@ class TestWallRecommendations: Since epc built after 1990 are typically built with insulation and this property already has really good insulation, we do NOT recommend any measures for this property """ - input_properties[0].year_built = 2014 - input_properties[0].in_conservation_area = None - input_properties[0].restricted_measures = False - input_properties[0].insulation_wall_area = 100 + + p = Mock( + id=1, + insulation_wall_area=100, + year_built=2014, + in_conservation_area=None, + restricted_measure=False, + walls={ + 'original_description': 'Average thermal transmittance 0.16 W/m-¦K', + 'clean_description': 'Average thermal transmittance 0.16 W/m-¦K', + 'thermal_transmittance': 0.16, + 'thermal_transmittance_unit': 'w/m-¦k', 'is_cavity_wall': False, 'is_filled_cavity': False, + 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False, + 'is_sandstone_or_limestone': False, 'insulation_thickness': None, 'external_insulation': False, + 'internal_insulation': False + }, + already_installed=[], + data={"county": "Greater London Authority", 'transaction-type': 'new dwelling'} + ) recommender = WallRecommendations( - property_instance=input_properties[0], + property_instance=p, materials=materials ) assert recommender.property.walls["original_description"] == "Average thermal transmittance 0.16 W/m-¦K" @@ -73,7 +88,7 @@ class TestWallRecommendations: # This should be empty assert recommender.recommendations == [] - def test_solid_brick_no_insulation(self, input_properties): + def test_solid_brick_no_insulation(self): """ This tests a property with a wall description of Solid brick, as built, no insulation (assumed) The property was built in 1930, right on the threshold for when cavity walls were introduced @@ -82,25 +97,35 @@ class TestWallRecommendations: This property is not in a conservation area, however it's a flat so we don't recommend external wall insulation """ - input_properties[1].year_built = 1930 - input_properties[1].insulation_wall_area = 100 - input_properties[1].walls["clean_description"] = "Solid brick, as built, no insulation" - input_properties[1].walls["is_sandstone_or_limestone"] = False - input_properties[1].age_band = "A" - input_properties[1].restricted_measures = False - input_properties[1].already_installed = [] - input_properties[1].walls["is_park_home"] = False - input_properties[1].construction_age_band = "England and Wales: 1930-1949" - input_properties[1].non_invasive_recommendations = [] + + p = Mock( + id=2, + year_built=1930, + insulation_wall_area=100, + age_band="A", + restricted_measures=False, + already_installed=[], + construction_age_band="England and Wales: 1930-1949", + in_conservation_area="not_in_conservation_area", + non_invasive_recommendations=[], + walls={ + 'original_description': 'Solid brick, as built, no insulation (assumed)', + "clean_description": "Solid brick, as built, no insulation", + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False, + 'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True, + 'is_sandstone_or_limestone': False, 'insulation_thickness': 'none', 'external_insulation': False, + 'internal_insulation': False, 'is_park_home': False + }, + data={"county": "Greater London Authority", 'property-type': 'Flat', 'walls-energy-eff': 'Very Poor'} + ) recommender = WallRecommendations( - property_instance=input_properties[1], + property_instance=p, materials=materials ) - assert recommender.property.walls["original_description"] == "Solid brick, as built, no insulation (assumed)" assert not recommender.ewi_valid() - assert recommender.property.in_conservation_area == "not_in_conservation_area" - assert recommender.property.data["property-type"] == "Flat" recommender.recommend(phase=0) # This should result in some recommendations, all of which should be internal insulation @@ -115,7 +140,7 @@ class TestWallRecommendations: recommender.recommendations ) - def test_solid_brick_insulation(self, input_properties): + def test_solid_brick_insulation(self): """ This tests a property with a wall description of Solid brick, as built, insulation (assumed) The property was built in 1991, after cavity walls were introduced @@ -127,19 +152,34 @@ class TestWallRecommendations: This property is not in a conservation area, however it's a flat so we don't recommend external wall insulation """ - input_properties[6].year_built = 1991 - input_properties[6].restricted_measures = False - input_properties[6].insulation_wall_area = 100 + p = Mock( + id=3, + year_built=1991, + restricted_measures=False, + insulation_wall_area=100, + already_installed=[], + in_conservation_area="not_in_conservation_area", + data={'county': 'Greater London Authority', 'property-type': 'Flat'}, + walls={ + 'original_description': 'Solid brick, as built, insulated (assumed)', + 'clean_description': 'Solid brick, as built, insulated', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False, + 'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True, + 'is_sandstone_or_limestone': False, 'insulation_thickness': 'average', 'external_insulation': False, + 'internal_insulation': False + } + + ) recommender = WallRecommendations( - property_instance=input_properties[6], + property_instance=p, materials=materials ) assert recommender.property.walls["original_description"] == "Solid brick, as built, insulated (assumed)" assert not recommender.ewi_valid() - assert recommender.property.in_conservation_area == "not_in_conservation_area" - assert recommender.property.data["property-type"] == "Flat" assert recommender.estimated_u_value is None recommender.recommend() @@ -507,8 +547,7 @@ class TestCavityWallRecommensations: input_property6.insulation_wall_area = 350 input_property6.restricted_measures = False input_property6.construction_age_band = "England and Wales: 1976-1982" - - assert input_property6.walls["is_sandstone_or_limestone"] + input_property6.already_installed = [] recommender6 = WallRecommendations( property_instance=input_property6, @@ -524,6 +563,6 @@ class TestCavityWallRecommensations: assert len(recommender6.recommendations) == 1 assert recommender6.estimated_u_value == 1 assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.26) - assert np.isclose(recommender6.recommendations[0]["total"], 85680.0) + assert np.isclose(recommender6.recommendations[0]["total"], 68250.0) assert recommender6.recommendations[0]["parts"][0]["type"] == "internal_wall_insulation" assert recommender6.recommendations[0]["parts"][0]["depth"] == 95 From fc08a7df4ff64dcc2c64c9ad1de692c588adf66d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 11:01:25 +0000 Subject: [PATCH 13/74] =?UTF-8?q?Map=20LbwfHouse=20to=20AssetCondition=20l?= =?UTF-8?q?ist=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../condition/domain/mapping/lbwf_mapper.py | 52 +++++++++++++++++-- backend/condition/domain/mapping/mapper.py | 2 +- .../tests/mapping/test_lbwf_mapper.py | 2 +- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/backend/condition/domain/mapping/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf_mapper.py index 0bbdc0d6..63434240 100644 --- a/backend/condition/domain/mapping/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf_mapper.py @@ -1,15 +1,59 @@ from typing import Any, List, Optional +from datetime import datetime, date + from backend.condition.domain.asset_condition import AssetCondition from backend.condition.domain.element import Element from backend.condition.domain.mapping.mapper import Mapper from backend.condition.parsing.records.lbwf.lbwf_asset_condition import LbwfAssetCondition +from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse +from utils.logger import setup_logger +logger = setup_logger() class LbwfMapper(Mapper): - def map_asset_conditions(self, client_data: List[Any]) -> List[AssetCondition]: - raise NotImplementedError + def map_asset_conditions_for_property(self, client_data: Any) -> List[AssetCondition]: + assert isinstance(client_data, LbwfHouse) # TODO: think of a better way to do this + + mapped_assets: List[AssetCondition] = [] + + uprn: int = client_data.uprn + for raw_asset in client_data.assets: + try: + element: Element = LbwfMapper._map_element(raw_asset.element_code) + except: + logger.warning(f"Unrecognised LBWF Asset Element Code: {raw_asset.element_code}. Skipping record") + continue + + + mapped_assets.append( + AssetCondition( + uprn=uprn, + element=element, + condition_description=raw_asset.attribute_code_description, + quantity=raw_asset.quantity, + renewal_year=LbwfMapper._calculate_renewal_year(raw_asset), + source=raw_asset.element_comments, + ) + ) + + return mapped_assets + + @staticmethod - def _map_element(lbwf_asset: LbwfAssetCondition) -> Optional[Element]: - raise NotImplementedError \ No newline at end of file + def _map_element(lbwf_element_code: LbwfAssetCondition) -> Element: + return Element[lbwf_element_code] + + @staticmethod + def _calculate_renewal_year(lbwf_asset: LbwfAssetCondition) -> Optional[int]: + remaining_life_years: Optional[int] = lbwf_asset.remaining_life + if not remaining_life_years: + return None + + try: + survey_year: int = datetime.now().year # TODO: get survey year from filename or elsewhere + return survey_year + remaining_life_years + except: + logger.debug(f"Unable to map LBWF Asset remaining life {remaining_life_years} to renewal year, returning None") + return None \ No newline at end of file diff --git a/backend/condition/domain/mapping/mapper.py b/backend/condition/domain/mapping/mapper.py index b314e01c..f08fa4e1 100644 --- a/backend/condition/domain/mapping/mapper.py +++ b/backend/condition/domain/mapping/mapper.py @@ -6,6 +6,6 @@ from backend.condition.domain.asset_condition import AssetCondition class Mapper(ABC): @abstractmethod - def map_asset_conditions(self, client_data: List[Any]) -> List[AssetCondition]: + def map_asset_conditions_for_property(self, client_data: Any) -> List[AssetCondition]: #TODO: client_data should be properly typed pass \ No newline at end of file diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index e26b9928..3e066d27 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -233,7 +233,7 @@ def test_lbwf_mapper_maps_house(): ] # act - actual_assets: List[AssetCondition] = mapper.map_asset_conditions(lbwf_house) + actual_assets: List[AssetCondition] = mapper.map_asset_conditions_for_property(lbwf_house) # assert assert actual_assets == expected_assets \ No newline at end of file From 64c98dd4152d28332174e8ed8c0ccdcc4d780a76 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 21 Jan 2026 11:46:57 +0000 Subject: [PATCH 14/74] fixed remaining walls tests --- .../tests/test_wall_recommendations.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/recommendations/tests/test_wall_recommendations.py b/recommendations/tests/test_wall_recommendations.py index 4a224734..18560118 100644 --- a/recommendations/tests/test_wall_recommendations.py +++ b/recommendations/tests/test_wall_recommendations.py @@ -300,6 +300,7 @@ class TestCavityWallRecommensations: input_property.age_band = "C" input_property.insulation_wall_area = 50 input_property.construction_age_band = "England and Wales: 1930-1949" + input_property.already_installed = [] recommender = WallRecommendations( property_instance=input_property, @@ -313,7 +314,7 @@ class TestCavityWallRecommensations: assert recommender.recommendations assert recommender.estimated_u_value == 1.5 assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.35) - assert np.isclose(recommender.recommendations[0]["total"], 710.5) + assert np.isclose(recommender.recommendations[0]["total"], 925) def test_fill_partial_filled_cavity(self): epc_record = EPCRecord() @@ -333,6 +334,7 @@ class TestCavityWallRecommensations: input_property.age_band = "C" input_property.insulation_wall_area = 50 input_property.construction_age_band = "England and Wales: 1930-1949" + input_property.already_installed = [] recommender = WallRecommendations( property_instance=input_property, @@ -346,7 +348,7 @@ class TestCavityWallRecommensations: assert recommender.recommendations assert recommender.estimated_u_value == 1.3 assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.41) - assert np.isclose(recommender.recommendations[0]["total"], 710.5) + assert np.isclose(recommender.recommendations[0]["total"], 925.0) def test_system_built_wall(self): epc_record = EPCRecord() @@ -369,6 +371,7 @@ class TestCavityWallRecommensations: input_property2.insulation_wall_area = 120 input_property2.restricted_measures = False input_property2.construction_age_band = "England and Wales: 1976-1982" + input_property2.already_installed = [] assert input_property2.walls["is_system_built"] @@ -390,7 +393,7 @@ class TestCavityWallRecommensations: assert recommender2.recommendations[0]["parts"][0]["depth"] == 150 assert np.isclose(recommender2.recommendations[1]["new_u_value"], 0.26) - assert np.isclose(recommender2.recommendations[1]["total"], 29376) + assert np.isclose(recommender2.recommendations[1]["total"], 23400) assert recommender2.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation" assert recommender2.recommendations[1]["parts"][0]["depth"] == 95 @@ -416,6 +419,7 @@ class TestCavityWallRecommensations: input_property3.insulation_wall_area = 99 input_property3.restricted_measures = False input_property3.construction_age_band = "England and Wales: 1950-1966" + input_property3.already_installed = [] assert input_property3.walls["is_timber_frame"] @@ -437,7 +441,7 @@ class TestCavityWallRecommensations: assert recommender3.recommendations[0]["parts"][0]["depth"] == 150.0 assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.29) - assert np.isclose(recommender3.recommendations[1]["total"], 24235.2) + assert np.isclose(recommender3.recommendations[1]["total"], 19305.0) assert recommender3.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation" assert recommender3.recommendations[1]["parts"][0]["depth"] == 95.0 @@ -463,6 +467,7 @@ class TestCavityWallRecommensations: input_property4.insulation_wall_area = 223 input_property4.restricted_measures = False input_property4.construction_age_band = "England and Wales: before 1900" + input_property4.already_installed = [] assert input_property4.walls["is_granite_or_whinstone"] @@ -484,7 +489,7 @@ class TestCavityWallRecommensations: assert recommender4.recommendations[0]["parts"][0]["depth"] == 150 assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.3) - assert np.isclose(recommender4.recommendations[1]["total"], 54590.4) + assert np.isclose(recommender4.recommendations[1]["total"], 43485.0) assert recommender4.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation" assert recommender4.recommendations[1]["parts"][0]["depth"] == 95 @@ -510,6 +515,7 @@ class TestCavityWallRecommensations: input_property5.insulation_wall_area = 77 input_property5.restricted_measures = False input_property5.construction_age_band = "England and Wales: 1967-1975" + input_property5.already_installed = [] assert input_property5.walls["is_cob"] From 36e963c7aaacb2adfb8ced0f4787f704ce291766 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 21 Jan 2026 11:52:16 +0000 Subject: [PATCH 15/74] fixed failing ventilation recommendations --- .../tests/test_ventilation_recommendations.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/recommendations/tests/test_ventilation_recommendations.py b/recommendations/tests/test_ventilation_recommendations.py index ea87a632..15c9435c 100644 --- a/recommendations/tests/test_ventilation_recommendations.py +++ b/recommendations/tests/test_ventilation_recommendations.py @@ -10,6 +10,7 @@ class TestVentilationRecommendations: epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": "natural"} input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property1.already_installed = [] recommender = VentilationRecommendations( property_instance=input_property1, @@ -22,16 +23,18 @@ class TestVentilationRecommendations: assert len(recommender.recommendation) == 1 - assert recommender.recommendation[0]["total"] == 1071.0 + assert recommender.recommendation[0]["total"] == 560.0 assert recommender.recommendation[0]["type"] == "mechanical_ventilation" assert len(recommender.recommendation[0]["parts"]) == 1 - assert recommender.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation' + assert recommender.recommendation[0]["parts"][0][ + "description"] == 'Decentralised mechanical extract ventilation' assert recommender.recommendation[0]["parts"][0]["quantity"] == 2 def test_missing_ventilation(self): epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": None} input_property2 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property2.already_installed = [] recommender2 = VentilationRecommendations( property_instance=input_property2, @@ -44,16 +47,18 @@ class TestVentilationRecommendations: assert len(recommender2.recommendation) == 1 - assert recommender2.recommendation[0]["total"] == 1071.0 + assert recommender2.recommendation[0]["total"] == 560.0 assert recommender2.recommendation[0]["type"] == "mechanical_ventilation" assert len(recommender2.recommendation[0]["parts"]) == 1 - assert recommender2.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation' + assert recommender2.recommendation[0]["parts"][0][ + "description"] == 'Decentralised mechanical extract ventilation' assert recommender2.recommendation[0]["parts"][0]["quantity"] == 2 def test_nodata_ventilation(self): epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": "NO DATA!!"} input_property3 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property3.already_installed = [] recommender3 = VentilationRecommendations( property_instance=input_property3, @@ -66,16 +71,18 @@ class TestVentilationRecommendations: assert len(recommender3.recommendation) == 1 - assert recommender3.recommendation[0]["total"] == 1071.0 + assert recommender3.recommendation[0]["total"] == 560.0 assert recommender3.recommendation[0]["type"] == "mechanical_ventilation" assert len(recommender3.recommendation[0]["parts"]) == 1 - assert recommender3.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation' + assert recommender3.recommendation[0]["parts"][0][ + "description"] == 'Decentralised mechanical extract ventilation' assert recommender3.recommendation[0]["parts"][0]["quantity"] == 2 def test_existing_ventilation_1(self): epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, extract only"} input_property4 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property4.already_installed = [] input_property4.identify_ventilation() assert input_property4.has_ventilation @@ -94,6 +101,7 @@ class TestVentilationRecommendations: epc_record = EPCRecord() epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, supply and extract"} input_property5 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) + input_property5.already_installed = [] input_property5.identify_ventilation() assert input_property5.has_ventilation From 25923cbc9fb0f9c9f7f6bfa72ffb46480df2e8c0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 12:09:02 +0000 Subject: [PATCH 16/74] add mapping to processor --- backend/condition/parsing/factory.py | 8 ++++++++ backend/condition/processor.py | 13 ++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/condition/parsing/factory.py b/backend/condition/parsing/factory.py index 01dce75d..ea54d3e0 100644 --- a/backend/condition/parsing/factory.py +++ b/backend/condition/parsing/factory.py @@ -1,3 +1,5 @@ +from backend.condition.domain.mapping.lbwf_mapper import LbwfMapper +from backend.condition.domain.mapping.mapper import Mapper from backend.condition.file_type import FileType from backend.condition.parsing.parser import Parser from backend.condition.parsing.lbwf_parser import LbwfParser @@ -7,3 +9,9 @@ def select_parser(file_type: FileType) -> Parser: return LbwfParser() raise ValueError("Unrecognised file type, unable to instantiate Parser") + +def select_mapper(file_type: FileType) -> Mapper: + if file_type is FileType.LBWF: + return LbwfMapper() + + raise ValueError("Unrecognised file type, unable to instantiate Mapper") diff --git a/backend/condition/processor.py b/backend/condition/processor.py index fb06c888..4f379b23 100644 --- a/backend/condition/processor.py +++ b/backend/condition/processor.py @@ -1,9 +1,11 @@ from typing import Any, BinaryIO, List +from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.mapping.mapper import Mapper from backend.condition.parsing.parser import Parser from utils.logger import setup_logger from backend.condition.file_type import FileType, detect_file_type -from backend.condition.parsing.factory import select_parser +from backend.condition.parsing.factory import select_parser, select_mapper def process_file(file_stream: BinaryIO, source_key: str) -> None: print(f"[processor] Received file: {source_key}") @@ -11,8 +13,13 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None: # Instantiation file_type: FileType = detect_file_type(source_key) parser: Parser = select_parser(file_type) + mapper: Mapper = select_mapper(file_type) # Orchestration - records: List[Any] = parser.parse(file_stream) + raw_properties: List[Any] = parser.parse(file_stream) - print(records) # temp \ No newline at end of file + assets: List[AssetCondition] = [] + for p in raw_properties: + assets.extend(mapper.map_asset_conditions_for_property(p)) + + print(assets) # temp \ No newline at end of file From dfe04601551d94f3b831a405a3095a0d62f34cb0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 12:14:16 +0000 Subject: [PATCH 17/74] Pass survey year to mapper rather than using today's year --- backend/condition/domain/mapping/lbwf_mapper.py | 10 ++++++---- backend/condition/domain/mapping/mapper.py | 4 ++-- backend/condition/processor.py | 5 ++++- backend/condition/tests/mapping/test_lbwf_mapper.py | 4 ++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/backend/condition/domain/mapping/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf_mapper.py index 63434240..bc44e4c3 100644 --- a/backend/condition/domain/mapping/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf_mapper.py @@ -12,7 +12,7 @@ logger = setup_logger() class LbwfMapper(Mapper): - def map_asset_conditions_for_property(self, client_data: Any) -> List[AssetCondition]: + def map_asset_conditions_for_property(self, client_data: Any, survey_year: Optional[int]) -> List[AssetCondition]: assert isinstance(client_data, LbwfHouse) # TODO: think of a better way to do this mapped_assets: List[AssetCondition] = [] @@ -32,7 +32,7 @@ class LbwfMapper(Mapper): element=element, condition_description=raw_asset.attribute_code_description, quantity=raw_asset.quantity, - renewal_year=LbwfMapper._calculate_renewal_year(raw_asset), + renewal_year=LbwfMapper._calculate_renewal_year(raw_asset, survey_year), source=raw_asset.element_comments, ) ) @@ -46,13 +46,15 @@ class LbwfMapper(Mapper): return Element[lbwf_element_code] @staticmethod - def _calculate_renewal_year(lbwf_asset: LbwfAssetCondition) -> Optional[int]: + def _calculate_renewal_year(lbwf_asset: LbwfAssetCondition, survey_year: Optional[int]) -> Optional[int]: remaining_life_years: Optional[int] = lbwf_asset.remaining_life if not remaining_life_years: return None + if not survey_year: + return None + try: - survey_year: int = datetime.now().year # TODO: get survey year from filename or elsewhere return survey_year + remaining_life_years except: logger.debug(f"Unable to map LBWF Asset remaining life {remaining_life_years} to renewal year, returning None") diff --git a/backend/condition/domain/mapping/mapper.py b/backend/condition/domain/mapping/mapper.py index f08fa4e1..4e51d46b 100644 --- a/backend/condition/domain/mapping/mapper.py +++ b/backend/condition/domain/mapping/mapper.py @@ -1,11 +1,11 @@ from abc import ABC, abstractmethod -from typing import Any, List +from typing import Any, List, Optional from backend.condition.domain.asset_condition import AssetCondition class Mapper(ABC): @abstractmethod - def map_asset_conditions_for_property(self, client_data: Any) -> List[AssetCondition]: + def map_asset_conditions_for_property(self, client_data: Any, survey_year: Optional[int]) -> List[AssetCondition]: #TODO: client_data should be properly typed pass \ No newline at end of file diff --git a/backend/condition/processor.py b/backend/condition/processor.py index 4f379b23..cc44e38a 100644 --- a/backend/condition/processor.py +++ b/backend/condition/processor.py @@ -1,4 +1,5 @@ from typing import Any, BinaryIO, List +from datetime import datetime from backend.condition.domain.asset_condition import AssetCondition from backend.condition.domain.mapping.mapper import Mapper @@ -18,8 +19,10 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None: # Orchestration raw_properties: List[Any] = parser.parse(file_stream) + survey_year = datetime.now().year # TODO: get this from filepath or elsewhere + assets: List[AssetCondition] = [] for p in raw_properties: - assets.extend(mapper.map_asset_conditions_for_property(p)) + assets.extend(mapper.map_asset_conditions_for_property(p, survey_year)) print(assets) # temp \ No newline at end of file diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index 3e066d27..926e34c1 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -170,7 +170,7 @@ def test_lbwf_mapper_maps_house(): ) mapper = LbwfMapper() - current_year = 2026 + survey_year = 2026 expected_assets: List[AssetCondition] = [ AssetCondition( @@ -233,7 +233,7 @@ def test_lbwf_mapper_maps_house(): ] # act - actual_assets: List[AssetCondition] = mapper.map_asset_conditions_for_property(lbwf_house) + actual_assets: List[AssetCondition] = mapper.map_asset_conditions_for_property(lbwf_house, survey_year) # assert assert actual_assets == expected_assets \ No newline at end of file From 7bd70ae001c63acaf58e34bd069af0d31326c129 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 12:36:35 +0000 Subject: [PATCH 18/74] =?UTF-8?q?include=20install=5Fdate=20on=20AssetCond?= =?UTF-8?q?ition=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/domain/asset_condition.py | 5 +++-- .../tests/mapping/test_lbwf_mapper.py | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/backend/condition/domain/asset_condition.py b/backend/condition/domain/asset_condition.py index 2b7946c2..dffbdf88 100644 --- a/backend/condition/domain/asset_condition.py +++ b/backend/condition/domain/asset_condition.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from typing import Optional +from datetime import date from backend.condition.domain.element import Element @@ -7,8 +8,8 @@ from backend.condition.domain.element import Element class AssetCondition: uprn: int element: Element # TODO: should HHSRS elements be handled differently? - condition_description: str # TODO: this probably needs to be some sort of enum so it's searchable/filterable on the frontend + condition_description: str # TODO: this probably needs to be some sort of enum so it's searchable/filterable on the frontend. Could be hard to map from string though quantity: int renewal_year: Optional[int] = None source: Optional[str] = None - # TODO: add install_date + install_date: Optional[date] = None diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index 926e34c1..151e5d19 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -179,7 +179,8 @@ def test_lbwf_mapper_maps_house(): condition_description="General Needs", quantity=1, renewal_year=None, - source=None + source=None, + install_date=None, ), AssetCondition( uprn=1, @@ -187,7 +188,8 @@ def test_lbwf_mapper_maps_house(): condition_description="Ground Floor", quantity=1, renewal_year=None, - source=None + source=None, + install_date=None, ), AssetCondition( uprn=1, @@ -195,7 +197,8 @@ def test_lbwf_mapper_maps_house(): condition_description="Yes", quantity=None, renewal_year=None, - source="Source of Data = ACT" + source="Source of Data = ACT", + install_date=None, ), AssetCondition( uprn=1, @@ -203,7 +206,8 @@ def test_lbwf_mapper_maps_house(): condition_description="Bathroom on Entrance Level in Property", quantity=1, renewal_year=None, - source="Source of Data = Codeman" + source="Source of Data = Codeman", + install_date=None, ), AssetCondition( uprn=1, @@ -211,7 +215,8 @@ def test_lbwf_mapper_maps_house(): condition_description="No Central Heating in Property", quantity=1, renewal_year=None, - source="Source of Data = Codeman" + source="Source of Data = Codeman", + install_date=None, ), AssetCondition( uprn=1, @@ -219,7 +224,8 @@ def test_lbwf_mapper_maps_house(): condition_description="Category 4 - Typical Risk", quantity=1, renewal_year=None, - source="Source of Data = Morgan Sindall" + source="Source of Data = Morgan Sindall", + install_date=None, ), AssetCondition( uprn=1, @@ -227,7 +233,8 @@ def test_lbwf_mapper_maps_house(): condition_description="Render or Pebbledash Wall Finish 1 in External Area", quantity=1, renewal_year=2052, - source="Source of Data = Codeman" + source="Source of Data = Codeman", + install_date=date(2009,4,1), ), ] From a8ff74c2ea1c71e0a822152ff3bee6129806d435 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 12:37:23 +0000 Subject: [PATCH 19/74] =?UTF-8?q?include=20install=5Fdate=20on=20AssetCond?= =?UTF-8?q?ition=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/domain/mapping/lbwf_mapper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/condition/domain/mapping/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf_mapper.py index bc44e4c3..0af21b7a 100644 --- a/backend/condition/domain/mapping/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf_mapper.py @@ -34,6 +34,7 @@ class LbwfMapper(Mapper): quantity=raw_asset.quantity, renewal_year=LbwfMapper._calculate_renewal_year(raw_asset, survey_year), source=raw_asset.element_comments, + install_date=raw_asset.install_date, ) ) From ade999807d232795075fc1cea421492498ad09d4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 14:12:25 +0000 Subject: [PATCH 20/74] add black formatting on-save, to edited file only, to devcontainer --- .devcontainer/devcontainer.json | 16 +++++++++++----- .devcontainer/requirements.txt | 4 +++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 761786cd..5e23ae0d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,9 +11,6 @@ ], "customizations": { "vscode": { - "settings": { - "files.defaultWorkspace": "/workspaces/model" - }, "extensions": [ "ms-python.python", "ms-toolsai.jupyter", @@ -24,8 +21,17 @@ "fabiospampinato.vscode-todo-plus", "jgclark.vscode-todo-highlight", "corentinartaud.pdfpreview", - "ms-python.vscode-python-envs" - ] + "ms-python.vscode-python-envs", + "ms-python.black-formatter" + ], + "settings": { + "files.defaultWorkspace": "/workspaces/model", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true + }, + "python.formatting.provider": "none" + } } }, "containerEnv": { diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt index 300b86b0..5e7753a6 100644 --- a/.devcontainer/requirements.txt +++ b/.devcontainer/requirements.txt @@ -17,4 +17,6 @@ sqlmodel # Testing pytest==9.0.2 pytest-cov==7.0.0 -ipykernel>=6.25,<7 \ No newline at end of file +ipykernel>=6.25,<7 +# Formatting +black==26.1.0 \ No newline at end of file From d43d9d9069d2610943df7cb99a83603cb4699f64 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 15:13:17 +0000 Subject: [PATCH 21/74] =?UTF-8?q?Parse=20Peabody=20condition=20data=20xlsx?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/file_type.py | 4 + backend/condition/parsing/factory.py | 4 + backend/condition/parsing/peabody_parser.py | 7 + .../peabody/peabody_asset_condition.py | 22 ++++ .../records/peabody/peabody_property.py | 9 ++ .../tests/parsing/test_parsing_factory.py | 11 ++ .../tests/parsing/test_peabody_parser.py | 124 ++++++++++++++++++ 7 files changed, 181 insertions(+) create mode 100644 backend/condition/parsing/peabody_parser.py create mode 100644 backend/condition/parsing/records/peabody/peabody_asset_condition.py create mode 100644 backend/condition/parsing/records/peabody/peabody_property.py create mode 100644 backend/condition/tests/parsing/test_peabody_parser.py diff --git a/backend/condition/file_type.py b/backend/condition/file_type.py index b9a4357f..07a0669c 100644 --- a/backend/condition/file_type.py +++ b/backend/condition/file_type.py @@ -2,6 +2,7 @@ from enum import Enum class FileType(Enum): LBWF = "lbwf" + Peabody = "peabody" def detect_file_type(filepath: str) -> FileType: path = filepath.lower() @@ -9,4 +10,7 @@ def detect_file_type(filepath: str) -> FileType: if "lbwf" in path: return FileType.LBWF + if "peadbody" in path: + return FileType.Peabody + raise ValueError("Unrecognised file path") \ No newline at end of file diff --git a/backend/condition/parsing/factory.py b/backend/condition/parsing/factory.py index ea54d3e0..3a28df78 100644 --- a/backend/condition/parsing/factory.py +++ b/backend/condition/parsing/factory.py @@ -3,10 +3,14 @@ from backend.condition.domain.mapping.mapper import Mapper from backend.condition.file_type import FileType from backend.condition.parsing.parser import Parser from backend.condition.parsing.lbwf_parser import LbwfParser +from backend.condition.parsing.peabody_parser import PeabodyParser def select_parser(file_type: FileType) -> Parser: if file_type is FileType.LBWF: return LbwfParser() + + if file_type is FileType.Peabody: + return PeabodyParser() raise ValueError("Unrecognised file type, unable to instantiate Parser") diff --git a/backend/condition/parsing/peabody_parser.py b/backend/condition/parsing/peabody_parser.py new file mode 100644 index 00000000..e276e48e --- /dev/null +++ b/backend/condition/parsing/peabody_parser.py @@ -0,0 +1,7 @@ +from typing import Any, BinaryIO +from backend.condition.parsing.parser import Parser + + +class PeabodyParser(Parser): + def parse(self, file_stream: BinaryIO) -> Any: + raise NotImplementedError \ No newline at end of file diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py new file mode 100644 index 00000000..5682d13a --- /dev/null +++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +@dataclass +class PeabodyAssetCondition: + lo_reference: str + full_address: str + location_type_code: int + parent_lo_reference: str + element_code: int + element: int + sub_element_code: int + sub_element: str + material_code: int + material_or_answer: str + renewal_quantity: int + renewal: int + cloned: str + lo_type_code: int + renewal_cost: Optional[float] = None + condition_survey_date: Optional[datetime] = None \ No newline at end of file diff --git a/backend/condition/parsing/records/peabody/peabody_property.py b/backend/condition/parsing/records/peabody/peabody_property.py new file mode 100644 index 00000000..1bff1b55 --- /dev/null +++ b/backend/condition/parsing/records/peabody/peabody_property.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from typing import List + +from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition + +@dataclass +class PeabodyProperty: + uprn: int + assets: List[PeabodyAssetCondition] \ No newline at end of file diff --git a/backend/condition/tests/parsing/test_parsing_factory.py b/backend/condition/tests/parsing/test_parsing_factory.py index 481418d7..e2b478ff 100644 --- a/backend/condition/tests/parsing/test_parsing_factory.py +++ b/backend/condition/tests/parsing/test_parsing_factory.py @@ -11,5 +11,16 @@ def test_selects_lbwf_parser(): # act actual_class_name = select_parser(file_type).__class__.__name__ + # assert + assert expected_class_name == actual_class_name + +def test_selects_peabody_parser(): + # arrange + file_type = FileType.Peabody + expected_class_name = "PeabodyParser" + + # act + actual_class_name = select_parser(file_type).__class__.__name__ + # assert assert expected_class_name == actual_class_name \ No newline at end of file diff --git a/backend/condition/tests/parsing/test_peabody_parser.py b/backend/condition/tests/parsing/test_peabody_parser.py new file mode 100644 index 00000000..5196e65d --- /dev/null +++ b/backend/condition/tests/parsing/test_peabody_parser.py @@ -0,0 +1,124 @@ +from typing import Any +import pytest +from io import BytesIO +from openpyxl import Workbook +from datetime import datetime + +from backend.condition.parsing.peabody_parser import PeabodyParser +from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition +from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty + +@pytest.fixture +def peabody_assets_xlsx_bytes() -> BytesIO: + wb = Workbook() + survey_records_d_and_lower = wb.active + survey_records_d_and_lower.title = "Survey Records - D & Lower" + survey_records_d_and_lower.append([ + "Lo_Reference", + "full_address", + "location_type_code", + "Parent_Lo_Reference", + "Element_Code", + "Element", + "Sub_Element_Code", + "Sub_Element", + "Material_Code", + "material_or_answer", + "Renewal_Quantity", + "Renewal_Year", + "Renewal_Cost", + "cloned", + "lo_type_code", + "condition_survey_date", + ]) + survey_records_d_and_lower.append([ + "B000RAND", + "1-11 RANDOM HOUSE LONDON", + 3, + "RAND2EST", + 110, + "ROOFS", + 1, + "Primary Roof", + 9, + "Other", + 3, + 2054, + 330, + "N", + 3, + datetime(2025,12,4,9,17,0) + ]) + survey_records_d_and_lower.append([ + "B000FAKE", + "3-10 FAKE CLOSE LONDON", + 3, + "FAKEEST", + 100, + "GENERAL", + 15, + "External Decoration", + 2, + "Normal", + 1, + 2035, + 1500.7, + "N", + 3, + datetime(2025,7,5,0,0,0) + ]) + survey_records_d_and_lower.append([ + "B000MIS", + "99 MISC ROAD LONDON", + 3, + "300828", + 54, + "HHSRS", + 29, + "HHSRS Structural Collapse & Falling Elements", + 4, + "HHSRS Moderate", + 2, + 2027, + None, + "N", + 3, + None + ]) + survey_records_d_and_lower.append([ + "B000MIS", + "99 MISC ROAD LONDON", + 3, + "300828", + 53, + "External", + 2, + "Chimney", + 2, + "Present", + 33, + 2053, + 3531, + "N", + 3, + None + ]) + + + stream = BytesIO() + wb.save(stream) + stream.seek(0) + + return stream + +def test_peabody_parser_parses_conditions(peabody_assets_xlsx_bytes): + # arrange + parser = PeabodyParser() + + # act + result: Any = parser.parse(peabody_assets_xlsx_bytes) + + # assert + assert len(result) == 3 + + assert all(isinstance(item, PeabodyProperty) for item in result) From 2142bb7843afca47d5274bd997e0edf2912e4f09 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 21 Jan 2026 16:09:03 +0000 Subject: [PATCH 22/74] fixed solar tests & added additional description to wall map --- recommendations/WallRecommendations.py | 2 + .../tests/test_solar_pv_recommendations.py | 48 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py index 284d1d2a..38b206da 100644 --- a/recommendations/WallRecommendations.py +++ b/recommendations/WallRecommendations.py @@ -71,6 +71,7 @@ class WallRecommendations(Definitions): "Timber frame, as built, no insulation": "Timber frame, with external insulation", 'Timber frame, as built, partial insulation': 'Timber frame, with external insulation', "Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with external insulation", + "Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with external insulation", "Sandstone, as built, no insulation": "Sandstone, with external insulation", "Sandstone, as built, partial insulation": "Sandstone, with external insulation", } @@ -88,6 +89,7 @@ class WallRecommendations(Definitions): "Timber frame, as built, no insulation": "Timber frame, with internal insulation", 'Timber frame, as built, partial insulation': 'Timber frame, with internal insulation', "Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with internal insulation", + "Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with internal insulation", "Sandstone, as built, no insulation": "Sandstone, with internal insulation", "Sandstone, as built, partial insulation": "Sandstone, with internal insulation", } diff --git a/recommendations/tests/test_solar_pv_recommendations.py b/recommendations/tests/test_solar_pv_recommendations.py index b16fcc3b..f93cc644 100644 --- a/recommendations/tests/test_solar_pv_recommendations.py +++ b/recommendations/tests/test_solar_pv_recommendations.py @@ -1,9 +1,10 @@ -import pytest -from recommendations.SolarPvRecommendations import SolarPvRecommendations -from backend.Property import Property -from etl.epc.Record import EPCRecord import pandas as pd import numpy as np +import pytest +from backend.Property import Property +from etl.epc.Record import EPCRecord +from recommendations.tests.test_data.materials import materials +from recommendations.SolarPvRecommendations import SolarPvRecommendations class TestSolarPvRecommendations: @@ -16,6 +17,7 @@ class TestSolarPvRecommendations: } property_instance_invalid_type = Property(id=1, address="", postcode="", epc_record=epc_record) property_instance_invalid_type.roof = {"is_flat": False, "is_pitched": False, "is_roof_room": False} + property_instance_invalid_type.already_installed = [] return property_instance_invalid_type @pytest.fixture @@ -29,6 +31,7 @@ class TestSolarPvRecommendations: property_instance_invalid_roof.roof = { "is_flat": False, "is_pitched": False, "is_roof_room": False, "thermal_transmittance": None } + property_instance_invalid_roof.already_installed = [] return property_instance_invalid_roof @pytest.fixture @@ -39,6 +42,7 @@ class TestSolarPvRecommendations: "property-type": "House"} property_instance_has_solar_pv = Property(id=1, address="", postcode="", epc_record=epc_record) property_instance_has_solar_pv.roof = {"is_flat": True, "thermal_transmittance": None} + property_instance_has_solar_pv.already_installed = [] return property_instance_has_solar_pv @pytest.fixture @@ -50,6 +54,7 @@ class TestSolarPvRecommendations: property_instance_valid_all.roof_area = 40 property_instance_valid_all.number_of_floors = 2 property_instance_valid_all.roof = {"is_flat": True, "thermal_transmittance": None} + property_instance_valid_all.already_installed = [] property_instance_valid_all.solar_panel_configuration = { "panel_performance": pd.DataFrame( [ @@ -66,35 +71,32 @@ class TestSolarPvRecommendations: return property_instance_valid_all def test_invalid_property_type(self, property_instance_invalid_type): - solar_pv = SolarPvRecommendations(property_instance_invalid_type) + solar_pv = SolarPvRecommendations(property_instance_invalid_type, materials=materials) solar_pv.recommend(phase=0) assert not solar_pv.recommendation def test_invalid_roof_type(self, property_instance_invalid_roof): - solar_pv = SolarPvRecommendations(property_instance_invalid_roof) + solar_pv = SolarPvRecommendations(property_instance_invalid_roof, materials=materials) solar_pv.recommend(phase=0) assert not solar_pv.recommendation def test_existing_solar_pv(self, property_instance_has_solar_pv): - solar_pv = SolarPvRecommendations(property_instance_has_solar_pv) + solar_pv = SolarPvRecommendations(property_instance_has_solar_pv, materials=materials) solar_pv.recommend(phase=0) assert not solar_pv.recommendation def test_valid_all_conditions(self, property_instance_valid_all): - solar_pv = SolarPvRecommendations(property_instance_valid_all) + solar_pv = SolarPvRecommendations(property_instance_valid_all, materials=materials) solar_pv.recommend(phase=0) - assert len(solar_pv.recommendation) == 2 - assert solar_pv.recommendation == [ - {'phase': 0, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, - 'new_u_value': None, 'sap_points': np.float64(10.0), 'already_installed': False, - 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, 'labour_hours': 48, 'labour_days': 2, - 'photo_supply': np.float64(50.0), 'has_battery': False, 'initial_ac_kwh_per_year': np.int64(3800), - 'description_simulation': {'photo-supply': np.float64(50.0)}}, - {'phase': 0, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(10.0), 'already_installed': False, - 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, 'labour_hours': 48, 'labour_days': 2, - 'photo_supply': np.float64(50.0), 'has_battery': True, 'initial_ac_kwh_per_year': np.int64(3800), - 'description_simulation': {'photo-supply': np.float64(50.0)}} - ] + assert len(solar_pv.recommendation) == 11 + assert solar_pv.recommendation[0]["description"] == '10 panel system, 400W solar panels - 4.0 kWp system' + assert not solar_pv.recommendation[0]["has_battery"] + assert solar_pv.recommendation[0]["initial_ac_kwh_per_year"] == np.int64(3800) + assert solar_pv.recommendation[0]["description_simulation"] == {'photo-supply': np.float64(50.0)} + assert solar_pv.recommendation[0]["simulation_config"] == {'photo_supply_ending': np.float64(50.0)} + + assert ( + solar_pv.recommendation[1]["description"] == + '10 panel system, 400W solar panels, 5.8kw Growatt battery - 4.0 kWp system' + ) + assert solar_pv.recommendation[1]["has_battery"] From 4e190328cc93481a025a35291c73ac3ea43259dd Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 16:25:58 +0000 Subject: [PATCH 23/74] =?UTF-8?q?Parse=20Peabody=20condition=20data=20xlsx?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/parsing/lbwf_parser.py | 8 +- backend/condition/parsing/peabody_parser.py | 142 +++++++++++++++++- .../peabody/peabody_asset_condition.py | 2 +- .../records/peabody/peabody_property.py | 2 + 4 files changed, 147 insertions(+), 7 deletions(-) diff --git a/backend/condition/parsing/lbwf_parser.py b/backend/condition/parsing/lbwf_parser.py index 8d52f6d5..63512c41 100644 --- a/backend/condition/parsing/lbwf_parser.py +++ b/backend/condition/parsing/lbwf_parser.py @@ -8,13 +8,13 @@ from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse from backend.condition.utils.date_utils import normalise_date from utils.logger import setup_logger -logger = setup_logger +logger = setup_logger() class LbwfParser(Parser): def parse(self, file_stream: BinaryIO) -> Any: wb: Workbook = load_workbook(file_stream) - address_to_uprn_map: Dict[str, int] = self._generate_address_to_uprn_dict(wb) + address_to_uprn_map: Dict[str, int] = LbwfParser._generate_address_to_uprn_dict(wb) assets = self._parse_assets(wb) houses = self._parse_houses(wb, address_to_uprn_map) @@ -132,7 +132,7 @@ class LbwfParser(Parser): @staticmethod def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]: - sheet: Workbook = wb["All Energy Breakdown "] + sheet = wb["All Energy Breakdown "] rows: Iterator[Tuple[object | None, ...]] = sheet.iter_rows(values_only=True) @@ -159,6 +159,7 @@ class LbwfParser(Parser): return mapping + @staticmethod def _get_column_indexes_by_name( headers: Tuple[object | None, ...] ) -> Dict[str, int]: @@ -170,6 +171,7 @@ class LbwfParser(Parser): return index + @staticmethod def _get_uprn_from_address(address: str, address_to_uprn_map: Dict[str, int]) -> int | None: pseudo_name = address.split(",")[0] diff --git a/backend/condition/parsing/peabody_parser.py b/backend/condition/parsing/peabody_parser.py index e276e48e..d2229e1c 100644 --- a/backend/condition/parsing/peabody_parser.py +++ b/backend/condition/parsing/peabody_parser.py @@ -1,7 +1,143 @@ -from typing import Any, BinaryIO -from backend.condition.parsing.parser import Parser +from typing import Any, BinaryIO, Dict, Iterator, List, Tuple, DefaultDict +from openpyxl import Workbook, load_workbook +from collections import defaultdict +from backend.condition.parsing.parser import Parser +from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition +from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty +from utils.logger import setup_logger + +logger = setup_logger() class PeabodyParser(Parser): def parse(self, file_stream: BinaryIO) -> Any: - raise NotImplementedError \ No newline at end of file + wb: Workbook = load_workbook(file_stream) + address_to_uprn_map: Dict[str, int] = PeabodyParser._generate_address_to_uprn_dict(wb) + + assets = self._parse_assets(wb) + + return self._group_assets_into_properties( + assets=assets, + address_to_uprn_map=address_to_uprn_map, + ) + + + @staticmethod + def _parse_assets(wb: Workbook) -> List[PeabodyAssetCondition]: + assets_sheet = wb["Survey Records - D & Lower"] + asset_rows = assets_sheet.iter_rows(values_only=True) + + asset_headers = next(asset_rows) + asset_header_indexes = PeabodyParser._get_column_indexes_by_name(asset_headers) + + assets: List[PeabodyAssetCondition] = [] + for row in asset_rows: + try: + assets.append( + PeabodyParser._map_row_to_asset_record(row, asset_header_indexes) + ) + except Exception as e: + logger.error(f"Error mapping Peabody row to asset record: {e}") + continue + + return assets + + @staticmethod + def _group_assets_into_properties( + assets: List[PeabodyAssetCondition], + address_to_uprn_map: Dict[str, int], + ) -> List[PeabodyProperty]: + assets_by_address: DefaultDict[str, List[PeabodyAssetCondition]] = defaultdict(list) + + for asset in assets: + if asset.full_address is None: + continue + + address = asset.full_address + assets_by_address[address].append(asset) + + properties: List[PeabodyProperty] = [] + + for address, grouped_assets in assets_by_address.items(): + uprn = address_to_uprn_map.get(address) + + if uprn is None: + logger.warning(f"No UPRN found for address: {address}") + continue + + properties.append( + PeabodyProperty( + uprn=uprn, + assets=grouped_assets, + ) + ) + + return properties + + + @staticmethod + def _map_row_to_asset_record( + row: Any | Tuple[object | None, ...], + header_indexes: Dict[str, int], + ) -> PeabodyAssetCondition: + return PeabodyAssetCondition( + lo_reference=row[header_indexes["Lo_Reference"]], + full_address=row[header_indexes["full_address"]], + location_type_code=row[header_indexes["location_type_code"]], + parent_lo_reference=row[header_indexes["Parent_Lo_Reference"]], + element_code=row[header_indexes["Element_Code"]], + element=row[header_indexes["Element"]], + sub_element_code=row[header_indexes["Sub_Element_Code"]], + sub_element=row[header_indexes["Sub_Element"]], + material_code=row[header_indexes["Material_Code"]], + material_or_answer=row[header_indexes["material_or_answer"]], + renewal_quantity=row[header_indexes["Renewal_Quantity"]], + renewal_year=row[header_indexes["Renewal_Year"]], + renewal_cost=row[header_indexes["Renewal_Cost"]], + cloned=row[header_indexes["cloned"]], + lo_type_code=row[header_indexes["lo_type_code"]], + condition_survey_date=row[header_indexes["condition_survey_date"]], + ) + + @staticmethod + def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]: + sheet = wb["Survey Records - D & Lower"] + rows: Iterator[Tuple[object | None, ...]] = sheet.iter_rows(values_only=True) + + headers = next(rows) + header_indexes: Dict[str, int] = PeabodyParser._get_column_indexes_by_name(headers) + + address_idx = header_indexes["full_address"] + + + address_to_uprn: Dict[str, int] = {} + # Generate random UPRNs for now + next_uprn = 1 # TODO: get real UPRNs + + for row in rows: + address = row[address_idx] + + if address is None: + continue + + # Optional normalization + address = address.strip() + + if address not in address_to_uprn: + address_to_uprn[address] = next_uprn + next_uprn += 1 + + return address_to_uprn + + + @staticmethod + def _get_column_indexes_by_name( + headers: Tuple[object | None, ...] + ) -> Dict[str, int]: + index: Dict[str, int] = {} + + for i, header in enumerate(headers): + if isinstance(header, str): + index[header] = i + + return index \ No newline at end of file diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py index 5682d13a..a82e87f1 100644 --- a/backend/condition/parsing/records/peabody/peabody_asset_condition.py +++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py @@ -15,7 +15,7 @@ class PeabodyAssetCondition: material_code: int material_or_answer: str renewal_quantity: int - renewal: int + renewal_year: int cloned: str lo_type_code: int renewal_cost: Optional[float] = None diff --git a/backend/condition/parsing/records/peabody/peabody_property.py b/backend/condition/parsing/records/peabody/peabody_property.py index 1bff1b55..bfa6b65b 100644 --- a/backend/condition/parsing/records/peabody/peabody_property.py +++ b/backend/condition/parsing/records/peabody/peabody_property.py @@ -5,5 +5,7 @@ from backend.condition.parsing.records.peabody.peabody_asset_condition import Pe @dataclass class PeabodyProperty: + # This could just be a uprn:assets dict, but making it a dataclass for consistency with + # other client models, might change in future uprn: int assets: List[PeabodyAssetCondition] \ No newline at end of file From fe5e7814913366de4cd309b0633585aa483d8e41 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 21 Jan 2026 16:31:07 +0000 Subject: [PATCH 24/74] fixed roof recommendation tests --- .../tests/test_roof_recommendations.py | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py index 214ea6c0..2241aeb7 100644 --- a/recommendations/tests/test_roof_recommendations.py +++ b/recommendations/tests/test_roof_recommendations.py @@ -23,6 +23,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance.already_installed = [] roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials) @@ -30,7 +31,7 @@ class TestRoofRecommendations: roof_recommender.recommend(phase=0) - assert len(roof_recommender.recommendations) == 1 + assert len(roof_recommender.recommendations) == 3 assert roof_recommender.recommendations[0]["parts"][0]["depth"] == 300 def test_loft_insulation_recommendation_50mm_insulation(self): @@ -48,6 +49,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance2.already_installed = [] roof_recommender2 = RoofRecommendations(property_instance=property_instance2, materials=materials) @@ -55,11 +57,11 @@ class TestRoofRecommendations: roof_recommender2.recommend(phase=0) - assert len(roof_recommender2.recommendations) == 1 + assert len(roof_recommender2.recommendations) == 3 - assert roof_recommender2.recommendations[0]["total"] == 1653 + assert roof_recommender2.recommendations[0]["total"] == 2100 assert roof_recommender2.recommendations[0]["new_u_value"] == 0.13 - assert roof_recommender2.recommendations[0]["starting_u_value"] == 0.68 + assert float(roof_recommender2.recommendations[0]["starting_u_value"]) == 0.68 assert roof_recommender2.recommendations[0]["parts"][0]["depth"] == 300 epc_record = EPCRecord() @@ -76,6 +78,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance3.already_installed = [] roof_recommender3 = RoofRecommendations(property_instance=property_instance3, materials=materials) @@ -84,7 +87,7 @@ class TestRoofRecommendations: roof_recommender3.recommend(phase=0) assert roof_recommender3.recommendations - assert len(roof_recommender3.recommendations) == 1 + assert len(roof_recommender3.recommendations) == 3 assert roof_recommender3.recommendations[0]["parts"][0]["depth"] == 300.0 def test_loft_insulation_recommendation_150mm_insulation(self): @@ -102,6 +105,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance4.already_installed = [] roof_recommender4 = RoofRecommendations(property_instance=property_instance4, materials=materials) @@ -109,11 +113,11 @@ class TestRoofRecommendations: roof_recommender4.recommend(phase=0, default_u_values=True) - assert len(roof_recommender4.recommendations) == 1 + assert len(roof_recommender4.recommendations) == 3 - assert roof_recommender4.recommendations[0]["total"] == 1653.0 - assert roof_recommender4.recommendations[0]["new_u_value"] == 0.14 - assert roof_recommender4.recommendations[0]["starting_u_value"] == 0.3 + assert roof_recommender4.recommendations[0]["total"] == 2100.0 + assert float(roof_recommender4.recommendations[0]["new_u_value"]) == 0.14 + assert float(roof_recommender4.recommendations[0]["starting_u_value"]) == 0.3 assert roof_recommender4.recommendations[0]["parts"][0]["depth"] == 300 epc_record = EPCRecord() @@ -130,6 +134,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance5.already_installed = [] roof_recommender5 = RoofRecommendations(property_instance=property_instance5, materials=materials) @@ -138,7 +143,7 @@ class TestRoofRecommendations: roof_recommender5.recommend(phase=0) assert roof_recommender5.recommendations - assert len(roof_recommender5.recommendations) == 1 + assert len(roof_recommender5.recommendations) == 3 assert roof_recommender5.recommendations[0]["parts"][0]["depth"] == 300 def test_loft_insulation_recommendation_270mm_insulation(self): @@ -157,6 +162,7 @@ class TestRoofRecommendations: 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '270', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' } + property_instance6.already_installed = [] roof_recommender6 = RoofRecommendations(property_instance=property_instance6, materials=materials) @@ -179,6 +185,7 @@ class TestRoofRecommendations: 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none' } + property_instance7.already_installed = [] property_instance7.pitched_roof_area = 110 @@ -189,7 +196,7 @@ class TestRoofRecommendations: roof_recommender7.recommend(phase=0) assert len(roof_recommender7.recommendations) == 1 - assert roof_recommender7.recommendations[0]["new_u_value"] == 0.2 + assert roof_recommender7.recommendations[0]["new_u_value"] == 0.18 assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8 assert roof_recommender7.recommendations[0]["description"] == "Insulate room in roof at rafters and re-decorate" @@ -208,6 +215,7 @@ class TestRoofRecommendations: 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' } + property_instance8.already_installed = [] property_instance8.pitched_roof_area = 110 @@ -233,6 +241,7 @@ class TestRoofRecommendations: 'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' } + property_instance9.already_installed = [] property_instance9.pitched_roof_area = 110 property_instance9.data = {"county": "Rutland"} @@ -260,6 +269,7 @@ class TestRoofRecommendations: 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average' } + property_instance10.already_installed = [] property_instance10.pitched_roof_area = 110 @@ -271,7 +281,7 @@ class TestRoofRecommendations: assert len(roof_recommender10.recommendations) == 1 - assert roof_recommender10.recommendations[0]["new_u_value"] == 0.19 + assert roof_recommender10.recommendations[0]["new_u_value"] == 0.17 assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.68 @@ -292,6 +302,7 @@ class TestRoofRecommendations: 'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none' } + property_instance11.already_installed = [] roof_recommender11 = RoofRecommendations(property_instance=property_instance11, materials=materials) @@ -324,6 +335,7 @@ class TestRoofRecommendations: 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average' } + property_instance12.already_installed = [] roof_recommender12 = RoofRecommendations(property_instance=property_instance12, materials=materials) @@ -348,6 +360,7 @@ class TestRoofRecommendations: 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average' } + property_instance13.already_installed = [] roof_recommender13 = RoofRecommendations(property_instance=property_instance13, materials=materials) @@ -380,6 +393,7 @@ class TestRoofRecommendations: 'is_assumed': False, 'has_dwelling_above': True, 'is_valid': True, 'insulation_thickness': None } + property_instance14.already_installed = [] roof_recommender14 = RoofRecommendations(property_instance=property_instance14, materials=materials) From ad03d11bc9443b02adb9fc363e5302a257d282e3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 17:26:15 +0000 Subject: [PATCH 25/74] fix typo in file type detector --- backend/condition/file_type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/condition/file_type.py b/backend/condition/file_type.py index 07a0669c..e0736814 100644 --- a/backend/condition/file_type.py +++ b/backend/condition/file_type.py @@ -10,7 +10,7 @@ def detect_file_type(filepath: str) -> FileType: if "lbwf" in path: return FileType.LBWF - if "peadbody" in path: + if "peabody" in path: return FileType.Peabody raise ValueError("Unrecognised file path") \ No newline at end of file From a07020d085d2517737624dd24d42bba37316e1f3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 21 Jan 2026 17:33:14 +0000 Subject: [PATCH 26/74] =?UTF-8?q?Detect=20block-level=20asset=20conditions?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peabody/peabody_asset_condition.py | 6 +++- .../tests/parsing/test_peabody_parser.py | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py index a82e87f1..5451d570 100644 --- a/backend/condition/parsing/records/peabody/peabody_asset_condition.py +++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py @@ -19,4 +19,8 @@ class PeabodyAssetCondition: cloned: str lo_type_code: int renewal_cost: Optional[float] = None - condition_survey_date: Optional[datetime] = None \ No newline at end of file + condition_survey_date: Optional[datetime] = None + + @property + def is_block_level(self) -> bool: + raise NotImplementedError \ No newline at end of file diff --git a/backend/condition/tests/parsing/test_peabody_parser.py b/backend/condition/tests/parsing/test_peabody_parser.py index 5196e65d..830e8f2c 100644 --- a/backend/condition/tests/parsing/test_peabody_parser.py +++ b/backend/condition/tests/parsing/test_peabody_parser.py @@ -122,3 +122,34 @@ def test_peabody_parser_parses_conditions(peabody_assets_xlsx_bytes): assert len(result) == 3 assert all(isinstance(item, PeabodyProperty) for item in result) + +def test_peabody_asset_is_block_level(): + # arrange + asset_condition = PeabodyAssetCondition( + lo_reference="", + full_address="1-80 PRINCESS ALICE HOUSE LONDON", + location_type_code=0, + parent_lo_reference="", + element_code=0, + element="", + sub_element_code=0, + sub_element="", + material_code=0, + material_or_answer="", + renewal_quantity=0, + renewal_year=2026, + cloned="", + lo_type_code=0, + renewal_cost=None, + condition_survey_date=None + ) + + expected_block_level = True + + # act + actual_block_level = asset_condition.is_block_level + + # assert + assert expected_block_level == actual_block_level + + \ No newline at end of file From 201e8dd82901a8d8a3f4fc93c763f34272c745e8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 08:49:57 +0000 Subject: [PATCH 27/74] fixed heating recommendation tests --- .../optimiser/optimiser_functions.py | 11 +- recommendations/recommendation_utils.py | 15 +- .../test_data/heating_recommendations_data.py | 31 +- .../tests/test_data/measures_to_optimise.py | 24 +- .../tests/test_heating_recommendations.py | 9 +- .../tests/test_lighting_recommendations.py | 19 +- .../tests/test_optimiser_functions.py | 11 +- recommendations/tests/test_optimisers.py | 661 +----------------- .../tests/test_recommendation_utils.py | 43 +- 9 files changed, 94 insertions(+), 730 deletions(-) diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index a4543dbf..d704b3fb 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -236,10 +236,13 @@ def calculate_gain( if body.goal == "Increasing EPC": current_sap = int(p.data["current-energy-efficiency"]) + already_installed_gain - target_sap = ( - eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None - else epc_to_sap_lower_bound(body.goal_value) - ) + if eco_packages is None: + target_sap = epc_to_sap_lower_bound(body.goal_value) + else: + target_sap = ( + eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None + else epc_to_sap_lower_bound(body.goal_value) + ) if target_sap <= current_sap: # We've already met or exceeded the target EPC diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index 0794013e..b1744c69 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -488,10 +488,11 @@ def estimate_perimeter(floor_area, num_rooms): return perimeter -def get_exposed_floor_uvalue(insulation_thickness_str, age_band): +def get_exposed_floor_uvalue(insulation_thickness_str: None | str, age_band: str) -> float: """ We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document - :param insulation_thickness_str: + :param insulation_thickness_str: Insulation thickness as defined in the EPC data + :param age_band: Age band of the property :return: """ @@ -513,9 +514,15 @@ def get_exposed_floor_uvalue(insulation_thickness_str, age_band): else: insulation_thickness = int(insulation_thickness_str.replace("mm", "")) - return s12[s12["age_band"] == age_band][ + filtered = s12[s12["age_band"] == age_band][ f"insulation_{insulation_thickness}" - ].values[0] + ] + + if filtered.empty: + # We don't have data so we use the median value + return float(s12[f"insulation_{insulation_thickness}"].median()) + + return float(filtered.values[0]) def get_floor_u_value( diff --git a/recommendations/tests/test_data/heating_recommendations_data.py b/recommendations/tests/test_data/heating_recommendations_data.py index 37c854c3..1bd58c9e 100644 --- a/recommendations/tests/test_data/heating_recommendations_data.py +++ b/recommendations/tests/test_data/heating_recommendations_data.py @@ -223,15 +223,16 @@ testing_examples = [ 'local-authority-label': 'Lewisham', 'constituency-label': 'Lewisham, Deptford', 'posttown': 'LONDON', 'construction-age-band': 'England and Wales: before 1900', 'lodgement-datetime': '2014-06-26 11:40:50', 'tenure': 'owner-occupied', 'fixed-lighting-outlets-count': 9.0, 'low-energy-fixed-light-count': 5.0, - 'uprn': 100021936225.0, 'uprn-source': 'Address Matched', + 'uprn': 100021936225, 'uprn-source': 'Address Matched', }, "heating_measure_types": [ + "air_source_heat_pump", 'roomstat_programmer_trvs', 'time_temperature_zone_control' ], - "notes": "Because this property already has a boiler, we don't recommend HHR. We don't recommend an ashp " - "because the home is mid-terraced. Because the heating controls are " - "Programmer, no room thermostat, we have a programmer, room thermostat and trvs recommendation" + "notes": "Because this property already has a boiler, we don't recommend HHR. " + "Because the heating controls are Programmer, no room thermostat, " + "we have a programmer, room thermostat and trvs recommendation" "for heating controls and for TTZC." }, { @@ -369,12 +370,13 @@ testing_examples = [ 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'boiler_upgrade' ], "notes": "This property has assumed electric heating and is mid-terrace house. It has a mains gas connection." - "We can recommend a boiler upgrade and high heat retention storage heaters" + "We can recommend a boiler upgrade, high heat retention storage heaters, and an ASHP" }, { "epc": { @@ -510,12 +512,12 @@ testing_examples = [ }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'boiler_upgrade', - 'high_heat_retention_storage_heaters', ], - "notes": "This property has assumed electric heaters. Boiler upgrade, HHR are recommended. We don't recommend" - "an ASHP off of the bat because it's mid-terrace." + "notes": "This property has assumed electric heaters. Boiler upgrade, ASHP are recommended. We don't recommend" + "HHRSH since there is potential community heating" }, { "epc": { @@ -556,6 +558,7 @@ testing_examples = [ 'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'boiler_upgrade' @@ -603,12 +606,12 @@ testing_examples = [ 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'boiler_upgrade', 'high_heat_retention_storage_heaters', ], - "notes": "This property already has storage heaters with manual charge control. The home is mid terrace so" - "the ashp is not suitable" + "notes": "This property already has storage heaters with manual charge control" }, { "epc": { @@ -1149,6 +1152,7 @@ testing_examples = [ 'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ + "air_source_heat_pump", 'boiler_upgrade', 'boiler_upgrade', 'high_heat_retention_storage_heaters' @@ -1193,10 +1197,9 @@ testing_examples = [ 'uprn': 100070685908, 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None }, - "heating_measure_types": [], - "notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating" - "don't recommend anything. HHRSH isn't recommended as with underfloor heating, it's quite" - "disruptive" + "heating_measure_types": ["high_heat_retention_storage_heaters"], + "notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating. " + "In this case we just recommend hhrsh as an additional heating system, which would become the primary" }, { "epc": { diff --git a/recommendations/tests/test_data/measures_to_optimise.py b/recommendations/tests/test_data/measures_to_optimise.py index cefd36e4..d84111cd 100644 --- a/recommendations/tests/test_data/measures_to_optimise.py +++ b/recommendations/tests/test_data/measures_to_optimise.py @@ -214,7 +214,7 @@ measures_to_optimise = [ 'heat_demand': np.float64(15.400000000000006), 'kwh_savings': np.float64(202.30000000000018), 'energy_cost_savings': np.float64(15.065400000000011)}], [ - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), 'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, @@ -226,7 +226,7 @@ measures_to_optimise = [ 'heat_demand': np.float64(88.69999999999999), 'kwh_savings': np.float64(2040.8566307499998), 'energy_cost_savings': np.float64(525.1124110919749)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), 'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, @@ -238,7 +238,7 @@ measures_to_optimise = [ 'heat_demand': np.float64(88.69999999999999), 'kwh_savings': np.float64(2857.1992830499994), 'energy_cost_savings': np.float64(735.1573755287648)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), 'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0, @@ -249,7 +249,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.42834948104), 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397), 'energy_cost_savings': np.float64(475.0617304809999)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), 'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0, @@ -260,7 +260,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.599689273456), 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558), 'energy_cost_savings': np.float64(665.0864226734)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), 'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0, @@ -271,7 +271,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(1650.2708274), 'energy_cost_savings': np.float64(424.61468389001993)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), 'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0, @@ -282,7 +282,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.53600796473952), 'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(2310.3791583599996), 'energy_cost_savings': np.float64(594.4605574460278)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), 'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0, @@ -293,7 +293,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(1453.5933906), 'energy_cost_savings': np.float64(374.00957940138)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), 'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0, @@ -304,7 +304,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.47212713326688), 'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(2035.03074684), 'energy_cost_savings': np.float64(523.6134111619319)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), 'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0, @@ -315,7 +315,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1255.12594), 'energy_cost_savings': np.float64(322.94390436199996)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), 'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0, @@ -326,7 +326,7 @@ measures_to_optimise = [ 'co2_equivalent_savings': np.float64(0.40766490531199995), 'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1757.1763159999998), 'energy_cost_savings': np.float64(452.1214661067999)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), 'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0, @@ -336,7 +336,7 @@ measures_to_optimise = [ 'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856), 'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5), 'kwh_savings': np.float64(1048.341318), 'energy_cost_savings': np.float64(269.7382211214)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', + {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv', 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.', 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), 'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0, diff --git a/recommendations/tests/test_heating_recommendations.py b/recommendations/tests/test_heating_recommendations.py index 93acdefa..b62483ec 100644 --- a/recommendations/tests/test_heating_recommendations.py +++ b/recommendations/tests/test_heating_recommendations.py @@ -7,6 +7,7 @@ from etl.epc.Record import EPCRecord from etl.bill_savings.KwhData import KwhData from recommendations.HeatingRecommender import HeatingRecommender from recommendations.tests.test_data.heating_recommendations_data import testing_examples +from recommendations.tests.test_data.materials import materials class TestHeatingRecommendations: @@ -56,6 +57,7 @@ class TestHeatingRecommendations: x["has_hot-water-only"] = False x["has_mineral_and_wood"] = False x["has_dual_fuel_appliance"] = False + x["has_wood_chips"] = False epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []} @@ -75,6 +77,7 @@ class TestHeatingRecommendations: "energy_assessment_is_newer": False } ) + p.already_installed = [] # For these tests, this can be fixed kwh_predictions = { @@ -92,7 +95,7 @@ class TestHeatingRecommendations: p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=kwh_predictions) - recommender = HeatingRecommender(property_instance=p) + recommender = HeatingRecommender(property_instance=p, materials=materials) # Check they're empty assert not recommender.heating_recommendations @@ -194,9 +197,9 @@ def test_pick_model_boundaries(): """ assert HeatingRecommender.pick_model((2.0, 4.9), models_kw=(3, 5, 6, 8.5)) == 5 assert HeatingRecommender.pick_model((5.0, 5.0), models_kw=(3, 5, 6, 8.5)) == 5 - assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 6 + assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 8.5 assert HeatingRecommender.pick_model((8.6, 9.0), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 - assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) is None + assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 # largest model def test_parameter_validation_and_defaults(): diff --git a/recommendations/tests/test_lighting_recommendations.py b/recommendations/tests/test_lighting_recommendations.py index 5fdca9f7..aeaffdb4 100644 --- a/recommendations/tests/test_lighting_recommendations.py +++ b/recommendations/tests/test_lighting_recommendations.py @@ -13,6 +13,7 @@ class TestLightingRecommendations: epc_record.prepared_epc = {"county": "Greater London Authority"} input_property0 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) input_property0.lighting = {"low_energy_proportion": 0} + input_property0.already_installed = [] # Test for invalid materials with pytest.raises(ValueError): LightingRecommendations(input_property0, []) @@ -23,6 +24,7 @@ class TestLightingRecommendations: epc_record.prepared_epc = {"county": "Greater London Authority"} input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) input_property1.lighting = {"low_energy_proportion": 100} + input_property1.already_installed = [] lr = LightingRecommendations(input_property1, materials) lr.recommend() @@ -35,19 +37,16 @@ class TestLightingRecommendations: input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) input_property1.lighting = {"low_energy_proportion": 0.80} input_property1.number_lighting_outlets = 20 + input_property1.already_installed = [] lr = LightingRecommendations(input_property1, materials) lr.recommend() assert len(lr.recommendation) == 1 # Note - this test may be dependent on the ofgem price caps - assert lr.recommendation == [ - {'phase': 0, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', - 'description': 'Install low energy lighting in 4 outlets', 'starting_u_value': None, 'new_u_value': None, - 'already_installed': False, 'sap_points': 0.4, 'kwh_savings': 219.0, - 'energy_cost_savings': 56.348699999999994, 'co2_equivalent_savings': 0.035478, - 'description_simulation': {'lighting-energy-eff': 'Very Good', - 'lighting-description': 'Low energy lighting in all fixed outlets', - 'low-energy-lighting': 100}, 'total': 188.76000000000002, 'subtotal': 157.3, - 'vat': 31.460000000000004, 'contingency': 14.3, 'material': 80.0, 'labour_hours': 3.2, 'labour_days': 0.4, - 'labour_cost': 63.0, 'survey': False}] + assert lr.recommendation[0]["description_simulation"] == {'lighting-energy-eff': 'Very Good', + 'lighting-description': 'Low energy lighting in all ' + 'fixed outlets', + 'low-energy-lighting': 100} + assert lr.recommendation[0]["description"] == 'Install low energy lighting in 4 outlets' + assert lr.recommendation[0]["total"] == 14 diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py index ea0b5d94..c2927790 100644 --- a/recommendations/tests/test_optimiser_functions.py +++ b/recommendations/tests/test_optimiser_functions.py @@ -108,7 +108,7 @@ class TestCalculateGain: body = SimpleNamespace(goal="Increasing EPC", goal_value="C", simulate_sap_10=False) prop = SimpleNamespace(data={"current-energy-efficiency": "50"}) gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=2) - assert gain == 18.5 + assert gain == 17.5 class TestAddRequiredMeasures: @@ -235,7 +235,7 @@ class TestIncreasingEpcE2e: gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) - assert gain == 18.5, "Expected gain to be calculated correctly based on fixed gain and SAP target" + assert gain == 17.5, "Expected gain to be calculated correctly based on fixed gain and SAP target" optimiser = ( GainOptimiser( @@ -254,7 +254,8 @@ class TestIncreasingEpcE2e: # Collect selected measure IDs selected = {r["id"] for r in solution} - assert selected == {'8_phase=7', '5_phase=4', '7_phase=6'} + assert selected == {'7_phase=6', '5_phase=4', '10_phase=7'} + assert float(optimiser.solution_gain) == 17.6 # Add required measures (none here) solution = optimiser_functions.add_required_measures( @@ -265,11 +266,11 @@ class TestIncreasingEpcE2e: assert solution == [ {'id': '5_phase=4', 'cost': 58.8, 'gain': 2, 'type': 'low_energy_lighting'}, {'id': '7_phase=6', 'cost': 30.0, 'gain': np.float64(3.6), 'type': 'secondary_heating'}, - {'id': '8_phase=7', 'cost': 6013.139999999999, 'gain': np.float64(13.0), 'type': 'solar_pv'} + {'id': '10_phase=7', 'cost': 5826.491999999999, 'gain': np.float64(12.0), 'type': 'solar_pv'} ] total_optimised_gain = sum(m["gain"] for m in solution) - assert total_optimised_gain == 18.6, "Total gain of optimised measures should meet or exceed target gain" + assert total_optimised_gain == 17.6, "Total gain of optimised measures should meet or exceed target gain" selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py index ecc6ea56..17e45154 100644 --- a/recommendations/tests/test_optimisers.py +++ b/recommendations/tests/test_optimisers.py @@ -1,52 +1,6 @@ -from pandas import Timestamp -from numpy import nan -import datetime - -import numpy as np -import pandas as pd import pytest -from copy import deepcopy -from recommendations.optimiser import optimiser_functions -from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths, build_heat_pump_paths -from backend.Funding import Funding -from backend.app.plan.schemas import WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, ECO4_ELIGIBILE_FABRIC_MEASURES - -ALLOWED_FABRIC_TYPES = set(WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES) - - -@pytest.fixture -def mock_project_scores_matrix(): - data = [] - floor_segments = ["0-72", "73-97", "98-199", "200"] - bands = [ - "Low_G", "High_G", "Low_F", "High_F", "Low_E", "High_E", "Low_D", "High_D", "Low_C", "High_C", "Low_B", - "High_B", "Low_A", "High_A" - ] - - cost = 50.0 - for floor in floor_segments: - for start in bands: - for finish in bands: - if start != finish: # skip identical start/finish (no SAP movement) - data.append({ - "Floor Area Segment": floor, - "Starting Band": start, - "Finishing Band": finish, - "Cost Savings": cost - }) - cost += 5.0 # increment to create variety - - return pd.DataFrame(data) - - -@pytest.fixture -def mock_partial_scores_matrix(): - df = pd.read_csv("backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv") - df.columns = ['Measure category', 'Measure_Type', 'Pre_Main_Heating_Source', - 'Post_Main_Heating_Source', 'Total Floor Area Band', 'Starting Band', - 'Average Treatable Factor', 'Cost Savings', 'SAP Savings'] - return df +from recommendations.optimiser.funding_optimiser import build_heat_pump_paths class DummyProp: @@ -105,619 +59,6 @@ def p(): return DummyProp() -@pytest.fixture -def funding(monkeypatch, mock_partial_scores_matrix, mock_project_scores_matrix): - """Simple Funding that returns zero uplift so costs stay as provided.""" - # Build the Funding with tiny in-memory frames (avoid test I/O) - - f = Funding( - project_scores_matrix=mock_project_scores_matrix, - partial_project_scores_matrix=mock_partial_scores_matrix, - whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]), - eco4_social_cavity_abs_rate=13.5, eco4_social_solid_abs_rate=17, - eco4_private_cavity_abs_rate=13.5, eco4_private_solid_abs_rate=17, - gbis_social_cavity_abs_rate=21, gbis_social_solid_abs_rate=25, - gbis_private_cavity_abs_rate=22, gbis_private_solid_abs_rate=28, - tenure="Social" - ) - - # Keep innovation_uplift simple for the first test - # monkeypatch.setattr(f, "get_innovation_uplift", lambda *args, **kwargs: 0.0) - - # If your solar precondition matters, you can force True/False here: - # monkeypatch.setattr( - # __import__("backend").Funding, "check_solar_eligible_heating_system", - # staticmethod(lambda mainheat_description, heating_control_description: False) - # ) - - return f - - -@pytest.fixture -def property_recommendations(): - """Short sample; replace with your full block if you want.""" - recs = [ - [{'phase': 0, 'parts': [{'id': 2466, 'type': 'external_wall_insulation', - 'description': 'EWI Pro EPS external wall insulation system with ' - 'Brick Slip finish', - 'depth': 150.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.038, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': Timestamp('2025-03-16 15:26:22.379496'), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.0, 'plant_cost': 0.0, - 'total_cost': 298.35, - 'notes': 'This is the quoted value from SCIS', - 'is_installer_quote': True, 'quantity': 63.98796761892035, - 'quantity_unit': 'm2', 'total': 19090.810139104888, - 'labour_hours': 0.0, 'labour_days': 0.0}], - 'type': 'external_wall_insulation', 'measure_type': 'external_wall_insulation', - "innovation_rate": 0, - 'description': 'Install 150mm EWI Pro EPS external wall insulation system with Brick ' - 'Slip finish on external walls', - 'starting_u_value': 1.7, 'new_u_value': 0.32, 'already_installed': False, - 'sap_points': np.float64(9.6), - 'simulation_config': {'is_as_built_ending': False, 'walls_is_assumed_ending': False, - 'walls_insulation_thickness_ending': 'average', - 'external_insulation_ending': True, - 'walls_energy_eff_ending': 'Good', - 'walls_thermal_transmittance_ending': 0.23}, - 'description_simulation': {'walls-description': 'Solid brick, with external insulation', - 'walls-energy-eff': 'Good'}, 'total': 19090.810139104888, - 'labour_hours': 0.0, 'labour_days': 0.0, 'survey': False, - 'recommendation_id': '0_phase=0', 'efficiency': 11229.568317120522, - 'co2_equivalent_savings': np.float64(0.5), 'heat_demand': np.float64(37.099999999999994), - 'kwh_savings': np.float64(1827.8999999999996), - 'energy_cost_savings': np.float64(136.1247882352941)}, {'phase': 0, 'parts': [ - {'id': 2373, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish', - 'depth': 95.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032, - 'thermal_conductivity_unit': None, - 'link': 'SCIS', 'created_at': Timestamp('2025-03-16 15:26:22.379496'), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1, - 'plant_cost': 0.0, 'total_cost': 89.0, 'notes': None, 'is_installer_quote': True, - 'quantity': 63.98796761892035, - 'quantity_unit': 'm2', 'total': 5694.929118083911, 'labour_hours': 134.37473199973275, - 'labour_days': 4.199210374991648}], 'type': 'internal_wall_insulation', - 'measure_type': 'internal_wall_insulation', - "innovation_rate": 0, - 'description': 'Install 95mm ' - 'SWIP EcoBatt & ' - 'Plastered ' - 'finish on ' - 'internal walls', - 'starting_u_value': 1.7, - 'new_u_value': 0.32, - 'already_installed': False, - 'sap_points': 6, - 'simulation_config': { - 'is_as_built_ending': False, - 'walls_is_assumed_ending': - False, - 'walls_insulation_thickness_ending': 'average', - 'internal_insulation_ending': True, - 'walls_energy_eff_ending': - 'Good', - 'walls_thermal_transmittance_ending': 0.29}, - 'description_simulation': { - 'walls-description': 'Solid ' - 'brick, with internal ' - 'insulation', - 'walls-energy-eff': 'Good'}, - 'total': 5694.929118083911, - 'labour_hours': 134.37473199973275, - 'labour_days': 4.199210374991648, - 'survey': True, - 'recommendation_id': '1_phase=0', - 'efficiency': 3349.6383047552417, - 'co2_equivalent_savings': np.float64( - 0.5), - 'heat_demand': np.float64( - 35.30000000000001), - 'kwh_savings': np.float64( - 1432.3999999999996), - 'energy_cost_savings': np.float64( - 106.67167058823532)}], [ - {'phase': 1, 'parts': [{'id': 2351, 'type': 'loft_insulation', - 'description': 'Knauf Loft Roll 44 glass fibre roll', - 'depth': 300.0, 'depth_unit': 'mm', 'cost': None, - 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.044, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': Timestamp('2025-03-16 15:26:22.379496'), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 0.11, 'plant_cost': 0.0, - 'total_cost': 15.0, - 'notes': 'This is the cost if there is less than 100mm ' - 'existing insulation', - 'is_installer_quote': True, 'quantity': 63.98796761892035, - 'quantity_unit': 'm2', 'total': 645.0, 'labour_hours': 8, - 'labour_days': 1}], 'type': 'loft_insulation', - 'measure_type': 'loft_insulation', - "innovation_rate": 0, - 'description': 'Install 300mm of Knauf Loft Roll 44 glass fibre roll in your loft', - 'starting_u_value': 2.3, 'new_u_value': 2.3, 'sap_points': np.float64(2.4), - 'already_installed': False, - 'simulation_config': {'is_loft_ending': True, 'roof_is_assumed_ending': False, - 'roof_insulation_thickness_ending': '300', - 'roof_thermal_transmittance_ending': 2.3, - 'roof_energy_eff_ending': 'Very Good'}, - 'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation', - 'roof-energy-eff': 'Very Good'}, 'total': 645.0, - 'labour_hours': 8, 'labour_days': 1, 'survey': False, 'recommendation_id': '2_phase=1', - 'efficiency': 278.1347826086957, - 'co2_equivalent_savings': np.float64(0.10000000000000009), - 'heat_demand': np.float64(1.5), 'kwh_savings': np.float64(566.1499999999996), - 'energy_cost_savings': np.float64(42.16152352941185)}], [{'phase': 2, 'parts': [ - {'id': 2329, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation', - 'depth': 0.0, - 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan, - 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, - 'thermal_conductivity_unit': None, - 'link': 'SCIS', 'created_at': datetime.datetime(2025, 3, 16, 15, 26, 22, 379496), 'is_active': True, - 'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0, - 'plant_cost': 0.0, 'total_cost': 350.0, 'notes': None, 'is_installer_quote': True, 'total': 700.0, - 'quantity': 2, - 'quantity_unit': 'part'}], 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', - "innovation_rate": 0, - 'description': 'Install 2 ' - 'Mechanical ' - 'Extract ' - 'Ventilation units', - 'starting_u_value': None, - 'new_u_value': None, - 'already_installed': False, - 'sap_points': np.float64( - -0.10000000000000142), - 'heat_demand': np.float64( - -3.3999999999999773), - 'kwh_savings': np.float64( - -53.80000000000018), - 'co2_equivalent_savings': np.float64( - 0.0), - 'energy_cost_savings': np.float64( - -4.0065176470588995), - 'total': 700.0, - 'labour_hours': 8, - 'labour_days': 1.0, - 'simulation_config': { - 'mechanical_ventilation_ending': - 'mechanical, ' - 'extract ' - 'only'}, - 'description_simulation': { - 'mechanical-ventilation': 'mechanical, ' - 'extract only'}, - 'recommendation_id': '3_phase=2', - 'efficiency': 0}], [ - {'phase': 3, 'parts': [{'id': 2409, 'type': 'suspended_floor_insulation', - 'description': 'Q-bot underfloor insulation', 'depth': 75.0, - 'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', - 'r_value_per_mm': 0.045454547, - 'r_value_unit': 'square_meter_kelvin_per_watt', - 'thermal_conductivity': 0.022, - 'thermal_conductivity_unit': 'watt_per_meter_kelvin', - 'link': 'SCIS', - 'created_at': Timestamp('2025-03-16 15:26:22.379496'), - 'is_active': True, 'prime_material_cost': None, - 'material_cost': 0.0, 'labour_cost': 0.0, - 'labour_hours_per_unit': 1.63, 'plant_cost': 0.0, - 'total_cost': 93.75, - 'notes': 'Linearly interpolated based on Qbot costs', - 'is_installer_quote': True, 'quantity': 43.0, - 'quantity_unit': 'm2', 'total': 4031.25, - 'labour_hours': 70.08999999999999, - 'labour_days': 2.920416666666666}], - 'type': 'suspended_floor_insulation', 'measure_type': 'suspended_floor_insulation', - "innovation_rate": 0, - 'description': 'Install 75mm Q-bot underfloor insulation insulation in suspended ' - 'floor', - 'starting_u_value': 0.83, 'new_u_value': 0.22, 'sap_points': 2, 'survey': True, - 'already_installed': False, 'simulation_config': {'floor_is_assumed_ending': False, - 'floor_insulation_thickness_ending': 'average', - 'floor_thermal_transmittance_ending': 0.685593}, - 'description_simulation': {'floor-description': 'Suspended, insulated'}, - 'total': 4031.25, 'labour_hours': 70.08999999999999, 'labour_days': 2.920416666666666, - 'recommendation_id': '4_phase=3', 'efficiency': 4856.707710843373, - 'co2_equivalent_savings': np.float64(0.20000000000000018), - 'heat_demand': np.float64(33.5), 'kwh_savings': np.float64(1021.1999999999998), - 'energy_cost_savings': np.float64(76.04936470588231)}], [ - {'phase': 4, 'parts': [], 'type': 'low_energy_lighting', - 'measure_type': 'low_energy_lighting', - "innovation_rate": 0, - 'description': 'Install low energy lighting in -886 outlets', 'starting_u_value': None, - 'new_u_value': None, 'already_installed': False, 'sap_points': 2, - 'kwh_savings': -48508.5, 'energy_cost_savings': -12481.237049999998, - 'co2_equivalent_savings': -7.858377, - 'description_simulation': {'lighting-energy-eff': 'Very Good', - 'lighting-description': 'Low energy lighting in all fixed' - ' outlets', - 'low-energy-lighting': 100}, 'total': -3411.1000000000004, - 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, - 'recommendation_id': '5_phase=4', 'efficiency': -1705.5500000000002, - 'heat_demand': np.float64(5.099999999999994)}], [ - {'type': 'heating', 'phase': 5, 'measure_type': 'time_temperature_zone_control', - "innovation_rate": 0, - 'parts': [], - 'description': 'Upgrade heating controls to Smart Thermostats, room sensors and ' - 'smart radiator valves (time & temperature zone control)', - 'total': 739.576, 'subtotal': 700.48, 'vat': 39.096000000000004, - 'labour_hours': 3.6199999999999997, 'labour_days': np.float64(1.0), - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(2.9), - 'already_installed': False, 'simulation_config': { - 'thermostatic_control_ending': 'time and temperature zone control', - 'switch_system_ending': None, 'trvs_ending': None, - 'mainheatc_energy_eff_ending': 'Very Good'}, 'description_simulation': { - 'mainheatcont-description': 'Time and temperature zone control', - 'mainheatc-energy-eff': 'Very Good'}, 'recommendation_id': '6_phase=5', - 'efficiency': 739.576, 'co2_equivalent_savings': np.float64(0.30000000000000027), - 'heat_demand': np.float64(6.599999999999994), - 'kwh_savings': np.float64(876.8000000000002), - 'energy_cost_savings': np.float64(65.29581176470589)}], [ - {'phase': 6, 'parts': [], 'type': 'secondary_heating', - 'measure_type': 'secondary_heating', - "innovation_rate": 0, - 'description': 'Remove the secondary heating system', 'starting_u_value': None, - 'new_u_value': None, 'sap_points': np.float64(3.6), 'already_installed': False, - 'total': 30.0, 'subtotal': 25.0, 'vat': 5.0, 'labour_hours': 3.0, - 'labour_days': np.float64(1.0), - 'simulation_config': {'secondheat_description_ending': 'None'}, - 'description_simulation': {'secondheat-description': 'None'}, - 'recommendation_id': '7_phase=6', 'efficiency': 30.0, - 'co2_equivalent_savings': np.float64(0.10000000000000009), - 'heat_demand': np.float64(15.400000000000006), - 'kwh_savings': np.float64(196.29999999999927), - 'energy_cost_savings': np.float64(14.61857647058821)}], [ - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), - 'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996), - 'description_simulation': {'photo-supply': np.float64(65.0)}, - 'recommendation_id': '8_phase=7', 'efficiency': np.float64(462.54923076923075), - 'co2_equivalent_savings': np.float64(0.47347873833399995), - 'heat_demand': np.float64(88.69999999999999), - 'kwh_savings': np.float64(2040.8566307499998), - 'energy_cost_savings': np.float64(525.1124110919749)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), - 'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996), - 'description_simulation': {'photo-supply': np.float64(65.0)}, - 'recommendation_id': '9_phase=7', 'efficiency': np.float64(810.5390769230769), - 'co2_equivalent_savings': np.float64(0.6628702336675999), - 'heat_demand': np.float64(88.69999999999999), - 'kwh_savings': np.float64(2857.1992830499994), - 'energy_cost_savings': np.float64(735.1573755287648)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), - 'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3692.66794), - 'description_simulation': {'photo-supply': np.float64(60.0)}, - 'recommendation_id': '10_phase=7', 'efficiency': np.float64(485.54099999999994), - 'co2_equivalent_savings': np.float64(0.42834948104), - 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397), - 'energy_cost_savings': np.float64(475.0617304809999)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), - 'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3692.66794), - 'description_simulation': {'photo-supply': np.float64(60.0)}, - 'recommendation_id': '11_phase=7', 'efficiency': np.float64(862.5299999999999), - 'co2_equivalent_savings': np.float64(0.599689273456), - 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558), - 'energy_cost_savings': np.float64(665.0864226734)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), - 'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3300.5416548), - 'description_simulation': {'photo-supply': np.float64(55.0)}, - 'recommendation_id': '12_phase=7', 'efficiency': np.float64(512.964), - 'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3), - 'kwh_savings': np.float64(1650.2708274), - 'energy_cost_savings': np.float64(424.61468389001993)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), - 'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3300.5416548), - 'description_simulation': {'photo-supply': np.float64(55.0)}, - 'recommendation_id': '13_phase=7', 'efficiency': np.float64(924.2247272727273), - 'co2_equivalent_savings': np.float64(0.53600796473952), - 'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(2310.3791583599996), - 'energy_cost_savings': np.float64(594.4605574460278)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), - 'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2907.1867812), - 'description_simulation': {'photo-supply': np.float64(45.0)}, - 'recommendation_id': '14_phase=7', 'efficiency': np.float64(606.5253333333333), - 'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0), - 'kwh_savings': np.float64(1453.5933906), - 'energy_cost_savings': np.float64(374.00957940138)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), - 'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2907.1867812), - 'description_simulation': {'photo-supply': np.float64(45.0)}, - 'recommendation_id': '15_phase=7', 'efficiency': np.float64(1109.1773333333333), - 'co2_equivalent_savings': np.float64(0.47212713326688), - 'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(2035.03074684), - 'energy_cost_savings': np.float64(523.6134111619319)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), - 'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2510.25188), - 'description_simulation': {'photo-supply': np.float64(40.0)}, - 'recommendation_id': '16_phase=7', 'efficiency': np.float64(659.3565), - 'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3), - 'kwh_savings': np.float64(1255.12594), - 'energy_cost_savings': np.float64(322.94390436199996)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), - 'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2510.25188), - 'description_simulation': {'photo-supply': np.float64(40.0)}, - 'recommendation_id': '17_phase=7', 'efficiency': np.float64(1224.84), - 'co2_equivalent_savings': np.float64(0.40766490531199995), - 'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1757.1763159999998), - 'energy_cost_savings': np.float64(452.1214661067999)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), - 'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0), - 'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2096.682636), - 'description_simulation': {'photo-supply': np.float64(35.0)}, - 'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856), - 'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5), - 'kwh_savings': np.float64(1048.341318), - 'energy_cost_savings': np.float64(269.7382211214)}, - {'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', - "innovation_rate": 0, - 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.', - 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), - 'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0, - 'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0), - 'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2096.682636), - 'description_simulation': {'photo-supply': np.float64(35.0)}, - 'recommendation_id': '19_phase=7', 'efficiency': np.float64(1373.5491428571427), - 'co2_equivalent_savings': np.float64(0.3405012600864), 'heat_demand': np.float64(48.5), - 'kwh_savings': np.float64(1467.6778451999999), - 'energy_cost_savings': np.float64(377.6335095699599)}] - ] - return recs - - -def _attach_costs_and_uplifts(recs, funding, p): - """Mimic what your script did: add cost fields & innovation uplift.""" - out = deepcopy(recs) - for group in out: - for r in group: - if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]: - ( - r["partial_project_score"], - r["partial_project_funding"], - r["innovation_uplift"], - r["uplift_project_score"], - ) = ( - 0, 0, 0, 0 - ) - continue - - ( - r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - r["uplift_project_score"] - ) = funding.get_innovation_uplift( - measure=r, - starting_sap=55, - floor_area=70.0, - is_cavity=False, - current_wall_uvalue=1.7, - is_partial=False, - existing_li_thickness=150, - mainheating=p.main_heating, - main_fuel=p.main_fuel, - mainheat_energy_eff="Very Good", - ) - # the optimiser_functions.prepare_input_measures will translate these to input format; but - # for safety add explicit cost fields some downstream code expects: - r["total"] = float(r["total"]) - return out - - -def _to_input_measures(recs, p): - """Use your own helper so we test the full pipeline.""" - property_measure_types = {rec["type"] for grp in recs for rec in grp} - needs_ventilation = any( - x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation - ) and not getattr(p, "has_ventilation", False) - - # goal="Increasing EPC", add_uplift=True for Social path - return optimiser_functions.prepare_input_measures( - recs, goal="Increasing EPC", needs_ventilation=needs_ventilation, funding=True - ) - - -def _types_of(picked_items): - return {item["type"] for item in picked_items} - - -def test_social_fabric_only_returns_only_fabric_types(p, funding, property_recommendations, monkeypatch): - # 1) prepare data like your script - recs = _attach_costs_and_uplifts(property_recommendations, funding, p) - input_measures = _to_input_measures(recs, p) - - # 2) run optimiser wrapper (budget and target_gain can be modest for the test) - budget = 30000.0 - target_gain = 8.0 - - solutions = optimise_with_funding_paths( - p=p, - input_measures=input_measures, - housing_type="Social", - budget=budget, - target_gain=target_gain, - funding=funding - ) - - # 3) basic shape assertions - assert isinstance(solutions, pd.DataFrame) - assert not solutions.empty - - # 4) find the fabric-only ECO4 row - fabric_rows = solutions[ - solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "fabric-only:eco4")] - assert not fabric_rows.empty, "Expected a fabric-only:eco4 solution for Social tenure" - - # 5) ensure only fabric measure types are present in that solution - picked_types = _types_of(fabric_rows.iloc[0]["items"]) - assert picked_types == {'internal_wall_insulation+mechanical_ventilation', - 'suspended_floor_insulation'}, "incorrect types selected" - - # 6) respect budget - assert fabric_rows.iloc[0]["total_cost"] <= budget + 1e-9 - - # (optional) ensure unfunded baseline also appears - unfunded_rows = solutions[ - solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "unfunded:all")] - assert not unfunded_rows.empty - - -def test_private_solid_wall_no_innovation_epc_d(p, funding, mock_project_scores_matrix, mock_partial_scores_matrix): - """ - We have a specific test for this case which was implemented incorrectly originally. - This is an EPC D property and so shouldn't be eligible for ECO4. Instead, only GBIS should be considered. - """ - - # Overwrite the data - copied from real example - p2 = deepcopy(p) - p2.data = { - "current-energy-rating": "D", - "current-energy-efficiency": 68, - "mainheat-energy-eff": "Good", - } - p2.walls = {'original_description': 'Sandstone or limestone, as built, no insulation (assumed)', - 'clean_description': 'Sandstone or limestone, as built, no insulation', 'thermal_transmittance': None, - 'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False, - 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False, - 'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True, - 'is_sandstone_or_limestone': True, 'is_park_home': False, 'insulation_thickness': 'none', - 'external_insulation': False, 'internal_insulation': False} - - funding2 = Funding( - tenure="Private", - project_scores_matrix=mock_project_scores_matrix, - partial_project_scores_matrix=mock_partial_scores_matrix, - whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]), - eco4_social_cavity_abs_rate=12.5, - eco4_social_solid_abs_rate=17, - eco4_private_cavity_abs_rate=12.5, - eco4_private_solid_abs_rate=17, - gbis_social_cavity_abs_rate=21, - gbis_social_solid_abs_rate=25, - gbis_private_cavity_abs_rate=21, - gbis_private_solid_abs_rate=28, - ) - - input_measures = [ - [{'id': '0_phase=0', 'cost': np.float64(4441.202499013676), 'gain': np.float64(3.4000000000000057), - 'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0), - 'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756, - 'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3), - 'uplift_project_score': np.float64(0.0)}], [ - {'id': '2_phase=2', 'cost': np.float64(2280.0), 'gain': np.float64(0.4), 'type': 'secondary_glazing', - 'innovation_uplift': np.float64(0.0), 'cost_minus_uplift': np.float64(2280.0), - 'raw_cost': np.float64(2280.0), 'partial_project_funding': np.float64(1421.1999999999998), - 'partial_project_score': np.float64(83.6), 'uplift_project_score': np.float64(0.0)}], [ - {'id': '3_phase=3', 'cost': np.float64(604.5840000000001), 'gain': np.float64(1.2), - 'type': 'time_temperature_zone_control', 'innovation_uplift': np.float64(0.0), - 'cost_minus_uplift': np.float64(604.5840000000001), 'raw_cost': 604.5840000000001, - 'partial_project_funding': np.float64(702.0999999999999), 'partial_project_score': np.float64(41.3), - 'uplift_project_score': np.float64(0.0)}], [ - {'id': '4_phase=4', 'cost': 60.0, 'gain': np.float64(0.0), 'type': 'secondary_heating', - 'innovation_uplift': 0, 'cost_minus_uplift': 60.0, 'raw_cost': 60.0, 'partial_project_funding': 0, - 'partial_project_score': 0, 'uplift_project_score': 0}] - ] - - solutions = optimise_with_funding_paths( - p=p2, - input_measures=input_measures, - housing_type="Private", - budget=None, - target_gain=1.5, - funding=funding2 - ) - - # 3) basic shape assertions - assert isinstance(solutions, pd.DataFrame) - assert not solutions.empty - - # We should have 2 rows - assert solutions.shape[0] == 2 - - # We should only have None or GBIS - assert set(solutions["scheme"].unique()) == {"none", "gbis"} - - meets_upgrade_gbis = solutions[solutions["meets_upgrade_target"] & solutions["is_eligible"]] - assert meets_upgrade_gbis.shape[0] == 1 - - # Check exact result - assert meets_upgrade_gbis.squeeze().to_dict() == { - 'fixed_ids': ['0_phase=0'], 'items': [ - {'id': '0_phase=0', 'cost': 3881.2024990136756, 'gain': np.float64(3.4000000000000057), - 'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0), - 'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756, - 'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3), - 'uplift_project_score': np.float64(0.0)}], 'total_cost': 3881.2024990136756, - 'total_gain': 3.4000000000000057, 'path': [{'AND': ['internal_wall_insulation+mechanical_ventilation'], - 'reference': - 'internal_wall_insulation+mechanical_ventilation:gbis'}], - 'scheme': 'gbis', 'is_eligible': True, 'unfunded_items': [], 'meets_upgrade_target': True, 'starting_sap': 68, - 'floor_area': 70.0, 'ending_sap': 71.4, 'starting_band': 'High_D', 'ending_band': 'Low_C', - 'floor_area_band': '0-72', 'project_score': 540.0, 'full_project_funding': 0.0, - 'partial_project_funding': 2300.1000000000004, 'partial_project_score': 135.3, 'total_uplift': 0.0, - 'total_uplift_score': 0.0 - } - - def test_build_heat_pump_paths(): eg1 = build_heat_pump_paths([], ["loft_insulation"]) diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py index fa707b4b..1484a09c 100644 --- a/recommendations/tests/test_recommendation_utils.py +++ b/recommendations/tests/test_recommendation_utils.py @@ -279,27 +279,34 @@ class TestRecommendationUtils: # Test with wall_type not in default_wall_thickness def test_wall_type_not_in_default_wall_thickness(self): - with pytest.raises(IndexError): - recommendation_utils.get_floor_u_value( - floor_type="solid", - area=100, - perimeter=40, - age_band="A", - wall_type="InvalidWallType", - insulation_thickness=None, - ) + # THis previously raised an error but because it largely dicates the thickness, often defaulted to + # 300, we just use the default instead of raising an error. We see cases of this in the wild, where we + # estimate EPCs and end up with unusual wall types, so we have fallbacks in place + assert recommendation_utils.get_floor_u_value( + floor_type="solid", + area=100, + perimeter=40, + age_band="A", + wall_type="InvalidWallType", + insulation_thickness=None, + ) == 0.6 # Test with age_band not in s11 def test_age_band_not_in_s11(self): - with pytest.raises(IndexError): - recommendation_utils.get_floor_u_value( - floor_type="solid", - area=100, - perimeter=40, - age_band="Z", - wall_type="Cavity", - insulation_thickness=None, - ) + # This previously raised an error but because it largely dicates the thickness, often defaulted to + # 300, we just use the default instead of raising an error. We see cases of this in the wild, where we + # might estimate an EPC + recommendation_utils.get_floor_u_value( + floor_type="solid", + area=100, + perimeter=40, + age_band="Z", + wall_type="Cavity", + insulation_thickness=None, + ) + + def test_age_band_not_in_s11_exposed_floor(self): + recommendation_utils.get_exposed_floor_uvalue(None, "BadValue") def test_convert_thickness_to_numeric(self): From ed7cb6998bbc683d09b850908887c8ea3b74bbcc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 08:55:51 +0000 Subject: [PATCH 28/74] fixed floor recommendation tests --- .../tests/test_floor_recommendations.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/recommendations/tests/test_floor_recommendations.py b/recommendations/tests/test_floor_recommendations.py index eb4f30d2..e24312fe 100644 --- a/recommendations/tests/test_floor_recommendations.py +++ b/recommendations/tests/test_floor_recommendations.py @@ -132,11 +132,21 @@ class TestFloorRecommendations: assert types == {"solid_floor_insulation"} - assert len(recommender.recommendations) == 3 - assert recommender.recommendations[2]["total"] == 14604.660000000002 - assert recommender.recommendations[2]["new_u_value"] == 0.21 - assert recommender.recommendations[2]["parts"][0]["depth"] == 75 - assert recommender.recommendations[2]["parts"][0]["depth"] == 75 + assert len(recommender.recommendations) == 1 + assert ( + recommender.recommendations[0]["description"] == + 'Install 75mm Kingspan Thermafloor TF70 High Performance Rigid Floor ' + 'Insulation insulation on solid floor' + ) + + assert recommender.recommendations[0]["new_u_value"] == 0.21 + assert recommender.recommendations[0]["simulation_config"] == { + 'floor_is_assumed_ending': False, 'floor_insulation_thickness_ending': 'average', + 'floor_thermal_transmittance_ending': 0.685593 + } + assert recommender.recommendations[0]["description_simulation"] == { + 'floor-description': 'Solid, insulated' + } def test_another_dwelling_below(self, input_properties): """ @@ -172,6 +182,7 @@ class TestFloorRecommendations: input_property.set_floor_type() input_property.floor_area = 100 input_property.number_of_floors = 1 + input_property.already_installed = [] recommender = FloorRecommendations( property_instance=input_property, @@ -203,6 +214,7 @@ class TestFloorRecommendations: input_property2.set_floor_type() input_property2.insulation_floor_area = 100 input_property2.number_of_floors = 1 + input_property2.already_installed = [] recommender2 = FloorRecommendations( property_instance=input_property2, @@ -235,6 +247,7 @@ class TestFloorRecommendations: input_property3.set_floor_type() input_property3.insulation_floor_area = 100 input_property3.number_of_floors = 1 + input_property3.already_installed = [] recommender3 = FloorRecommendations( property_instance=input_property3, From 187d7fbadd2284f58c31e89f30e91f13352ceb7e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 09:06:05 +0000 Subject: [PATCH 29/74] =?UTF-8?q?Detect=20block-level=20asset=20conditions?= =?UTF-8?q?=20-=20additional=20test=20cases=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/parsing/test_peabody_parser.py | 69 +++++++++++-------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/backend/condition/tests/parsing/test_peabody_parser.py b/backend/condition/tests/parsing/test_peabody_parser.py index 830e8f2c..63ed0799 100644 --- a/backend/condition/tests/parsing/test_peabody_parser.py +++ b/backend/condition/tests/parsing/test_peabody_parser.py @@ -1,5 +1,5 @@ -from typing import Any import pytest +from typing import Any from io import BytesIO from openpyxl import Workbook from datetime import datetime @@ -123,33 +123,46 @@ def test_peabody_parser_parses_conditions(peabody_assets_xlsx_bytes): assert all(isinstance(item, PeabodyProperty) for item in result) -def test_peabody_asset_is_block_level(): - # arrange - asset_condition = PeabodyAssetCondition( - lo_reference="", - full_address="1-80 PRINCESS ALICE HOUSE LONDON", - location_type_code=0, - parent_lo_reference="", - element_code=0, - element="", - sub_element_code=0, - sub_element="", - material_code=0, - material_or_answer="", - renewal_quantity=0, - renewal_year=2026, - cloned="", - lo_type_code=0, - renewal_cost=None, - condition_survey_date=None - ) +@pytest.fixture +def asset_condition_factory(): + def _factory(full_address: str) -> PeabodyAssetCondition: + return PeabodyAssetCondition( + lo_reference="", + full_address=full_address, + location_type_code=0, + parent_lo_reference="", + element_code=0, + element="", + sub_element_code=0, + sub_element="", + material_code=0, + material_or_answer="", + renewal_quantity=0, + renewal_year=2026, + cloned="", + lo_type_code=0, + renewal_cost=None, + condition_survey_date=None, + ) - expected_block_level = True + return _factory - # act - actual_block_level = asset_condition.is_block_level +@pytest.mark.parametrize( + "full_address, expected_block_level", + [ + ("1-80 PRINCESS ALICE HOUSE LONDON", True), + ("FLATS A-D 7 ST CHARLES SQUARE LONDON", True), + ("9A-9H HEDGEGATE COURT LONDON", True), + ("BLOCK MILNE HOUSE LONDON", True), + ("25 HAVERSHAM COURT GREENFORD", False), + ("FLAT 10 SPARROW COURT SOUTHMERE DRIVE LONDON SE2 9ES", False) + ], +) +def test_peabody_asset_is_block_level( + asset_condition_factory, + full_address, + expected_block_level, +): + asset_condition = asset_condition_factory(full_address) - # assert - assert expected_block_level == actual_block_level - - \ No newline at end of file + assert asset_condition.is_block_level == expected_block_level \ No newline at end of file From 80f3325cf07c7a5820f3efaa1bc1e34f628cead2 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 09:08:15 +0000 Subject: [PATCH 30/74] =?UTF-8?q?Detect=20block-level=20asset=20conditions?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../records/peabody/peabody_asset_condition.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py index 5451d570..71fa1e9d 100644 --- a/backend/condition/parsing/records/peabody/peabody_asset_condition.py +++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py @@ -1,3 +1,5 @@ +import re + from dataclasses import dataclass from datetime import datetime from typing import Optional @@ -23,4 +25,15 @@ class PeabodyAssetCondition: @property def is_block_level(self) -> bool: - raise NotImplementedError \ No newline at end of file + if not self.full_address: + return False + + address = self.full_address.upper() + + block_level_patterns = [ + r"\bBLOCK\b", # "BLOCK MILNE HOUSE" + r"\bFLATS\b", # "FLATS A-D ..." + r"\b\d+[A-Z]?-\d+[A-Z]?\b", # "1-80", "9A-9H" + ] + + return any(re.search(pattern, address) for pattern in block_level_patterns) \ No newline at end of file From f8db0cadbafa33fee75419c8fb38c55cadf04f75 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 09:15:37 +0000 Subject: [PATCH 31/74] Ignore block level assets during parsing --- backend/condition/parsing/peabody_parser.py | 7 +++--- .../peabody/peabody_asset_condition.py | 2 +- .../tests/parsing/test_peabody_parser.py | 24 +++++++++++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/backend/condition/parsing/peabody_parser.py b/backend/condition/parsing/peabody_parser.py index d2229e1c..b053f2ea 100644 --- a/backend/condition/parsing/peabody_parser.py +++ b/backend/condition/parsing/peabody_parser.py @@ -33,9 +33,10 @@ class PeabodyParser(Parser): assets: List[PeabodyAssetCondition] = [] for row in asset_rows: try: - assets.append( - PeabodyParser._map_row_to_asset_record(row, asset_header_indexes) - ) + asset = PeabodyParser._map_row_to_asset_record(row, asset_header_indexes) + if not asset.is_block_level: + assets.append(asset) + except Exception as e: logger.error(f"Error mapping Peabody row to asset record: {e}") continue diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py index 71fa1e9d..b1624999 100644 --- a/backend/condition/parsing/records/peabody/peabody_asset_condition.py +++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py @@ -32,7 +32,7 @@ class PeabodyAssetCondition: block_level_patterns = [ r"\bBLOCK\b", # "BLOCK MILNE HOUSE" - r"\bFLATS\b", # "FLATS A-D ..." + r"\bFLATS\b", # "FLATS A-D ..." r"\b\d+[A-Z]?-\d+[A-Z]?\b", # "1-80", "9A-9H" ] diff --git a/backend/condition/tests/parsing/test_peabody_parser.py b/backend/condition/tests/parsing/test_peabody_parser.py index 63ed0799..fb0e9d51 100644 --- a/backend/condition/tests/parsing/test_peabody_parser.py +++ b/backend/condition/tests/parsing/test_peabody_parser.py @@ -33,7 +33,25 @@ def peabody_assets_xlsx_bytes() -> BytesIO: ]) survey_records_d_and_lower.append([ "B000RAND", - "1-11 RANDOM HOUSE LONDON", + "1 RANDOM HOUSE LONDON", + 3, + "RAND2EST", + 110, + "ROOFS", + 1, + "Primary Roof", + 9, + "Other", + 3, + 2054, + 330, + "N", + 3, + datetime(2025,12,4,9,17,0) + ]) + survey_records_d_and_lower.append([ + "B000BLOCK", + "1100 BLOCK", 3, "RAND2EST", 110, @@ -51,7 +69,7 @@ def peabody_assets_xlsx_bytes() -> BytesIO: ]) survey_records_d_and_lower.append([ "B000FAKE", - "3-10 FAKE CLOSE LONDON", + "3 FAKE CLOSE LONDON", 3, "FAKEEST", 100, @@ -163,6 +181,8 @@ def test_peabody_asset_is_block_level( full_address, expected_block_level, ): + # arrange asset_condition = asset_condition_factory(full_address) + # act + assert assert asset_condition.is_block_level == expected_block_level \ No newline at end of file From 3cdc871aaea0575be8d97f0ea5a391681b8e8962 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 09:23:01 +0000 Subject: [PATCH 32/74] =?UTF-8?q?Detect=20block-level=20asset=20conditions?= =?UTF-8?q?=20-=20additional=20test=20cases=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/tests/parsing/test_peabody_parser.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/condition/tests/parsing/test_peabody_parser.py b/backend/condition/tests/parsing/test_peabody_parser.py index fb0e9d51..32ff79d8 100644 --- a/backend/condition/tests/parsing/test_peabody_parser.py +++ b/backend/condition/tests/parsing/test_peabody_parser.py @@ -172,6 +172,8 @@ def asset_condition_factory(): ("FLATS A-D 7 ST CHARLES SQUARE LONDON", True), ("9A-9H HEDGEGATE COURT LONDON", True), ("BLOCK MILNE HOUSE LONDON", True), + ("81A-B GORE ROAD LONDON", True), + ("73 & 74 HARVEST COURT ST. ALBANS", True), ("25 HAVERSHAM COURT GREENFORD", False), ("FLAT 10 SPARROW COURT SOUTHMERE DRIVE LONDON SE2 9ES", False) ], From 9ccbbafb29817d64f0e4c68262b663f04bc80331 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 09:24:23 +0000 Subject: [PATCH 33/74] =?UTF-8?q?Detect=20block-level=20asset=20conditions?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parsing/records/peabody/peabody_asset_condition.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py index b1624999..01215a26 100644 --- a/backend/condition/parsing/records/peabody/peabody_asset_condition.py +++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py @@ -31,9 +31,11 @@ class PeabodyAssetCondition: address = self.full_address.upper() block_level_patterns = [ - r"\bBLOCK\b", # "BLOCK MILNE HOUSE" - r"\bFLATS\b", # "FLATS A-D ..." - r"\b\d+[A-Z]?-\d+[A-Z]?\b", # "1-80", "9A-9H" + r"\bBLOCK\b", # BLOCK MILNE HOUSE + r"\bFLATS\b", # FLATS A-D + r"\b\d+[A-Z]?-\d+[A-Z]?\b", # 1-80, 9A-9H + r"\b\d+[A-Z]-[A-Z]\b", # 81A-B + r"\b\d+\s*&\s*\d+\b", # 73 & 74 ] return any(re.search(pattern, address) for pattern in block_level_patterns) \ No newline at end of file From 1634e04dda32bc4159b56a98970d68cd1779b5f4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 09:30:50 +0000 Subject: [PATCH 34/74] consistent address trimming when assmebling property objects --- backend/condition/parsing/peabody_parser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/condition/parsing/peabody_parser.py b/backend/condition/parsing/peabody_parser.py index b053f2ea..17a955d7 100644 --- a/backend/condition/parsing/peabody_parser.py +++ b/backend/condition/parsing/peabody_parser.py @@ -54,7 +54,7 @@ class PeabodyParser(Parser): if asset.full_address is None: continue - address = asset.full_address + address = asset.full_address.strip() assets_by_address[address].append(asset) properties: List[PeabodyProperty] = [] @@ -121,7 +121,6 @@ class PeabodyParser(Parser): if address is None: continue - # Optional normalization address = address.strip() if address not in address_to_uprn: From 4ae439ddd31f279e23642290d7cd038640797834 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 09:45:32 +0000 Subject: [PATCH 35/74] fixed costs tests --- recommendations/Costs.py | 70 +++++++++--- recommendations/tests/test_costs.py | 168 ++++++++-------------------- 2 files changed, 103 insertions(+), 135 deletions(-) diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 86062433..60b1d8a2 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -308,12 +308,64 @@ class Costs: return { "total": total_cost, - "contengency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost, + "contingency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost, "contingency_rate": self.CONTINGENCIES["suspended_floor_insulation"], "labour_hours": labour_hours, "labour_days": labour_days, } + @staticmethod + def _estimate_number_of_days_for_solid_floor(insulation_floor_area: float) -> float: + """ + Estimate the number of labour days required to install solid floor insulation, + based on the floor area being treated. + + This is a heuristic (rule-of-thumb) estimate designed for early-stage planning + and costing. It deliberately avoids strict linear scaling because real-world + construction work includes fixed overheads and efficiency gains. + + Core assumptions: + - A typical solid floor insulation job covering ~45 m2 takes around 7 working days. + This is based on market guidance (e.g. Checkatrade). + - Very small jobs still require multiple days due to setup, preparation, + curing/drying time, and inspections — even if the area is small. + - Larger jobs take longer, but each additional square metre adds slightly less + time than the previous one, because crews become more efficient once work + is underway. + + The estimate therefore: + - Scales with floor area + - Applies a minimum realistic duration + - Uses non-linear scaling to reflect economies of scale + + :param insulation_floor_area: float - total floor area to be insulated + """ + # Reference case: + # A "typical" job (about half of a 90 m² house) takes ~7 days to complete + base_days = 7 + base_area = 45 # m2 of solid floor insulated in the reference case + + # Exponent < 1 means sub-linear scaling: + # doubling the area does NOT double the time, because setup costs + # and learning effects reduce the marginal effort per extra m² + labour_exponent = 0.85 + + # Minimum number of days for any solid floor job. + # Even small areas require mobilisation, preparation, installation, + # and finishing time, so jobs realistically won't complete faster than this. + min_days = 3 + + # Calculate estimated labour days: + # - Scale relative to the reference job + # - Apply sub-linear scaling for realism + # - Enforce a minimum duration so estimates are not unrealistically low + labour_days = max( + min_days, + base_days * (insulation_floor_area / base_area) ** labour_exponent + ) + + return labour_days + def solid_floor_insulation(self, insulation_floor_area, material): """ based on costing data from installers, produces an estimate for the cost of works. Returns contingency @@ -324,21 +376,9 @@ class Costs: """ total_cost = material["total_cost"] * insulation_floor_area - - # We assume the average house takes ~7 days to complete at £300/day incl. VAT, as per checkatrade - # which can be seen here: https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost - # Assumptions - base_days = 7 # The quickest it will be completed - base_area = 45 # The area that can be completed in that time (for a typical 90m2 house) - labour_exponent = 0.85 # Non-linear scaling daily_labour_rate = 300 # Based on checkatrade - min_days = 3 # Fewest days it will take - labour_days = max( - min_days, - base_days * (insulation_floor_area / base_area) ** labour_exponent - ) - + labour_days = self._estimate_number_of_days_for_solid_floor(insulation_floor_area) labour_cost = labour_days * daily_labour_rate total_cost = total_cost + labour_cost @@ -460,7 +500,7 @@ class Costs: # We estimate the cost of an appliance thermostat at £400, which is the upper end of the range return { "total": total_cost, - "contengency": total_cost * self.CONTINGENCY, + "contingency": total_cost * self.CONTINGENCY, "contingency_rate": self.CONTINGENCY, "subtotal": subtotal_before_vat, "vat": vat, diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py index 4b8d74db..752caf8c 100644 --- a/recommendations/tests/test_costs.py +++ b/recommendations/tests/test_costs.py @@ -27,7 +27,8 @@ class TestCosts: material=cwi_material, ) - assert cwi_results == {'total': 1342.7459938871539, 'labour_hours': 8, 'labour_days': 1} + assert cwi_results["total"] == 1342.7459938871539 + assert cwi_results["contingency"] == 134.2745993887154 def test_loft_insulation(self): mock_property = Mock() @@ -37,6 +38,7 @@ class TestCosts: costs = Costs(mock_property) loft_material = { + "type": "loft_insulation", "description": "Crown Loft Roll 44 glass fibre roll", "depth": 270, "thermal_conductivity": 0.044, @@ -50,7 +52,8 @@ class TestCosts: material=loft_material, ) - assert loft_results == {'total': 368.5, 'labour_hours': 8, 'labour_days': 1} + assert loft_results["total"] == 368.5 + assert loft_results["contingency"] == 36.85 def test_internal_wall_insulation(self): mock_property = Mock() @@ -79,9 +82,8 @@ class TestCosts: material=iwi_material, ) - assert iwi_results == { - 'total': 19182.085626959342, 'labour_hours': 17.263877064263404, 'labour_days': 0.5394961582582314 - } + assert iwi_results["total"] == 19182.085626959342 + assert iwi_results["contingency"] == 4987.342263009429 def test_suspended_floor_insulation(self): mock_property = Mock() @@ -98,53 +100,38 @@ class TestCosts: 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0, 'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0, - 'total_cost': 13.46, 'link': 'SPONs', - 'Notes': 'Spons did not contain labour costs so we use values for similar insulations. ' - 'We use the ' - 'same values as in Crown loft roll 44, since it is also an insulation roll', + 'total_cost': 75, "is_installer_quote": False } - sus_floor_non_insulation_materials = [ - {'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, - 'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs', - 'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and ' - 'therefore there is no need for a skip'}, - {'type': 'suspended_floor_demolition', - 'description': 'Remove boarding; withdraw nails; set aside for reuse; ground level', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 9.34, 'labour_hours_per_unit': 0.3, - 'plant_cost': 0, 'total_cost': 9.34, 'link': 'SPONs', 'Notes': 0}, - {'type': 'suspended_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', - 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, - 'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0}, - {'type': 'suspended_floor_redecoration', 'description': 'refix floorboards previously set aside', - 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, 'prime_material_cost': 0, 'material_cost': 1.54, 'labour_cost': 24.98, - 'labour_hours_per_unit': 0.74, 'plant_cost': 0, 'total_cost': 26.52, 'link': 'SPONs', 'Notes': 0}, - {'type': 'suspended_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, - 'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs', - 'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; ' - 'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, ' - 'therefore we need just labour rates'}] - sus_floor_results = costs.suspended_floor_insulation( insulation_floor_area=33.5, material=sus_floor_material, - non_insulation_materials=sus_floor_non_insulation_materials ) - assert sus_floor_results == { - 'total': 3337.07436, 'subtotal': 2780.8953, 'vat': 556.17906, 'contingency': 370.78604, - 'preliminaries': 185.39302, 'material': 483.405, 'profit': 370.78604, 'labour_hours': 54.940000000000005, - 'labour_days': 2.289166666666667, 'labour_cost': 1370.5252 + assert sus_floor_results["total"] == 2512.5 + assert sus_floor_results["contingency"] == 502.5 + + @pytest.mark.parametrize("insulation_floor_area, expected_result", [ + (5, 3), + (33.5, 5.446976345666071), + (45, 7), + (70, 10.190623048464415), + (90, 12.617506476551622), + (150, 19.47802744828744), + (200, 24.873843619763473), + ]) + def test_estimate_estimate_number_of_days_for_solid_floor( + self, insulation_floor_area, expected_result + ): + mock_property = Mock() + mock_property.data = { + "county": "Northamptonshire" } + costs = Costs(mock_property) + assert costs._estimate_number_of_days_for_solid_floor(insulation_floor_area) == expected_result + def test_solid_floor_insulation(self): mock_property = Mock() mock_property.data = { @@ -160,75 +147,13 @@ class TestCosts: 'total_cost': 16.42, 'link': 'SPONs', 'Notes': 0, "is_installer_quote": False } - sol_floor_non_insulation_materials = [ - {'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, - 'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs', - 'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and ' - 'therefore there is no need for a skip'}, - {'type': 'solid_floor_preparation', - 'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, - 'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, { - 'type': 'solid_floor_preparation', - 'description': 'Clean out crack to ' - 'form a 20mm×20mm ' - 'groove and fill with ' - 'cement: mortar mixed ' - 'with bonding agent', - 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, - 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, - 'material_cost': 6.91, - 'labour_cost': 18.99, - 'labour_hours_per_unit': 0.61, - 'plant_cost': 0.16, - 'total_cost': 26.06, 'link': 0, - 'Notes': 'This step is the ' - 'assessment and repair of ' - 'any damage to the concrete ' - 'floor such as filling ' - 'cracks or levelling uneven ' - 'areas'}, - {'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', - 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, - 'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0}, - {'type': 'solid_floor_redecoration', - 'description': 'Screeded beds; protection to compressible formwork exceeding 600mm wide', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 9.6, 'material_cost': 9.89, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, - 'plant_cost': 0, 'total_cost': 12.56, 'link': 'SPONs', - 'Notes': 'This is the screed layer, placed on top of the insulation'}, - {'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, - 'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs', - 'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; ' - 'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, ' - 'therefore we need just labour rates'}, - {'type': 'solid_floor_redecoration', - 'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12, - 'plant_cost': 0, 'total_cost': 4.88, 'link': 'SPONs', 'Notes': 0} - ] - sol_floor_results = costs.solid_floor_insulation( insulation_floor_area=33.5, material=sol_floor_material, - non_insulation_materials=sol_floor_non_insulation_materials ) - assert sol_floor_results == { - 'total': 4245.023520000001, 'subtotal': 3537.5196, 'vat': 707.5039200000001, 'contingency': 471.66928, - 'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 471.66928, 'labour_hours': 57.285, - 'labour_days': 2.386875, 'labour_cost': 1346.6464 - } + assert sol_floor_results["total"] == 2184 + assert sol_floor_results["contingency"] == 567.84 def test_external_wall_insulation(self): mock_property = Mock() @@ -253,9 +178,8 @@ class TestCosts: material=ewi_material, ) - assert ewi_results == { - 'total': 28773.12844043901, 'labour_hours': 134.2745993887154, 'labour_days': 4.196081230897356 - } + assert ewi_results["total"] == 28773.12844043901 + assert ewi_results["contingency"] == 7481.013394514142 def test_flat_roof_insulation(self): mock_property = Mock() @@ -288,23 +212,27 @@ class TestCosts: material=flat_roof_material, ) - assert flat_roof_floor_results == { - 'total': 2063.935, 'subtotal': 1719.9458333333334, 'vat': 343.9891666666665, 'labour_hours': 8, - 'labour_days': 1 - } + assert flat_roof_floor_results["total"] == 2063.935 + assert flat_roof_floor_results["contingency"] == 536.6231 assert costs.labour_adjustment_factor == 0.88 # Test for different wattages - @pytest.mark.parametrize("n_panels, expected_cost", [ - (7, 5458.727999999999), - (10, 6013.139999999999), - (12, 6386.447999999999), - (15, 7594.451999999999), + @pytest.mark.parametrize("solar_product, expected_cost", [ + ({"total_cost": 5000, "includes_scaffolding": False}, 6000), + ({"total_cost": 5000, "includes_scaffolding": True}, 5000), ]) - def test_solar_pv_different_wattages(self, n_panels, expected_cost): + def test_solar_pv_different_wattages(self, solar_product, expected_cost): mock_property = Mock() mock_property.data = {"county": "Mansfield"} + scaffolding_options = [ + {"size": 2, "total_cost": 1000} + ] costs = Costs(mock_property) - result = costs.solar_pv(n_panels) + result = costs.solar_pv( + solar_product=solar_product, + scaffolding_options=scaffolding_options, + n_floors=2 + ) + assert result['total'] == pytest.approx(expected_cost, rel=0.01) From 6e2da7c166a9f990366582ea8b668cf9e6ce5831 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 11:08:11 +0000 Subject: [PATCH 36/74] change unit tests to run on every pr --- .github/workflows/unit_tests.yml | 11 ++--------- backend/engine/engine.py | 4 +--- recommendations/HeatingRecommender.py | 3 +-- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 5428fe89..0e9c48fd 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -1,9 +1,9 @@ name: Run unit tests on: - push: + pull_request: branches: - - main + - * jobs: test: @@ -22,13 +22,6 @@ jobs: run: | make setup - - name: Set dev AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} - aws-region: eu-west-2 - - name: Run tests with tox via Makefile env: EPC_AUTH_TOKEN: ${{ secrets.DEV_EPC_AUTH_TOKEN }} diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 50ed0772..a9156078 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -885,13 +885,11 @@ async def model_engine(body: PlanTriggerRequest): ) # The materials data could be cached or local so we don't need to make - # consistent requests to the backend for - # the same data + # consistent requests to the backend for the same data logger.info("Reading in materials and cleaned datasets") with db_read_session() as session: materials = db_funcs.materials_functions.get_materials(session) cleaned = get_cleaned() - # project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes = get_funding_data() kwh_client = KwhData(bucket=get_settings().DATA_BUCKET, read_consumption_data=True) diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index ea3056ba..20568360 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -1265,8 +1265,7 @@ class HeatingRecommender: # We check if there's a mains connection and the hot water is inefficient, as this will improve with a boiler has_inefficient_water = ( - self.property.data["mains-gas-flag"] and - self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor"] + self.property.data["mains-gas-flag"] and self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor"] ) non_invasive_recommendation = next(( From 0143e8cbafd13d7a704db9fcd1458d4c4d9aab0b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 11:10:05 +0000 Subject: [PATCH 37/74] remove branch filtering from workflow --- .github/workflows/unit_tests.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 0e9c48fd..95155c86 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -2,8 +2,6 @@ name: Run unit tests on: pull_request: - branches: - - * jobs: test: From 6af6ba46565175ac66a8288901c711e710cc4a4a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 11:37:41 +0000 Subject: [PATCH 38/74] adding config env variables for pytest --- conftest.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 conftest.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..ddd12575 --- /dev/null +++ b/conftest.py @@ -0,0 +1,34 @@ +# conftest.py +import os +import pytest +from backend.app.config import get_settings + + +@pytest.fixture(autouse=True, scope="session") +def fake_env(): + vars = { + "API_KEY": "test", + "SECRET_KEY": "test", + "ENVIRONMENT": "test", + "DATA_BUCKET": "test", + "PLAN_TRIGGER_BUCKET": "test", + "ENGINE_SQS_URL": "test", + "EPC_AUTH_TOKEN": "test", # Will be overwritten in CI/CD + "GOOGLE_SOLAR_API_KEY": "test", + "DB_HOST": "localhost", + "DB_USERNAME": "test", + "DB_PASSWORD": "test", + "DB_PORT": "5432", + "DB_NAME": "test", + "SAP_PREDICTIONS_BUCKET": "test", + "CARBON_PREDICTIONS_BUCKET": "test", + "HEAT_PREDICTIONS_BUCKET": "test", + "HEATING_KWH_PREDICTIONS_BUCKET": "test", + "HOTWATER_KWH_PREDICTIONS_BUCKET": "test", + "ENERGY_ASSESSMENTS_BUCKET": "test", + } + + for k, v in vars.items(): + os.environ.setdefault(k, v) + + get_settings.cache_clear() From cf55aac64bbceb8883cb1a19664fbc763e86da63 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 11:41:58 +0000 Subject: [PATCH 39/74] changes pytest config --- conftest.py | 55 +++++++++++++++++++++++++---------------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/conftest.py b/conftest.py index ddd12575..e3add6e6 100644 --- a/conftest.py +++ b/conftest.py @@ -1,34 +1,31 @@ -# conftest.py import os -import pytest from backend.app.config import get_settings +DEFAULT_ENV = { + "API_KEY": "test", + "SECRET_KEY": "test", + "ENVIRONMENT": "test", + "DATA_BUCKET": "test", + "PLAN_TRIGGER_BUCKET": "test", + "ENGINE_SQS_URL": "test", + "EPC_AUTH_TOKEN": "test", # overridden in GitHub Actions + "GOOGLE_SOLAR_API_KEY": "test", + "DB_HOST": "localhost", + "DB_USERNAME": "test", + "DB_PASSWORD": "test", + "DB_PORT": "5432", + "DB_NAME": "test", + "SAP_PREDICTIONS_BUCKET": "test", + "CARBON_PREDICTIONS_BUCKET": "test", + "HEAT_PREDICTIONS_BUCKET": "test", + "HEATING_KWH_PREDICTIONS_BUCKET": "test", + "HOTWATER_KWH_PREDICTIONS_BUCKET": "test", + "ENERGY_ASSESSMENTS_BUCKET": "test", +} -@pytest.fixture(autouse=True, scope="session") -def fake_env(): - vars = { - "API_KEY": "test", - "SECRET_KEY": "test", - "ENVIRONMENT": "test", - "DATA_BUCKET": "test", - "PLAN_TRIGGER_BUCKET": "test", - "ENGINE_SQS_URL": "test", - "EPC_AUTH_TOKEN": "test", # Will be overwritten in CI/CD - "GOOGLE_SOLAR_API_KEY": "test", - "DB_HOST": "localhost", - "DB_USERNAME": "test", - "DB_PASSWORD": "test", - "DB_PORT": "5432", - "DB_NAME": "test", - "SAP_PREDICTIONS_BUCKET": "test", - "CARBON_PREDICTIONS_BUCKET": "test", - "HEAT_PREDICTIONS_BUCKET": "test", - "HEATING_KWH_PREDICTIONS_BUCKET": "test", - "HOTWATER_KWH_PREDICTIONS_BUCKET": "test", - "ENERGY_ASSESSMENTS_BUCKET": "test", - } +# runs immediately when pytest starts, BEFORE collection +for k, v in DEFAULT_ENV.items(): + os.environ.setdefault(k, v) - for k, v in vars.items(): - os.environ.setdefault(k, v) - - get_settings.cache_clear() +# clear cached settings AFTER env is final +get_settings.cache_clear() From a7201b0dc406a50f43b8307ed765b57458da0523 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 12:20:17 +0000 Subject: [PATCH 40/74] Start writing peabody mapper. Now rethink model before continuing --- .../condition/domain/mapping/lbwf_mapper.py | 1 - .../domain/mapping/peabody_mapper.py | 14 +++++++ backend/condition/parsing/peabody_parser.py | 4 +- .../tests/mapping/test_peabody_mapper.py | 38 +++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 backend/condition/domain/mapping/peabody_mapper.py create mode 100644 backend/condition/tests/mapping/test_peabody_mapper.py diff --git a/backend/condition/domain/mapping/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf_mapper.py index 0af21b7a..8b556284 100644 --- a/backend/condition/domain/mapping/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf_mapper.py @@ -1,5 +1,4 @@ from typing import Any, List, Optional -from datetime import datetime, date from backend.condition.domain.asset_condition import AssetCondition from backend.condition.domain.element import Element diff --git a/backend/condition/domain/mapping/peabody_mapper.py b/backend/condition/domain/mapping/peabody_mapper.py new file mode 100644 index 00000000..88d0a626 --- /dev/null +++ b/backend/condition/domain/mapping/peabody_mapper.py @@ -0,0 +1,14 @@ +from typing import Any, List, Optional + +from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.element import Element +from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition +from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty +from utils.logger import setup_logger + +logger = setup_logger() + +class PeabodyMapper(Mapper): + def map_asset_conditions_for_property(self, client_data: Any, survey_year: Optional[int]) -> List[AssetCondition]: + raise NotImplementedError \ No newline at end of file diff --git a/backend/condition/parsing/peabody_parser.py b/backend/condition/parsing/peabody_parser.py index 17a955d7..b8a548a7 100644 --- a/backend/condition/parsing/peabody_parser.py +++ b/backend/condition/parsing/peabody_parser.py @@ -35,7 +35,9 @@ class PeabodyParser(Parser): try: asset = PeabodyParser._map_row_to_asset_record(row, asset_header_indexes) if not asset.is_block_level: - assets.append(asset) + # Block-level condition surveys are out of scope for now + # until we have a wider think on how to handle block + assets.append(asset) # TODO: handle block-level assets except Exception as e: logger.error(f"Error mapping Peabody row to asset record: {e}") diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py new file mode 100644 index 00000000..fc70b015 --- /dev/null +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from backend.condition.domain.mapping.peabody_mapper import PeabodyMapper +from backend.condition.domain.element import Element +from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition +from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty +from backend.condition.domain.asset_condition import AssetCondition + +def test_peabody_mapper_maps_property(): + # arrange + peabody_property = PeabodyProperty( + uprn=1, + assets=[ + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=50, + element="Internal", + sub_element_code=3, + sub_element="CCU", + material_code=2, + material_or_answer="RCD/MCB CCU", + renewal_quantity=1, + renewal_year=2038, + renewal_cost=500, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2024,2,15,0,0,0), + ) + ] + ) + + # act + + # assert + assert False #temp \ No newline at end of file From 3289dc226dbbcbf17f5bce14339b329515c53664 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 13:28:51 +0000 Subject: [PATCH 41/74] Rename Element to LbwfElement --- backend/condition/domain/asset_condition.py | 7 +-- .../domain/{element.py => lbwf_element.py} | 18 +++++-- .../condition/domain/mapping/lbwf_mapper.py | 50 ++++++++++++------- .../domain/mapping/peabody_mapper.py | 13 +++-- .../tests/mapping/test_lbwf_mapper.py | 34 +++++++------ .../tests/mapping/test_peabody_mapper.py | 13 +++-- 6 files changed, 84 insertions(+), 51 deletions(-) rename backend/condition/domain/{element.py => lbwf_element.py} (92%) diff --git a/backend/condition/domain/asset_condition.py b/backend/condition/domain/asset_condition.py index dffbdf88..a489090f 100644 --- a/backend/condition/domain/asset_condition.py +++ b/backend/condition/domain/asset_condition.py @@ -2,13 +2,14 @@ from dataclasses import dataclass from typing import Optional from datetime import date -from backend.condition.domain.element import Element +from backend.condition.domain.lbwf_element import LbwfElement + @dataclass class AssetCondition: uprn: int - element: Element # TODO: should HHSRS elements be handled differently? - condition_description: str # TODO: this probably needs to be some sort of enum so it's searchable/filterable on the frontend. Could be hard to map from string though + element: LbwfElement # TODO: should HHSRS elements be handled differently? + condition_description: str # TODO: this probably needs to be some sort of enum so it's searchable/filterable on the frontend. Could be hard to map from string though quantity: int renewal_year: Optional[int] = None source: Optional[str] = None diff --git a/backend/condition/domain/element.py b/backend/condition/domain/lbwf_element.py similarity index 92% rename from backend/condition/domain/element.py rename to backend/condition/domain/lbwf_element.py index 021c8492..52928c72 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/lbwf_element.py @@ -1,7 +1,7 @@ from enum import StrEnum -class Element(StrEnum): +class LbwfElement(StrEnum): AHR_CAT = "Accessible Housing Register Category" ASBESTOS = "Asbestos Present" ASSETSAREA = "Assets Area for Decent Homes and Investment" @@ -49,12 +49,20 @@ class Element(StrEnum): EXTWNDWS1 = "Windows 1 in External Area" EXTWNDWS2 = "Windows 2 in External Area" FFHHDAMP = "Fitness for Human Habitation - Serious problem with damp" - FFHHDRNWC = "Fitness for Human Habitation - Problems with the drainage or the lavatories" - FFHHHCWAT = "Fitness for Human Habitation - Problem with the supply of hot and cold water" - FFHHNEGLC = "Fitness for Human Habitation - Building neglected and is in a bad condition" + FFHHDRNWC = ( + "Fitness for Human Habitation - Problems with the drainage or the lavatories" + ) + FFHHHCWAT = ( + "Fitness for Human Habitation - Problem with the supply of hot and cold water" + ) + FFHHNEGLC = ( + "Fitness for Human Habitation - Building neglected and is in a bad condition" + ) FFHHNONAT = "Fitness for Human Habitation - Not enough natural light" FFHHNOVEN = "Fitness for Human Habitation - Not enough ventilation" - FFHHPRPCK = "Fitness for Human Habitation - Difficult to prepare and cook food or wash up" + FFHHPRPCK = ( + "Fitness for Human Habitation - Difficult to prepare and cook food or wash up" + ) FFHHUNLAY = "Fitness for Human Habitation - Unsafe layout" FFHHUNSTA = "Fitness for Human Habitation - Building is unstable" FRARISKRTG = "Fire Risk Assessment Rating" diff --git a/backend/condition/domain/mapping/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf_mapper.py index 8b556284..dcd1d748 100644 --- a/backend/condition/domain/mapping/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf_mapper.py @@ -1,29 +1,37 @@ from typing import Any, List, Optional from backend.condition.domain.asset_condition import AssetCondition -from backend.condition.domain.element import Element +from backend.condition.domain.lbwf_element import LbwfElement from backend.condition.domain.mapping.mapper import Mapper -from backend.condition.parsing.records.lbwf.lbwf_asset_condition import LbwfAssetCondition +from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( + LbwfAssetCondition, +) from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse from utils.logger import setup_logger logger = setup_logger() + class LbwfMapper(Mapper): - - def map_asset_conditions_for_property(self, client_data: Any, survey_year: Optional[int]) -> List[AssetCondition]: - assert isinstance(client_data, LbwfHouse) # TODO: think of a better way to do this + + def map_asset_conditions_for_property( + self, client_data: Any, survey_year: Optional[int] + ) -> List[AssetCondition]: + assert isinstance( + client_data, LbwfHouse + ) # TODO: think of a better way to do this mapped_assets: List[AssetCondition] = [] uprn: int = client_data.uprn for raw_asset in client_data.assets: try: - element: Element = LbwfMapper._map_element(raw_asset.element_code) + element: LbwfElement = LbwfMapper._map_element(raw_asset.element_code) except: - logger.warning(f"Unrecognised LBWF Asset Element Code: {raw_asset.element_code}. Skipping record") + logger.warning( + f"Unrecognised LBWF Asset Element Code: {raw_asset.element_code}. Skipping record" + ) continue - mapped_assets.append( AssetCondition( @@ -31,7 +39,9 @@ class LbwfMapper(Mapper): element=element, condition_description=raw_asset.attribute_code_description, quantity=raw_asset.quantity, - renewal_year=LbwfMapper._calculate_renewal_year(raw_asset, survey_year), + renewal_year=LbwfMapper._calculate_renewal_year( + raw_asset, survey_year + ), source=raw_asset.element_comments, install_date=raw_asset.install_date, ) @@ -39,23 +49,25 @@ class LbwfMapper(Mapper): return mapped_assets + @staticmethod + def _map_element(lbwf_element_code: LbwfAssetCondition) -> LbwfElement: + return LbwfElement[lbwf_element_code] - @staticmethod - def _map_element(lbwf_element_code: LbwfAssetCondition) -> Element: - return Element[lbwf_element_code] - - @staticmethod - def _calculate_renewal_year(lbwf_asset: LbwfAssetCondition, survey_year: Optional[int]) -> Optional[int]: + def _calculate_renewal_year( + lbwf_asset: LbwfAssetCondition, survey_year: Optional[int] + ) -> Optional[int]: remaining_life_years: Optional[int] = lbwf_asset.remaining_life if not remaining_life_years: return None - + if not survey_year: return None - + try: return survey_year + remaining_life_years except: - logger.debug(f"Unable to map LBWF Asset remaining life {remaining_life_years} to renewal year, returning None") - return None \ No newline at end of file + logger.debug( + f"Unable to map LBWF Asset remaining life {remaining_life_years} to renewal year, returning None" + ) + return None diff --git a/backend/condition/domain/mapping/peabody_mapper.py b/backend/condition/domain/mapping/peabody_mapper.py index 88d0a626..4c647380 100644 --- a/backend/condition/domain/mapping/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody_mapper.py @@ -1,14 +1,19 @@ from typing import Any, List, Optional from backend.condition.domain.asset_condition import AssetCondition -from backend.condition.domain.element import Element +from backend.condition.domain.lbwf_element import LbwfElement from backend.condition.domain.mapping.mapper import Mapper -from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition +from backend.condition.parsing.records.peabody.peabody_asset_condition import ( + PeabodyAssetCondition, +) from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty from utils.logger import setup_logger logger = setup_logger() + class PeabodyMapper(Mapper): - def map_asset_conditions_for_property(self, client_data: Any, survey_year: Optional[int]) -> List[AssetCondition]: - raise NotImplementedError \ No newline at end of file + def map_asset_conditions_for_property( + self, client_data: Any, survey_year: Optional[int] + ) -> List[AssetCondition]: + raise NotImplementedError diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index 151e5d19..c007b575 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -4,10 +4,13 @@ from datetime import date from backend.condition.domain.mapping.lbwf_mapper import LbwfMapper from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse -from backend.condition.parsing.records.lbwf.lbwf_asset_condition import LbwfAssetCondition -from backend.condition.domain.element import Element +from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( + LbwfAssetCondition, +) +from backend.condition.domain.lbwf_element import LbwfElement from backend.condition.domain.asset_condition import AssetCondition + def test_lbwf_mapper_maps_house(): # arrange lbwf_house = LbwfHouse( @@ -162,11 +165,11 @@ def test_lbwf_mapper_maps_house(): element_numerical_value=None, element_text_value=None, quantity=1, - install_date=date(2009,4,1), + install_date=date(2009, 4, 1), remaining_life=26, element_comments="Source of Data = Codeman", ), - ] + ], ) mapper = LbwfMapper() @@ -175,7 +178,7 @@ def test_lbwf_mapper_maps_house(): expected_assets: List[AssetCondition] = [ AssetCondition( uprn=1, - element=Element.AHR_CAT, + element=LbwfElement.AHR_CAT, condition_description="General Needs", quantity=1, renewal_year=None, @@ -184,7 +187,7 @@ def test_lbwf_mapper_maps_house(): ), AssetCondition( uprn=1, - element=Element.FLVL, + element=LbwfElement.FLVL, condition_description="Ground Floor", quantity=1, renewal_year=None, @@ -193,7 +196,7 @@ def test_lbwf_mapper_maps_house(): ), AssetCondition( uprn=1, - element=Element.ASBESTOS, + element=LbwfElement.ASBESTOS, condition_description="Yes", quantity=None, renewal_year=None, @@ -202,7 +205,7 @@ def test_lbwf_mapper_maps_house(): ), AssetCondition( uprn=1, - element=Element.INTBTHRLOC, + element=LbwfElement.INTBTHRLOC, condition_description="Bathroom on Entrance Level in Property", quantity=1, renewal_year=None, @@ -211,7 +214,7 @@ def test_lbwf_mapper_maps_house(): ), AssetCondition( uprn=1, - element=Element.INTCHEXTNT, + element=LbwfElement.INTCHEXTNT, condition_description="No Central Heating in Property", quantity=1, renewal_year=None, @@ -220,7 +223,7 @@ def test_lbwf_mapper_maps_house(): ), AssetCondition( uprn=1, - element=Element.HHSRSFIRE, + element=LbwfElement.HHSRSFIRE, condition_description="Category 4 - Typical Risk", quantity=1, renewal_year=None, @@ -229,18 +232,19 @@ def test_lbwf_mapper_maps_house(): ), AssetCondition( uprn=1, - element=Element.EXTWALLFN1, + element=LbwfElement.EXTWALLFN1, condition_description="Render or Pebbledash Wall Finish 1 in External Area", quantity=1, renewal_year=2052, source="Source of Data = Codeman", - install_date=date(2009,4,1), + install_date=date(2009, 4, 1), ), - ] # act - actual_assets: List[AssetCondition] = mapper.map_asset_conditions_for_property(lbwf_house, survey_year) + actual_assets: List[AssetCondition] = mapper.map_asset_conditions_for_property( + lbwf_house, survey_year + ) # assert - assert actual_assets == expected_assets \ No newline at end of file + assert actual_assets == expected_assets diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py index fc70b015..2d2446e5 100644 --- a/backend/condition/tests/mapping/test_peabody_mapper.py +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -1,11 +1,14 @@ from datetime import datetime from backend.condition.domain.mapping.peabody_mapper import PeabodyMapper -from backend.condition.domain.element import Element -from backend.condition.parsing.records.peabody.peabody_asset_condition import PeabodyAssetCondition +from backend.condition.domain.lbwf_element import LbwfElement +from backend.condition.parsing.records.peabody.peabody_asset_condition import ( + PeabodyAssetCondition, +) from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty from backend.condition.domain.asset_condition import AssetCondition + def test_peabody_mapper_maps_property(): # arrange peabody_property = PeabodyProperty( @@ -27,12 +30,12 @@ def test_peabody_mapper_maps_property(): renewal_cost=500, cloned="N", lo_type_code=1, - condition_survey_date=datetime(2024,2,15,0,0,0), + condition_survey_date=datetime(2024, 2, 15, 0, 0, 0), ) - ] + ], ) # act # assert - assert False #temp \ No newline at end of file + assert False # temp From d5d9a16bd97ae00f76e212f1bc01210626a89717 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 14:10:02 +0000 Subject: [PATCH 42/74] added missing key --- backend/Property.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/Property.py b/backend/Property.py index 0df29405..88bee6dc 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -1262,6 +1262,7 @@ class Property: "biodiesel": "Smokeless Fuel", "b30d": "B30K Biofuel", "coal": "Coal", + "oil": "Oil" } self.heating_energy_source = list({ From 03fb727994672c9d389c7c44a129ab2679c4705d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 15:00:58 +0000 Subject: [PATCH 43/74] =?UTF-8?q?Remodel=20dataclasses=20map=20from=20LBWF?= =?UTF-8?q?=20objects=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/domain/aspect_type.py | 20 +++ backend/condition/domain/asset_condition.py | 25 ++- backend/condition/domain/element.py | 152 ++++++++++++++++++ .../tests/mapping/test_lbwf_mapper.py | 108 ++++++++++--- 4 files changed, 274 insertions(+), 31 deletions(-) create mode 100644 backend/condition/domain/aspect_type.py create mode 100644 backend/condition/domain/element.py diff --git a/backend/condition/domain/aspect_type.py b/backend/condition/domain/aspect_type.py new file mode 100644 index 00000000..45d0f24b --- /dev/null +++ b/backend/condition/domain/aspect_type.py @@ -0,0 +1,20 @@ +from enum import Enum + + +class AspectType(str, Enum): + MATERIAL = "material" + CONDITION = "condition" + TYPE = "type" + CONFIGURATION = "configuration" + PRESENCE = "presence" + RISK = "risk" + SEVERITY = "severity" + LOCATION = "location" + FINISH = "finish" + INSULATION = "insulation" + POINTING = "pointing" + SPALLING = "spalling" + LINTELS = "lintels" + CLADDING = "cladding" + CATEGORY = "category" + QUANTITY = "quantity" diff --git a/backend/condition/domain/asset_condition.py b/backend/condition/domain/asset_condition.py index a489090f..cd57d9ff 100644 --- a/backend/condition/domain/asset_condition.py +++ b/backend/condition/domain/asset_condition.py @@ -1,16 +1,27 @@ from dataclasses import dataclass -from typing import Optional from datetime import date +from typing import Optional +from xml.dom.minidom import Element -from backend.condition.domain.lbwf_element import LbwfElement +from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.element import Element @dataclass class AssetCondition: uprn: int - element: LbwfElement # TODO: should HHSRS elements be handled differently? - condition_description: str # TODO: this probably needs to be some sort of enum so it's searchable/filterable on the frontend. Could be hard to map from string though - quantity: int - renewal_year: Optional[int] = None - source: Optional[str] = None + + element: Element + aspect_type: AspectType + + value: Optional[str] = None + + quantity: Optional[int] = None install_date: Optional[date] = None + renewal_year: Optional[int] = None + + element_instance: Optional[int] = None + element_location: Optional[str] = None + + source_system: Optional[str] = None + comments: Optional[str] = None diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py new file mode 100644 index 00000000..d9698ddf --- /dev/null +++ b/backend/condition/domain/element.py @@ -0,0 +1,152 @@ +from enum import Enum + + +class Element(str, Enum): + + # ====================== + # PROPERTY / GENERAL + # ====================== + PROPERTY_TYPE = "property_type" + PROPERTY_CONSTRUCTION_TYPE = "property_construction_type" + PROPERTY_CLASSIFICATION = "property_classification" + PROPERTY_AGE_BAND = "property_age_band" + STOREY_COUNT = "storey_count" + FLOOR_LEVEL_FRONT_DOOR = "floor_level_front_door" + ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register" + ASBESTOS = "asbestos" + + # ====================== + # EXTERNAL – ROOF + # ====================== + ROOF_COVERING = "roof_covering" + ROOF_STRUCTURE = "roof_structure" + ROOF_CHIMNEY = "roof_chimney" + ROOF_FASCIA = "roof_fascia" + ROOF_SOFFIT = "roof_soffit" + RAINWATER_GOODS = "rainwater_goods" + ROOF_PORCH_CANOPY = "roof_porch_canopy" + LOFT_INSULATION = "loft_insulation" + + # ====================== + # EXTERNAL – WALLS + # ====================== + EXTERNAL_WALL = "external_wall" + + # ====================== + # EXTERNAL – WINDOWS + # ====================== + WINDOWS = "windows" + COMMUNAL_WINDOWS = "communal_windows" + SECONDARY_GLAZING = "secondary_glazing" + + # ====================== + # EXTERNAL – DOORS + # ====================== + FRONT_DOOR = "front_door" + REAR_DOOR = "rear_door" + STORE_DOOR = "store_door" + GARAGE_DOOR = "garage_door" + COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door" + + # ====================== + # EXTERNAL – AREAS + # ====================== + PATHS_AND_HARDSTANDINGS = "paths_and_hardstandings" + PARKING_AREAS = "parking_areas" + BOUNDARY_WALLS = "boundary_walls" + FENCING = "fencing" + GATES = "gates" + RETAINING_WALLS = "retaining_walls" + PRIVATE_BALCONY = "private_balcony" + BALCONY_BALUSTRADE = "balcony_balustrade" + OUTBUILDINGS = "outbuildings" + GARAGE_STRUCTURE = "garage_structure" + + # ====================== + # INTERNAL – KITCHEN + # ====================== + KITCHEN = "kitchen" + KITCHEN_SPACE_LAYOUT = "kitchen_space_layout" + TENANT_INSTALLED_KITCHEN = "tenant_installed_kitchen" + + # ====================== + # INTERNAL – BATHROOM + # ====================== + BATHROOM = "bathroom" + + # ====================== + # INTERNAL – HEATING / WATER + # ====================== + HEATING_BOILER = "heating_boiler" + HEATING_DISTRIBUTION = "heating_distribution" + HEATING_EXTENT = "heating_extent" + SECONDARY_HEATING = "secondary_heating" + HOT_WATER_SYSTEM = "hot_water_system" + COLD_WATER_STORAGE = "cold_water_storage" + PROGRAMMABLE_HEATING = "programmable_heating" + + # ====================== + # INTERNAL – ELECTRICS / FIRE + # ====================== + ELECTRICAL_WIRING = "electrical_wiring" + CONSUMER_UNIT = "consumer_unit" + SMOKE_DETECTION = "smoke_detection" + HEAT_DETECTION = "heat_detection" + CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection" + FIRE_DOOR_RATING = "fire_door_rating" + + # ====================== + # COMMUNAL SYSTEMS + # ====================== + COMMUNAL_HEATING = "communal_heating" + COMMUNAL_BOILER = "communal_boiler" + COMMUNAL_ELECTRICS = "communal_electrics" + COMMUNAL_FIRE_ALARM = "communal_fire_alarm" + COMMUNAL_EMERGENCY_LIGHTING = "communal_emergency_lighting" + COMMUNAL_LIFT = "communal_lift" + COMMUNAL_DOOR_ENTRY = "communal_door_entry" + COMMUNAL_CCTV = "communal_cctv" + COMMUNAL_BIN_STORE = "communal_bin_store" + COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute" + + # ========================================================== + # HHSRS – ALL 29 HAZARDS + # ========================================================== + + # --- Physiological requirements (4) + HHSRS_DAMP_AND_MOULD = "hhsrs_damp_and_mould" + HHSRS_EXCESS_COLD = "hhsrs_excess_cold" + HHSRS_EXCESS_HEAT = "hhsrs_excess_heat" + HHSRS_ASBESTOS_AND_MMF = "hhsrs_asbestos_and_mmf" + + # --- Psychological requirements (4) + HHSRS_CROWDING_AND_SPACE = "hhsrs_crowding_and_space" + HHSRS_ENTRY_BY_INTRUDERS = "hhsrs_entry_by_intruders" + HHSRS_LIGHTING = "hhsrs_lighting" + HHSRS_NOISE = "hhsrs_noise" + + # --- Protection against infection (6) + HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE = "hhsrs_domestic_hygiene_pests_refuse" + HHSRS_FOOD_SAFETY = "hhsrs_food_safety" + HHSRS_PERSONAL_HYGIENE_SANITATION = "hhsrs_personal_hygiene_sanitation" + HHSRS_WATER_SUPPLY = "hhsrs_water_supply" + HHSRS_FALLS_ASSOCIATED_WITH_BATHS = "hhsrs_falls_associated_with_baths" + HHSRS_SURFACES_MOULD = "hhsrs_surfaces_mould" + + # --- Protection against accidents (10) + HHSRS_FALLS_ON_LEVEL_SURFACES = "hhsrs_falls_on_level_surfaces" + HHSRS_FALLS_ON_STAIRS = "hhsrs_falls_on_stairs" + HHSRS_FALLS_BETWEEN_LEVELS = "hhsrs_falls_between_levels" + HHSRS_ELECTRICAL_HAZARDS = "hhsrs_electrical_hazards" + HHSRS_FIRE = "hhsrs_fire" + HHSRS_FLAMES_HOT_SURFACES = "hhsrs_flames_hot_surfaces" + HHSRS_COLLISION_AND_ENTRAPMENT = "hhsrs_collision_and_entrapment" + HHSRS_EXPLOSION = "hhsrs_explosion" + HHSRS_STRUCTURAL_COLLAPSE = "hhsrs_structural_collapse" + HHSRS_UNSAFE_GAS = "hhsrs_unsafe_gas" + + # --- Protection against pollution (4) + HHSRS_CARBON_MONOXIDE = "hhsrs_carbon_monoxide" + HHSRS_LEAD = "hhsrs_lead" + HHSRS_RADIATION = "hhsrs_radiation" + HHSRS_UNCOMBUSTED_FUEL_GAS = "hhsrs_uncombusted_fuel_gas" diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index c007b575..f930fdb4 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -1,7 +1,10 @@ from typing import List +from xml.dom.minidom import Element import pytest from datetime import date +from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.element import Element from backend.condition.domain.mapping.lbwf_mapper import LbwfMapper from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( @@ -85,6 +88,27 @@ def test_lbwf_mapper_maps_house(): remaining_life=None, element_comments="Source of Data = ACT", ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="HHSRSASB", + element_code_description="Asbestos (and MMF)", + attribute_code="TYPRISK", + attribute_code_description="Category 4 - Typical Risk", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=None, + install_date=None, + remaining_life=None, + element_comments="Source of Data = ACT", + ), LbwfAssetCondition( prop_ref=100, domna=100, @@ -158,9 +182,9 @@ def test_lbwf_mapper_maps_house(): prop_sub_type="TERRACED", element_group="ASSETS", element_code="EXTWALLFN1", - element_code_description="Wall Finish 1 in External Area", - attribute_code="RENDERPBBL", - attribute_code_description="Render or Pebbledash Wall Finish 1 in External Area", + element_code_description="Wall Finish 2 in External Area", + attribute_code="SMTHRENDER", + attribute_code_description="Smooth Render Wall Finish 1 in External Area", element_date_value=None, element_numerical_value=None, element_text_value=None, @@ -178,66 +202,102 @@ def test_lbwf_mapper_maps_house(): expected_assets: List[AssetCondition] = [ AssetCondition( uprn=1, - element=LbwfElement.AHR_CAT, - condition_description="General Needs", + element=Element.ACCESSIBLE_HOUSING_REGISTER, + aspect_type=AspectType.CATEGORY, + element_instance=None, + value="General Needs", quantity=1, renewal_year=None, - source=None, install_date=None, + comments=None, ), AssetCondition( uprn=1, - element=LbwfElement.FLVL, - condition_description="Ground Floor", + element=Element.FLOOR_LEVEL_FRONT_DOOR, + aspect_type=AspectType.LOCATION, + element_instance=None, + value="Ground Floor", quantity=1, renewal_year=None, - source=None, install_date=None, + comments=None, ), AssetCondition( uprn=1, - element=LbwfElement.ASBESTOS, - condition_description="Yes", + element=Element.ASBESTOS, + aspect_type=AspectType.PRESENCE, + element_instance=None, + value="Yes", quantity=None, renewal_year=None, - source="Source of Data = ACT", install_date=None, + comments="Source of Data = ACT", ), AssetCondition( uprn=1, - element=LbwfElement.INTBTHRLOC, - condition_description="Bathroom on Entrance Level in Property", + element=Element.HHSRS_ASBESTOS_AND_MMF, + aspect_type=AspectType.RISK, + element_instance=None, + value="Category 4 - Typical Risk", + quantity=None, + renewal_year=None, + install_date=None, + comments="Source of Data = ACT", + ), + AssetCondition( + uprn=1, + element=Element.BATHROOM, + aspect_type=AspectType.LOCATION, + element_instance=None, + value="Bathroom on Entrance Level in Property", quantity=1, renewal_year=None, - source="Source of Data = Codeman", install_date=None, + comments="Source of Data = Codeman", ), AssetCondition( uprn=1, - element=LbwfElement.INTCHEXTNT, - condition_description="No Central Heating in Property", + element=Element.HEATING_EXTENT, + aspect_type=AspectType.CONFIGURATION, + element_instance=None, + value="No Central Heating in Property", quantity=1, renewal_year=None, - source="Source of Data = Codeman", install_date=None, + comments="Source of Data = Codeman", ), AssetCondition( uprn=1, - element=LbwfElement.HHSRSFIRE, - condition_description="Category 4 - Typical Risk", + element=Element.HHSRS_FIRE, + aspect_type=AspectType.RISK, + element_instance=None, + value="Category 4 - Typical Risk", quantity=1, renewal_year=None, - source="Source of Data = Morgan Sindall", install_date=None, + comments="Source of Data = Morgan Sindall", ), AssetCondition( uprn=1, - element=LbwfElement.EXTWALLFN1, - condition_description="Render or Pebbledash Wall Finish 1 in External Area", + element=Element.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + element_instance=1, + value="Render or Pebbledash", quantity=1, renewal_year=2052, - source="Source of Data = Codeman", install_date=date(2009, 4, 1), + comments="Source of Data = Codeman", + ), + AssetCondition( + uprn=1, + element=Element.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + element_instance=2, + value="Smooth Render Wall Finish 1 in External Area", + quantity=1, + renewal_year=2052, + install_date=date(2009, 4, 1), + comments="Source of Data = Codeman", ), ] From fa72d162395f3e70c34600757afda49c98cacdd1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 15:21:31 +0000 Subject: [PATCH 44/74] small tidies --- .../condition/domain/mapping/{ => lbwf}/lbwf_mapper.py | 0 .../domain/mapping/{ => peabody}/peabody_mapper.py | 1 - backend/condition/parsing/factory.py | 8 +++++--- backend/condition/processor.py | 5 +++-- backend/condition/tests/mapping/test_lbwf_mapper.py | 3 +-- backend/condition/tests/mapping/test_peabody_mapper.py | 3 +-- 6 files changed, 10 insertions(+), 10 deletions(-) rename backend/condition/domain/mapping/{ => lbwf}/lbwf_mapper.py (100%) rename backend/condition/domain/mapping/{ => peabody}/peabody_mapper.py (90%) diff --git a/backend/condition/domain/mapping/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py similarity index 100% rename from backend/condition/domain/mapping/lbwf_mapper.py rename to backend/condition/domain/mapping/lbwf/lbwf_mapper.py diff --git a/backend/condition/domain/mapping/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py similarity index 90% rename from backend/condition/domain/mapping/peabody_mapper.py rename to backend/condition/domain/mapping/peabody/peabody_mapper.py index 4c647380..8413b888 100644 --- a/backend/condition/domain/mapping/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -1,7 +1,6 @@ from typing import Any, List, Optional from backend.condition.domain.asset_condition import AssetCondition -from backend.condition.domain.lbwf_element import LbwfElement from backend.condition.domain.mapping.mapper import Mapper from backend.condition.parsing.records.peabody.peabody_asset_condition import ( PeabodyAssetCondition, diff --git a/backend/condition/parsing/factory.py b/backend/condition/parsing/factory.py index 3a28df78..7233a1df 100644 --- a/backend/condition/parsing/factory.py +++ b/backend/condition/parsing/factory.py @@ -1,21 +1,23 @@ -from backend.condition.domain.mapping.lbwf_mapper import LbwfMapper +from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper from backend.condition.domain.mapping.mapper import Mapper from backend.condition.file_type import FileType from backend.condition.parsing.parser import Parser from backend.condition.parsing.lbwf_parser import LbwfParser from backend.condition.parsing.peabody_parser import PeabodyParser + def select_parser(file_type: FileType) -> Parser: if file_type is FileType.LBWF: return LbwfParser() - + if file_type is FileType.Peabody: return PeabodyParser() raise ValueError("Unrecognised file type, unable to instantiate Parser") + def select_mapper(file_type: FileType) -> Mapper: if file_type is FileType.LBWF: return LbwfMapper() - + raise ValueError("Unrecognised file type, unable to instantiate Mapper") diff --git a/backend/condition/processor.py b/backend/condition/processor.py index cc44e38a..a48e22f4 100644 --- a/backend/condition/processor.py +++ b/backend/condition/processor.py @@ -8,6 +8,7 @@ from utils.logger import setup_logger from backend.condition.file_type import FileType, detect_file_type from backend.condition.parsing.factory import select_parser, select_mapper + def process_file(file_stream: BinaryIO, source_key: str) -> None: print(f"[processor] Received file: {source_key}") @@ -19,10 +20,10 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None: # Orchestration raw_properties: List[Any] = parser.parse(file_stream) - survey_year = datetime.now().year # TODO: get this from filepath or elsewhere + survey_year = datetime.now().year # TODO: get this from filepath or elsewhere assets: List[AssetCondition] = [] for p in raw_properties: assets.extend(mapper.map_asset_conditions_for_property(p, survey_year)) - print(assets) # temp \ No newline at end of file + print(assets) # temp diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index f930fdb4..f4266ac4 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -5,12 +5,11 @@ from datetime import date from backend.condition.domain.aspect_type import AspectType from backend.condition.domain.element import Element -from backend.condition.domain.mapping.lbwf_mapper import LbwfMapper +from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( LbwfAssetCondition, ) -from backend.condition.domain.lbwf_element import LbwfElement from backend.condition.domain.asset_condition import AssetCondition diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py index 2d2446e5..de027fe7 100644 --- a/backend/condition/tests/mapping/test_peabody_mapper.py +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -1,7 +1,6 @@ from datetime import datetime -from backend.condition.domain.mapping.peabody_mapper import PeabodyMapper -from backend.condition.domain.lbwf_element import LbwfElement +from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper from backend.condition.parsing.records.peabody.peabody_asset_condition import ( PeabodyAssetCondition, ) From 212d62e8358eaee6f722b90d295660074d08537c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 16:28:10 +0000 Subject: [PATCH 45/74] =?UTF-8?q?Map=20to=20new=20dataclasses=20from=20LBW?= =?UTF-8?q?F=20objects=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/domain/aspect_type.py | 9 + backend/condition/domain/asset_condition.py | 1 - backend/condition/domain/element.py | 18 +- .../domain/mapping/lbwf/lbwf_element_map.py | 351 ++++++++++++++++++ .../domain/mapping/lbwf/lbwf_mapper.py | 25 +- .../tests/mapping/test_lbwf_mapper.py | 36 +- 6 files changed, 417 insertions(+), 23 deletions(-) create mode 100644 backend/condition/domain/mapping/lbwf/lbwf_element_map.py diff --git a/backend/condition/domain/aspect_type.py b/backend/condition/domain/aspect_type.py index 45d0f24b..0f9a406a 100644 --- a/backend/condition/domain/aspect_type.py +++ b/backend/condition/domain/aspect_type.py @@ -5,6 +5,7 @@ class AspectType(str, Enum): MATERIAL = "material" CONDITION = "condition" TYPE = "type" + AREA = "area" CONFIGURATION = "configuration" PRESENCE = "presence" RISK = "risk" @@ -18,3 +19,11 @@ class AspectType(str, Enum): CLADDING = "cladding" CATEGORY = "category" QUANTITY = "quantity" + ADEQUACY = "adequacy" + RATING = "rating" + STRATEGY = "strategy" + EXTENT = "extent" + DISTRIBUTION = "distribution" + STRUCTURE = "structure" + COVERING = "covering" + FIRE_RATING = "fire_rating" diff --git a/backend/condition/domain/asset_condition.py b/backend/condition/domain/asset_condition.py index cd57d9ff..1b157a6b 100644 --- a/backend/condition/domain/asset_condition.py +++ b/backend/condition/domain/asset_condition.py @@ -21,7 +21,6 @@ class AssetCondition: renewal_year: Optional[int] = None element_instance: Optional[int] = None - element_location: Optional[str] = None source_system: Optional[str] = None comments: Optional[str] = None diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index d9698ddf..e082bd4f 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -6,7 +6,7 @@ class Element(str, Enum): # ====================== # PROPERTY / GENERAL # ====================== - PROPERTY_TYPE = "property_type" + PROPERTY = "property" PROPERTY_CONSTRUCTION_TYPE = "property_construction_type" PROPERTY_CLASSIFICATION = "property_classification" PROPERTY_AGE_BAND = "property_age_band" @@ -14,18 +14,15 @@ class Element(str, Enum): FLOOR_LEVEL_FRONT_DOOR = "floor_level_front_door" ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register" ASBESTOS = "asbestos" + QUALITY_STANDARD = "quality_standard" # ====================== # EXTERNAL – ROOF # ====================== - ROOF_COVERING = "roof_covering" - ROOF_STRUCTURE = "roof_structure" - ROOF_CHIMNEY = "roof_chimney" - ROOF_FASCIA = "roof_fascia" - ROOF_SOFFIT = "roof_soffit" + ROOF = "roof" RAINWATER_GOODS = "rainwater_goods" - ROOF_PORCH_CANOPY = "roof_porch_canopy" LOFT_INSULATION = "loft_insulation" + PORCH_CANOPY = "porch_canopy" # ====================== # EXTERNAL – WALLS @@ -35,13 +32,14 @@ class Element(str, Enum): # ====================== # EXTERNAL – WINDOWS # ====================== - WINDOWS = "windows" + EXTERNAL_WINDOWS = "external_windows" COMMUNAL_WINDOWS = "communal_windows" SECONDARY_GLAZING = "secondary_glazing" # ====================== # EXTERNAL – DOORS # ====================== + EXTERNAL_DOOR = "external_door" FRONT_DOOR = "front_door" REAR_DOOR = "rear_door" STORE_DOOR = "store_door" @@ -84,6 +82,9 @@ class Element(str, Enum): HOT_WATER_SYSTEM = "hot_water_system" COLD_WATER_STORAGE = "cold_water_storage" PROGRAMMABLE_HEATING = "programmable_heating" + HEATING_SYSTEM = "heating_system" + BOILER_FUEL = "boiler_fuel" + WATER_HEATING = "water_heating" # ====================== # INTERNAL – ELECTRICS / FIRE @@ -94,6 +95,7 @@ class Element(str, Enum): HEAT_DETECTION = "heat_detection" CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection" FIRE_DOOR_RATING = "fire_door_rating" + FIRE_RISK_ASSESSMENT = "fire" # ====================== # COMMUNAL SYSTEMS diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py new file mode 100644 index 00000000..6927e2fd --- /dev/null +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -0,0 +1,351 @@ +from dataclasses import dataclass +from typing import Optional + +from backend.condition.domain.element import Element +from backend.condition.domain.aspect_type import AspectType + + +@dataclass(frozen=True) +class LbwfElementMapping: + element: Element + aspect_type: AspectType + element_instance: Optional[int] = None + + +LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { + # ========================================================== + # PROPERTY / GENERAL + # ========================================================== + "AHR_CAT": LbwfElementMapping( + element=Element.ACCESSIBLE_HOUSING_REGISTER, + aspect_type=AspectType.CATEGORY, + ), + "ASSETSAREA": LbwfElementMapping( + element=Element.PROPERTY, + aspect_type=AspectType.AREA, + ), + # "DECNTHMINC": LbwfElementMapping( + # element=Element.DECENT_HOMES, + # aspect_type=AspectType.INCLUSION, + # ), # Ignore this one + "QUALITYSTD": LbwfElementMapping( + element=Element.QUALITY_STANDARD, + aspect_type=AspectType.TYPE, + ), + "EXTSTOREY": LbwfElementMapping( + element=Element.PROPERTY, + aspect_type=AspectType.CONFIGURATION, + ), + "FLVL": LbwfElementMapping( + element=Element.FLOOR_LEVEL_FRONT_DOOR, + aspect_type=AspectType.LOCATION, + ), + # ========================================================== + # ASBESTOS (NON-HHSRS RECORD) + # ========================================================== + "ASBESTOS": LbwfElementMapping( + element=Element.ASBESTOS, + aspect_type=AspectType.PRESENCE, + ), + # ========================================================== + # INTERNAL – BATHROOMS & KITCHENS + # ========================================================== + "INTBTHRLOC": LbwfElementMapping( + element=Element.BATHROOM, + aspect_type=AspectType.LOCATION, + ), + "INTBTHADEQ": LbwfElementMapping( + element=Element.BATHROOM, + aspect_type=AspectType.ADEQUACY, + ), + "INTKITADEQ": LbwfElementMapping( + element=Element.KITCHEN, + aspect_type=AspectType.ADEQUACY, + ), + "INTCKRLOC": LbwfElementMapping( + element=Element.KITCHEN, + aspect_type=AspectType.LOCATION, + ), + # ========================================================== + # INTERNAL – HEATING + # ========================================================== + "INTCHEXTNT": LbwfElementMapping( + element=Element.HEATING_EXTENT, + aspect_type=AspectType.CONFIGURATION, + ), + "INTCHDIST": LbwfElementMapping( + element=Element.HEATING_DISTRIBUTION, + aspect_type=AspectType.TYPE, + ), + "INTCHBLR": LbwfElementMapping( + element=Element.HEATING_BOILER, + aspect_type=AspectType.TYPE, + ), + # ========================================================== + # INTERNAL – FIRE + # ========================================================== + "FRARISKRTG": LbwfElementMapping( + element=Element.FIRE_RISK_ASSESSMENT, + aspect_type=AspectType.RATING, + ), + "FRATYPE": LbwfElementMapping( + element=Element.FIRE_RISK_ASSESSMENT, + aspect_type=AspectType.TYPE, + ), + "FRAEVACSTR": LbwfElementMapping( + element=Element.FIRE_RISK_ASSESSMENT, + aspect_type=AspectType.STRATEGY, + ), + "INTSMKDET": LbwfElementMapping( + element=Element.SMOKE_DETECTION, + aspect_type=AspectType.PRESENCE, + ), + "INTCHEXTNT": LbwfElementMapping( + element=Element.HEATING_SYSTEM, + aspect_type=AspectType.EXTENT, + ), + # ========================================================== + # HEATING & SERVICES + # ========================================================== + "INTBOILERF": LbwfElementMapping( + element=Element.BOILER_FUEL, + aspect_type=AspectType.TYPE, + ), + "INTHTDISYS": LbwfElementMapping( + element=Element.HEATING_SYSTEM, + aspect_type=AspectType.DISTRIBUTION, + ), + "INTWTRHTNG": LbwfElementMapping( + element=Element.WATER_HEATING, + aspect_type=AspectType.TYPE, + ), + # ========================================================== + # EXTERNAL – WALLS (INSTANCED) + # ========================================================== + "EXTWALLSTR": LbwfElementMapping( + element=Element.EXTERNAL_WALL, + aspect_type=AspectType.STRUCTURE, + element_instance=1, + ), + "EXTWALLFN1": LbwfElementMapping( + element=Element.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + element_instance=1, + ), + "EXTWALLFN2": LbwfElementMapping( + element=Element.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + element_instance=2, + ), + "EXTWALLINS": LbwfElementMapping( + element=Element.EXTERNAL_WALL, + aspect_type=AspectType.INSULATION, + ), + "EXTWALLSPL": LbwfElementMapping( + element=Element.EXTERNAL_WALL, + aspect_type=AspectType.CONDITION, + ), + # ========================================================== + # EXTERNAL – ROOFS (INSTANCED) + # ========================================================== + "EXTRFSTR1": LbwfElementMapping( + element=Element.ROOF, + aspect_type=AspectType.STRUCTURE, + element_instance=1, + ), + "EXTRFSTR2": LbwfElementMapping( + element=Element.ROOF, + aspect_type=AspectType.STRUCTURE, + element_instance=2, + ), + "EXTRFSTR3": LbwfElementMapping( + element=Element.ROOF, + aspect_type=AspectType.STRUCTURE, + element_instance=3, + ), + "EXTROOF1": LbwfElementMapping( + element=Element.ROOF, + aspect_type=AspectType.COVERING, + element_instance=1, + ), + "EXTROOF2": LbwfElementMapping( + element=Element.ROOF, + aspect_type=AspectType.COVERING, + element_instance=2, + ), + "EXTROOF3": LbwfElementMapping( + element=Element.ROOF, + aspect_type=AspectType.COVERING, + element_instance=3, + ), + # ========================================================== + # EXTERNAL – DOORS & WINDOWS + # ========================================================== + "INTFRDOOR": LbwfElementMapping( + element=Element.EXTERNAL_DOOR, + aspect_type=AspectType.TYPE, + ), + "INTFRDRFRR": LbwfElementMapping( + element=Element.EXTERNAL_DOOR, + aspect_type=AspectType.FIRE_RATING, + ), + "EXTBKSDDR1": LbwfElementMapping( + element=Element.EXTERNAL_DOOR, + aspect_type=AspectType.TYPE, + element_instance=1, + ), + "EXTBKSDDR2": LbwfElementMapping( + element=Element.EXTERNAL_DOOR, + aspect_type=AspectType.TYPE, + element_instance=2, + ), + "INTWDWTYPE": LbwfElementMapping( + element=Element.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + ), + "EXTWNDWS1": LbwfElementMapping( + element=Element.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + element_instance=1, + ), + "EXTWNDWS2": LbwfElementMapping( + element=Element.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + element_instance=2, + ), + # ========================================================== + # HHSRS – PHYSIOLOGICAL REQUIREMENTS + # ========================================================== + "HHSRSDAMP": LbwfElementMapping( + element=Element.HHSRS_DAMP_AND_MOULD, + aspect_type=AspectType.RISK, + ), + "HHSRSCOLD": LbwfElementMapping( + element=Element.HHSRS_EXCESS_COLD, + aspect_type=AspectType.RISK, + ), + "HHSRSHEAT": LbwfElementMapping( + element=Element.HHSRS_EXCESS_HEAT, + aspect_type=AspectType.RISK, + ), + "HHSRSASB": LbwfElementMapping( + element=Element.HHSRS_ASBESTOS_AND_MMF, + aspect_type=AspectType.RISK, + ), + # ========================================================== + # HHSRS – PSYCHOLOGICAL REQUIREMENTS + # ========================================================== + "HHSRSCROWD": LbwfElementMapping( + element=Element.HHSRS_CROWDING_AND_SPACE, + aspect_type=AspectType.RISK, + ), + "HHSRSENTRY": LbwfElementMapping( + element=Element.HHSRS_ENTRY_BY_INTRUDERS, + aspect_type=AspectType.RISK, + ), + "HHSRSENTRP": LbwfElementMapping( # collision / entrapment + element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, + aspect_type=AspectType.RISK, + ), + "HHSRSLIGHT": LbwfElementMapping( + element=Element.HHSRS_LIGHTING, + aspect_type=AspectType.RISK, + ), + "HHSRSNOISE": LbwfElementMapping( + element=Element.HHSRS_NOISE, + aspect_type=AspectType.RISK, + ), + # ========================================================== + # HHSRS – PROTECTION AGAINST INFECTION + # ========================================================== + "HHSRSDOMES": LbwfElementMapping( + element=Element.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, + aspect_type=AspectType.RISK, + ), + "HHSRSFOOD": LbwfElementMapping( + element=Element.HHSRS_FOOD_SAFETY, + aspect_type=AspectType.RISK, + ), + "HHSRSPERS": LbwfElementMapping( + element=Element.HHSRS_PERSONAL_HYGIENE_SANITATION, + aspect_type=AspectType.RISK, + ), + "HHSRSWATER": LbwfElementMapping( + element=Element.HHSRS_WATER_SUPPLY, + aspect_type=AspectType.RISK, + ), + "HHSRSFBATH": LbwfElementMapping( + element=Element.HHSRS_FALLS_ASSOCIATED_WITH_BATHS, + aspect_type=AspectType.RISK, + ), + "HHSRSPOSI": LbwfElementMapping( + element=Element.HHSRS_SURFACES_MOULD, + aspect_type=AspectType.RISK, + ), + # ========================================================== + # HHSRS – PROTECTION AGAINST ACCIDENTS + # ========================================================== + "HHSRSFLEVE": LbwfElementMapping( + element=Element.HHSRS_FALLS_ON_LEVEL_SURFACES, + aspect_type=AspectType.RISK, + ), + "HHSRSFSTAI": LbwfElementMapping( + element=Element.HHSRS_FALLS_ON_STAIRS, + aspect_type=AspectType.RISK, + ), + "HHSRSFBETW": LbwfElementMapping( + element=Element.HHSRS_FALLS_BETWEEN_LEVELS, + aspect_type=AspectType.RISK, + ), + "HHSRSELEC": LbwfElementMapping( + element=Element.HHSRS_ELECTRICAL_HAZARDS, + aspect_type=AspectType.RISK, + ), + "HHSRSFIRE": LbwfElementMapping( + element=Element.HHSRS_FIRE, + aspect_type=AspectType.RISK, + ), + "HHSRSFLAME": LbwfElementMapping( + element=Element.HHSRS_FLAMES_HOT_SURFACES, + aspect_type=AspectType.RISK, + ), + "HHSRSEXPLO": LbwfElementMapping( + element=Element.HHSRS_EXPLOSION, + aspect_type=AspectType.RISK, + ), + "HHSRSSTRUC": LbwfElementMapping( + element=Element.HHSRS_STRUCTURAL_COLLAPSE, + aspect_type=AspectType.RISK, + ), + # ========================================================== + # HHSRS – PROTECTION AGAINST POLLUTION + # ========================================================== + "HHSRSCO": LbwfElementMapping( + element=Element.HHSRS_CARBON_MONOXIDE, + aspect_type=AspectType.RISK, + ), + "HHSRSFUEL": LbwfElementMapping( + element=Element.HHSRS_UNSAFE_GAS, + aspect_type=AspectType.RISK, + ), + "HHSRSNO2": LbwfElementMapping( + element=Element.HHSRS_UNCOMBUSTED_FUEL_GAS, + aspect_type=AspectType.RISK, + ), + "HHSRSSO2": LbwfElementMapping( + element=Element.HHSRS_UNCOMBUSTED_FUEL_GAS, + aspect_type=AspectType.RISK, + ), + "HHSRSLEAD": LbwfElementMapping( + element=Element.HHSRS_LEAD, + aspect_type=AspectType.RISK, + ), + "HHSRSRADIA": LbwfElementMapping( + element=Element.HHSRS_RADIATION, + aspect_type=AspectType.RISK, + ), + "HHSRSBIOC": LbwfElementMapping( + element=Element.HHSRS_UNCOMBUSTED_FUEL_GAS, + aspect_type=AspectType.RISK, + ), +} diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py index dcd1d748..01f48a35 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py @@ -1,7 +1,11 @@ from typing import Any, List, Optional from backend.condition.domain.asset_condition import AssetCondition -from backend.condition.domain.lbwf_element import LbwfElement +from backend.condition.domain.element import Element +from backend.condition.domain.mapping.lbwf.lbwf_element_map import ( + LbwfElementMapping, + LBWF_ELEMENT_MAP, +) from backend.condition.domain.mapping.mapper import Mapper from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( LbwfAssetCondition, @@ -26,7 +30,9 @@ class LbwfMapper(Mapper): uprn: int = client_data.uprn for raw_asset in client_data.assets: try: - element: LbwfElement = LbwfMapper._map_element(raw_asset.element_code) + element_mapping: LbwfElementMapping = LbwfMapper._map_element( + raw_asset.element_code + ) except: logger.warning( f"Unrecognised LBWF Asset Element Code: {raw_asset.element_code}. Skipping record" @@ -36,22 +42,25 @@ class LbwfMapper(Mapper): mapped_assets.append( AssetCondition( uprn=uprn, - element=element, - condition_description=raw_asset.attribute_code_description, + element=element_mapping.element, + aspect_type=element_mapping.aspect_type, + value=raw_asset.attribute_code_description, quantity=raw_asset.quantity, + install_date=raw_asset.install_date, renewal_year=LbwfMapper._calculate_renewal_year( raw_asset, survey_year ), - source=raw_asset.element_comments, - install_date=raw_asset.install_date, + element_instance=element_mapping.element_instance, + source_system=None, # Once we know the system name we'll set it here + comments=raw_asset.element_comments, ) ) return mapped_assets @staticmethod - def _map_element(lbwf_element_code: LbwfAssetCondition) -> LbwfElement: - return LbwfElement[lbwf_element_code] + def _map_element(lbwf_element_code: str) -> LbwfElementMapping: + return LBWF_ELEMENT_MAP[lbwf_element_code] @staticmethod def _calculate_renewal_year( diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index f4266ac4..918b6fea 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -181,9 +181,30 @@ def test_lbwf_mapper_maps_house(): prop_sub_type="TERRACED", element_group="ASSETS", element_code="EXTWALLFN1", + element_code_description="Wall Finish 1 in External Area", + attribute_code="SMTHRENDER", + attribute_code_description="Render or Pebbledash in External Area", + element_date_value=None, + element_numerical_value=None, + element_text_value=None, + quantity=1, + install_date=date(2009, 4, 1), + remaining_life=26, + element_comments="Source of Data = Codeman", + ), + LbwfAssetCondition( + prop_ref=100, + domna=100, + address="123 Fake Street, London, A10 1AB", + ownership="LBWF_OWNED", + prop_status="OCCP", + prop_type="HOU", + prop_sub_type="TERRACED", + element_group="ASSETS", + element_code="EXTWALLFN2", element_code_description="Wall Finish 2 in External Area", attribute_code="SMTHRENDER", - attribute_code_description="Smooth Render Wall Finish 1 in External Area", + attribute_code_description="Smooth Render Wall Finish 2 in External Area", element_date_value=None, element_numerical_value=None, element_text_value=None, @@ -256,8 +277,8 @@ def test_lbwf_mapper_maps_house(): ), AssetCondition( uprn=1, - element=Element.HEATING_EXTENT, - aspect_type=AspectType.CONFIGURATION, + element=Element.HEATING_SYSTEM, + aspect_type=AspectType.EXTENT, element_instance=None, value="No Central Heating in Property", quantity=1, @@ -281,7 +302,7 @@ def test_lbwf_mapper_maps_house(): element=Element.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=1, - value="Render or Pebbledash", + value="Render or Pebbledash in External Area", quantity=1, renewal_year=2052, install_date=date(2009, 4, 1), @@ -292,7 +313,7 @@ def test_lbwf_mapper_maps_house(): element=Element.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=2, - value="Smooth Render Wall Finish 1 in External Area", + value="Smooth Render Wall Finish 2 in External Area", quantity=1, renewal_year=2052, install_date=date(2009, 4, 1), @@ -306,4 +327,7 @@ def test_lbwf_mapper_maps_house(): ) # assert - assert actual_assets == expected_assets + assert len(actual_assets) == len(expected_assets) + + for i, (actual, expected) in enumerate(zip(actual_assets, expected_assets)): + assert actual == expected, f"Mismatch at index {i}" From 7f74c892e65acfd524c837dae1fe8b36b0496c4c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 22 Jan 2026 17:04:06 +0000 Subject: [PATCH 46/74] make a note of missing element codes and tidy up HHSRS --- backend/condition/domain/element.py | 26 ++-- .../domain/mapping/lbwf/lbwf_element_map.py | 133 +++++++++++------- 2 files changed, 96 insertions(+), 63 deletions(-) diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index e082bd4f..c8fb6167 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -115,27 +115,25 @@ class Element(str, Enum): # HHSRS – ALL 29 HAZARDS # ========================================================== - # --- Physiological requirements (4) HHSRS_DAMP_AND_MOULD = "hhsrs_damp_and_mould" HHSRS_EXCESS_COLD = "hhsrs_excess_cold" HHSRS_EXCESS_HEAT = "hhsrs_excess_heat" HHSRS_ASBESTOS_AND_MMF = "hhsrs_asbestos_and_mmf" - - # --- Psychological requirements (4) + HHSRS_BIOCIDES = "hhsrs_biocides" + HHSRS_CARBON_MONOXIDE = "hhsrs_carbon_monoxide" + HHSRS_LEAD = "hhsrs_lead" + HHSRS_RADIATION = "hhsrs_radiation" + HHSRS_UNCOMBUSTED_FUEL_GAS = "hhsrs_uncombusted_fuel_gas" + HHSRS_VOLATILE_ORGANIC_COMPOUNDS = "hhsrs_volatile_organic_compounds" HHSRS_CROWDING_AND_SPACE = "hhsrs_crowding_and_space" HHSRS_ENTRY_BY_INTRUDERS = "hhsrs_entry_by_intruders" HHSRS_LIGHTING = "hhsrs_lighting" HHSRS_NOISE = "hhsrs_noise" - - # --- Protection against infection (6) HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE = "hhsrs_domestic_hygiene_pests_refuse" HHSRS_FOOD_SAFETY = "hhsrs_food_safety" HHSRS_PERSONAL_HYGIENE_SANITATION = "hhsrs_personal_hygiene_sanitation" HHSRS_WATER_SUPPLY = "hhsrs_water_supply" HHSRS_FALLS_ASSOCIATED_WITH_BATHS = "hhsrs_falls_associated_with_baths" - HHSRS_SURFACES_MOULD = "hhsrs_surfaces_mould" - - # --- Protection against accidents (10) HHSRS_FALLS_ON_LEVEL_SURFACES = "hhsrs_falls_on_level_surfaces" HHSRS_FALLS_ON_STAIRS = "hhsrs_falls_on_stairs" HHSRS_FALLS_BETWEEN_LEVELS = "hhsrs_falls_between_levels" @@ -143,12 +141,8 @@ class Element(str, Enum): HHSRS_FIRE = "hhsrs_fire" HHSRS_FLAMES_HOT_SURFACES = "hhsrs_flames_hot_surfaces" HHSRS_COLLISION_AND_ENTRAPMENT = "hhsrs_collision_and_entrapment" - HHSRS_EXPLOSION = "hhsrs_explosion" + HHSRS_COLLISION_HAZARDS_LOW_HEADROOM = "hhsrs_collision_hazards_low_headroom" + HHSRS_EXPLOSIONS = "hhsrs_explosions" + HHSRS_ERGONOMICS = "hhsrs_ergonomics" HHSRS_STRUCTURAL_COLLAPSE = "hhsrs_structural_collapse" - HHSRS_UNSAFE_GAS = "hhsrs_unsafe_gas" - - # --- Protection against pollution (4) - HHSRS_CARBON_MONOXIDE = "hhsrs_carbon_monoxide" - HHSRS_LEAD = "hhsrs_lead" - HHSRS_RADIATION = "hhsrs_radiation" - HHSRS_UNCOMBUSTED_FUEL_GAS = "hhsrs_uncombusted_fuel_gas" + HHSRS_AMENITIES = "hhsrs_amenities" diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index 6927e2fd..da13a6c8 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -214,7 +214,7 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { element_instance=2, ), # ========================================================== - # HHSRS – PHYSIOLOGICAL REQUIREMENTS + # HHSRS # ========================================================== "HHSRSDAMP": LbwfElementMapping( element=Element.HHSRS_DAMP_AND_MOULD, @@ -232,9 +232,29 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { element=Element.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK, ), - # ========================================================== - # HHSRS – PSYCHOLOGICAL REQUIREMENTS - # ========================================================== + "HHSRSBIOCIDES": LbwfElementMapping( + element=Element.HHSRS_BIOCIDES, + aspect_type=AspectType.RISK, + ), + "HHSRSCO": LbwfElementMapping( + element=Element.HHSRS_CARBON_MONOXIDE, + aspect_type=AspectType.RISK, + ), + "HHSRSLEAD": LbwfElementMapping( + element=Element.HHSRS_LEAD, + aspect_type=AspectType.RISK, + ), + "HHSRSRADIA": LbwfElementMapping( + element=Element.HHSRS_RADIATION, + aspect_type=AspectType.RISK, + ), + "HHSRSFUEL": LbwfElementMapping( + element=Element.HHSRS_UNCOMBUSTED_FUEL_GAS, + aspect_type=AspectType.RISK, + ), + "HHSRSORGAN": LbwfElementMapping( + element=Element.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, aspect_type=AspectType.Risk + ), "HHSRSCROWD": LbwfElementMapping( element=Element.HHSRS_CROWDING_AND_SPACE, aspect_type=AspectType.RISK, @@ -243,10 +263,6 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { element=Element.HHSRS_ENTRY_BY_INTRUDERS, aspect_type=AspectType.RISK, ), - "HHSRSENTRP": LbwfElementMapping( # collision / entrapment - element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, - aspect_type=AspectType.RISK, - ), "HHSRSLIGHT": LbwfElementMapping( element=Element.HHSRS_LIGHTING, aspect_type=AspectType.RISK, @@ -255,9 +271,6 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { element=Element.HHSRS_NOISE, aspect_type=AspectType.RISK, ), - # ========================================================== - # HHSRS – PROTECTION AGAINST INFECTION - # ========================================================== "HHSRSDOMES": LbwfElementMapping( element=Element.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, aspect_type=AspectType.RISK, @@ -278,13 +291,6 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { element=Element.HHSRS_FALLS_ASSOCIATED_WITH_BATHS, aspect_type=AspectType.RISK, ), - "HHSRSPOSI": LbwfElementMapping( - element=Element.HHSRS_SURFACES_MOULD, - aspect_type=AspectType.RISK, - ), - # ========================================================== - # HHSRS – PROTECTION AGAINST ACCIDENTS - # ========================================================== "HHSRSFLEVE": LbwfElementMapping( element=Element.HHSRS_FALLS_ON_LEVEL_SURFACES, aspect_type=AspectType.RISK, @@ -309,43 +315,76 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { element=Element.HHSRS_FLAMES_HOT_SURFACES, aspect_type=AspectType.RISK, ), + "HHSRSENTRP": LbwfElementMapping( + element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, + aspect_type=AspectType.RISK, + ), "HHSRSEXPLO": LbwfElementMapping( - element=Element.HHSRS_EXPLOSION, + element=Element.HHSRS_EXPLOSIONS, aspect_type=AspectType.RISK, ), "HHSRSSTRUC": LbwfElementMapping( element=Element.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK, ), - # ========================================================== - # HHSRS – PROTECTION AGAINST POLLUTION - # ========================================================== - "HHSRSCO": LbwfElementMapping( - element=Element.HHSRS_CARBON_MONOXIDE, - aspect_type=AspectType.RISK, + "HHSRSCLOW": LbwfElementMapping( + element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.Risk ), - "HHSRSFUEL": LbwfElementMapping( - element=Element.HHSRS_UNSAFE_GAS, - aspect_type=AspectType.RISK, - ), - "HHSRSNO2": LbwfElementMapping( - element=Element.HHSRS_UNCOMBUSTED_FUEL_GAS, - aspect_type=AspectType.RISK, - ), - "HHSRSSO2": LbwfElementMapping( - element=Element.HHSRS_UNCOMBUSTED_FUEL_GAS, - aspect_type=AspectType.RISK, - ), - "HHSRSLEAD": LbwfElementMapping( - element=Element.HHSRS_LEAD, - aspect_type=AspectType.RISK, - ), - "HHSRSRADIA": LbwfElementMapping( - element=Element.HHSRS_RADIATION, - aspect_type=AspectType.RISK, - ), - "HHSRSBIOC": LbwfElementMapping( - element=Element.HHSRS_UNCOMBUSTED_FUEL_GAS, + "HHSRSPOSI": LbwfElementMapping( + element=Element.HHSRS_AMENITIES, aspect_type=AspectType.RISK, ), } + +# Unhandled: +# DECNTHMINC +# EICINSFREQ +# EXTBALCONY +# EXTBPOINTG +# EXTCHIMNEY +# EXTDRPKERB +# EXTDWNPTYP +# EXTEXTDECS +# EXTFASOFBR +# EXTGARDOOR +# EXTGARROOF +# EXTGARSTDR +# EXTGARSTRF +# EXTGARSTWD +# EXTGARWDWS +# EXTGUTRTYP +# EXTHARDSTD +# EXTINTDWNP +# EXTLINTELS +# EXTOUTBOH +# EXTPARKING +# EXTPCHCNPY +# EXTPTFRDR1 +# EXTSTRDOOR +# EXTSTRINSP +# EXTSTRROOF +# EXTSTRWDWS +# FFHHDAMP +# FFHHDRNWC +# FFHHHCWAT +# FFHHNEGLC +# FFHHNONAT +# FFHHNOVEN +# FFHHPRPCK +# FFHHUNLAY +# FFHHUNSTA +# INTACCRAMP +# INTADDWCW +# INTBTHREML +# INTCOMHTG +# INTELECTRC +# INTFLRLVL +# INTGASAVAI +# INTHEATREC +# INTHTIMP +# INTKITREML +# INTLOFTINS +# INTNSEINSL +# INTPROGHTG +# INTSTEPSFD +# INTTNTINST From 552047f85c6d58b5dfb973e5efe9c7281f2d76ce Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 22:56:58 +0000 Subject: [PATCH 47/74] Fixing errored case for calico run with missing data in floors --- asset_list/app.py | 23 ++++++------- backend/Property.py | 32 +++++++------------ backend/apis/GoogleSolarApi.py | 12 ++++--- .../epc_attributes/FloorAttributes.py | 4 +-- .../epc_attributes/WallAttributes.py | 3 +- etl/epc_clean/tests/test_floor_attributes.py | 18 ++++------- .../tests/test_hotwater_attributes.py | 7 ++++ .../tests/test_lighting_attributes.py | 4 +++ .../tests/test_mainfuel_attributes.py | 6 ++++ .../tests/test_mainheat_attributes.py | 19 +++++++++++ .../test_mainheat_controls_attributes.py | 9 ++++++ etl/epc_clean/tests/test_roof_attributes.py | 15 ++++++--- etl/epc_clean/tests/test_wall_attributes.py | 9 ++++++ etl/epc_clean/tests/test_window_attributes.py | 11 ++++--- recommendations/FloorRecommendations.py | 3 ++ 15 files changed, 116 insertions(+), 59 deletions(-) diff --git a/asset_list/app.py b/asset_list/app.py index c58eccd7..21a06a07 100644 --- a/asset_list/app.py +++ b/asset_list/app.py @@ -59,19 +59,19 @@ def app(): Property UPRN """ - data_folder = ("/Users/khalimconn-kowlessar/Documents/hestia/Warmfront/SCIS") - data_filename = "SCIS_Historic_Deemed_Combined_Workings.xlsx" - sheet_name = "SCIS" - postcode_column = 'POSTCODE' - address1_column = "NO" + data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Hackney" + data_filename = "Domna SHF Wave 3.xlsx" + sheet_name = "Domna Wave 3" + postcode_column = 'Postcode' + address1_column = "Address 1" address1_method = None fulladdress_column = None - address_cols_to_concat = ["NO", "Street / Block Name", "Town/Area"] + address_cols_to_concat = ["Address 1"] missing_postcodes_method = None landlord_year_built = None - landlord_os_uprn = None - landlord_property_type = "PROPERTY TYPE As per table emailed" - landlord_built_form = "PROPERTY TYPE As per table emailed" + landlord_os_uprn = "UPRN" + landlord_property_type = None + landlord_built_form = None landlord_wall_construction = None landlord_roof_construction = None landlord_heating_system = None @@ -492,5 +492,6 @@ def app(): asset_list.geographical_areas.to_excel(writer, sheet_name="Geographical Areas", index=False) # Store dupes - if not asset_list.duplicated_addresses.empty: - asset_list.duplicated_addresses.to_excel(writer, sheet_name="Duplicate Properties", index=False) + if asset_list.duplicated_addresses is not None: + if not asset_list.duplicated_addresses.empty: + asset_list.duplicated_addresses.to_excel(writer, sheet_name="Duplicate Properties", index=False) diff --git a/backend/Property.py b/backend/Property.py index 88bee6dc..fa607cfd 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -607,26 +607,19 @@ class Property: for description, attribute in cleaned.items(): - if self.data[description] in self.DATA_ANOMALY_MATCHES: - template = cleaned[description][0] - # Handling edge case for walls - fill_with = False if description == "walls-description" else None - fill_dict = dict(zip(template.keys(), [fill_with] * len(template))) - if description == "walls-description": - fill_dict["thermal_transmittance_unit"] = None - fill_dict["insulation_thickness"] = "none" + cleaner_cls = all_cleaner_map[description] - fill_dict.update( - { - "original_description": self.data[description], - "clean_description": self.data[description], - } - ) - setattr( - self, - self.ATTRIBUTE_MAP[description], - fill_dict, - ) + if self.data[description] in self.DATA_ANOMALY_MATCHES: + if description == "lighting-description": + cleaner_cls = cleaner_cls("", averages=None) + else: + cleaner_cls = cleaner_cls("") + fill_dict = { + "original_description": self.data[description], + "clean_description": self.data[description], + **cleaner_cls.process() + } + setattr(self, self.ATTRIBUTE_MAP[description], fill_dict) continue attributes = [ @@ -642,7 +635,6 @@ class Property: if len(attributes) == 0: # We attempt to perform the clean on the fly - cleaner_cls = all_cleaner_map[description] if description == "lighting-description": cleaner_cls = cleaner_cls(self.data[description], averages=None) else: diff --git a/backend/apis/GoogleSolarApi.py b/backend/apis/GoogleSolarApi.py index f7aa311f..bf07b5e5 100644 --- a/backend/apis/GoogleSolarApi.py +++ b/backend/apis/GoogleSolarApi.py @@ -404,9 +404,10 @@ class GoogleSolarApi: panel_performance["initial_ac_kwh_per_year"] = panel_performance["yearly_dc_energy"] * self.dc_to_ac_rate # Remove anything where the total ac energy is less than half of the array wattage - panel_performance = panel_performance[ - (panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_wattage"]) >= 0.5 - ] + # But - only where this is possible + wattage_filter = (panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_wattage"]) >= 0.5 + if any(wattage_filter): + panel_performance = panel_performance[wattage_filter] # 2) Calculate the liftime solar energy production panel_performance['lifetime_ac_kwh'] = panel_performance.apply( @@ -477,7 +478,10 @@ class GoogleSolarApi: } ) - roi_results = pd.DataFrame(roi_results) + roi_results = pd.DataFrame( + roi_results, + columns=["n_panels", "roi", "generation_value", "generation_deficit", "expected_payback_years", "surplus"] + ) panel_performance = panel_performance.merge(roi_results, how="left", on="n_panels") diff --git a/etl/epc_clean/epc_attributes/FloorAttributes.py b/etl/epc_clean/epc_attributes/FloorAttributes.py index cd1499c2..b0d2b361 100644 --- a/etl/epc_clean/epc_attributes/FloorAttributes.py +++ b/etl/epc_clean/epc_attributes/FloorAttributes.py @@ -113,8 +113,8 @@ class FloorAttributes(Definitions): if self.nodata: return { - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': True, - 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': True, 'is_solid': False, + 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': False, + 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, 'another_property_below': False, 'insulation_thickness': 'none', 'no_data': True } diff --git a/etl/epc_clean/epc_attributes/WallAttributes.py b/etl/epc_clean/epc_attributes/WallAttributes.py index 075dee96..3d92e7b3 100644 --- a/etl/epc_clean/epc_attributes/WallAttributes.py +++ b/etl/epc_clean/epc_attributes/WallAttributes.py @@ -147,9 +147,10 @@ class WallAttributes(Definitions): if self.nodata: for key in self.DEFAULT_KEYS: result[key] = False - + result["thermal_transmittance"] = None result["thermal_transmittance_unit"] = None result["insulation_thickness"] = "none" + result["is_park_home"] = False return result diff --git a/etl/epc_clean/tests/test_floor_attributes.py b/etl/epc_clean/tests/test_floor_attributes.py index 887cb689..a1f021e3 100644 --- a/etl/epc_clean/tests/test_floor_attributes.py +++ b/etl/epc_clean/tests/test_floor_attributes.py @@ -11,17 +11,6 @@ class TestCleanFloor: floor_attr = FloorAttributes(valid_description) assert floor_attr.description == valid_description.lower() - # Test initialization with an empty description - empty = FloorAttributes('') - assert empty.nodata - output = empty.process() - assert output == { - 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': True, - 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': True, - 'is_solid': False, 'another_property_below': False, 'insulation_thickness': 'none', - 'no_data': True - } - # Test initialization with a description that contains none of the keywords with pytest.raises(ValueError): FloorAttributes('description without keywords') @@ -37,6 +26,13 @@ class TestCleanFloor: # Ensure the output ordering is correct assert sorted(result.items()) == sorted(expected_result.items()) + def test_empty_str_description(self): + assert FloorAttributes("").process() == { + 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_assumed': False, + 'is_to_unheated_space': False, 'is_to_external_air': False, 'is_suspended': False, 'is_solid': False, + 'another_property_below': False, 'insulation_thickness': 'none', 'no_data': True + } + def test_invalid_description(self): # Test that invalid descriptions raise a ValueError invalid_descriptions = [ diff --git a/etl/epc_clean/tests/test_hotwater_attributes.py b/etl/epc_clean/tests/test_hotwater_attributes.py index 2809b805..ab0f6409 100644 --- a/etl/epc_clean/tests/test_hotwater_attributes.py +++ b/etl/epc_clean/tests/test_hotwater_attributes.py @@ -15,6 +15,13 @@ class TestHotWaterAttributes: with pytest.raises(ValueError): HotWaterAttributes('description without keywords') + def test_empty_str_input(self): + assert HotWaterAttributes("").process() == { + 'heater_type': None, 'system_type': None, 'thermostat_characteristics': None, 'heating_scope': None, + 'energy_recovery': None, 'tariff_type': None, 'extra_features': None, 'chp_systems': None, + 'distribution_system': None, 'no_system_present': None, 'assumed': None, 'appliance': None + } + @pytest.mark.parametrize( "test_case", hotwater_cases diff --git a/etl/epc_clean/tests/test_lighting_attributes.py b/etl/epc_clean/tests/test_lighting_attributes.py index f3c23e8f..e6171268 100644 --- a/etl/epc_clean/tests/test_lighting_attributes.py +++ b/etl/epc_clean/tests/test_lighting_attributes.py @@ -13,6 +13,10 @@ averages = [ class TestLightingAttributes: + + def test_empty_str(self): + assert LightingAttributes("", averages).process() == {'low_energy_proportion': None} + def test_no_lighting(self): lighting = LightingAttributes("no low energy lighting", averages) result = lighting.process() diff --git a/etl/epc_clean/tests/test_mainfuel_attributes.py b/etl/epc_clean/tests/test_mainfuel_attributes.py index bface6e2..ed60b24d 100644 --- a/etl/epc_clean/tests/test_mainfuel_attributes.py +++ b/etl/epc_clean/tests/test_mainfuel_attributes.py @@ -15,6 +15,12 @@ class TestMainHeatControlAttributes: with pytest.raises(ValueError): MainFuelAttributes('description without keywords') + def test_empty_str(self): + assert MainFuelAttributes("").process() == { + 'fuel_type': 'unknown', 'tariff_type': None, 'is_community': False, + 'no_individual_heating_or_community_network': False, 'complex_fuel_type': None + } + @pytest.mark.parametrize( "test_case", mainfuel_cases diff --git a/etl/epc_clean/tests/test_mainheat_attributes.py b/etl/epc_clean/tests/test_mainheat_attributes.py index d79c271a..5813c1cf 100644 --- a/etl/epc_clean/tests/test_mainheat_attributes.py +++ b/etl/epc_clean/tests/test_mainheat_attributes.py @@ -15,6 +15,25 @@ class TestMainHeatAttributes: with pytest.raises(ValueError): MainHeatAttributes('description without keywords') + def test_empty_str(self): + assert MainHeatAttributes("").process() == { + 'has_radiators': False, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False, + 'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': False, + 'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False, + 'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False, + 'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False, + 'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, + 'has_electric_heat_pump': False, 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, + 'has_exhaust_source_heat_pump': False, 'has_community_heat_pump': False, 'has_hot-water-only': False, + 'has_electric': False, 'has_mains_gas': False, 'has_wood_logs': False, 'has_coal': False, 'has_oil': False, + 'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': False, + 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False, 'has_mineral_and_wood': False, + 'has_dual_fuel_appliance': False, 'has_wood_chips': False, 'has_assumed': False, 'has_electricaire': False, + 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False + } + + assert set(list(MainHeatAttributes("").process().values())) == {False} + @pytest.mark.parametrize( "test_case", mainheat_cases diff --git a/etl/epc_clean/tests/test_mainheat_controls_attributes.py b/etl/epc_clean/tests/test_mainheat_controls_attributes.py index 7b114107..8826546b 100644 --- a/etl/epc_clean/tests/test_mainheat_controls_attributes.py +++ b/etl/epc_clean/tests/test_mainheat_controls_attributes.py @@ -15,6 +15,15 @@ class TestMainHeatControlAttributes: with pytest.raises(ValueError): MainheatControlAttributes('description without keywords') + def test_empty_str(self): + assert MainheatControlAttributes("").process() == { + 'thermostatic_control': False, 'charging_system': False, 'switch_system': False, 'no_control': False, + 'dhw_control': False, 'community_heating': False, 'multiple_room_thermostats': False, + 'auxiliary_systems': False, 'trvs': False, 'rate_control': False + } + + assert set(list(MainheatControlAttributes("").process().values())) == {False} + @pytest.mark.parametrize( "test_case", mainheat_control_cases diff --git a/etl/epc_clean/tests/test_roof_attributes.py b/etl/epc_clean/tests/test_roof_attributes.py index 481beedc..33c6a829 100644 --- a/etl/epc_clean/tests/test_roof_attributes.py +++ b/etl/epc_clean/tests/test_roof_attributes.py @@ -1,10 +1,10 @@ import pytest -from pathlib import Path from etl.epc_clean.tests.test_data.test_roof_attributes_cases import clean_roof_test_cases from etl.epc_clean.epc_attributes.RoofAttributes import RoofAttributes # For local testing +# from pathlib import Path # if __file__ == "": # input_data_path = Path("./model_data/tests/test_data/EpcClean_inputs.obj") # else: @@ -20,13 +20,18 @@ class TestRoofAttributes: floor_attr = RoofAttributes(valid_description) assert floor_attr.description == valid_description.lower() - # Test initialization with an empty description - ra = RoofAttributes('') - assert ra.nodata - with pytest.raises(ValueError): RoofAttributes('description without keywords') + def test_empty_str(self): + # Test initialization with an empty description + assert RoofAttributes('').process() == { + 'thermal_transmittance': False, 'thermal_transmittance_unit': False, 'is_pitched': False, + 'is_roof_room': False, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, + 'is_assumed': False, 'has_dwelling_above': False, 'is_valid': False, 'insulation_thickness': False + } + assert set(list(RoofAttributes('').process().values())) == {False} + def test_clean_roof(self): result = RoofAttributes('Pitched, 270 mm loft insulation').process() diff --git a/etl/epc_clean/tests/test_wall_attributes.py b/etl/epc_clean/tests/test_wall_attributes.py index 970dbd98..67e87bf5 100644 --- a/etl/epc_clean/tests/test_wall_attributes.py +++ b/etl/epc_clean/tests/test_wall_attributes.py @@ -56,3 +56,12 @@ class TestWallAttributes: raise Exception("Something went wong") # Ensure the output ordering is correct assert sorted(result.items()) == sorted(expected_result.items()) + + def test_empty_str(self): + assert WallAttributes("").process() == { + 'thermal_transmittance': None, 'thermal_transmittance_unit': None, 'is_cavity_wall': False, + 'is_filled_cavity': False, 'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False, + 'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False, + 'is_sandstone_or_limestone': False, 'insulation_thickness': 'none', 'external_insulation': False, + 'internal_insulation': False, "is_park_home": False + } diff --git a/etl/epc_clean/tests/test_window_attributes.py b/etl/epc_clean/tests/test_window_attributes.py index 46ebde45..baa421d1 100644 --- a/etl/epc_clean/tests/test_window_attributes.py +++ b/etl/epc_clean/tests/test_window_attributes.py @@ -11,15 +11,16 @@ class TestWindowAttributes: window_attr = WindowAttributes(valid_description) assert window_attr.description == valid_description.lower() - # Test initialization with an empty description - empty_description = '' - window_attr_empty = WindowAttributes(empty_description) - assert window_attr_empty.nodata - # Test initialization with a description that contains none of the keywords with pytest.raises(ValueError): WindowAttributes('description without keywords') + def test_empty_str(self): + # Test initialization with an empty description + assert WindowAttributes("").process() == { + 'has_glazing': False, 'glazing_coverage': None, 'glazing_type': None, 'no_data': True + } + @pytest.mark.parametrize( "case", windows_cases diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py index 2610c842..7469031c 100644 --- a/recommendations/FloorRecommendations.py +++ b/recommendations/FloorRecommendations.py @@ -72,6 +72,9 @@ class FloorRecommendations(Definitions): if not measures or not any(x in measures for x in MEASURE_MAP["floor_insulation"]): return + if self.property.floor.get("no_data", False): + return + u_value = self.property.floor["thermal_transmittance"] property_type = self.property.data["property-type"] floor_area = self.property.insulation_floor_area From 949c9f684de8555bdfae21fee0dbfeece29bef5b Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 23 Jan 2026 11:40:32 +0000 Subject: [PATCH 48/74] fix typos in lbwf element mapper --- backend/condition/domain/mapping/lbwf/lbwf_element_map.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index da13a6c8..750a76c6 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -253,7 +253,7 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { aspect_type=AspectType.RISK, ), "HHSRSORGAN": LbwfElementMapping( - element=Element.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, aspect_type=AspectType.Risk + element=Element.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, aspect_type=AspectType.RISK ), "HHSRSCROWD": LbwfElementMapping( element=Element.HHSRS_CROWDING_AND_SPACE, @@ -328,7 +328,7 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { aspect_type=AspectType.RISK, ), "HHSRSCLOW": LbwfElementMapping( - element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.Risk + element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.RISK ), "HHSRSPOSI": LbwfElementMapping( element=Element.HHSRS_AMENITIES, From d480339ba616ecd50d592b2481a012f1bedd49ce Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 23 Jan 2026 12:09:19 +0000 Subject: [PATCH 49/74] =?UTF-8?q?Map=20to=20dataclasses=20from=20Peabody?= =?UTF-8?q?=20objects=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/domain/lbwf_element.py | 131 --------------- .../domain/mapping/element_mapping.py | 12 ++ .../domain/mapping/lbwf/lbwf_element_map.py | 153 +++++++++--------- .../domain/mapping/lbwf/lbwf_mapper.py | 12 +- backend/condition/domain/mapping/mapper.py | 9 +- .../mapping/peabody/peabody_element_map.py | 40 +++++ .../domain/mapping/peabody/peabody_mapper.py | 2 +- .../tests/mapping/test_peabody_mapper.py | 44 +++-- 8 files changed, 169 insertions(+), 234 deletions(-) delete mode 100644 backend/condition/domain/lbwf_element.py create mode 100644 backend/condition/domain/mapping/element_mapping.py create mode 100644 backend/condition/domain/mapping/peabody/peabody_element_map.py diff --git a/backend/condition/domain/lbwf_element.py b/backend/condition/domain/lbwf_element.py deleted file mode 100644 index 52928c72..00000000 --- a/backend/condition/domain/lbwf_element.py +++ /dev/null @@ -1,131 +0,0 @@ -from enum import StrEnum - - -class LbwfElement(StrEnum): - AHR_CAT = "Accessible Housing Register Category" - ASBESTOS = "Asbestos Present" - ASSETSAREA = "Assets Area for Decent Homes and Investment" - DECNTHMINC = "Include for Decent Homes Reporting - LBWF Stock" - EICINSFREQ = "EICR - Elec Install Conditions Report Inspection Frequency" - EXTBALCONY = "Private Balconies in External Area" - EXTBKSDDR1 = "Back and Side Doors 1 in External Area" - EXTBKSDDR2 = "Back and Side Doors 2 in External Area" - EXTBPOINTG = "Brickwork Pointing in External Area" - EXTCHIMNEY = "Chimneys in External Area" - EXTDWNPTYP = "Downpipes in External Area" - EXTDRPKERB = "Drop Kerb in External Area" - EXTEXTDECS = "External Decorations in External Area" - EXTFASOFBR = "Fascia / Soffit / Bargeboard in External Area" - EXTGARDOOR = "Garage Door in External Area" - EXTGARROOF = "Garage Roof in External Area" - EXTGARSTDR = "Garage and Store Doors in External Area" - EXTGARSTRF = "Garage and Store Roofs in External Area" - EXTGARSTWD = "Garage and Store Windows in External Area" - EXTGARWDWS = "Garage Windows in External Area" - EXTGUTRTYP = "Gutters in External Area" - EXTHARDSTD = "Hardstanding in External Area" - EXTINTDWNP = "Internal Downpipes in External Area" - EXTLINTELS = "Lintels in External Area" - EXTOUTBOH = "Overhaul of Outbuilding in External Area" - EXTPARKING = "Parking in External Area" - EXTPCHCNPY = "Porch and / or Canopy in External Area" - EXTPTFRDR1 = "Patio and French Doors 1 in External Area" - EXTROOF1 = "Roof Covering 1 in External Area" - EXTROOF2 = "Roof Covering 2 in External Area" - EXTROOF3 = "Roof Covering 3 in External Area" - EXTRFSTR1 = "Roof Structure 1 in External Area" - EXTRFSTR2 = "Roof Structure 2 in External Area" - EXTRFSTR3 = "Roof Structure 3 in External Area" - EXTSTOREY = "Number of Storeys within the Property or Block" - EXTSTRDOOR = "Store Door in External Area" - EXTSTRINSP = "Structural Defects in External Area" - EXTSTRROOF = "Store Roof in External Area" - EXTSTRWDWS = "Store Windows in External Area" - EXTWALLFN1 = "Wall Finish 1 in External Area" - EXTWALLFN2 = "Wall Finish 2 in External Area" - EXTWALLINS = "Wall Insulation Improvement in External Area" - EXTWALLSPL = "Wall Spalling in External Area" - EXTWALLSTR = "Wall Structure in External Area" - EXTWNDWS1 = "Windows 1 in External Area" - EXTWNDWS2 = "Windows 2 in External Area" - FFHHDAMP = "Fitness for Human Habitation - Serious problem with damp" - FFHHDRNWC = ( - "Fitness for Human Habitation - Problems with the drainage or the lavatories" - ) - FFHHHCWAT = ( - "Fitness for Human Habitation - Problem with the supply of hot and cold water" - ) - FFHHNEGLC = ( - "Fitness for Human Habitation - Building neglected and is in a bad condition" - ) - FFHHNONAT = "Fitness for Human Habitation - Not enough natural light" - FFHHNOVEN = "Fitness for Human Habitation - Not enough ventilation" - FFHHPRPCK = ( - "Fitness for Human Habitation - Difficult to prepare and cook food or wash up" - ) - FFHHUNLAY = "Fitness for Human Habitation - Unsafe layout" - FFHHUNSTA = "Fitness for Human Habitation - Building is unstable" - FRARISKRTG = "Fire Risk Assessment Rating" - FRAEVACSTR = "Fire Risk Assessment Evacuation Strategy" - FRATYPE = "Fire Risk Assessment Type" - FLVL = "Floor Level of Front Door" - HHSRSASB = "Asbestos (and MMF)" - HHSRSBIOC = "Biocides" - HHSRSCO = "Carbon monoxide" - HHSRSCOLD = "Excess cold" - HHSRSCLOW = "Collision hazards and low headroom" - HHSRSCROWD = "Crowding and space" - HHSRSDAMP = "Damp and mould growth" - HHSRSDOMES = "Domestic hygeine, Pests and Refuse" - HHSRSELEC = "Electrical hazards" - HHSRSENTRP = "Collision and entrapment" - HHSRSENTRY = "Entry by intruders" - HHSRSEXPLO = "Explosions" - HHSRSFBATH = "Falls associated with baths etc" - HHSRSFBETW = "Falling between levels" - HHSRSFIRE = "Fire" - HHSRSFLAME = "Flames, hot surfaces etc" - HHSRSFLEVE = "Falling on level surfaces etc" - HHSRSFOOD = "Food safety" - HHSRSFSTAI = "Falling on stairs etc" - HHSRSFUEL = "Uncombusted fuel gas" - HHSRSHEAT = "Excess heat" - HHSRSLEAD = "Lead" - HHSRSLIGHT = "Lighting" - HHSRSNO2 = "Nitrogen dioxide" - HHSRSNOISE = "Noise" - HHSRSORGAN = "Volatile organic compounds" - HHSRSPERS = "Personal hygeine, Sanitation and Drainage" - HHSRSPOSI = "Position and operability of amenities etc" - HHSRSRADIA = "Radiation" - HHSRSSO2 = "Sulphur dioxide and smoke" - HHSRSSTRUC = "Structural collapse and falling elements" - HHSRSWATER = "Water supply" - INTACCRAMP = "Access Ramp 1:12 Gradient to Property" - INTADDWCW = "Additional WCs and / or WHBs in Property" - INTBTHADEQ = "Adequacy of Bathroom Location in Property" - INTBTHREML = "Source of Bathroom Remaining Life in Property" - INTBTHRLOC = "Location of Bathroom in Property" - INTBOILERF = "Boiler Fuel in Property" - INTCHEXTNT = "Extent of Central Heating in Property" - INTCKRLOC = "Adequacy of Cooker Location in Property" - INTCOMHTG = "Community Heating in Property" - INTELECTRC = "Electrics Required in Property" - INTFLRLVL = "Floor Level Location for Property" - INTFRDOOR = "Type and Location of Front Door in Property" - INTFRDRFRR = "Front Door Fire Rating in Property" - INTGASAVAI = "Gas Available in Property" - INTHEATREC = "Heat Recovery Units in Property" - INTHTDISYS = "Heating Distribution System in Property" - INTHTIMP = "Heating Improvement Required in Property" - INTKITADEQ = "Adequacy of Kitchen and Type in Property" - INTKITREML = "Source of Kitchen Remaining Life in Property" - INTLOFTINS = "Size in mm of Loft Insulation Thickness in Property" - INTNSEINSL = "Adequacy of Noise Insulation in Property" - INTPROGHTG = "Programmable Heating in Property" - INTSMKDET = "Smoke Detectors in Property" - INTSTEPSFD = "Number of Steps to Front Door for Property" - INTTNTINST = "Tenant Installed Kitchen in Property" - INTWDWTYPE = "Windows in Property" - INTWTRHTNG = "Type of Water Heating in Property" - QUALITYSTD = "Quality standard" diff --git a/backend/condition/domain/mapping/element_mapping.py b/backend/condition/domain/mapping/element_mapping.py new file mode 100644 index 00000000..01e1f316 --- /dev/null +++ b/backend/condition/domain/mapping/element_mapping.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from typing import Optional +from xml.dom.minidom import Element + +from backend.condition.domain.aspect_type import AspectType + + +@dataclass(frozen=True) +class ElementMapping: + element: Element + aspect_type: AspectType + element_instance: Optional[int] = None diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index 750a76c6..047013f4 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -1,179 +1,170 @@ -from dataclasses import dataclass -from typing import Optional - from backend.condition.domain.element import Element from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.mapping.element_mapping import ElementMapping -@dataclass(frozen=True) -class LbwfElementMapping: - element: Element - aspect_type: AspectType - element_instance: Optional[int] = None - - -LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { +LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # ========================================================== # PROPERTY / GENERAL # ========================================================== - "AHR_CAT": LbwfElementMapping( + "AHR_CAT": ElementMapping( element=Element.ACCESSIBLE_HOUSING_REGISTER, aspect_type=AspectType.CATEGORY, ), - "ASSETSAREA": LbwfElementMapping( + "ASSETSAREA": ElementMapping( element=Element.PROPERTY, aspect_type=AspectType.AREA, ), - # "DECNTHMINC": LbwfElementMapping( + # "DECNTHMINC": ElementMapping( # element=Element.DECENT_HOMES, # aspect_type=AspectType.INCLUSION, # ), # Ignore this one - "QUALITYSTD": LbwfElementMapping( + "QUALITYSTD": ElementMapping( element=Element.QUALITY_STANDARD, aspect_type=AspectType.TYPE, ), - "EXTSTOREY": LbwfElementMapping( + "EXTSTOREY": ElementMapping( element=Element.PROPERTY, aspect_type=AspectType.CONFIGURATION, ), - "FLVL": LbwfElementMapping( + "FLVL": ElementMapping( element=Element.FLOOR_LEVEL_FRONT_DOOR, aspect_type=AspectType.LOCATION, ), # ========================================================== # ASBESTOS (NON-HHSRS RECORD) # ========================================================== - "ASBESTOS": LbwfElementMapping( + "ASBESTOS": ElementMapping( element=Element.ASBESTOS, aspect_type=AspectType.PRESENCE, ), # ========================================================== # INTERNAL – BATHROOMS & KITCHENS # ========================================================== - "INTBTHRLOC": LbwfElementMapping( + "INTBTHRLOC": ElementMapping( element=Element.BATHROOM, aspect_type=AspectType.LOCATION, ), - "INTBTHADEQ": LbwfElementMapping( + "INTBTHADEQ": ElementMapping( element=Element.BATHROOM, aspect_type=AspectType.ADEQUACY, ), - "INTKITADEQ": LbwfElementMapping( + "INTKITADEQ": ElementMapping( element=Element.KITCHEN, aspect_type=AspectType.ADEQUACY, ), - "INTCKRLOC": LbwfElementMapping( + "INTCKRLOC": ElementMapping( element=Element.KITCHEN, aspect_type=AspectType.LOCATION, ), # ========================================================== # INTERNAL – HEATING # ========================================================== - "INTCHEXTNT": LbwfElementMapping( + "INTCHEXTNT": ElementMapping( element=Element.HEATING_EXTENT, aspect_type=AspectType.CONFIGURATION, ), - "INTCHDIST": LbwfElementMapping( + "INTCHDIST": ElementMapping( element=Element.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE, ), - "INTCHBLR": LbwfElementMapping( + "INTCHBLR": ElementMapping( element=Element.HEATING_BOILER, aspect_type=AspectType.TYPE, ), # ========================================================== # INTERNAL – FIRE # ========================================================== - "FRARISKRTG": LbwfElementMapping( + "FRARISKRTG": ElementMapping( element=Element.FIRE_RISK_ASSESSMENT, aspect_type=AspectType.RATING, ), - "FRATYPE": LbwfElementMapping( + "FRATYPE": ElementMapping( element=Element.FIRE_RISK_ASSESSMENT, aspect_type=AspectType.TYPE, ), - "FRAEVACSTR": LbwfElementMapping( + "FRAEVACSTR": ElementMapping( element=Element.FIRE_RISK_ASSESSMENT, aspect_type=AspectType.STRATEGY, ), - "INTSMKDET": LbwfElementMapping( + "INTSMKDET": ElementMapping( element=Element.SMOKE_DETECTION, aspect_type=AspectType.PRESENCE, ), - "INTCHEXTNT": LbwfElementMapping( + "INTCHEXTNT": ElementMapping( element=Element.HEATING_SYSTEM, aspect_type=AspectType.EXTENT, ), # ========================================================== # HEATING & SERVICES # ========================================================== - "INTBOILERF": LbwfElementMapping( + "INTBOILERF": ElementMapping( element=Element.BOILER_FUEL, aspect_type=AspectType.TYPE, ), - "INTHTDISYS": LbwfElementMapping( + "INTHTDISYS": ElementMapping( element=Element.HEATING_SYSTEM, aspect_type=AspectType.DISTRIBUTION, ), - "INTWTRHTNG": LbwfElementMapping( + "INTWTRHTNG": ElementMapping( element=Element.WATER_HEATING, aspect_type=AspectType.TYPE, ), # ========================================================== # EXTERNAL – WALLS (INSTANCED) # ========================================================== - "EXTWALLSTR": LbwfElementMapping( + "EXTWALLSTR": ElementMapping( element=Element.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE, element_instance=1, ), - "EXTWALLFN1": LbwfElementMapping( + "EXTWALLFN1": ElementMapping( element=Element.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=1, ), - "EXTWALLFN2": LbwfElementMapping( + "EXTWALLFN2": ElementMapping( element=Element.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=2, ), - "EXTWALLINS": LbwfElementMapping( + "EXTWALLINS": ElementMapping( element=Element.EXTERNAL_WALL, aspect_type=AspectType.INSULATION, ), - "EXTWALLSPL": LbwfElementMapping( + "EXTWALLSPL": ElementMapping( element=Element.EXTERNAL_WALL, aspect_type=AspectType.CONDITION, ), # ========================================================== # EXTERNAL – ROOFS (INSTANCED) # ========================================================== - "EXTRFSTR1": LbwfElementMapping( + "EXTRFSTR1": ElementMapping( element=Element.ROOF, aspect_type=AspectType.STRUCTURE, element_instance=1, ), - "EXTRFSTR2": LbwfElementMapping( + "EXTRFSTR2": ElementMapping( element=Element.ROOF, aspect_type=AspectType.STRUCTURE, element_instance=2, ), - "EXTRFSTR3": LbwfElementMapping( + "EXTRFSTR3": ElementMapping( element=Element.ROOF, aspect_type=AspectType.STRUCTURE, element_instance=3, ), - "EXTROOF1": LbwfElementMapping( + "EXTROOF1": ElementMapping( element=Element.ROOF, aspect_type=AspectType.COVERING, element_instance=1, ), - "EXTROOF2": LbwfElementMapping( + "EXTROOF2": ElementMapping( element=Element.ROOF, aspect_type=AspectType.COVERING, element_instance=2, ), - "EXTROOF3": LbwfElementMapping( + "EXTROOF3": ElementMapping( element=Element.ROOF, aspect_type=AspectType.COVERING, element_instance=3, @@ -181,34 +172,34 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== - "INTFRDOOR": LbwfElementMapping( + "INTFRDOOR": ElementMapping( element=Element.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, ), - "INTFRDRFRR": LbwfElementMapping( + "INTFRDRFRR": ElementMapping( element=Element.EXTERNAL_DOOR, aspect_type=AspectType.FIRE_RATING, ), - "EXTBKSDDR1": LbwfElementMapping( + "EXTBKSDDR1": ElementMapping( element=Element.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, element_instance=1, ), - "EXTBKSDDR2": LbwfElementMapping( + "EXTBKSDDR2": ElementMapping( element=Element.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, element_instance=2, ), - "INTWDWTYPE": LbwfElementMapping( + "INTWDWTYPE": ElementMapping( element=Element.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, ), - "EXTWNDWS1": LbwfElementMapping( + "EXTWNDWS1": ElementMapping( element=Element.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=1, ), - "EXTWNDWS2": LbwfElementMapping( + "EXTWNDWS2": ElementMapping( element=Element.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=2, @@ -216,121 +207,121 @@ LBWF_ELEMENT_MAP: dict[str, LbwfElementMapping] = { # ========================================================== # HHSRS # ========================================================== - "HHSRSDAMP": LbwfElementMapping( + "HHSRSDAMP": ElementMapping( element=Element.HHSRS_DAMP_AND_MOULD, aspect_type=AspectType.RISK, ), - "HHSRSCOLD": LbwfElementMapping( + "HHSRSCOLD": ElementMapping( element=Element.HHSRS_EXCESS_COLD, aspect_type=AspectType.RISK, ), - "HHSRSHEAT": LbwfElementMapping( + "HHSRSHEAT": ElementMapping( element=Element.HHSRS_EXCESS_HEAT, aspect_type=AspectType.RISK, ), - "HHSRSASB": LbwfElementMapping( + "HHSRSASB": ElementMapping( element=Element.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK, ), - "HHSRSBIOCIDES": LbwfElementMapping( + "HHSRSBIOCIDES": ElementMapping( element=Element.HHSRS_BIOCIDES, aspect_type=AspectType.RISK, ), - "HHSRSCO": LbwfElementMapping( + "HHSRSCO": ElementMapping( element=Element.HHSRS_CARBON_MONOXIDE, aspect_type=AspectType.RISK, ), - "HHSRSLEAD": LbwfElementMapping( + "HHSRSLEAD": ElementMapping( element=Element.HHSRS_LEAD, aspect_type=AspectType.RISK, ), - "HHSRSRADIA": LbwfElementMapping( + "HHSRSRADIA": ElementMapping( element=Element.HHSRS_RADIATION, aspect_type=AspectType.RISK, ), - "HHSRSFUEL": LbwfElementMapping( + "HHSRSFUEL": ElementMapping( element=Element.HHSRS_UNCOMBUSTED_FUEL_GAS, aspect_type=AspectType.RISK, ), - "HHSRSORGAN": LbwfElementMapping( + "HHSRSORGAN": ElementMapping( element=Element.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, aspect_type=AspectType.RISK ), - "HHSRSCROWD": LbwfElementMapping( + "HHSRSCROWD": ElementMapping( element=Element.HHSRS_CROWDING_AND_SPACE, aspect_type=AspectType.RISK, ), - "HHSRSENTRY": LbwfElementMapping( + "HHSRSENTRY": ElementMapping( element=Element.HHSRS_ENTRY_BY_INTRUDERS, aspect_type=AspectType.RISK, ), - "HHSRSLIGHT": LbwfElementMapping( + "HHSRSLIGHT": ElementMapping( element=Element.HHSRS_LIGHTING, aspect_type=AspectType.RISK, ), - "HHSRSNOISE": LbwfElementMapping( + "HHSRSNOISE": ElementMapping( element=Element.HHSRS_NOISE, aspect_type=AspectType.RISK, ), - "HHSRSDOMES": LbwfElementMapping( + "HHSRSDOMES": ElementMapping( element=Element.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, aspect_type=AspectType.RISK, ), - "HHSRSFOOD": LbwfElementMapping( + "HHSRSFOOD": ElementMapping( element=Element.HHSRS_FOOD_SAFETY, aspect_type=AspectType.RISK, ), - "HHSRSPERS": LbwfElementMapping( + "HHSRSPERS": ElementMapping( element=Element.HHSRS_PERSONAL_HYGIENE_SANITATION, aspect_type=AspectType.RISK, ), - "HHSRSWATER": LbwfElementMapping( + "HHSRSWATER": ElementMapping( element=Element.HHSRS_WATER_SUPPLY, aspect_type=AspectType.RISK, ), - "HHSRSFBATH": LbwfElementMapping( + "HHSRSFBATH": ElementMapping( element=Element.HHSRS_FALLS_ASSOCIATED_WITH_BATHS, aspect_type=AspectType.RISK, ), - "HHSRSFLEVE": LbwfElementMapping( + "HHSRSFLEVE": ElementMapping( element=Element.HHSRS_FALLS_ON_LEVEL_SURFACES, aspect_type=AspectType.RISK, ), - "HHSRSFSTAI": LbwfElementMapping( + "HHSRSFSTAI": ElementMapping( element=Element.HHSRS_FALLS_ON_STAIRS, aspect_type=AspectType.RISK, ), - "HHSRSFBETW": LbwfElementMapping( + "HHSRSFBETW": ElementMapping( element=Element.HHSRS_FALLS_BETWEEN_LEVELS, aspect_type=AspectType.RISK, ), - "HHSRSELEC": LbwfElementMapping( + "HHSRSELEC": ElementMapping( element=Element.HHSRS_ELECTRICAL_HAZARDS, aspect_type=AspectType.RISK, ), - "HHSRSFIRE": LbwfElementMapping( + "HHSRSFIRE": ElementMapping( element=Element.HHSRS_FIRE, aspect_type=AspectType.RISK, ), - "HHSRSFLAME": LbwfElementMapping( + "HHSRSFLAME": ElementMapping( element=Element.HHSRS_FLAMES_HOT_SURFACES, aspect_type=AspectType.RISK, ), - "HHSRSENTRP": LbwfElementMapping( + "HHSRSENTRP": ElementMapping( element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.RISK, ), - "HHSRSEXPLO": LbwfElementMapping( + "HHSRSEXPLO": ElementMapping( element=Element.HHSRS_EXPLOSIONS, aspect_type=AspectType.RISK, ), - "HHSRSSTRUC": LbwfElementMapping( + "HHSRSSTRUC": ElementMapping( element=Element.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK, ), - "HHSRSCLOW": LbwfElementMapping( + "HHSRSCLOW": ElementMapping( element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.RISK ), - "HHSRSPOSI": LbwfElementMapping( + "HHSRSPOSI": ElementMapping( element=Element.HHSRS_AMENITIES, aspect_type=AspectType.RISK, ), diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py index 01f48a35..635e5898 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py @@ -2,10 +2,8 @@ from typing import Any, List, Optional from backend.condition.domain.asset_condition import AssetCondition from backend.condition.domain.element import Element -from backend.condition.domain.mapping.lbwf.lbwf_element_map import ( - LbwfElementMapping, - LBWF_ELEMENT_MAP, -) +from backend.condition.domain.mapping.element_mapping import ElementMapping +from backend.condition.domain.mapping.lbwf.lbwf_element_map import LBWF_ELEMENT_MAP from backend.condition.domain.mapping.mapper import Mapper from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( LbwfAssetCondition, @@ -19,7 +17,7 @@ logger = setup_logger() class LbwfMapper(Mapper): def map_asset_conditions_for_property( - self, client_data: Any, survey_year: Optional[int] + self, client_data: Any, survey_year: Optional[int] = None ) -> List[AssetCondition]: assert isinstance( client_data, LbwfHouse @@ -30,7 +28,7 @@ class LbwfMapper(Mapper): uprn: int = client_data.uprn for raw_asset in client_data.assets: try: - element_mapping: LbwfElementMapping = LbwfMapper._map_element( + element_mapping: ElementMapping = LbwfMapper._map_element( raw_asset.element_code ) except: @@ -59,7 +57,7 @@ class LbwfMapper(Mapper): return mapped_assets @staticmethod - def _map_element(lbwf_element_code: str) -> LbwfElementMapping: + def _map_element(lbwf_element_code: str) -> ElementMapping: return LBWF_ELEMENT_MAP[lbwf_element_code] @staticmethod diff --git a/backend/condition/domain/mapping/mapper.py b/backend/condition/domain/mapping/mapper.py index 4e51d46b..c0b07184 100644 --- a/backend/condition/domain/mapping/mapper.py +++ b/backend/condition/domain/mapping/mapper.py @@ -3,9 +3,12 @@ from typing import Any, List, Optional from backend.condition.domain.asset_condition import AssetCondition + class Mapper(ABC): @abstractmethod - def map_asset_conditions_for_property(self, client_data: Any, survey_year: Optional[int]) -> List[AssetCondition]: - #TODO: client_data should be properly typed - pass \ No newline at end of file + def map_asset_conditions_for_property( + self, client_data: Any, survey_year: Optional[int] = None + ) -> List[AssetCondition]: + # TODO: client_data should be properly typed + pass diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py new file mode 100644 index 00000000..2a1203c0 --- /dev/null +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -0,0 +1,40 @@ +from backend.condition.domain.mapping.element_mapping import ElementMapping + + +PEABODY_ELEMENT_MAP = { + # -------------------- + # GENERAL + # -------------------- + ("100", "1"): ElementMapping(element="property", aspect="type"), + ("100", "3"): ElementMapping(element="property", aspect="age_band"), + ("100", "14"): ElementMapping(element="property", aspect="construction_type"), + # -------------------- + # EXTERNALS + # -------------------- + ("120", "1"): ElementMapping(element="external_wall", aspect="structure"), + ("120", "2"): ElementMapping(element="external_wall", aspect="finish"), + ("110", "1"): ElementMapping(element="roof", aspect="covering"), + # -------------------- + # INTERNALS + # -------------------- + ("160", "1"): ElementMapping(element="kitchen", aspect="condition"), + ("190", "1"): ElementMapping(element="bathroom", aspect="condition"), + # -------------------- + # HHSRS (PEABODY) + # -------------------- + ("54", "1"): ElementMapping( + element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=1 + ), + ("54", "2"): ElementMapping( + element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=2 + ), + ("54", "4"): ElementMapping( + element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=4 + ), + ("54", "24"): ElementMapping( + element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=24 + ), + ("54", "29"): ElementMapping( + element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=29 + ), +} diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index 8413b888..3f0ee931 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -13,6 +13,6 @@ logger = setup_logger() class PeabodyMapper(Mapper): def map_asset_conditions_for_property( - self, client_data: Any, survey_year: Optional[int] + self, client_data: Any, survey_year: Optional[int] = None ) -> List[AssetCondition]: raise NotImplementedError diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py index de027fe7..ca3f78a4 100644 --- a/backend/condition/tests/mapping/test_peabody_mapper.py +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -1,5 +1,8 @@ from datetime import datetime +from typing import List +from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.element import Element from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper from backend.condition.parsing.records.peabody.peabody_asset_condition import ( PeabodyAssetCondition, @@ -18,23 +21,42 @@ def test_peabody_mapper_maps_property(): full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", location_type_code=1, parent_lo_reference="RAND1000", - element_code=50, - element="Internal", - sub_element_code=3, - sub_element="CCU", - material_code=2, - material_or_answer="RCD/MCB CCU", - renewal_quantity=1, - renewal_year=2038, - renewal_cost=500, + element_code=130, + element="WINDOWS", + sub_element_code=1, + sub_element="Windows", + material_code=1, + material_or_answer="UPVC Double Glazed", + renewal_quantity=8, + renewal_year=2036, + renewal_cost=4800, cloned="N", lo_type_code=1, - condition_survey_date=datetime(2024, 2, 15, 0, 0, 0), + condition_survey_date=datetime(2024, 2, 15, 12, 47, 0), ) ], ) + mapper = PeabodyMapper() + expected_assets: List[AssetCondition] = [ + AssetCondition( + uprn=1, + element=Element.EXTERNAL_WINDOWS, + aspect_type=AspectType.MATERIAL, + value="UPVC Double Glazed", + quantity=8, + install_date=None, + renewal_year=2036, + element_instance=None, + source_system=None, + comments=None, + ) + ] # act + actual_assets = mapper.map_asset_conditions_for_property(peabody_property) # assert - assert False # temp + assert len(actual_assets) == len(expected_assets) + + for i, (actual, expected) in enumerate(zip(actual_assets, expected_assets)): + assert actual == expected, f"Mismatch at index {i}" From 42f9821f1b4a9f279e716cd9ed2725754d161b41 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 23 Jan 2026 12:50:48 +0000 Subject: [PATCH 50/74] =?UTF-8?q?Map=20to=20dataclasses=20from=20Peabody?= =?UTF-8?q?=20objects=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapping/peabody/peabody_element_map.py | 70 +++++++++++-------- .../domain/mapping/peabody/peabody_mapper.py | 44 +++++++++++- 2 files changed, 85 insertions(+), 29 deletions(-) diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 2a1203c0..5b89c578 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -1,40 +1,54 @@ +from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.element import Element from backend.condition.domain.mapping.element_mapping import ElementMapping PEABODY_ELEMENT_MAP = { - # -------------------- - # GENERAL - # -------------------- - ("100", "1"): ElementMapping(element="property", aspect="type"), - ("100", "3"): ElementMapping(element="property", aspect="age_band"), - ("100", "14"): ElementMapping(element="property", aspect="construction_type"), - # -------------------- - # EXTERNALS - # -------------------- - ("120", "1"): ElementMapping(element="external_wall", aspect="structure"), - ("120", "2"): ElementMapping(element="external_wall", aspect="finish"), - ("110", "1"): ElementMapping(element="roof", aspect="covering"), - # -------------------- - # INTERNALS - # -------------------- - ("160", "1"): ElementMapping(element="kitchen", aspect="condition"), - ("190", "1"): ElementMapping(element="bathroom", aspect="condition"), + # ========================================================== + # PROPERTY / GENERAL + # ========================================================== + (100, 1): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.TYPE), + # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE), + # (100, 14): ElementMapping(element="property", aspect_type="construction_type"), + # ========================================================== + # EXTERNAL – WALLS + # ========================================================== + (120, 1): ElementMapping( + element=Element.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE + ), + (120, 2): ElementMapping( + element=Element.EXTERNAL_WALL, aspect_type=AspectType.FINISH + ), + # ========================================================== + # EXTERNAL – ROOFS + # ========================================================== + (110, 1): ElementMapping(element=Element.ROOF, aspect_type=AspectType.COVERING), + # ========================================================== + # EXTERNAL – DOORS & WINDOWS + # ========================================================== + (130, 1): ElementMapping( + element=Element.EXTERNAL_WINDOWS, aspect_type=AspectType.MATERIAL + ), + # ========================================================== + # INTERNAL – BATHROOMS & KITCHENS + # ========================================================== + (160, 1): ElementMapping(element=Element.KITCHEN, aspect_type=AspectType.CONDITION), + (190, 1): ElementMapping( + element=Element.BATHROOM, aspect_type=AspectType.CONDITION + ), # -------------------- # HHSRS (PEABODY) # -------------------- - ("54", "1"): ElementMapping( - element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=1 + (54, 1): ElementMapping( + element=Element.HHSRS_DAMP_AND_MOULD, aspect_type=AspectType.RISK ), - ("54", "2"): ElementMapping( - element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=2 + (54, 4): ElementMapping( + element=Element.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK ), - ("54", "4"): ElementMapping( - element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=4 + (54, 15): ElementMapping( + element=Element.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, aspect_type=AspectType.RISK ), - ("54", "24"): ElementMapping( - element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=24 - ), - ("54", "29"): ElementMapping( - element="hhsrs", aspect="risk", is_hhsrs=True, hhsrs_hazard_id=29 + (54, 29): ElementMapping( + element=Element.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK ), } diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index 3f0ee931..44dbd56e 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -1,6 +1,10 @@ from typing import Any, List, Optional from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.mapping.element_mapping import ElementMapping +from backend.condition.domain.mapping.peabody.peabody_element_map import ( + PEABODY_ELEMENT_MAP, +) from backend.condition.domain.mapping.mapper import Mapper from backend.condition.parsing.records.peabody.peabody_asset_condition import ( PeabodyAssetCondition, @@ -15,4 +19,42 @@ class PeabodyMapper(Mapper): def map_asset_conditions_for_property( self, client_data: Any, survey_year: Optional[int] = None ) -> List[AssetCondition]: - raise NotImplementedError + assert isinstance( + client_data, PeabodyProperty + ) # TODO: think of a better way to do this + + mapped_assets: List[AssetCondition] = [] + + uprn: int = client_data.uprn + for raw_asset in client_data.assets: + try: + element_mapping: ElementMapping = PeabodyMapper._map_element( + raw_asset.element_code, raw_asset.sub_element_code + ) + except: + logger.warning( + f"""Unrecognised Peabody Asset Element: {raw_asset.element} ({raw_asset.element_code}), + Sub-Element: {raw_asset.sub_element} ({raw_asset.sub_element_code}). Skipping record""" + ) + continue + + mapped_assets.append( + AssetCondition( + uprn=uprn, + element=element_mapping.element, + aspect_type=element_mapping.aspect_type, + value=raw_asset.material_or_answer, + quantity=raw_asset.renewal_quantity, + install_date=None, # Not available in peabody data + renewal_year=raw_asset.renewal_year, + element_instance=element_mapping.element_instance, + source_system=None, # Once we know the system name we'll set it here + comments=None, # Not available in peabody data + ) + ) + + return mapped_assets + + @staticmethod + def _map_element(element_code: int, sub_element_code: int) -> ElementMapping: + return PEABODY_ELEMENT_MAP[(element_code, sub_element_code)] From 4c16632b2f942eb426ccaeac768e8415cce629e9 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 23 Jan 2026 14:01:21 +0000 Subject: [PATCH 51/74] process both file types in local runner --- backend/condition/local_runner.py | 23 ++++++++++++++++------- backend/condition/parsing/factory.py | 4 ++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/backend/condition/local_runner.py b/backend/condition/local_runner.py index 28f9b06c..404f64d4 100644 --- a/backend/condition/local_runner.py +++ b/backend/condition/local_runner.py @@ -2,6 +2,7 @@ from pathlib import Path from backend.condition.processor import process_file + def main() -> None: try: # Works in scripts / debugger / pytest @@ -12,14 +13,22 @@ def main() -> None: path: Path = ROOT_DIR / "condition" / "sample_data" - lbwf_path: Path = path / "lbwf" / "LBWF - Example Asset Data September 2025.xlsx" # TODO: get this from s3 as part of devcontainer init + # TODO: get these from s3, maybe as part of devcontainer init + lbwf_path: Path = path / "lbwf" / "LBWF - Example Asset Data September 2025.xlsx" + peabody_path: Path = ( + path + / "peabody" + / "2026_01_06 - Peabody - Stock Condition Data - Survey Records - D Lower.xlsx" + ) + filepaths = [lbwf_path, peabody_path] + + for fp in filepaths: + with fp.open("rb") as f: + process_file( + file_stream=f, + source_key=fp.as_posix(), + ) - with lbwf_path.open("rb") as f: - process_file( - file_stream=f, - source_key=lbwf_path.as_posix(), - ) if __name__ == "__main__": main() - diff --git a/backend/condition/parsing/factory.py b/backend/condition/parsing/factory.py index 7233a1df..68ca0292 100644 --- a/backend/condition/parsing/factory.py +++ b/backend/condition/parsing/factory.py @@ -1,5 +1,6 @@ from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper from backend.condition.file_type import FileType from backend.condition.parsing.parser import Parser from backend.condition.parsing.lbwf_parser import LbwfParser @@ -20,4 +21,7 @@ def select_mapper(file_type: FileType) -> Mapper: if file_type is FileType.LBWF: return LbwfMapper() + if file_type is FileType.Peabody: + return PeabodyMapper() + raise ValueError("Unrecognised file type, unable to instantiate Mapper") From 42cfcf604c5181ff64651acf93b6a3ae38cfcccc Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 23 Jan 2026 14:52:52 +0000 Subject: [PATCH 52/74] Extra test case, and note unhandled elements --- backend/condition/domain/aspect_type.py | 1 + .../mapping/peabody/peabody_element_map.py | 155 ++++++++++++++++++ .../domain/mapping/peabody/peabody_mapper.py | 3 - .../tests/mapping/test_peabody_mapper.py | 34 +++- 4 files changed, 188 insertions(+), 5 deletions(-) diff --git a/backend/condition/domain/aspect_type.py b/backend/condition/domain/aspect_type.py index 0f9a406a..d4db10bc 100644 --- a/backend/condition/domain/aspect_type.py +++ b/backend/condition/domain/aspect_type.py @@ -27,3 +27,4 @@ class AspectType(str, Enum): STRUCTURE = "structure" COVERING = "covering" FIRE_RATING = "fire_rating" + EXTERNAL_DECORATION = "external_decoration" diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 5b89c578..80b3aa70 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -8,6 +8,9 @@ PEABODY_ELEMENT_MAP = { # PROPERTY / GENERAL # ========================================================== (100, 1): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.TYPE), + (100, 15): ElementMapping( + element=Element.PROPERTY, aspect_type=AspectType.EXTERNAL_DECORATION + ), # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE), # (100, 14): ElementMapping(element="property", aspect_type="construction_type"), # ========================================================== @@ -52,3 +55,155 @@ PEABODY_ELEMENT_MAP = { element=Element.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK ), } + + +# unhandled +# 'Element: ROOFS - Code: 110, Sub-Element: Chimney - Code: 3', +# 'Element: ROOFS - Code: 110, Sub-Element: Fascia - Code: 4', +# 'Element: ROOFS - Code: 110, Sub-Element: Rainwater Goods - Code: 6', +# 'Element: WINDOWS - Code: 130, Sub-Element: Communal Windows - Code: 2', +# 'Element: DOORS - Code: 140, Sub-Element: Main Doors - Code: 1', +# 'Element: DOORS - Code: 140, Sub-Element: Block Entrance Doors - Code: 4', +# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Paving - Code: 1', +# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Boundaries - Code: 4', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Boiler - Code: 1', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Heating - Code: 2', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Electrics - Code: 3', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Fire Detection - Code: 4', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Floor Covering - Code: 6', +# 'Element: ROOFS - Code: 110, Sub-Element: Soffit - Code: 5', +# 'Element: External - Code: 53, Sub-Element: Window Type 01 - Code: 38', +# 'Element: ROOFS - Code: 110, Sub-Element: Secondary Roof - Code: 2', +# 'Element: HEATING - Code: 170, Sub-Element: Boiler - Code: 1', +# 'Element: HEATING - Code: 170, Sub-Element: Heating Distribution - Code: 2', +# 'Element: ELECTRICS - Code: 180, Sub-Element: Wiring - Code: 1', +# 'Element: ELECTRICS - Code: 180, Sub-Element: Consumer Unit - Code: 2', +# 'Element: ELECTRICS - Code: 180, Sub-Element: Smoke Detectors - Code: 3', +# 'Element: KITCHEN - Code: 160, Sub-Element: Kitchen space and layout - Code: 2', +# 'Element: HEATING - Code: 170, Sub-Element: Secondary Heating - Code: 3', +# 'Element: BATHROOM - Code: 190, Sub-Element: Secondary Toilet - Code: 2', +# 'Element: ELECTRICS - Code: 180, Sub-Element: Carbon Monoxide Alarms - Code: 4', +# 'Element: ROOFS - Code: 110, Sub-Element: Porch/Bay/Canopy - Code: 8', +# 'Element: HEATING - Code: 170, Sub-Element: Hot Water - Code: 5', +# 'Element: DOORS - Code: 140, Sub-Element: Garage Doors - Code: 3', +# 'Element: HEATING - Code: 170, Sub-Element: Cold Water - Code: 4', +# 'Element: DOORS - Code: 140, Sub-Element: Store Doors - Code: 2', +# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Hardstanding - Code: 2', +# 'Element: WALLS - Code: 120, Sub-Element: Wall Insulation - Code: 3', +# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Roads - Code: 3', +# 'Element: ROOFS - Code: 110, Sub-Element: Loft Insulation - Code: 7', +# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Outbuilding - Code: 5', +# 'Element: Internal - Code: 50, Sub-Element: Additional WC - Code: 1', +# 'Element: Internal - Code: 50, Sub-Element: Carbon Monoxide Detector Type - Code: 2', +# 'Element: Internal - Code: 50, Sub-Element: CCU - Code: 3', +# 'Element: Internal - Code: 50, Sub-Element: Central Heating Boiler - Code: 4', +# 'Element: Internal - Code: 50, Sub-Element: Extractor Fan Bathroom - Code: 9', +# 'Element: Internal - Code: 50, Sub-Element: Extractor Fan Kitchen - Code: 10', +# 'Element: Internal - Code: 50, Sub-Element: Heat Detector Type - Code: 11', +# 'Element: Internal - Code: 50, Sub-Element: Kitchen Type - Code: 14', +# 'Element: Internal - Code: 50, Sub-Element: Primary Bathroom Type - Code: 18', +# 'Element: Internal - Code: 50, Sub-Element: Smoke Detector Type - Code: 21', +# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Garage - Code: 6', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Lifts - Code: 5', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Kitchen - Code: 7', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Bathroom - Code: 8', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Toilets - Code: 9', +# 'Element: Internal - Code: 50, Sub-Element: Wiring - Code: 24', +# 'Element: External - Code: 53, Sub-Element: Front Door Material - Code: 8', +# 'Element: External - Code: 53, Sub-Element: Primary Wall Finish - Code: 23', +# 'Element: PASSENGER LIFTS - Code: 210, Sub-Element: Lift - Code: 2', +# 'Element: Internal - Code: 50, Sub-Element: Heating Distribution Type - Code: 12', +# 'Element: External - Code: 53, Sub-Element: Downpipes - Code: 3', +# 'Element: External - Code: 53, Sub-Element: Fascia/Soffits/Bargeboards - Code: 6', +# 'Element: External - Code: 53, Sub-Element: Gutters - Code: 15', +# 'Element: External - Code: 53, Sub-Element: Paths & Hardstandings - Code: 18', +# 'Element: External - Code: 53, Sub-Element: Pitched Roof Covering Material - Code: 21', +# 'Element: Internal - Code: 50, Sub-Element: Secondary Bathroom Type - Code: 20', +# 'Element: External - Code: 53, Sub-Element: Chimney - Code: 2', +# 'Element: External - Code: 53, Sub-Element: External Decoration - Code: 4', +# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Gates - Code: 10', +# 'Element: GENERAL - Code: 100, Sub-Element: Property Age Band - Code: 3', +# 'Element: GENERAL - Code: 100, Sub-Element: Construction Type - Code: 14', +# 'Element: GENERAL - Code: 100, Sub-Element: Classification - Code: 16', +# 'Element: Communal - Code: 51, Sub-Element: Common Balcony/Walkway - Code: 3', +# 'Element: Communal - Code: 51, Sub-Element: Common Bin Store Doors - Code: 5', +# 'Element: Communal - Code: 51, Sub-Element: Common Bin Store Walls - Code: 7', +# 'Element: Communal - Code: 51, Sub-Element: Common Primary Entrance Material - Code: 28', +# 'Element: External - Code: 53, Sub-Element: Parking Areas - Code: 17', +# 'Element: External - Code: 53, Sub-Element: Front Fencing - Code: 9', +# 'Element: External - Code: 53, Sub-Element: Retaining Walls - Code: 28', +# 'Element: Communal - Code: 51, Sub-Element: Common Internal Decorations - Code: 20', +# 'Element: Communal - Code: 51, Sub-Element: Common Internal Floor Finish - Code: 22', +# 'Element: Communal - Code: 51, Sub-Element: Common Walkways Finish - Code: 36', +# 'Element: External - Code: 53, Sub-Element: Boundary Walls - Code: 1', +# 'Element: External - Code: 53, Sub-Element: Flat Roof Covering Material - Code: 7', +# 'Element: External - Code: 53, Sub-Element: Porch/Canopy - Code: 22', +# 'Element: External - Code: 53, Sub-Element: Private Balcony - Code: 24', +# 'Element: External - Code: 53, Sub-Element: Rear Gate - Code: 27', +# 'Element: Communal - Code: 51, Sub-Element: Common External Doors Other - Code: 17', +# 'Element: Communal - Code: 51, Sub-Element: Common Stair Finish - Code: 32', +# 'Element: External - Code: 53, Sub-Element: Front Gate - Code: 10', +# 'Element: External - Code: 53, Sub-Element: Rear Fencing - Code: 26', +# 'Element: External - Code: 53, Sub-Element: Side Fencing - Code: 31', +# 'Element: Communal - Code: 51, Sub-Element: Common Aerial - Code: 1', +# 'Element: Communal - Code: 51, Sub-Element: Common AOV - Code: 2', +# 'Element: Communal - Code: 51, Sub-Element: Common Door Entry System - Code: 14', +# 'Element: Communal - Code: 51, Sub-Element: Common Fire Alarm - Code: 19', +# 'Element: Communal - Code: 51, Sub-Element: Common Internal Doors - Code: 21', +# 'Element: External - Code: 53, Sub-Element: Store Door Material - Code: 35', +# 'Element: External - Code: 53, Sub-Element: Secondary Wall Finish - Code: 30', +# 'Element: Communal - Code: 51, Sub-Element: Common Emergency Lighting - Code: 16', +# 'Element: Communal - Code: 51, Sub-Element: Common Lateral Mains - Code: 24', +# 'Element: Communal - Code: 51, Sub-Element: Common Lighting - Code: 25', +# 'Element: Communal - Code: 51, Sub-Element: Common Store Roof - Code: 34', +# 'Element: Communal - Code: 51, Sub-Element: Common Store Walls - Code: 35', +# 'Element: External - Code: 53, Sub-Element: Cladding Material - Code: 41', +# 'Element: External - Code: 53, Sub-Element: Spandrel Panels - Code: 40', +# 'Element: Communal - Code: 51, Sub-Element: Common CCTV - Code: 11', +# 'Element: Communal - Code: 51, Sub-Element: Common Kitchen - Code: 23', +# 'Element: Communal - Code: 51, Sub-Element: Common Secondary Entrance Material - Code: 30', +# 'Element: Communal - Code: 51, Sub-Element: Common Warden Call System - Code: 37', +# 'Element: External - Code: 53, Sub-Element: Lintels - Code: 16', +# 'Element: Communal - Code: 51, Sub-Element: Common Boiler - Code: 9', +# 'Element: External - Code: 53, Sub-Element: Soil & Vent Material - Code: 32', +# 'Element: Communal - Code: 51, Sub-Element: Common Passenger Lift - Code: 27', +# 'Element: Communal - Code: 51, Sub-Element: Common Store Doors - Code: 33', +# 'Element: External - Code: 53, Sub-Element: Window Type 02 - Code: 39', +# 'Element: Communal - Code: 51, Sub-Element: Common BMS - Code: 8', +# 'Element: Communal - Code: 51, Sub-Element: Common Booster Pump - Code: 10', +# 'Element: Communal - Code: 51, Sub-Element: Common Dry Riser - Code: 15', +# 'Element: Communal - Code: 51, Sub-Element: Common Lightning Conductor - Code: 26', +# 'Element: Communal - Code: 51, Sub-Element: Common Bin Store Roof - Code: 6', +# 'Element: Communal - Code: 51, Sub-Element: Common Bathroom - Code: 4', +# 'Element: Communal - Code: 51, Sub-Element: Common WC - Code: 38', +# 'Element: External - Code: 53, Sub-Element: Wall Insulation - Code: 36', +# 'Element: External - Code: 53, Sub-Element: Garage Door - Code: 12', +# 'Element: Communal - Code: 51, Sub-Element: Common Cold Water Storage Tank - Code: 13', +# 'Element: Communal - Code: 51, Sub-Element: Common Sprinker - Code: 31', +# 'Element: External - Code: 53, Sub-Element: Garage Walls - Code: 14', +# 'Element: Communal - Code: 51, Sub-Element: Communal Plug Sockets - Code: 40', +# 'Element: Communal - Code: 51, Sub-Element: Common Wet Riser - Code: 39', +# 'Element: Communal - Code: 51, Sub-Element: Common Refuse Chute - Code: 29', +# 'Element: External - Code: 53, Sub-Element: Secondary Glazing - Code: 29', +# 'Element: External - Code: 53, Sub-Element: Solar Thermals - Code: 34', +# 'Element: External - Code: 53, Sub-Element: Garage Roof - Code: 13', +# 'Element: External - Code: 53, Sub-Element: Patio/French Door - Code: 19', +# 'Element: External - Code: 53, Sub-Element: Rear Door Material - Code: 25', +# 'Element: Internal - Code: 50, Sub-Element: Party Wall Fire Break - Code: 16', +# 'Element: Internal - Code: 50, Sub-Element: Boiler Type - Code: 25', +# 'Element: External - Code: 53, Sub-Element: Roof Structure - Code: 47', +# 'Element: External - Code: 53, Sub-Element: Front Door Type - Code: 43', +# 'Element: Communal - Code: 51, Sub-Element: Common Cirulation Space - Code: 12', +# 'Element: External - Code: 53, Sub-Element: External Noise Insulation - Code: 5', +# 'Element: Internal - Code: 50, Sub-Element: Door Entry Handset - Code: 8', +# 'Element: Internal - Code: 50, Sub-Element: Cold Water Storage Tank - Code: 6', +# 'Element: Internal - Code: 50, Sub-Element: Programmable Heating - Code: 19', +# 'Element: Internal - Code: 50, Sub-Element: Central Heating Extent - Code: 5', +# 'Element: Internal - Code: 50, Sub-Element: Kitchen Space & Layout - Code: 13', +# 'Element: Internal - Code: 50, Sub-Element: Loft Insulation - Code: 15', +# 'Element: Internal - Code: 50, Sub-Element: Stairlift - Code: 22', +# 'Element: Internal - Code: 50, Sub-Element: Primary Bathroom Location - Code: 17', +# 'Element: Internal - Code: 50, Sub-Element: Disabled Hoist Tracking - Code: 7', +# 'Element: External - Code: 53, Sub-Element: Garage Type - Code: 44', +# 'Element: External - Code: 53, Sub-Element: Private Balcony Balustrade Material - Code: 45', +# 'Element: Internal - Code: 50, Sub-Element: Disabled Facilities - Code: 26' diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index 44dbd56e..dea07756 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -6,9 +6,6 @@ from backend.condition.domain.mapping.peabody.peabody_element_map import ( PEABODY_ELEMENT_MAP, ) from backend.condition.domain.mapping.mapper import Mapper -from backend.condition.parsing.records.peabody.peabody_asset_condition import ( - PeabodyAssetCondition, -) from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty from utils.logger import setup_logger diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py index ca3f78a4..7fad77f7 100644 --- a/backend/condition/tests/mapping/test_peabody_mapper.py +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -33,7 +33,25 @@ def test_peabody_mapper_maps_property(): cloned="N", lo_type_code=1, condition_survey_date=datetime(2024, 2, 15, 12, 47, 0), - ) + ), + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=100, + element="GENERAL", + sub_element_code=15, + sub_element="External Decoration", + material_code=2, + material_or_answer="Normal", + renewal_quantity=1, + renewal_year=2029, + renewal_cost=1500, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2024, 2, 15, 12, 47, 0), + ), ], ) mapper = PeabodyMapper() @@ -50,7 +68,19 @@ def test_peabody_mapper_maps_property(): element_instance=None, source_system=None, comments=None, - ) + ), + AssetCondition( + uprn=1, + element=Element.PROPERTY, + aspect_type=AspectType.EXTERNAL_DECORATION, + value="Normal", + quantity=1, + install_date=None, + renewal_year=2029, + element_instance=None, + source_system=None, + comments=None, + ), ] # act actual_assets = mapper.map_asset_conditions_for_property(peabody_property) From 7741373671093bc0c4bdaa2fd01aabc70dc9950d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 23 Jan 2026 15:54:53 +0000 Subject: [PATCH 53/74] Map more peabody elements --- backend/condition/domain/aspect_type.py | 1 + backend/condition/domain/element.py | 8 ++ .../mapping/peabody/peabody_element_map.py | 104 +++++++++++++----- 3 files changed, 86 insertions(+), 27 deletions(-) diff --git a/backend/condition/domain/aspect_type.py b/backend/condition/domain/aspect_type.py index d4db10bc..94522b03 100644 --- a/backend/condition/domain/aspect_type.py +++ b/backend/condition/domain/aspect_type.py @@ -28,3 +28,4 @@ class AspectType(str, Enum): COVERING = "covering" FIRE_RATING = "fire_rating" EXTERNAL_DECORATION = "external_decoration" + WORK_REQUIRED = "work_required" diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index c8fb6167..1d109002 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -23,6 +23,9 @@ class Element(str, Enum): RAINWATER_GOODS = "rainwater_goods" LOFT_INSULATION = "loft_insulation" PORCH_CANOPY = "porch_canopy" + CHIMNEY = "chimney" + FASCIA = "fascia" + SOFFIT = "soffit" # ====================== # EXTERNAL – WALLS @@ -45,6 +48,8 @@ class Element(str, Enum): STORE_DOOR = "store_door" GARAGE_DOOR = "garage_door" COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door" + MAIN_DOOR = "main_door" + BLOCK_ENTRANCE_DOOR = "block_entrance_door" # ====================== # EXTERNAL – AREAS @@ -59,6 +64,8 @@ class Element(str, Enum): BALCONY_BALUSTRADE = "balcony_balustrade" OUTBUILDINGS = "outbuildings" GARAGE_STRUCTURE = "garage_structure" + PAVING = "paving" + ROADS = "roads" # ====================== # INTERNAL – KITCHEN @@ -110,6 +117,7 @@ class Element(str, Enum): COMMUNAL_CCTV = "communal_cctv" COMMUNAL_BIN_STORE = "communal_bin_store" COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute" + COMMUNAL_FLOOR_COVERING = "communal_floor_covering" # ========================================================== # HHSRS – ALL 29 HAZARDS diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 80b3aa70..12c4bf66 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -25,13 +25,65 @@ PEABODY_ELEMENT_MAP = { # ========================================================== # EXTERNAL – ROOFS # ========================================================== - (110, 1): ElementMapping(element=Element.ROOF, aspect_type=AspectType.COVERING), + (110, 1): ElementMapping( + element=Element.ROOF, aspect_type=AspectType.MATERIAL, element_instance=1 + ), + (110, 2): ElementMapping( + element=Element.ROOF, aspect_type=AspectType.MATERIAL, element_instance=1 + ), + (110, 3): ElementMapping( + element=Element.CHIMNEY, aspect_type=AspectType.WORK_REQUIRED + ), + (110, 4): ElementMapping(element=Element.FASCIA, aspect_type=AspectType.MATERIAL), + (110, 5): ElementMapping(element=Element.SOFFIT, aspect_type=AspectType.MATERIAL), + (110, 6): ElementMapping( + element=Element.RAINWATER_GOODS, aspect_type=AspectType.MATERIAL + ), + (110, 7): ElementMapping( + element=Element.LOFT_INSULATION, + aspect_type=AspectType.WORK_REQUIRED, # possibly not the right aspect type + ), + (110, 8): ElementMapping( + element=Element.PORCH_CANOPY, aspect_type=AspectType.MATERIAL + ), # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== (130, 1): ElementMapping( element=Element.EXTERNAL_WINDOWS, aspect_type=AspectType.MATERIAL ), + (130, 2): ElementMapping( + element=Element.COMMUNAL_WINDOWS, aspect_type=AspectType.MATERIAL + ), + (140, 1): ElementMapping( + element=Element.MAIN_DOOR, aspect_type=AspectType.MATERIAL + ), + (140, 2): ElementMapping( + element=Element.STORE_DOOR, aspect_type=AspectType.MATERIAL + ), + (140, 3): ElementMapping( + element=Element.GARAGE_DOOR, aspect_type=AspectType.MATERIAL + ), + (140, 4): ElementMapping( + element=Element.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL + ), + # ========================================================== + # EXTERNAL AREAS + # ========================================================== + (150, 1): ElementMapping( + element=Element.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL + ), + (150, 2): ElementMapping( + element=Element.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL + ), + (150, 3): ElementMapping(element=Element.ROADS, aspect_type=AspectType.MATERIAL), + (150, 4): ElementMapping( + element=Element.BOUNDARY_WALLS, aspect_type=AspectType.MATERIAL + ), + (150, 5): ElementMapping(element=Element.OUTBUILDINGS, aspect_type=AspectType.TYPE), + (150, 6): ElementMapping( + element=Element.GARAGE_STRUCTURE, aspect_type=AspectType.TYPE + ), # ========================================================== # INTERNAL – BATHROOMS & KITCHENS # ========================================================== @@ -39,9 +91,30 @@ PEABODY_ELEMENT_MAP = { (190, 1): ElementMapping( element=Element.BATHROOM, aspect_type=AspectType.CONDITION ), - # -------------------- - # HHSRS (PEABODY) - # -------------------- + # ========================================================== + # COMMUNAL SYSTEMS + # ========================================================== + (200, 1): ElementMapping( + element=Element.COMMUNAL_BOILER, aspect_type=AspectType.TYPE + ), + (200, 2): ElementMapping( + element=Element.COMMUNAL_HEATING, aspect_type=AspectType.TYPE + ), + (200, 3): ElementMapping( + element=Element.COMMUNAL_ELECTRICS, aspect_type=AspectType.TYPE + ), + (200, 4): ElementMapping( + element=Element.COMMUNAL_FIRE_ALARM, aspect_type=AspectType.TYPE + ), + (200, 5): ElementMapping( + element=Element.COMMUNAL_LIFT, aspect_type=AspectType.TYPE + ), + (200, 6): ElementMapping( + element=Element.COMMUNAL_FLOOR_COVERING, aspect_type=AspectType.MATERIAL + ), + # ========================================================== + # HHSRS + # ========================================================== (54, 1): ElementMapping( element=Element.HHSRS_DAMP_AND_MOULD, aspect_type=AspectType.RISK ), @@ -58,22 +131,7 @@ PEABODY_ELEMENT_MAP = { # unhandled -# 'Element: ROOFS - Code: 110, Sub-Element: Chimney - Code: 3', -# 'Element: ROOFS - Code: 110, Sub-Element: Fascia - Code: 4', -# 'Element: ROOFS - Code: 110, Sub-Element: Rainwater Goods - Code: 6', -# 'Element: WINDOWS - Code: 130, Sub-Element: Communal Windows - Code: 2', -# 'Element: DOORS - Code: 140, Sub-Element: Main Doors - Code: 1', -# 'Element: DOORS - Code: 140, Sub-Element: Block Entrance Doors - Code: 4', -# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Paving - Code: 1', -# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Boundaries - Code: 4', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Boiler - Code: 1', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Heating - Code: 2', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Electrics - Code: 3', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Fire Detection - Code: 4', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Floor Covering - Code: 6', -# 'Element: ROOFS - Code: 110, Sub-Element: Soffit - Code: 5', # 'Element: External - Code: 53, Sub-Element: Window Type 01 - Code: 38', -# 'Element: ROOFS - Code: 110, Sub-Element: Secondary Roof - Code: 2', # 'Element: HEATING - Code: 170, Sub-Element: Boiler - Code: 1', # 'Element: HEATING - Code: 170, Sub-Element: Heating Distribution - Code: 2', # 'Element: ELECTRICS - Code: 180, Sub-Element: Wiring - Code: 1', @@ -83,16 +141,9 @@ PEABODY_ELEMENT_MAP = { # 'Element: HEATING - Code: 170, Sub-Element: Secondary Heating - Code: 3', # 'Element: BATHROOM - Code: 190, Sub-Element: Secondary Toilet - Code: 2', # 'Element: ELECTRICS - Code: 180, Sub-Element: Carbon Monoxide Alarms - Code: 4', -# 'Element: ROOFS - Code: 110, Sub-Element: Porch/Bay/Canopy - Code: 8', # 'Element: HEATING - Code: 170, Sub-Element: Hot Water - Code: 5', -# 'Element: DOORS - Code: 140, Sub-Element: Garage Doors - Code: 3', # 'Element: HEATING - Code: 170, Sub-Element: Cold Water - Code: 4', -# 'Element: DOORS - Code: 140, Sub-Element: Store Doors - Code: 2', -# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Hardstanding - Code: 2', # 'Element: WALLS - Code: 120, Sub-Element: Wall Insulation - Code: 3', -# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Roads - Code: 3', -# 'Element: ROOFS - Code: 110, Sub-Element: Loft Insulation - Code: 7', -# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Outbuilding - Code: 5', # 'Element: Internal - Code: 50, Sub-Element: Additional WC - Code: 1', # 'Element: Internal - Code: 50, Sub-Element: Carbon Monoxide Detector Type - Code: 2', # 'Element: Internal - Code: 50, Sub-Element: CCU - Code: 3', @@ -103,7 +154,6 @@ PEABODY_ELEMENT_MAP = { # 'Element: Internal - Code: 50, Sub-Element: Kitchen Type - Code: 14', # 'Element: Internal - Code: 50, Sub-Element: Primary Bathroom Type - Code: 18', # 'Element: Internal - Code: 50, Sub-Element: Smoke Detector Type - Code: 21', -# 'Element: EXTERNAL AREAS - Code: 150, Sub-Element: Garage - Code: 6', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Lifts - Code: 5', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Kitchen - Code: 7', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Bathroom - Code: 8', From 8cdbe107e5f9c306efc1b6d0e12cf05e99057d0c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 23 Jan 2026 16:21:33 +0000 Subject: [PATCH 54/74] add TODO to is_block_level --- .../records/peabody/peabody_asset_condition.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/condition/parsing/records/peabody/peabody_asset_condition.py b/backend/condition/parsing/records/peabody/peabody_asset_condition.py index 01215a26..a74dc359 100644 --- a/backend/condition/parsing/records/peabody/peabody_asset_condition.py +++ b/backend/condition/parsing/records/peabody/peabody_asset_condition.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Optional + @dataclass class PeabodyAssetCondition: lo_reference: str @@ -25,17 +26,19 @@ class PeabodyAssetCondition: @property def is_block_level(self) -> bool: + # TODO: maybe use block codes from other Peabody dataset to do this instead + if not self.full_address: return False address = self.full_address.upper() block_level_patterns = [ - r"\bBLOCK\b", # BLOCK MILNE HOUSE - r"\bFLATS\b", # FLATS A-D - r"\b\d+[A-Z]?-\d+[A-Z]?\b", # 1-80, 9A-9H - r"\b\d+[A-Z]-[A-Z]\b", # 81A-B - r"\b\d+\s*&\s*\d+\b", # 73 & 74 + r"\bBLOCK\b", # BLOCK MILNE HOUSE + r"\bFLATS\b", # FLATS A-D + r"\b\d+[A-Z]?-\d+[A-Z]?\b", # 1-80, 9A-9H + r"\b\d+[A-Z]-[A-Z]\b", # 81A-B + r"\b\d+\s*&\s*\d+\b", # 73 & 74 ] - return any(re.search(pattern, address) for pattern in block_level_patterns) \ No newline at end of file + return any(re.search(pattern, address) for pattern in block_level_patterns) From 2d77511650219f4a6f931c318a88107156a587ca Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 23 Jan 2026 17:11:11 +0000 Subject: [PATCH 55/74] Map remaining Peabody EXTERNAL elements --- backend/condition/domain/element.py | 23 ++- .../mapping/peabody/peabody_element_map.py | 160 +++++++++++++----- .../tests/mapping/test_peabody_mapper.py | 4 +- 3 files changed, 137 insertions(+), 50 deletions(-) diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index 1d109002..fed5ab3b 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -20,17 +20,30 @@ class Element(str, Enum): # EXTERNAL – ROOF # ====================== ROOF = "roof" + PITCHED_ROOF_COVERING = "pitched_roof_covering" + FLAT_ROOF_COVERING = "flat_roof_covering" RAINWATER_GOODS = "rainwater_goods" LOFT_INSULATION = "loft_insulation" PORCH_CANOPY = "porch_canopy" CHIMNEY = "chimney" FASCIA = "fascia" SOFFIT = "soffit" + FASCIA_SOFFIT_BARGEBOARDS = "fascia_soffit_bargeboards" + GUTTERS = "gutters" + GARAGE_ROOF = "garage_roof" # ====================== # EXTERNAL – WALLS # ====================== EXTERNAL_WALL = "external_wall" + EXTERNAL_NOISE_INSULATION = "external_noise_insulation" + PRIMARY_WALL = "primary_wall" + SECONDARY_WALL = "secondary_wall" + DOWNPIPES = "downpipes" + EXTERNAL_DECORATION = "external_decoration" + CLADDING = "cladding" + SPANDREL_PANELS = "spandrel_panels" + GARAGE_WALLS = "garage_walls" # ====================== # EXTERNAL – WINDOWS @@ -50,6 +63,8 @@ class Element(str, Enum): COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door" MAIN_DOOR = "main_door" BLOCK_ENTRANCE_DOOR = "block_entrance_door" + LINTEL = "lintel" + PATIO_FRENCH_DOOR = "patio_french_door" # ====================== # EXTERNAL – AREAS @@ -57,7 +72,11 @@ class Element(str, Enum): PATHS_AND_HARDSTANDINGS = "paths_and_hardstandings" PARKING_AREAS = "parking_areas" BOUNDARY_WALLS = "boundary_walls" - FENCING = "fencing" + FRONT_FENCING = "front_fencing" + REAR_FENCING = "rear_fencing" + SIDE_FENCING = "side_fencing" + REAR_GATE = "rear_gate" + FRONT_GATE = "front_gate" GATES = "gates" RETAINING_WALLS = "retaining_walls" PRIVATE_BALCONY = "private_balcony" @@ -66,6 +85,8 @@ class Element(str, Enum): GARAGE_STRUCTURE = "garage_structure" PAVING = "paving" ROADS = "roads" + SOIL_AND_VENT = "soil_and_vent" + SOLAR_THERMALS = "solar_thermals" # ====================== # INTERNAL – KITCHEN diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 12c4bf66..81aa8b9e 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -8,14 +8,39 @@ PEABODY_ELEMENT_MAP = { # PROPERTY / GENERAL # ========================================================== (100, 1): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.TYPE), - (100, 15): ElementMapping( - element=Element.PROPERTY, aspect_type=AspectType.EXTERNAL_DECORATION - ), # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE), # (100, 14): ElementMapping(element="property", aspect_type="construction_type"), # ========================================================== # EXTERNAL – WALLS # ========================================================== + (53, 1): ElementMapping( + element=Element.BOUNDARY_WALLS, aspect_type=AspectType.PRESENCE + ), + (53, 4): ElementMapping( + element=Element.EXTERNAL_DECORATION, aspect_type=AspectType.PRESENCE + ), + (53, 4): ElementMapping( + element=Element.EXTERNAL_NOISE_INSULATION, aspect_type=AspectType.ADEQUACY + ), + (53, 14): ElementMapping( + element=Element.GARAGE_WALLS, aspect_type=AspectType.MATERIAL + ), + (53, 23): ElementMapping( + element=Element.PRIMARY_WALL, aspect_type=AspectType.FINISH + ), + (53, 30): ElementMapping( + element=Element.SECONDARY_WALL, aspect_type=AspectType.FINISH + ), # Should this be combined with primary wall, with different instance value? + (53, 36): ElementMapping( + element=Element.EXTERNAL_WALL, aspect_type=AspectType.INSULATION + ), + (53, 40): ElementMapping( + element=Element.SPANDREL_PANELS, aspect_type=AspectType.MATERIAL + ), + (53, 41): ElementMapping(element=Element.CLADDING, aspect_type=AspectType.MATERIAL), + (100, 15): ElementMapping( + element=Element.EXTERNAL_DECORATION, aspect_type=AspectType.CONDITION + ), (120, 1): ElementMapping( element=Element.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE ), @@ -25,6 +50,22 @@ PEABODY_ELEMENT_MAP = { # ========================================================== # EXTERNAL – ROOFS # ========================================================== + (53, 2): ElementMapping(element=Element.CHIMNEY, aspect_type=AspectType.PRESENCE), + (53, 6): ElementMapping( + element=Element.FASCIA_SOFFIT_BARGEBOARDS, aspect_type=AspectType.MATERIAL + ), + (53, 7): ElementMapping( + element=Element.FLAT_ROOF_COVERING, aspect_type=AspectType.MATERIAL + ), + (53, 13): ElementMapping( + element=Element.GARAGE_ROOF, aspect_type=AspectType.MATERIAL + ), + (53, 15): ElementMapping(element=Element.GUTTERS, aspect_type=AspectType.MATERIAL), + (53, 18): ElementMapping( + element=Element.PITCHED_ROOF_COVERING, aspect_type=AspectType.MATERIAL + ), + (53, 22): ElementMapping(element=Element.PORCH_CANOPY, aspect_type=AspectType.TYPE), + (53, 47): ElementMapping(element=Element.ROOF, aspect_type=AspectType.STRUCTURE), (110, 1): ElementMapping( element=Element.ROOF, aspect_type=AspectType.MATERIAL, element_instance=1 ), @@ -49,6 +90,36 @@ PEABODY_ELEMENT_MAP = { # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== + (53, 8): ElementMapping( + element=Element.FRONT_DOOR, aspect_type=AspectType.MATERIAL + ), + (53, 12): ElementMapping( + element=Element.GARAGE_DOOR, aspect_type=AspectType.MATERIAL + ), + (53, 16): ElementMapping(element=Element.LINTEL, aspect_type=AspectType.PRESENCE), + (53, 19): ElementMapping( + element=Element.PATIO_FRENCH_DOOR, aspect_type=AspectType.MATERIAL + ), + (53, 25): ElementMapping( + element=Element.REAR_DOOR, aspect_type=AspectType.MATERIAL + ), + (53, 29): ElementMapping( + element=Element.SECONDARY_GLAZING, aspect_type=AspectType.PRESENCE + ), + (53, 35): ElementMapping( + element=Element.STORE_DOOR, aspect_type=AspectType.MATERIAL + ), + (53, 38): ElementMapping( + element=Element.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + element_instance=1, + ), + (53, 39): ElementMapping( + element=Element.EXTERNAL_WINDOWS, + aspect_type=AspectType.TYPE, + element_instance=2, + ), + (53, 43): ElementMapping(element=Element.FRONT_DOOR, aspect_type=AspectType.TYPE), (130, 1): ElementMapping( element=Element.EXTERNAL_WINDOWS, aspect_type=AspectType.MATERIAL ), @@ -60,22 +131,58 @@ PEABODY_ELEMENT_MAP = { ), (140, 2): ElementMapping( element=Element.STORE_DOOR, aspect_type=AspectType.MATERIAL - ), + ), # Duplicate of (53, 35) (140, 3): ElementMapping( element=Element.GARAGE_DOOR, aspect_type=AspectType.MATERIAL - ), + ), # Duplicate of (53, 12) (140, 4): ElementMapping( element=Element.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL ), # ========================================================== # EXTERNAL AREAS # ========================================================== + (53, 3): ElementMapping(element=Element.DOWNPIPES, aspect_type=AspectType.MATERIAL), + (53, 9): ElementMapping( + element=Element.FRONT_FENCING, aspect_type=AspectType.MATERIAL + ), + (53, 10): ElementMapping(element=Element.FRONT_GATE, aspect_type=AspectType.TYPE), + (53, 17): ElementMapping( + element=Element.PARKING_AREAS, aspect_type=AspectType.MATERIAL + ), + (53, 18): ElementMapping( + element=Element.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL + ), + (53, 24): ElementMapping( + element=Element.PRIVATE_BALCONY, aspect_type=AspectType.PRESENCE + ), + (53, 26): ElementMapping( + element=Element.REAR_FENCING, aspect_type=AspectType.MATERIAL + ), + (53, 27): ElementMapping(element=Element.REAR_GATE, aspect_type=AspectType.TYPE), + (53, 28): ElementMapping( + element=Element.RETAINING_WALLS, aspect_type=AspectType.PRESENCE + ), + (53, 31): ElementMapping( + element=Element.SIDE_FENCING, aspect_type=AspectType.MATERIAL + ), + (53, 32): ElementMapping( + element=Element.SOIL_AND_VENT, aspect_type=AspectType.MATERIAL + ), + (53, 34): ElementMapping( + element=Element.SOLAR_THERMALS, aspect_type=AspectType.PRESENCE + ), + (53, 44): ElementMapping( + element=Element.GARAGE_STRUCTURE, aspect_type=AspectType.TYPE + ), + (53, 45): ElementMapping( + element=Element.BALCONY_BALUSTRADE, aspect_type=AspectType.MATERIAL + ), (150, 1): ElementMapping( element=Element.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL ), (150, 2): ElementMapping( element=Element.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL - ), + ), # Duplicate of (53, 18) - correct? (150, 3): ElementMapping(element=Element.ROADS, aspect_type=AspectType.MATERIAL), (150, 4): ElementMapping( element=Element.BOUNDARY_WALLS, aspect_type=AspectType.MATERIAL @@ -131,7 +238,6 @@ PEABODY_ELEMENT_MAP = { # unhandled -# 'Element: External - Code: 53, Sub-Element: Window Type 01 - Code: 38', # 'Element: HEATING - Code: 170, Sub-Element: Boiler - Code: 1', # 'Element: HEATING - Code: 170, Sub-Element: Heating Distribution - Code: 2', # 'Element: ELECTRICS - Code: 180, Sub-Element: Wiring - Code: 1', @@ -159,18 +265,9 @@ PEABODY_ELEMENT_MAP = { # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Bathroom - Code: 8', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Toilets - Code: 9', # 'Element: Internal - Code: 50, Sub-Element: Wiring - Code: 24', -# 'Element: External - Code: 53, Sub-Element: Front Door Material - Code: 8', -# 'Element: External - Code: 53, Sub-Element: Primary Wall Finish - Code: 23', # 'Element: PASSENGER LIFTS - Code: 210, Sub-Element: Lift - Code: 2', # 'Element: Internal - Code: 50, Sub-Element: Heating Distribution Type - Code: 12', -# 'Element: External - Code: 53, Sub-Element: Downpipes - Code: 3', -# 'Element: External - Code: 53, Sub-Element: Fascia/Soffits/Bargeboards - Code: 6', -# 'Element: External - Code: 53, Sub-Element: Gutters - Code: 15', -# 'Element: External - Code: 53, Sub-Element: Paths & Hardstandings - Code: 18', -# 'Element: External - Code: 53, Sub-Element: Pitched Roof Covering Material - Code: 21', # 'Element: Internal - Code: 50, Sub-Element: Secondary Bathroom Type - Code: 20', -# 'Element: External - Code: 53, Sub-Element: Chimney - Code: 2', -# 'Element: External - Code: 53, Sub-Element: External Decoration - Code: 4', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Gates - Code: 10', # 'Element: GENERAL - Code: 100, Sub-Element: Property Age Band - Code: 3', # 'Element: GENERAL - Code: 100, Sub-Element: Construction Type - Code: 14', @@ -179,46 +276,28 @@ PEABODY_ELEMENT_MAP = { # 'Element: Communal - Code: 51, Sub-Element: Common Bin Store Doors - Code: 5', # 'Element: Communal - Code: 51, Sub-Element: Common Bin Store Walls - Code: 7', # 'Element: Communal - Code: 51, Sub-Element: Common Primary Entrance Material - Code: 28', -# 'Element: External - Code: 53, Sub-Element: Parking Areas - Code: 17', -# 'Element: External - Code: 53, Sub-Element: Front Fencing - Code: 9', -# 'Element: External - Code: 53, Sub-Element: Retaining Walls - Code: 28', # 'Element: Communal - Code: 51, Sub-Element: Common Internal Decorations - Code: 20', # 'Element: Communal - Code: 51, Sub-Element: Common Internal Floor Finish - Code: 22', # 'Element: Communal - Code: 51, Sub-Element: Common Walkways Finish - Code: 36', -# 'Element: External - Code: 53, Sub-Element: Boundary Walls - Code: 1', -# 'Element: External - Code: 53, Sub-Element: Flat Roof Covering Material - Code: 7', -# 'Element: External - Code: 53, Sub-Element: Porch/Canopy - Code: 22', -# 'Element: External - Code: 53, Sub-Element: Private Balcony - Code: 24', -# 'Element: External - Code: 53, Sub-Element: Rear Gate - Code: 27', # 'Element: Communal - Code: 51, Sub-Element: Common External Doors Other - Code: 17', # 'Element: Communal - Code: 51, Sub-Element: Common Stair Finish - Code: 32', -# 'Element: External - Code: 53, Sub-Element: Front Gate - Code: 10', -# 'Element: External - Code: 53, Sub-Element: Rear Fencing - Code: 26', -# 'Element: External - Code: 53, Sub-Element: Side Fencing - Code: 31', # 'Element: Communal - Code: 51, Sub-Element: Common Aerial - Code: 1', # 'Element: Communal - Code: 51, Sub-Element: Common AOV - Code: 2', # 'Element: Communal - Code: 51, Sub-Element: Common Door Entry System - Code: 14', # 'Element: Communal - Code: 51, Sub-Element: Common Fire Alarm - Code: 19', # 'Element: Communal - Code: 51, Sub-Element: Common Internal Doors - Code: 21', -# 'Element: External - Code: 53, Sub-Element: Store Door Material - Code: 35', -# 'Element: External - Code: 53, Sub-Element: Secondary Wall Finish - Code: 30', # 'Element: Communal - Code: 51, Sub-Element: Common Emergency Lighting - Code: 16', # 'Element: Communal - Code: 51, Sub-Element: Common Lateral Mains - Code: 24', # 'Element: Communal - Code: 51, Sub-Element: Common Lighting - Code: 25', # 'Element: Communal - Code: 51, Sub-Element: Common Store Roof - Code: 34', # 'Element: Communal - Code: 51, Sub-Element: Common Store Walls - Code: 35', -# 'Element: External - Code: 53, Sub-Element: Cladding Material - Code: 41', -# 'Element: External - Code: 53, Sub-Element: Spandrel Panels - Code: 40', # 'Element: Communal - Code: 51, Sub-Element: Common CCTV - Code: 11', # 'Element: Communal - Code: 51, Sub-Element: Common Kitchen - Code: 23', # 'Element: Communal - Code: 51, Sub-Element: Common Secondary Entrance Material - Code: 30', # 'Element: Communal - Code: 51, Sub-Element: Common Warden Call System - Code: 37', -# 'Element: External - Code: 53, Sub-Element: Lintels - Code: 16', # 'Element: Communal - Code: 51, Sub-Element: Common Boiler - Code: 9', -# 'Element: External - Code: 53, Sub-Element: Soil & Vent Material - Code: 32', # 'Element: Communal - Code: 51, Sub-Element: Common Passenger Lift - Code: 27', # 'Element: Communal - Code: 51, Sub-Element: Common Store Doors - Code: 33', -# 'Element: External - Code: 53, Sub-Element: Window Type 02 - Code: 39', # 'Element: Communal - Code: 51, Sub-Element: Common BMS - Code: 8', # 'Element: Communal - Code: 51, Sub-Element: Common Booster Pump - Code: 10', # 'Element: Communal - Code: 51, Sub-Element: Common Dry Riser - Code: 15', @@ -226,25 +305,14 @@ PEABODY_ELEMENT_MAP = { # 'Element: Communal - Code: 51, Sub-Element: Common Bin Store Roof - Code: 6', # 'Element: Communal - Code: 51, Sub-Element: Common Bathroom - Code: 4', # 'Element: Communal - Code: 51, Sub-Element: Common WC - Code: 38', -# 'Element: External - Code: 53, Sub-Element: Wall Insulation - Code: 36', -# 'Element: External - Code: 53, Sub-Element: Garage Door - Code: 12', # 'Element: Communal - Code: 51, Sub-Element: Common Cold Water Storage Tank - Code: 13', # 'Element: Communal - Code: 51, Sub-Element: Common Sprinker - Code: 31', -# 'Element: External - Code: 53, Sub-Element: Garage Walls - Code: 14', # 'Element: Communal - Code: 51, Sub-Element: Communal Plug Sockets - Code: 40', # 'Element: Communal - Code: 51, Sub-Element: Common Wet Riser - Code: 39', # 'Element: Communal - Code: 51, Sub-Element: Common Refuse Chute - Code: 29', -# 'Element: External - Code: 53, Sub-Element: Secondary Glazing - Code: 29', -# 'Element: External - Code: 53, Sub-Element: Solar Thermals - Code: 34', -# 'Element: External - Code: 53, Sub-Element: Garage Roof - Code: 13', -# 'Element: External - Code: 53, Sub-Element: Patio/French Door - Code: 19', -# 'Element: External - Code: 53, Sub-Element: Rear Door Material - Code: 25', # 'Element: Internal - Code: 50, Sub-Element: Party Wall Fire Break - Code: 16', # 'Element: Internal - Code: 50, Sub-Element: Boiler Type - Code: 25', -# 'Element: External - Code: 53, Sub-Element: Roof Structure - Code: 47', -# 'Element: External - Code: 53, Sub-Element: Front Door Type - Code: 43', # 'Element: Communal - Code: 51, Sub-Element: Common Cirulation Space - Code: 12', -# 'Element: External - Code: 53, Sub-Element: External Noise Insulation - Code: 5', # 'Element: Internal - Code: 50, Sub-Element: Door Entry Handset - Code: 8', # 'Element: Internal - Code: 50, Sub-Element: Cold Water Storage Tank - Code: 6', # 'Element: Internal - Code: 50, Sub-Element: Programmable Heating - Code: 19', @@ -254,6 +322,4 @@ PEABODY_ELEMENT_MAP = { # 'Element: Internal - Code: 50, Sub-Element: Stairlift - Code: 22', # 'Element: Internal - Code: 50, Sub-Element: Primary Bathroom Location - Code: 17', # 'Element: Internal - Code: 50, Sub-Element: Disabled Hoist Tracking - Code: 7', -# 'Element: External - Code: 53, Sub-Element: Garage Type - Code: 44', -# 'Element: External - Code: 53, Sub-Element: Private Balcony Balustrade Material - Code: 45', # 'Element: Internal - Code: 50, Sub-Element: Disabled Facilities - Code: 26' diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py index 7fad77f7..a975a308 100644 --- a/backend/condition/tests/mapping/test_peabody_mapper.py +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -71,8 +71,8 @@ def test_peabody_mapper_maps_property(): ), AssetCondition( uprn=1, - element=Element.PROPERTY, - aspect_type=AspectType.EXTERNAL_DECORATION, + element=Element.EXTERNAL_DECORATION, + aspect_type=AspectType.CONDITION, value="Normal", quantity=1, install_date=None, From 793ae8098f69062291490c4bbc33ee5202188743 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 26 Jan 2026 10:25:03 +0000 Subject: [PATCH 56/74] More peabody -> domain mapping --- backend/condition/domain/element.py | 8 +- .../domain/mapping/lbwf/lbwf_element_map.py | 4 +- .../mapping/peabody/peabody_element_map.py | 101 +++++++++++++----- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index fed5ab3b..5fbd35dc 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -15,6 +15,7 @@ class Element(str, Enum): ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register" ASBESTOS = "asbestos" QUALITY_STANDARD = "quality_standard" + CCU = "ccu" # ====================== # EXTERNAL – ROOF @@ -94,18 +95,22 @@ class Element(str, Enum): KITCHEN = "kitchen" KITCHEN_SPACE_LAYOUT = "kitchen_space_layout" TENANT_INSTALLED_KITCHEN = "tenant_installed_kitchen" + KITCHEN_EXTRACTOR_FAN = "kitchen_extractor_fan" # ====================== # INTERNAL – BATHROOM # ====================== BATHROOM = "bathroom" + SECONDARY_BATHROOM = "secondary_bathroom" + SECONDARY_TOILET = "secondary_toilet" + BATHROOM_EXTRACTOR_FAN = "bathroom_extractor_fan" # ====================== # INTERNAL – HEATING / WATER # ====================== + CENTRAL_HEATING = "central_heating" HEATING_BOILER = "heating_boiler" HEATING_DISTRIBUTION = "heating_distribution" - HEATING_EXTENT = "heating_extent" SECONDARY_HEATING = "secondary_heating" HOT_WATER_SYSTEM = "hot_water_system" COLD_WATER_STORAGE = "cold_water_storage" @@ -113,6 +118,7 @@ class Element(str, Enum): HEATING_SYSTEM = "heating_system" BOILER_FUEL = "boiler_fuel" WATER_HEATING = "water_heating" + PROGRAMMABLE_HEATING = "programmable_heating" # ====================== # INTERNAL – ELECTRICS / FIRE diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index 047013f4..be8a50b2 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -61,8 +61,8 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # INTERNAL – HEATING # ========================================================== "INTCHEXTNT": ElementMapping( - element=Element.HEATING_EXTENT, - aspect_type=AspectType.CONFIGURATION, + element=Element.CENTRAL_HEATING, + aspect_type=AspectType.EXTENT, ), "INTCHDIST": ElementMapping( element=Element.HEATING_DISTRIBUTION, diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 81aa8b9e..8c29c60b 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -10,6 +10,16 @@ PEABODY_ELEMENT_MAP = { (100, 1): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.TYPE), # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE), # (100, 14): ElementMapping(element="property", aspect_type="construction_type"), + (50, 2): ElementMapping( + element=Element.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE + ), + (50, 3): ElementMapping(element=Element.CCU, aspect_type=AspectType.TYPE), + (50, 11): ElementMapping( + element=Element.HEAT_DETECTION, aspect_type=AspectType.TYPE + ), + (50, 21): ElementMapping( + element=Element.SMOKE_DETECTION, aspect_type=AspectType.TYPE + ), # ========================================================== # EXTERNAL – WALLS # ========================================================== @@ -47,6 +57,9 @@ PEABODY_ELEMENT_MAP = { (120, 2): ElementMapping( element=Element.EXTERNAL_WALL, aspect_type=AspectType.FINISH ), + (120, 3): ElementMapping( + element=Element.PRIMARY_WALL, aspect_type=AspectType.INSULATION + ), # This code element code is actually "WALL" not "external wall" - correct? # ========================================================== # EXTERNAL – ROOFS # ========================================================== @@ -194,10 +207,36 @@ PEABODY_ELEMENT_MAP = { # ========================================================== # INTERNAL – BATHROOMS & KITCHENS # ========================================================== + (50, 1): ElementMapping( + element=Element.SECONDARY_TOILET, aspect_type=AspectType.PRESENCE + ), + (50, 9): ElementMapping( + element=Element.BATHROOM_EXTRACTOR_FAN, aspect_type=AspectType.PRESENCE + ), + (50, 9): ElementMapping(element=Element.KITCHEN, aspect_type=AspectType.TYPE), + (50, 10): ElementMapping( + element=Element.KITCHEN_EXTRACTOR_FAN, aspect_type=AspectType.PRESENCE + ), + (50, 13): ElementMapping( + element=Element.KITCHEN_SPACE_LAYOUT, aspect_type=AspectType.ADEQUACY + ), + (50, 17): ElementMapping(element=Element.BATHRROM, aspect_type=AspectType.LOCATION), + (50, 18): ElementMapping( + element=Element.BATHROOM, aspect_type=AspectType.TYPE + ), # Actually "Primary bathroom type" - ok like this? + (50, 20): ElementMapping( + element=Element.BATHROOM, aspect_type=AspectType.TYPE, element_instance=2 + ), # Actually "Secondary bathroom type" - ok like this? (160, 1): ElementMapping(element=Element.KITCHEN, aspect_type=AspectType.CONDITION), + (160, 2): ElementMapping( + element=Element.KITCHEN_SPACE_LAYOUT, aspect_type=AspectType.ADEQUACY + ), (190, 1): ElementMapping( element=Element.BATHROOM, aspect_type=AspectType.CONDITION ), + (190, 2): ElementMapping( + element=Element.SECONDARY_TOILET, aspect_type=AspectType.TYPE + ), # ========================================================== # COMMUNAL SYSTEMS # ========================================================== @@ -220,6 +259,42 @@ PEABODY_ELEMENT_MAP = { element=Element.COMMUNAL_FLOOR_COVERING, aspect_type=AspectType.MATERIAL ), # ========================================================== + # INTERNAL – HEATING + # ========================================================== + (50, 4): ElementMapping( + element=Element.HEATING_BOILER, aspect_type=AspectType.PRESENCE + ), # This is actually "Central heating boiler" - ok like this? + (50, 5): ElementMapping( + element=Element.CENTRAL_HEATING, aspect_type=AspectType.EXTENT + ), + (50, 6): ElementMapping( + element=Element.COLD_WATER_STORAGE, aspect_type=AspectType.PRESENCE + ), + (50, 12): ElementMapping( + element=Element.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE + ), + (50, 19): ElementMapping( + element=Element.PROGRAMMABLE_HEATING, aspect_type=AspectType.TYPE + ), + (50, 25): ElementMapping( + element=Element.HEATING_BOILER, aspect_type=AspectType.TYPE + ), + (170, 1): ElementMapping( + element=Element.HEATING_BOILER, aspect_type=AspectType.TYPE + ), # Duplicate of (50,25) - correct? + (170, 2): ElementMapping( + element=Element.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE + ), # Duplicate of (50,12) - correct? + (170, 3): ElementMapping( + element=Element.SECONDARY_HEATING, aspect_type=AspectType.TYPE + ), + (170, 4): ElementMapping( + element=Element.COLD_WATER_STORAGE, aspect_type=AspectType.TYPE + ), + (170, 5): ElementMapping( + element=Element.HOT_WATER_SYSTEM, aspect_type=AspectType.TYPE + ), + # ========================================================== # HHSRS # ========================================================== (54, 1): ElementMapping( @@ -238,36 +313,16 @@ PEABODY_ELEMENT_MAP = { # unhandled -# 'Element: HEATING - Code: 170, Sub-Element: Boiler - Code: 1', -# 'Element: HEATING - Code: 170, Sub-Element: Heating Distribution - Code: 2', # 'Element: ELECTRICS - Code: 180, Sub-Element: Wiring - Code: 1', # 'Element: ELECTRICS - Code: 180, Sub-Element: Consumer Unit - Code: 2', # 'Element: ELECTRICS - Code: 180, Sub-Element: Smoke Detectors - Code: 3', -# 'Element: KITCHEN - Code: 160, Sub-Element: Kitchen space and layout - Code: 2', -# 'Element: HEATING - Code: 170, Sub-Element: Secondary Heating - Code: 3', -# 'Element: BATHROOM - Code: 190, Sub-Element: Secondary Toilet - Code: 2', # 'Element: ELECTRICS - Code: 180, Sub-Element: Carbon Monoxide Alarms - Code: 4', -# 'Element: HEATING - Code: 170, Sub-Element: Hot Water - Code: 5', -# 'Element: HEATING - Code: 170, Sub-Element: Cold Water - Code: 4', -# 'Element: WALLS - Code: 120, Sub-Element: Wall Insulation - Code: 3', -# 'Element: Internal - Code: 50, Sub-Element: Additional WC - Code: 1', -# 'Element: Internal - Code: 50, Sub-Element: Carbon Monoxide Detector Type - Code: 2', -# 'Element: Internal - Code: 50, Sub-Element: CCU - Code: 3', -# 'Element: Internal - Code: 50, Sub-Element: Central Heating Boiler - Code: 4', -# 'Element: Internal - Code: 50, Sub-Element: Extractor Fan Bathroom - Code: 9', -# 'Element: Internal - Code: 50, Sub-Element: Extractor Fan Kitchen - Code: 10', -# 'Element: Internal - Code: 50, Sub-Element: Heat Detector Type - Code: 11', -# 'Element: Internal - Code: 50, Sub-Element: Kitchen Type - Code: 14', -# 'Element: Internal - Code: 50, Sub-Element: Primary Bathroom Type - Code: 18', -# 'Element: Internal - Code: 50, Sub-Element: Smoke Detector Type - Code: 21', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Lifts - Code: 5', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Kitchen - Code: 7', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Bathroom - Code: 8', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Toilets - Code: 9', # 'Element: Internal - Code: 50, Sub-Element: Wiring - Code: 24', # 'Element: PASSENGER LIFTS - Code: 210, Sub-Element: Lift - Code: 2', -# 'Element: Internal - Code: 50, Sub-Element: Heating Distribution Type - Code: 12', -# 'Element: Internal - Code: 50, Sub-Element: Secondary Bathroom Type - Code: 20', # 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Gates - Code: 10', # 'Element: GENERAL - Code: 100, Sub-Element: Property Age Band - Code: 3', # 'Element: GENERAL - Code: 100, Sub-Element: Construction Type - Code: 14', @@ -311,15 +366,9 @@ PEABODY_ELEMENT_MAP = { # 'Element: Communal - Code: 51, Sub-Element: Common Wet Riser - Code: 39', # 'Element: Communal - Code: 51, Sub-Element: Common Refuse Chute - Code: 29', # 'Element: Internal - Code: 50, Sub-Element: Party Wall Fire Break - Code: 16', -# 'Element: Internal - Code: 50, Sub-Element: Boiler Type - Code: 25', # 'Element: Communal - Code: 51, Sub-Element: Common Cirulation Space - Code: 12', # 'Element: Internal - Code: 50, Sub-Element: Door Entry Handset - Code: 8', -# 'Element: Internal - Code: 50, Sub-Element: Cold Water Storage Tank - Code: 6', -# 'Element: Internal - Code: 50, Sub-Element: Programmable Heating - Code: 19', -# 'Element: Internal - Code: 50, Sub-Element: Central Heating Extent - Code: 5', -# 'Element: Internal - Code: 50, Sub-Element: Kitchen Space & Layout - Code: 13', # 'Element: Internal - Code: 50, Sub-Element: Loft Insulation - Code: 15', # 'Element: Internal - Code: 50, Sub-Element: Stairlift - Code: 22', -# 'Element: Internal - Code: 50, Sub-Element: Primary Bathroom Location - Code: 17', # 'Element: Internal - Code: 50, Sub-Element: Disabled Hoist Tracking - Code: 7', # 'Element: Internal - Code: 50, Sub-Element: Disabled Facilities - Code: 26' From 3da9a643e0cdd014adf0bf633e69e75fbccdc443 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 26 Jan 2026 12:37:57 +0000 Subject: [PATCH 57/74] more peabody mappings --- backend/condition/domain/aspect_type.py | 4 + backend/condition/domain/element.py | 25 +++- .../mapping/peabody/peabody_element_map.py | 111 +++++++++++++----- 3 files changed, 110 insertions(+), 30 deletions(-) diff --git a/backend/condition/domain/aspect_type.py b/backend/condition/domain/aspect_type.py index 94522b03..2dc2be58 100644 --- a/backend/condition/domain/aspect_type.py +++ b/backend/condition/domain/aspect_type.py @@ -29,3 +29,7 @@ class AspectType(str, Enum): FIRE_RATING = "fire_rating" EXTERNAL_DECORATION = "external_decoration" WORK_REQUIRED = "work_required" + AGE_BAND = "age_band" + CONSTRUCTION_TYPE = "construction_type" + CLASSIFICATION = "classification" + SYSTEM = "system" diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index 5fbd35dc..b146d09b 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -16,6 +16,8 @@ class Element(str, Enum): ASBESTOS = "asbestos" QUALITY_STANDARD = "quality_standard" CCU = "ccu" + PASSENGER_LIFT = "passenger_lift" + STAIRLIFT = "stairlift" # ====================== # EXTERNAL – ROOF @@ -130,21 +132,40 @@ class Element(str, Enum): CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection" FIRE_DOOR_RATING = "fire_door_rating" FIRE_RISK_ASSESSMENT = "fire" + INTERNAL_WIRING = ( + "internal_wiring" # Is this definitely different from ELECTRICAL_WIRING? + ) # ====================== - # COMMUNAL SYSTEMS + # COMMUNAL # ====================== COMMUNAL_HEATING = "communal_heating" COMMUNAL_BOILER = "communal_boiler" COMMUNAL_ELECTRICS = "communal_electrics" COMMUNAL_FIRE_ALARM = "communal_fire_alarm" COMMUNAL_EMERGENCY_LIGHTING = "communal_emergency_lighting" - COMMUNAL_LIFT = "communal_lift" COMMUNAL_DOOR_ENTRY = "communal_door_entry" COMMUNAL_CCTV = "communal_cctv" COMMUNAL_BIN_STORE = "communal_bin_store" + COMMUNAL_BIN_STORE_DOORS = "communal_bin_store_doors" + COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_wall" COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute" COMMUNAL_FLOOR_COVERING = "communal_floor_covering" + COMMUNAL_KITCHEN = "communal_kitchen" + COMMUNAL_BATHROOM = "communal_bathroom" + COMMUNAL_TOILETS = "communal_toilets" + COMMUNAL_GATES = "communal_gates" + COMMUNAL_LIFT = "communal_lift" + COMMUNAL_PASSENGER_LIFT = "communal_passenger_lift" + COMMUNAL_BALCONY_WALKWAY = "communal_balcony_walkway" + COMMUNAL_PRIMARY_ENTRANCE = "communal_primary_entrance" + COMMUNAL_INTERNAL_DECORATIONS = "communal_internal_decorations" + COMMUNAL_INTERNAL_FLOOR = "communal_internal_floor" + COMMUNAL_WALKWAYS = "communal_walkways" + COMMUNAL_EXTERNAL_DOORS = "communal_external_doors" + COMMUNAL_STAIRS = "communal_stairs" + COMMUNAL_AERIAL = "communal_aerial" + COMMUNAL_AOV = "communal_aov" # ========================================================== # HHSRS – ALL 29 HAZARDS diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 8c29c60b..08e63568 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -20,6 +20,19 @@ PEABODY_ELEMENT_MAP = { (50, 21): ElementMapping( element=Element.SMOKE_DETECTION, aspect_type=AspectType.TYPE ), + (50, 22): ElementMapping( + element=Element.STAIRLIFT, aspect_type=AspectType.PRESENCE + ), + (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE_BAND), + (100, 14): ElementMapping( + element=Element.PROPERTY, aspect_type=AspectType.CONSTRUCTION_TYPE + ), + (100, 16): ElementMapping( + element=Element.PROPERTY, aspect_type=AspectType.CLASSIFICATION + ), + (210, 2): ElementMapping( + element=Element.PASSENGER_LIFT, aspect_type=AspectType.TYPE + ), # ========================================================== # EXTERNAL – WALLS # ========================================================== @@ -238,8 +251,47 @@ PEABODY_ELEMENT_MAP = { element=Element.SECONDARY_TOILET, aspect_type=AspectType.TYPE ), # ========================================================== - # COMMUNAL SYSTEMS + # COMMUNAL # ========================================================== + (51, 1): ElementMapping( + element=Element.COMMUNAL_AERIAL, aspect_type=AspectType.PRESENCE + ), + (51, 2): ElementMapping( + element=Element.COMMUNAL_AOV, aspect_type=AspectType.PRESENCE + ), + (51, 3): ElementMapping( + element=Element.COMMUNAL_BALCONY_WALKWAY, aspect_type=AspectType.PRESENCE + ), + (51, 5): ElementMapping( + element=Element.COMMUNAL_BIN_STORE_DOORS, aspect_type=AspectType.PRESENCE + ), + (51, 7): ElementMapping( + element=Element.COMMUNAL_BIN_STORE_WALLS, aspect_type=AspectType.MATERIAL + ), + (51, 14): ElementMapping( + element=Element.COMMUNAL_DOOR_ENTRY, aspect_type=AspectType.SYSTEM + ), + (51, 17): ElementMapping( + element=Element.COMMUNAL_EXTERNAL_DOORS, aspect_type=AspectType.MATERIAL + ), + (51, 20): ElementMapping( + element=Element.COMMUNAL_INTERNAL_DECORATIONS, aspect_type=AspectType.PRESENCE + ), + (51, 22): ElementMapping( + element=Element.COMMUNAL_INTERNAL_FLOOR, aspect_type=AspectType.FINISH + ), + (51, 27): ElementMapping( + element=Element.COMMUNAL_PASSENGER_LIFT, aspect_type=AspectType.TYPE + ), + (51, 28): ElementMapping( + element=Element.COMMUNAL_PRIMARY_ENTRANCE, aspect_type=AspectType.MATERIAL + ), + (51, 32): ElementMapping( + element=Element.COMMUNAL_STAIRS, aspect_type=AspectType.FINISH + ), + (51, 36): ElementMapping( + element=Element.COMMUNAL_WALKWAYS, aspect_type=AspectType.FINISH + ), (200, 1): ElementMapping( element=Element.COMMUNAL_BOILER, aspect_type=AspectType.TYPE ), @@ -258,6 +310,18 @@ PEABODY_ELEMENT_MAP = { (200, 6): ElementMapping( element=Element.COMMUNAL_FLOOR_COVERING, aspect_type=AspectType.MATERIAL ), + (200, 7): ElementMapping( + element=Element.COMMUNAL_KITCHEN, aspect_type=AspectType.TYPE + ), + (200, 8): ElementMapping( + element=Element.COMMUNAL_BATHROOM, aspect_type=AspectType.TYPE + ), + (200, 9): ElementMapping( + element=Element.COMMUNAL_TOILETS, aspect_type=AspectType.TYPE + ), + (200, 10): ElementMapping( + element=Element.COMMUNAL_GATES, aspect_type=AspectType.TYPE + ), # ========================================================== # INTERNAL – HEATING # ========================================================== @@ -295,6 +359,24 @@ PEABODY_ELEMENT_MAP = { element=Element.HOT_WATER_SYSTEM, aspect_type=AspectType.TYPE ), # ========================================================== + # ELECTRICS + # ========================================================== + (50, 24): ElementMapping( + element=Element.INTERNAL_WIRING, aspect_type=AspectType.MATERIAL + ), + (180, 1): ElementMapping( + element=Element.ELECTRICAL_WIRING, aspect_type=AspectType.WORK_REQUIRED + ), # Not certain about the AspectType - only example in the sample data is "Full Rewire" + (180, 2): ElementMapping( + element=Element.CONSUMER_UNIT, aspect_type=AspectType.TYPE + ), + (180, 3): ElementMapping( + element=Element.SMOKE_DETECTION, aspect_type=AspectType.TYPE + ), # Duplicate of (50, 21) - correct? + (180, 4): ElementMapping( + element=Element.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE + ), # Duplicate of (50, 2) - correct? + # ========================================================== # HHSRS # ========================================================== (54, 1): ElementMapping( @@ -313,31 +395,6 @@ PEABODY_ELEMENT_MAP = { # unhandled -# 'Element: ELECTRICS - Code: 180, Sub-Element: Wiring - Code: 1', -# 'Element: ELECTRICS - Code: 180, Sub-Element: Consumer Unit - Code: 2', -# 'Element: ELECTRICS - Code: 180, Sub-Element: Smoke Detectors - Code: 3', -# 'Element: ELECTRICS - Code: 180, Sub-Element: Carbon Monoxide Alarms - Code: 4', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Lifts - Code: 5', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Kitchen - Code: 7', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Bathroom - Code: 8', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Toilets - Code: 9', -# 'Element: Internal - Code: 50, Sub-Element: Wiring - Code: 24', -# 'Element: PASSENGER LIFTS - Code: 210, Sub-Element: Lift - Code: 2', -# 'Element: COMMUNAL - Code: 200, Sub-Element: Communal Gates - Code: 10', -# 'Element: GENERAL - Code: 100, Sub-Element: Property Age Band - Code: 3', -# 'Element: GENERAL - Code: 100, Sub-Element: Construction Type - Code: 14', -# 'Element: GENERAL - Code: 100, Sub-Element: Classification - Code: 16', -# 'Element: Communal - Code: 51, Sub-Element: Common Balcony/Walkway - Code: 3', -# 'Element: Communal - Code: 51, Sub-Element: Common Bin Store Doors - Code: 5', -# 'Element: Communal - Code: 51, Sub-Element: Common Bin Store Walls - Code: 7', -# 'Element: Communal - Code: 51, Sub-Element: Common Primary Entrance Material - Code: 28', -# 'Element: Communal - Code: 51, Sub-Element: Common Internal Decorations - Code: 20', -# 'Element: Communal - Code: 51, Sub-Element: Common Internal Floor Finish - Code: 22', -# 'Element: Communal - Code: 51, Sub-Element: Common Walkways Finish - Code: 36', -# 'Element: Communal - Code: 51, Sub-Element: Common External Doors Other - Code: 17', -# 'Element: Communal - Code: 51, Sub-Element: Common Stair Finish - Code: 32', -# 'Element: Communal - Code: 51, Sub-Element: Common Aerial - Code: 1', -# 'Element: Communal - Code: 51, Sub-Element: Common AOV - Code: 2', # 'Element: Communal - Code: 51, Sub-Element: Common Door Entry System - Code: 14', # 'Element: Communal - Code: 51, Sub-Element: Common Fire Alarm - Code: 19', # 'Element: Communal - Code: 51, Sub-Element: Common Internal Doors - Code: 21', @@ -351,7 +408,6 @@ PEABODY_ELEMENT_MAP = { # 'Element: Communal - Code: 51, Sub-Element: Common Secondary Entrance Material - Code: 30', # 'Element: Communal - Code: 51, Sub-Element: Common Warden Call System - Code: 37', # 'Element: Communal - Code: 51, Sub-Element: Common Boiler - Code: 9', -# 'Element: Communal - Code: 51, Sub-Element: Common Passenger Lift - Code: 27', # 'Element: Communal - Code: 51, Sub-Element: Common Store Doors - Code: 33', # 'Element: Communal - Code: 51, Sub-Element: Common BMS - Code: 8', # 'Element: Communal - Code: 51, Sub-Element: Common Booster Pump - Code: 10', @@ -369,6 +425,5 @@ PEABODY_ELEMENT_MAP = { # 'Element: Communal - Code: 51, Sub-Element: Common Cirulation Space - Code: 12', # 'Element: Internal - Code: 50, Sub-Element: Door Entry Handset - Code: 8', # 'Element: Internal - Code: 50, Sub-Element: Loft Insulation - Code: 15', -# 'Element: Internal - Code: 50, Sub-Element: Stairlift - Code: 22', # 'Element: Internal - Code: 50, Sub-Element: Disabled Hoist Tracking - Code: 7', # 'Element: Internal - Code: 50, Sub-Element: Disabled Facilities - Code: 26' From 1bd7117097682faafda932d289fa24c32487a2cd Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 26 Jan 2026 14:27:52 +0000 Subject: [PATCH 58/74] final peabody element mappings --- backend/condition/domain/element.py | 23 ++- .../mapping/peabody/peabody_element_map.py | 140 +++++++++++++----- 2 files changed, 123 insertions(+), 40 deletions(-) diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index b146d09b..72687aed 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -18,6 +18,8 @@ class Element(str, Enum): CCU = "ccu" PASSENGER_LIFT = "passenger_lift" STAIRLIFT = "stairlift" + DISABLED_HOIST_TRACKING = "disabled_hoist_tracking" + DISABLED_FACILITIES = "disabled_facilities" # ====================== # EXTERNAL – ROOF @@ -47,6 +49,7 @@ class Element(str, Enum): CLADDING = "cladding" SPANDREL_PANELS = "spandrel_panels" GARAGE_WALLS = "garage_walls" + PARTY_WALL_FIRE_BREAK = "party_wall_fire_break" # ====================== # EXTERNAL – WINDOWS @@ -68,6 +71,7 @@ class Element(str, Enum): BLOCK_ENTRANCE_DOOR = "block_entrance_door" LINTEL = "lintel" PATIO_FRENCH_DOOR = "patio_french_door" + DOOR_ENTRY_HANDSET = "door_entry_handset" # ====================== # EXTERNAL – AREAS @@ -149,6 +153,7 @@ class Element(str, Enum): COMMUNAL_BIN_STORE = "communal_bin_store" COMMUNAL_BIN_STORE_DOORS = "communal_bin_store_doors" COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_wall" + COMMUNAL_BIN_STORE_ROOF = "communal_bin_store_roof" COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute" COMMUNAL_FLOOR_COVERING = "communal_floor_covering" COMMUNAL_KITCHEN = "communal_kitchen" @@ -158,7 +163,7 @@ class Element(str, Enum): COMMUNAL_LIFT = "communal_lift" COMMUNAL_PASSENGER_LIFT = "communal_passenger_lift" COMMUNAL_BALCONY_WALKWAY = "communal_balcony_walkway" - COMMUNAL_PRIMARY_ENTRANCE = "communal_primary_entrance" + COMMUNAL_ENTRANCE = "communal_entrance" COMMUNAL_INTERNAL_DECORATIONS = "communal_internal_decorations" COMMUNAL_INTERNAL_FLOOR = "communal_internal_floor" COMMUNAL_WALKWAYS = "communal_walkways" @@ -166,6 +171,22 @@ class Element(str, Enum): COMMUNAL_STAIRS = "communal_stairs" COMMUNAL_AERIAL = "communal_aerial" COMMUNAL_AOV = "communal_aov" + COMMUNAL_INTERNAL_DOORS = "communal_internal_doors" + COMMUNAL_LATERAL_MAINS = "communal_lateral_mains" + COMMUNAL_LIGHTING = "communal_lighting" + COMMUNAL_LIGHTING_CONDUCTOR = "communal_lighting_conductor" + COMMUNAL_STORE_ROOF = "communal_store_roof" + COMMUNAL_STORE_WALLS = "communal_store_walls" + COMMUNAL_STORE_DOORS = "communal_store_doors" + COMMUNAL_WARDEN_CALL_SYSTEM = "communal_warden_call_system" + COMMUNAL_BMS = "communal_bms" + COMMUNAL_BOOSTER_PUMP = "communal_booster_pump" + COMMUNAL_DRY_RISER = "communal_dry_riser" + COMMUNAL_WET_RISER = "communal_wet_riser" + COMMUNAL_COLD_WATER_STORAGE = "communal_cold_water_storage" + COMMUNAL_SPRINKLER = "communal_sprinkler" + COMMUNAL_PLUG_SOCKETS = "communal_plug_sockets" + COMMUNAL_CIRCULATION_SPACE = "communal_circulation_space" # ========================================================== # HHSRS – ALL 29 HAZARDS diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 08e63568..7a266a9f 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -14,6 +14,9 @@ PEABODY_ELEMENT_MAP = { element=Element.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE ), (50, 3): ElementMapping(element=Element.CCU, aspect_type=AspectType.TYPE), + (50, 7): ElementMapping( + element=Element.DISABLED_HOIST_TRACKING, aspect_type=AspectType.PRESENCE + ), (50, 11): ElementMapping( element=Element.HEAT_DETECTION, aspect_type=AspectType.TYPE ), @@ -23,6 +26,9 @@ PEABODY_ELEMENT_MAP = { (50, 22): ElementMapping( element=Element.STAIRLIFT, aspect_type=AspectType.PRESENCE ), + (50, 26): ElementMapping( + element=Element.DISABLED_FACILITIES, aspect_type=AspectType.TYPE + ), (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE_BAND), (100, 14): ElementMapping( element=Element.PROPERTY, aspect_type=AspectType.CONSTRUCTION_TYPE @@ -36,6 +42,9 @@ PEABODY_ELEMENT_MAP = { # ========================================================== # EXTERNAL – WALLS # ========================================================== + (50, 16): ElementMapping( + element=Element.PARTY_WALL_FIRE_BREAK, aspect_type=AspectType.PRESENCE + ), (53, 1): ElementMapping( element=Element.BOUNDARY_WALLS, aspect_type=AspectType.PRESENCE ), @@ -76,6 +85,9 @@ PEABODY_ELEMENT_MAP = { # ========================================================== # EXTERNAL – ROOFS # ========================================================== + (50, 15): ElementMapping( + element=Element.LOFT_INSULATION, aspect_type=AspectType.TYPE + ), (53, 2): ElementMapping(element=Element.CHIMNEY, aspect_type=AspectType.PRESENCE), (53, 6): ElementMapping( element=Element.FASCIA_SOFFIT_BARGEBOARDS, aspect_type=AspectType.MATERIAL @@ -116,6 +128,9 @@ PEABODY_ELEMENT_MAP = { # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== + (50, 8): ElementMapping( + element=Element.DOOR_ENTRY_HANDSET, aspect_type=AspectType.PRESENCE + ), (53, 8): ElementMapping( element=Element.FRONT_DOOR, aspect_type=AspectType.MATERIAL ), @@ -262,39 +277,121 @@ PEABODY_ELEMENT_MAP = { (51, 3): ElementMapping( element=Element.COMMUNAL_BALCONY_WALKWAY, aspect_type=AspectType.PRESENCE ), + (51, 4): ElementMapping( + element=Element.COMMUNAL_BATHROOM, aspect_type=AspectType.TYPE + ), (51, 5): ElementMapping( element=Element.COMMUNAL_BIN_STORE_DOORS, aspect_type=AspectType.PRESENCE ), + (51, 6): ElementMapping( + element=Element.COMMUNAL_BIN_STORE_ROOF, aspect_type=AspectType.PRESENCE + ), (51, 7): ElementMapping( element=Element.COMMUNAL_BIN_STORE_WALLS, aspect_type=AspectType.MATERIAL ), + (51, 8): ElementMapping( + element=Element.COMMUNAL_BMS, aspect_type=AspectType.PRESENCE + ), + (51, 9): ElementMapping( + element=Element.COMMUNAL_BOILER, aspect_type=AspectType.TYPE + ), + (51, 10): ElementMapping( + element=Element.COMMUNAL_BOOSTER_PUMP, aspect_type=AspectType.PRESENCE + ), + (51, 11): ElementMapping( + element=Element.COMMUNAL_CCTV, aspect_type=AspectType.PRESENCE + ), + (51, 12): ElementMapping( + element=Element.COMMUNAL_CIRCULATION_SPACE, aspect_type=AspectType.ADEQUACY + ), + (51, 13): ElementMapping( + element=Element.COMMUNAL_COLD_WATER_STORAGE, aspect_type=AspectType.PRESENCE + ), (51, 14): ElementMapping( element=Element.COMMUNAL_DOOR_ENTRY, aspect_type=AspectType.SYSTEM ), + (51, 15): ElementMapping( + element=Element.COMMUNAL_DRY_RISER, aspect_type=AspectType.PRESENCE + ), + (51, 16): ElementMapping( + element=Element.COMMUNAL_EMERGENCY_LIGHTING, aspect_type=AspectType.PRESENCE + ), (51, 17): ElementMapping( element=Element.COMMUNAL_EXTERNAL_DOORS, aspect_type=AspectType.MATERIAL ), + (51, 19): ElementMapping( + element=Element.COMMUNAL_FIRE_ALARM, aspect_type=AspectType.TYPE + ), (51, 20): ElementMapping( element=Element.COMMUNAL_INTERNAL_DECORATIONS, aspect_type=AspectType.PRESENCE ), + (51, 21): ElementMapping( + element=Element.COMMUNAL_INTERNAL_DOORS, aspect_type=AspectType.MATERIAL + ), (51, 22): ElementMapping( element=Element.COMMUNAL_INTERNAL_FLOOR, aspect_type=AspectType.FINISH ), + (51, 23): ElementMapping( + element=Element.COMMUNAL_KITCHEN, aspect_type=AspectType.TYPE + ), + (51, 24): ElementMapping( + element=Element.COMMUNAL_LATERAL_MAINS, aspect_type=AspectType.PRESENCE + ), + (51, 25): ElementMapping( + element=Element.COMMUNAL_LIGHTING, aspect_type=AspectType.PRESENCE + ), + (51, 26): ElementMapping( + element=Element.COMMUNAL_LIGHTING_CONDUCTOR, aspect_type=AspectType.PRESENCE + ), (51, 27): ElementMapping( element=Element.COMMUNAL_PASSENGER_LIFT, aspect_type=AspectType.TYPE ), (51, 28): ElementMapping( - element=Element.COMMUNAL_PRIMARY_ENTRANCE, aspect_type=AspectType.MATERIAL + element=Element.COMMUNAL_ENTRANCE, + aspect_type=AspectType.MATERIAL, + element_instance=1, + ), + (51, 30): ElementMapping( + element=Element.COMMUNAL_ENTRANCE, + aspect_type=AspectType.FINISH, + element_instance=2, + ), + (51, 14): ElementMapping( + element=Element.COMMUNAL_SPRINKLER, aspect_type=AspectType.PRESENCE + ), + (51, 29): ElementMapping( + element=Element.COMMUNAL_REFUSE_CHUTE, aspect_type=AspectType.PRESENCE ), (51, 32): ElementMapping( element=Element.COMMUNAL_STAIRS, aspect_type=AspectType.FINISH ), + (51, 33): ElementMapping( + element=Element.COMMUNAL_STORE_DOORS, aspect_type=AspectType.MATERIAL + ), + (51, 34): ElementMapping( + element=Element.COMMUNAL_STORE_ROOF, aspect_type=AspectType.MATERIAL + ), + (51, 35): ElementMapping( + element=Element.COMMUNAL_STORE_WALLS, aspect_type=AspectType.MATERIAL + ), (51, 36): ElementMapping( element=Element.COMMUNAL_WALKWAYS, aspect_type=AspectType.FINISH ), + (51, 37): ElementMapping( + element=Element.COMMUNAL_WARDEN_CALL_SYSTEM, aspect_type=AspectType.PRESENCE + ), + (51, 38): ElementMapping( + element=Element.COMMUNAL_TOILETS, aspect_type=AspectType.TYPE + ), + (51, 39): ElementMapping( + element=Element.COMMUNAL_WET_RISER, aspect_type=AspectType.PRESENCE + ), + (51, 40): ElementMapping( + element=Element.COMMUNAL_PLUG_SOCKETS, aspect_type=AspectType.PRESENCE + ), (200, 1): ElementMapping( element=Element.COMMUNAL_BOILER, aspect_type=AspectType.TYPE - ), + ), # Duplicate of (51, 9) - correct? (200, 2): ElementMapping( element=Element.COMMUNAL_HEATING, aspect_type=AspectType.TYPE ), @@ -315,10 +412,10 @@ PEABODY_ELEMENT_MAP = { ), (200, 8): ElementMapping( element=Element.COMMUNAL_BATHROOM, aspect_type=AspectType.TYPE - ), + ), # Duplicate of (51, 4) - correct? (200, 9): ElementMapping( element=Element.COMMUNAL_TOILETS, aspect_type=AspectType.TYPE - ), + ), # Duplicate of (51, 38) - correct? (200, 10): ElementMapping( element=Element.COMMUNAL_GATES, aspect_type=AspectType.TYPE ), @@ -392,38 +489,3 @@ PEABODY_ELEMENT_MAP = { element=Element.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK ), } - - -# unhandled -# 'Element: Communal - Code: 51, Sub-Element: Common Door Entry System - Code: 14', -# 'Element: Communal - Code: 51, Sub-Element: Common Fire Alarm - Code: 19', -# 'Element: Communal - Code: 51, Sub-Element: Common Internal Doors - Code: 21', -# 'Element: Communal - Code: 51, Sub-Element: Common Emergency Lighting - Code: 16', -# 'Element: Communal - Code: 51, Sub-Element: Common Lateral Mains - Code: 24', -# 'Element: Communal - Code: 51, Sub-Element: Common Lighting - Code: 25', -# 'Element: Communal - Code: 51, Sub-Element: Common Store Roof - Code: 34', -# 'Element: Communal - Code: 51, Sub-Element: Common Store Walls - Code: 35', -# 'Element: Communal - Code: 51, Sub-Element: Common CCTV - Code: 11', -# 'Element: Communal - Code: 51, Sub-Element: Common Kitchen - Code: 23', -# 'Element: Communal - Code: 51, Sub-Element: Common Secondary Entrance Material - Code: 30', -# 'Element: Communal - Code: 51, Sub-Element: Common Warden Call System - Code: 37', -# 'Element: Communal - Code: 51, Sub-Element: Common Boiler - Code: 9', -# 'Element: Communal - Code: 51, Sub-Element: Common Store Doors - Code: 33', -# 'Element: Communal - Code: 51, Sub-Element: Common BMS - Code: 8', -# 'Element: Communal - Code: 51, Sub-Element: Common Booster Pump - Code: 10', -# 'Element: Communal - Code: 51, Sub-Element: Common Dry Riser - Code: 15', -# 'Element: Communal - Code: 51, Sub-Element: Common Lightning Conductor - Code: 26', -# 'Element: Communal - Code: 51, Sub-Element: Common Bin Store Roof - Code: 6', -# 'Element: Communal - Code: 51, Sub-Element: Common Bathroom - Code: 4', -# 'Element: Communal - Code: 51, Sub-Element: Common WC - Code: 38', -# 'Element: Communal - Code: 51, Sub-Element: Common Cold Water Storage Tank - Code: 13', -# 'Element: Communal - Code: 51, Sub-Element: Common Sprinker - Code: 31', -# 'Element: Communal - Code: 51, Sub-Element: Communal Plug Sockets - Code: 40', -# 'Element: Communal - Code: 51, Sub-Element: Common Wet Riser - Code: 39', -# 'Element: Communal - Code: 51, Sub-Element: Common Refuse Chute - Code: 29', -# 'Element: Internal - Code: 50, Sub-Element: Party Wall Fire Break - Code: 16', -# 'Element: Communal - Code: 51, Sub-Element: Common Cirulation Space - Code: 12', -# 'Element: Internal - Code: 50, Sub-Element: Door Entry Handset - Code: 8', -# 'Element: Internal - Code: 50, Sub-Element: Loft Insulation - Code: 15', -# 'Element: Internal - Code: 50, Sub-Element: Disabled Hoist Tracking - Code: 7', -# 'Element: Internal - Code: 50, Sub-Element: Disabled Facilities - Code: 26' From eaf793011b43b4f0655a45d8526401a39bf0f114 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 26 Jan 2026 16:20:21 +0000 Subject: [PATCH 59/74] remaining lbwf mappings --- backend/condition/domain/element.py | 38 +- .../domain/mapping/lbwf/lbwf_element_map.py | 275 ++++++++--- .../mapping/peabody/peabody_element_map.py | 429 ++++++++++++------ 3 files changed, 548 insertions(+), 194 deletions(-) diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index 72687aed..f78f2d52 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -11,6 +11,7 @@ class Element(str, Enum): PROPERTY_CLASSIFICATION = "property_classification" PROPERTY_AGE_BAND = "property_age_band" STOREY_COUNT = "storey_count" + FLOOR_LEVEL = "floor_level" FLOOR_LEVEL_FRONT_DOOR = "floor_level_front_door" ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register" ASBESTOS = "asbestos" @@ -20,6 +21,7 @@ class Element(str, Enum): STAIRLIFT = "stairlift" DISABLED_HOIST_TRACKING = "disabled_hoist_tracking" DISABLED_FACILITIES = "disabled_facilities" + STEPS_TO_FRONT_DOOR = "steps_to_front_door" # ====================== # EXTERNAL – ROOF @@ -35,7 +37,9 @@ class Element(str, Enum): SOFFIT = "soffit" FASCIA_SOFFIT_BARGEBOARDS = "fascia_soffit_bargeboards" GUTTERS = "gutters" + STORE_ROOF = "store_roof" GARAGE_ROOF = "garage_roof" + GARAGE_AND_STORE_ROOF = "garage_and_store_roof" # ====================== # EXTERNAL – WALLS @@ -50,6 +54,8 @@ class Element(str, Enum): SPANDREL_PANELS = "spandrel_panels" GARAGE_WALLS = "garage_walls" PARTY_WALL_FIRE_BREAK = "party_wall_fire_break" + EXTERNAL_BRICKWORK_POINTING = "external_brickwork_pointing" + INTERNAL_DOWNPIPES_EXTERNAL_AREA = "internal_downpipes_in_external_area" # ====================== # EXTERNAL – WINDOWS @@ -57,6 +63,9 @@ class Element(str, Enum): EXTERNAL_WINDOWS = "external_windows" COMMUNAL_WINDOWS = "communal_windows" SECONDARY_GLAZING = "secondary_glazing" + STORE_WINDOWS = "store_windows" + GARAGE_WINDOWS = "garage_windows" + GARAGE_AND_STORE_WINDOWS = "garage_and_store_windows" # ====================== # EXTERNAL – DOORS @@ -66,6 +75,7 @@ class Element(str, Enum): REAR_DOOR = "rear_door" STORE_DOOR = "store_door" GARAGE_DOOR = "garage_door" + GARAGE_AND_STORE_DOOR = "garage_and_store_door" COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door" MAIN_DOOR = "main_door" BLOCK_ENTRANCE_DOOR = "block_entrance_door" @@ -94,6 +104,10 @@ class Element(str, Enum): ROADS = "roads" SOIL_AND_VENT = "soil_and_vent" SOLAR_THERMALS = "solar_thermals" + DROP_KERB = "drop_kerb" + OUTBUILDING_OVERHAUL = "outbuilding_overhaul" + EXTERNAL_STRUCTURAL_DEFECTS = "external_structural_defects" + ACCESS_RAMP = "access_ramp" # ====================== # INTERNAL – KITCHEN @@ -110,6 +124,9 @@ class Element(str, Enum): SECONDARY_BATHROOM = "secondary_bathroom" SECONDARY_TOILET = "secondary_toilet" BATHROOM_EXTRACTOR_FAN = "bathroom_extractor_fan" + ADDITIONAL_WC_OR_WHB = "additional_wc_or_whb" + BATHROOM_REMAINING_LIFE_SOURCE = "bathroom_remaining_life_source" + KITCHEN_REMAINING_LIFE_SOURCE = "kitchen_remaining_life_source" # ====================== # INTERNAL – HEATING / WATER @@ -120,11 +137,16 @@ class Element(str, Enum): SECONDARY_HEATING = "secondary_heating" HOT_WATER_SYSTEM = "hot_water_system" COLD_WATER_STORAGE = "cold_water_storage" - PROGRAMMABLE_HEATING = "programmable_heating" HEATING_SYSTEM = "heating_system" BOILER_FUEL = "boiler_fuel" WATER_HEATING = "water_heating" PROGRAMMABLE_HEATING = "programmable_heating" + COMMUNITY_HEATING = ( + "community_heating" # Is this definitely different from COMMUNAL_HEATING? + ) + GAS_AVAILABLE = "gas_available" + HEAT_RECOVERY_UNITS = "heat_recovery_units" + HEATING_IMPROVEMENTS = "heating_improvements" # ====================== # INTERNAL – ELECTRICS / FIRE @@ -139,6 +161,7 @@ class Element(str, Enum): INTERNAL_WIRING = ( "internal_wiring" # Is this definitely different from ELECTRICAL_WIRING? ) + ELECTRICS = "electrics" # ====================== # COMMUNAL @@ -188,6 +211,19 @@ class Element(str, Enum): COMMUNAL_PLUG_SOCKETS = "communal_plug_sockets" COMMUNAL_CIRCULATION_SPACE = "communal_circulation_space" + # ====================== + # FITNESS FOR HUMAN HABITATION + # ====================== + FFHH_DAMP = "ffhh_damp" + FFHH_HOT_AND_COLD_WATER = "ffhh_hold_and_cold_water" + FFHH_DRAINAGE_LAVATORIES = "ffhh_drainage_or_lavatories" + FFHH_NEGLECTED = "ffhh_neglected_and_in_bad_condition" + FFHH_NATURAL_LIGHT = "ffhh_natural_light" + FFHH_VENTILATION = "ffhh_ventilation" + FFHH_FOOD_PREP_AND_WASHUP = "ffhh_prepare_and_cook_food_or_wash_up" + FFHH_UNSAFE_LAYOUT = "ffhh_unsafe_layout" + FFHH_UNSTABLE_BUILDING = "ffhh_unstable_building" + # ========================================================== # HHSRS – ALL 29 HAZARDS # ========================================================== diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index be8a50b2..02722b11 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -31,6 +31,18 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { element=Element.FLOOR_LEVEL_FRONT_DOOR, aspect_type=AspectType.LOCATION, ), + "INTFLRLVL": ElementMapping( + element=Element.FLOOR_LEVEL, + aspect_type=AspectType.LOCATION, + ), + "INTNSEINSL": ElementMapping( + element=Element.EXTERNAL_NOISE_INSULATION, # Maybe this shouldn't be "EXTERNAL_" + aspect_type=AspectType.ADEQUACY, + ), + "INTSTEPSFD": ElementMapping( + element=Element.STEPS_TO_FRONT_DOOR, + aspect_type=AspectType.QUANTITY, + ), # ========================================================== # ASBESTOS (NON-HHSRS RECORD) # ========================================================== @@ -57,21 +69,22 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { element=Element.KITCHEN, aspect_type=AspectType.LOCATION, ), - # ========================================================== - # INTERNAL – HEATING - # ========================================================== - "INTCHEXTNT": ElementMapping( - element=Element.CENTRAL_HEATING, - aspect_type=AspectType.EXTENT, + "INTADDWCW": ElementMapping( + element=Element.ADDITIONAL_WC_OR_WHB, + aspect_type=AspectType.PRESENCE, ), - "INTCHDIST": ElementMapping( - element=Element.HEATING_DISTRIBUTION, + "INTBTHREML": ElementMapping( + element=Element.BATHROOM_REMAINING_LIFE_SOURCE, aspect_type=AspectType.TYPE, ), - "INTCHBLR": ElementMapping( - element=Element.HEATING_BOILER, + "INTKITREML": ElementMapping( + element=Element.KITCHEN_REMAINING_LIFE_SOURCE, aspect_type=AspectType.TYPE, ), + "INTTNTINST": ElementMapping( + element=Element.TENANT_INSTALLED_KITCHEN, + aspect_type=AspectType.TYPE, # Not certain about this aspect type - need more data + ), # ========================================================== # INTERNAL – FIRE # ========================================================== @@ -98,6 +111,18 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # ========================================================== # HEATING & SERVICES # ========================================================== + "INTCHEXTNT": ElementMapping( + element=Element.CENTRAL_HEATING, + aspect_type=AspectType.EXTENT, + ), + "INTCHDIST": ElementMapping( + element=Element.HEATING_DISTRIBUTION, + aspect_type=AspectType.TYPE, + ), + "INTCHBLR": ElementMapping( + element=Element.HEATING_BOILER, + aspect_type=AspectType.TYPE, + ), "INTBOILERF": ElementMapping( element=Element.BOILER_FUEL, aspect_type=AspectType.TYPE, @@ -110,6 +135,30 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { element=Element.WATER_HEATING, aspect_type=AspectType.TYPE, ), + "INTCOMHTG": ElementMapping( + element=Element.COMMUNITY_HEATING, + aspect_type=AspectType.TYPE, + ), + "INTELECTRC": ElementMapping( + element=Element.ELECTRICS, + aspect_type=AspectType.WORK_REQUIRED, # Not certain about this aspect type - need more data + ), + "INTGASAVAI": ElementMapping( + element=Element.GAS_AVAILABLE, + aspect_type=AspectType.PRESENCE, # Maybe should be AspectType.TYPE ? + ), + "INTHEATREC": ElementMapping( + element=Element.HEAT_RECOVERY_UNITS, + aspect_type=AspectType.PRESENCE, + ), + "INTHTIMP": ElementMapping( + element=Element.GAS_AVAILABLE, + aspect_type=AspectType.WORK_REQUIRED, + ), + "INTPROGHTG": ElementMapping( + element=Element.PROGRAMMABLE_HEATING, + aspect_type=AspectType.TYPE, # Should maybe be PRESENCE, but set to TYPE for consistency with Peabody data + ), # ========================================================== # EXTERNAL – WALLS (INSTANCED) # ========================================================== @@ -136,6 +185,14 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { element=Element.EXTERNAL_WALL, aspect_type=AspectType.CONDITION, ), + "EXTDWNPTYP": ElementMapping( + element=Element.DOWNPIPES, + aspect_type=AspectType.MATERIAL, + ), + "EXTGUTRTYP": ElementMapping( + element=Element.GUTTERS, + aspect_type=AspectType.MATERIAL, + ), # ========================================================== # EXTERNAL – ROOFS (INSTANCED) # ========================================================== @@ -169,6 +226,30 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { aspect_type=AspectType.COVERING, element_instance=3, ), + "EXTCHIMNEY": ElementMapping( + element=Element.CHIMNEY, + aspect_type=AspectType.WORK_REQUIRED, + ), + "EXTFASOFBR": ElementMapping( + element=Element.FASCIA_SOFFIT_BARGEBOARDS, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARROOF": ElementMapping( + element=Element.GARAGE_ROOF, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARSTRF": ElementMapping( + element=Element.GARAGE_AND_STORE_ROOF, + aspect_type=AspectType.MATERIAL, + ), + "EXTSTRROOF": ElementMapping( + element=Element.STORE_ROOF, + aspect_type=AspectType.MATERIAL, + ), + "INTLOFTINS": ElementMapping( + element=Element.LOFT_INSULATION, + aspect_type=AspectType.TYPE, + ), # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== @@ -204,6 +285,125 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { aspect_type=AspectType.TYPE, element_instance=2, ), + "EXTGARDOOR": ElementMapping( + element=Element.GARAGE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARSTDR": ElementMapping( + element=Element.GARAGE_AND_STORE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + "EXTSTRDOOR": ElementMapping( + element=Element.STORE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARWDWS": ElementMapping( + element=Element.GARAGE_WINDOWS, + aspect_type=AspectType.MATERIAL, + ), + "EXTSTRWDWS": ElementMapping( + element=Element.STORE_WINDOWS, + aspect_type=AspectType.MATERIAL, + ), + "EXTGARSTWD": ElementMapping( + element=Element.GARAGE_AND_STORE_WINDOWS, + aspect_type=AspectType.MATERIAL, + ), + "EXTLINTELS": ElementMapping( + element=Element.LINTEL, + aspect_type=AspectType.PRESENCE, + ), + "EXTPTFRDR1": ElementMapping( + element=Element.PATIO_FRENCH_DOOR, + aspect_type=AspectType.MATERIAL, + element_instance=1, + ), + # ========================================================== + # EXTERNAL AREAS + # ========================================================== + "EXTBALCONY": ElementMapping( + element=Element.PRIVATE_BALCONY, + aspect_type=AspectType.PRESENCE, + ), + "EXTBPOINTG": ElementMapping( + element=Element.EXTERNAL_BRICKWORK_POINTING, + aspect_type=AspectType.PRESENCE, + ), + "EXTDRPKERB": ElementMapping( + element=Element.DROP_KERB, + aspect_type=AspectType.PRESENCE, + ), + "EXTEXTDECS": ElementMapping( + element=Element.EXTERNAL_DECORATION, + aspect_type=AspectType.PRESENCE, + ), + "EXTHARDSTD": ElementMapping( + element=Element.PATHS_AND_HARDSTANDINGS, + aspect_type=AspectType.MATERIAL, + ), + "EXTINTDWNP": ElementMapping( + element=Element.INTERNAL_DOWNPIPES_EXTERNAL_AREA, + aspect_type=AspectType.MATERIAL, + ), + "EXTOUTBOH": ElementMapping( + element=Element.OUTBUILDING_OVERHAUL, + aspect_type=AspectType.TYPE, + ), + "EXTPARKING": ElementMapping( + element=Element.PARKING_AREAS, + aspect_type=AspectType.PRESENCE, + ), + "EXTPCHCNPY": ElementMapping( + element=Element.PORCH_CANOPY, + aspect_type=AspectType.TYPE, + ), + "EXTSTRINSP": ElementMapping( + element=Element.EXTERNAL_STRUCTURAL_DEFECTS, + aspect_type=AspectType.TYPE, # Need more sample data to know whether this is the correct aspect type + ), + "INTACCRAMP": ElementMapping( + element=Element.ACCESS_RAMP, + aspect_type=AspectType.TYPE, # # Need more sample data to know whether this is the correct aspect type + ), + # ====================== + # FITNESS FOR HUMAN HABITATION + # ====================== + "FFHHDAMP": ElementMapping( + element=Element.FFHH_DAMP, + aspect_type=AspectType.RISK, + ), + "FFHHHCWAT": ElementMapping( + element=Element.FFHH_HOT_AND_COLD_WATER, + aspect_type=AspectType.RISK, + ), + "FFHHDRNWC": ElementMapping( + element=Element.FFHH_DRAINAGE_LAVATORIES, + aspect_type=AspectType.RISK, + ), + "FFHHNEGLC": ElementMapping( + element=Element.FFHH_NEGLECTED, + aspect_type=AspectType.RISK, + ), + "FFHHNONAT": ElementMapping( + element=Element.FFHH_NATURAL_LIGHT, + aspect_type=AspectType.RISK, + ), + "FFHHNOVEN": ElementMapping( + element=Element.FFHH_VENTILATION, + aspect_type=AspectType.RISK, + ), + "FFHHPRPCK": ElementMapping( + element=Element.FFHH_FOOD_PREP_AND_WASHUP, + aspect_type=AspectType.RISK, + ), + "FFHHUNLAY": ElementMapping( + element=Element.FFHH_UNSAFE_LAYOUT, + aspect_type=AspectType.RISK, + ), + "FFHHUNSTA": ElementMapping( + element=Element.FFHH_UNSTABLE_BUILDING, + aspect_type=AspectType.RISK, + ), # ========================================================== # HHSRS # ========================================================== @@ -244,7 +444,8 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { aspect_type=AspectType.RISK, ), "HHSRSORGAN": ElementMapping( - element=Element.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, aspect_type=AspectType.RISK + element=Element.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, + aspect_type=AspectType.RISK, ), "HHSRSCROWD": ElementMapping( element=Element.HHSRS_CROWDING_AND_SPACE, @@ -319,7 +520,8 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { aspect_type=AspectType.RISK, ), "HHSRSCLOW": ElementMapping( - element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.RISK + element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, + aspect_type=AspectType.RISK, ), "HHSRSPOSI": ElementMapping( element=Element.HHSRS_AMENITIES, @@ -330,52 +532,3 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # Unhandled: # DECNTHMINC # EICINSFREQ -# EXTBALCONY -# EXTBPOINTG -# EXTCHIMNEY -# EXTDRPKERB -# EXTDWNPTYP -# EXTEXTDECS -# EXTFASOFBR -# EXTGARDOOR -# EXTGARROOF -# EXTGARSTDR -# EXTGARSTRF -# EXTGARSTWD -# EXTGARWDWS -# EXTGUTRTYP -# EXTHARDSTD -# EXTINTDWNP -# EXTLINTELS -# EXTOUTBOH -# EXTPARKING -# EXTPCHCNPY -# EXTPTFRDR1 -# EXTSTRDOOR -# EXTSTRINSP -# EXTSTRROOF -# EXTSTRWDWS -# FFHHDAMP -# FFHHDRNWC -# FFHHHCWAT -# FFHHNEGLC -# FFHHNONAT -# FFHHNOVEN -# FFHHPRPCK -# FFHHUNLAY -# FFHHUNSTA -# INTACCRAMP -# INTADDWCW -# INTBTHREML -# INTCOMHTG -# INTELECTRC -# INTFLRLVL -# INTGASAVAI -# INTHEATREC -# INTHTIMP -# INTKITREML -# INTLOFTINS -# INTNSEINSL -# INTPROGHTG -# INTSTEPSFD -# INTTNTINST diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 7a266a9f..8fe2ccb9 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -86,69 +86,109 @@ PEABODY_ELEMENT_MAP = { # EXTERNAL – ROOFS # ========================================================== (50, 15): ElementMapping( - element=Element.LOFT_INSULATION, aspect_type=AspectType.TYPE + element=Element.LOFT_INSULATION, + aspect_type=AspectType.TYPE, + ), + (53, 2): ElementMapping( + element=Element.CHIMNEY, + aspect_type=AspectType.PRESENCE, ), - (53, 2): ElementMapping(element=Element.CHIMNEY, aspect_type=AspectType.PRESENCE), (53, 6): ElementMapping( - element=Element.FASCIA_SOFFIT_BARGEBOARDS, aspect_type=AspectType.MATERIAL + element=Element.FASCIA_SOFFIT_BARGEBOARDS, + aspect_type=AspectType.MATERIAL, ), (53, 7): ElementMapping( - element=Element.FLAT_ROOF_COVERING, aspect_type=AspectType.MATERIAL + element=Element.FLAT_ROOF_COVERING, + aspect_type=AspectType.MATERIAL, ), (53, 13): ElementMapping( - element=Element.GARAGE_ROOF, aspect_type=AspectType.MATERIAL + element=Element.GARAGE_ROOF, + aspect_type=AspectType.MATERIAL, + ), + (53, 15): ElementMapping( + element=Element.GUTTERS, + aspect_type=AspectType.MATERIAL, ), - (53, 15): ElementMapping(element=Element.GUTTERS, aspect_type=AspectType.MATERIAL), (53, 18): ElementMapping( - element=Element.PITCHED_ROOF_COVERING, aspect_type=AspectType.MATERIAL + element=Element.PITCHED_ROOF_COVERING, + aspect_type=AspectType.MATERIAL, + ), + (53, 22): ElementMapping( + element=Element.PORCH_CANOPY, + aspect_type=AspectType.TYPE, + ), + (53, 47): ElementMapping( + element=Element.ROOF, + aspect_type=AspectType.STRUCTURE, ), - (53, 22): ElementMapping(element=Element.PORCH_CANOPY, aspect_type=AspectType.TYPE), - (53, 47): ElementMapping(element=Element.ROOF, aspect_type=AspectType.STRUCTURE), (110, 1): ElementMapping( - element=Element.ROOF, aspect_type=AspectType.MATERIAL, element_instance=1 + element=Element.ROOF, + aspect_type=AspectType.MATERIAL, + element_instance=1, ), (110, 2): ElementMapping( - element=Element.ROOF, aspect_type=AspectType.MATERIAL, element_instance=1 + element=Element.ROOF, + aspect_type=AspectType.MATERIAL, + element_instance=1, ), (110, 3): ElementMapping( - element=Element.CHIMNEY, aspect_type=AspectType.WORK_REQUIRED + element=Element.CHIMNEY, + aspect_type=AspectType.WORK_REQUIRED, + ), + (110, 4): ElementMapping( + element=Element.FASCIA, + aspect_type=AspectType.MATERIAL, + ), + (110, 5): ElementMapping( + element=Element.SOFFIT, + aspect_type=AspectType.MATERIAL, ), - (110, 4): ElementMapping(element=Element.FASCIA, aspect_type=AspectType.MATERIAL), - (110, 5): ElementMapping(element=Element.SOFFIT, aspect_type=AspectType.MATERIAL), (110, 6): ElementMapping( - element=Element.RAINWATER_GOODS, aspect_type=AspectType.MATERIAL + element=Element.RAINWATER_GOODS, + aspect_type=AspectType.MATERIAL, ), (110, 7): ElementMapping( element=Element.LOFT_INSULATION, aspect_type=AspectType.WORK_REQUIRED, # possibly not the right aspect type ), (110, 8): ElementMapping( - element=Element.PORCH_CANOPY, aspect_type=AspectType.MATERIAL + element=Element.PORCH_CANOPY, + aspect_type=AspectType.MATERIAL, ), # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== (50, 8): ElementMapping( - element=Element.DOOR_ENTRY_HANDSET, aspect_type=AspectType.PRESENCE + element=Element.DOOR_ENTRY_HANDSET, + aspect_type=AspectType.PRESENCE, ), (53, 8): ElementMapping( - element=Element.FRONT_DOOR, aspect_type=AspectType.MATERIAL + element=Element.FRONT_DOOR, + aspect_type=AspectType.MATERIAL, ), (53, 12): ElementMapping( - element=Element.GARAGE_DOOR, aspect_type=AspectType.MATERIAL + element=Element.GARAGE_DOOR, + aspect_type=AspectType.MATERIAL, + ), + (53, 16): ElementMapping( + element=Element.LINTEL, + aspect_type=AspectType.PRESENCE, ), - (53, 16): ElementMapping(element=Element.LINTEL, aspect_type=AspectType.PRESENCE), (53, 19): ElementMapping( - element=Element.PATIO_FRENCH_DOOR, aspect_type=AspectType.MATERIAL + element=Element.PATIO_FRENCH_DOOR, + aspect_type=AspectType.MATERIAL, ), (53, 25): ElementMapping( - element=Element.REAR_DOOR, aspect_type=AspectType.MATERIAL + element=Element.REAR_DOOR, + aspect_type=AspectType.MATERIAL, ), (53, 29): ElementMapping( - element=Element.SECONDARY_GLAZING, aspect_type=AspectType.PRESENCE + element=Element.SECONDARY_GLAZING, + aspect_type=AspectType.PRESENCE, ), (53, 35): ElementMapping( - element=Element.STORE_DOOR, aspect_type=AspectType.MATERIAL + element=Element.STORE_DOOR, + aspect_type=AspectType.MATERIAL, ), (53, 38): ElementMapping( element=Element.EXTERNAL_WINDOWS, @@ -160,191 +200,275 @@ PEABODY_ELEMENT_MAP = { aspect_type=AspectType.TYPE, element_instance=2, ), - (53, 43): ElementMapping(element=Element.FRONT_DOOR, aspect_type=AspectType.TYPE), + (53, 43): ElementMapping( + element=Element.FRONT_DOOR, + aspect_type=AspectType.TYPE, + ), (130, 1): ElementMapping( - element=Element.EXTERNAL_WINDOWS, aspect_type=AspectType.MATERIAL + element=Element.EXTERNAL_WINDOWS, + aspect_type=AspectType.MATERIAL, ), (130, 2): ElementMapping( - element=Element.COMMUNAL_WINDOWS, aspect_type=AspectType.MATERIAL + element=Element.COMMUNAL_WINDOWS, + aspect_type=AspectType.MATERIAL, ), (140, 1): ElementMapping( - element=Element.MAIN_DOOR, aspect_type=AspectType.MATERIAL + element=Element.MAIN_DOOR, + aspect_type=AspectType.MATERIAL, ), (140, 2): ElementMapping( - element=Element.STORE_DOOR, aspect_type=AspectType.MATERIAL + element=Element.STORE_DOOR, + aspect_type=AspectType.MATERIAL, ), # Duplicate of (53, 35) (140, 3): ElementMapping( - element=Element.GARAGE_DOOR, aspect_type=AspectType.MATERIAL + element=Element.GARAGE_DOOR, + aspect_type=AspectType.MATERIAL, ), # Duplicate of (53, 12) (140, 4): ElementMapping( - element=Element.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL + element=Element.BLOCK_ENTRANCE_DOOR, + aspect_type=AspectType.MATERIAL, ), # ========================================================== # EXTERNAL AREAS # ========================================================== - (53, 3): ElementMapping(element=Element.DOWNPIPES, aspect_type=AspectType.MATERIAL), - (53, 9): ElementMapping( - element=Element.FRONT_FENCING, aspect_type=AspectType.MATERIAL + (53, 3): ElementMapping( + element=Element.DOWNPIPES, + aspect_type=AspectType.MATERIAL, + ), + (53, 9): ElementMapping( + element=Element.FRONT_FENCING, + aspect_type=AspectType.MATERIAL, + ), + (53, 10): ElementMapping( + element=Element.FRONT_GATE, + aspect_type=AspectType.TYPE, ), - (53, 10): ElementMapping(element=Element.FRONT_GATE, aspect_type=AspectType.TYPE), (53, 17): ElementMapping( - element=Element.PARKING_AREAS, aspect_type=AspectType.MATERIAL + element=Element.PARKING_AREAS, + aspect_type=AspectType.MATERIAL, ), (53, 18): ElementMapping( - element=Element.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL + element=Element.PATHS_AND_HARDSTANDINGS, + aspect_type=AspectType.MATERIAL, ), (53, 24): ElementMapping( - element=Element.PRIVATE_BALCONY, aspect_type=AspectType.PRESENCE + element=Element.PRIVATE_BALCONY, + aspect_type=AspectType.PRESENCE, ), (53, 26): ElementMapping( - element=Element.REAR_FENCING, aspect_type=AspectType.MATERIAL + element=Element.REAR_FENCING, + aspect_type=AspectType.MATERIAL, + ), + (53, 27): ElementMapping( + element=Element.REAR_GATE, + aspect_type=AspectType.TYPE, ), - (53, 27): ElementMapping(element=Element.REAR_GATE, aspect_type=AspectType.TYPE), (53, 28): ElementMapping( - element=Element.RETAINING_WALLS, aspect_type=AspectType.PRESENCE + element=Element.RETAINING_WALLS, + aspect_type=AspectType.PRESENCE, ), (53, 31): ElementMapping( - element=Element.SIDE_FENCING, aspect_type=AspectType.MATERIAL + element=Element.SIDE_FENCING, + aspect_type=AspectType.MATERIAL, ), (53, 32): ElementMapping( - element=Element.SOIL_AND_VENT, aspect_type=AspectType.MATERIAL + element=Element.SOIL_AND_VENT, + aspect_type=AspectType.MATERIAL, ), (53, 34): ElementMapping( - element=Element.SOLAR_THERMALS, aspect_type=AspectType.PRESENCE + element=Element.SOLAR_THERMALS, + aspect_type=AspectType.PRESENCE, ), (53, 44): ElementMapping( - element=Element.GARAGE_STRUCTURE, aspect_type=AspectType.TYPE + element=Element.GARAGE_STRUCTURE, + aspect_type=AspectType.TYPE, ), (53, 45): ElementMapping( - element=Element.BALCONY_BALUSTRADE, aspect_type=AspectType.MATERIAL + element=Element.BALCONY_BALUSTRADE, + aspect_type=AspectType.MATERIAL, ), (150, 1): ElementMapping( - element=Element.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL + element=Element.BLOCK_ENTRANCE_DOOR, + aspect_type=AspectType.MATERIAL, ), (150, 2): ElementMapping( - element=Element.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL + element=Element.PATHS_AND_HARDSTANDINGS, + aspect_type=AspectType.MATERIAL, ), # Duplicate of (53, 18) - correct? - (150, 3): ElementMapping(element=Element.ROADS, aspect_type=AspectType.MATERIAL), - (150, 4): ElementMapping( - element=Element.BOUNDARY_WALLS, aspect_type=AspectType.MATERIAL + (150, 3): ElementMapping( + element=Element.ROADS, + aspect_type=AspectType.MATERIAL, + ), + (150, 4): ElementMapping( + element=Element.BOUNDARY_WALLS, + aspect_type=AspectType.MATERIAL, + ), + (150, 5): ElementMapping( + element=Element.OUTBUILDINGS, + aspect_type=AspectType.TYPE, ), - (150, 5): ElementMapping(element=Element.OUTBUILDINGS, aspect_type=AspectType.TYPE), (150, 6): ElementMapping( - element=Element.GARAGE_STRUCTURE, aspect_type=AspectType.TYPE + element=Element.GARAGE_STRUCTURE, + aspect_type=AspectType.TYPE, ), # ========================================================== # INTERNAL – BATHROOMS & KITCHENS # ========================================================== (50, 1): ElementMapping( - element=Element.SECONDARY_TOILET, aspect_type=AspectType.PRESENCE + element=Element.SECONDARY_TOILET, + aspect_type=AspectType.PRESENCE, ), (50, 9): ElementMapping( - element=Element.BATHROOM_EXTRACTOR_FAN, aspect_type=AspectType.PRESENCE + element=Element.BATHROOM_EXTRACTOR_FAN, + aspect_type=AspectType.PRESENCE, + ), + (50, 9): ElementMapping( + element=Element.KITCHEN, + aspect_type=AspectType.TYPE, ), - (50, 9): ElementMapping(element=Element.KITCHEN, aspect_type=AspectType.TYPE), (50, 10): ElementMapping( - element=Element.KITCHEN_EXTRACTOR_FAN, aspect_type=AspectType.PRESENCE + element=Element.KITCHEN_EXTRACTOR_FAN, + aspect_type=AspectType.PRESENCE, ), (50, 13): ElementMapping( - element=Element.KITCHEN_SPACE_LAYOUT, aspect_type=AspectType.ADEQUACY + element=Element.KITCHEN_SPACE_LAYOUT, + aspect_type=AspectType.ADEQUACY, + ), + (50, 17): ElementMapping( + element=Element.BATHRROM, + aspect_type=AspectType.LOCATION, ), - (50, 17): ElementMapping(element=Element.BATHRROM, aspect_type=AspectType.LOCATION), (50, 18): ElementMapping( - element=Element.BATHROOM, aspect_type=AspectType.TYPE + element=Element.BATHROOM, + aspect_type=AspectType.TYPE, ), # Actually "Primary bathroom type" - ok like this? (50, 20): ElementMapping( - element=Element.BATHROOM, aspect_type=AspectType.TYPE, element_instance=2 + element=Element.BATHROOM, + aspect_type=AspectType.TYPE, + element_instance=2, ), # Actually "Secondary bathroom type" - ok like this? - (160, 1): ElementMapping(element=Element.KITCHEN, aspect_type=AspectType.CONDITION), + (160, 1): ElementMapping( + element=Element.KITCHEN, + aspect_type=AspectType.CONDITION, + ), (160, 2): ElementMapping( - element=Element.KITCHEN_SPACE_LAYOUT, aspect_type=AspectType.ADEQUACY + element=Element.KITCHEN_SPACE_LAYOUT, + aspect_type=AspectType.ADEQUACY, ), (190, 1): ElementMapping( - element=Element.BATHROOM, aspect_type=AspectType.CONDITION + element=Element.BATHROOM, + aspect_type=AspectType.CONDITION, ), (190, 2): ElementMapping( - element=Element.SECONDARY_TOILET, aspect_type=AspectType.TYPE + element=Element.SECONDARY_TOILET, + aspect_type=AspectType.TYPE, ), # ========================================================== # COMMUNAL # ========================================================== (51, 1): ElementMapping( - element=Element.COMMUNAL_AERIAL, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_AERIAL, + aspect_type=AspectType.PRESENCE, ), (51, 2): ElementMapping( - element=Element.COMMUNAL_AOV, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_AOV, + aspect_type=AspectType.PRESENCE, ), (51, 3): ElementMapping( - element=Element.COMMUNAL_BALCONY_WALKWAY, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_BALCONY_WALKWAY, + aspect_type=AspectType.PRESENCE, ), (51, 4): ElementMapping( - element=Element.COMMUNAL_BATHROOM, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_BATHROOM, + aspect_type=AspectType.TYPE, ), (51, 5): ElementMapping( - element=Element.COMMUNAL_BIN_STORE_DOORS, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_BIN_STORE_DOORS, + aspect_type=AspectType.PRESENCE, ), (51, 6): ElementMapping( - element=Element.COMMUNAL_BIN_STORE_ROOF, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_BIN_STORE_ROOF, + aspect_type=AspectType.PRESENCE, ), (51, 7): ElementMapping( - element=Element.COMMUNAL_BIN_STORE_WALLS, aspect_type=AspectType.MATERIAL + element=Element.COMMUNAL_BIN_STORE_WALLS, + aspect_type=AspectType.MATERIAL, ), (51, 8): ElementMapping( - element=Element.COMMUNAL_BMS, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_BMS, + aspect_type=AspectType.PRESENCE, ), (51, 9): ElementMapping( - element=Element.COMMUNAL_BOILER, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_BOILER, + aspect_type=AspectType.TYPE, ), (51, 10): ElementMapping( - element=Element.COMMUNAL_BOOSTER_PUMP, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_BOOSTER_PUMP, + aspect_type=AspectType.PRESENCE, ), (51, 11): ElementMapping( - element=Element.COMMUNAL_CCTV, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_CCTV, + aspect_type=AspectType.PRESENCE, ), (51, 12): ElementMapping( - element=Element.COMMUNAL_CIRCULATION_SPACE, aspect_type=AspectType.ADEQUACY + element=Element.COMMUNAL_CIRCULATION_SPACE, + aspect_type=AspectType.ADEQUACY, ), (51, 13): ElementMapping( - element=Element.COMMUNAL_COLD_WATER_STORAGE, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_COLD_WATER_STORAGE, + aspect_type=AspectType.PRESENCE, ), (51, 14): ElementMapping( - element=Element.COMMUNAL_DOOR_ENTRY, aspect_type=AspectType.SYSTEM + element=Element.COMMUNAL_DOOR_ENTRY, + aspect_type=AspectType.SYSTEM, ), (51, 15): ElementMapping( - element=Element.COMMUNAL_DRY_RISER, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_DRY_RISER, + aspect_type=AspectType.PRESENCE, ), (51, 16): ElementMapping( - element=Element.COMMUNAL_EMERGENCY_LIGHTING, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_EMERGENCY_LIGHTING, + aspect_type=AspectType.PRESENCE, ), (51, 17): ElementMapping( - element=Element.COMMUNAL_EXTERNAL_DOORS, aspect_type=AspectType.MATERIAL + element=Element.COMMUNAL_EXTERNAL_DOORS, + aspect_type=AspectType.MATERIAL, ), (51, 19): ElementMapping( - element=Element.COMMUNAL_FIRE_ALARM, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_FIRE_ALARM, + aspect_type=AspectType.TYPE, ), (51, 20): ElementMapping( - element=Element.COMMUNAL_INTERNAL_DECORATIONS, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_INTERNAL_DECORATIONS, + aspect_type=AspectType.PRESENCE, ), (51, 21): ElementMapping( - element=Element.COMMUNAL_INTERNAL_DOORS, aspect_type=AspectType.MATERIAL + element=Element.COMMUNAL_INTERNAL_DOORS, + aspect_type=AspectType.MATERIAL, ), (51, 22): ElementMapping( - element=Element.COMMUNAL_INTERNAL_FLOOR, aspect_type=AspectType.FINISH + element=Element.COMMUNAL_INTERNAL_FLOOR, + aspect_type=AspectType.FINISH, ), (51, 23): ElementMapping( - element=Element.COMMUNAL_KITCHEN, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_KITCHEN, + aspect_type=AspectType.TYPE, ), (51, 24): ElementMapping( - element=Element.COMMUNAL_LATERAL_MAINS, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_LATERAL_MAINS, + aspect_type=AspectType.PRESENCE, ), (51, 25): ElementMapping( - element=Element.COMMUNAL_LIGHTING, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_LIGHTING, + aspect_type=AspectType.PRESENCE, ), (51, 26): ElementMapping( - element=Element.COMMUNAL_LIGHTING_CONDUCTOR, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_LIGHTING_CONDUCTOR, + aspect_type=AspectType.PRESENCE, ), (51, 27): ElementMapping( - element=Element.COMMUNAL_PASSENGER_LIFT, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_PASSENGER_LIFT, + aspect_type=AspectType.TYPE, ), (51, 28): ElementMapping( element=Element.COMMUNAL_ENTRANCE, @@ -357,135 +481,176 @@ PEABODY_ELEMENT_MAP = { element_instance=2, ), (51, 14): ElementMapping( - element=Element.COMMUNAL_SPRINKLER, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_SPRINKLER, + aspect_type=AspectType.PRESENCE, ), (51, 29): ElementMapping( - element=Element.COMMUNAL_REFUSE_CHUTE, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_REFUSE_CHUTE, + aspect_type=AspectType.PRESENCE, ), (51, 32): ElementMapping( - element=Element.COMMUNAL_STAIRS, aspect_type=AspectType.FINISH + element=Element.COMMUNAL_STAIRS, + aspect_type=AspectType.FINISH, ), (51, 33): ElementMapping( - element=Element.COMMUNAL_STORE_DOORS, aspect_type=AspectType.MATERIAL + element=Element.COMMUNAL_STORE_DOORS, + aspect_type=AspectType.MATERIAL, ), (51, 34): ElementMapping( - element=Element.COMMUNAL_STORE_ROOF, aspect_type=AspectType.MATERIAL + element=Element.COMMUNAL_STORE_ROOF, + aspect_type=AspectType.MATERIAL, ), (51, 35): ElementMapping( - element=Element.COMMUNAL_STORE_WALLS, aspect_type=AspectType.MATERIAL + element=Element.COMMUNAL_STORE_WALLS, + aspect_type=AspectType.MATERIAL, ), (51, 36): ElementMapping( - element=Element.COMMUNAL_WALKWAYS, aspect_type=AspectType.FINISH + element=Element.COMMUNAL_WALKWAYS, + aspect_type=AspectType.FINISH, ), (51, 37): ElementMapping( - element=Element.COMMUNAL_WARDEN_CALL_SYSTEM, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_WARDEN_CALL_SYSTEM, + aspect_type=AspectType.PRESENCE, ), (51, 38): ElementMapping( - element=Element.COMMUNAL_TOILETS, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_TOILETS, + aspect_type=AspectType.TYPE, ), (51, 39): ElementMapping( - element=Element.COMMUNAL_WET_RISER, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_WET_RISER, + aspect_type=AspectType.PRESENCE, ), (51, 40): ElementMapping( - element=Element.COMMUNAL_PLUG_SOCKETS, aspect_type=AspectType.PRESENCE + element=Element.COMMUNAL_PLUG_SOCKETS, + aspect_type=AspectType.PRESENCE, ), (200, 1): ElementMapping( - element=Element.COMMUNAL_BOILER, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_BOILER, + aspect_type=AspectType.TYPE, ), # Duplicate of (51, 9) - correct? (200, 2): ElementMapping( - element=Element.COMMUNAL_HEATING, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_HEATING, + aspect_type=AspectType.TYPE, ), (200, 3): ElementMapping( - element=Element.COMMUNAL_ELECTRICS, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_ELECTRICS, + aspect_type=AspectType.TYPE, ), (200, 4): ElementMapping( - element=Element.COMMUNAL_FIRE_ALARM, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_FIRE_ALARM, + aspect_type=AspectType.TYPE, ), (200, 5): ElementMapping( - element=Element.COMMUNAL_LIFT, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_LIFT, + aspect_type=AspectType.TYPE, ), (200, 6): ElementMapping( - element=Element.COMMUNAL_FLOOR_COVERING, aspect_type=AspectType.MATERIAL + element=Element.COMMUNAL_FLOOR_COVERING, + aspect_type=AspectType.MATERIAL, ), (200, 7): ElementMapping( - element=Element.COMMUNAL_KITCHEN, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_KITCHEN, + aspect_type=AspectType.TYPE, ), (200, 8): ElementMapping( - element=Element.COMMUNAL_BATHROOM, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_BATHROOM, + aspect_type=AspectType.TYPE, ), # Duplicate of (51, 4) - correct? (200, 9): ElementMapping( - element=Element.COMMUNAL_TOILETS, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_TOILETS, + aspect_type=AspectType.TYPE, ), # Duplicate of (51, 38) - correct? (200, 10): ElementMapping( - element=Element.COMMUNAL_GATES, aspect_type=AspectType.TYPE + element=Element.COMMUNAL_GATES, + aspect_type=AspectType.TYPE, ), # ========================================================== # INTERNAL – HEATING # ========================================================== (50, 4): ElementMapping( - element=Element.HEATING_BOILER, aspect_type=AspectType.PRESENCE + element=Element.HEATING_BOILER, + aspect_type=AspectType.PRESENCE, ), # This is actually "Central heating boiler" - ok like this? (50, 5): ElementMapping( - element=Element.CENTRAL_HEATING, aspect_type=AspectType.EXTENT + element=Element.CENTRAL_HEATING, + aspect_type=AspectType.EXTENT, ), (50, 6): ElementMapping( - element=Element.COLD_WATER_STORAGE, aspect_type=AspectType.PRESENCE + element=Element.COLD_WATER_STORAGE, + aspect_type=AspectType.PRESENCE, ), (50, 12): ElementMapping( - element=Element.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE + element=Element.HEATING_DISTRIBUTION, + aspect_type=AspectType.TYPE, ), (50, 19): ElementMapping( - element=Element.PROGRAMMABLE_HEATING, aspect_type=AspectType.TYPE + element=Element.PROGRAMMABLE_HEATING, + aspect_type=AspectType.TYPE, ), (50, 25): ElementMapping( - element=Element.HEATING_BOILER, aspect_type=AspectType.TYPE + element=Element.HEATING_BOILER, + aspect_type=AspectType.TYPE, ), (170, 1): ElementMapping( - element=Element.HEATING_BOILER, aspect_type=AspectType.TYPE + element=Element.HEATING_BOILER, + aspect_type=AspectType.TYPE, ), # Duplicate of (50,25) - correct? (170, 2): ElementMapping( - element=Element.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE + element=Element.HEATING_DISTRIBUTION, + aspect_type=AspectType.TYPE, ), # Duplicate of (50,12) - correct? (170, 3): ElementMapping( - element=Element.SECONDARY_HEATING, aspect_type=AspectType.TYPE + element=Element.SECONDARY_HEATING, + aspect_type=AspectType.TYPE, ), (170, 4): ElementMapping( - element=Element.COLD_WATER_STORAGE, aspect_type=AspectType.TYPE + element=Element.COLD_WATER_STORAGE, + aspect_type=AspectType.TYPE, ), (170, 5): ElementMapping( - element=Element.HOT_WATER_SYSTEM, aspect_type=AspectType.TYPE + element=Element.HOT_WATER_SYSTEM, + aspect_type=AspectType.TYPE, ), # ========================================================== # ELECTRICS # ========================================================== (50, 24): ElementMapping( - element=Element.INTERNAL_WIRING, aspect_type=AspectType.MATERIAL + element=Element.INTERNAL_WIRING, + aspect_type=AspectType.MATERIAL, ), (180, 1): ElementMapping( - element=Element.ELECTRICAL_WIRING, aspect_type=AspectType.WORK_REQUIRED + element=Element.ELECTRICAL_WIRING, + aspect_type=AspectType.WORK_REQUIRED, ), # Not certain about the AspectType - only example in the sample data is "Full Rewire" (180, 2): ElementMapping( - element=Element.CONSUMER_UNIT, aspect_type=AspectType.TYPE + element=Element.CONSUMER_UNIT, + aspect_type=AspectType.TYPE, ), (180, 3): ElementMapping( - element=Element.SMOKE_DETECTION, aspect_type=AspectType.TYPE + element=Element.SMOKE_DETECTION, + aspect_type=AspectType.TYPE, ), # Duplicate of (50, 21) - correct? (180, 4): ElementMapping( - element=Element.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE + element=Element.CARBON_MONOXIDE_DETECTION, + aspect_type=AspectType.TYPE, ), # Duplicate of (50, 2) - correct? # ========================================================== # HHSRS # ========================================================== (54, 1): ElementMapping( - element=Element.HHSRS_DAMP_AND_MOULD, aspect_type=AspectType.RISK + element=Element.HHSRS_DAMP_AND_MOULD, + aspect_type=AspectType.RISK, ), (54, 4): ElementMapping( - element=Element.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK + element=Element.HHSRS_ASBESTOS_AND_MMF, + aspect_type=AspectType.RISK, ), (54, 15): ElementMapping( - element=Element.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, aspect_type=AspectType.RISK + element=Element.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, + aspect_type=AspectType.RISK, ), (54, 29): ElementMapping( - element=Element.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK + element=Element.HHSRS_STRUCTURAL_COLLAPSE, + aspect_type=AspectType.RISK, ), } From d2ce135f07f3b7fcda1f512b49b52c0e860c0de5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 26 Jan 2026 16:45:36 +0000 Subject: [PATCH 60/74] final missing mappings --- .../domain/mapping/lbwf/lbwf_element_map.py | 14 ++++-- .../domain/mapping/lbwf/lbwf_mapper.py | 50 ++++++++++--------- .../mapping/peabody/peabody_element_map.py | 12 +++-- backend/condition/processor.py | 2 +- 4 files changed, 44 insertions(+), 34 deletions(-) diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index 02722b11..8d6ea858 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -423,7 +423,7 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { element=Element.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK, ), - "HHSRSBIOCIDES": ElementMapping( + "HHSRSBIOC": ElementMapping( element=Element.HHSRS_BIOCIDES, aspect_type=AspectType.RISK, ), @@ -431,6 +431,14 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { element=Element.HHSRS_CARBON_MONOXIDE, aspect_type=AspectType.RISK, ), + "HHSRSNO2": ElementMapping( + element=Element.HHSRS_CARBON_MONOXIDE, + aspect_type=AspectType.RISK, + ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard + "HHSRSSO2": ElementMapping( + element=Element.HHSRS_CARBON_MONOXIDE, + aspect_type=AspectType.RISK, + ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard "HHSRSLEAD": ElementMapping( element=Element.HHSRS_LEAD, aspect_type=AspectType.RISK, @@ -528,7 +536,3 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { aspect_type=AspectType.RISK, ), } - -# Unhandled: -# DECNTHMINC -# EICINSFREQ diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py index 635e5898..3d7b7349 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py @@ -27,32 +27,34 @@ class LbwfMapper(Mapper): uprn: int = client_data.uprn for raw_asset in client_data.assets: - try: - element_mapping: ElementMapping = LbwfMapper._map_element( - raw_asset.element_code - ) - except: - logger.warning( - f"Unrecognised LBWF Asset Element Code: {raw_asset.element_code}. Skipping record" - ) - continue + # Ignore metadata rows + if raw_asset.element_code not in ["EICINSFREQ", "DECNTHMINC"]: + try: + element_mapping: ElementMapping = LbwfMapper._map_element( + raw_asset.element_code + ) + except: + logger.warning( + f"Unrecognised LBWF Asset Element Code: {raw_asset.element_code}. Skipping record" + ) + continue - mapped_assets.append( - AssetCondition( - uprn=uprn, - element=element_mapping.element, - aspect_type=element_mapping.aspect_type, - value=raw_asset.attribute_code_description, - quantity=raw_asset.quantity, - install_date=raw_asset.install_date, - renewal_year=LbwfMapper._calculate_renewal_year( - raw_asset, survey_year - ), - element_instance=element_mapping.element_instance, - source_system=None, # Once we know the system name we'll set it here - comments=raw_asset.element_comments, + mapped_assets.append( + AssetCondition( + uprn=uprn, + element=element_mapping.element, + aspect_type=element_mapping.aspect_type, + value=raw_asset.attribute_code_description, + quantity=raw_asset.quantity, + install_date=raw_asset.install_date, + renewal_year=LbwfMapper._calculate_renewal_year( + raw_asset, survey_year + ), + element_instance=element_mapping.element_instance, + source_system=None, # Once we know the system name we'll set it here + comments=raw_asset.element_comments, + ) ) - ) return mapped_assets diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 8fe2ccb9..2485136b 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -51,7 +51,7 @@ PEABODY_ELEMENT_MAP = { (53, 4): ElementMapping( element=Element.EXTERNAL_DECORATION, aspect_type=AspectType.PRESENCE ), - (53, 4): ElementMapping( + (53, 5): ElementMapping( element=Element.EXTERNAL_NOISE_INSULATION, aspect_type=AspectType.ADEQUACY ), (53, 14): ElementMapping( @@ -109,7 +109,7 @@ PEABODY_ELEMENT_MAP = { element=Element.GUTTERS, aspect_type=AspectType.MATERIAL, ), - (53, 18): ElementMapping( + (53, 21): ElementMapping( element=Element.PITCHED_ROOF_COVERING, aspect_type=AspectType.MATERIAL, ), @@ -334,8 +334,12 @@ PEABODY_ELEMENT_MAP = { element=Element.KITCHEN_SPACE_LAYOUT, aspect_type=AspectType.ADEQUACY, ), + (50, 14): ElementMapping( + element=Element.KITCHEN, + aspect_type=AspectType.TYPE, + ), (50, 17): ElementMapping( - element=Element.BATHRROM, + element=Element.BATHROOM, aspect_type=AspectType.LOCATION, ), (50, 18): ElementMapping( @@ -480,7 +484,7 @@ PEABODY_ELEMENT_MAP = { aspect_type=AspectType.FINISH, element_instance=2, ), - (51, 14): ElementMapping( + (51, 31): ElementMapping( element=Element.COMMUNAL_SPRINKLER, aspect_type=AspectType.PRESENCE, ), diff --git a/backend/condition/processor.py b/backend/condition/processor.py index a48e22f4..903c9f23 100644 --- a/backend/condition/processor.py +++ b/backend/condition/processor.py @@ -26,4 +26,4 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None: for p in raw_properties: assets.extend(mapper.map_asset_conditions_for_property(p, survey_year)) - print(assets) # temp + print("done") # temp From cfc73d8f90ac429ef34590c08a0029dda4f14a9d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 26 Jan 2026 17:07:22 +0000 Subject: [PATCH 61/74] fix broken test --- backend/condition/tests/mapping/test_lbwf_mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index 918b6fea..907bd250 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -277,7 +277,7 @@ def test_lbwf_mapper_maps_house(): ), AssetCondition( uprn=1, - element=Element.HEATING_SYSTEM, + element=Element.CENTRAL_HEATING, aspect_type=AspectType.EXTENT, element_instance=None, value="No Central Heating in Property", From 5ab07d69030cb60cd62040ed82d68be3eb516ef5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 09:07:12 +0000 Subject: [PATCH 62/74] make element keys and values consistent --- backend/condition/domain/element.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index f78f2d52..2f6bac42 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -55,7 +55,7 @@ class Element(str, Enum): GARAGE_WALLS = "garage_walls" PARTY_WALL_FIRE_BREAK = "party_wall_fire_break" EXTERNAL_BRICKWORK_POINTING = "external_brickwork_pointing" - INTERNAL_DOWNPIPES_EXTERNAL_AREA = "internal_downpipes_in_external_area" + INTERNAL_DOWNPIPES_EXTERNAL_AREA = "internal_downpipes_external_area" # ====================== # EXTERNAL – WINDOWS @@ -157,7 +157,7 @@ class Element(str, Enum): HEAT_DETECTION = "heat_detection" CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection" FIRE_DOOR_RATING = "fire_door_rating" - FIRE_RISK_ASSESSMENT = "fire" + FIRE_RISK_ASSESSMENT = "fire_risk_assessment" INTERNAL_WIRING = ( "internal_wiring" # Is this definitely different from ELECTRICAL_WIRING? ) @@ -175,7 +175,7 @@ class Element(str, Enum): COMMUNAL_CCTV = "communal_cctv" COMMUNAL_BIN_STORE = "communal_bin_store" COMMUNAL_BIN_STORE_DOORS = "communal_bin_store_doors" - COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_wall" + COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_walls" COMMUNAL_BIN_STORE_ROOF = "communal_bin_store_roof" COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute" COMMUNAL_FLOOR_COVERING = "communal_floor_covering" @@ -216,11 +216,11 @@ class Element(str, Enum): # ====================== FFHH_DAMP = "ffhh_damp" FFHH_HOT_AND_COLD_WATER = "ffhh_hold_and_cold_water" - FFHH_DRAINAGE_LAVATORIES = "ffhh_drainage_or_lavatories" - FFHH_NEGLECTED = "ffhh_neglected_and_in_bad_condition" + FFHH_DRAINAGE_LAVATORIES = "ffhh_drainage_lavatories" + FFHH_NEGLECTED = "ffhh_neglected" FFHH_NATURAL_LIGHT = "ffhh_natural_light" FFHH_VENTILATION = "ffhh_ventilation" - FFHH_FOOD_PREP_AND_WASHUP = "ffhh_prepare_and_cook_food_or_wash_up" + FFHH_FOOD_PREP_AND_WASHUP = "ffhh_food_prep_and_washup" FFHH_UNSAFE_LAYOUT = "ffhh_unsafe_layout" FFHH_UNSTABLE_BUILDING = "ffhh_unstable_building" From 9509306e3d8621f5346daabdf94c5fa70cc552e3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 13:08:35 +0000 Subject: [PATCH 63/74] =?UTF-8?q?Add=20aspect=20instance=20to=20asset=20co?= =?UTF-8?q?ndition=20and=20modify=20how=20peabody=20walls=20are=20mapped?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/domain/asset_condition.py | 4 +- .../domain/mapping/element_mapping.py | 1 + .../mapping/peabody/peabody_element_map.py | 2 +- .../tests/mapping/test_peabody_mapper.py | 114 ++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/backend/condition/domain/asset_condition.py b/backend/condition/domain/asset_condition.py index 1b157a6b..8b054f45 100644 --- a/backend/condition/domain/asset_condition.py +++ b/backend/condition/domain/asset_condition.py @@ -13,6 +13,8 @@ class AssetCondition: element: Element aspect_type: AspectType + element_instance: Optional[int] = None + aspect_instance: Optional[int] = None value: Optional[str] = None @@ -20,7 +22,5 @@ class AssetCondition: install_date: Optional[date] = None renewal_year: Optional[int] = None - element_instance: Optional[int] = None - source_system: Optional[str] = None comments: Optional[str] = None diff --git a/backend/condition/domain/mapping/element_mapping.py b/backend/condition/domain/mapping/element_mapping.py index 01e1f316..c93862c8 100644 --- a/backend/condition/domain/mapping/element_mapping.py +++ b/backend/condition/domain/mapping/element_mapping.py @@ -10,3 +10,4 @@ class ElementMapping: element: Element aspect_type: AspectType element_instance: Optional[int] = None + aspect_instance: Optional[int] = None diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 2485136b..1f9cceee 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -58,7 +58,7 @@ PEABODY_ELEMENT_MAP = { element=Element.GARAGE_WALLS, aspect_type=AspectType.MATERIAL ), (53, 23): ElementMapping( - element=Element.PRIMARY_WALL, aspect_type=AspectType.FINISH + element=Element.EXTERNAL_WALL, aspect_type=AspectType.FINISH ), (53, 30): ElementMapping( element=Element.SECONDARY_WALL, aspect_type=AspectType.FINISH diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py index a975a308..9997dfa8 100644 --- a/backend/condition/tests/mapping/test_peabody_mapper.py +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -90,3 +90,117 @@ def test_peabody_mapper_maps_property(): for i, (actual, expected) in enumerate(zip(actual_assets, expected_assets)): assert actual == expected, f"Mismatch at index {i}" + + +def test_wall_primary_and_secondary_wall_finish_map_correctly(): + # arrange + peabody_property = PeabodyProperty( + uprn=1, + assets=[ + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=53, + element="External", + sub_element_code=23, + sub_element="Primary Wall Finish", + material_code=4, + material_or_answer="Pointed", + renewal_quantity=65, + renewal_year=2045, + renewal_cost=3835, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2024, 2, 15, 12, 47, 0), + ), + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=120, + element="WALLS", + sub_element_code=2, + sub_element="Wall Finish", + material_code=1, + material_or_answer="Pointing", + renewal_quantity=1, + renewal_year=2069, + renewal_cost=2450, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2014, 2, 15, 12, 47, 0), + ), + PeabodyAssetCondition( + lo_reference="1000RAND0000", + full_address="FLAT 1 RANDOM SQUARE FAKE STREET LONDON E1 1EE", + location_type_code=1, + parent_lo_reference="RAND1000", + element_code=53, + element="External", + sub_element_code=30, + sub_element="Secondary Wall Finish", + material_code=8, + material_or_answer="Tile Hung", + renewal_quantity=8, + renewal_year=2049, + renewal_cost=472, + cloned="N", + lo_type_code=1, + condition_survey_date=datetime(2014, 2, 15, 12, 47, 0), + ), + ], + ) + mapper = PeabodyMapper() + + expected_assets: List[AssetCondition] = [ + AssetCondition( + uprn=1, + element=Element.EXTERNAL_WALLS, + aspect_type=AspectType.FINISH, + value="Pointed", + element_instance=1, + aspect_instance=1, + quantity=65, + install_date=None, + renewal_year=2045, + source_system=None, + comments=None, + ), + AssetCondition( + uprn=1, + element=Element.EXTERNAL_WALLS, + aspect_type=AspectType.FINISH, + value="Pointing", + element_instance=1, + aspect_instance=1, + quantity=1, + install_date=None, + renewal_year=2069, + source_system=None, + comments=None, + ), + AssetCondition( + uprn=1, + element=Element.EXTERNAL_WALLS, + aspect_type=AspectType.FINISH, + value="Tile Hung", + element_instance=1, + aspect_instance=2, + quantity=8, + install_date=None, + renewal_year=2049, + source_system=None, + comments=None, + ), + ] + # act + actual_assets = mapper.map_asset_conditions_for_property(peabody_property) + + # assert + assert len(actual_assets) == len(expected_assets) + + for i, (actual, expected) in enumerate(zip(actual_assets, expected_assets)): + assert actual == expected, f"Mismatch at index {i}" From 6a2bb26baeacee877bcd1477925ac0d4acb0d9a2 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 13:29:06 +0000 Subject: [PATCH 64/74] Redefine conditions data structures. Mapping tests broken --- ...asset_condition.py => aspect_condition.py} | 15 +- backend/condition/domain/element.py | 269 +------------- backend/condition/domain/element_type.py | 261 ++++++++++++++ .../domain/mapping/lbwf/lbwf_element_map.py | 242 ++++++------- .../domain/mapping/lbwf/lbwf_mapper.py | 7 +- backend/condition/domain/mapping/mapper.py | 4 +- .../mapping/peabody/peabody_element_map.py | 330 +++++++++--------- .../domain/mapping/peabody/peabody_mapper.py | 8 +- .../domain/property_condition_survey.py | 14 + backend/condition/processor.py | 4 +- .../tests/mapping/test_lbwf_mapper.py | 44 +-- .../tests/mapping/test_peabody_mapper.py | 28 +- 12 files changed, 623 insertions(+), 603 deletions(-) rename backend/condition/domain/{asset_condition.py => aspect_condition.py} (57%) create mode 100644 backend/condition/domain/element_type.py create mode 100644 backend/condition/domain/property_condition_survey.py diff --git a/backend/condition/domain/asset_condition.py b/backend/condition/domain/aspect_condition.py similarity index 57% rename from backend/condition/domain/asset_condition.py rename to backend/condition/domain/aspect_condition.py index 8b054f45..75b46b09 100644 --- a/backend/condition/domain/asset_condition.py +++ b/backend/condition/domain/aspect_condition.py @@ -1,26 +1,17 @@ from dataclasses import dataclass -from datetime import date from typing import Optional -from xml.dom.minidom import Element +from datetime import date from backend.condition.domain.aspect_type import AspectType -from backend.condition.domain.element import Element @dataclass -class AssetCondition: - uprn: int - - element: Element +class AspectCondition: aspect_type: AspectType - element_instance: Optional[int] = None - aspect_instance: Optional[int] = None + aspect_instance: int value: Optional[str] = None - quantity: Optional[int] = None install_date: Optional[date] = None renewal_year: Optional[int] = None - - source_system: Optional[str] = None comments: Optional[str] = None diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index 2f6bac42..7aca11fd 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -1,261 +1,12 @@ -from enum import Enum +from dataclasses import dataclass +from typing import List + +from backend.condition.domain.aspect_condition import AspectCondition +from backend.condition.domain.element_type import ElementType -class Element(str, Enum): - - # ====================== - # PROPERTY / GENERAL - # ====================== - PROPERTY = "property" - PROPERTY_CONSTRUCTION_TYPE = "property_construction_type" - PROPERTY_CLASSIFICATION = "property_classification" - PROPERTY_AGE_BAND = "property_age_band" - STOREY_COUNT = "storey_count" - FLOOR_LEVEL = "floor_level" - FLOOR_LEVEL_FRONT_DOOR = "floor_level_front_door" - ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register" - ASBESTOS = "asbestos" - QUALITY_STANDARD = "quality_standard" - CCU = "ccu" - PASSENGER_LIFT = "passenger_lift" - STAIRLIFT = "stairlift" - DISABLED_HOIST_TRACKING = "disabled_hoist_tracking" - DISABLED_FACILITIES = "disabled_facilities" - STEPS_TO_FRONT_DOOR = "steps_to_front_door" - - # ====================== - # EXTERNAL – ROOF - # ====================== - ROOF = "roof" - PITCHED_ROOF_COVERING = "pitched_roof_covering" - FLAT_ROOF_COVERING = "flat_roof_covering" - RAINWATER_GOODS = "rainwater_goods" - LOFT_INSULATION = "loft_insulation" - PORCH_CANOPY = "porch_canopy" - CHIMNEY = "chimney" - FASCIA = "fascia" - SOFFIT = "soffit" - FASCIA_SOFFIT_BARGEBOARDS = "fascia_soffit_bargeboards" - GUTTERS = "gutters" - STORE_ROOF = "store_roof" - GARAGE_ROOF = "garage_roof" - GARAGE_AND_STORE_ROOF = "garage_and_store_roof" - - # ====================== - # EXTERNAL – WALLS - # ====================== - EXTERNAL_WALL = "external_wall" - EXTERNAL_NOISE_INSULATION = "external_noise_insulation" - PRIMARY_WALL = "primary_wall" - SECONDARY_WALL = "secondary_wall" - DOWNPIPES = "downpipes" - EXTERNAL_DECORATION = "external_decoration" - CLADDING = "cladding" - SPANDREL_PANELS = "spandrel_panels" - GARAGE_WALLS = "garage_walls" - PARTY_WALL_FIRE_BREAK = "party_wall_fire_break" - EXTERNAL_BRICKWORK_POINTING = "external_brickwork_pointing" - INTERNAL_DOWNPIPES_EXTERNAL_AREA = "internal_downpipes_external_area" - - # ====================== - # EXTERNAL – WINDOWS - # ====================== - EXTERNAL_WINDOWS = "external_windows" - COMMUNAL_WINDOWS = "communal_windows" - SECONDARY_GLAZING = "secondary_glazing" - STORE_WINDOWS = "store_windows" - GARAGE_WINDOWS = "garage_windows" - GARAGE_AND_STORE_WINDOWS = "garage_and_store_windows" - - # ====================== - # EXTERNAL – DOORS - # ====================== - EXTERNAL_DOOR = "external_door" - FRONT_DOOR = "front_door" - REAR_DOOR = "rear_door" - STORE_DOOR = "store_door" - GARAGE_DOOR = "garage_door" - GARAGE_AND_STORE_DOOR = "garage_and_store_door" - COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door" - MAIN_DOOR = "main_door" - BLOCK_ENTRANCE_DOOR = "block_entrance_door" - LINTEL = "lintel" - PATIO_FRENCH_DOOR = "patio_french_door" - DOOR_ENTRY_HANDSET = "door_entry_handset" - - # ====================== - # EXTERNAL – AREAS - # ====================== - PATHS_AND_HARDSTANDINGS = "paths_and_hardstandings" - PARKING_AREAS = "parking_areas" - BOUNDARY_WALLS = "boundary_walls" - FRONT_FENCING = "front_fencing" - REAR_FENCING = "rear_fencing" - SIDE_FENCING = "side_fencing" - REAR_GATE = "rear_gate" - FRONT_GATE = "front_gate" - GATES = "gates" - RETAINING_WALLS = "retaining_walls" - PRIVATE_BALCONY = "private_balcony" - BALCONY_BALUSTRADE = "balcony_balustrade" - OUTBUILDINGS = "outbuildings" - GARAGE_STRUCTURE = "garage_structure" - PAVING = "paving" - ROADS = "roads" - SOIL_AND_VENT = "soil_and_vent" - SOLAR_THERMALS = "solar_thermals" - DROP_KERB = "drop_kerb" - OUTBUILDING_OVERHAUL = "outbuilding_overhaul" - EXTERNAL_STRUCTURAL_DEFECTS = "external_structural_defects" - ACCESS_RAMP = "access_ramp" - - # ====================== - # INTERNAL – KITCHEN - # ====================== - KITCHEN = "kitchen" - KITCHEN_SPACE_LAYOUT = "kitchen_space_layout" - TENANT_INSTALLED_KITCHEN = "tenant_installed_kitchen" - KITCHEN_EXTRACTOR_FAN = "kitchen_extractor_fan" - - # ====================== - # INTERNAL – BATHROOM - # ====================== - BATHROOM = "bathroom" - SECONDARY_BATHROOM = "secondary_bathroom" - SECONDARY_TOILET = "secondary_toilet" - BATHROOM_EXTRACTOR_FAN = "bathroom_extractor_fan" - ADDITIONAL_WC_OR_WHB = "additional_wc_or_whb" - BATHROOM_REMAINING_LIFE_SOURCE = "bathroom_remaining_life_source" - KITCHEN_REMAINING_LIFE_SOURCE = "kitchen_remaining_life_source" - - # ====================== - # INTERNAL – HEATING / WATER - # ====================== - CENTRAL_HEATING = "central_heating" - HEATING_BOILER = "heating_boiler" - HEATING_DISTRIBUTION = "heating_distribution" - SECONDARY_HEATING = "secondary_heating" - HOT_WATER_SYSTEM = "hot_water_system" - COLD_WATER_STORAGE = "cold_water_storage" - HEATING_SYSTEM = "heating_system" - BOILER_FUEL = "boiler_fuel" - WATER_HEATING = "water_heating" - PROGRAMMABLE_HEATING = "programmable_heating" - COMMUNITY_HEATING = ( - "community_heating" # Is this definitely different from COMMUNAL_HEATING? - ) - GAS_AVAILABLE = "gas_available" - HEAT_RECOVERY_UNITS = "heat_recovery_units" - HEATING_IMPROVEMENTS = "heating_improvements" - - # ====================== - # INTERNAL – ELECTRICS / FIRE - # ====================== - ELECTRICAL_WIRING = "electrical_wiring" - CONSUMER_UNIT = "consumer_unit" - SMOKE_DETECTION = "smoke_detection" - HEAT_DETECTION = "heat_detection" - CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection" - FIRE_DOOR_RATING = "fire_door_rating" - FIRE_RISK_ASSESSMENT = "fire_risk_assessment" - INTERNAL_WIRING = ( - "internal_wiring" # Is this definitely different from ELECTRICAL_WIRING? - ) - ELECTRICS = "electrics" - - # ====================== - # COMMUNAL - # ====================== - COMMUNAL_HEATING = "communal_heating" - COMMUNAL_BOILER = "communal_boiler" - COMMUNAL_ELECTRICS = "communal_electrics" - COMMUNAL_FIRE_ALARM = "communal_fire_alarm" - COMMUNAL_EMERGENCY_LIGHTING = "communal_emergency_lighting" - COMMUNAL_DOOR_ENTRY = "communal_door_entry" - COMMUNAL_CCTV = "communal_cctv" - COMMUNAL_BIN_STORE = "communal_bin_store" - COMMUNAL_BIN_STORE_DOORS = "communal_bin_store_doors" - COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_walls" - COMMUNAL_BIN_STORE_ROOF = "communal_bin_store_roof" - COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute" - COMMUNAL_FLOOR_COVERING = "communal_floor_covering" - COMMUNAL_KITCHEN = "communal_kitchen" - COMMUNAL_BATHROOM = "communal_bathroom" - COMMUNAL_TOILETS = "communal_toilets" - COMMUNAL_GATES = "communal_gates" - COMMUNAL_LIFT = "communal_lift" - COMMUNAL_PASSENGER_LIFT = "communal_passenger_lift" - COMMUNAL_BALCONY_WALKWAY = "communal_balcony_walkway" - COMMUNAL_ENTRANCE = "communal_entrance" - COMMUNAL_INTERNAL_DECORATIONS = "communal_internal_decorations" - COMMUNAL_INTERNAL_FLOOR = "communal_internal_floor" - COMMUNAL_WALKWAYS = "communal_walkways" - COMMUNAL_EXTERNAL_DOORS = "communal_external_doors" - COMMUNAL_STAIRS = "communal_stairs" - COMMUNAL_AERIAL = "communal_aerial" - COMMUNAL_AOV = "communal_aov" - COMMUNAL_INTERNAL_DOORS = "communal_internal_doors" - COMMUNAL_LATERAL_MAINS = "communal_lateral_mains" - COMMUNAL_LIGHTING = "communal_lighting" - COMMUNAL_LIGHTING_CONDUCTOR = "communal_lighting_conductor" - COMMUNAL_STORE_ROOF = "communal_store_roof" - COMMUNAL_STORE_WALLS = "communal_store_walls" - COMMUNAL_STORE_DOORS = "communal_store_doors" - COMMUNAL_WARDEN_CALL_SYSTEM = "communal_warden_call_system" - COMMUNAL_BMS = "communal_bms" - COMMUNAL_BOOSTER_PUMP = "communal_booster_pump" - COMMUNAL_DRY_RISER = "communal_dry_riser" - COMMUNAL_WET_RISER = "communal_wet_riser" - COMMUNAL_COLD_WATER_STORAGE = "communal_cold_water_storage" - COMMUNAL_SPRINKLER = "communal_sprinkler" - COMMUNAL_PLUG_SOCKETS = "communal_plug_sockets" - COMMUNAL_CIRCULATION_SPACE = "communal_circulation_space" - - # ====================== - # FITNESS FOR HUMAN HABITATION - # ====================== - FFHH_DAMP = "ffhh_damp" - FFHH_HOT_AND_COLD_WATER = "ffhh_hold_and_cold_water" - FFHH_DRAINAGE_LAVATORIES = "ffhh_drainage_lavatories" - FFHH_NEGLECTED = "ffhh_neglected" - FFHH_NATURAL_LIGHT = "ffhh_natural_light" - FFHH_VENTILATION = "ffhh_ventilation" - FFHH_FOOD_PREP_AND_WASHUP = "ffhh_food_prep_and_washup" - FFHH_UNSAFE_LAYOUT = "ffhh_unsafe_layout" - FFHH_UNSTABLE_BUILDING = "ffhh_unstable_building" - - # ========================================================== - # HHSRS – ALL 29 HAZARDS - # ========================================================== - - HHSRS_DAMP_AND_MOULD = "hhsrs_damp_and_mould" - HHSRS_EXCESS_COLD = "hhsrs_excess_cold" - HHSRS_EXCESS_HEAT = "hhsrs_excess_heat" - HHSRS_ASBESTOS_AND_MMF = "hhsrs_asbestos_and_mmf" - HHSRS_BIOCIDES = "hhsrs_biocides" - HHSRS_CARBON_MONOXIDE = "hhsrs_carbon_monoxide" - HHSRS_LEAD = "hhsrs_lead" - HHSRS_RADIATION = "hhsrs_radiation" - HHSRS_UNCOMBUSTED_FUEL_GAS = "hhsrs_uncombusted_fuel_gas" - HHSRS_VOLATILE_ORGANIC_COMPOUNDS = "hhsrs_volatile_organic_compounds" - HHSRS_CROWDING_AND_SPACE = "hhsrs_crowding_and_space" - HHSRS_ENTRY_BY_INTRUDERS = "hhsrs_entry_by_intruders" - HHSRS_LIGHTING = "hhsrs_lighting" - HHSRS_NOISE = "hhsrs_noise" - HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE = "hhsrs_domestic_hygiene_pests_refuse" - HHSRS_FOOD_SAFETY = "hhsrs_food_safety" - HHSRS_PERSONAL_HYGIENE_SANITATION = "hhsrs_personal_hygiene_sanitation" - HHSRS_WATER_SUPPLY = "hhsrs_water_supply" - HHSRS_FALLS_ASSOCIATED_WITH_BATHS = "hhsrs_falls_associated_with_baths" - HHSRS_FALLS_ON_LEVEL_SURFACES = "hhsrs_falls_on_level_surfaces" - HHSRS_FALLS_ON_STAIRS = "hhsrs_falls_on_stairs" - HHSRS_FALLS_BETWEEN_LEVELS = "hhsrs_falls_between_levels" - HHSRS_ELECTRICAL_HAZARDS = "hhsrs_electrical_hazards" - HHSRS_FIRE = "hhsrs_fire" - HHSRS_FLAMES_HOT_SURFACES = "hhsrs_flames_hot_surfaces" - HHSRS_COLLISION_AND_ENTRAPMENT = "hhsrs_collision_and_entrapment" - HHSRS_COLLISION_HAZARDS_LOW_HEADROOM = "hhsrs_collision_hazards_low_headroom" - HHSRS_EXPLOSIONS = "hhsrs_explosions" - HHSRS_ERGONOMICS = "hhsrs_ergonomics" - HHSRS_STRUCTURAL_COLLAPSE = "hhsrs_structural_collapse" - HHSRS_AMENITIES = "hhsrs_amenities" +@dataclass +class Element: + element: ElementType + element_instance: int + aspect_conditions: List[AspectCondition] diff --git a/backend/condition/domain/element_type.py b/backend/condition/domain/element_type.py new file mode 100644 index 00000000..32897895 --- /dev/null +++ b/backend/condition/domain/element_type.py @@ -0,0 +1,261 @@ +from enum import Enum + + +class ElementType(str, Enum): + + # ====================== + # PROPERTY / GENERAL + # ====================== + PROPERTY = "property" + PROPERTY_CONSTRUCTION_TYPE = "property_construction_type" + PROPERTY_CLASSIFICATION = "property_classification" + PROPERTY_AGE_BAND = "property_age_band" + STOREY_COUNT = "storey_count" + FLOOR_LEVEL = "floor_level" + FLOOR_LEVEL_FRONT_DOOR = "floor_level_front_door" + ACCESSIBLE_HOUSING_REGISTER = "accessible_housing_register" + ASBESTOS = "asbestos" + QUALITY_STANDARD = "quality_standard" + CCU = "ccu" + PASSENGER_LIFT = "passenger_lift" + STAIRLIFT = "stairlift" + DISABLED_HOIST_TRACKING = "disabled_hoist_tracking" + DISABLED_FACILITIES = "disabled_facilities" + STEPS_TO_FRONT_DOOR = "steps_to_front_door" + + # ====================== + # EXTERNAL – ROOF + # ====================== + ROOF = "roof" + PITCHED_ROOF_COVERING = "pitched_roof_covering" + FLAT_ROOF_COVERING = "flat_roof_covering" + RAINWATER_GOODS = "rainwater_goods" + LOFT_INSULATION = "loft_insulation" + PORCH_CANOPY = "porch_canopy" + CHIMNEY = "chimney" + FASCIA = "fascia" + SOFFIT = "soffit" + FASCIA_SOFFIT_BARGEBOARDS = "fascia_soffit_bargeboards" + GUTTERS = "gutters" + STORE_ROOF = "store_roof" + GARAGE_ROOF = "garage_roof" + GARAGE_AND_STORE_ROOF = "garage_and_store_roof" + + # ====================== + # EXTERNAL – WALLS + # ====================== + EXTERNAL_WALL = "external_wall" + EXTERNAL_NOISE_INSULATION = "external_noise_insulation" + PRIMARY_WALL = "primary_wall" + SECONDARY_WALL = "secondary_wall" + DOWNPIPES = "downpipes" + EXTERNAL_DECORATION = "external_decoration" + CLADDING = "cladding" + SPANDREL_PANELS = "spandrel_panels" + GARAGE_WALLS = "garage_walls" + PARTY_WALL_FIRE_BREAK = "party_wall_fire_break" + EXTERNAL_BRICKWORK_POINTING = "external_brickwork_pointing" + INTERNAL_DOWNPIPES_EXTERNAL_AREA = "internal_downpipes_external_area" + + # ====================== + # EXTERNAL – WINDOWS + # ====================== + EXTERNAL_WINDOWS = "external_windows" + COMMUNAL_WINDOWS = "communal_windows" + SECONDARY_GLAZING = "secondary_glazing" + STORE_WINDOWS = "store_windows" + GARAGE_WINDOWS = "garage_windows" + GARAGE_AND_STORE_WINDOWS = "garage_and_store_windows" + + # ====================== + # EXTERNAL – DOORS + # ====================== + EXTERNAL_DOOR = "external_door" + FRONT_DOOR = "front_door" + REAR_DOOR = "rear_door" + STORE_DOOR = "store_door" + GARAGE_DOOR = "garage_door" + GARAGE_AND_STORE_DOOR = "garage_and_store_door" + COMMUNAL_ENTRANCE_DOOR = "communal_entrance_door" + MAIN_DOOR = "main_door" + BLOCK_ENTRANCE_DOOR = "block_entrance_door" + LINTEL = "lintel" + PATIO_FRENCH_DOOR = "patio_french_door" + DOOR_ENTRY_HANDSET = "door_entry_handset" + + # ====================== + # EXTERNAL – AREAS + # ====================== + PATHS_AND_HARDSTANDINGS = "paths_and_hardstandings" + PARKING_AREAS = "parking_areas" + BOUNDARY_WALLS = "boundary_walls" + FRONT_FENCING = "front_fencing" + REAR_FENCING = "rear_fencing" + SIDE_FENCING = "side_fencing" + REAR_GATE = "rear_gate" + FRONT_GATE = "front_gate" + GATES = "gates" + RETAINING_WALLS = "retaining_walls" + PRIVATE_BALCONY = "private_balcony" + BALCONY_BALUSTRADE = "balcony_balustrade" + OUTBUILDINGS = "outbuildings" + GARAGE_STRUCTURE = "garage_structure" + PAVING = "paving" + ROADS = "roads" + SOIL_AND_VENT = "soil_and_vent" + SOLAR_THERMALS = "solar_thermals" + DROP_KERB = "drop_kerb" + OUTBUILDING_OVERHAUL = "outbuilding_overhaul" + EXTERNAL_STRUCTURAL_DEFECTS = "external_structural_defects" + ACCESS_RAMP = "access_ramp" + + # ====================== + # INTERNAL – KITCHEN + # ====================== + KITCHEN = "kitchen" + KITCHEN_SPACE_LAYOUT = "kitchen_space_layout" + TENANT_INSTALLED_KITCHEN = "tenant_installed_kitchen" + KITCHEN_EXTRACTOR_FAN = "kitchen_extractor_fan" + + # ====================== + # INTERNAL – BATHROOM + # ====================== + BATHROOM = "bathroom" + SECONDARY_BATHROOM = "secondary_bathroom" + SECONDARY_TOILET = "secondary_toilet" + BATHROOM_EXTRACTOR_FAN = "bathroom_extractor_fan" + ADDITIONAL_WC_OR_WHB = "additional_wc_or_whb" + BATHROOM_REMAINING_LIFE_SOURCE = "bathroom_remaining_life_source" + KITCHEN_REMAINING_LIFE_SOURCE = "kitchen_remaining_life_source" + + # ====================== + # INTERNAL – HEATING / WATER + # ====================== + CENTRAL_HEATING = "central_heating" + HEATING_BOILER = "heating_boiler" + HEATING_DISTRIBUTION = "heating_distribution" + SECONDARY_HEATING = "secondary_heating" + HOT_WATER_SYSTEM = "hot_water_system" + COLD_WATER_STORAGE = "cold_water_storage" + HEATING_SYSTEM = "heating_system" + BOILER_FUEL = "boiler_fuel" + WATER_HEATING = "water_heating" + PROGRAMMABLE_HEATING = "programmable_heating" + COMMUNITY_HEATING = ( + "community_heating" # Is this definitely different from COMMUNAL_HEATING? + ) + GAS_AVAILABLE = "gas_available" + HEAT_RECOVERY_UNITS = "heat_recovery_units" + HEATING_IMPROVEMENTS = "heating_improvements" + + # ====================== + # INTERNAL – ELECTRICS / FIRE + # ====================== + ELECTRICAL_WIRING = "electrical_wiring" + CONSUMER_UNIT = "consumer_unit" + SMOKE_DETECTION = "smoke_detection" + HEAT_DETECTION = "heat_detection" + CARBON_MONOXIDE_DETECTION = "carbon_monoxide_detection" + FIRE_DOOR_RATING = "fire_door_rating" + FIRE_RISK_ASSESSMENT = "fire_risk_assessment" + INTERNAL_WIRING = ( + "internal_wiring" # Is this definitely different from ELECTRICAL_WIRING? + ) + ELECTRICS = "electrics" + + # ====================== + # COMMUNAL + # ====================== + COMMUNAL_HEATING = "communal_heating" + COMMUNAL_BOILER = "communal_boiler" + COMMUNAL_ELECTRICS = "communal_electrics" + COMMUNAL_FIRE_ALARM = "communal_fire_alarm" + COMMUNAL_EMERGENCY_LIGHTING = "communal_emergency_lighting" + COMMUNAL_DOOR_ENTRY = "communal_door_entry" + COMMUNAL_CCTV = "communal_cctv" + COMMUNAL_BIN_STORE = "communal_bin_store" + COMMUNAL_BIN_STORE_DOORS = "communal_bin_store_doors" + COMMUNAL_BIN_STORE_WALLS = "communal_bin_store_walls" + COMMUNAL_BIN_STORE_ROOF = "communal_bin_store_roof" + COMMUNAL_REFUSE_CHUTE = "communal_refuse_chute" + COMMUNAL_FLOOR_COVERING = "communal_floor_covering" + COMMUNAL_KITCHEN = "communal_kitchen" + COMMUNAL_BATHROOM = "communal_bathroom" + COMMUNAL_TOILETS = "communal_toilets" + COMMUNAL_GATES = "communal_gates" + COMMUNAL_LIFT = "communal_lift" + COMMUNAL_PASSENGER_LIFT = "communal_passenger_lift" + COMMUNAL_BALCONY_WALKWAY = "communal_balcony_walkway" + COMMUNAL_ENTRANCE = "communal_entrance" + COMMUNAL_INTERNAL_DECORATIONS = "communal_internal_decorations" + COMMUNAL_INTERNAL_FLOOR = "communal_internal_floor" + COMMUNAL_WALKWAYS = "communal_walkways" + COMMUNAL_EXTERNAL_DOORS = "communal_external_doors" + COMMUNAL_STAIRS = "communal_stairs" + COMMUNAL_AERIAL = "communal_aerial" + COMMUNAL_AOV = "communal_aov" + COMMUNAL_INTERNAL_DOORS = "communal_internal_doors" + COMMUNAL_LATERAL_MAINS = "communal_lateral_mains" + COMMUNAL_LIGHTING = "communal_lighting" + COMMUNAL_LIGHTING_CONDUCTOR = "communal_lighting_conductor" + COMMUNAL_STORE_ROOF = "communal_store_roof" + COMMUNAL_STORE_WALLS = "communal_store_walls" + COMMUNAL_STORE_DOORS = "communal_store_doors" + COMMUNAL_WARDEN_CALL_SYSTEM = "communal_warden_call_system" + COMMUNAL_BMS = "communal_bms" + COMMUNAL_BOOSTER_PUMP = "communal_booster_pump" + COMMUNAL_DRY_RISER = "communal_dry_riser" + COMMUNAL_WET_RISER = "communal_wet_riser" + COMMUNAL_COLD_WATER_STORAGE = "communal_cold_water_storage" + COMMUNAL_SPRINKLER = "communal_sprinkler" + COMMUNAL_PLUG_SOCKETS = "communal_plug_sockets" + COMMUNAL_CIRCULATION_SPACE = "communal_circulation_space" + + # ====================== + # FITNESS FOR HUMAN HABITATION + # ====================== + FFHH_DAMP = "ffhh_damp" + FFHH_HOT_AND_COLD_WATER = "ffhh_hold_and_cold_water" + FFHH_DRAINAGE_LAVATORIES = "ffhh_drainage_lavatories" + FFHH_NEGLECTED = "ffhh_neglected" + FFHH_NATURAL_LIGHT = "ffhh_natural_light" + FFHH_VENTILATION = "ffhh_ventilation" + FFHH_FOOD_PREP_AND_WASHUP = "ffhh_food_prep_and_washup" + FFHH_UNSAFE_LAYOUT = "ffhh_unsafe_layout" + FFHH_UNSTABLE_BUILDING = "ffhh_unstable_building" + + # ========================================================== + # HHSRS – ALL 29 HAZARDS + # ========================================================== + + HHSRS_DAMP_AND_MOULD = "hhsrs_damp_and_mould" + HHSRS_EXCESS_COLD = "hhsrs_excess_cold" + HHSRS_EXCESS_HEAT = "hhsrs_excess_heat" + HHSRS_ASBESTOS_AND_MMF = "hhsrs_asbestos_and_mmf" + HHSRS_BIOCIDES = "hhsrs_biocides" + HHSRS_CARBON_MONOXIDE = "hhsrs_carbon_monoxide" + HHSRS_LEAD = "hhsrs_lead" + HHSRS_RADIATION = "hhsrs_radiation" + HHSRS_UNCOMBUSTED_FUEL_GAS = "hhsrs_uncombusted_fuel_gas" + HHSRS_VOLATILE_ORGANIC_COMPOUNDS = "hhsrs_volatile_organic_compounds" + HHSRS_CROWDING_AND_SPACE = "hhsrs_crowding_and_space" + HHSRS_ENTRY_BY_INTRUDERS = "hhsrs_entry_by_intruders" + HHSRS_LIGHTING = "hhsrs_lighting" + HHSRS_NOISE = "hhsrs_noise" + HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE = "hhsrs_domestic_hygiene_pests_refuse" + HHSRS_FOOD_SAFETY = "hhsrs_food_safety" + HHSRS_PERSONAL_HYGIENE_SANITATION = "hhsrs_personal_hygiene_sanitation" + HHSRS_WATER_SUPPLY = "hhsrs_water_supply" + HHSRS_FALLS_ASSOCIATED_WITH_BATHS = "hhsrs_falls_associated_with_baths" + HHSRS_FALLS_ON_LEVEL_SURFACES = "hhsrs_falls_on_level_surfaces" + HHSRS_FALLS_ON_STAIRS = "hhsrs_falls_on_stairs" + HHSRS_FALLS_BETWEEN_LEVELS = "hhsrs_falls_between_levels" + HHSRS_ELECTRICAL_HAZARDS = "hhsrs_electrical_hazards" + HHSRS_FIRE = "hhsrs_fire" + HHSRS_FLAMES_HOT_SURFACES = "hhsrs_flames_hot_surfaces" + HHSRS_COLLISION_AND_ENTRAPMENT = "hhsrs_collision_and_entrapment" + HHSRS_COLLISION_HAZARDS_LOW_HEADROOM = "hhsrs_collision_hazards_low_headroom" + HHSRS_EXPLOSIONS = "hhsrs_explosions" + HHSRS_ERGONOMICS = "hhsrs_ergonomics" + HHSRS_STRUCTURAL_COLLAPSE = "hhsrs_structural_collapse" + HHSRS_AMENITIES = "hhsrs_amenities" diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index 8d6ea858..dfd9ca4e 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -1,4 +1,4 @@ -from backend.condition.domain.element import Element +from backend.condition.domain.element_type import ElementType from backend.condition.domain.aspect_type import AspectType from backend.condition.domain.mapping.element_mapping import ElementMapping @@ -8,11 +8,11 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # PROPERTY / GENERAL # ========================================================== "AHR_CAT": ElementMapping( - element=Element.ACCESSIBLE_HOUSING_REGISTER, + element=ElementType.ACCESSIBLE_HOUSING_REGISTER, aspect_type=AspectType.CATEGORY, ), "ASSETSAREA": ElementMapping( - element=Element.PROPERTY, + element=ElementType.PROPERTY, aspect_type=AspectType.AREA, ), # "DECNTHMINC": ElementMapping( @@ -20,301 +20,301 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # aspect_type=AspectType.INCLUSION, # ), # Ignore this one "QUALITYSTD": ElementMapping( - element=Element.QUALITY_STANDARD, + element=ElementType.QUALITY_STANDARD, aspect_type=AspectType.TYPE, ), "EXTSTOREY": ElementMapping( - element=Element.PROPERTY, + element=ElementType.PROPERTY, aspect_type=AspectType.CONFIGURATION, ), "FLVL": ElementMapping( - element=Element.FLOOR_LEVEL_FRONT_DOOR, + element=ElementType.FLOOR_LEVEL_FRONT_DOOR, aspect_type=AspectType.LOCATION, ), "INTFLRLVL": ElementMapping( - element=Element.FLOOR_LEVEL, + element=ElementType.FLOOR_LEVEL, aspect_type=AspectType.LOCATION, ), "INTNSEINSL": ElementMapping( - element=Element.EXTERNAL_NOISE_INSULATION, # Maybe this shouldn't be "EXTERNAL_" + element=ElementType.EXTERNAL_NOISE_INSULATION, # Maybe this shouldn't be "EXTERNAL_" aspect_type=AspectType.ADEQUACY, ), "INTSTEPSFD": ElementMapping( - element=Element.STEPS_TO_FRONT_DOOR, + element=ElementType.STEPS_TO_FRONT_DOOR, aspect_type=AspectType.QUANTITY, ), # ========================================================== # ASBESTOS (NON-HHSRS RECORD) # ========================================================== "ASBESTOS": ElementMapping( - element=Element.ASBESTOS, + element=ElementType.ASBESTOS, aspect_type=AspectType.PRESENCE, ), # ========================================================== # INTERNAL – BATHROOMS & KITCHENS # ========================================================== "INTBTHRLOC": ElementMapping( - element=Element.BATHROOM, + element=ElementType.BATHROOM, aspect_type=AspectType.LOCATION, ), "INTBTHADEQ": ElementMapping( - element=Element.BATHROOM, + element=ElementType.BATHROOM, aspect_type=AspectType.ADEQUACY, ), "INTKITADEQ": ElementMapping( - element=Element.KITCHEN, + element=ElementType.KITCHEN, aspect_type=AspectType.ADEQUACY, ), "INTCKRLOC": ElementMapping( - element=Element.KITCHEN, + element=ElementType.KITCHEN, aspect_type=AspectType.LOCATION, ), "INTADDWCW": ElementMapping( - element=Element.ADDITIONAL_WC_OR_WHB, + element=ElementType.ADDITIONAL_WC_OR_WHB, aspect_type=AspectType.PRESENCE, ), "INTBTHREML": ElementMapping( - element=Element.BATHROOM_REMAINING_LIFE_SOURCE, + element=ElementType.BATHROOM_REMAINING_LIFE_SOURCE, aspect_type=AspectType.TYPE, ), "INTKITREML": ElementMapping( - element=Element.KITCHEN_REMAINING_LIFE_SOURCE, + element=ElementType.KITCHEN_REMAINING_LIFE_SOURCE, aspect_type=AspectType.TYPE, ), "INTTNTINST": ElementMapping( - element=Element.TENANT_INSTALLED_KITCHEN, + element=ElementType.TENANT_INSTALLED_KITCHEN, aspect_type=AspectType.TYPE, # Not certain about this aspect type - need more data ), # ========================================================== # INTERNAL – FIRE # ========================================================== "FRARISKRTG": ElementMapping( - element=Element.FIRE_RISK_ASSESSMENT, + element=ElementType.FIRE_RISK_ASSESSMENT, aspect_type=AspectType.RATING, ), "FRATYPE": ElementMapping( - element=Element.FIRE_RISK_ASSESSMENT, + element=ElementType.FIRE_RISK_ASSESSMENT, aspect_type=AspectType.TYPE, ), "FRAEVACSTR": ElementMapping( - element=Element.FIRE_RISK_ASSESSMENT, + element=ElementType.FIRE_RISK_ASSESSMENT, aspect_type=AspectType.STRATEGY, ), "INTSMKDET": ElementMapping( - element=Element.SMOKE_DETECTION, + element=ElementType.SMOKE_DETECTION, aspect_type=AspectType.PRESENCE, ), "INTCHEXTNT": ElementMapping( - element=Element.HEATING_SYSTEM, + element=ElementType.HEATING_SYSTEM, aspect_type=AspectType.EXTENT, ), # ========================================================== # HEATING & SERVICES # ========================================================== "INTCHEXTNT": ElementMapping( - element=Element.CENTRAL_HEATING, + element=ElementType.CENTRAL_HEATING, aspect_type=AspectType.EXTENT, ), "INTCHDIST": ElementMapping( - element=Element.HEATING_DISTRIBUTION, + element=ElementType.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE, ), "INTCHBLR": ElementMapping( - element=Element.HEATING_BOILER, + element=ElementType.HEATING_BOILER, aspect_type=AspectType.TYPE, ), "INTBOILERF": ElementMapping( - element=Element.BOILER_FUEL, + element=ElementType.BOILER_FUEL, aspect_type=AspectType.TYPE, ), "INTHTDISYS": ElementMapping( - element=Element.HEATING_SYSTEM, + element=ElementType.HEATING_SYSTEM, aspect_type=AspectType.DISTRIBUTION, ), "INTWTRHTNG": ElementMapping( - element=Element.WATER_HEATING, + element=ElementType.WATER_HEATING, aspect_type=AspectType.TYPE, ), "INTCOMHTG": ElementMapping( - element=Element.COMMUNITY_HEATING, + element=ElementType.COMMUNITY_HEATING, aspect_type=AspectType.TYPE, ), "INTELECTRC": ElementMapping( - element=Element.ELECTRICS, + element=ElementType.ELECTRICS, aspect_type=AspectType.WORK_REQUIRED, # Not certain about this aspect type - need more data ), "INTGASAVAI": ElementMapping( - element=Element.GAS_AVAILABLE, + element=ElementType.GAS_AVAILABLE, aspect_type=AspectType.PRESENCE, # Maybe should be AspectType.TYPE ? ), "INTHEATREC": ElementMapping( - element=Element.HEAT_RECOVERY_UNITS, + element=ElementType.HEAT_RECOVERY_UNITS, aspect_type=AspectType.PRESENCE, ), "INTHTIMP": ElementMapping( - element=Element.GAS_AVAILABLE, + element=ElementType.GAS_AVAILABLE, aspect_type=AspectType.WORK_REQUIRED, ), "INTPROGHTG": ElementMapping( - element=Element.PROGRAMMABLE_HEATING, + element=ElementType.PROGRAMMABLE_HEATING, aspect_type=AspectType.TYPE, # Should maybe be PRESENCE, but set to TYPE for consistency with Peabody data ), # ========================================================== # EXTERNAL – WALLS (INSTANCED) # ========================================================== "EXTWALLSTR": ElementMapping( - element=Element.EXTERNAL_WALL, + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE, element_instance=1, ), "EXTWALLFN1": ElementMapping( - element=Element.EXTERNAL_WALL, + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=1, ), "EXTWALLFN2": ElementMapping( - element=Element.EXTERNAL_WALL, + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=2, ), "EXTWALLINS": ElementMapping( - element=Element.EXTERNAL_WALL, + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION, ), "EXTWALLSPL": ElementMapping( - element=Element.EXTERNAL_WALL, + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.CONDITION, ), "EXTDWNPTYP": ElementMapping( - element=Element.DOWNPIPES, + element=ElementType.DOWNPIPES, aspect_type=AspectType.MATERIAL, ), "EXTGUTRTYP": ElementMapping( - element=Element.GUTTERS, + element=ElementType.GUTTERS, aspect_type=AspectType.MATERIAL, ), # ========================================================== # EXTERNAL – ROOFS (INSTANCED) # ========================================================== "EXTRFSTR1": ElementMapping( - element=Element.ROOF, + element=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, element_instance=1, ), "EXTRFSTR2": ElementMapping( - element=Element.ROOF, + element=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, element_instance=2, ), "EXTRFSTR3": ElementMapping( - element=Element.ROOF, + element=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, element_instance=3, ), "EXTROOF1": ElementMapping( - element=Element.ROOF, + element=ElementType.ROOF, aspect_type=AspectType.COVERING, element_instance=1, ), "EXTROOF2": ElementMapping( - element=Element.ROOF, + element=ElementType.ROOF, aspect_type=AspectType.COVERING, element_instance=2, ), "EXTROOF3": ElementMapping( - element=Element.ROOF, + element=ElementType.ROOF, aspect_type=AspectType.COVERING, element_instance=3, ), "EXTCHIMNEY": ElementMapping( - element=Element.CHIMNEY, + element=ElementType.CHIMNEY, aspect_type=AspectType.WORK_REQUIRED, ), "EXTFASOFBR": ElementMapping( - element=Element.FASCIA_SOFFIT_BARGEBOARDS, + element=ElementType.FASCIA_SOFFIT_BARGEBOARDS, aspect_type=AspectType.MATERIAL, ), "EXTGARROOF": ElementMapping( - element=Element.GARAGE_ROOF, + element=ElementType.GARAGE_ROOF, aspect_type=AspectType.MATERIAL, ), "EXTGARSTRF": ElementMapping( - element=Element.GARAGE_AND_STORE_ROOF, + element=ElementType.GARAGE_AND_STORE_ROOF, aspect_type=AspectType.MATERIAL, ), "EXTSTRROOF": ElementMapping( - element=Element.STORE_ROOF, + element=ElementType.STORE_ROOF, aspect_type=AspectType.MATERIAL, ), "INTLOFTINS": ElementMapping( - element=Element.LOFT_INSULATION, + element=ElementType.LOFT_INSULATION, aspect_type=AspectType.TYPE, ), # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== "INTFRDOOR": ElementMapping( - element=Element.EXTERNAL_DOOR, + element=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, ), "INTFRDRFRR": ElementMapping( - element=Element.EXTERNAL_DOOR, + element=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.FIRE_RATING, ), "EXTBKSDDR1": ElementMapping( - element=Element.EXTERNAL_DOOR, + element=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, element_instance=1, ), "EXTBKSDDR2": ElementMapping( - element=Element.EXTERNAL_DOOR, + element=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, element_instance=2, ), "INTWDWTYPE": ElementMapping( - element=Element.EXTERNAL_WINDOWS, + element=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, ), "EXTWNDWS1": ElementMapping( - element=Element.EXTERNAL_WINDOWS, + element=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=1, ), "EXTWNDWS2": ElementMapping( - element=Element.EXTERNAL_WINDOWS, + element=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=2, ), "EXTGARDOOR": ElementMapping( - element=Element.GARAGE_DOOR, + element=ElementType.GARAGE_DOOR, aspect_type=AspectType.MATERIAL, ), "EXTGARSTDR": ElementMapping( - element=Element.GARAGE_AND_STORE_DOOR, + element=ElementType.GARAGE_AND_STORE_DOOR, aspect_type=AspectType.MATERIAL, ), "EXTSTRDOOR": ElementMapping( - element=Element.STORE_DOOR, + element=ElementType.STORE_DOOR, aspect_type=AspectType.MATERIAL, ), "EXTGARWDWS": ElementMapping( - element=Element.GARAGE_WINDOWS, + element=ElementType.GARAGE_WINDOWS, aspect_type=AspectType.MATERIAL, ), "EXTSTRWDWS": ElementMapping( - element=Element.STORE_WINDOWS, + element=ElementType.STORE_WINDOWS, aspect_type=AspectType.MATERIAL, ), "EXTGARSTWD": ElementMapping( - element=Element.GARAGE_AND_STORE_WINDOWS, + element=ElementType.GARAGE_AND_STORE_WINDOWS, aspect_type=AspectType.MATERIAL, ), "EXTLINTELS": ElementMapping( - element=Element.LINTEL, + element=ElementType.LINTEL, aspect_type=AspectType.PRESENCE, ), "EXTPTFRDR1": ElementMapping( - element=Element.PATIO_FRENCH_DOOR, + element=ElementType.PATIO_FRENCH_DOOR, aspect_type=AspectType.MATERIAL, element_instance=1, ), @@ -322,217 +322,217 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # EXTERNAL AREAS # ========================================================== "EXTBALCONY": ElementMapping( - element=Element.PRIVATE_BALCONY, + element=ElementType.PRIVATE_BALCONY, aspect_type=AspectType.PRESENCE, ), "EXTBPOINTG": ElementMapping( - element=Element.EXTERNAL_BRICKWORK_POINTING, + element=ElementType.EXTERNAL_BRICKWORK_POINTING, aspect_type=AspectType.PRESENCE, ), "EXTDRPKERB": ElementMapping( - element=Element.DROP_KERB, + element=ElementType.DROP_KERB, aspect_type=AspectType.PRESENCE, ), "EXTEXTDECS": ElementMapping( - element=Element.EXTERNAL_DECORATION, + element=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.PRESENCE, ), "EXTHARDSTD": ElementMapping( - element=Element.PATHS_AND_HARDSTANDINGS, + element=ElementType.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL, ), "EXTINTDWNP": ElementMapping( - element=Element.INTERNAL_DOWNPIPES_EXTERNAL_AREA, + element=ElementType.INTERNAL_DOWNPIPES_EXTERNAL_AREA, aspect_type=AspectType.MATERIAL, ), "EXTOUTBOH": ElementMapping( - element=Element.OUTBUILDING_OVERHAUL, + element=ElementType.OUTBUILDING_OVERHAUL, aspect_type=AspectType.TYPE, ), "EXTPARKING": ElementMapping( - element=Element.PARKING_AREAS, + element=ElementType.PARKING_AREAS, aspect_type=AspectType.PRESENCE, ), "EXTPCHCNPY": ElementMapping( - element=Element.PORCH_CANOPY, + element=ElementType.PORCH_CANOPY, aspect_type=AspectType.TYPE, ), "EXTSTRINSP": ElementMapping( - element=Element.EXTERNAL_STRUCTURAL_DEFECTS, + element=ElementType.EXTERNAL_STRUCTURAL_DEFECTS, aspect_type=AspectType.TYPE, # Need more sample data to know whether this is the correct aspect type ), "INTACCRAMP": ElementMapping( - element=Element.ACCESS_RAMP, + element=ElementType.ACCESS_RAMP, aspect_type=AspectType.TYPE, # # Need more sample data to know whether this is the correct aspect type ), # ====================== # FITNESS FOR HUMAN HABITATION # ====================== "FFHHDAMP": ElementMapping( - element=Element.FFHH_DAMP, + element=ElementType.FFHH_DAMP, aspect_type=AspectType.RISK, ), "FFHHHCWAT": ElementMapping( - element=Element.FFHH_HOT_AND_COLD_WATER, + element=ElementType.FFHH_HOT_AND_COLD_WATER, aspect_type=AspectType.RISK, ), "FFHHDRNWC": ElementMapping( - element=Element.FFHH_DRAINAGE_LAVATORIES, + element=ElementType.FFHH_DRAINAGE_LAVATORIES, aspect_type=AspectType.RISK, ), "FFHHNEGLC": ElementMapping( - element=Element.FFHH_NEGLECTED, + element=ElementType.FFHH_NEGLECTED, aspect_type=AspectType.RISK, ), "FFHHNONAT": ElementMapping( - element=Element.FFHH_NATURAL_LIGHT, + element=ElementType.FFHH_NATURAL_LIGHT, aspect_type=AspectType.RISK, ), "FFHHNOVEN": ElementMapping( - element=Element.FFHH_VENTILATION, + element=ElementType.FFHH_VENTILATION, aspect_type=AspectType.RISK, ), "FFHHPRPCK": ElementMapping( - element=Element.FFHH_FOOD_PREP_AND_WASHUP, + element=ElementType.FFHH_FOOD_PREP_AND_WASHUP, aspect_type=AspectType.RISK, ), "FFHHUNLAY": ElementMapping( - element=Element.FFHH_UNSAFE_LAYOUT, + element=ElementType.FFHH_UNSAFE_LAYOUT, aspect_type=AspectType.RISK, ), "FFHHUNSTA": ElementMapping( - element=Element.FFHH_UNSTABLE_BUILDING, + element=ElementType.FFHH_UNSTABLE_BUILDING, aspect_type=AspectType.RISK, ), # ========================================================== # HHSRS # ========================================================== "HHSRSDAMP": ElementMapping( - element=Element.HHSRS_DAMP_AND_MOULD, + element=ElementType.HHSRS_DAMP_AND_MOULD, aspect_type=AspectType.RISK, ), "HHSRSCOLD": ElementMapping( - element=Element.HHSRS_EXCESS_COLD, + element=ElementType.HHSRS_EXCESS_COLD, aspect_type=AspectType.RISK, ), "HHSRSHEAT": ElementMapping( - element=Element.HHSRS_EXCESS_HEAT, + element=ElementType.HHSRS_EXCESS_HEAT, aspect_type=AspectType.RISK, ), "HHSRSASB": ElementMapping( - element=Element.HHSRS_ASBESTOS_AND_MMF, + element=ElementType.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK, ), "HHSRSBIOC": ElementMapping( - element=Element.HHSRS_BIOCIDES, + element=ElementType.HHSRS_BIOCIDES, aspect_type=AspectType.RISK, ), "HHSRSCO": ElementMapping( - element=Element.HHSRS_CARBON_MONOXIDE, + element=ElementType.HHSRS_CARBON_MONOXIDE, aspect_type=AspectType.RISK, ), "HHSRSNO2": ElementMapping( - element=Element.HHSRS_CARBON_MONOXIDE, + element=ElementType.HHSRS_CARBON_MONOXIDE, aspect_type=AspectType.RISK, ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard "HHSRSSO2": ElementMapping( - element=Element.HHSRS_CARBON_MONOXIDE, + element=ElementType.HHSRS_CARBON_MONOXIDE, aspect_type=AspectType.RISK, ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard "HHSRSLEAD": ElementMapping( - element=Element.HHSRS_LEAD, + element=ElementType.HHSRS_LEAD, aspect_type=AspectType.RISK, ), "HHSRSRADIA": ElementMapping( - element=Element.HHSRS_RADIATION, + element=ElementType.HHSRS_RADIATION, aspect_type=AspectType.RISK, ), "HHSRSFUEL": ElementMapping( - element=Element.HHSRS_UNCOMBUSTED_FUEL_GAS, + element=ElementType.HHSRS_UNCOMBUSTED_FUEL_GAS, aspect_type=AspectType.RISK, ), "HHSRSORGAN": ElementMapping( - element=Element.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, + element=ElementType.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, aspect_type=AspectType.RISK, ), "HHSRSCROWD": ElementMapping( - element=Element.HHSRS_CROWDING_AND_SPACE, + element=ElementType.HHSRS_CROWDING_AND_SPACE, aspect_type=AspectType.RISK, ), "HHSRSENTRY": ElementMapping( - element=Element.HHSRS_ENTRY_BY_INTRUDERS, + element=ElementType.HHSRS_ENTRY_BY_INTRUDERS, aspect_type=AspectType.RISK, ), "HHSRSLIGHT": ElementMapping( - element=Element.HHSRS_LIGHTING, + element=ElementType.HHSRS_LIGHTING, aspect_type=AspectType.RISK, ), "HHSRSNOISE": ElementMapping( - element=Element.HHSRS_NOISE, + element=ElementType.HHSRS_NOISE, aspect_type=AspectType.RISK, ), "HHSRSDOMES": ElementMapping( - element=Element.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, + element=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, aspect_type=AspectType.RISK, ), "HHSRSFOOD": ElementMapping( - element=Element.HHSRS_FOOD_SAFETY, + element=ElementType.HHSRS_FOOD_SAFETY, aspect_type=AspectType.RISK, ), "HHSRSPERS": ElementMapping( - element=Element.HHSRS_PERSONAL_HYGIENE_SANITATION, + element=ElementType.HHSRS_PERSONAL_HYGIENE_SANITATION, aspect_type=AspectType.RISK, ), "HHSRSWATER": ElementMapping( - element=Element.HHSRS_WATER_SUPPLY, + element=ElementType.HHSRS_WATER_SUPPLY, aspect_type=AspectType.RISK, ), "HHSRSFBATH": ElementMapping( - element=Element.HHSRS_FALLS_ASSOCIATED_WITH_BATHS, + element=ElementType.HHSRS_FALLS_ASSOCIATED_WITH_BATHS, aspect_type=AspectType.RISK, ), "HHSRSFLEVE": ElementMapping( - element=Element.HHSRS_FALLS_ON_LEVEL_SURFACES, + element=ElementType.HHSRS_FALLS_ON_LEVEL_SURFACES, aspect_type=AspectType.RISK, ), "HHSRSFSTAI": ElementMapping( - element=Element.HHSRS_FALLS_ON_STAIRS, + element=ElementType.HHSRS_FALLS_ON_STAIRS, aspect_type=AspectType.RISK, ), "HHSRSFBETW": ElementMapping( - element=Element.HHSRS_FALLS_BETWEEN_LEVELS, + element=ElementType.HHSRS_FALLS_BETWEEN_LEVELS, aspect_type=AspectType.RISK, ), "HHSRSELEC": ElementMapping( - element=Element.HHSRS_ELECTRICAL_HAZARDS, + element=ElementType.HHSRS_ELECTRICAL_HAZARDS, aspect_type=AspectType.RISK, ), "HHSRSFIRE": ElementMapping( - element=Element.HHSRS_FIRE, + element=ElementType.HHSRS_FIRE, aspect_type=AspectType.RISK, ), "HHSRSFLAME": ElementMapping( - element=Element.HHSRS_FLAMES_HOT_SURFACES, + element=ElementType.HHSRS_FLAMES_HOT_SURFACES, aspect_type=AspectType.RISK, ), "HHSRSENTRP": ElementMapping( - element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, + element=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.RISK, ), "HHSRSEXPLO": ElementMapping( - element=Element.HHSRS_EXPLOSIONS, + element=ElementType.HHSRS_EXPLOSIONS, aspect_type=AspectType.RISK, ), "HHSRSSTRUC": ElementMapping( - element=Element.HHSRS_STRUCTURAL_COLLAPSE, + element=ElementType.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK, ), "HHSRSCLOW": ElementMapping( - element=Element.HHSRS_COLLISION_AND_ENTRAPMENT, + element=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.RISK, ), "HHSRSPOSI": ElementMapping( - element=Element.HHSRS_AMENITIES, + element=ElementType.HHSRS_AMENITIES, aspect_type=AspectType.RISK, ), } diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py index 3d7b7349..bb5f777d 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py @@ -1,6 +1,5 @@ from typing import Any, List, Optional -from backend.condition.domain.asset_condition import AssetCondition from backend.condition.domain.element import Element from backend.condition.domain.mapping.element_mapping import ElementMapping from backend.condition.domain.mapping.lbwf.lbwf_element_map import LBWF_ELEMENT_MAP @@ -18,12 +17,12 @@ class LbwfMapper(Mapper): def map_asset_conditions_for_property( self, client_data: Any, survey_year: Optional[int] = None - ) -> List[AssetCondition]: + ) -> List[Element]: assert isinstance( client_data, LbwfHouse ) # TODO: think of a better way to do this - mapped_assets: List[AssetCondition] = [] + mapped_assets: List[Element] = [] uprn: int = client_data.uprn for raw_asset in client_data.assets: @@ -40,7 +39,7 @@ class LbwfMapper(Mapper): continue mapped_assets.append( - AssetCondition( + Element( uprn=uprn, element=element_mapping.element, aspect_type=element_mapping.aspect_type, diff --git a/backend/condition/domain/mapping/mapper.py b/backend/condition/domain/mapping/mapper.py index c0b07184..ace6ad73 100644 --- a/backend/condition/domain/mapping/mapper.py +++ b/backend/condition/domain/mapping/mapper.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import Any, List, Optional -from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.element import Element class Mapper(ABC): @@ -9,6 +9,6 @@ class Mapper(ABC): @abstractmethod def map_asset_conditions_for_property( self, client_data: Any, survey_year: Optional[int] = None - ) -> List[AssetCondition]: + ) -> List[Element]: # TODO: client_data should be properly typed pass diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 1f9cceee..508b8968 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -1,5 +1,5 @@ from backend.condition.domain.aspect_type import AspectType -from backend.condition.domain.element import Element +from backend.condition.domain.element_type import ElementType from backend.condition.domain.mapping.element_mapping import ElementMapping @@ -7,654 +7,658 @@ PEABODY_ELEMENT_MAP = { # ========================================================== # PROPERTY / GENERAL # ========================================================== - (100, 1): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.TYPE), + (100, 1): ElementMapping(element=ElementType.PROPERTY, aspect_type=AspectType.TYPE), # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE), # (100, 14): ElementMapping(element="property", aspect_type="construction_type"), (50, 2): ElementMapping( - element=Element.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE + element=ElementType.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE ), - (50, 3): ElementMapping(element=Element.CCU, aspect_type=AspectType.TYPE), + (50, 3): ElementMapping(element=ElementType.CCU, aspect_type=AspectType.TYPE), (50, 7): ElementMapping( - element=Element.DISABLED_HOIST_TRACKING, aspect_type=AspectType.PRESENCE + element=ElementType.DISABLED_HOIST_TRACKING, aspect_type=AspectType.PRESENCE ), (50, 11): ElementMapping( - element=Element.HEAT_DETECTION, aspect_type=AspectType.TYPE + element=ElementType.HEAT_DETECTION, aspect_type=AspectType.TYPE ), (50, 21): ElementMapping( - element=Element.SMOKE_DETECTION, aspect_type=AspectType.TYPE + element=ElementType.SMOKE_DETECTION, aspect_type=AspectType.TYPE ), (50, 22): ElementMapping( - element=Element.STAIRLIFT, aspect_type=AspectType.PRESENCE + element=ElementType.STAIRLIFT, aspect_type=AspectType.PRESENCE ), (50, 26): ElementMapping( - element=Element.DISABLED_FACILITIES, aspect_type=AspectType.TYPE + element=ElementType.DISABLED_FACILITIES, aspect_type=AspectType.TYPE + ), + (100, 3): ElementMapping( + element=ElementType.PROPERTY, aspect_type=AspectType.AGE_BAND ), - (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE_BAND), (100, 14): ElementMapping( - element=Element.PROPERTY, aspect_type=AspectType.CONSTRUCTION_TYPE + element=ElementType.PROPERTY, aspect_type=AspectType.CONSTRUCTION_TYPE ), (100, 16): ElementMapping( - element=Element.PROPERTY, aspect_type=AspectType.CLASSIFICATION + element=ElementType.PROPERTY, aspect_type=AspectType.CLASSIFICATION ), (210, 2): ElementMapping( - element=Element.PASSENGER_LIFT, aspect_type=AspectType.TYPE + element=ElementType.PASSENGER_LIFT, aspect_type=AspectType.TYPE ), # ========================================================== # EXTERNAL – WALLS # ========================================================== (50, 16): ElementMapping( - element=Element.PARTY_WALL_FIRE_BREAK, aspect_type=AspectType.PRESENCE + element=ElementType.PARTY_WALL_FIRE_BREAK, aspect_type=AspectType.PRESENCE ), (53, 1): ElementMapping( - element=Element.BOUNDARY_WALLS, aspect_type=AspectType.PRESENCE + element=ElementType.BOUNDARY_WALLS, aspect_type=AspectType.PRESENCE ), (53, 4): ElementMapping( - element=Element.EXTERNAL_DECORATION, aspect_type=AspectType.PRESENCE + element=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.PRESENCE ), (53, 5): ElementMapping( - element=Element.EXTERNAL_NOISE_INSULATION, aspect_type=AspectType.ADEQUACY + element=ElementType.EXTERNAL_NOISE_INSULATION, aspect_type=AspectType.ADEQUACY ), (53, 14): ElementMapping( - element=Element.GARAGE_WALLS, aspect_type=AspectType.MATERIAL + element=ElementType.GARAGE_WALLS, aspect_type=AspectType.MATERIAL ), (53, 23): ElementMapping( - element=Element.EXTERNAL_WALL, aspect_type=AspectType.FINISH + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH ), (53, 30): ElementMapping( - element=Element.SECONDARY_WALL, aspect_type=AspectType.FINISH + element=ElementType.SECONDARY_WALL, aspect_type=AspectType.FINISH ), # Should this be combined with primary wall, with different instance value? (53, 36): ElementMapping( - element=Element.EXTERNAL_WALL, aspect_type=AspectType.INSULATION + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION ), (53, 40): ElementMapping( - element=Element.SPANDREL_PANELS, aspect_type=AspectType.MATERIAL + element=ElementType.SPANDREL_PANELS, aspect_type=AspectType.MATERIAL + ), + (53, 41): ElementMapping( + element=ElementType.CLADDING, aspect_type=AspectType.MATERIAL ), - (53, 41): ElementMapping(element=Element.CLADDING, aspect_type=AspectType.MATERIAL), (100, 15): ElementMapping( - element=Element.EXTERNAL_DECORATION, aspect_type=AspectType.CONDITION + element=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.CONDITION ), (120, 1): ElementMapping( - element=Element.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE ), (120, 2): ElementMapping( - element=Element.EXTERNAL_WALL, aspect_type=AspectType.FINISH + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH ), (120, 3): ElementMapping( - element=Element.PRIMARY_WALL, aspect_type=AspectType.INSULATION + element=ElementType.PRIMARY_WALL, aspect_type=AspectType.INSULATION ), # This code element code is actually "WALL" not "external wall" - correct? # ========================================================== # EXTERNAL – ROOFS # ========================================================== (50, 15): ElementMapping( - element=Element.LOFT_INSULATION, + element=ElementType.LOFT_INSULATION, aspect_type=AspectType.TYPE, ), (53, 2): ElementMapping( - element=Element.CHIMNEY, + element=ElementType.CHIMNEY, aspect_type=AspectType.PRESENCE, ), (53, 6): ElementMapping( - element=Element.FASCIA_SOFFIT_BARGEBOARDS, + element=ElementType.FASCIA_SOFFIT_BARGEBOARDS, aspect_type=AspectType.MATERIAL, ), (53, 7): ElementMapping( - element=Element.FLAT_ROOF_COVERING, + element=ElementType.FLAT_ROOF_COVERING, aspect_type=AspectType.MATERIAL, ), (53, 13): ElementMapping( - element=Element.GARAGE_ROOF, + element=ElementType.GARAGE_ROOF, aspect_type=AspectType.MATERIAL, ), (53, 15): ElementMapping( - element=Element.GUTTERS, + element=ElementType.GUTTERS, aspect_type=AspectType.MATERIAL, ), (53, 21): ElementMapping( - element=Element.PITCHED_ROOF_COVERING, + element=ElementType.PITCHED_ROOF_COVERING, aspect_type=AspectType.MATERIAL, ), (53, 22): ElementMapping( - element=Element.PORCH_CANOPY, + element=ElementType.PORCH_CANOPY, aspect_type=AspectType.TYPE, ), (53, 47): ElementMapping( - element=Element.ROOF, + element=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, ), (110, 1): ElementMapping( - element=Element.ROOF, + element=ElementType.ROOF, aspect_type=AspectType.MATERIAL, element_instance=1, ), (110, 2): ElementMapping( - element=Element.ROOF, + element=ElementType.ROOF, aspect_type=AspectType.MATERIAL, element_instance=1, ), (110, 3): ElementMapping( - element=Element.CHIMNEY, + element=ElementType.CHIMNEY, aspect_type=AspectType.WORK_REQUIRED, ), (110, 4): ElementMapping( - element=Element.FASCIA, + element=ElementType.FASCIA, aspect_type=AspectType.MATERIAL, ), (110, 5): ElementMapping( - element=Element.SOFFIT, + element=ElementType.SOFFIT, aspect_type=AspectType.MATERIAL, ), (110, 6): ElementMapping( - element=Element.RAINWATER_GOODS, + element=ElementType.RAINWATER_GOODS, aspect_type=AspectType.MATERIAL, ), (110, 7): ElementMapping( - element=Element.LOFT_INSULATION, + element=ElementType.LOFT_INSULATION, aspect_type=AspectType.WORK_REQUIRED, # possibly not the right aspect type ), (110, 8): ElementMapping( - element=Element.PORCH_CANOPY, + element=ElementType.PORCH_CANOPY, aspect_type=AspectType.MATERIAL, ), # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== (50, 8): ElementMapping( - element=Element.DOOR_ENTRY_HANDSET, + element=ElementType.DOOR_ENTRY_HANDSET, aspect_type=AspectType.PRESENCE, ), (53, 8): ElementMapping( - element=Element.FRONT_DOOR, + element=ElementType.FRONT_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 12): ElementMapping( - element=Element.GARAGE_DOOR, + element=ElementType.GARAGE_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 16): ElementMapping( - element=Element.LINTEL, + element=ElementType.LINTEL, aspect_type=AspectType.PRESENCE, ), (53, 19): ElementMapping( - element=Element.PATIO_FRENCH_DOOR, + element=ElementType.PATIO_FRENCH_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 25): ElementMapping( - element=Element.REAR_DOOR, + element=ElementType.REAR_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 29): ElementMapping( - element=Element.SECONDARY_GLAZING, + element=ElementType.SECONDARY_GLAZING, aspect_type=AspectType.PRESENCE, ), (53, 35): ElementMapping( - element=Element.STORE_DOOR, + element=ElementType.STORE_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 38): ElementMapping( - element=Element.EXTERNAL_WINDOWS, + element=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=1, ), (53, 39): ElementMapping( - element=Element.EXTERNAL_WINDOWS, + element=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=2, ), (53, 43): ElementMapping( - element=Element.FRONT_DOOR, + element=ElementType.FRONT_DOOR, aspect_type=AspectType.TYPE, ), (130, 1): ElementMapping( - element=Element.EXTERNAL_WINDOWS, + element=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.MATERIAL, ), (130, 2): ElementMapping( - element=Element.COMMUNAL_WINDOWS, + element=ElementType.COMMUNAL_WINDOWS, aspect_type=AspectType.MATERIAL, ), (140, 1): ElementMapping( - element=Element.MAIN_DOOR, + element=ElementType.MAIN_DOOR, aspect_type=AspectType.MATERIAL, ), (140, 2): ElementMapping( - element=Element.STORE_DOOR, + element=ElementType.STORE_DOOR, aspect_type=AspectType.MATERIAL, ), # Duplicate of (53, 35) (140, 3): ElementMapping( - element=Element.GARAGE_DOOR, + element=ElementType.GARAGE_DOOR, aspect_type=AspectType.MATERIAL, ), # Duplicate of (53, 12) (140, 4): ElementMapping( - element=Element.BLOCK_ENTRANCE_DOOR, + element=ElementType.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL, ), # ========================================================== # EXTERNAL AREAS # ========================================================== (53, 3): ElementMapping( - element=Element.DOWNPIPES, + element=ElementType.DOWNPIPES, aspect_type=AspectType.MATERIAL, ), (53, 9): ElementMapping( - element=Element.FRONT_FENCING, + element=ElementType.FRONT_FENCING, aspect_type=AspectType.MATERIAL, ), (53, 10): ElementMapping( - element=Element.FRONT_GATE, + element=ElementType.FRONT_GATE, aspect_type=AspectType.TYPE, ), (53, 17): ElementMapping( - element=Element.PARKING_AREAS, + element=ElementType.PARKING_AREAS, aspect_type=AspectType.MATERIAL, ), (53, 18): ElementMapping( - element=Element.PATHS_AND_HARDSTANDINGS, + element=ElementType.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL, ), (53, 24): ElementMapping( - element=Element.PRIVATE_BALCONY, + element=ElementType.PRIVATE_BALCONY, aspect_type=AspectType.PRESENCE, ), (53, 26): ElementMapping( - element=Element.REAR_FENCING, + element=ElementType.REAR_FENCING, aspect_type=AspectType.MATERIAL, ), (53, 27): ElementMapping( - element=Element.REAR_GATE, + element=ElementType.REAR_GATE, aspect_type=AspectType.TYPE, ), (53, 28): ElementMapping( - element=Element.RETAINING_WALLS, + element=ElementType.RETAINING_WALLS, aspect_type=AspectType.PRESENCE, ), (53, 31): ElementMapping( - element=Element.SIDE_FENCING, + element=ElementType.SIDE_FENCING, aspect_type=AspectType.MATERIAL, ), (53, 32): ElementMapping( - element=Element.SOIL_AND_VENT, + element=ElementType.SOIL_AND_VENT, aspect_type=AspectType.MATERIAL, ), (53, 34): ElementMapping( - element=Element.SOLAR_THERMALS, + element=ElementType.SOLAR_THERMALS, aspect_type=AspectType.PRESENCE, ), (53, 44): ElementMapping( - element=Element.GARAGE_STRUCTURE, + element=ElementType.GARAGE_STRUCTURE, aspect_type=AspectType.TYPE, ), (53, 45): ElementMapping( - element=Element.BALCONY_BALUSTRADE, + element=ElementType.BALCONY_BALUSTRADE, aspect_type=AspectType.MATERIAL, ), (150, 1): ElementMapping( - element=Element.BLOCK_ENTRANCE_DOOR, + element=ElementType.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL, ), (150, 2): ElementMapping( - element=Element.PATHS_AND_HARDSTANDINGS, + element=ElementType.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL, ), # Duplicate of (53, 18) - correct? (150, 3): ElementMapping( - element=Element.ROADS, + element=ElementType.ROADS, aspect_type=AspectType.MATERIAL, ), (150, 4): ElementMapping( - element=Element.BOUNDARY_WALLS, + element=ElementType.BOUNDARY_WALLS, aspect_type=AspectType.MATERIAL, ), (150, 5): ElementMapping( - element=Element.OUTBUILDINGS, + element=ElementType.OUTBUILDINGS, aspect_type=AspectType.TYPE, ), (150, 6): ElementMapping( - element=Element.GARAGE_STRUCTURE, + element=ElementType.GARAGE_STRUCTURE, aspect_type=AspectType.TYPE, ), # ========================================================== # INTERNAL – BATHROOMS & KITCHENS # ========================================================== (50, 1): ElementMapping( - element=Element.SECONDARY_TOILET, + element=ElementType.SECONDARY_TOILET, aspect_type=AspectType.PRESENCE, ), (50, 9): ElementMapping( - element=Element.BATHROOM_EXTRACTOR_FAN, + element=ElementType.BATHROOM_EXTRACTOR_FAN, aspect_type=AspectType.PRESENCE, ), (50, 9): ElementMapping( - element=Element.KITCHEN, + element=ElementType.KITCHEN, aspect_type=AspectType.TYPE, ), (50, 10): ElementMapping( - element=Element.KITCHEN_EXTRACTOR_FAN, + element=ElementType.KITCHEN_EXTRACTOR_FAN, aspect_type=AspectType.PRESENCE, ), (50, 13): ElementMapping( - element=Element.KITCHEN_SPACE_LAYOUT, + element=ElementType.KITCHEN_SPACE_LAYOUT, aspect_type=AspectType.ADEQUACY, ), (50, 14): ElementMapping( - element=Element.KITCHEN, + element=ElementType.KITCHEN, aspect_type=AspectType.TYPE, ), (50, 17): ElementMapping( - element=Element.BATHROOM, + element=ElementType.BATHROOM, aspect_type=AspectType.LOCATION, ), (50, 18): ElementMapping( - element=Element.BATHROOM, + element=ElementType.BATHROOM, aspect_type=AspectType.TYPE, ), # Actually "Primary bathroom type" - ok like this? (50, 20): ElementMapping( - element=Element.BATHROOM, + element=ElementType.BATHROOM, aspect_type=AspectType.TYPE, element_instance=2, ), # Actually "Secondary bathroom type" - ok like this? (160, 1): ElementMapping( - element=Element.KITCHEN, + element=ElementType.KITCHEN, aspect_type=AspectType.CONDITION, ), (160, 2): ElementMapping( - element=Element.KITCHEN_SPACE_LAYOUT, + element=ElementType.KITCHEN_SPACE_LAYOUT, aspect_type=AspectType.ADEQUACY, ), (190, 1): ElementMapping( - element=Element.BATHROOM, + element=ElementType.BATHROOM, aspect_type=AspectType.CONDITION, ), (190, 2): ElementMapping( - element=Element.SECONDARY_TOILET, + element=ElementType.SECONDARY_TOILET, aspect_type=AspectType.TYPE, ), # ========================================================== # COMMUNAL # ========================================================== (51, 1): ElementMapping( - element=Element.COMMUNAL_AERIAL, + element=ElementType.COMMUNAL_AERIAL, aspect_type=AspectType.PRESENCE, ), (51, 2): ElementMapping( - element=Element.COMMUNAL_AOV, + element=ElementType.COMMUNAL_AOV, aspect_type=AspectType.PRESENCE, ), (51, 3): ElementMapping( - element=Element.COMMUNAL_BALCONY_WALKWAY, + element=ElementType.COMMUNAL_BALCONY_WALKWAY, aspect_type=AspectType.PRESENCE, ), (51, 4): ElementMapping( - element=Element.COMMUNAL_BATHROOM, + element=ElementType.COMMUNAL_BATHROOM, aspect_type=AspectType.TYPE, ), (51, 5): ElementMapping( - element=Element.COMMUNAL_BIN_STORE_DOORS, + element=ElementType.COMMUNAL_BIN_STORE_DOORS, aspect_type=AspectType.PRESENCE, ), (51, 6): ElementMapping( - element=Element.COMMUNAL_BIN_STORE_ROOF, + element=ElementType.COMMUNAL_BIN_STORE_ROOF, aspect_type=AspectType.PRESENCE, ), (51, 7): ElementMapping( - element=Element.COMMUNAL_BIN_STORE_WALLS, + element=ElementType.COMMUNAL_BIN_STORE_WALLS, aspect_type=AspectType.MATERIAL, ), (51, 8): ElementMapping( - element=Element.COMMUNAL_BMS, + element=ElementType.COMMUNAL_BMS, aspect_type=AspectType.PRESENCE, ), (51, 9): ElementMapping( - element=Element.COMMUNAL_BOILER, + element=ElementType.COMMUNAL_BOILER, aspect_type=AspectType.TYPE, ), (51, 10): ElementMapping( - element=Element.COMMUNAL_BOOSTER_PUMP, + element=ElementType.COMMUNAL_BOOSTER_PUMP, aspect_type=AspectType.PRESENCE, ), (51, 11): ElementMapping( - element=Element.COMMUNAL_CCTV, + element=ElementType.COMMUNAL_CCTV, aspect_type=AspectType.PRESENCE, ), (51, 12): ElementMapping( - element=Element.COMMUNAL_CIRCULATION_SPACE, + element=ElementType.COMMUNAL_CIRCULATION_SPACE, aspect_type=AspectType.ADEQUACY, ), (51, 13): ElementMapping( - element=Element.COMMUNAL_COLD_WATER_STORAGE, + element=ElementType.COMMUNAL_COLD_WATER_STORAGE, aspect_type=AspectType.PRESENCE, ), (51, 14): ElementMapping( - element=Element.COMMUNAL_DOOR_ENTRY, + element=ElementType.COMMUNAL_DOOR_ENTRY, aspect_type=AspectType.SYSTEM, ), (51, 15): ElementMapping( - element=Element.COMMUNAL_DRY_RISER, + element=ElementType.COMMUNAL_DRY_RISER, aspect_type=AspectType.PRESENCE, ), (51, 16): ElementMapping( - element=Element.COMMUNAL_EMERGENCY_LIGHTING, + element=ElementType.COMMUNAL_EMERGENCY_LIGHTING, aspect_type=AspectType.PRESENCE, ), (51, 17): ElementMapping( - element=Element.COMMUNAL_EXTERNAL_DOORS, + element=ElementType.COMMUNAL_EXTERNAL_DOORS, aspect_type=AspectType.MATERIAL, ), (51, 19): ElementMapping( - element=Element.COMMUNAL_FIRE_ALARM, + element=ElementType.COMMUNAL_FIRE_ALARM, aspect_type=AspectType.TYPE, ), (51, 20): ElementMapping( - element=Element.COMMUNAL_INTERNAL_DECORATIONS, + element=ElementType.COMMUNAL_INTERNAL_DECORATIONS, aspect_type=AspectType.PRESENCE, ), (51, 21): ElementMapping( - element=Element.COMMUNAL_INTERNAL_DOORS, + element=ElementType.COMMUNAL_INTERNAL_DOORS, aspect_type=AspectType.MATERIAL, ), (51, 22): ElementMapping( - element=Element.COMMUNAL_INTERNAL_FLOOR, + element=ElementType.COMMUNAL_INTERNAL_FLOOR, aspect_type=AspectType.FINISH, ), (51, 23): ElementMapping( - element=Element.COMMUNAL_KITCHEN, + element=ElementType.COMMUNAL_KITCHEN, aspect_type=AspectType.TYPE, ), (51, 24): ElementMapping( - element=Element.COMMUNAL_LATERAL_MAINS, + element=ElementType.COMMUNAL_LATERAL_MAINS, aspect_type=AspectType.PRESENCE, ), (51, 25): ElementMapping( - element=Element.COMMUNAL_LIGHTING, + element=ElementType.COMMUNAL_LIGHTING, aspect_type=AspectType.PRESENCE, ), (51, 26): ElementMapping( - element=Element.COMMUNAL_LIGHTING_CONDUCTOR, + element=ElementType.COMMUNAL_LIGHTING_CONDUCTOR, aspect_type=AspectType.PRESENCE, ), (51, 27): ElementMapping( - element=Element.COMMUNAL_PASSENGER_LIFT, + element=ElementType.COMMUNAL_PASSENGER_LIFT, aspect_type=AspectType.TYPE, ), (51, 28): ElementMapping( - element=Element.COMMUNAL_ENTRANCE, + element=ElementType.COMMUNAL_ENTRANCE, aspect_type=AspectType.MATERIAL, element_instance=1, ), (51, 30): ElementMapping( - element=Element.COMMUNAL_ENTRANCE, + element=ElementType.COMMUNAL_ENTRANCE, aspect_type=AspectType.FINISH, element_instance=2, ), (51, 31): ElementMapping( - element=Element.COMMUNAL_SPRINKLER, + element=ElementType.COMMUNAL_SPRINKLER, aspect_type=AspectType.PRESENCE, ), (51, 29): ElementMapping( - element=Element.COMMUNAL_REFUSE_CHUTE, + element=ElementType.COMMUNAL_REFUSE_CHUTE, aspect_type=AspectType.PRESENCE, ), (51, 32): ElementMapping( - element=Element.COMMUNAL_STAIRS, + element=ElementType.COMMUNAL_STAIRS, aspect_type=AspectType.FINISH, ), (51, 33): ElementMapping( - element=Element.COMMUNAL_STORE_DOORS, + element=ElementType.COMMUNAL_STORE_DOORS, aspect_type=AspectType.MATERIAL, ), (51, 34): ElementMapping( - element=Element.COMMUNAL_STORE_ROOF, + element=ElementType.COMMUNAL_STORE_ROOF, aspect_type=AspectType.MATERIAL, ), (51, 35): ElementMapping( - element=Element.COMMUNAL_STORE_WALLS, + element=ElementType.COMMUNAL_STORE_WALLS, aspect_type=AspectType.MATERIAL, ), (51, 36): ElementMapping( - element=Element.COMMUNAL_WALKWAYS, + element=ElementType.COMMUNAL_WALKWAYS, aspect_type=AspectType.FINISH, ), (51, 37): ElementMapping( - element=Element.COMMUNAL_WARDEN_CALL_SYSTEM, + element=ElementType.COMMUNAL_WARDEN_CALL_SYSTEM, aspect_type=AspectType.PRESENCE, ), (51, 38): ElementMapping( - element=Element.COMMUNAL_TOILETS, + element=ElementType.COMMUNAL_TOILETS, aspect_type=AspectType.TYPE, ), (51, 39): ElementMapping( - element=Element.COMMUNAL_WET_RISER, + element=ElementType.COMMUNAL_WET_RISER, aspect_type=AspectType.PRESENCE, ), (51, 40): ElementMapping( - element=Element.COMMUNAL_PLUG_SOCKETS, + element=ElementType.COMMUNAL_PLUG_SOCKETS, aspect_type=AspectType.PRESENCE, ), (200, 1): ElementMapping( - element=Element.COMMUNAL_BOILER, + element=ElementType.COMMUNAL_BOILER, aspect_type=AspectType.TYPE, ), # Duplicate of (51, 9) - correct? (200, 2): ElementMapping( - element=Element.COMMUNAL_HEATING, + element=ElementType.COMMUNAL_HEATING, aspect_type=AspectType.TYPE, ), (200, 3): ElementMapping( - element=Element.COMMUNAL_ELECTRICS, + element=ElementType.COMMUNAL_ELECTRICS, aspect_type=AspectType.TYPE, ), (200, 4): ElementMapping( - element=Element.COMMUNAL_FIRE_ALARM, + element=ElementType.COMMUNAL_FIRE_ALARM, aspect_type=AspectType.TYPE, ), (200, 5): ElementMapping( - element=Element.COMMUNAL_LIFT, + element=ElementType.COMMUNAL_LIFT, aspect_type=AspectType.TYPE, ), (200, 6): ElementMapping( - element=Element.COMMUNAL_FLOOR_COVERING, + element=ElementType.COMMUNAL_FLOOR_COVERING, aspect_type=AspectType.MATERIAL, ), (200, 7): ElementMapping( - element=Element.COMMUNAL_KITCHEN, + element=ElementType.COMMUNAL_KITCHEN, aspect_type=AspectType.TYPE, ), (200, 8): ElementMapping( - element=Element.COMMUNAL_BATHROOM, + element=ElementType.COMMUNAL_BATHROOM, aspect_type=AspectType.TYPE, ), # Duplicate of (51, 4) - correct? (200, 9): ElementMapping( - element=Element.COMMUNAL_TOILETS, + element=ElementType.COMMUNAL_TOILETS, aspect_type=AspectType.TYPE, ), # Duplicate of (51, 38) - correct? (200, 10): ElementMapping( - element=Element.COMMUNAL_GATES, + element=ElementType.COMMUNAL_GATES, aspect_type=AspectType.TYPE, ), # ========================================================== # INTERNAL – HEATING # ========================================================== (50, 4): ElementMapping( - element=Element.HEATING_BOILER, + element=ElementType.HEATING_BOILER, aspect_type=AspectType.PRESENCE, ), # This is actually "Central heating boiler" - ok like this? (50, 5): ElementMapping( - element=Element.CENTRAL_HEATING, + element=ElementType.CENTRAL_HEATING, aspect_type=AspectType.EXTENT, ), (50, 6): ElementMapping( - element=Element.COLD_WATER_STORAGE, + element=ElementType.COLD_WATER_STORAGE, aspect_type=AspectType.PRESENCE, ), (50, 12): ElementMapping( - element=Element.HEATING_DISTRIBUTION, + element=ElementType.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE, ), (50, 19): ElementMapping( - element=Element.PROGRAMMABLE_HEATING, + element=ElementType.PROGRAMMABLE_HEATING, aspect_type=AspectType.TYPE, ), (50, 25): ElementMapping( - element=Element.HEATING_BOILER, + element=ElementType.HEATING_BOILER, aspect_type=AspectType.TYPE, ), (170, 1): ElementMapping( - element=Element.HEATING_BOILER, + element=ElementType.HEATING_BOILER, aspect_type=AspectType.TYPE, ), # Duplicate of (50,25) - correct? (170, 2): ElementMapping( - element=Element.HEATING_DISTRIBUTION, + element=ElementType.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE, ), # Duplicate of (50,12) - correct? (170, 3): ElementMapping( - element=Element.SECONDARY_HEATING, + element=ElementType.SECONDARY_HEATING, aspect_type=AspectType.TYPE, ), (170, 4): ElementMapping( - element=Element.COLD_WATER_STORAGE, + element=ElementType.COLD_WATER_STORAGE, aspect_type=AspectType.TYPE, ), (170, 5): ElementMapping( - element=Element.HOT_WATER_SYSTEM, + element=ElementType.HOT_WATER_SYSTEM, aspect_type=AspectType.TYPE, ), # ========================================================== # ELECTRICS # ========================================================== (50, 24): ElementMapping( - element=Element.INTERNAL_WIRING, + element=ElementType.INTERNAL_WIRING, aspect_type=AspectType.MATERIAL, ), (180, 1): ElementMapping( - element=Element.ELECTRICAL_WIRING, + element=ElementType.ELECTRICAL_WIRING, aspect_type=AspectType.WORK_REQUIRED, ), # Not certain about the AspectType - only example in the sample data is "Full Rewire" (180, 2): ElementMapping( - element=Element.CONSUMER_UNIT, + element=ElementType.CONSUMER_UNIT, aspect_type=AspectType.TYPE, ), (180, 3): ElementMapping( - element=Element.SMOKE_DETECTION, + element=ElementType.SMOKE_DETECTION, aspect_type=AspectType.TYPE, ), # Duplicate of (50, 21) - correct? (180, 4): ElementMapping( - element=Element.CARBON_MONOXIDE_DETECTION, + element=ElementType.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE, ), # Duplicate of (50, 2) - correct? # ========================================================== # HHSRS # ========================================================== (54, 1): ElementMapping( - element=Element.HHSRS_DAMP_AND_MOULD, + element=ElementType.HHSRS_DAMP_AND_MOULD, aspect_type=AspectType.RISK, ), (54, 4): ElementMapping( - element=Element.HHSRS_ASBESTOS_AND_MMF, + element=ElementType.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK, ), (54, 15): ElementMapping( - element=Element.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, + element=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, aspect_type=AspectType.RISK, ), (54, 29): ElementMapping( - element=Element.HHSRS_STRUCTURAL_COLLAPSE, + element=ElementType.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK, ), } diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index dea07756..8c8a103b 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -1,6 +1,6 @@ from typing import Any, List, Optional -from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.element import Element from backend.condition.domain.mapping.element_mapping import ElementMapping from backend.condition.domain.mapping.peabody.peabody_element_map import ( PEABODY_ELEMENT_MAP, @@ -15,12 +15,12 @@ logger = setup_logger() class PeabodyMapper(Mapper): def map_asset_conditions_for_property( self, client_data: Any, survey_year: Optional[int] = None - ) -> List[AssetCondition]: + ) -> List[Element]: assert isinstance( client_data, PeabodyProperty ) # TODO: think of a better way to do this - mapped_assets: List[AssetCondition] = [] + mapped_assets: List[Element] = [] uprn: int = client_data.uprn for raw_asset in client_data.assets: @@ -36,7 +36,7 @@ class PeabodyMapper(Mapper): continue mapped_assets.append( - AssetCondition( + Element( uprn=uprn, element=element_mapping.element, aspect_type=element_mapping.aspect_type, diff --git a/backend/condition/domain/property_condition_survey.py b/backend/condition/domain/property_condition_survey.py new file mode 100644 index 00000000..6955e5fa --- /dev/null +++ b/backend/condition/domain/property_condition_survey.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from typing import List +from datetime import date + +from backend.condition.domain.element import Element + + +@dataclass +class PropertyConditionSurvey: + uprn: int + elements: List[Element] + + date: date + source: str # TODO: make enum diff --git a/backend/condition/processor.py b/backend/condition/processor.py index 903c9f23..3ed0904a 100644 --- a/backend/condition/processor.py +++ b/backend/condition/processor.py @@ -1,7 +1,7 @@ from typing import Any, BinaryIO, List from datetime import datetime -from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.element import Element from backend.condition.domain.mapping.mapper import Mapper from backend.condition.parsing.parser import Parser from utils.logger import setup_logger @@ -22,7 +22,7 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None: survey_year = datetime.now().year # TODO: get this from filepath or elsewhere - assets: List[AssetCondition] = [] + assets: List[Element] = [] for p in raw_properties: assets.extend(mapper.map_asset_conditions_for_property(p, survey_year)) diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index 907bd250..dab9c7a1 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -4,13 +4,13 @@ import pytest from datetime import date from backend.condition.domain.aspect_type import AspectType -from backend.condition.domain.element import Element +from backend.condition.domain.element_type import ElementType from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( LbwfAssetCondition, ) -from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.element import Element def test_lbwf_mapper_maps_house(): @@ -219,10 +219,10 @@ def test_lbwf_mapper_maps_house(): survey_year = 2026 - expected_assets: List[AssetCondition] = [ - AssetCondition( + expected_assets: List[Element] = [ + Element( uprn=1, - element=Element.ACCESSIBLE_HOUSING_REGISTER, + element=ElementType.ACCESSIBLE_HOUSING_REGISTER, aspect_type=AspectType.CATEGORY, element_instance=None, value="General Needs", @@ -231,9 +231,9 @@ def test_lbwf_mapper_maps_house(): install_date=None, comments=None, ), - AssetCondition( + Element( uprn=1, - element=Element.FLOOR_LEVEL_FRONT_DOOR, + element=ElementType.FLOOR_LEVEL_FRONT_DOOR, aspect_type=AspectType.LOCATION, element_instance=None, value="Ground Floor", @@ -242,9 +242,9 @@ def test_lbwf_mapper_maps_house(): install_date=None, comments=None, ), - AssetCondition( + Element( uprn=1, - element=Element.ASBESTOS, + element=ElementType.ASBESTOS, aspect_type=AspectType.PRESENCE, element_instance=None, value="Yes", @@ -253,9 +253,9 @@ def test_lbwf_mapper_maps_house(): install_date=None, comments="Source of Data = ACT", ), - AssetCondition( + Element( uprn=1, - element=Element.HHSRS_ASBESTOS_AND_MMF, + element=ElementType.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK, element_instance=None, value="Category 4 - Typical Risk", @@ -264,9 +264,9 @@ def test_lbwf_mapper_maps_house(): install_date=None, comments="Source of Data = ACT", ), - AssetCondition( + Element( uprn=1, - element=Element.BATHROOM, + element=ElementType.BATHROOM, aspect_type=AspectType.LOCATION, element_instance=None, value="Bathroom on Entrance Level in Property", @@ -275,9 +275,9 @@ def test_lbwf_mapper_maps_house(): install_date=None, comments="Source of Data = Codeman", ), - AssetCondition( + Element( uprn=1, - element=Element.CENTRAL_HEATING, + element=ElementType.CENTRAL_HEATING, aspect_type=AspectType.EXTENT, element_instance=None, value="No Central Heating in Property", @@ -286,9 +286,9 @@ def test_lbwf_mapper_maps_house(): install_date=None, comments="Source of Data = Codeman", ), - AssetCondition( + Element( uprn=1, - element=Element.HHSRS_FIRE, + element=ElementType.HHSRS_FIRE, aspect_type=AspectType.RISK, element_instance=None, value="Category 4 - Typical Risk", @@ -297,9 +297,9 @@ def test_lbwf_mapper_maps_house(): install_date=None, comments="Source of Data = Morgan Sindall", ), - AssetCondition( + Element( uprn=1, - element=Element.EXTERNAL_WALL, + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=1, value="Render or Pebbledash in External Area", @@ -308,9 +308,9 @@ def test_lbwf_mapper_maps_house(): install_date=date(2009, 4, 1), comments="Source of Data = Codeman", ), - AssetCondition( + Element( uprn=1, - element=Element.EXTERNAL_WALL, + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=2, value="Smooth Render Wall Finish 2 in External Area", @@ -322,7 +322,7 @@ def test_lbwf_mapper_maps_house(): ] # act - actual_assets: List[AssetCondition] = mapper.map_asset_conditions_for_property( + actual_assets: List[Element] = mapper.map_asset_conditions_for_property( lbwf_house, survey_year ) diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py index 9997dfa8..75e03016 100644 --- a/backend/condition/tests/mapping/test_peabody_mapper.py +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -2,13 +2,13 @@ from datetime import datetime from typing import List from backend.condition.domain.aspect_type import AspectType -from backend.condition.domain.element import Element +from backend.condition.domain.element_type import ElementType from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper from backend.condition.parsing.records.peabody.peabody_asset_condition import ( PeabodyAssetCondition, ) from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty -from backend.condition.domain.asset_condition import AssetCondition +from backend.condition.domain.element import Element def test_peabody_mapper_maps_property(): @@ -56,10 +56,10 @@ def test_peabody_mapper_maps_property(): ) mapper = PeabodyMapper() - expected_assets: List[AssetCondition] = [ - AssetCondition( + expected_assets: List[Element] = [ + Element( uprn=1, - element=Element.EXTERNAL_WINDOWS, + element=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.MATERIAL, value="UPVC Double Glazed", quantity=8, @@ -69,9 +69,9 @@ def test_peabody_mapper_maps_property(): source_system=None, comments=None, ), - AssetCondition( + Element( uprn=1, - element=Element.EXTERNAL_DECORATION, + element=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.CONDITION, value="Normal", quantity=1, @@ -155,10 +155,10 @@ def test_wall_primary_and_secondary_wall_finish_map_correctly(): ) mapper = PeabodyMapper() - expected_assets: List[AssetCondition] = [ - AssetCondition( + expected_assets: List[Element] = [ + Element( uprn=1, - element=Element.EXTERNAL_WALLS, + element=ElementType.EXTERNAL_WALLS, aspect_type=AspectType.FINISH, value="Pointed", element_instance=1, @@ -169,9 +169,9 @@ def test_wall_primary_and_secondary_wall_finish_map_correctly(): source_system=None, comments=None, ), - AssetCondition( + Element( uprn=1, - element=Element.EXTERNAL_WALLS, + element=ElementType.EXTERNAL_WALLS, aspect_type=AspectType.FINISH, value="Pointing", element_instance=1, @@ -182,9 +182,9 @@ def test_wall_primary_and_secondary_wall_finish_map_correctly(): source_system=None, comments=None, ), - AssetCondition( + Element( uprn=1, - element=Element.EXTERNAL_WALLS, + element=ElementType.EXTERNAL_WALLS, aspect_type=AspectType.FINISH, value="Tile Hung", element_instance=1, From a0fa676230e5be1a34aac8d463a68dcca8090799 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 13:53:09 +0000 Subject: [PATCH 65/74] =?UTF-8?q?Map=20peabody=20data=20to=20new=20structu?= =?UTF-8?q?re=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/condition/domain/element.py | 2 +- .../domain/mapping/lbwf/lbwf_mapper.py | 15 +- backend/condition/domain/mapping/mapper.py | 5 +- .../domain/mapping/peabody/peabody_mapper.py | 15 +- backend/condition/processor.py | 9 +- .../tests/mapping/test_lbwf_mapper.py | 18 +- .../tests/mapping/test_peabody_mapper.py | 169 ++++++++++-------- 7 files changed, 127 insertions(+), 106 deletions(-) diff --git a/backend/condition/domain/element.py b/backend/condition/domain/element.py index 7aca11fd..4a154815 100644 --- a/backend/condition/domain/element.py +++ b/backend/condition/domain/element.py @@ -7,6 +7,6 @@ from backend.condition.domain.element_type import ElementType @dataclass class Element: - element: ElementType + element_type: ElementType element_instance: int aspect_conditions: List[AspectCondition] diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py index bb5f777d..fa61abf0 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py @@ -4,6 +4,7 @@ from backend.condition.domain.element import Element from backend.condition.domain.mapping.element_mapping import ElementMapping from backend.condition.domain.mapping.lbwf.lbwf_element_map import LBWF_ELEMENT_MAP from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( LbwfAssetCondition, ) @@ -16,16 +17,18 @@ logger = setup_logger() class LbwfMapper(Mapper): def map_asset_conditions_for_property( - self, client_data: Any, survey_year: Optional[int] = None - ) -> List[Element]: + self, client_property_data: Any, survey_year: Optional[int] = None + ) -> PropertyConditionSurvey: + raise NotImplementedError + assert isinstance( - client_data, LbwfHouse + client_property_data, LbwfHouse ) # TODO: think of a better way to do this mapped_assets: List[Element] = [] - uprn: int = client_data.uprn - for raw_asset in client_data.assets: + uprn: int = client_property_data.uprn + for raw_asset in client_property_data.assets: # Ignore metadata rows if raw_asset.element_code not in ["EICINSFREQ", "DECNTHMINC"]: try: @@ -41,7 +44,7 @@ class LbwfMapper(Mapper): mapped_assets.append( Element( uprn=uprn, - element=element_mapping.element, + element_type=element_mapping.element, aspect_type=element_mapping.aspect_type, value=raw_asset.attribute_code_description, quantity=raw_asset.quantity, diff --git a/backend/condition/domain/mapping/mapper.py b/backend/condition/domain/mapping/mapper.py index ace6ad73..3479668a 100644 --- a/backend/condition/domain/mapping/mapper.py +++ b/backend/condition/domain/mapping/mapper.py @@ -2,13 +2,14 @@ from abc import ABC, abstractmethod from typing import Any, List, Optional from backend.condition.domain.element import Element +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey class Mapper(ABC): @abstractmethod def map_asset_conditions_for_property( - self, client_data: Any, survey_year: Optional[int] = None - ) -> List[Element]: + self, client_property_data: Any, survey_year: Optional[int] = None + ) -> PropertyConditionSurvey: # TODO: client_data should be properly typed pass diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index 8c8a103b..e9ee99a9 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -6,6 +6,7 @@ from backend.condition.domain.mapping.peabody.peabody_element_map import ( PEABODY_ELEMENT_MAP, ) from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty from utils.logger import setup_logger @@ -14,16 +15,18 @@ logger = setup_logger() class PeabodyMapper(Mapper): def map_asset_conditions_for_property( - self, client_data: Any, survey_year: Optional[int] = None - ) -> List[Element]: + self, client_property_data: Any, survey_year: Optional[int] = None + ) -> PropertyConditionSurvey: + raise NotImplementedError + assert isinstance( - client_data, PeabodyProperty + client_property_data, PeabodyProperty ) # TODO: think of a better way to do this mapped_assets: List[Element] = [] - uprn: int = client_data.uprn - for raw_asset in client_data.assets: + uprn: int = client_property_data.uprn + for raw_asset in client_property_data.assets: try: element_mapping: ElementMapping = PeabodyMapper._map_element( raw_asset.element_code, raw_asset.sub_element_code @@ -38,7 +41,7 @@ class PeabodyMapper(Mapper): mapped_assets.append( Element( uprn=uprn, - element=element_mapping.element, + element_type=element_mapping.element, aspect_type=element_mapping.aspect_type, value=raw_asset.material_or_answer, quantity=raw_asset.renewal_quantity, diff --git a/backend/condition/processor.py b/backend/condition/processor.py index 3ed0904a..3135d8a5 100644 --- a/backend/condition/processor.py +++ b/backend/condition/processor.py @@ -1,8 +1,8 @@ from typing import Any, BinaryIO, List from datetime import datetime -from backend.condition.domain.element import Element from backend.condition.domain.mapping.mapper import Mapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey from backend.condition.parsing.parser import Parser from utils.logger import setup_logger from backend.condition.file_type import FileType, detect_file_type @@ -22,8 +22,11 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None: survey_year = datetime.now().year # TODO: get this from filepath or elsewhere - assets: List[Element] = [] + property_condition_surveys: List[PropertyConditionSurvey] = [] + for p in raw_properties: - assets.extend(mapper.map_asset_conditions_for_property(p, survey_year)) + property_condition_surveys.push( + mapper.map_asset_conditions_for_property(p, survey_year) + ) print("done") # temp diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index dab9c7a1..8c92c029 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -222,7 +222,7 @@ def test_lbwf_mapper_maps_house(): expected_assets: List[Element] = [ Element( uprn=1, - element=ElementType.ACCESSIBLE_HOUSING_REGISTER, + element_type=ElementType.ACCESSIBLE_HOUSING_REGISTER, aspect_type=AspectType.CATEGORY, element_instance=None, value="General Needs", @@ -233,7 +233,7 @@ def test_lbwf_mapper_maps_house(): ), Element( uprn=1, - element=ElementType.FLOOR_LEVEL_FRONT_DOOR, + element_type=ElementType.FLOOR_LEVEL_FRONT_DOOR, aspect_type=AspectType.LOCATION, element_instance=None, value="Ground Floor", @@ -244,7 +244,7 @@ def test_lbwf_mapper_maps_house(): ), Element( uprn=1, - element=ElementType.ASBESTOS, + element_type=ElementType.ASBESTOS, aspect_type=AspectType.PRESENCE, element_instance=None, value="Yes", @@ -255,7 +255,7 @@ def test_lbwf_mapper_maps_house(): ), Element( uprn=1, - element=ElementType.HHSRS_ASBESTOS_AND_MMF, + element_type=ElementType.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK, element_instance=None, value="Category 4 - Typical Risk", @@ -266,7 +266,7 @@ def test_lbwf_mapper_maps_house(): ), Element( uprn=1, - element=ElementType.BATHROOM, + element_type=ElementType.BATHROOM, aspect_type=AspectType.LOCATION, element_instance=None, value="Bathroom on Entrance Level in Property", @@ -277,7 +277,7 @@ def test_lbwf_mapper_maps_house(): ), Element( uprn=1, - element=ElementType.CENTRAL_HEATING, + element_type=ElementType.CENTRAL_HEATING, aspect_type=AspectType.EXTENT, element_instance=None, value="No Central Heating in Property", @@ -288,7 +288,7 @@ def test_lbwf_mapper_maps_house(): ), Element( uprn=1, - element=ElementType.HHSRS_FIRE, + element_type=ElementType.HHSRS_FIRE, aspect_type=AspectType.RISK, element_instance=None, value="Category 4 - Typical Risk", @@ -299,7 +299,7 @@ def test_lbwf_mapper_maps_house(): ), Element( uprn=1, - element=ElementType.EXTERNAL_WALL, + element_type=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=1, value="Render or Pebbledash in External Area", @@ -310,7 +310,7 @@ def test_lbwf_mapper_maps_house(): ), Element( uprn=1, - element=ElementType.EXTERNAL_WALL, + element_type=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=2, value="Smooth Render Wall Finish 2 in External Area", diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py index 75e03016..63cd19c9 100644 --- a/backend/condition/tests/mapping/test_peabody_mapper.py +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -1,9 +1,10 @@ -from datetime import datetime -from typing import List +from datetime import datetime, date +from backend.condition.domain.aspect_condition import AspectCondition from backend.condition.domain.aspect_type import AspectType from backend.condition.domain.element_type import ElementType from backend.condition.domain.mapping.peabody.peabody_mapper import PeabodyMapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey from backend.condition.parsing.records.peabody.peabody_asset_condition import ( PeabodyAssetCondition, ) @@ -56,40 +57,51 @@ def test_peabody_mapper_maps_property(): ) mapper = PeabodyMapper() - expected_assets: List[Element] = [ - Element( - uprn=1, - element=ElementType.EXTERNAL_WINDOWS, - aspect_type=AspectType.MATERIAL, - value="UPVC Double Glazed", - quantity=8, - install_date=None, - renewal_year=2036, - element_instance=None, - source_system=None, - comments=None, - ), - Element( - uprn=1, - element=ElementType.EXTERNAL_DECORATION, - aspect_type=AspectType.CONDITION, - value="Normal", - quantity=1, - install_date=None, - renewal_year=2029, - element_instance=None, - source_system=None, - comments=None, - ), - ] + expected_condition_survey = PropertyConditionSurvey( + uprn=1, + elements=[ + Element( + element_type=ElementType.EXTERNAL_WINDOWS, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.MATERIAL, + aspect_instance=1, + value="UPVC Double Glazed", + quantity=8, + install_date=None, + renewal_year=2036, + comments=None, + ), + ], + ), + Element( + element_type=ElementType.EXTERNAL_DECORATION, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.CONDITION, + aspect_instance=1, + value="Normal", + quantity=1, + install_date=None, + renewal_year=2029, + comments=None, + ) + ], + ), + ], + date=date(2000, 1, 1), # what should this be? + source="Peabody", + ) + # act - actual_assets = mapper.map_asset_conditions_for_property(peabody_property) + actual_condition_survey: PropertyConditionSurvey = ( + mapper.map_asset_conditions_for_property(peabody_property) + ) # assert - assert len(actual_assets) == len(expected_assets) - - for i, (actual, expected) in enumerate(zip(actual_assets, expected_assets)): - assert actual == expected, f"Mismatch at index {i}" + assert actual_condition_survey == expected_condition_survey def test_wall_primary_and_secondary_wall_finish_map_correctly(): @@ -155,52 +167,51 @@ def test_wall_primary_and_secondary_wall_finish_map_correctly(): ) mapper = PeabodyMapper() - expected_assets: List[Element] = [ - Element( - uprn=1, - element=ElementType.EXTERNAL_WALLS, - aspect_type=AspectType.FINISH, - value="Pointed", - element_instance=1, - aspect_instance=1, - quantity=65, - install_date=None, - renewal_year=2045, - source_system=None, - comments=None, - ), - Element( - uprn=1, - element=ElementType.EXTERNAL_WALLS, - aspect_type=AspectType.FINISH, - value="Pointing", - element_instance=1, - aspect_instance=1, - quantity=1, - install_date=None, - renewal_year=2069, - source_system=None, - comments=None, - ), - Element( - uprn=1, - element=ElementType.EXTERNAL_WALLS, - aspect_type=AspectType.FINISH, - value="Tile Hung", - element_instance=1, - aspect_instance=2, - quantity=8, - install_date=None, - renewal_year=2049, - source_system=None, - comments=None, - ), - ] + expected_condition_survey = PropertyConditionSurvey( + uprn=1, + elements=[ + Element( + element_type=ElementType.EXTERNAL_WALL, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=1, + value="Pointed", + quantity=65, + install_date=None, + renewal_year=2045, + comments=None, + ), + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=1, + value="Pointing", + quantity=1, + install_date=None, + renewal_year=2069, + comments=None, + ), + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=2, + value="Tile Hung", + quantity=8, + install_date=None, + renewal_year=2049, + comments=None, + ), + ], + ), + ], + date=date(2000, 1, 1), # what should this be? + source="Peabody", + ) + # act - actual_assets = mapper.map_asset_conditions_for_property(peabody_property) + actual_condition_survey: PropertyConditionSurvey = ( + mapper.map_asset_conditions_for_property(peabody_property) + ) # assert - assert len(actual_assets) == len(expected_assets) - - for i, (actual, expected) in enumerate(zip(actual_assets, expected_assets)): - assert actual == expected, f"Mismatch at index {i}" + assert actual_condition_survey == expected_condition_survey From 0bd5106cb4535ba46a657d8777ac310a53e4c563 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 14:30:23 +0000 Subject: [PATCH 66/74] =?UTF-8?q?Map=20peabody=20data=20to=20new=20structu?= =?UTF-8?q?re=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapping/peabody/peabody_element_map.py | 10 +-- .../domain/mapping/peabody/peabody_mapper.py | 63 ++++++++++++++----- 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 508b8968..62cb2fc3 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -63,8 +63,10 @@ PEABODY_ELEMENT_MAP = { element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH ), (53, 30): ElementMapping( - element=ElementType.SECONDARY_WALL, aspect_type=AspectType.FINISH - ), # Should this be combined with primary wall, with different instance value? + element=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, + aspect_instance=2, + ), (53, 36): ElementMapping( element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION ), @@ -84,8 +86,8 @@ PEABODY_ELEMENT_MAP = { element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH ), (120, 3): ElementMapping( - element=ElementType.PRIMARY_WALL, aspect_type=AspectType.INSULATION - ), # This code element code is actually "WALL" not "external wall" - correct? + element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION + ), # ========================================================== # EXTERNAL – ROOFS # ========================================================== diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index e9ee99a9..37bb3b55 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -1,6 +1,9 @@ from typing import Any, List, Optional +from datetime import date +from backend.condition.domain.aspect_condition import AspectCondition from backend.condition.domain.element import Element +from backend.condition.domain.element_type import ElementType from backend.condition.domain.mapping.element_mapping import ElementMapping from backend.condition.domain.mapping.peabody.peabody_element_map import ( PEABODY_ELEMENT_MAP, @@ -17,15 +20,12 @@ class PeabodyMapper(Mapper): def map_asset_conditions_for_property( self, client_property_data: Any, survey_year: Optional[int] = None ) -> PropertyConditionSurvey: - raise NotImplementedError - assert isinstance( client_property_data, PeabodyProperty ) # TODO: think of a better way to do this - mapped_assets: List[Element] = [] + mapped_elements: List[Element] = [] - uprn: int = client_property_data.uprn for raw_asset in client_property_data.assets: try: element_mapping: ElementMapping = PeabodyMapper._map_element( @@ -38,22 +38,51 @@ class PeabodyMapper(Mapper): ) continue - mapped_assets.append( - Element( - uprn=uprn, - element_type=element_mapping.element, - aspect_type=element_mapping.aspect_type, - value=raw_asset.material_or_answer, - quantity=raw_asset.renewal_quantity, - install_date=None, # Not available in peabody data - renewal_year=raw_asset.renewal_year, - element_instance=element_mapping.element_instance, - source_system=None, # Once we know the system name we'll set it here - comments=None, # Not available in peabody data + aspect_condition = AspectCondition( + aspect_type=element_mapping.aspect_type, + aspect_instance=element_mapping.aspect_instance or 1, + value=raw_asset.material_or_answer, + quantity=raw_asset.renewal_quantity, + install_date=None, # Not available in peabody data + renewal_year=raw_asset.renewal_year, + comments=None, # Not available in peabody data + ) + matching_element_type_instance: Optional[Element] = ( + PeabodyMapper._check_for_element_type_and_instance( + mapped_elements, + element_mapping.element, + element_mapping.element_instance or 1, ) ) - return mapped_assets + if not matching_element_type_instance: + mapped_elements.append( + Element( + element_type=element_mapping.element, + element_instance=element_mapping.element_instance or 1, + aspect_conditions=[aspect_condition], + ) + ) + else: + matching_element_type_instance.aspect_conditions.append( + aspect_condition + ) + + return PropertyConditionSurvey( + uprn=client_property_data.uprn, + elements=mapped_elements, + date=date(2000, 1, 1), # Temp. Not sure how to get this + source="Peabody", # TODO: Make this the system, not the client + ) + + @staticmethod + def _check_for_element_type_and_instance( + elements: List[Element], type: ElementType, instance: int + ) -> Optional[Element]: + for e in elements: + if e.element_type == type and e.element_instance == instance: + return e + return None @staticmethod def _map_element(element_code: int, sub_element_code: int) -> ElementMapping: From dc5b43d4539ebbda6588dafe5a7bae28e67def7f Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 14:47:17 +0000 Subject: [PATCH 67/74] =?UTF-8?q?Map=20peabody=20data=20to=20new=20structu?= =?UTF-8?q?re=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mapping/peabody/peabody_mapper.py | 114 +++++++++++------- 1 file changed, 69 insertions(+), 45 deletions(-) diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index 37bb3b55..e052249b 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional, Tuple from datetime import date from backend.condition.domain.aspect_condition import AspectCondition @@ -24,57 +24,85 @@ class PeabodyMapper(Mapper): client_property_data, PeabodyProperty ) # TODO: think of a better way to do this - mapped_elements: List[Element] = [] + elements_by_key: dict[tuple[ElementType, int], Element] = {} for raw_asset in client_property_data.assets: - try: - element_mapping: ElementMapping = PeabodyMapper._map_element( - raw_asset.element_code, raw_asset.sub_element_code - ) - except: - logger.warning( - f"""Unrecognised Peabody Asset Element: {raw_asset.element} ({raw_asset.element_code}), - Sub-Element: {raw_asset.sub_element} ({raw_asset.sub_element_code}). Skipping record""" - ) - continue + element_mapping = PeabodyMapper._safe_map_element(raw_asset) - aspect_condition = AspectCondition( - aspect_type=element_mapping.aspect_type, - aspect_instance=element_mapping.aspect_instance or 1, - value=raw_asset.material_or_answer, - quantity=raw_asset.renewal_quantity, - install_date=None, # Not available in peabody data - renewal_year=raw_asset.renewal_year, - comments=None, # Not available in peabody data - ) - matching_element_type_instance: Optional[Element] = ( - PeabodyMapper._check_for_element_type_and_instance( - mapped_elements, - element_mapping.element, - element_mapping.element_instance or 1, - ) + aspect_condition = PeabodyMapper._build_aspect_condition( + raw_asset, element_mapping ) - if not matching_element_type_instance: - mapped_elements.append( - Element( - element_type=element_mapping.element, - element_instance=element_mapping.element_instance or 1, - aspect_conditions=[aspect_condition], - ) - ) - else: - matching_element_type_instance.aspect_conditions.append( - aspect_condition - ) + element_key = ( + element_mapping.element, + element_mapping.element_instance or 1, + ) + + PeabodyMapper._attach_aspect_condition_to_element( + elements_by_key, + element_key, + aspect_condition, + ) return PropertyConditionSurvey( uprn=client_property_data.uprn, - elements=mapped_elements, - date=date(2000, 1, 1), # Temp. Not sure how to get this + elements=list(elements_by_key.values()), + date=date(2000, 1, 1), # Temp - not sure how to get this source="Peabody", # TODO: Make this the system, not the client ) + @staticmethod + def _safe_map_element(raw_asset) -> Optional[ElementMapping]: + try: + return PeabodyMapper._map_element( + raw_asset.element_code, + raw_asset.sub_element_code, + ) + except KeyError: + logger.warning( + f"Unrecognised Peabody Asset Element: " + f"{raw_asset.element} ({raw_asset.element_code}), " + f"Sub-Element: {raw_asset.sub_element} ({raw_asset.sub_element_code}). " + "Skipping record" + ) + return None + + @staticmethod + def _map_element(element_code: int, sub_element_code: int) -> ElementMapping: + return PEABODY_ELEMENT_MAP[(element_code, sub_element_code)] + + @staticmethod + def _attach_aspect_condition_to_element( + elements_by_key: Dict[Tuple[ElementType, int], Element], + element_key: Tuple[ElementType, int], + aspect_condition: AspectCondition, + ) -> None: + element = elements_by_key.get(element_key) + + if element is None: + element = Element( + element_type=element_key[0], + element_instance=element_key[1], + aspect_conditions=[], + ) + elements_by_key[element_key] = element + + element.aspect_conditions.append(aspect_condition) + + @staticmethod + def _build_aspect_condition( + raw_asset, element_mapping: ElementMapping + ) -> AspectCondition: + return AspectCondition( + aspect_type=element_mapping.aspect_type, + aspect_instance=element_mapping.aspect_instance or 1, + value=raw_asset.material_or_answer, + quantity=raw_asset.renewal_quantity, + install_date=None, + renewal_year=raw_asset.renewal_year, + comments=None, + ) + @staticmethod def _check_for_element_type_and_instance( elements: List[Element], type: ElementType, instance: int @@ -83,7 +111,3 @@ class PeabodyMapper(Mapper): if e.element_type == type and e.element_instance == instance: return e return None - - @staticmethod - def _map_element(element_code: int, sub_element_code: int) -> ElementMapping: - return PEABODY_ELEMENT_MAP[(element_code, sub_element_code)] From 61caf8c495baaa7b4a7cdda4fed8e3027b4dc055 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 14:48:05 +0000 Subject: [PATCH 68/74] =?UTF-8?q?Map=20peabody=20data=20to=20new=20structu?= =?UTF-8?q?re=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../condition/domain/mapping/peabody/peabody_mapper.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index e052249b..41b2ff39 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -102,12 +102,3 @@ class PeabodyMapper(Mapper): renewal_year=raw_asset.renewal_year, comments=None, ) - - @staticmethod - def _check_for_element_type_and_instance( - elements: List[Element], type: ElementType, instance: int - ) -> Optional[Element]: - for e in elements: - if e.element_type == type and e.element_instance == instance: - return e - return None From d6112f3dc8f45aabd63f789f475c09929bafb56d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 15:20:13 +0000 Subject: [PATCH 69/74] =?UTF-8?q?Map=20lbwf=20data=20to=20new=20structure?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mapping/peabody/peabody_mapper.py | 2 +- .../tests/mapping/test_lbwf_mapper.py | 247 ++++++++++-------- 2 files changed, 141 insertions(+), 108 deletions(-) diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index 41b2ff39..7749b024 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Optional, Tuple from datetime import date from backend.condition.domain.aspect_condition import AspectCondition diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index 8c92c029..064e06f7 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -3,9 +3,11 @@ from xml.dom.minidom import Element import pytest from datetime import date +from backend.condition.domain.aspect_condition import AspectCondition from backend.condition.domain.aspect_type import AspectType from backend.condition.domain.element_type import ElementType from backend.condition.domain.mapping.lbwf.lbwf_mapper import LbwfMapper +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( LbwfAssetCondition, @@ -219,115 +221,146 @@ def test_lbwf_mapper_maps_house(): survey_year = 2026 - expected_assets: List[Element] = [ - Element( - uprn=1, - element_type=ElementType.ACCESSIBLE_HOUSING_REGISTER, - aspect_type=AspectType.CATEGORY, - element_instance=None, - value="General Needs", - quantity=1, - renewal_year=None, - install_date=None, - comments=None, - ), - Element( - uprn=1, - element_type=ElementType.FLOOR_LEVEL_FRONT_DOOR, - aspect_type=AspectType.LOCATION, - element_instance=None, - value="Ground Floor", - quantity=1, - renewal_year=None, - install_date=None, - comments=None, - ), - Element( - uprn=1, - element_type=ElementType.ASBESTOS, - aspect_type=AspectType.PRESENCE, - element_instance=None, - value="Yes", - quantity=None, - renewal_year=None, - install_date=None, - comments="Source of Data = ACT", - ), - Element( - uprn=1, - element_type=ElementType.HHSRS_ASBESTOS_AND_MMF, - aspect_type=AspectType.RISK, - element_instance=None, - value="Category 4 - Typical Risk", - quantity=None, - renewal_year=None, - install_date=None, - comments="Source of Data = ACT", - ), - Element( - uprn=1, - element_type=ElementType.BATHROOM, - aspect_type=AspectType.LOCATION, - element_instance=None, - value="Bathroom on Entrance Level in Property", - quantity=1, - renewal_year=None, - install_date=None, - comments="Source of Data = Codeman", - ), - Element( - uprn=1, - element_type=ElementType.CENTRAL_HEATING, - aspect_type=AspectType.EXTENT, - element_instance=None, - value="No Central Heating in Property", - quantity=1, - renewal_year=None, - install_date=None, - comments="Source of Data = Codeman", - ), - Element( - uprn=1, - element_type=ElementType.HHSRS_FIRE, - aspect_type=AspectType.RISK, - element_instance=None, - value="Category 4 - Typical Risk", - quantity=1, - renewal_year=None, - install_date=None, - comments="Source of Data = Morgan Sindall", - ), - Element( - uprn=1, - element_type=ElementType.EXTERNAL_WALL, - aspect_type=AspectType.FINISH, - element_instance=1, - value="Render or Pebbledash in External Area", - quantity=1, - renewal_year=2052, - install_date=date(2009, 4, 1), - comments="Source of Data = Codeman", - ), - Element( - uprn=1, - element_type=ElementType.EXTERNAL_WALL, - aspect_type=AspectType.FINISH, - element_instance=2, - value="Smooth Render Wall Finish 2 in External Area", - quantity=1, - renewal_year=2052, - install_date=date(2009, 4, 1), - comments="Source of Data = Codeman", - ), - ] + expected_condition_survey = PropertyConditionSurvey( + uprn=1, + elements=[ + Element( + element_type=ElementType.ACCESSIBLE_HOUSING_REGISTER, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.CATEGORY, + aspect_instance=1, + value="General Needs", + quantity=1, + install_date=None, + renewal_year=None, + comments=None, + ) + ], + ), + Element( + element_type=ElementType.FLOOR_LEVEL_FRONT_DOOR, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.LOCATION, + aspect_instance=1, + value="Ground Floor", + quantity=1, + install_date=None, + renewal_year=None, + comments=None, + ) + ], + ), + Element( + element_type=ElementType.ASBESTOS, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.PRESENCE, + aspect_instance=1, + value="Yes", + quantity=None, + install_date=None, + renewal_year=None, + comments=None, + ) + ], + ), + Element( + element_type=ElementType.HHSRS_ASBESTOS_AND_MMF, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.RISK, + aspect_instance=1, + value="Category 4 - Typical Risk", + quantity=None, + renewal_year=None, + comments="Source of Data = ACT", + ) + ], + ), + Element( + element_type=ElementType.BATHROOM, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.LOCATION, + aspect_instance=1, + value="Bathroom on Entrance Level in Property", + quantity=1, + install_date=None, + renewal_year=None, + comments="Source of Data = Codeman", + ) + ], + ), + Element( + element_type=ElementType.CENTRAL_HEATING, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.EXTENT, + aspect_instance=1, + value="No Central Heating in Property", + quantity=1, + install_date=None, + renewal_year=None, + comments="Source of Data = Codeman", + ) + ], + ), + Element( + element_type=ElementType.HHSRS_FIRE, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.RISK, + aspect_instance=1, + value="Category 4 - Typical Risk", + quantity=1, + install_date=None, + renewal_year=None, + comments="Source of Data = Morgan Sindall", + ) + ], + ), + Element( + element_type=ElementType.EXTERNAL_WALL, + element_instance=1, + aspect_conditions=[ + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=1, + value="Render or Pebbledash in External Area", + quantity=1, + install_date=date(2009, 4, 1), + renewal_year=2052, + comments="Source of Data = Codeman", + ), + AspectCondition( + aspect_type=AspectType.FINISH, + aspect_instance=2, + value="Smooth Render Wall Finish 2 in External Area", + quantity=1, + install_date=date(2009, 4, 1), + renewal_year=2052, + comments="Source of Data = Codeman", + ), + ], + ), + ], + date=date(2000, 1, 1), # what should this be? + source="LBWF", + ) # act - actual_assets: List[Element] = mapper.map_asset_conditions_for_property( - lbwf_house, survey_year + actual_condition_survey: PropertyConditionSurvey = ( + mapper.map_asset_conditions_for_property(lbwf_house) ) # assert - assert len(actual_assets) == len(expected_assets) - - for i, (actual, expected) in enumerate(zip(actual_assets, expected_assets)): - assert actual == expected, f"Mismatch at index {i}" + assert actual_condition_survey == expected_condition_survey From 803484defd8319e1d893f23e65c53b7cd97f0a88 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 15:59:06 +0000 Subject: [PATCH 70/74] =?UTF-8?q?Map=20lbwf=20data=20to=20new=20structure?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mapping/lbwf/lbwf_element_map.py | 3 +- .../domain/mapping/lbwf/lbwf_mapper.py | 103 ++++++++++++------ .../domain/mapping/peabody/peabody_mapper.py | 7 +- backend/condition/tests/custom_asserts.py | 74 +++++++++++++ .../tests/mapping/test_lbwf_mapper.py | 12 +- .../tests/mapping/test_peabody_mapper.py | 5 +- 6 files changed, 161 insertions(+), 43 deletions(-) create mode 100644 backend/condition/tests/custom_asserts.py diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index dfd9ca4e..96f21b84 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -175,7 +175,8 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { "EXTWALLFN2": ElementMapping( element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, - element_instance=2, + element_instance=1, + aspect_instance=2, ), "EXTWALLINS": ElementMapping( element=ElementType.EXTERNAL_WALL, diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py index fa61abf0..f11133bf 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py @@ -1,6 +1,9 @@ -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional, Tuple +from datetime import date +from backend.condition.domain.aspect_condition import AspectCondition from backend.condition.domain.element import Element +from backend.condition.domain.element_type import ElementType from backend.condition.domain.mapping.element_mapping import ElementMapping from backend.condition.domain.mapping.lbwf.lbwf_element_map import LBWF_ELEMENT_MAP from backend.condition.domain.mapping.mapper import Mapper @@ -19,51 +22,85 @@ class LbwfMapper(Mapper): def map_asset_conditions_for_property( self, client_property_data: Any, survey_year: Optional[int] = None ) -> PropertyConditionSurvey: - raise NotImplementedError - assert isinstance( client_property_data, LbwfHouse ) # TODO: think of a better way to do this - mapped_assets: List[Element] = [] + elements_by_key: dict[tuple[ElementType, int], Element] = {} - uprn: int = client_property_data.uprn for raw_asset in client_property_data.assets: - # Ignore metadata rows - if raw_asset.element_code not in ["EICINSFREQ", "DECNTHMINC"]: - try: - element_mapping: ElementMapping = LbwfMapper._map_element( - raw_asset.element_code - ) - except: - logger.warning( - f"Unrecognised LBWF Asset Element Code: {raw_asset.element_code}. Skipping record" - ) - continue + element_mapping = LbwfMapper._safe_map_element(raw_asset) - mapped_assets.append( - Element( - uprn=uprn, - element_type=element_mapping.element, - aspect_type=element_mapping.aspect_type, - value=raw_asset.attribute_code_description, - quantity=raw_asset.quantity, - install_date=raw_asset.install_date, - renewal_year=LbwfMapper._calculate_renewal_year( - raw_asset, survey_year - ), - element_instance=element_mapping.element_instance, - source_system=None, # Once we know the system name we'll set it here - comments=raw_asset.element_comments, - ) + aspect_condition = LbwfMapper._build_aspect_condition( + raw_asset, element_mapping, survey_year + ) + + element_key = ( + element_mapping.element, + element_mapping.element_instance or 1, + ) + + LbwfMapper._attach_aspect_condition_to_element( + elements_by_key, element_key, aspect_condition + ) + + return PropertyConditionSurvey( + uprn=client_property_data.uprn, + elements=list(elements_by_key.values()), + date=date(2000, 1, 1), # Temp - not sure how to get this + source="LBWF", # TODO: Make this the system, not the client + ) + + @staticmethod + def _safe_map_element(raw_asset: LbwfAssetCondition) -> Optional[ElementMapping]: + try: + return LbwfMapper._map_element(raw_asset.element_code) + except KeyError: + logger.warning( + logger.warning( + f"Unrecognised LBWF Asset Element: " + f"{raw_asset.element_code} ({raw_asset.element_code_description})). " + "Skipping record" ) - - return mapped_assets + ) + return None @staticmethod def _map_element(lbwf_element_code: str) -> ElementMapping: return LBWF_ELEMENT_MAP[lbwf_element_code] + @staticmethod + def _build_aspect_condition( + raw_asset, element_mapping: ElementMapping, survey_year: int + ) -> AspectCondition: + return AspectCondition( + aspect_type=element_mapping.aspect_type, + aspect_instance=element_mapping.aspect_instance or 1, + value=raw_asset.attribute_code_description, + quantity=raw_asset.quantity, + install_date=raw_asset.install_date, + renewal_year=LbwfMapper._calculate_renewal_year(raw_asset, survey_year), + comments=raw_asset.element_comments, + ) + + @staticmethod + def _attach_aspect_condition_to_element( + elements_by_key: Dict[Tuple[ElementType, int], Element], + element_key: Tuple[ElementType, int], + aspect_condition: AspectCondition, + ) -> None: + element = elements_by_key.get(element_key) + + if element is None: + element = Element( + element_type=element_key[0], + element_instance=element_key[1], + aspect_conditions=[], + ) + elements_by_key[element_key] = element + + element.aspect_conditions.append(aspect_condition) + @staticmethod def _calculate_renewal_year( lbwf_asset: LbwfAssetCondition, survey_year: Optional[int] diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index 7749b024..184b2898 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -10,6 +10,9 @@ from backend.condition.domain.mapping.peabody.peabody_element_map import ( ) from backend.condition.domain.mapping.mapper import Mapper from backend.condition.domain.property_condition_survey import PropertyConditionSurvey +from backend.condition.parsing.records.peabody.peabody_asset_condition import ( + PeabodyAssetCondition, +) from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty from utils.logger import setup_logger @@ -52,7 +55,7 @@ class PeabodyMapper(Mapper): ) @staticmethod - def _safe_map_element(raw_asset) -> Optional[ElementMapping]: + def _safe_map_element(raw_asset: PeabodyAssetCondition) -> Optional[ElementMapping]: try: return PeabodyMapper._map_element( raw_asset.element_code, @@ -98,7 +101,7 @@ class PeabodyMapper(Mapper): aspect_instance=element_mapping.aspect_instance or 1, value=raw_asset.material_or_answer, quantity=raw_asset.renewal_quantity, - install_date=None, + install_date=None, # Not available in peabody data renewal_year=raw_asset.renewal_year, comments=None, ) diff --git a/backend/condition/tests/custom_asserts.py b/backend/condition/tests/custom_asserts.py new file mode 100644 index 00000000..9e3abd7f --- /dev/null +++ b/backend/condition/tests/custom_asserts.py @@ -0,0 +1,74 @@ +from backend.condition.domain.property_condition_survey import PropertyConditionSurvey + + +class CustomAsserts: + def assert_property_condition_surveys_equal( + actual: PropertyConditionSurvey, + expected: PropertyConditionSurvey, + ) -> bool: + assert actual.uprn == expected.uprn, "UPRN differs" + assert actual.source == expected.source, "Source differs" + assert actual.date == expected.date, "Date differs" + + assert len(actual.elements) == len(expected.elements), ( + f"Expected {len(expected.elements)} elements, " + f"got {len(actual.elements)}" + ) + + for i, (actual_element, expected_element) in enumerate( + zip(actual.elements, expected.elements) + ): + assert actual_element.element_type == expected_element.element_type, ( + f"Element[{i}] type differs: " + f"{actual_element.element_type} != {expected_element.element_type}" + ) + assert ( + actual_element.element_instance == expected_element.element_instance + ), ( + f"Element[{i}] instance differs: " + f"{actual_element.element_instance} != {expected_element.element_instance}" + ) + + assert len(actual_element.aspect_conditions) == len( + expected_element.aspect_conditions + ), f"Element[{i}] aspect count differs" + + for j, (actual_aspect, expected_aspect) in enumerate( + zip( + actual_element.aspect_conditions, + expected_element.aspect_conditions, + ) + ): + prefix = f"Element[{i}].Aspect[{j}]" + + assert actual_aspect.aspect_type == expected_aspect.aspect_type, ( + f"{prefix}.aspect_type differs: " + f"{actual_aspect.aspect_type} != {expected_aspect.aspect_type}" + ) + assert ( + actual_aspect.aspect_instance == expected_aspect.aspect_instance + ), ( + f"{prefix}.aspect_instance differs: " + f"{actual_aspect.aspect_instance} != {expected_aspect.aspect_instance}" + ) + assert actual_aspect.value == expected_aspect.value, ( + f"{prefix}.value differs: " + f"{actual_aspect.value} != {expected_aspect.value}" + ) + assert actual_aspect.quantity == expected_aspect.quantity, ( + f"{prefix}.quantity differs: " + f"{actual_aspect.quantity} != {expected_aspect.quantity}" + ) + assert actual_aspect.install_date == expected_aspect.install_date, ( + f"{prefix}.install_date differs: " + f"{actual_aspect.install_date} != {expected_aspect.install_date}" + ) + assert actual_aspect.renewal_year == expected_aspect.renewal_year, ( + f"{prefix}.renewal_year differs: " + f"{actual_aspect.renewal_year} != {expected_aspect.renewal_year}" + ) + assert actual_aspect.comments == expected_aspect.comments, ( + f"{prefix}.comments differs: " + f"{actual_aspect.comments} != {expected_aspect.comments}" + ) + return True diff --git a/backend/condition/tests/mapping/test_lbwf_mapper.py b/backend/condition/tests/mapping/test_lbwf_mapper.py index 064e06f7..77890155 100644 --- a/backend/condition/tests/mapping/test_lbwf_mapper.py +++ b/backend/condition/tests/mapping/test_lbwf_mapper.py @@ -1,6 +1,3 @@ -from typing import List -from xml.dom.minidom import Element -import pytest from datetime import date from backend.condition.domain.aspect_condition import AspectCondition @@ -13,6 +10,7 @@ from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( LbwfAssetCondition, ) from backend.condition.domain.element import Element +from backend.condition.tests.custom_asserts import CustomAsserts def test_lbwf_mapper_maps_house(): @@ -265,7 +263,7 @@ def test_lbwf_mapper_maps_house(): quantity=None, install_date=None, renewal_year=None, - comments=None, + comments="Source of Data = ACT", ) ], ), @@ -359,8 +357,10 @@ def test_lbwf_mapper_maps_house(): # act actual_condition_survey: PropertyConditionSurvey = ( - mapper.map_asset_conditions_for_property(lbwf_house) + mapper.map_asset_conditions_for_property(lbwf_house, survey_year) ) # assert - assert actual_condition_survey == expected_condition_survey + assert CustomAsserts.assert_property_condition_surveys_equal( + actual_condition_survey, expected_condition_survey + ) diff --git a/backend/condition/tests/mapping/test_peabody_mapper.py b/backend/condition/tests/mapping/test_peabody_mapper.py index 63cd19c9..979258b0 100644 --- a/backend/condition/tests/mapping/test_peabody_mapper.py +++ b/backend/condition/tests/mapping/test_peabody_mapper.py @@ -10,6 +10,7 @@ from backend.condition.parsing.records.peabody.peabody_asset_condition import ( ) from backend.condition.parsing.records.peabody.peabody_property import PeabodyProperty from backend.condition.domain.element import Element +from backend.condition.tests.custom_asserts import CustomAsserts def test_peabody_mapper_maps_property(): @@ -214,4 +215,6 @@ def test_wall_primary_and_secondary_wall_finish_map_correctly(): ) # assert - assert actual_condition_survey == expected_condition_survey + assert CustomAsserts.assert_property_condition_surveys_equal( + actual_condition_survey, expected_condition_survey + ) From 32f9850a27e101503d819c7b3abda5d5eeb251f6 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 16:08:20 +0000 Subject: [PATCH 71/74] minor fixes and TODO --- backend/condition/domain/element_type.py | 2 + .../domain/mapping/element_mapping.py | 4 +- .../domain/mapping/lbwf/lbwf_element_map.py | 240 ++++++------- .../domain/mapping/lbwf/lbwf_mapper.py | 2 +- .../mapping/peabody/peabody_element_map.py | 327 +++++++++--------- .../domain/mapping/peabody/peabody_mapper.py | 2 +- 6 files changed, 291 insertions(+), 286 deletions(-) diff --git a/backend/condition/domain/element_type.py b/backend/condition/domain/element_type.py index 32897895..bc2aa2d6 100644 --- a/backend/condition/domain/element_type.py +++ b/backend/condition/domain/element_type.py @@ -228,6 +228,8 @@ class ElementType(str, Enum): # HHSRS – ALL 29 HAZARDS # ========================================================== + # TODO: In order to group HHSRS, should there be a single HHSRS element type, and each of the below is an AspectType? + HHSRS_DAMP_AND_MOULD = "hhsrs_damp_and_mould" HHSRS_EXCESS_COLD = "hhsrs_excess_cold" HHSRS_EXCESS_HEAT = "hhsrs_excess_heat" diff --git a/backend/condition/domain/mapping/element_mapping.py b/backend/condition/domain/mapping/element_mapping.py index c93862c8..95fd08b9 100644 --- a/backend/condition/domain/mapping/element_mapping.py +++ b/backend/condition/domain/mapping/element_mapping.py @@ -1,13 +1,13 @@ from dataclasses import dataclass from typing import Optional -from xml.dom.minidom import Element from backend.condition.domain.aspect_type import AspectType +from backend.condition.domain.element_type import ElementType @dataclass(frozen=True) class ElementMapping: - element: Element + elementType: ElementType aspect_type: AspectType element_instance: Optional[int] = None aspect_instance: Optional[int] = None diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index 96f21b84..a547fe5c 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -8,11 +8,11 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # PROPERTY / GENERAL # ========================================================== "AHR_CAT": ElementMapping( - element=ElementType.ACCESSIBLE_HOUSING_REGISTER, + elementType=ElementType.ACCESSIBLE_HOUSING_REGISTER, aspect_type=AspectType.CATEGORY, ), "ASSETSAREA": ElementMapping( - element=ElementType.PROPERTY, + elementType=ElementType.PROPERTY, aspect_type=AspectType.AREA, ), # "DECNTHMINC": ElementMapping( @@ -20,302 +20,302 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # aspect_type=AspectType.INCLUSION, # ), # Ignore this one "QUALITYSTD": ElementMapping( - element=ElementType.QUALITY_STANDARD, + elementType=ElementType.QUALITY_STANDARD, aspect_type=AspectType.TYPE, ), "EXTSTOREY": ElementMapping( - element=ElementType.PROPERTY, + elementType=ElementType.PROPERTY, aspect_type=AspectType.CONFIGURATION, ), "FLVL": ElementMapping( - element=ElementType.FLOOR_LEVEL_FRONT_DOOR, + elementType=ElementType.FLOOR_LEVEL_FRONT_DOOR, aspect_type=AspectType.LOCATION, ), "INTFLRLVL": ElementMapping( - element=ElementType.FLOOR_LEVEL, + elementType=ElementType.FLOOR_LEVEL, aspect_type=AspectType.LOCATION, ), "INTNSEINSL": ElementMapping( - element=ElementType.EXTERNAL_NOISE_INSULATION, # Maybe this shouldn't be "EXTERNAL_" + elementType=ElementType.EXTERNAL_NOISE_INSULATION, # Maybe this shouldn't be "EXTERNAL_" aspect_type=AspectType.ADEQUACY, ), "INTSTEPSFD": ElementMapping( - element=ElementType.STEPS_TO_FRONT_DOOR, + elementType=ElementType.STEPS_TO_FRONT_DOOR, aspect_type=AspectType.QUANTITY, ), # ========================================================== # ASBESTOS (NON-HHSRS RECORD) # ========================================================== "ASBESTOS": ElementMapping( - element=ElementType.ASBESTOS, + elementType=ElementType.ASBESTOS, aspect_type=AspectType.PRESENCE, ), # ========================================================== # INTERNAL – BATHROOMS & KITCHENS # ========================================================== "INTBTHRLOC": ElementMapping( - element=ElementType.BATHROOM, + elementType=ElementType.BATHROOM, aspect_type=AspectType.LOCATION, ), "INTBTHADEQ": ElementMapping( - element=ElementType.BATHROOM, + elementType=ElementType.BATHROOM, aspect_type=AspectType.ADEQUACY, ), "INTKITADEQ": ElementMapping( - element=ElementType.KITCHEN, + elementType=ElementType.KITCHEN, aspect_type=AspectType.ADEQUACY, ), "INTCKRLOC": ElementMapping( - element=ElementType.KITCHEN, + elementType=ElementType.KITCHEN, aspect_type=AspectType.LOCATION, ), "INTADDWCW": ElementMapping( - element=ElementType.ADDITIONAL_WC_OR_WHB, + elementType=ElementType.ADDITIONAL_WC_OR_WHB, aspect_type=AspectType.PRESENCE, ), "INTBTHREML": ElementMapping( - element=ElementType.BATHROOM_REMAINING_LIFE_SOURCE, + elementType=ElementType.BATHROOM_REMAINING_LIFE_SOURCE, aspect_type=AspectType.TYPE, ), "INTKITREML": ElementMapping( - element=ElementType.KITCHEN_REMAINING_LIFE_SOURCE, + elementType=ElementType.KITCHEN_REMAINING_LIFE_SOURCE, aspect_type=AspectType.TYPE, ), "INTTNTINST": ElementMapping( - element=ElementType.TENANT_INSTALLED_KITCHEN, + elementType=ElementType.TENANT_INSTALLED_KITCHEN, aspect_type=AspectType.TYPE, # Not certain about this aspect type - need more data ), # ========================================================== # INTERNAL – FIRE # ========================================================== "FRARISKRTG": ElementMapping( - element=ElementType.FIRE_RISK_ASSESSMENT, + elementType=ElementType.FIRE_RISK_ASSESSMENT, aspect_type=AspectType.RATING, ), "FRATYPE": ElementMapping( - element=ElementType.FIRE_RISK_ASSESSMENT, + elementType=ElementType.FIRE_RISK_ASSESSMENT, aspect_type=AspectType.TYPE, ), "FRAEVACSTR": ElementMapping( - element=ElementType.FIRE_RISK_ASSESSMENT, + elementType=ElementType.FIRE_RISK_ASSESSMENT, aspect_type=AspectType.STRATEGY, ), "INTSMKDET": ElementMapping( - element=ElementType.SMOKE_DETECTION, + elementType=ElementType.SMOKE_DETECTION, aspect_type=AspectType.PRESENCE, ), "INTCHEXTNT": ElementMapping( - element=ElementType.HEATING_SYSTEM, + elementType=ElementType.HEATING_SYSTEM, aspect_type=AspectType.EXTENT, ), # ========================================================== # HEATING & SERVICES # ========================================================== "INTCHEXTNT": ElementMapping( - element=ElementType.CENTRAL_HEATING, + elementType=ElementType.CENTRAL_HEATING, aspect_type=AspectType.EXTENT, ), "INTCHDIST": ElementMapping( - element=ElementType.HEATING_DISTRIBUTION, + elementType=ElementType.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE, ), "INTCHBLR": ElementMapping( - element=ElementType.HEATING_BOILER, + elementType=ElementType.HEATING_BOILER, aspect_type=AspectType.TYPE, ), "INTBOILERF": ElementMapping( - element=ElementType.BOILER_FUEL, + elementType=ElementType.BOILER_FUEL, aspect_type=AspectType.TYPE, ), "INTHTDISYS": ElementMapping( - element=ElementType.HEATING_SYSTEM, + elementType=ElementType.HEATING_SYSTEM, aspect_type=AspectType.DISTRIBUTION, ), "INTWTRHTNG": ElementMapping( - element=ElementType.WATER_HEATING, + elementType=ElementType.WATER_HEATING, aspect_type=AspectType.TYPE, ), "INTCOMHTG": ElementMapping( - element=ElementType.COMMUNITY_HEATING, + elementType=ElementType.COMMUNITY_HEATING, aspect_type=AspectType.TYPE, ), "INTELECTRC": ElementMapping( - element=ElementType.ELECTRICS, + elementType=ElementType.ELECTRICS, aspect_type=AspectType.WORK_REQUIRED, # Not certain about this aspect type - need more data ), "INTGASAVAI": ElementMapping( - element=ElementType.GAS_AVAILABLE, + elementType=ElementType.GAS_AVAILABLE, aspect_type=AspectType.PRESENCE, # Maybe should be AspectType.TYPE ? ), "INTHEATREC": ElementMapping( - element=ElementType.HEAT_RECOVERY_UNITS, + elementType=ElementType.HEAT_RECOVERY_UNITS, aspect_type=AspectType.PRESENCE, ), "INTHTIMP": ElementMapping( - element=ElementType.GAS_AVAILABLE, + elementType=ElementType.GAS_AVAILABLE, aspect_type=AspectType.WORK_REQUIRED, ), "INTPROGHTG": ElementMapping( - element=ElementType.PROGRAMMABLE_HEATING, + elementType=ElementType.PROGRAMMABLE_HEATING, aspect_type=AspectType.TYPE, # Should maybe be PRESENCE, but set to TYPE for consistency with Peabody data ), # ========================================================== # EXTERNAL – WALLS (INSTANCED) # ========================================================== "EXTWALLSTR": ElementMapping( - element=ElementType.EXTERNAL_WALL, + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE, element_instance=1, ), "EXTWALLFN1": ElementMapping( - element=ElementType.EXTERNAL_WALL, + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=1, ), "EXTWALLFN2": ElementMapping( - element=ElementType.EXTERNAL_WALL, + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, element_instance=1, aspect_instance=2, ), "EXTWALLINS": ElementMapping( - element=ElementType.EXTERNAL_WALL, + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION, ), "EXTWALLSPL": ElementMapping( - element=ElementType.EXTERNAL_WALL, + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.CONDITION, ), "EXTDWNPTYP": ElementMapping( - element=ElementType.DOWNPIPES, + elementType=ElementType.DOWNPIPES, aspect_type=AspectType.MATERIAL, ), "EXTGUTRTYP": ElementMapping( - element=ElementType.GUTTERS, + elementType=ElementType.GUTTERS, aspect_type=AspectType.MATERIAL, ), # ========================================================== # EXTERNAL – ROOFS (INSTANCED) # ========================================================== "EXTRFSTR1": ElementMapping( - element=ElementType.ROOF, + elementType=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, element_instance=1, ), "EXTRFSTR2": ElementMapping( - element=ElementType.ROOF, + elementType=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, element_instance=2, ), "EXTRFSTR3": ElementMapping( - element=ElementType.ROOF, + elementType=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, element_instance=3, ), "EXTROOF1": ElementMapping( - element=ElementType.ROOF, + elementType=ElementType.ROOF, aspect_type=AspectType.COVERING, element_instance=1, ), "EXTROOF2": ElementMapping( - element=ElementType.ROOF, + elementType=ElementType.ROOF, aspect_type=AspectType.COVERING, element_instance=2, ), "EXTROOF3": ElementMapping( - element=ElementType.ROOF, + elementType=ElementType.ROOF, aspect_type=AspectType.COVERING, element_instance=3, ), "EXTCHIMNEY": ElementMapping( - element=ElementType.CHIMNEY, + elementType=ElementType.CHIMNEY, aspect_type=AspectType.WORK_REQUIRED, ), "EXTFASOFBR": ElementMapping( - element=ElementType.FASCIA_SOFFIT_BARGEBOARDS, + elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS, aspect_type=AspectType.MATERIAL, ), "EXTGARROOF": ElementMapping( - element=ElementType.GARAGE_ROOF, + elementType=ElementType.GARAGE_ROOF, aspect_type=AspectType.MATERIAL, ), "EXTGARSTRF": ElementMapping( - element=ElementType.GARAGE_AND_STORE_ROOF, + elementType=ElementType.GARAGE_AND_STORE_ROOF, aspect_type=AspectType.MATERIAL, ), "EXTSTRROOF": ElementMapping( - element=ElementType.STORE_ROOF, + elementType=ElementType.STORE_ROOF, aspect_type=AspectType.MATERIAL, ), "INTLOFTINS": ElementMapping( - element=ElementType.LOFT_INSULATION, + elementType=ElementType.LOFT_INSULATION, aspect_type=AspectType.TYPE, ), # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== "INTFRDOOR": ElementMapping( - element=ElementType.EXTERNAL_DOOR, + elementType=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, ), "INTFRDRFRR": ElementMapping( - element=ElementType.EXTERNAL_DOOR, + elementType=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.FIRE_RATING, ), "EXTBKSDDR1": ElementMapping( - element=ElementType.EXTERNAL_DOOR, + elementType=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, element_instance=1, ), "EXTBKSDDR2": ElementMapping( - element=ElementType.EXTERNAL_DOOR, + elementType=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, element_instance=2, ), "INTWDWTYPE": ElementMapping( - element=ElementType.EXTERNAL_WINDOWS, + elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, ), "EXTWNDWS1": ElementMapping( - element=ElementType.EXTERNAL_WINDOWS, + elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=1, ), "EXTWNDWS2": ElementMapping( - element=ElementType.EXTERNAL_WINDOWS, + elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=2, ), "EXTGARDOOR": ElementMapping( - element=ElementType.GARAGE_DOOR, + elementType=ElementType.GARAGE_DOOR, aspect_type=AspectType.MATERIAL, ), "EXTGARSTDR": ElementMapping( - element=ElementType.GARAGE_AND_STORE_DOOR, + elementType=ElementType.GARAGE_AND_STORE_DOOR, aspect_type=AspectType.MATERIAL, ), "EXTSTRDOOR": ElementMapping( - element=ElementType.STORE_DOOR, + elementType=ElementType.STORE_DOOR, aspect_type=AspectType.MATERIAL, ), "EXTGARWDWS": ElementMapping( - element=ElementType.GARAGE_WINDOWS, + elementType=ElementType.GARAGE_WINDOWS, aspect_type=AspectType.MATERIAL, ), "EXTSTRWDWS": ElementMapping( - element=ElementType.STORE_WINDOWS, + elementType=ElementType.STORE_WINDOWS, aspect_type=AspectType.MATERIAL, ), "EXTGARSTWD": ElementMapping( - element=ElementType.GARAGE_AND_STORE_WINDOWS, + elementType=ElementType.GARAGE_AND_STORE_WINDOWS, aspect_type=AspectType.MATERIAL, ), "EXTLINTELS": ElementMapping( - element=ElementType.LINTEL, + elementType=ElementType.LINTEL, aspect_type=AspectType.PRESENCE, ), "EXTPTFRDR1": ElementMapping( - element=ElementType.PATIO_FRENCH_DOOR, + elementType=ElementType.PATIO_FRENCH_DOOR, aspect_type=AspectType.MATERIAL, element_instance=1, ), @@ -323,217 +323,217 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { # EXTERNAL AREAS # ========================================================== "EXTBALCONY": ElementMapping( - element=ElementType.PRIVATE_BALCONY, + elementType=ElementType.PRIVATE_BALCONY, aspect_type=AspectType.PRESENCE, ), "EXTBPOINTG": ElementMapping( - element=ElementType.EXTERNAL_BRICKWORK_POINTING, + elementType=ElementType.EXTERNAL_BRICKWORK_POINTING, aspect_type=AspectType.PRESENCE, ), "EXTDRPKERB": ElementMapping( - element=ElementType.DROP_KERB, + elementType=ElementType.DROP_KERB, aspect_type=AspectType.PRESENCE, ), "EXTEXTDECS": ElementMapping( - element=ElementType.EXTERNAL_DECORATION, + elementType=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.PRESENCE, ), "EXTHARDSTD": ElementMapping( - element=ElementType.PATHS_AND_HARDSTANDINGS, + elementType=ElementType.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL, ), "EXTINTDWNP": ElementMapping( - element=ElementType.INTERNAL_DOWNPIPES_EXTERNAL_AREA, + elementType=ElementType.INTERNAL_DOWNPIPES_EXTERNAL_AREA, aspect_type=AspectType.MATERIAL, ), "EXTOUTBOH": ElementMapping( - element=ElementType.OUTBUILDING_OVERHAUL, + elementType=ElementType.OUTBUILDING_OVERHAUL, aspect_type=AspectType.TYPE, ), "EXTPARKING": ElementMapping( - element=ElementType.PARKING_AREAS, + elementType=ElementType.PARKING_AREAS, aspect_type=AspectType.PRESENCE, ), "EXTPCHCNPY": ElementMapping( - element=ElementType.PORCH_CANOPY, + elementType=ElementType.PORCH_CANOPY, aspect_type=AspectType.TYPE, ), "EXTSTRINSP": ElementMapping( - element=ElementType.EXTERNAL_STRUCTURAL_DEFECTS, + elementType=ElementType.EXTERNAL_STRUCTURAL_DEFECTS, aspect_type=AspectType.TYPE, # Need more sample data to know whether this is the correct aspect type ), "INTACCRAMP": ElementMapping( - element=ElementType.ACCESS_RAMP, + elementType=ElementType.ACCESS_RAMP, aspect_type=AspectType.TYPE, # # Need more sample data to know whether this is the correct aspect type ), # ====================== # FITNESS FOR HUMAN HABITATION # ====================== "FFHHDAMP": ElementMapping( - element=ElementType.FFHH_DAMP, + elementType=ElementType.FFHH_DAMP, aspect_type=AspectType.RISK, ), "FFHHHCWAT": ElementMapping( - element=ElementType.FFHH_HOT_AND_COLD_WATER, + elementType=ElementType.FFHH_HOT_AND_COLD_WATER, aspect_type=AspectType.RISK, ), "FFHHDRNWC": ElementMapping( - element=ElementType.FFHH_DRAINAGE_LAVATORIES, + elementType=ElementType.FFHH_DRAINAGE_LAVATORIES, aspect_type=AspectType.RISK, ), "FFHHNEGLC": ElementMapping( - element=ElementType.FFHH_NEGLECTED, + elementType=ElementType.FFHH_NEGLECTED, aspect_type=AspectType.RISK, ), "FFHHNONAT": ElementMapping( - element=ElementType.FFHH_NATURAL_LIGHT, + elementType=ElementType.FFHH_NATURAL_LIGHT, aspect_type=AspectType.RISK, ), "FFHHNOVEN": ElementMapping( - element=ElementType.FFHH_VENTILATION, + elementType=ElementType.FFHH_VENTILATION, aspect_type=AspectType.RISK, ), "FFHHPRPCK": ElementMapping( - element=ElementType.FFHH_FOOD_PREP_AND_WASHUP, + elementType=ElementType.FFHH_FOOD_PREP_AND_WASHUP, aspect_type=AspectType.RISK, ), "FFHHUNLAY": ElementMapping( - element=ElementType.FFHH_UNSAFE_LAYOUT, + elementType=ElementType.FFHH_UNSAFE_LAYOUT, aspect_type=AspectType.RISK, ), "FFHHUNSTA": ElementMapping( - element=ElementType.FFHH_UNSTABLE_BUILDING, + elementType=ElementType.FFHH_UNSTABLE_BUILDING, aspect_type=AspectType.RISK, ), # ========================================================== # HHSRS # ========================================================== "HHSRSDAMP": ElementMapping( - element=ElementType.HHSRS_DAMP_AND_MOULD, + elementType=ElementType.HHSRS_DAMP_AND_MOULD, aspect_type=AspectType.RISK, ), "HHSRSCOLD": ElementMapping( - element=ElementType.HHSRS_EXCESS_COLD, + elementType=ElementType.HHSRS_EXCESS_COLD, aspect_type=AspectType.RISK, ), "HHSRSHEAT": ElementMapping( - element=ElementType.HHSRS_EXCESS_HEAT, + elementType=ElementType.HHSRS_EXCESS_HEAT, aspect_type=AspectType.RISK, ), "HHSRSASB": ElementMapping( - element=ElementType.HHSRS_ASBESTOS_AND_MMF, + elementType=ElementType.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK, ), "HHSRSBIOC": ElementMapping( - element=ElementType.HHSRS_BIOCIDES, + elementType=ElementType.HHSRS_BIOCIDES, aspect_type=AspectType.RISK, ), "HHSRSCO": ElementMapping( - element=ElementType.HHSRS_CARBON_MONOXIDE, + elementType=ElementType.HHSRS_CARBON_MONOXIDE, aspect_type=AspectType.RISK, ), "HHSRSNO2": ElementMapping( - element=ElementType.HHSRS_CARBON_MONOXIDE, + elementType=ElementType.HHSRS_CARBON_MONOXIDE, aspect_type=AspectType.RISK, ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard "HHSRSSO2": ElementMapping( - element=ElementType.HHSRS_CARBON_MONOXIDE, + elementType=ElementType.HHSRS_CARBON_MONOXIDE, aspect_type=AspectType.RISK, ), # Duplicate of HHSRSCO; I think they relate to the same HHSRS hazard "HHSRSLEAD": ElementMapping( - element=ElementType.HHSRS_LEAD, + elementType=ElementType.HHSRS_LEAD, aspect_type=AspectType.RISK, ), "HHSRSRADIA": ElementMapping( - element=ElementType.HHSRS_RADIATION, + elementType=ElementType.HHSRS_RADIATION, aspect_type=AspectType.RISK, ), "HHSRSFUEL": ElementMapping( - element=ElementType.HHSRS_UNCOMBUSTED_FUEL_GAS, + elementType=ElementType.HHSRS_UNCOMBUSTED_FUEL_GAS, aspect_type=AspectType.RISK, ), "HHSRSORGAN": ElementMapping( - element=ElementType.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, + elementType=ElementType.HHSRS_VOLATILE_ORGANIC_COMPOUNDS, aspect_type=AspectType.RISK, ), "HHSRSCROWD": ElementMapping( - element=ElementType.HHSRS_CROWDING_AND_SPACE, + elementType=ElementType.HHSRS_CROWDING_AND_SPACE, aspect_type=AspectType.RISK, ), "HHSRSENTRY": ElementMapping( - element=ElementType.HHSRS_ENTRY_BY_INTRUDERS, + elementType=ElementType.HHSRS_ENTRY_BY_INTRUDERS, aspect_type=AspectType.RISK, ), "HHSRSLIGHT": ElementMapping( - element=ElementType.HHSRS_LIGHTING, + elementType=ElementType.HHSRS_LIGHTING, aspect_type=AspectType.RISK, ), "HHSRSNOISE": ElementMapping( - element=ElementType.HHSRS_NOISE, + elementType=ElementType.HHSRS_NOISE, aspect_type=AspectType.RISK, ), "HHSRSDOMES": ElementMapping( - element=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, + elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, aspect_type=AspectType.RISK, ), "HHSRSFOOD": ElementMapping( - element=ElementType.HHSRS_FOOD_SAFETY, + elementType=ElementType.HHSRS_FOOD_SAFETY, aspect_type=AspectType.RISK, ), "HHSRSPERS": ElementMapping( - element=ElementType.HHSRS_PERSONAL_HYGIENE_SANITATION, + elementType=ElementType.HHSRS_PERSONAL_HYGIENE_SANITATION, aspect_type=AspectType.RISK, ), "HHSRSWATER": ElementMapping( - element=ElementType.HHSRS_WATER_SUPPLY, + elementType=ElementType.HHSRS_WATER_SUPPLY, aspect_type=AspectType.RISK, ), "HHSRSFBATH": ElementMapping( - element=ElementType.HHSRS_FALLS_ASSOCIATED_WITH_BATHS, + elementType=ElementType.HHSRS_FALLS_ASSOCIATED_WITH_BATHS, aspect_type=AspectType.RISK, ), "HHSRSFLEVE": ElementMapping( - element=ElementType.HHSRS_FALLS_ON_LEVEL_SURFACES, + elementType=ElementType.HHSRS_FALLS_ON_LEVEL_SURFACES, aspect_type=AspectType.RISK, ), "HHSRSFSTAI": ElementMapping( - element=ElementType.HHSRS_FALLS_ON_STAIRS, + elementType=ElementType.HHSRS_FALLS_ON_STAIRS, aspect_type=AspectType.RISK, ), "HHSRSFBETW": ElementMapping( - element=ElementType.HHSRS_FALLS_BETWEEN_LEVELS, + elementType=ElementType.HHSRS_FALLS_BETWEEN_LEVELS, aspect_type=AspectType.RISK, ), "HHSRSELEC": ElementMapping( - element=ElementType.HHSRS_ELECTRICAL_HAZARDS, + elementType=ElementType.HHSRS_ELECTRICAL_HAZARDS, aspect_type=AspectType.RISK, ), "HHSRSFIRE": ElementMapping( - element=ElementType.HHSRS_FIRE, + elementType=ElementType.HHSRS_FIRE, aspect_type=AspectType.RISK, ), "HHSRSFLAME": ElementMapping( - element=ElementType.HHSRS_FLAMES_HOT_SURFACES, + elementType=ElementType.HHSRS_FLAMES_HOT_SURFACES, aspect_type=AspectType.RISK, ), "HHSRSENTRP": ElementMapping( - element=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT, + elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.RISK, ), "HHSRSEXPLO": ElementMapping( - element=ElementType.HHSRS_EXPLOSIONS, + elementType=ElementType.HHSRS_EXPLOSIONS, aspect_type=AspectType.RISK, ), "HHSRSSTRUC": ElementMapping( - element=ElementType.HHSRS_STRUCTURAL_COLLAPSE, + elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK, ), "HHSRSCLOW": ElementMapping( - element=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT, + elementType=ElementType.HHSRS_COLLISION_AND_ENTRAPMENT, aspect_type=AspectType.RISK, ), "HHSRSPOSI": ElementMapping( - element=ElementType.HHSRS_AMENITIES, + elementType=ElementType.HHSRS_AMENITIES, aspect_type=AspectType.RISK, ), } diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py index f11133bf..09109ef9 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py @@ -36,7 +36,7 @@ class LbwfMapper(Mapper): ) element_key = ( - element_mapping.element, + element_mapping.elementType, element_mapping.element_instance or 1, ) diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 62cb2fc3..2281a17c 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -7,660 +7,663 @@ PEABODY_ELEMENT_MAP = { # ========================================================== # PROPERTY / GENERAL # ========================================================== - (100, 1): ElementMapping(element=ElementType.PROPERTY, aspect_type=AspectType.TYPE), + (100, 1): ElementMapping( + elementType=ElementType.PROPERTY, aspect_type=AspectType.TYPE + ), # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE), # (100, 14): ElementMapping(element="property", aspect_type="construction_type"), (50, 2): ElementMapping( - element=ElementType.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE + elementType=ElementType.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE ), - (50, 3): ElementMapping(element=ElementType.CCU, aspect_type=AspectType.TYPE), + (50, 3): ElementMapping(elementType=ElementType.CCU, aspect_type=AspectType.TYPE), (50, 7): ElementMapping( - element=ElementType.DISABLED_HOIST_TRACKING, aspect_type=AspectType.PRESENCE + elementType=ElementType.DISABLED_HOIST_TRACKING, aspect_type=AspectType.PRESENCE ), (50, 11): ElementMapping( - element=ElementType.HEAT_DETECTION, aspect_type=AspectType.TYPE + elementType=ElementType.HEAT_DETECTION, aspect_type=AspectType.TYPE ), (50, 21): ElementMapping( - element=ElementType.SMOKE_DETECTION, aspect_type=AspectType.TYPE + elementType=ElementType.SMOKE_DETECTION, aspect_type=AspectType.TYPE ), (50, 22): ElementMapping( - element=ElementType.STAIRLIFT, aspect_type=AspectType.PRESENCE + elementType=ElementType.STAIRLIFT, aspect_type=AspectType.PRESENCE ), (50, 26): ElementMapping( - element=ElementType.DISABLED_FACILITIES, aspect_type=AspectType.TYPE + elementType=ElementType.DISABLED_FACILITIES, aspect_type=AspectType.TYPE ), (100, 3): ElementMapping( - element=ElementType.PROPERTY, aspect_type=AspectType.AGE_BAND + elementType=ElementType.PROPERTY, aspect_type=AspectType.AGE_BAND ), (100, 14): ElementMapping( - element=ElementType.PROPERTY, aspect_type=AspectType.CONSTRUCTION_TYPE + elementType=ElementType.PROPERTY, aspect_type=AspectType.CONSTRUCTION_TYPE ), (100, 16): ElementMapping( - element=ElementType.PROPERTY, aspect_type=AspectType.CLASSIFICATION + elementType=ElementType.PROPERTY, aspect_type=AspectType.CLASSIFICATION ), (210, 2): ElementMapping( - element=ElementType.PASSENGER_LIFT, aspect_type=AspectType.TYPE + elementType=ElementType.PASSENGER_LIFT, aspect_type=AspectType.TYPE ), # ========================================================== # EXTERNAL – WALLS # ========================================================== (50, 16): ElementMapping( - element=ElementType.PARTY_WALL_FIRE_BREAK, aspect_type=AspectType.PRESENCE + elementType=ElementType.PARTY_WALL_FIRE_BREAK, aspect_type=AspectType.PRESENCE ), (53, 1): ElementMapping( - element=ElementType.BOUNDARY_WALLS, aspect_type=AspectType.PRESENCE + elementType=ElementType.BOUNDARY_WALLS, aspect_type=AspectType.PRESENCE ), (53, 4): ElementMapping( - element=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.PRESENCE + elementType=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.PRESENCE ), (53, 5): ElementMapping( - element=ElementType.EXTERNAL_NOISE_INSULATION, aspect_type=AspectType.ADEQUACY + elementType=ElementType.EXTERNAL_NOISE_INSULATION, + aspect_type=AspectType.ADEQUACY, ), (53, 14): ElementMapping( - element=ElementType.GARAGE_WALLS, aspect_type=AspectType.MATERIAL + elementType=ElementType.GARAGE_WALLS, aspect_type=AspectType.MATERIAL ), (53, 23): ElementMapping( - element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH ), (53, 30): ElementMapping( - element=ElementType.EXTERNAL_WALL, + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, aspect_instance=2, ), (53, 36): ElementMapping( - element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION ), (53, 40): ElementMapping( - element=ElementType.SPANDREL_PANELS, aspect_type=AspectType.MATERIAL + elementType=ElementType.SPANDREL_PANELS, aspect_type=AspectType.MATERIAL ), (53, 41): ElementMapping( - element=ElementType.CLADDING, aspect_type=AspectType.MATERIAL + elementType=ElementType.CLADDING, aspect_type=AspectType.MATERIAL ), (100, 15): ElementMapping( - element=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.CONDITION + elementType=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.CONDITION ), (120, 1): ElementMapping( - element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE ), (120, 2): ElementMapping( - element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH ), (120, 3): ElementMapping( - element=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION + elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION ), # ========================================================== # EXTERNAL – ROOFS # ========================================================== (50, 15): ElementMapping( - element=ElementType.LOFT_INSULATION, + elementType=ElementType.LOFT_INSULATION, aspect_type=AspectType.TYPE, ), (53, 2): ElementMapping( - element=ElementType.CHIMNEY, + elementType=ElementType.CHIMNEY, aspect_type=AspectType.PRESENCE, ), (53, 6): ElementMapping( - element=ElementType.FASCIA_SOFFIT_BARGEBOARDS, + elementType=ElementType.FASCIA_SOFFIT_BARGEBOARDS, aspect_type=AspectType.MATERIAL, ), (53, 7): ElementMapping( - element=ElementType.FLAT_ROOF_COVERING, + elementType=ElementType.FLAT_ROOF_COVERING, aspect_type=AspectType.MATERIAL, ), (53, 13): ElementMapping( - element=ElementType.GARAGE_ROOF, + elementType=ElementType.GARAGE_ROOF, aspect_type=AspectType.MATERIAL, ), (53, 15): ElementMapping( - element=ElementType.GUTTERS, + elementType=ElementType.GUTTERS, aspect_type=AspectType.MATERIAL, ), (53, 21): ElementMapping( - element=ElementType.PITCHED_ROOF_COVERING, + elementType=ElementType.PITCHED_ROOF_COVERING, aspect_type=AspectType.MATERIAL, ), (53, 22): ElementMapping( - element=ElementType.PORCH_CANOPY, + elementType=ElementType.PORCH_CANOPY, aspect_type=AspectType.TYPE, ), (53, 47): ElementMapping( - element=ElementType.ROOF, + elementType=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, ), (110, 1): ElementMapping( - element=ElementType.ROOF, + elementType=ElementType.ROOF, aspect_type=AspectType.MATERIAL, element_instance=1, ), (110, 2): ElementMapping( - element=ElementType.ROOF, + elementType=ElementType.ROOF, aspect_type=AspectType.MATERIAL, element_instance=1, ), (110, 3): ElementMapping( - element=ElementType.CHIMNEY, + elementType=ElementType.CHIMNEY, aspect_type=AspectType.WORK_REQUIRED, ), (110, 4): ElementMapping( - element=ElementType.FASCIA, + elementType=ElementType.FASCIA, aspect_type=AspectType.MATERIAL, ), (110, 5): ElementMapping( - element=ElementType.SOFFIT, + elementType=ElementType.SOFFIT, aspect_type=AspectType.MATERIAL, ), (110, 6): ElementMapping( - element=ElementType.RAINWATER_GOODS, + elementType=ElementType.RAINWATER_GOODS, aspect_type=AspectType.MATERIAL, ), (110, 7): ElementMapping( - element=ElementType.LOFT_INSULATION, + elementType=ElementType.LOFT_INSULATION, aspect_type=AspectType.WORK_REQUIRED, # possibly not the right aspect type ), (110, 8): ElementMapping( - element=ElementType.PORCH_CANOPY, + elementType=ElementType.PORCH_CANOPY, aspect_type=AspectType.MATERIAL, ), # ========================================================== # EXTERNAL – DOORS & WINDOWS # ========================================================== (50, 8): ElementMapping( - element=ElementType.DOOR_ENTRY_HANDSET, + elementType=ElementType.DOOR_ENTRY_HANDSET, aspect_type=AspectType.PRESENCE, ), (53, 8): ElementMapping( - element=ElementType.FRONT_DOOR, + elementType=ElementType.FRONT_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 12): ElementMapping( - element=ElementType.GARAGE_DOOR, + elementType=ElementType.GARAGE_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 16): ElementMapping( - element=ElementType.LINTEL, + elementType=ElementType.LINTEL, aspect_type=AspectType.PRESENCE, ), (53, 19): ElementMapping( - element=ElementType.PATIO_FRENCH_DOOR, + elementType=ElementType.PATIO_FRENCH_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 25): ElementMapping( - element=ElementType.REAR_DOOR, + elementType=ElementType.REAR_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 29): ElementMapping( - element=ElementType.SECONDARY_GLAZING, + elementType=ElementType.SECONDARY_GLAZING, aspect_type=AspectType.PRESENCE, ), (53, 35): ElementMapping( - element=ElementType.STORE_DOOR, + elementType=ElementType.STORE_DOOR, aspect_type=AspectType.MATERIAL, ), (53, 38): ElementMapping( - element=ElementType.EXTERNAL_WINDOWS, + elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=1, ), (53, 39): ElementMapping( - element=ElementType.EXTERNAL_WINDOWS, + elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, element_instance=2, ), (53, 43): ElementMapping( - element=ElementType.FRONT_DOOR, + elementType=ElementType.FRONT_DOOR, aspect_type=AspectType.TYPE, ), (130, 1): ElementMapping( - element=ElementType.EXTERNAL_WINDOWS, + elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.MATERIAL, ), (130, 2): ElementMapping( - element=ElementType.COMMUNAL_WINDOWS, + elementType=ElementType.COMMUNAL_WINDOWS, aspect_type=AspectType.MATERIAL, ), (140, 1): ElementMapping( - element=ElementType.MAIN_DOOR, + elementType=ElementType.MAIN_DOOR, aspect_type=AspectType.MATERIAL, ), (140, 2): ElementMapping( - element=ElementType.STORE_DOOR, + elementType=ElementType.STORE_DOOR, aspect_type=AspectType.MATERIAL, ), # Duplicate of (53, 35) (140, 3): ElementMapping( - element=ElementType.GARAGE_DOOR, + elementType=ElementType.GARAGE_DOOR, aspect_type=AspectType.MATERIAL, ), # Duplicate of (53, 12) (140, 4): ElementMapping( - element=ElementType.BLOCK_ENTRANCE_DOOR, + elementType=ElementType.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL, ), # ========================================================== # EXTERNAL AREAS # ========================================================== (53, 3): ElementMapping( - element=ElementType.DOWNPIPES, + elementType=ElementType.DOWNPIPES, aspect_type=AspectType.MATERIAL, ), (53, 9): ElementMapping( - element=ElementType.FRONT_FENCING, + elementType=ElementType.FRONT_FENCING, aspect_type=AspectType.MATERIAL, ), (53, 10): ElementMapping( - element=ElementType.FRONT_GATE, + elementType=ElementType.FRONT_GATE, aspect_type=AspectType.TYPE, ), (53, 17): ElementMapping( - element=ElementType.PARKING_AREAS, + elementType=ElementType.PARKING_AREAS, aspect_type=AspectType.MATERIAL, ), (53, 18): ElementMapping( - element=ElementType.PATHS_AND_HARDSTANDINGS, + elementType=ElementType.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL, ), (53, 24): ElementMapping( - element=ElementType.PRIVATE_BALCONY, + elementType=ElementType.PRIVATE_BALCONY, aspect_type=AspectType.PRESENCE, ), (53, 26): ElementMapping( - element=ElementType.REAR_FENCING, + elementType=ElementType.REAR_FENCING, aspect_type=AspectType.MATERIAL, ), (53, 27): ElementMapping( - element=ElementType.REAR_GATE, + elementType=ElementType.REAR_GATE, aspect_type=AspectType.TYPE, ), (53, 28): ElementMapping( - element=ElementType.RETAINING_WALLS, + elementType=ElementType.RETAINING_WALLS, aspect_type=AspectType.PRESENCE, ), (53, 31): ElementMapping( - element=ElementType.SIDE_FENCING, + elementType=ElementType.SIDE_FENCING, aspect_type=AspectType.MATERIAL, ), (53, 32): ElementMapping( - element=ElementType.SOIL_AND_VENT, + elementType=ElementType.SOIL_AND_VENT, aspect_type=AspectType.MATERIAL, ), (53, 34): ElementMapping( - element=ElementType.SOLAR_THERMALS, + elementType=ElementType.SOLAR_THERMALS, aspect_type=AspectType.PRESENCE, ), (53, 44): ElementMapping( - element=ElementType.GARAGE_STRUCTURE, + elementType=ElementType.GARAGE_STRUCTURE, aspect_type=AspectType.TYPE, ), (53, 45): ElementMapping( - element=ElementType.BALCONY_BALUSTRADE, + elementType=ElementType.BALCONY_BALUSTRADE, aspect_type=AspectType.MATERIAL, ), (150, 1): ElementMapping( - element=ElementType.BLOCK_ENTRANCE_DOOR, + elementType=ElementType.BLOCK_ENTRANCE_DOOR, aspect_type=AspectType.MATERIAL, ), (150, 2): ElementMapping( - element=ElementType.PATHS_AND_HARDSTANDINGS, + elementType=ElementType.PATHS_AND_HARDSTANDINGS, aspect_type=AspectType.MATERIAL, ), # Duplicate of (53, 18) - correct? (150, 3): ElementMapping( - element=ElementType.ROADS, + elementType=ElementType.ROADS, aspect_type=AspectType.MATERIAL, ), (150, 4): ElementMapping( - element=ElementType.BOUNDARY_WALLS, + elementType=ElementType.BOUNDARY_WALLS, aspect_type=AspectType.MATERIAL, ), (150, 5): ElementMapping( - element=ElementType.OUTBUILDINGS, + elementType=ElementType.OUTBUILDINGS, aspect_type=AspectType.TYPE, ), (150, 6): ElementMapping( - element=ElementType.GARAGE_STRUCTURE, + elementType=ElementType.GARAGE_STRUCTURE, aspect_type=AspectType.TYPE, ), # ========================================================== # INTERNAL – BATHROOMS & KITCHENS # ========================================================== (50, 1): ElementMapping( - element=ElementType.SECONDARY_TOILET, + elementType=ElementType.SECONDARY_TOILET, aspect_type=AspectType.PRESENCE, ), (50, 9): ElementMapping( - element=ElementType.BATHROOM_EXTRACTOR_FAN, + elementType=ElementType.BATHROOM_EXTRACTOR_FAN, aspect_type=AspectType.PRESENCE, ), (50, 9): ElementMapping( - element=ElementType.KITCHEN, + elementType=ElementType.KITCHEN, aspect_type=AspectType.TYPE, ), (50, 10): ElementMapping( - element=ElementType.KITCHEN_EXTRACTOR_FAN, + elementType=ElementType.KITCHEN_EXTRACTOR_FAN, aspect_type=AspectType.PRESENCE, ), (50, 13): ElementMapping( - element=ElementType.KITCHEN_SPACE_LAYOUT, + elementType=ElementType.KITCHEN_SPACE_LAYOUT, aspect_type=AspectType.ADEQUACY, ), (50, 14): ElementMapping( - element=ElementType.KITCHEN, + elementType=ElementType.KITCHEN, aspect_type=AspectType.TYPE, ), (50, 17): ElementMapping( - element=ElementType.BATHROOM, + elementType=ElementType.BATHROOM, aspect_type=AspectType.LOCATION, ), (50, 18): ElementMapping( - element=ElementType.BATHROOM, + elementType=ElementType.BATHROOM, aspect_type=AspectType.TYPE, ), # Actually "Primary bathroom type" - ok like this? (50, 20): ElementMapping( - element=ElementType.BATHROOM, + elementType=ElementType.BATHROOM, aspect_type=AspectType.TYPE, element_instance=2, ), # Actually "Secondary bathroom type" - ok like this? (160, 1): ElementMapping( - element=ElementType.KITCHEN, + elementType=ElementType.KITCHEN, aspect_type=AspectType.CONDITION, ), (160, 2): ElementMapping( - element=ElementType.KITCHEN_SPACE_LAYOUT, + elementType=ElementType.KITCHEN_SPACE_LAYOUT, aspect_type=AspectType.ADEQUACY, ), (190, 1): ElementMapping( - element=ElementType.BATHROOM, + elementType=ElementType.BATHROOM, aspect_type=AspectType.CONDITION, ), (190, 2): ElementMapping( - element=ElementType.SECONDARY_TOILET, + elementType=ElementType.SECONDARY_TOILET, aspect_type=AspectType.TYPE, ), # ========================================================== # COMMUNAL # ========================================================== (51, 1): ElementMapping( - element=ElementType.COMMUNAL_AERIAL, + elementType=ElementType.COMMUNAL_AERIAL, aspect_type=AspectType.PRESENCE, ), (51, 2): ElementMapping( - element=ElementType.COMMUNAL_AOV, + elementType=ElementType.COMMUNAL_AOV, aspect_type=AspectType.PRESENCE, ), (51, 3): ElementMapping( - element=ElementType.COMMUNAL_BALCONY_WALKWAY, + elementType=ElementType.COMMUNAL_BALCONY_WALKWAY, aspect_type=AspectType.PRESENCE, ), (51, 4): ElementMapping( - element=ElementType.COMMUNAL_BATHROOM, + elementType=ElementType.COMMUNAL_BATHROOM, aspect_type=AspectType.TYPE, ), (51, 5): ElementMapping( - element=ElementType.COMMUNAL_BIN_STORE_DOORS, + elementType=ElementType.COMMUNAL_BIN_STORE_DOORS, aspect_type=AspectType.PRESENCE, ), (51, 6): ElementMapping( - element=ElementType.COMMUNAL_BIN_STORE_ROOF, + elementType=ElementType.COMMUNAL_BIN_STORE_ROOF, aspect_type=AspectType.PRESENCE, ), (51, 7): ElementMapping( - element=ElementType.COMMUNAL_BIN_STORE_WALLS, + elementType=ElementType.COMMUNAL_BIN_STORE_WALLS, aspect_type=AspectType.MATERIAL, ), (51, 8): ElementMapping( - element=ElementType.COMMUNAL_BMS, + elementType=ElementType.COMMUNAL_BMS, aspect_type=AspectType.PRESENCE, ), (51, 9): ElementMapping( - element=ElementType.COMMUNAL_BOILER, + elementType=ElementType.COMMUNAL_BOILER, aspect_type=AspectType.TYPE, ), (51, 10): ElementMapping( - element=ElementType.COMMUNAL_BOOSTER_PUMP, + elementType=ElementType.COMMUNAL_BOOSTER_PUMP, aspect_type=AspectType.PRESENCE, ), (51, 11): ElementMapping( - element=ElementType.COMMUNAL_CCTV, + elementType=ElementType.COMMUNAL_CCTV, aspect_type=AspectType.PRESENCE, ), (51, 12): ElementMapping( - element=ElementType.COMMUNAL_CIRCULATION_SPACE, + elementType=ElementType.COMMUNAL_CIRCULATION_SPACE, aspect_type=AspectType.ADEQUACY, ), (51, 13): ElementMapping( - element=ElementType.COMMUNAL_COLD_WATER_STORAGE, + elementType=ElementType.COMMUNAL_COLD_WATER_STORAGE, aspect_type=AspectType.PRESENCE, ), (51, 14): ElementMapping( - element=ElementType.COMMUNAL_DOOR_ENTRY, + elementType=ElementType.COMMUNAL_DOOR_ENTRY, aspect_type=AspectType.SYSTEM, ), (51, 15): ElementMapping( - element=ElementType.COMMUNAL_DRY_RISER, + elementType=ElementType.COMMUNAL_DRY_RISER, aspect_type=AspectType.PRESENCE, ), (51, 16): ElementMapping( - element=ElementType.COMMUNAL_EMERGENCY_LIGHTING, + elementType=ElementType.COMMUNAL_EMERGENCY_LIGHTING, aspect_type=AspectType.PRESENCE, ), (51, 17): ElementMapping( - element=ElementType.COMMUNAL_EXTERNAL_DOORS, + elementType=ElementType.COMMUNAL_EXTERNAL_DOORS, aspect_type=AspectType.MATERIAL, ), (51, 19): ElementMapping( - element=ElementType.COMMUNAL_FIRE_ALARM, + elementType=ElementType.COMMUNAL_FIRE_ALARM, aspect_type=AspectType.TYPE, ), (51, 20): ElementMapping( - element=ElementType.COMMUNAL_INTERNAL_DECORATIONS, + elementType=ElementType.COMMUNAL_INTERNAL_DECORATIONS, aspect_type=AspectType.PRESENCE, ), (51, 21): ElementMapping( - element=ElementType.COMMUNAL_INTERNAL_DOORS, + elementType=ElementType.COMMUNAL_INTERNAL_DOORS, aspect_type=AspectType.MATERIAL, ), (51, 22): ElementMapping( - element=ElementType.COMMUNAL_INTERNAL_FLOOR, + elementType=ElementType.COMMUNAL_INTERNAL_FLOOR, aspect_type=AspectType.FINISH, ), (51, 23): ElementMapping( - element=ElementType.COMMUNAL_KITCHEN, + elementType=ElementType.COMMUNAL_KITCHEN, aspect_type=AspectType.TYPE, ), (51, 24): ElementMapping( - element=ElementType.COMMUNAL_LATERAL_MAINS, + elementType=ElementType.COMMUNAL_LATERAL_MAINS, aspect_type=AspectType.PRESENCE, ), (51, 25): ElementMapping( - element=ElementType.COMMUNAL_LIGHTING, + elementType=ElementType.COMMUNAL_LIGHTING, aspect_type=AspectType.PRESENCE, ), (51, 26): ElementMapping( - element=ElementType.COMMUNAL_LIGHTING_CONDUCTOR, + elementType=ElementType.COMMUNAL_LIGHTING_CONDUCTOR, aspect_type=AspectType.PRESENCE, ), (51, 27): ElementMapping( - element=ElementType.COMMUNAL_PASSENGER_LIFT, + elementType=ElementType.COMMUNAL_PASSENGER_LIFT, aspect_type=AspectType.TYPE, ), (51, 28): ElementMapping( - element=ElementType.COMMUNAL_ENTRANCE, + elementType=ElementType.COMMUNAL_ENTRANCE, aspect_type=AspectType.MATERIAL, element_instance=1, ), (51, 30): ElementMapping( - element=ElementType.COMMUNAL_ENTRANCE, + elementType=ElementType.COMMUNAL_ENTRANCE, aspect_type=AspectType.FINISH, element_instance=2, ), (51, 31): ElementMapping( - element=ElementType.COMMUNAL_SPRINKLER, + elementType=ElementType.COMMUNAL_SPRINKLER, aspect_type=AspectType.PRESENCE, ), (51, 29): ElementMapping( - element=ElementType.COMMUNAL_REFUSE_CHUTE, + elementType=ElementType.COMMUNAL_REFUSE_CHUTE, aspect_type=AspectType.PRESENCE, ), (51, 32): ElementMapping( - element=ElementType.COMMUNAL_STAIRS, + elementType=ElementType.COMMUNAL_STAIRS, aspect_type=AspectType.FINISH, ), (51, 33): ElementMapping( - element=ElementType.COMMUNAL_STORE_DOORS, + elementType=ElementType.COMMUNAL_STORE_DOORS, aspect_type=AspectType.MATERIAL, ), (51, 34): ElementMapping( - element=ElementType.COMMUNAL_STORE_ROOF, + elementType=ElementType.COMMUNAL_STORE_ROOF, aspect_type=AspectType.MATERIAL, ), (51, 35): ElementMapping( - element=ElementType.COMMUNAL_STORE_WALLS, + elementType=ElementType.COMMUNAL_STORE_WALLS, aspect_type=AspectType.MATERIAL, ), (51, 36): ElementMapping( - element=ElementType.COMMUNAL_WALKWAYS, + elementType=ElementType.COMMUNAL_WALKWAYS, aspect_type=AspectType.FINISH, ), (51, 37): ElementMapping( - element=ElementType.COMMUNAL_WARDEN_CALL_SYSTEM, + elementType=ElementType.COMMUNAL_WARDEN_CALL_SYSTEM, aspect_type=AspectType.PRESENCE, ), (51, 38): ElementMapping( - element=ElementType.COMMUNAL_TOILETS, + elementType=ElementType.COMMUNAL_TOILETS, aspect_type=AspectType.TYPE, ), (51, 39): ElementMapping( - element=ElementType.COMMUNAL_WET_RISER, + elementType=ElementType.COMMUNAL_WET_RISER, aspect_type=AspectType.PRESENCE, ), (51, 40): ElementMapping( - element=ElementType.COMMUNAL_PLUG_SOCKETS, + elementType=ElementType.COMMUNAL_PLUG_SOCKETS, aspect_type=AspectType.PRESENCE, ), (200, 1): ElementMapping( - element=ElementType.COMMUNAL_BOILER, + elementType=ElementType.COMMUNAL_BOILER, aspect_type=AspectType.TYPE, ), # Duplicate of (51, 9) - correct? (200, 2): ElementMapping( - element=ElementType.COMMUNAL_HEATING, + elementType=ElementType.COMMUNAL_HEATING, aspect_type=AspectType.TYPE, ), (200, 3): ElementMapping( - element=ElementType.COMMUNAL_ELECTRICS, + elementType=ElementType.COMMUNAL_ELECTRICS, aspect_type=AspectType.TYPE, ), (200, 4): ElementMapping( - element=ElementType.COMMUNAL_FIRE_ALARM, + elementType=ElementType.COMMUNAL_FIRE_ALARM, aspect_type=AspectType.TYPE, ), (200, 5): ElementMapping( - element=ElementType.COMMUNAL_LIFT, + elementType=ElementType.COMMUNAL_LIFT, aspect_type=AspectType.TYPE, ), (200, 6): ElementMapping( - element=ElementType.COMMUNAL_FLOOR_COVERING, + elementType=ElementType.COMMUNAL_FLOOR_COVERING, aspect_type=AspectType.MATERIAL, ), (200, 7): ElementMapping( - element=ElementType.COMMUNAL_KITCHEN, + elementType=ElementType.COMMUNAL_KITCHEN, aspect_type=AspectType.TYPE, ), (200, 8): ElementMapping( - element=ElementType.COMMUNAL_BATHROOM, + elementType=ElementType.COMMUNAL_BATHROOM, aspect_type=AspectType.TYPE, ), # Duplicate of (51, 4) - correct? (200, 9): ElementMapping( - element=ElementType.COMMUNAL_TOILETS, + elementType=ElementType.COMMUNAL_TOILETS, aspect_type=AspectType.TYPE, ), # Duplicate of (51, 38) - correct? (200, 10): ElementMapping( - element=ElementType.COMMUNAL_GATES, + elementType=ElementType.COMMUNAL_GATES, aspect_type=AspectType.TYPE, ), # ========================================================== # INTERNAL – HEATING # ========================================================== (50, 4): ElementMapping( - element=ElementType.HEATING_BOILER, + elementType=ElementType.HEATING_BOILER, aspect_type=AspectType.PRESENCE, ), # This is actually "Central heating boiler" - ok like this? (50, 5): ElementMapping( - element=ElementType.CENTRAL_HEATING, + elementType=ElementType.CENTRAL_HEATING, aspect_type=AspectType.EXTENT, ), (50, 6): ElementMapping( - element=ElementType.COLD_WATER_STORAGE, + elementType=ElementType.COLD_WATER_STORAGE, aspect_type=AspectType.PRESENCE, ), (50, 12): ElementMapping( - element=ElementType.HEATING_DISTRIBUTION, + elementType=ElementType.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE, ), (50, 19): ElementMapping( - element=ElementType.PROGRAMMABLE_HEATING, + elementType=ElementType.PROGRAMMABLE_HEATING, aspect_type=AspectType.TYPE, ), (50, 25): ElementMapping( - element=ElementType.HEATING_BOILER, + elementType=ElementType.HEATING_BOILER, aspect_type=AspectType.TYPE, ), (170, 1): ElementMapping( - element=ElementType.HEATING_BOILER, + elementType=ElementType.HEATING_BOILER, aspect_type=AspectType.TYPE, ), # Duplicate of (50,25) - correct? (170, 2): ElementMapping( - element=ElementType.HEATING_DISTRIBUTION, + elementType=ElementType.HEATING_DISTRIBUTION, aspect_type=AspectType.TYPE, ), # Duplicate of (50,12) - correct? (170, 3): ElementMapping( - element=ElementType.SECONDARY_HEATING, + elementType=ElementType.SECONDARY_HEATING, aspect_type=AspectType.TYPE, ), (170, 4): ElementMapping( - element=ElementType.COLD_WATER_STORAGE, + elementType=ElementType.COLD_WATER_STORAGE, aspect_type=AspectType.TYPE, ), (170, 5): ElementMapping( - element=ElementType.HOT_WATER_SYSTEM, + elementType=ElementType.HOT_WATER_SYSTEM, aspect_type=AspectType.TYPE, ), # ========================================================== # ELECTRICS # ========================================================== (50, 24): ElementMapping( - element=ElementType.INTERNAL_WIRING, + elementType=ElementType.INTERNAL_WIRING, aspect_type=AspectType.MATERIAL, ), (180, 1): ElementMapping( - element=ElementType.ELECTRICAL_WIRING, + elementType=ElementType.ELECTRICAL_WIRING, aspect_type=AspectType.WORK_REQUIRED, ), # Not certain about the AspectType - only example in the sample data is "Full Rewire" (180, 2): ElementMapping( - element=ElementType.CONSUMER_UNIT, + elementType=ElementType.CONSUMER_UNIT, aspect_type=AspectType.TYPE, ), (180, 3): ElementMapping( - element=ElementType.SMOKE_DETECTION, + elementType=ElementType.SMOKE_DETECTION, aspect_type=AspectType.TYPE, ), # Duplicate of (50, 21) - correct? (180, 4): ElementMapping( - element=ElementType.CARBON_MONOXIDE_DETECTION, + elementType=ElementType.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE, ), # Duplicate of (50, 2) - correct? # ========================================================== # HHSRS # ========================================================== (54, 1): ElementMapping( - element=ElementType.HHSRS_DAMP_AND_MOULD, + elementType=ElementType.HHSRS_DAMP_AND_MOULD, aspect_type=AspectType.RISK, ), (54, 4): ElementMapping( - element=ElementType.HHSRS_ASBESTOS_AND_MMF, + elementType=ElementType.HHSRS_ASBESTOS_AND_MMF, aspect_type=AspectType.RISK, ), (54, 15): ElementMapping( - element=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, + elementType=ElementType.HHSRS_DOMESTIC_HYGIENE_PESTS_REFUSE, aspect_type=AspectType.RISK, ), (54, 29): ElementMapping( - element=ElementType.HHSRS_STRUCTURAL_COLLAPSE, + elementType=ElementType.HHSRS_STRUCTURAL_COLLAPSE, aspect_type=AspectType.RISK, ), } diff --git a/backend/condition/domain/mapping/peabody/peabody_mapper.py b/backend/condition/domain/mapping/peabody/peabody_mapper.py index 184b2898..92f1687f 100644 --- a/backend/condition/domain/mapping/peabody/peabody_mapper.py +++ b/backend/condition/domain/mapping/peabody/peabody_mapper.py @@ -37,7 +37,7 @@ class PeabodyMapper(Mapper): ) element_key = ( - element_mapping.element, + element_mapping.elementType, element_mapping.element_instance or 1, ) From 0d9ee79c40a60a32f94fba86c634a4bc13a64e6e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 16:25:21 +0000 Subject: [PATCH 72/74] adjust some element mappings for consistency between systems --- .../domain/mapping/lbwf/lbwf_element_map.py | 26 +++--- .../mapping/peabody/peabody_element_map.py | 80 ++++++++++++------- 2 files changed, 61 insertions(+), 45 deletions(-) diff --git a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py index a547fe5c..bf54c5bb 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_element_map.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_element_map.py @@ -165,17 +165,14 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { "EXTWALLSTR": ElementMapping( elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE, - element_instance=1, ), "EXTWALLFN1": ElementMapping( elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, - element_instance=1, ), "EXTWALLFN2": ElementMapping( elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH, - element_instance=1, aspect_instance=2, ), "EXTWALLINS": ElementMapping( @@ -200,32 +197,30 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { "EXTRFSTR1": ElementMapping( elementType=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, - element_instance=1, ), "EXTRFSTR2": ElementMapping( elementType=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, - element_instance=2, + aspect_instance=2, ), "EXTRFSTR3": ElementMapping( elementType=ElementType.ROOF, aspect_type=AspectType.STRUCTURE, - element_instance=3, + aspect_instance=3, ), "EXTROOF1": ElementMapping( elementType=ElementType.ROOF, - aspect_type=AspectType.COVERING, - element_instance=1, + aspect_type=AspectType.MATERIAL, ), "EXTROOF2": ElementMapping( elementType=ElementType.ROOF, - aspect_type=AspectType.COVERING, - element_instance=2, + aspect_type=AspectType.MATERIAL, + aspect_instance=2, ), "EXTROOF3": ElementMapping( elementType=ElementType.ROOF, - aspect_type=AspectType.COVERING, - element_instance=3, + aspect_type=AspectType.MATERIAL, + aspect_instance=3, ), "EXTCHIMNEY": ElementMapping( elementType=ElementType.CHIMNEY, @@ -265,12 +260,11 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { "EXTBKSDDR1": ElementMapping( elementType=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, - element_instance=1, ), "EXTBKSDDR2": ElementMapping( elementType=ElementType.EXTERNAL_DOOR, aspect_type=AspectType.TYPE, - element_instance=2, + aspect_instance=2, ), "INTWDWTYPE": ElementMapping( elementType=ElementType.EXTERNAL_WINDOWS, @@ -279,12 +273,11 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { "EXTWNDWS1": ElementMapping( elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, - element_instance=1, ), "EXTWNDWS2": ElementMapping( elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, - element_instance=2, + aspect_instance=2, ), "EXTGARDOOR": ElementMapping( elementType=ElementType.GARAGE_DOOR, @@ -317,7 +310,6 @@ LBWF_ELEMENT_MAP: dict[str, ElementMapping] = { "EXTPTFRDR1": ElementMapping( elementType=ElementType.PATIO_FRENCH_DOOR, aspect_type=AspectType.MATERIAL, - element_instance=1, ), # ========================================================== # EXTERNAL AREAS diff --git a/backend/condition/domain/mapping/peabody/peabody_element_map.py b/backend/condition/domain/mapping/peabody/peabody_element_map.py index 2281a17c..ce344b9a 100644 --- a/backend/condition/domain/mapping/peabody/peabody_element_map.py +++ b/backend/condition/domain/mapping/peabody/peabody_element_map.py @@ -8,62 +8,81 @@ PEABODY_ELEMENT_MAP = { # PROPERTY / GENERAL # ========================================================== (100, 1): ElementMapping( - elementType=ElementType.PROPERTY, aspect_type=AspectType.TYPE + elementType=ElementType.PROPERTY, + aspect_type=AspectType.TYPE, ), # (100, 3): ElementMapping(element=Element.PROPERTY, aspect_type=AspectType.AGE), # (100, 14): ElementMapping(element="property", aspect_type="construction_type"), (50, 2): ElementMapping( - elementType=ElementType.CARBON_MONOXIDE_DETECTION, aspect_type=AspectType.TYPE + elementType=ElementType.CARBON_MONOXIDE_DETECTION, + aspect_type=AspectType.TYPE, + ), + (50, 3): ElementMapping( + elementType=ElementType.CCU, + aspect_type=AspectType.TYPE, ), - (50, 3): ElementMapping(elementType=ElementType.CCU, aspect_type=AspectType.TYPE), (50, 7): ElementMapping( - elementType=ElementType.DISABLED_HOIST_TRACKING, aspect_type=AspectType.PRESENCE + elementType=ElementType.DISABLED_HOIST_TRACKING, + aspect_type=AspectType.PRESENCE, ), (50, 11): ElementMapping( - elementType=ElementType.HEAT_DETECTION, aspect_type=AspectType.TYPE + elementType=ElementType.HEAT_DETECTION, + aspect_type=AspectType.TYPE, ), (50, 21): ElementMapping( - elementType=ElementType.SMOKE_DETECTION, aspect_type=AspectType.TYPE + elementType=ElementType.SMOKE_DETECTION, + aspect_type=AspectType.TYPE, ), (50, 22): ElementMapping( - elementType=ElementType.STAIRLIFT, aspect_type=AspectType.PRESENCE + elementType=ElementType.STAIRLIFT, + aspect_type=AspectType.PRESENCE, ), (50, 26): ElementMapping( - elementType=ElementType.DISABLED_FACILITIES, aspect_type=AspectType.TYPE + elementType=ElementType.DISABLED_FACILITIES, + aspect_type=AspectType.TYPE, ), (100, 3): ElementMapping( - elementType=ElementType.PROPERTY, aspect_type=AspectType.AGE_BAND + elementType=ElementType.PROPERTY, + aspect_type=AspectType.AGE_BAND, ), (100, 14): ElementMapping( - elementType=ElementType.PROPERTY, aspect_type=AspectType.CONSTRUCTION_TYPE + elementType=ElementType.PROPERTY, + aspect_type=AspectType.CONSTRUCTION_TYPE, ), (100, 16): ElementMapping( - elementType=ElementType.PROPERTY, aspect_type=AspectType.CLASSIFICATION + elementType=ElementType.PROPERTY, + aspect_type=AspectType.CLASSIFICATION, ), (210, 2): ElementMapping( - elementType=ElementType.PASSENGER_LIFT, aspect_type=AspectType.TYPE + elementType=ElementType.PASSENGER_LIFT, + aspect_type=AspectType.TYPE, ), # ========================================================== # EXTERNAL – WALLS # ========================================================== (50, 16): ElementMapping( - elementType=ElementType.PARTY_WALL_FIRE_BREAK, aspect_type=AspectType.PRESENCE + elementType=ElementType.PARTY_WALL_FIRE_BREAK, + aspect_type=AspectType.PRESENCE, ), (53, 1): ElementMapping( - elementType=ElementType.BOUNDARY_WALLS, aspect_type=AspectType.PRESENCE + elementType=ElementType.BOUNDARY_WALLS, + aspect_type=AspectType.PRESENCE, ), (53, 4): ElementMapping( - elementType=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.PRESENCE + elementType=ElementType.EXTERNAL_DECORATION, + aspect_type=AspectType.PRESENCE, ), (53, 5): ElementMapping( elementType=ElementType.EXTERNAL_NOISE_INSULATION, aspect_type=AspectType.ADEQUACY, ), (53, 14): ElementMapping( - elementType=ElementType.GARAGE_WALLS, aspect_type=AspectType.MATERIAL + elementType=ElementType.GARAGE_WALLS, + aspect_type=AspectType.MATERIAL, ), (53, 23): ElementMapping( - elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, ), (53, 30): ElementMapping( elementType=ElementType.EXTERNAL_WALL, @@ -71,25 +90,32 @@ PEABODY_ELEMENT_MAP = { aspect_instance=2, ), (53, 36): ElementMapping( - elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.INSULATION, ), (53, 40): ElementMapping( - elementType=ElementType.SPANDREL_PANELS, aspect_type=AspectType.MATERIAL + elementType=ElementType.SPANDREL_PANELS, + aspect_type=AspectType.MATERIAL, ), (53, 41): ElementMapping( - elementType=ElementType.CLADDING, aspect_type=AspectType.MATERIAL + elementType=ElementType.CLADDING, + aspect_type=AspectType.MATERIAL, ), (100, 15): ElementMapping( - elementType=ElementType.EXTERNAL_DECORATION, aspect_type=AspectType.CONDITION + elementType=ElementType.EXTERNAL_DECORATION, + aspect_type=AspectType.CONDITION, ), (120, 1): ElementMapping( - elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.STRUCTURE + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.STRUCTURE, ), (120, 2): ElementMapping( - elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.FINISH + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.FINISH, ), (120, 3): ElementMapping( - elementType=ElementType.EXTERNAL_WALL, aspect_type=AspectType.INSULATION + elementType=ElementType.EXTERNAL_WALL, + aspect_type=AspectType.INSULATION, ), # ========================================================== # EXTERNAL – ROOFS @@ -133,12 +159,11 @@ PEABODY_ELEMENT_MAP = { (110, 1): ElementMapping( elementType=ElementType.ROOF, aspect_type=AspectType.MATERIAL, - element_instance=1, ), (110, 2): ElementMapping( elementType=ElementType.ROOF, aspect_type=AspectType.MATERIAL, - element_instance=1, + aspect_instance=1, ), (110, 3): ElementMapping( elementType=ElementType.CHIMNEY, @@ -202,12 +227,11 @@ PEABODY_ELEMENT_MAP = { (53, 38): ElementMapping( elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, - element_instance=1, ), (53, 39): ElementMapping( elementType=ElementType.EXTERNAL_WINDOWS, aspect_type=AspectType.TYPE, - element_instance=2, + aspect_instance=2, ), (53, 43): ElementMapping( elementType=ElementType.FRONT_DOOR, From 60241f947e6991559d7d4f2adfc929dc2becc02a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 27 Jan 2026 16:57:59 +0000 Subject: [PATCH 73/74] add readme --- backend/condition/README.md | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 backend/condition/README.md diff --git a/backend/condition/README.md b/backend/condition/README.md new file mode 100644 index 00000000..140d4585 --- /dev/null +++ b/backend/condition/README.md @@ -0,0 +1,75 @@ +# Condition Data Processor + +The Condition Data Processor performs the following steps: + +- **Extract** + - Ingest client Condition Survey data files (currently from local files; future support planned for S3 and internal survey sources) + - Parse input files into Data Transfer Objects (DTOs) + +- **Transform** + - Map source data into the internal domain data model + +- **Load** + - Persist transformed data into the ARA database (not yet implemented) + +The processor currently supports file formats provided by **Peabody** and **LBWF**. + +--- + +## Running Locally + +The `local_runner` script allows the processor to be executed in a local environment. + +1. Copy a sample input file into the `sample_data/` directory. +2. Update `local_runner.py` as required, specifically the definitions of: + - `lbwf_path` + - `peabody_path` + - `file_paths` +3. Run `local_runner.py`. + Breakpoints may be added and the script run in debug mode if required. + +--- + +## Known Data Issues + +Some inconsistencies exist in the source datasets, primarily involving multiple representations of the same logical element within a single file. In these cases, assumptions have been made in order to normalise the data into the internal domain model. + +### Peabody Data – Wall Finish Mapping + +In the original Peabody sample dataset, multiple Element/Sub-Element combinations correspond to wall finishes: + +| Element_Code | Element | Sub_Element_Code | Sub_Element | +|--------------|----------|------------------|-----------------------| +| 53 | External | 23 | Primary Wall Finish | +| 53 | External | 30 | Secondary Wall Finish | +| 120 | WALLS | 2 | Wall Finish | + +A single property may contain records for all three combinations, and each combination may appear multiple times. + +For example, the property at **55 Burnaby Street, London** contains entries for all three of the above combinations. However, it contains only a single entry for *“WALLS: Wall structure”*, indicating that the property has only one structure rather than multiple. + +This pattern is also observed in other sampled properties. Based on this, the following assumption is applied: + +- “Secondary” refers to a secondary **finish**, not a secondary **wall**. + +As a result: +- The property is mapped to a single Wall element. +- That Wall element is assigned three Finish aspects: + - Two with `aspect_instance = 1` + - One with `aspect_instance = 2` + +This means that the combination of +`UPRN / ElementType / ElementInstance / AspectType / AspectInstance` +is **not guaranteed to be unique**. + +### LBWF Data – Wall Finish Mapping + +In the LBWF dataset, the following element codes map to wall finishes: + +- `EXTWALLFN1` +- `EXTWALLFN2` + +These are similarly mapped as multiple instances of the **Finish** aspect for a single Wall element. + +--- + From 751032e6669857ce75e6af9621a7949462a4c17d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 28 Jan 2026 09:54:33 +0000 Subject: [PATCH 74/74] fixes so it actually runs --- .../domain/mapping/lbwf/lbwf_mapper.py | 7 +++++ backend/condition/parsing/lbwf_parser.py | 31 +++++++++++-------- backend/condition/processor.py | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py index 09109ef9..60c8b1ac 100644 --- a/backend/condition/domain/mapping/lbwf/lbwf_mapper.py +++ b/backend/condition/domain/mapping/lbwf/lbwf_mapper.py @@ -29,8 +29,15 @@ class LbwfMapper(Mapper): elements_by_key: dict[tuple[ElementType, int], Element] = {} for raw_asset in client_property_data.assets: + if raw_asset.element_code in ["DECNTHMINC", "EICINSFREQ"]: + # skip metadata rows + continue + element_mapping = LbwfMapper._safe_map_element(raw_asset) + if not element_mapping: + continue + aspect_condition = LbwfMapper._build_aspect_condition( raw_asset, element_mapping, survey_year ) diff --git a/backend/condition/parsing/lbwf_parser.py b/backend/condition/parsing/lbwf_parser.py index 63512c41..14d2efe4 100644 --- a/backend/condition/parsing/lbwf_parser.py +++ b/backend/condition/parsing/lbwf_parser.py @@ -3,18 +3,23 @@ from openpyxl import Workbook, load_workbook from collections import defaultdict from backend.condition.parsing.parser import Parser -from backend.condition.parsing.records.lbwf.lbwf_asset_condition import LbwfAssetCondition +from backend.condition.parsing.records.lbwf.lbwf_asset_condition import ( + LbwfAssetCondition, +) from backend.condition.parsing.records.lbwf.lbwf_house import LbwfHouse from backend.condition.utils.date_utils import normalise_date from utils.logger import setup_logger logger = setup_logger() + class LbwfParser(Parser): def parse(self, file_stream: BinaryIO) -> Any: wb: Workbook = load_workbook(file_stream) - address_to_uprn_map: Dict[str, int] = LbwfParser._generate_address_to_uprn_dict(wb) + address_to_uprn_map: Dict[str, int] = LbwfParser._generate_address_to_uprn_dict( + wb + ) assets = self._parse_assets(wb) houses = self._parse_houses(wb, address_to_uprn_map) @@ -82,7 +87,6 @@ class LbwfParser(Parser): for house in houses: house.assets = assets_by_ref.get(house.reference, []) - @staticmethod def _map_row_to_house_record( row: Any | Tuple[object | None, ...], @@ -100,8 +104,8 @@ class LbwfParser(Parser): house=row[header_indexes["HOSUE"]], fail_decency=row[header_indexes["Fail Decency"]], assets=[], - ) - + ) + @staticmethod def _map_row_to_asset_record( row: Any | Tuple[object | None, ...], @@ -119,7 +123,9 @@ class LbwfParser(Parser): element_code=row[header_indexes["ELEMENT CODE"]], element_code_description=row[header_indexes["ELEMENT CODE DESCRIPTION"]], attribute_code=row[header_indexes["ATTRIBUTE CODE"]], - attribute_code_description=row[header_indexes["ATTRIBUTE CODE DESCRIPTION"]], + attribute_code_description=row[ + header_indexes["ATTRIBUTE CODE DESCRIPTION"] + ], element_date_value=row[header_indexes["ELEMENT DATE VALUE"]], element_numerical_value=row[header_indexes["ELEMENT NUMERIC VALUE"]], element_text_value=row[header_indexes["ELEMENT TEXT VALUE"]], @@ -128,7 +134,6 @@ class LbwfParser(Parser): remaining_life=row[header_indexes["REMAINING LIFE"]], element_comments=row[header_indexes["ELEMENT COMMENTS"]], ) - @staticmethod def _generate_address_to_uprn_dict(wb: Workbook) -> Dict[str, int | None]: @@ -158,10 +163,9 @@ class LbwfParser(Parser): return mapping - @staticmethod def _get_column_indexes_by_name( - headers: Tuple[object | None, ...] + headers: Tuple[object | None, ...], ) -> Dict[str, int]: index: Dict[str, int] = {} @@ -170,13 +174,14 @@ class LbwfParser(Parser): index[header] = i return index - + @staticmethod - def _get_uprn_from_address(address: str, address_to_uprn_map: Dict[str, int]) -> int | None: + def _get_uprn_from_address( + address: str, address_to_uprn_map: Dict[str, int] + ) -> int | None: pseudo_name = address.split(",")[0] if pseudo_name.lower() in (k.lower() for k in address_to_uprn_map.keys()): return address_to_uprn_map[pseudo_name.upper()] - + return None - diff --git a/backend/condition/processor.py b/backend/condition/processor.py index 3135d8a5..3cbff498 100644 --- a/backend/condition/processor.py +++ b/backend/condition/processor.py @@ -25,7 +25,7 @@ def process_file(file_stream: BinaryIO, source_key: str) -> None: property_condition_surveys: List[PropertyConditionSurvey] = [] for p in raw_properties: - property_condition_surveys.push( + property_condition_surveys.append( mapper.map_asset_conditions_for_property(p, survey_year) )