mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
added rebaselining for installed measures
This commit is contained in:
parent
808a5122ee
commit
3fe102c385
5 changed files with 136 additions and 30 deletions
|
|
@ -852,7 +852,10 @@ class Property:
|
|||
else None
|
||||
)
|
||||
|
||||
def get_property_details_epc(self, portfolio_id: int):
|
||||
def get_property_details_epc(
|
||||
self, portfolio_id: int, needs_rebaselining: bool = False, rebaselining_carbon: float = 0,
|
||||
rebaselining_heat_demand: float = 0, rebaselining_kwh: float = 0, rebaselining_bills: float = 0
|
||||
):
|
||||
|
||||
if self.current_energy_bill is None:
|
||||
raise ValueError("Current energy bill has not been set")
|
||||
|
|
@ -875,6 +878,19 @@ class Property:
|
|||
# We check if the lodgement date is more than 10 years old
|
||||
is_expired = (datetime.now() - pd.to_datetime(lodgement_date)) > timedelta(days=3650)
|
||||
|
||||
# Handle re-baselining
|
||||
co2_emissions = self.energy["co2_emissions"]
|
||||
primary_energy_consumption = self.energy["primary_energy_consumption"]
|
||||
current_kwh_demand = self.current_energy_consumption
|
||||
current_kwh_heating_hotwater = self.current_energy_consumption_heating_hotwater
|
||||
if needs_rebaselining:
|
||||
# Carbon will be reduced
|
||||
co2_emissions -= rebaselining_carbon
|
||||
# Heat demand will be reduced
|
||||
primary_energy_consumption -= rebaselining_heat_demand
|
||||
current_kwh_demand -= rebaselining_kwh
|
||||
current_kwh_heating_hotwater -= rebaselining_kwh
|
||||
|
||||
property_details_epc = {
|
||||
"property_id": self.id,
|
||||
"portfolio_id": portfolio_id,
|
||||
|
|
@ -911,16 +927,25 @@ class Property:
|
|||
"number_of_storeys": self.number_of_storeys["number_of_storeys"],
|
||||
"mains_gas": self.mains_gas,
|
||||
"energy_tariff": self.data["energy-tariff"],
|
||||
"primary_energy_consumption": self.energy["primary_energy_consumption"],
|
||||
"co2_emissions": self.energy["co2_emissions"],
|
||||
"current_energy_demand": self.current_energy_consumption,
|
||||
"current_energy_demand_heating_hotwater": self.current_energy_consumption_heating_hotwater,
|
||||
"primary_energy_consumption": primary_energy_consumption,
|
||||
"co2_emissions": co2_emissions,
|
||||
"current_energy_demand": current_kwh_demand, # This is kwh - naming is confusing
|
||||
"current_energy_demand_heating_hotwater": current_kwh_heating_hotwater, # This is kwh
|
||||
"estimated": self.data.get("estimated", False),
|
||||
# We indicate if we've overwritten a SAP 05 EPC
|
||||
"sap_05_overwritten": sap_05_overwritten,
|
||||
"sap_05_score": sap_05_score,
|
||||
"sap_05_epc_rating": sap_05_epc_rating,
|
||||
**self.current_energy_bill
|
||||
**self.current_energy_bill,
|
||||
"original_co2_emissions": self.energy["co2_emissions"],
|
||||
"original_primary_energy_consumption": self.energy["primary_energy_consumption"],
|
||||
"original_current_energy_demand": self.current_energy_consumption, # Bad naming, this is kwh
|
||||
"original_current_energy_demand_heating_hotwater": self.current_energy_consumption_heating_hotwater, # kwh
|
||||
"installed_measures_co2_adjustment": rebaselining_carbon,
|
||||
"installed_measures_energy_demand_adjustment": rebaselining_kwh, # kwh
|
||||
"installed_measures_total_energy_bill_adjustment": rebaselining_bills,
|
||||
"installed_measures_heat_demand_adjustment": rebaselining_heat_demand,
|
||||
"is_epc_adjusted_for_installed_measures": needs_rebaselining,
|
||||
}
|
||||
|
||||
return property_details_epc
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ from backend.app.db.connection import db_session, db_read_session
|
|||
|
||||
|
||||
def prepare_plan_data(
|
||||
p, body, scenario_id, eco_packages, valuations, new_sap_points, new_epc, default_recommendations
|
||||
p, body, scenario_id, eco_packages, valuations, new_sap_points, new_epc, default_recommendations,
|
||||
rebaselining_carbon=0, rebaselining_heat_demand=0, rebaselining_kwh=0, rebaselining_bills=0,
|
||||
):
|
||||
"""
|
||||
Utility function to prepare the data that goes into the production of a plan. Is a fairly rough and unstructured
|
||||
|
|
@ -23,19 +24,29 @@ def prepare_plan_data(
|
|||
:param new_sap_points: sap points, post default recommendations
|
||||
:param new_epc: new epc rating, post default recommendations
|
||||
:param default_recommendations: list of default recommendations for a property
|
||||
:param rebaselining_carbon: carbon emissions adjustment for rebaselining
|
||||
:param rebaselining_heat_demand: heat demand adjustment for rebaselining
|
||||
:param rebaselining_kwh: kwh consumption adjustment for rebaselining
|
||||
:param rebaselining_bills: energy bill adjustment for rebaselining
|
||||
:return:
|
||||
"""
|
||||
# Plan carbon savings
|
||||
co2_savings = sum([r["co2_equivalent_savings"] for r in default_recommendations])
|
||||
post_co2_emissions = p.energy["co2_emissions"] - co2_savings
|
||||
co2_savings = sum(
|
||||
[r["co2_equivalent_savings"] for r in default_recommendations if not r.get("already_installed", False)]
|
||||
)
|
||||
post_co2_emissions = p.energy["co2_emissions"] - rebaselining_carbon - co2_savings
|
||||
|
||||
# Plan bill savings
|
||||
energy_bill_savings = sum([r["energy_cost_savings"] for r in default_recommendations])
|
||||
post_energy_bill = sum(p.current_energy_bill.values()) - energy_bill_savings
|
||||
energy_bill_savings = sum(
|
||||
[r["energy_cost_savings"] for r in default_recommendations if not r.get("already_installed", False)]
|
||||
)
|
||||
post_energy_bill = sum(p.current_energy_bill.values()) - rebaselining_bills - energy_bill_savings
|
||||
|
||||
# energy consumption
|
||||
energy_consumption_savings = sum([r["kwh_savings"] for r in default_recommendations])
|
||||
post_energy_consumption = p.current_energy_consumption - energy_consumption_savings
|
||||
energy_consumption_savings = sum(
|
||||
[r["kwh_savings"] for r in default_recommendations if not r.get("already_installed", False)]
|
||||
)
|
||||
post_energy_consumption = p.current_energy_consumption - rebaselining_kwh - energy_consumption_savings
|
||||
|
||||
valuation_post_retrofit, valuation_increase = None, None
|
||||
if valuations["current_value"]:
|
||||
|
|
@ -43,8 +54,10 @@ def prepare_plan_data(
|
|||
valuation_post_retrofit = valuations["average_increased_value"]
|
||||
|
||||
# plan costing data
|
||||
cost_of_works = sum([r["total"] for r in default_recommendations])
|
||||
contingency_cost = sum([r.get("contingency", 0) for r in default_recommendations])
|
||||
cost_of_works = sum([r["total"] for r in default_recommendations if not r.get("already_installed", False)])
|
||||
contingency_cost = sum(
|
||||
[r.get("contingency", 0) for r in default_recommendations if not r.get("already_installed", False)]
|
||||
)
|
||||
|
||||
return {
|
||||
"portfolio_id": body.portfolio_id,
|
||||
|
|
|
|||
|
|
@ -525,6 +525,22 @@ def extract_address_data(config, body):
|
|||
return uprn, address1, full_address
|
||||
|
||||
|
||||
def keep_max_sap_per_measure_type(items):
|
||||
# First pass: find max sap_points per measure_type
|
||||
max_by_type = {}
|
||||
for item in items:
|
||||
t = item["measure_type"]
|
||||
max_by_type[t] = max(max_by_type.get(t, float("-inf")), item["sap_points"])
|
||||
|
||||
# Second pass: keep only items matching the max for their type
|
||||
output = []
|
||||
for measure_type, points in max_by_type.items():
|
||||
to_consider = [x for x in items if x["measure_type"] == measure_type and x["sap_points"] == points]
|
||||
output.append(to_consider[0]) # pick the first one in case of ties
|
||||
|
||||
return output
|
||||
|
||||
|
||||
async def model_engine(body: PlanTriggerRequest):
|
||||
logger.info("Model Engine triggered with body: %s", json.loads(body.model_dump_json()))
|
||||
|
||||
|
|
@ -1063,8 +1079,33 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
(r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"],
|
||||
r["uplift_project_score"]) = (0, 0, 0, 0)
|
||||
|
||||
already_installed_measures = []
|
||||
for measures in measures_to_optimise_with_uplift:
|
||||
for m in measures:
|
||||
# A) We're going to make the already installed measures default
|
||||
# B) We need to SAP points for all already installed measures to avoid double counting
|
||||
if m["already_installed"]:
|
||||
already_installed_measures.append(
|
||||
{
|
||||
"id": m["recommendation_id"],
|
||||
"measure_type": m["measure_type"],
|
||||
"sap_points": m["sap_points"],
|
||||
}
|
||||
)
|
||||
|
||||
# We get the ones with the highest SAP
|
||||
default_already_installed = keep_max_sap_per_measure_type(already_installed_measures)
|
||||
already_installed_sap = float(sum(d["sap_points"] for d in default_already_installed))
|
||||
|
||||
# Remove them from the optimisation pool
|
||||
finalised_measures_to_optimise = []
|
||||
for m in measures_to_optimise_with_uplift:
|
||||
filtered = [x for x in m if not x["already_installed"]]
|
||||
if filtered:
|
||||
finalised_measures_to_optimise.append(filtered)
|
||||
|
||||
input_measures = optimiser_functions.prepare_input_measures(
|
||||
measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True,
|
||||
finalised_measures_to_optimise, body.goal, needs_ventilation, funding=True,
|
||||
property_eco_packages=eco_packages.get(p.id)
|
||||
)
|
||||
|
||||
|
|
@ -1075,9 +1116,10 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
p=p,
|
||||
input_measures=input_measures,
|
||||
budget=body.budget,
|
||||
target_gain=gain,
|
||||
target_gain=gain - already_installed_sap,
|
||||
enforce_heat_pump_insulation=True,
|
||||
enforce_fabric_first=body.enforce_fabric_first
|
||||
enforce_fabric_first=body.enforce_fabric_first,
|
||||
already_installed_sap=already_installed_sap, # To be passed to output
|
||||
)
|
||||
|
||||
# if handle the empty case
|
||||
|
|
@ -1120,7 +1162,8 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
)
|
||||
battery_sap_score = BatterySAPScorer.score(starting_sap=post_sap, pv_size=pv_size)
|
||||
|
||||
selected = {r["id"] for r in solution}
|
||||
# We add the defauly already installed measures to the solution
|
||||
selected = {r["id"] for r in solution + default_already_installed}
|
||||
|
||||
if property_required_measures:
|
||||
solution = optimiser_functions.add_required_measures(
|
||||
|
|
@ -1206,7 +1249,6 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
rebaselining_heat_demand = float(sum([r["heat_demand"] for r in already_installed_default]))
|
||||
rebaselining_kwh = float(sum([r["kwh_savings"] for r in already_installed_default]))
|
||||
rebaselining_bills = float(sum([r["energy_cost_savings"] for r in already_installed_default]))
|
||||
# TODO - gotta apply the adjustments to the property table, and the property_details_epc table
|
||||
|
||||
# This will include everything, including already installed
|
||||
total_sap_points = sum([r["sap_points"] for r in default_recommendations])
|
||||
|
|
@ -1227,7 +1269,16 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
)
|
||||
})
|
||||
|
||||
property_epc_details.append(p.get_property_details_epc(portfolio_id=body.portfolio_id))
|
||||
property_epc_details.append(
|
||||
p.get_property_details_epc(
|
||||
portfolio_id=body.portfolio_id,
|
||||
needs_rebaselining=needs_rebaselining,
|
||||
rebaselining_carbon=rebaselining_carbon,
|
||||
rebaselining_heat_demand=rebaselining_heat_demand,
|
||||
rebaselining_kwh=rebaselining_kwh,
|
||||
rebaselining_bills=rebaselining_bills,
|
||||
)
|
||||
)
|
||||
|
||||
property_spatial_updates.append({"uprn": p.uprn, "data": p.spatial})
|
||||
|
||||
|
|
@ -1236,7 +1287,18 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
continue
|
||||
|
||||
plan_data = db_funcs.recommendations_functions.prepare_plan_data(
|
||||
p, body, scenario_id, eco_packages, valuations, new_sap_points, new_epc, default_recommendations
|
||||
p=p,
|
||||
body=body,
|
||||
scenario_id=scenario_id,
|
||||
eco_packages=eco_packages,
|
||||
valuations=valuations,
|
||||
new_sap_points=new_sap_points,
|
||||
new_epc=new_epc,
|
||||
default_recommendations=default_recommendations,
|
||||
rebaselining_carbon=rebaselining_carbon,
|
||||
rebaselining_heat_demand=rebaselining_heat_demand,
|
||||
rebaselining_kwh=rebaselining_kwh,
|
||||
rebaselining_bills=rebaselining_bills,
|
||||
)
|
||||
plans_to_create.append({"property_id": p.id, "plan_data": plan_data})
|
||||
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ class Recommendations:
|
|||
for recs in already_installed_recs:
|
||||
for rec in recs:
|
||||
rec["phase"] = already_installed_phase
|
||||
already_installed_phase += 1
|
||||
already_installed_phase += 1
|
||||
|
||||
property_recommendations = already_installed_recs + property_recommendations_removed_installed
|
||||
|
||||
|
|
|
|||
|
|
@ -643,7 +643,8 @@ def optimise_with_scenarios(
|
|||
budget=None,
|
||||
target_gain=None,
|
||||
enforce_heat_pump_insulation=True,
|
||||
enforce_fabric_first=False
|
||||
enforce_fabric_first=False,
|
||||
already_installed_sap=0
|
||||
):
|
||||
"""
|
||||
Scenario-based optimiser (funding-agnostic).
|
||||
|
|
@ -754,7 +755,11 @@ def optimise_with_scenarios(
|
|||
heat_pump_paths = build_heat_pump_paths(remaining_wall_measures, remaining_roof_measures)
|
||||
paths.extend(heat_pump_paths)
|
||||
|
||||
fixed_selections = expand_funding_path(optimisation_measures, paths)
|
||||
fixed_selections = []
|
||||
for path in paths:
|
||||
result = expand_funding_path(input_measures, [path])
|
||||
if result:
|
||||
fixed_selections.extend(result)
|
||||
|
||||
for fixed in fixed_selections:
|
||||
|
||||
|
|
@ -825,7 +830,7 @@ def optimise_with_scenarios(
|
|||
"already_installed_gain": sum([x["gain"] for x in picked if x["already_installed"]])
|
||||
})
|
||||
|
||||
solutions_df = append_solution_metrics(solutions, target_gain, p)
|
||||
solutions_df = append_solution_metrics(solutions, target_gain, p, already_installed_sap)
|
||||
|
||||
return solutions_df
|
||||
|
||||
|
|
@ -835,12 +840,14 @@ def _get_ending_sap_without_battery(x):
|
|||
return float(sum(gain))
|
||||
|
||||
|
||||
def append_solution_metrics(solutions, target_gain, p):
|
||||
def append_solution_metrics(solutions, target_gain, p, already_installed_sap=0):
|
||||
"""
|
||||
Given a set of solutions, this function will return a dataframe, with cost metrics appended, to allow
|
||||
the end user to select the optimal solution.
|
||||
:param solutions:
|
||||
:param target_gain:
|
||||
:param p:
|
||||
:param already_installed_sap:
|
||||
:return:
|
||||
"""
|
||||
|
||||
|
|
@ -852,7 +859,7 @@ def append_solution_metrics(solutions, target_gain, p):
|
|||
|
||||
# Given the scheme, we now check if the packages are eligible. If they *are* eligible, but they don't meet the
|
||||
# final upgrade target, we then look to perform a final optimisation pass to meet the target gain.
|
||||
solutions_df["meets_upgrade_target"] = solutions_df["total_gain"] >= target_gain - 0.1
|
||||
solutions_df["meets_upgrade_target"] = solutions_df["total_gain"] >= target_gain
|
||||
# We now can calculate the project ABS, which subtracts from the cost, but this is only relevant for ECO4
|
||||
# We flag projects that are including batteries
|
||||
solutions_df["has_battery"] = solutions_df["items"].apply(has_battery)
|
||||
|
|
@ -863,7 +870,7 @@ def append_solution_metrics(solutions, target_gain, p):
|
|||
# We need the ending SAP, but we'll need to remove the battery SAP uplift first
|
||||
|
||||
solutions_df["ending_sap_without_battery"] = solutions_df.apply(
|
||||
lambda x: int(p.data["current-energy-efficiency"]) + _get_ending_sap_without_battery(x),
|
||||
lambda x: int(p.data["current-energy-efficiency"]) + already_installed_sap + _get_ending_sap_without_battery(x),
|
||||
axis=1
|
||||
)
|
||||
|
||||
|
|
@ -1015,7 +1022,6 @@ def expand_funding_path(input_measures, path_spec):
|
|||
cands = iter_and_candidates(input_measures, elem["AND"])
|
||||
else:
|
||||
raise ValueError("unknown path element; expected 'OR' or 'AND'")
|
||||
|
||||
if not cands:
|
||||
return []
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue