Merge branch 'main' into feature/condition-data

This commit is contained in:
Daniel Roth 2026-01-21 11:51:31 +00:00
commit 7eb06396ef
18 changed files with 4002 additions and 1394 deletions

View file

@ -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)'
# '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)'

View file

@ -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 '

File diff suppressed because it is too large Load diff

View file

@ -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(

View file

@ -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
)

View file

@ -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
)

View file

@ -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

View file

@ -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
}
]

View file

@ -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):

View file

@ -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, Any
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,
@ -486,19 +498,354 @@ class Recommendations:
return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction
@staticmethod
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, "sap_adjustment": 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 _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
@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]
# 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 {
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_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_ventilation_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,
property_instance,
all_predictions,
recommendations,
representative_recommendations,
):
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
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
@ -507,49 +854,43 @@ 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
:return:
:param debug: boolean, indicating if the function is running in debug mode. The only difference is that
adjustments are returned for testing
:return: Updated recommendations with predicted impact, and a list of impacts by phase
"""
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"]
}
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
property_predictions = cls._filter_predictions_for_property(
all_predictions, str(property_instance.id)
)
impact_summary = []
# shallow copy intentional - we're going to modify the internals
property_recommendations = recommendations[property_instance.id].copy()
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)
# 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"]:
# 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(
{
@ -557,62 +898,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 ["sap_change", "heat_demand", "carbon_change"]
}
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"]
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"],
}
@ -625,113 +933,20 @@ 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
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"]
)
property_phase_impact["sap"] = min(property_phase_impact["sap"], lighting_sap_limit)
property_phase_impact["carbon"] = min(
property_phase_impact["carbon"], rec["co2_equivalent_savings"]
)
# 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
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"])
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)
if proposal_out_of_bounds:
property_phase_impact["sap"] = _adjust_ventilation_sap(proposed_sap_impact)
else:
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:
property_phase_impact["sap"] = min(property_phase_impact["sap"], li_sap_limit)
# 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"] = (
rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else
property_phase_impact["sap"]
)
# 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):
@ -740,11 +955,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(
{
@ -757,6 +968,9 @@ class Recommendations:
}
)
if debug:
return property_recommendations, impact_summary, adjustments
return property_recommendations, impact_summary
@staticmethod

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
@ -44,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)
@ -58,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):
"""
@ -97,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)
@ -108,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):
"""
@ -146,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)
@ -172,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)
@ -199,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)
@ -210,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()
@ -245,12 +238,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 +255,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 +281,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)
@ -321,10 +314,15 @@ 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)
@ -394,7 +392,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 +403,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 +411,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 +522,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 +540,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'
}

View file

@ -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",
)

View file

@ -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
commands = pytest {posargs}