added rebaselining for installed measures

This commit is contained in:
Khalim Conn-Kowlessar 2026-01-10 18:52:26 +00:00
parent 808a5122ee
commit 3fe102c385
5 changed files with 136 additions and 30 deletions

View file

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

View file

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

View file

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

View file

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