Merge pull request #645 from Hestia-Homes/main

Portfolio diagnostics - adding additional logging to engine
This commit is contained in:
KhalimCK 2026-01-08 12:21:17 +00:00 committed by GitHub
commit 00b8c87f8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1445 additions and 41 deletions

View file

@ -1,15 +1,11 @@
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy import insert, delete, select from sqlalchemy import insert, delete
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from backend.app.db.models.recommendations import ( from backend.app.db.models.recommendations import (
Plan, Recommendation, RecommendationMaterials, PlanRecommendations, Scenario Plan, Recommendation, RecommendationMaterials, PlanRecommendations, Scenario
) )
from backend.app.db.models.portfolio import ( from backend.app.db.models.portfolio import PropertyModel
PropertyModel, PropertyTargetsModel, PropertyDetailsEpcModel
)
from backend.app.db.models.funding import FundingPackageMeasures, FundingPackage
from backend.app.db.models.inspections import InspectionModel
from backend.app.db.connection import db_session, db_read_session from backend.app.db.connection import db_session, db_read_session

View file

@ -106,6 +106,10 @@ class PropertyModel(Base):
current_epc_rating = Column(Enum(Epc)) current_epc_rating = Column(Enum(Epc))
current_sap_points = Column(Float) current_sap_points = Column(Float)
current_valuation = Column(Float) current_valuation = Column(Float)
# Following fields are for recording already installed adjustments to a property's SAP
installed_measures_sap_point_adjustment = Column(Float)
is_sap_points_adjusted_for_installed_measures = Column(Boolean, default=False)
original_sap_points = Column(Float)
class FeatureRating(enum.Enum): class FeatureRating(enum.Enum):
@ -188,6 +192,18 @@ class PropertyDetailsEpcModel(Base):
gas_standing_charge = Column(Float) gas_standing_charge = Column(Float)
electricity_standing_charge = Column(Float) electricity_standing_charge = Column(Float)
# Columns for re-baselining if we have an already installed measure
original_co2_emissions = Column(Float)
original_primary_energy_consumption = Column(Float)
original_current_energy_demand = Column(Float)
original_current_energy_demand_heating_hotwater = Column(Float)
# Adjustments
installed_measures_co2_adjustment = Column(Float)
installed_measures_energy_demand_adjustment = Column(Float)
installed_measures_total_energy_bill_adjustment = Column(Float)
installed_measures_heat_demand_adjustment = Column(Float)
is_epc_adjusted_for_installed_measures = Column(Boolean, default=False)
class PropertyDetailsSpatial(Base): class PropertyDetailsSpatial(Base):
__tablename__ = "property_details_spatial" __tablename__ = "property_details_spatial"

View file

@ -146,3 +146,58 @@ class Scenario(Base):
valuation_return_on_investment = Column(String) valuation_return_on_investment = Column(String)
property_valuation_increase = Column(Float) property_valuation_increase = Column(Float)
labour_days = Column(Float) labour_days = Column(Float)
class MeasureType(enum.Enum):
air_source_heat_pump = "air_source_heat_pump"
boiler_upgrade = "boiler_upgrade"
high_heat_retention_storage_heaters = "high_heat_retention_storage_heaters"
secondary_heating = "secondary_heating"
roomstat_programmer_trvs = "roomstat_programmer_trvs"
time_temperature_zone_control = "time_temperature_zone_control"
cylinder_thermostat = "cylinder_thermostat"
cavity_wall_insulation = "cavity_wall_insulation"
extension_cavity_wall_insulation = "extension_cavity_wall_insulation"
external_wall_insulation = "external_wall_insulation"
internal_wall_insulation = "internal_wall_insulation"
loft_insulation = "loft_insulation"
flat_roof_insulation = "flat_roof_insulation"
room_roof_insulation = "room_roof_insulation"
solid_floor_insulation = "solid_floor_insulation"
suspended_floor_insulation = "suspended_floor_insulation"
double_glazing = "double_glazing"
secondary_glazing = "secondary_glazing"
draught_proofing = "draught_proofing"
mechanical_ventilation = "mechanical_ventilation"
low_energy_lighting = "low_energy_lighting"
solar_pv = "solar_pv"
hot_water_tank_insulation = "hot_water_tank_insulation"
sealing_open_fireplace = "sealing_open_fireplace"
class InstalledMeasure(Base):
__tablename__ = "installed_measure"
id = Column(BigInteger, primary_key=True, autoincrement=True)
uprn = Column(BigInteger, nullable=False)
measure_type = Column(
Enum(
MeasureType,
name="measure_type",
values_callable=lambda e: [m.value for m in e],
create_type=False, # <-- critical
),
nullable=False,
)
installed_at = Column(TIMESTAMP)
sap_points = Column(Float)
carbon_savings = Column(Float)
kwh_savings = Column(Float)
bill_savings = Column(Float)
heat_demand_savings = Column(Float)
source = Column(String)
is_active = Column(Boolean, nullable=False, default=True)

View file

@ -535,12 +535,14 @@ async def model_engine(body: PlanTriggerRequest):
logger.info("Getting the inputs") logger.info("Getting the inputs")
if body.file_type == "xlsx": if body.file_type == "xlsx":
logger.info("Getting the plan input")
plan_input = read_excel_from_s3( plan_input = read_excel_from_s3(
bucket_name=get_settings().PLAN_TRIGGER_BUCKET, bucket_name=get_settings().PLAN_TRIGGER_BUCKET,
file_key=body.trigger_file_path, file_key=body.trigger_file_path,
sheet_name=body.sheet_name, sheet_name=body.sheet_name,
header_row=0, header_row=0,
) )
logger.into("Got the plan input from excel")
# We now handle the case where the input data is a Domna standardised assset list # We now handle the case where the input data is a Domna standardised assset list
if body.file_format == "domna_asset_list": if body.file_format == "domna_asset_list":
@ -619,9 +621,11 @@ async def model_engine(body: PlanTriggerRequest):
raise ValueError("Other formats not yet supported") raise ValueError("Other formats not yet supported")
else: else:
logger.info("Getting the plan input from csv")
plan_input = read_csv_from_s3( plan_input = read_csv_from_s3(
bucket_name=get_settings().PLAN_TRIGGER_BUCKET, filepath=body.trigger_file_path bucket_name=get_settings().PLAN_TRIGGER_BUCKET, filepath=body.trigger_file_path
) )
logger.info("Got the plan input from csv")
# We then slide it on the indexes if they are provided # We then slide it on the indexes if they are provided
if body.index_start is not None and body.index_end is not None: if body.index_start is not None and body.index_end is not None:
@ -640,12 +644,14 @@ async def model_engine(body: PlanTriggerRequest):
if "domna_valuation" in plan_input[0]: if "domna_valuation" in plan_input[0]:
valuation_data = [{"uprn": x["uprn"], "valuation": x["domna_valuation"]} for x in plan_input] valuation_data = [{"uprn": x["uprn"], "valuation": x["domna_valuation"]} for x in plan_input]
logger.info("Getting cleaning_data")
cleaning_data = read_dataframe_from_s3_parquet( cleaning_data = read_dataframe_from_s3_parquet(
bucket_name=get_settings().DATA_BUCKET, file_key="sap_change_model/cleaning_dataset.parquet", bucket_name=get_settings().DATA_BUCKET, file_key="sap_change_model/cleaning_dataset.parquet",
) )
# Prepare input data # Prepare input data
addresses = Addresses.from_plan_input(plan_input, body) addresses = Addresses.from_plan_input(plan_input, body)
logger.info("Checking database for existing properties")
uprns = addresses.get_uprns() uprns = addresses.get_uprns()
landlord_ids = addresses.get_landlord_ids() landlord_ids = addresses.get_landlord_ids()
@ -670,6 +676,7 @@ async def model_engine(body: PlanTriggerRequest):
if key not in property_lookup: if key not in property_lookup:
to_create.append(addr) to_create.append(addr)
logger.info("Checking database for EPC cache")
# Pre-requests to the db # Pre-requests to the db
with db_read_session() as session: with db_read_session() as session:
epc_cache_by_uprn = db_funcs.epc_functions.EpcStoreService.get_epcs_for_uprns(session, uprns) epc_cache_by_uprn = db_funcs.epc_functions.EpcStoreService.get_epcs_for_uprns(session, uprns)
@ -679,6 +686,7 @@ async def model_engine(body: PlanTriggerRequest):
) )
# If we have properties that need to be created, we cerate them in bulk # If we have properties that need to be created, we cerate them in bulk
logger.info("Determine new properties to be created")
new_property_ids = set() new_property_ids = set()
if to_create: if to_create:
logger.info("Creating %d new properties", len(to_create)) logger.info("Creating %d new properties", len(to_create))

View file

@ -120,13 +120,127 @@ retry.to_excel(
# Delete associated plans # Delete associated plans
# 1) Get the property IDs for these UPRNS, for this portfolio # 1) Get the property IDs for these UPRNS, for this portfolio
portfolio_id = 419 portfolio_id = 419
uprns = retry uprns = retry["epc_os_uprn"].tolist()
# TODO: Delete all plans for these properties and re-build # TODO: Delete all plans for these properties and re-build
# Plan notes: from sqlalchemy.orm import Session
# UPRN: 5870109770, property ID: 281244 - need to delete and re-build all scenarios from backend.app.db.models.portfolio import PropertyModel
# UPRN: 100022725126, property ID: 283781 - need to delete and re-build all scenarios from backend.app.db.connection import db_session
from backend.app.db.models.recommendations import Plan
from sqlalchemy import select, delete
from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import sessionmaker
# Bugs: def get_property_ids_for_uprns(session: Session, portfolio_id: int, uprns: list[int]) -> list[int]:
12156800 return [
property.id
for property in session.query(PropertyModel)
.filter(
PropertyModel.portfolio_id == portfolio_id,
PropertyModel.uprn.in_(uprns)
)
.all()
]
with db_session() as session:
property_ids_to_delete = get_property_ids_for_uprns(session, portfolio_id, uprns)
# Get all and delete plans for these property IDs
def get_all_plans_for_property_ids(session: Session, property_ids: list[int]) -> list[Plan]:
return session.query(Plan).filter(Plan.property_id.in_(property_ids)).all()
def get_ids_of_plans_for_deletion(session: Session, property_ids: list[int]) -> list[int]:
return [
plan.id
for plan in session.query(Plan)
.filter(Plan.property_id.in_(property_ids))
.all()
]
with db_session() as session:
plan_ids_to_delete = get_ids_of_plans_for_deletion(session, property_ids_to_delete)
def chunked(iterable, size):
for i in range(0, len(iterable), size):
yield iterable[i:i + size]
from sqlalchemy import text
from sqlalchemy.orm import Session
def delete_plan_batch(session: Session, plan_ids: list[int]):
if not plan_ids:
return
session.execute(text("SET LOCAL lock_timeout = '5s'"))
params = {"plan_ids": plan_ids}
# ----------------------------
# recommendation_materials
# ----------------------------
session.execute(
text("""
DELETE FROM recommendation_materials rm
USING plan_recommendations pr
WHERE rm.recommendation_id = pr.recommendation_id
AND pr.plan_id = ANY(:plan_ids)
"""),
params,
)
# ----------------------------
# plan_recommendations
# ----------------------------
session.execute(
text("""
DELETE FROM plan_recommendations
WHERE plan_id = ANY(:plan_ids)
"""),
params,
)
# ----------------------------
# recommendations (only those used by these plans)
# ----------------------------
session.execute(
text("""
DELETE FROM recommendation r
WHERE r.id IN (
SELECT DISTINCT recommendation_id
FROM plan_recommendations
WHERE plan_id = ANY(:plan_ids)
)
"""),
params,
)
# ----------------------------
# plans LAST
# ----------------------------
session.execute(
text("""
DELETE FROM plan
WHERE id = ANY(:plan_ids)
"""),
params,
)
batch_size = 25
total = (len(plan_ids_to_delete) + batch_size - 1) // batch_size
for i, batch in enumerate(chunked(plan_ids_to_delete, batch_size), start=1):
print(f"Deleting plan batch {i}/{total} ({len(batch)} plans)")
with db_session() as session:
delete_plan_batch(session, batch)
print(f"Batch {i} committed")

View file

@ -0,0 +1,983 @@
import pandas as pd
from sqlalchemy.orm import sessionmaker
from backend.app.db.connection import db_engine, db_read_session, db_session
from backend.app.db.models.recommendations import Recommendation, Plan, PlanRecommendations, RecommendationMaterials, \
InstalledMeasure
from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel
from sqlalchemy import func
from backend.app.utils import sap_to_epc
from typing import Dict, List, Set
from recommendations.Costs import Costs
from backend.app.db.models.portfolio import Epc
pd.set_option('display.max_rows', 500)
pd.set_option('display.max_columns', 500)
pd.set_option('display.width', 1000)
def get_all_data(portfolio_id, scenario_ids):
session = sessionmaker(bind=db_engine)()
session.begin()
# --------------------
# Properties
# --------------------
properties_query = session.query(
PropertyModel,
PropertyDetailsEpcModel
).join(
PropertyDetailsEpcModel,
PropertyModel.id == PropertyDetailsEpcModel.property_id
).filter(
PropertyModel.portfolio_id == portfolio_id
).all()
properties_data = [
{
**{col.name: getattr(p.PropertyModel, col.name)
for col in PropertyModel.__table__.columns},
**{col.name: getattr(p.PropertyDetailsEpcModel, col.name)
for col in PropertyDetailsEpcModel.__table__.columns},
}
for p in properties_query
]
# --------------------
# Plans
# --------------------
plans_query = session.query(Plan).filter(
Plan.scenario_id.in_(scenario_ids)
).all()
plans_data = [
{col.name: getattr(plan, col.name) for col in Plan.__table__.columns}
for plan in plans_query
]
plan_ids = [p["id"] for p in plans_data]
# --------------------
# Recommendations (NO materials yet)
# --------------------
recommendations_query = session.query(
Recommendation,
Plan.scenario_id
).join(
PlanRecommendations,
Recommendation.id == PlanRecommendations.recommendation_id
).join(
Plan,
Plan.id == PlanRecommendations.plan_id
).filter(
PlanRecommendations.plan_id.in_(plan_ids),
).all()
recommendations_data = [
{
**{col.name: getattr(r.Recommendation, col.name)
for col in Recommendation.__table__.columns},
"scenario_id": r.scenario_id,
"materials": [] # placeholder
}
for r in recommendations_query
]
session.close()
return properties_data, plans_data, recommendations_data
PORTFOLIO_ID = 419 # Peabody
SCENARIOS = [
# 871, # EPC C - fabric first, no solid floor, ashp 3.0
# 863, # EPC B, No EWI/IWI, No Solid Floor, ASHP 3.0 COP
# 862, # EPC B - No solid floor, ASHP COP 3.0
# 861, # EPC C, No EWI/IWI, No Solid Floor, ASHP 3.0 COP
# 859, # EPC C - no solid floor, ashp 3.0
885, # EPC B - fabric first, no solid floor, ashp 3.0
]
# properties_data, plans_data, recommendations_data = get_all_data(portfolio_id=PORTFOLIO_ID, scenario_ids=SCENARIOS)
# # Store this data as dataframes for analysis
# properties_df = pd.DataFrame(properties_data)
# plans_df = pd.DataFrame(plans_data)
# recommendations_df = pd.DataFrame(recommendations_data)
# Save CSVs
# properties_df.to_csv(
# "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/"
# "f_peabody_properties_data_20260108.csv",
# index=False
# )
# plans_df.to_csv(
# "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/"
# "f_peabody_plans_data_20260108.csv",
# index=False
# )
# recommendations_df.to_csv(
# "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/"
# "f_peabody_recommendations_data_20260108.csv",
# index=False
# )
# Read csvs
properties_df = pd.read_csv(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/"
"f_peabody_properties_data_20260108.csv"
)
plans_df = pd.read_csv(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/"
"f_peabody_plans_data_20260108.csv"
)
recommendations_df = pd.read_csv(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/"
"f_peabody_recommendations_data_20260108.csv"
)
sustainability_data = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/2025_11_11 - Peabody "
"- Data Extracts for Domna.xlsx",
sheet_name="Sustainability"
)
# recommendations_df = pd.read_excel(
# "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/EPC B, "
# "No solid floor, ASHP COP 3.0.xlsx"
# )
# We just need all of the measure types, per property
recommendation_measure_types = recommendations_df[
["property_id", "measure_type"
, "sap_points", "heat_demand", "kwh_savings", "co2_equivalent_savings",
"energy_cost_savings"
]
].drop_duplicates()
recommendation_measure_types["flag"] = True
# We pivot
recommendations_measures_pivot = recommendation_measure_types[
["property_id", "measure_type", "flag"]
].drop_duplicates().pivot(
index='property_id',
columns='measure_type',
values='flag'
)
recommendations_measures_pivot = recommendations_measures_pivot.reset_index()
properties_to_recs = properties_df.rename(columns={"solar_pv": "solar_data"}).merge(
recommendations_measures_pivot, how="left", on="property_id"
)
sustainability_data["cavity_wall_insulation"] = sustainability_data["Wall Insulation"].isin(
["FilledCavity", "FilledCavityPlusInternal", "FilledCavityPlusExternal"]
)
sustainability_data["internal_wall_insulation"] = sustainability_data["Wall Insulation"].isin(
["Internal", "FilledCavityPlusInternal"]
)
sustainability_data["external_wall_insulation"] = sustainability_data["Wall Insulation"].isin(
["External", "FilledCavityPlusExternal"]
)
sustainability_data["loft_insulation"] = sustainability_data["Roof Insulation"].isin(
["mm300", "mm250"]
)
sustainability_data["double_glazing"] = sustainability_data["Glazing"].isin(
["Double 2002 or later", "Double but age unknown", "Triple", "DoubleKnownData", "Secondary", "TripleKnownData"]
)
sustainability_data["secondary_glazing"] = sustainability_data["Glazing"].isin(
["Double 2002 or later", "Double but age unknown", "Triple", "DoubleKnownData", "Secondary", "TripleKnownData"]
)
sustainability_data["suspended_floor_insulation"] = sustainability_data["Floor Insulation"].isin(
["RetroFitted"]
)
sustainability_data["boiler_upgrade"] = (
sustainability_data["Heating"].isin(["Boilers"]) & sustainability_data["Boiler Efficiency"].isin(["A"])
)
sustainability_data["air_source_heat_pump"] = (sustainability_data["Heating"].isin(["Heat pumps (wet)"]))
sustainability_data["time_temperature_zone_control"] = (
sustainability_data["Controls Adequacy"].isin(["Top Spec"])
)
sustainability_data["roomstat_programmer_trvs"] = (
sustainability_data["Controls Adequacy"].isin(["Optimal"])
)
sustainability_data["flat_roof_insulation"] = (
(sustainability_data["Roof Construction"] == "Flat") &
(sustainability_data["Roof Insulation"].isin(["mm50", "mm150", "mm100"]))
)
properties_to_recs["uprn"] = properties_to_recs["uprn"].astype(str)
comparison = sustainability_data.merge(
properties_to_recs[
["uprn", "cavity_wall_insulation", "external_wall_insulation", "internal_wall_insulation", "loft_insulation",
"double_glazing", "secondary_glazing", "suspended_floor_insulation", "boiler_upgrade", "air_source_heat_pump",
"time_temperature_zone_control", "roomstat_programmer_trvs", "flat_roof_insulation", "room_roof_insulation"
]
],
left_on="UPRN",
right_on="uprn",
how="left",
suffixes=("", "_from_recs")
)
# Flag entries where we've been told that walls are already insulated, but we have recommendations for wall insulation
# ------------ Walls ------------
comparison["conflict_cavity_wall_insulation"] = (
(comparison["cavity_wall_insulation"]) &
(pd.isnull(comparison["cavity_wall_insulation_from_recs"]) == False)
)
comparison["conflict_iwi_wall_insulation"] = (
(comparison["internal_wall_insulation"]) &
(pd.isnull(comparison["internal_wall_insulation_from_recs"]) == False)
)
comparison["conflict_ewi_wall_insulation"] = (
(comparison["external_wall_insulation"]) &
(pd.isnull(comparison["external_wall_insulation_from_recs"]) == False)
)
cwi_conflicting = comparison[comparison["conflict_cavity_wall_insulation"] == True]
iwi_conflicting = comparison[comparison["conflict_iwi_wall_insulation"] == True]
ewi_conflicting = comparison[comparison["conflict_ewi_wall_insulation"] == True]
# ------------ Roof ------------
comparison["conflict_loft_insulation"] = (
(comparison["loft_insulation"]) &
(pd.isnull(comparison["loft_insulation_from_recs"]) == False)
)
loft_conflicting = comparison[comparison["conflict_loft_insulation"] == True]
# ------------ Windows ------------
comparison["conflict_double_glazing"] = (
(comparison["double_glazing"]) &
(
(pd.isnull(comparison["double_glazing_from_recs"]) == False)
)
)
comparison["conflict_secondary_glazing"] = (
(comparison["secondary_glazing"]) &
(
(pd.isnull(comparison["secondary_glazing_from_recs"]) == False)
)
)
double_glazing_conflicting = comparison[comparison["conflict_double_glazing"] == True]
secondary_glazing_conflicting = comparison[comparison["conflict_secondary_glazing"] == True]
# ------------ Floors ------------
comparison["conflict_suspended_floor_insulation"] = (
(comparison["suspended_floor_insulation"]) &
(pd.isnull(comparison["suspended_floor_insulation_from_recs"]) == False)
)
floors_conflicting = comparison[comparison["conflict_suspended_floor_insulation"] == True]
# ------------ Boiler Upgrade ------------
comparison["conflict_boiler_upgrade"] = (
(comparison["boiler_upgrade"]) &
(pd.isnull(comparison["boiler_upgrade_from_recs"]) == False)
)
boiler_conflicting = comparison[comparison["conflict_boiler_upgrade"] == True]
# ------------ ASHP ------------
comparison["conflict_air_source_heat_pump"] = (
(comparison["air_source_heat_pump"]) &
(pd.isnull(comparison["air_source_heat_pump_from_recs"]) == False)
)
ashp_conflicting = comparison[comparison["conflict_air_source_heat_pump"] == True]
# ------------ heat controls ------------
comparison["conflict_time_temperature_zone_control"] = (
(comparison["time_temperature_zone_control"]) &
(pd.isnull(comparison["time_temperature_zone_control_from_recs"]) == False)
)
comparison["conflict_roomstat_programmer_trvs"] = (
(comparison["roomstat_programmer_trvs"]) &
(pd.isnull(comparison["roomstat_programmer_trvs_from_recs"]) == False)
)
ttzc_conflicting = comparison[comparison["conflict_time_temperature_zone_control"] == True]
rst_conflicting = comparison[comparison["conflict_roomstat_programmer_trvs"] == True]
# ------------ Flat Roof Insulation -----------
comparison["conflict_flat_roof_insulation"] = (
(comparison["flat_roof_insulation"]) &
(pd.isnull(comparison["flat_roof_insulation_from_recs"]) == False)
)
flat_roof_conflicting = comparison[comparison["conflict_flat_roof_insulation"] == True]
# All properties with conflicts
all_conflicts = pd.concat(
[
cwi_conflicting,
iwi_conflicting,
ewi_conflicting,
loft_conflicting,
double_glazing_conflicting,
secondary_glazing_conflicting,
floors_conflicting,
boiler_conflicting,
ashp_conflicting,
ttzc_conflicting,
rst_conflicting,
flat_roof_conflicting
]
)
all_conflicts = all_conflicts[
[
"uprn",
'conflict_cavity_wall_insulation',
'conflict_iwi_wall_insulation',
'conflict_ewi_wall_insulation',
'conflict_loft_insulation',
'conflict_double_glazing',
'conflict_secondary_glazing',
'conflict_suspended_floor_insulation', 'conflict_boiler_upgrade',
'conflict_air_source_heat_pump',
'conflict_time_temperature_zone_control', 'conflict_roomstat_programmer_trvs', 'conflict_flat_roof_insulation']
]
all_conflicts = all_conflicts.rename(
columns={
"conflict_cavity_wall_insulation": "cavity_wall_insulation",
"conflict_iwi_wall_insulation": "internal_wall_insulation",
"conflict_ewi_wall_insulation": "external_wall_insulation",
"conflict_loft_insulation": "loft_insulation",
"conflict_double_glazing": "double_glazing",
"conflict_secondary_glazing": "secondary_glazing",
"conflict_suspended_floor_insulation": "suspended_floor_insulation",
"conflict_boiler_upgrade": "boiler_upgrade",
"conflict_air_source_heat_pump": "air_source_heat_pump",
"conflict_time_temperature_zone_control": "time_temperature_zone_control",
"conflict_roomstat_programmer_trvs": "roomstat_programmer_trvs",
"conflict_flat_roof_insulation": "flat_roof_insulation"
}
)
# Reshape by UPRN by melting
all_conflicts = all_conflicts.melt(
id_vars=["uprn"],
var_name="measure_type",
value_name="already_installed"
)
recommendations_df["property_id"] = recommendations_df["property_id"].astype(int).astype(str)
properties_df["property_id"] = properties_df["property_id"].astype(int).astype(str)
recs_with_uprn = recommendations_df.merge(
properties_df[["property_id", "uprn"]],
on="property_id",
how="left",
suffixes=("", "_prop")
)
recs_with_uprn = (
recs_with_uprn
.sort_values("sap_points", ascending=False)
.groupby(["uprn", "measure_type"], as_index=False)
.first()
)
recs_with_uprn["uprn"] = recs_with_uprn["uprn"].astype(str)
installed_measures_df = all_conflicts.merge(
recs_with_uprn[["uprn", "measure_type", "sap_points", "heat_demand", "kwh_savings", "co2_equivalent_savings",
"energy_cost_savings"]],
how="left",
on=["uprn", "measure_type"]
)
installed_measures_df = installed_measures_df[installed_measures_df["already_installed"] == True]
for col in ["sap_points", "heat_demand", "kwh_savings", "co2_equivalent_savings", "energy_cost_savings"]:
print(f"n missings for {col}: {pd.isnull(installed_measures_df[col]).sum()}", )
# Do some calcs on SAP impact
sap_impact = installed_measures_df.groupby(["uprn"])["sap_points"].sum().reset_index()
properties_sap = properties_df[["uprn", "current_sap_points", "current_epc_rating"]].copy()
properties_sap["uprn"] = properties_sap["uprn"].astype(str)
old_sap_vs_new = properties_sap.merge(
sap_impact, how="inner", on="uprn"
)
old_sap_vs_new["new_sap_points"] = old_sap_vs_new["current_sap_points"] + old_sap_vs_new["sap_points"]
old_sap_vs_new["new_epc_rating"] = old_sap_vs_new["new_sap_points"].apply(
lambda x: sap_to_epc(x)
)
# How many properties go from below C to above
old_sap_vs_new[old_sap_vs_new["current_sap_points"] < 69]["new_epc_rating"].value_counts()
changed = old_sap_vs_new[
(old_sap_vs_new["current_sap_points"] < 69) & (old_sap_vs_new["new_sap_points"] >= 69)
]
properties_df[properties_df["current_sap_points"] < 69].shape
old_sap_vs_new[old_sap_vs_new["current_epc_rating"].isin(["Epc.F", "Epc.G"])]
25979 - 3891
sustainability_data[sustainability_data["UPRN"] == "100021204260"]
# What do I need to do:
# TODO: - need to get a view of "all" measures for the property, not just recommended. We can do this but just looking
# at one scenario
# 1) I should store the current recommendations table, for the portfolio as a backup
# 2) I need a total of already installed SAP points for each property. This should probably be stored on the
# property_details_epc tabe
# 3) For anything already installed, I should mark already installed as True, and set the cost to zero
# 4) I need to update the plan cost to remove the cost of the installed measures
### Rebaselining
from typing import Dict
from sqlalchemy import func
def get_installed_measure_adjustments_by_uprn_for_portfolio(
session,
portfolio_id: int,
) -> Dict[int, dict]:
"""
Returns per-UPRN installed-measure adjustments.
{
uprn: {
sap_points: float,
co2: float,
energy_kwh: float,
energy_bill: float,
heat_demand: float,
}
}
"""
uprn_subquery = (
session.query(PropertyModel.uprn)
.filter(PropertyModel.portfolio_id == portfolio_id)
.filter(PropertyModel.uprn.isnot(None))
.subquery()
)
rows = (
session.query(
InstalledMeasure.uprn.label("uprn"),
func.coalesce(func.sum(InstalledMeasure.sap_points), 0.0)
.label("sap_points"),
func.coalesce(func.sum(InstalledMeasure.carbon_savings), 0.0)
.label("co2"),
func.coalesce(func.sum(InstalledMeasure.kwh_savings), 0.0)
.label("energy_kwh"),
func.coalesce(func.sum(InstalledMeasure.bill_savings), 0.0)
.label("energy_bill"),
func.coalesce(func.sum(InstalledMeasure.heat_demand_savings), 0.0)
.label("heat_demand"),
)
.filter(InstalledMeasure.is_active.is_(True))
.filter(InstalledMeasure.uprn.in_(uprn_subquery))
.group_by(InstalledMeasure.uprn)
.all()
)
return {
row.uprn: {
"sap_points": float(row.sap_points),
"co2": float(row.co2),
"energy_kwh": float(row.energy_kwh),
"energy_bill": float(row.energy_bill),
"heat_demand": float(row.heat_demand),
}
for row in rows
}
def get_installed_measure_types_by_uprn(
session,
uprn: int,
) -> Set[str]:
rows = (
session.query(InstalledMeasure.measure_type)
.filter(InstalledMeasure.uprn == uprn)
.filter(InstalledMeasure.is_active.is_(True))
.all()
)
# Convert enums → strings
return {
r[0].value if hasattr(r[0], "value") else r[0]
for r in rows
}
# ------------------------------------------------------------
# PROPERTY REBASING (READ-ONLY)
# ------------------------------------------------------------
def compute_property_sap_updates(
properties: List[PropertyModel],
sap_adjustments: Dict[int, float],
) -> List[dict]:
"""
Returns property SAP rebasing results.
Does NOT mutate DB objects.
"""
updates = []
for prop in properties:
if prop.uprn is None or prop.original_sap_points is None:
continue
sap_delta = sap_adjustments.get(prop.uprn, 0.0)
new_sap = prop.original_sap_points + sap_delta
updates.append({
"property_id": prop.id,
"uprn": prop.uprn,
"original_sap_points": prop.original_sap_points,
"installed_sap_delta": sap_delta,
"new_sap_points": new_sap,
"is_adjusted": sap_delta != 0,
})
return updates
# ------------------------------------------------------------
# PLAN RECOMPUTATION HELPERS
# ------------------------------------------------------------
def get_effective_plan_recommendations(
session,
plan_id: int,
excluded_measure_types: Set[str],
) -> List[Recommendation]:
q = (
session.query(Recommendation)
.join(PlanRecommendations)
.filter(PlanRecommendations.plan_id == plan_id)
.filter(Recommendation.default.is_(True))
)
if excluded_measure_types:
q = q.filter(
~Recommendation.measure_type.in_(excluded_measure_types)
)
return q.all()
def aggregate_plan_metrics(recommendations: list[Recommendation]):
agg = {
"sap_points": 0.0,
"co2_savings": 0.0,
"energy_bill_savings": 0.0,
"energy_consumption_savings": 0.0,
"valuation_increase": 0.0,
"cost_of_works": 0.0,
"contingency_cost": 0.0,
}
for r in recommendations:
agg["sap_points"] += r.sap_points or 0.0
agg["co2_savings"] += r.co2_equivalent_savings or 0.0
agg["energy_bill_savings"] += r.energy_cost_savings or 0.0
agg["energy_consumption_savings"] += r.energy_savings or 0.0
agg["valuation_increase"] += r.property_valuation_increase or 0.0
base_cost = r.estimated_cost or 0.0
agg["cost_of_works"] += base_cost
agg["contingency_cost"] += calculate_contingency_for_recommendation(r)
return agg
# ------------------------------------------------------------
# PLAN REBASING (READ-ONLY)
# ------------------------------------------------------------
def compute_plan_updates(
session,
plans: List[Plan],
properties_by_id: Dict[int, PropertyModel],
epcs_by_property_id: Dict[int, PropertyDetailsEpcModel],
property_sap_updates: Dict[int, dict],
) -> List[dict]:
"""
Computes plan metrics assuming properties are already rebased.
"""
updates = []
for plan in plans:
prop = properties_by_id.get(plan.property_id)
epc = epcs_by_property_id.get(plan.property_id)
prop_update = property_sap_updates.get(plan.property_id)
if not prop or not epc or not prop_update:
continue
installed_types = get_installed_measure_types_by_uprn(
session, prop.uprn
)
future_recs = get_effective_plan_recommendations(
session,
plan.id,
installed_types,
)
metrics = aggregate_plan_metrics(future_recs)
baseline_bill = (
epc.heating_cost_current
+ epc.hot_water_cost_current
+ epc.lighting_cost_current
+ epc.appliances_cost_current
+ epc.gas_standing_charge
+ epc.electricity_standing_charge
)
post_sap = prop_update["new_sap_points"] + metrics["sap_points"]
updates.append({
"plan_id": plan.id,
"property_id": plan.property_id,
# SAP / EPC
"post_sap_points": post_sap,
"post_epc_rating": sap_to_epc(post_sap),
# Carbon
"co2_savings": metrics["co2_savings"],
"post_co2_emissions": (
epc.co2_emissions - metrics["co2_savings"]
if epc.co2_emissions is not None
else None
),
# Energy bills
"energy_bill_savings": metrics["energy_bill_savings"],
"post_energy_bill": baseline_bill - metrics["energy_bill_savings"],
# Energy consumption
"energy_consumption_savings": metrics["energy_consumption_savings"],
"post_energy_consumption": (
epc.primary_energy_consumption
- metrics["energy_consumption_savings"]
),
# Valuation
"valuation_increase": metrics["valuation_increase"],
"valuation_post_retrofit": (
prop.current_valuation + metrics["valuation_increase"]
if prop.current_valuation is not None
else None
),
# Costs
"cost_of_works": metrics["cost_of_works"],
"contingency_cost": metrics["contingency_cost"],
})
return updates
def calculate_contingency_for_recommendation(
recommendation,
) -> float:
"""
Recompute contingency for a recommendation using the same
logic as the costing engine.
Assumptions:
- recommendation.estimated_cost is the 'total' cost
- contingency is a percentage of total
"""
if recommendation.estimated_cost is None:
return 0.0
# Normalise measure_type (Enum → str)
measure_type = (
recommendation.measure_type.value
if hasattr(recommendation.measure_type, "value")
else recommendation.measure_type
)
# Measure-specific contingency if defined, else global fallback
contingency_rate = Costs.CONTINGENCIES.get(
measure_type,
Costs.CONTINGENCY, # default (e.g. 10%)
)
return recommendation.estimated_cost * contingency_rate
def persist_property_sap_updates(
property_updates_by_id: dict[int, dict],
):
"""
Writes adjusted SAP values back to property table.
Safe to re-run.
"""
with db_session() as session:
properties = (
session.query(PropertyModel)
.filter(PropertyModel.id.in_(property_updates_by_id.keys()))
.all()
)
for prop in properties:
update = property_updates_by_id[prop.id]
prop.installed_measures_sap_point_adjustment = update["installed_sap_delta"]
prop.is_sap_points_adjusted_for_installed_measures = update["is_adjusted"]
prop.current_sap_points = update["new_sap_points"]
prop.current_epc_rating = sap_to_epc(update["new_sap_points"])
print(f"✅ Updated {len(properties)} properties")
def compute_epc_rebasing_updates(
epcs: Dict[int, PropertyDetailsEpcModel],
properties_by_id: Dict[int, PropertyModel],
installed_adjustments_by_uprn: Dict[int, dict],
) -> Dict[int, dict]:
"""
Computes EPC rebasing updates without mutating DB objects.
Keyed by property_id.
"""
updates: Dict[int, dict] = {}
for property_id, epc in epcs.items():
prop = properties_by_id.get(property_id)
if not prop or prop.uprn is None:
continue
adj = installed_adjustments_by_uprn.get(prop.uprn)
if not adj:
continue
updates[property_id] = {
"property_id": property_id,
# Originals (only set once)
"original_co2_emissions": (
epc.original_co2_emissions
if epc.original_co2_emissions is not None
else epc.co2_emissions
),
"original_primary_energy_consumption": (
epc.original_primary_energy_consumption
if epc.original_primary_energy_consumption is not None
else epc.primary_energy_consumption
),
"original_current_energy_demand": (
epc.original_current_energy_demand
if epc.original_current_energy_demand is not None
else epc.current_energy_demand
),
"original_current_energy_demand_heating_hotwater": (
epc.original_current_energy_demand_heating_hotwater
if epc.original_current_energy_demand_heating_hotwater is not None
else epc.current_energy_demand_heating_hotwater
),
# Adjustments (always re-applied from originals)
"installed_measures_co2_adjustment": adj["co2"],
"installed_measures_energy_demand_adjustment": adj["energy_kwh"],
"installed_measures_total_energy_bill_adjustment": adj["energy_bill"],
"installed_measures_heat_demand_adjustment": adj["heat_demand"],
}
return updates
def persist_plan_updates(plan_updates: list[dict]):
"""
Writes recalculated plan metrics.
Safe to re-run.
"""
with db_session() as session:
plans = (
session.query(Plan)
.filter(Plan.id.in_([u["plan_id"] for u in plan_updates]))
.all()
)
plans_by_id = {p.id: p for p in plans}
for update in plan_updates:
plan = plans_by_id.get(update["plan_id"])
if not plan:
continue
# SAP / EPC
plan.post_sap_points = update["post_sap_points"]
plan.post_epc_rating = Epc(update["post_epc_rating"])
# Carbon
plan.co2_savings = update["co2_savings"]
plan.post_co2_emissions = update["post_co2_emissions"]
# Energy
plan.energy_bill_savings = update["energy_bill_savings"]
plan.post_energy_bill = update["post_energy_bill"]
plan.energy_consumption_savings = update["energy_consumption_savings"]
plan.post_energy_consumption = update["post_energy_consumption"]
# Valuation
plan.valuation_increase = update["valuation_increase"]
plan.valuation_post_retrofit = update["valuation_post_retrofit"]
# Costs
plan.cost_of_works = update["cost_of_works"]
plan.contingency_cost = update["contingency_cost"]
print(f"✅ Updated {len(plans)} plans")
def persist_epc_rebasing_updates(
epc_updates_by_property_id: Dict[int, dict],
):
"""
Overwrites EPC metrics using installed-measure rebasing.
Safe to re-run.
"""
with db_session() as session:
epcs = (
session.query(PropertyDetailsEpcModel)
.filter(
PropertyDetailsEpcModel.property_id.in_(
epc_updates_by_property_id.keys()
)
)
.all()
)
for epc in epcs:
u = epc_updates_by_property_id[epc.property_id]
# Store originals once
epc.original_co2_emissions = u["original_co2_emissions"]
epc.original_primary_energy_consumption = (
u["original_primary_energy_consumption"]
)
epc.original_current_energy_demand = (
u["original_current_energy_demand"]
)
epc.original_current_energy_demand_heating_hotwater = (
u["original_current_energy_demand_heating_hotwater"]
)
# Apply rebased values
epc.co2_emissions = (
u["original_co2_emissions"]
- u["installed_measures_co2_adjustment"]
)
epc.primary_energy_consumption = (
u["original_primary_energy_consumption"]
- u["installed_measures_heat_demand_adjustment"]
)
epc.current_energy_demand = (
u["original_current_energy_demand"]
- u["installed_measures_energy_demand_adjustment"]
)
# Flags + audit fields
epc.installed_measures_co2_adjustment = (
u["installed_measures_co2_adjustment"]
)
epc.installed_measures_energy_demand_adjustment = (
u["installed_measures_energy_demand_adjustment"]
)
epc.installed_measures_total_energy_bill_adjustment = (
u["installed_measures_total_energy_bill_adjustment"]
)
epc.installed_measures_heat_demand_adjustment = (
u["installed_measures_heat_demand_adjustment"]
)
epc.is_epc_adjusted_for_installed_measures = True
print(f"✅ Updated {len(epcs)} EPC records")
# ------------------------------------------------------------
# EXECUTION (DRY RUN)
# ------------------------------------------------------------
PORTFOLIO_ID = 430
# TODO - run the original sap points update on the peabody portfolio
with db_read_session() as session:
properties = (
session.query(PropertyModel)
.filter(PropertyModel.portfolio_id == PORTFOLIO_ID)
.all()
)
plans = (
session.query(Plan)
.filter(Plan.portfolio_id == PORTFOLIO_ID)
.all()
)
epcs = {
e.property_id: e
for e in (
session.query(PropertyDetailsEpcModel)
.join(PropertyModel)
.filter(PropertyModel.portfolio_id == PORTFOLIO_ID)
.all()
)
}
installed_adjustments = (
get_installed_measure_adjustments_by_uprn_for_portfolio(
session,
PORTFOLIO_ID,
)
)
property_updates = compute_property_sap_updates(
properties,
{uprn: v["sap_points"] for uprn, v in installed_adjustments.items()}
)
properties_by_id = {p.id: p for p in properties}
property_updates_by_id = {
u["property_id"]: u
for u in property_updates
}
epc_updates = compute_epc_rebasing_updates(
epcs,
properties_by_id,
installed_adjustments,
)
plan_updates = compute_plan_updates(
session,
plans,
properties_by_id,
epcs,
property_updates_by_id,
)
# When ready to run!
persist_property_sap_updates(property_updates_by_id)
persist_plan_updates(plan_updates)
persist_epc_rebasing_updates(epc_updates)

View file

@ -0,0 +1,156 @@
import pandas as pd
from sqlalchemy.orm import Session
from sqlalchemy import text, select
from backend.app.db.connection import db_read_session
from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel
from backend.app.db.models.recommendations import Plan
PORTFOLIO_ID = 431
with db_read_session() as session:
# Get all properties from PropertyDetailsEpcModel, where estimated is True, for portfolio 419
estimated_epcs = session.query(PropertyDetailsEpcModel).filter(
# PropertyDetailsEpcModel.estimated == True,
PropertyDetailsEpcModel.property_id.in_(
session.query(PropertyModel.id).filter(PropertyModel.portfolio_id == PORTFOLIO_ID)
)
).all()
# Get the ids
estimated_epc_ids = [epc.property_id for epc in estimated_epcs]
# I want to get the UPRNS for these properties, from the property model
with db_read_session() as session:
estimated_uprns = session.query(PropertyModel.uprn).filter(
PropertyModel.id.in_(
session.query(PropertyDetailsEpcModel.property_id).filter(
PropertyDetailsEpcModel.id.in_(estimated_epc_ids)
)
)
).all()
estimated_uprns_list = [uprn for (uprn,) in estimated_uprns]
# Go the the SAL
sal_1 = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/20251213 Model "
"data.xlsx",
sheet_name="Standardised Asset List"
)
sal_2 = pd.read_excel(
"/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/20260105 - additional "
"UPRNS.xlsx",
sheet_name="Standardised Asset List"
)
sal = pd.concat([sal_1, sal_2])
sal = sal.drop_duplicates(subset=['epc_os_uprn'])
estimated_to_refresh = sal[sal["epc_os_uprn"].isin(estimated_uprns_list)].copy()
SCENARIOS = [
871, # EPC C - fabric first, no solid floor, ashp 3.0
863, # EPC B, No EWI/IWI, No Solid Floor, ASHP 3.0 COP
862, # EPC B - No solid floor, ASHP COP 3.0
861, # EPC C, No EWI/IWI, No Solid Floor, ASHP 3.0 COP
859, # EPC C - no solid floor, ashp 3.0
885, # EPC B - fabric first, no solid floor, ashp 3.0
]
# Get all plans, associated to these properties - the property IDs are in estimated_epc_ids
with db_read_session() as session:
result = session.execute(
select(Plan.id, Plan.property_id)
.where(Plan.property_id.in_(estimated_epc_ids))
)
plans = [
{
"plan_id": row.id,
"property_id": row.property_id,
} for row in result
]
df = pd.DataFrame(plans)
df = df.sort_values("property_id", ascending=True)
agg = df.groupby("property_id").size().reset_index(name="n_plans")
agg = agg.sort_values("n_plans", ascending=True)
agg[agg["n_plans"] != 1]
assert all(agg["n_plans"] == 1)
def delete_plan_batch(session: Session, plan_ids: list[int]):
if not plan_ids:
return
session.execute(text("SET LOCAL lock_timeout = '5s'"))
params = {"plan_ids": plan_ids}
# ----------------------------
# recommendation_materials
# ----------------------------
session.execute(
text("""
DELETE FROM recommendation_materials rm
USING plan_recommendations pr
WHERE rm.recommendation_id = pr.recommendation_id
AND pr.plan_id = ANY(:plan_ids)
"""),
params,
)
# ----------------------------
# plan_recommendations
# ----------------------------
session.execute(
text("""
DELETE FROM plan_recommendations
WHERE plan_id = ANY(:plan_ids)
"""),
params,
)
# ----------------------------
# recommendations (only those used by these plans)
# ----------------------------
session.execute(
text("""
DELETE FROM recommendation r
WHERE r.id IN (
SELECT DISTINCT recommendation_id
FROM plan_recommendations
WHERE plan_id = ANY(:plan_ids)
)
"""),
params,
)
# ----------------------------
# plans LAST
# ----------------------------
session.execute(
text("""
DELETE FROM plan
WHERE id = ANY(:plan_ids)
"""),
params,
)
# Store the SAL
filename = ("/Users/khalimconn-kowlessar/Documents/hestia/Customers/Peabody/Nov 2025 Consulting Project/20260101 "
"sal.xlsx")
with pd.ExcelWriter(filename) as writer:
sal.to_excel(writer, sheet_name="Standardised Asset List", index=False)
# Top 1000 for testing
sal.iloc[0:1000, :].to_excel(writer, sheet_name="batch 1", index=False)
# Batch 2 is the next 20,000
sal.iloc[1000:21000, :].to_excel(writer, sheet_name="batch 2", index=False)
# Batch 3 is the next 20,000
sal.iloc[21000:41000, :].to_excel(writer, sheet_name="batch 3", index=False)
sal.iloc[41000:61000, :].to_excel(writer, sheet_name="batch 4", index=False)
sal.iloc[61000:81000, :].to_excel(writer, sheet_name="batch 5", index=False)
sal.iloc[81000:, :].to_excel(writer, sheet_name="batch 5", index=False)

View file

@ -3,11 +3,14 @@ This script prepares the data for the financial model
""" """
import pandas as pd import pandas as pd
import numpy as np
from backend.app.utils import sap_to_epc from backend.app.utils import sap_to_epc
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from backend.app.db.connection import db_engine from backend.app.db.connection import db_engine, db_read_session
from backend.app.db.models.recommendations import Recommendation, Plan, PlanRecommendations from backend.app.db.models.recommendations import Recommendation, Plan, PlanRecommendations, RecommendationMaterials
from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel, PropertyDetailsSpatial from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel, PropertyDetailsSpatial
from backend.app.db.functions.materials_functions import get_materials
from collections import defaultdict
# PORTFOLIO_ID = 206 # PORTFOLIO_ID = 206
# SCENARIOS = [389] # SCENARIOS = [389]
@ -18,6 +21,7 @@ SCENARIOS = [
862, # EPC B - No solid floor, ASHP COP 3.0 862, # EPC B - No solid floor, ASHP COP 3.0
861, # EPC C, No EWI/IWI, No Solid Floor, ASHP 3.0 COP 861, # EPC C, No EWI/IWI, No Solid Floor, ASHP 3.0 COP
859, # EPC C - no solid floor, ashp 3.0 859, # EPC C - no solid floor, ashp 3.0
885, # EPC B - fabric first, no solid floor, ashp 3.0
] ]
scenario_names = { scenario_names = {
871: "EPC C, fabric first, no solid floor, ashp 3.0", 871: "EPC C, fabric first, no solid floor, ashp 3.0",
@ -25,6 +29,7 @@ scenario_names = {
862: "EPC B, No solid floor, ASHP COP 3.0", 862: "EPC B, No solid floor, ASHP COP 3.0",
861: "EPC C, No EWI IWI, No Solid Floor, ASHP 3.0 COP", 861: "EPC C, No EWI IWI, No Solid Floor, ASHP 3.0 COP",
859: "EPC C, no solid floor, ashp 3.0", 859: "EPC C, no solid floor, ashp 3.0",
885: "EPC B, fabric first, no solid floor, ashp 3.0"
} }
@ -32,60 +37,97 @@ def get_data(portfolio_id, scenario_ids):
session = sessionmaker(bind=db_engine)() session = sessionmaker(bind=db_engine)()
session.begin() session.begin()
# Get properties and their details for a specific portfolio # --------------------
# Properties
# --------------------
properties_query = session.query( properties_query = session.query(
PropertyModel, PropertyModel,
PropertyDetailsEpcModel PropertyDetailsEpcModel
).join( ).join(
PropertyDetailsEpcModel, PropertyModel.id == PropertyDetailsEpcModel.property_id PropertyDetailsEpcModel,
PropertyModel.id == PropertyDetailsEpcModel.property_id
).filter( ).filter(
PropertyModel.portfolio_id == portfolio_id # Filter by portfolio ID PropertyModel.portfolio_id == portfolio_id
).all() ).all()
# Transform properties data to include all fields dynamically
properties_data = [ properties_data = [
{**{col.name: getattr(prop.PropertyModel, col.name) for col in PropertyModel.__table__.columns}, {
**{col.name: getattr(prop.PropertyDetailsEpcModel, col.name) for col in **{col.name: getattr(p.PropertyModel, col.name)
PropertyDetailsEpcModel.__table__.columns}} for col in PropertyModel.__table__.columns},
for prop in properties_query **{col.name: getattr(p.PropertyDetailsEpcModel, col.name)
for col in PropertyDetailsEpcModel.__table__.columns},
}
for p in properties_query
] ]
# Get property IDs from fetched properties # --------------------
# Plans
# --------------------
plans_query = session.query(Plan).filter(
Plan.scenario_id.in_(scenario_ids)
).all()
# Get plans linked to the fetched properties
plans_query = session.query(Plan).filter(Plan.scenario_id.in_(scenario_ids)).all()
# Transform plans data to include all fields dynamically
plans_data = [ plans_data = [
{col.name: getattr(plan, col.name) for col in Plan.__table__.columns} {col.name: getattr(plan, col.name) for col in Plan.__table__.columns}
for plan in plans_query for plan in plans_query
] ]
# Extract plan IDs for filtering recommendations through PlanRecommendations plan_ids = [p["id"] for p in plans_data]
plan_ids = [plan['id'] for plan in plans_data]
# Get recommendations through PlanRecommendations for those plans and that are default # --------------------
# Recommendations (NO materials yet)
# --------------------
recommendations_query = session.query( recommendations_query = session.query(
Recommendation, Recommendation,
Plan.scenario_id Plan.scenario_id
).join( ).join(
PlanRecommendations, Recommendation.id == PlanRecommendations.recommendation_id PlanRecommendations,
Recommendation.id == PlanRecommendations.recommendation_id
).join( ).join(
Plan, Plan.id == PlanRecommendations.plan_id # Join with Plan to access scenario_id Plan,
Plan.id == PlanRecommendations.plan_id
).filter( ).filter(
PlanRecommendations.plan_id.in_(plan_ids), PlanRecommendations.plan_id.in_(plan_ids),
Recommendation.default == True # Filtering for default recommendations Recommendation.default.is_(True)
).all() ).all()
# Transform recommendations data to include all fields dynamically and include scenario_id
recommendations_data = [ recommendations_data = [
{**{col.name: getattr(rec.Recommendation, col.name) if hasattr(rec, 'Recommendation') else getattr(rec, {
col.name) for **{col.name: getattr(r.Recommendation, col.name)
col in Recommendation.__table__.columns}, for col in Recommendation.__table__.columns},
"Scenario ID": rec.scenario_id} "scenario_id": r.scenario_id,
for rec in recommendations_query "materials": [] # placeholder
}
for r in recommendations_query
] ]
recommendation_ids = [r["id"] for r in recommendations_data]
# --------------------
# Recommendation materials (SEPARATE QUERY)
# --------------------
materials_query = session.query(
RecommendationMaterials
).filter(
RecommendationMaterials.recommendation_id.in_(recommendation_ids)
).all()
# Group materials by recommendation_id
materials_by_recommendation = defaultdict(list)
for m in materials_query:
materials_by_recommendation[m.recommendation_id].append({
"material_id": m.material_id,
"depth": m.depth,
"quantity": m.quantity,
"quantity_unit": m.quantity_unit,
"estimated_cost": m.estimated_cost,
})
# Attach materials safely (no filtering side effects)
for r in recommendations_data:
r["materials"] = materials_by_recommendation.get(r["id"], [])
session.close() session.close()
return properties_data, plans_data, recommendations_data return properties_data, plans_data, recommendations_data
@ -97,6 +139,40 @@ properties_df = pd.DataFrame(properties_data)
plans_df = pd.DataFrame(plans_data) plans_df = pd.DataFrame(plans_data)
recommendations_df = pd.DataFrame(recommendations_data) recommendations_df = pd.DataFrame(recommendations_data)
with db_read_session() as session:
materials = get_materials(session)
materials = pd.DataFrame(materials)
material_lookup = (
materials
.set_index("id")[["type", "includes_battery"]]
.to_dict("index")
)
def has_solar_with_battery(materials_list):
for m in materials_list or []:
mat = material_lookup.get(m["material_id"])
if not mat:
continue
if mat["type"] == "solar_pv" and mat["includes_battery"]:
return True
return False
recommendations_df["has_solar_with_battery"] = (
recommendations_df["materials"].apply(has_solar_with_battery)
)
recommendations_df["measure_type"] = np.where(
recommendations_df["has_solar_with_battery"] == True,
recommendations_df["measure_type"] + "_with_battery",
recommendations_df["measure_type"]
)
# Adjust material type to indicate if there is a battery included
from utils.s3 import read_csv_from_s3, read_excel_from_s3 from utils.s3 import read_csv_from_s3, read_excel_from_s3
# asset_list = read_excel_from_s3( # asset_list = read_excel_from_s3(
@ -107,13 +183,13 @@ from utils.s3 import read_csv_from_s3, read_excel_from_s3
for scenario_id in SCENARIOS: for scenario_id in SCENARIOS:
# Get recs for this scenario # Get recs for this scenario
recommended_measures_df = recommendations_df[recommendations_df["Scenario ID"] == scenario_id][ recommended_measures_df = recommendations_df[recommendations_df["scenario_id"] == scenario_id][
["property_id", "measure_type", "estimated_cost", "default"] ["property_id", "measure_type", "estimated_cost", "default"]
] ]
recommended_measures_df = recommended_measures_df[recommended_measures_df["default"]] recommended_measures_df = recommended_measures_df[recommended_measures_df["default"]]
recommended_measures_df = recommended_measures_df.drop(columns=["default"]) recommended_measures_df = recommended_measures_df.drop(columns=["default"])
post_install_sap = recommendations_df[recommendations_df["Scenario ID"] == scenario_id][ post_install_sap = recommendations_df[recommendations_df["scenario_id"] == scenario_id][
["property_id", "default", "sap_points"]] ["property_id", "default", "sap_points"]]
post_install_sap = post_install_sap[post_install_sap["default"]] post_install_sap = post_install_sap[post_install_sap["default"]]
# Sum up the sap points by property id # Sum up the sap points by property id