From 2aecf27900cfd2d280ce6e342b08e913528d7385 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Oct 2025 10:51:34 +0000 Subject: [PATCH] debugging funding optimiser for existing gshp - remove ashp and hhrsh recommendations when gshp in place --- backend/Funding.py | 23 +++- backend/app/db/models/inspections.py | 163 ++++++++++++++++++++++++++ backend/tests/test_funding.py | 82 +++++++++++++ recommendations/HeatingRecommender.py | 7 +- 4 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 backend/app/db/models/inspections.py diff --git a/backend/Funding.py b/backend/Funding.py index d590474c..ece8e3cf 100644 --- a/backend/Funding.py +++ b/backend/Funding.py @@ -1,11 +1,14 @@ from enum import Enum from typing import List import pandas as pd +from utils.logger import setup_logger from etl.epc_clean.epc_attributes.MainheatAttributes import MainHeatAttributes from backend.app.plan.schemas import VALID_HOUSING_TYPES, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, \ MEASURE_MAP +logger = setup_logger(__name__) + class EligibilityCaveats(Enum): EPC_RATING = "epc_rating" # EPC requirements not met @@ -637,13 +640,25 @@ class Funding: if self.starting_sap_band in ["Low_C", "High_C", "Low_B", "High_B", "Low_A", "High_A"]: return 0 - pps = filtered_pps_matrix[ - (filtered_pps_matrix["Pre_Main_Heating_Source"] == pre_heating_system) & - (filtered_pps_matrix["Post_Main_Heating_Source"] == "Air to Water ASHP") & - (filtered_pps_matrix["Measure_Type"] == "B_Upgrade_nopreHCs") + pps_data = filtered_pps_matrix[ + filtered_pps_matrix["Post_Main_Heating_Source"] == "Air to Water ASHP" + ] + + if pre_heating_system not in pps_data["Pre_Main_Heating_Source"].values: + logger.info( + f"No PPS data for ASHP upgrade from {pre_heating_system}, returning 0" + ) + return 0 + + pps = pps_data[ + (pps_data["Pre_Main_Heating_Source"] == pre_heating_system) & + (pps_data["Measure_Type"] == "B_Upgrade_nopreHCs") # We assume we'll be making a heating system upgrade ] + # Not every pre heating system will result in PPS, e.g. a ground source heat pump to ASHP upgrade + # won't have a PPS. + if pps.shape[0] != 1: raise ValueError("something went wrong, more than one pps for ashp") return pps.squeeze()["Cost Savings"] diff --git a/backend/app/db/models/inspections.py b/backend/app/db/models/inspections.py new file mode 100644 index 00000000..c9925a2a --- /dev/null +++ b/backend/app/db/models/inspections.py @@ -0,0 +1,163 @@ +import enum +import pytz +import datetime +from sqlalchemy import ( + Column, + BigInteger, + Text, + DateTime, + Enum, + ForeignKey, +) +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +# ------------------------------------------------------------------- +# ENUM DEFINITIONS (equivalent to drizzle pgEnum calls) +# ------------------------------------------------------------------- + +class InspectionArchetype(enum.Enum): + BUNGALOW = "Bungalow" + FLAT = "Flat" + MAISONETTE = "Maisonette" + HOUSE = "House" + NON_DOMESTIC = "non-domestic" + + +class InspectionArchetype2(enum.Enum): + DETACHED = "detached" + MID_TERRACE = "mid-terrace" + ENCLOSED_MID_TERRACE = "enclosed mid-terrace" + END_TERRACE = "end-terrace" + ENCLOSED_END_TERRACE = "enclosed end-terrace" + SEMI_DETACHED = "semi-detached" + + +class InspectionsWallConstruction(enum.Enum): + CAVITY = "cavity" + SOLID = "solid" + SYSTEM_BUILT = "system built" + TIMBER_FRAMED = "timber framed" + STEEL_FRAMED = "steel framed" + RE_WALLED_CAVITY = "re-walled cavity" + MANSARD_PRE_FAB = "mansard pre-fab" + MANSARD_EWI = "mansard ewi" + MANSARD_RE_WALLED = "mansard re-walled" + + +class InspectionsWallInsulation(enum.Enum): + EMPTY_CAVITY = "empty cavity" + FILLED_AT_BUILD = "filled at build" + PARTIAL = "partial" + RETRO_DRILLED = "retro drilled" + EWI = "ewi" + IWI = "iwi" + SOLID_NON_CAVITY = "solid non-cavity" + SYSTEM_BUILT = "system built" + TIMBER_FRAMED = "timber framed" + STEEL_FRAMED = "steel framed" + + +class InspectionsInsulationMaterial(enum.Enum): + EMPTY_50_90 = "empty 50-90" + EMPTY_100_PLUS = "empty 100+" + EMPTY_30_40 = "empty 30-40" + EMPTY_LESS_THAN_30 = "empty less than 30" + LOOSE_FIBRE_WOOL = "loose fibre/wool" + EPS_CELO_KING = "eps/celo/king" + FIBRE_BATTS_WITH_CAVITY = "fibre batts - with cavity" + FIBRE_BATTS_NO_CAVITY = "fibre batts - no cavity" + LOOSE_BEAD = "loose bead" + GLUED_BEAD = "glued bead" + FORMALDEHYDE = "formaldehyde" + BUBBLE_WRAP = "bubble wrap" + POLY_CHUNKS = "poly chunks" + + +class InspectionBorescoped(enum.Enum): + YES = "yes" + NO = "no" + REFUSED = "refused" + + +class InspectionsRoofOrientation(enum.Enum): + NORTH = "north" + EAST = "east" + SOUTH = "south" + WEST = "west" + NORTH_EAST = "north-east" + NORTH_WEST = "north-west" + SOUTH_EAST = "south-east" + SOUTH_WEST = "south-west" + N_S_SPLIT = "n/s split" + E_W_SPLIT = "e/w split" + NE_SW_SPLIT = "ne/sw split" + NW_SE_SPLIT = "nw/se split" + FLAT_ROOF = "flat roof" + NO_ROOF = "no roof" + ROOF_TOO_SMALL = "roof too small" + ALREADY_HAS_SOLAR_PV = "already has solar pv" + + +class InspectionsTileHung(enum.Enum): + YES = "yes" + NO = "no" + FIRST_FLOOR_FLATS_TILE_HUNG = "first floor flats are tile hung" + + +class InspectionsRendered(enum.Enum): + NO_RENDER = "no render" + INSUFFICIENT_DPC_SPACE = "rendered with “insufficient” space between dpc and render" + SUFFICIENT_DPC_SPACE = "rendered with “sufficient” space between dpc and render" + + +class InspectionsCladding(enum.Enum): + NONE = "none" + SUFFICIENT_SPACE = "cladded with “sufficient space to fill the wall”" + INSUFFICIENT_SPACE = "cladded with “insufficient space to fill the wall”" + + +class InspectionsAccessIssues(enum.Enum): + SEE_NOTES = "see notes" + DAMP_ISSUES = "damp issues" + FOLIAGE_ON_WALLS = "foliage on walls" + BUSHES_AGAINST_WALL = "bushes against wall" + TREES_AROUND_ABOVE = "trees around/anove property" + HIGH_RISE = "high rise block flats/maisonettes" + CONSERVATORY = "conservatory" + LEAN_TO = "lean-to" + GARAGE = "garage" + EXTENSION = "extension" + DECKING = "decking" + SHED_AGAINST_WALL = "shed against wall" + + +class InspectionModel(Base): + __tablename__ = "inspections" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + property_id = Column(BigInteger, ForeignKey("property.id"), nullable=False) + + archetype = Column(Enum(InspectionArchetype), nullable=True) + archetype_2 = Column(Enum(InspectionArchetype2), nullable=True) + wall_construction = Column(Enum(InspectionsWallConstruction), nullable=True) + insulation = Column(Enum(InspectionsWallInsulation), nullable=True) + insulation_material = Column(Enum(InspectionsInsulationMaterial), nullable=True) + borescoped = Column(Enum(InspectionBorescoped), nullable=True) + roof_orientation = Column(Enum(InspectionsRoofOrientation), nullable=True) + tile_hung = Column(Enum(InspectionsTileHung), nullable=True) + rendered = Column(Enum(InspectionsRendered), nullable=True) + cladding = Column(Enum(InspectionsCladding), nullable=True) + access_issues = Column(Enum(InspectionsAccessIssues), nullable=True) + + notes = Column(Text) + surveyor_name = Column(Text) + + created_at = Column( + DateTime, nullable=False, default=datetime.datetime.now(pytz.utc) + ) + uploaded_at = Column( + DateTime, nullable=False, default=datetime.datetime.now(pytz.utc) + ) diff --git a/backend/tests/test_funding.py b/backend/tests/test_funding.py index d84480ce..513c3271 100644 --- a/backend/tests/test_funding.py +++ b/backend/tests/test_funding.py @@ -1393,3 +1393,85 @@ def test_private_epc_e_solar_with_heating_and_minimum_insulation_produces_uplift assert funding.eco4_uplift and funding.eco4_uplift > 0 # And total funding should include that uplift assert funding.eco4_funding and funding.eco4_funding > 0 + + +def test_existing_gshp_to_ashp(): + 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 ' + 'single tariff', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': 7.7, 'already_installed': False, + 'simulation_config': {'mainheat_energy_eff_ending': 'Good', 'hot_water_energy_eff_ending': 'Average', + 'has_air_source_heat_pump_ending': True, 'has_ground_source_heat_pump_ending': False, + 'extra_features_ending': None, + 'thermostatic_control_ending': 'time and temperature zone control', + 'switch_system_ending': None, 'multiple_room_thermostats_ending': False, + 'mainheatc_energy_eff_ending': 'Very Good'}, + 'description_simulation': {'mainheat-description': 'Air source heat pump, radiators, electric', + 'mainheat-energy-eff': 'Good', 'hot-water-energy-eff': 'Average', + 'hotwater-description': 'From main system', + 'mainheatcont-description': 'Time and temperature zone control', + 'mainheatc-energy-eff': 'Very Good'}, 'total': 13188.996000000001, + 'contingency': 3145.8150000000005, 'contingency_rate': 0.35, 'vat': 2080.666, 'labour_hours': 44.7, + 'labour_days': 6.0, 'innovation_rate': 0, 'recommendation_id': '6_phase=3', + 'efficiency': 13188.996000000001, 'co2_equivalent_savings': 0.4999999999999998, + 'heat_demand': 53.20000000000002, 'kwh_savings': 801.5000000000005, + 'energy_cost_savings': 327.31316785714296 + } + + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + eco4_social_cavity_abs_rate=13.5, + eco4_social_solid_abs_rate=17, + eco4_private_cavity_abs_rate=13.5, + eco4_private_solid_abs_rate=17, + gbis_social_cavity_abs_rate=21, + gbis_social_solid_abs_rate=25, + gbis_private_cavity_abs_rate=22, + gbis_private_solid_abs_rate=28, + tenure="Private", + ) + + ( + pps, ppf, iu, ups + ) = funding.get_innovation_uplift( + measure=r, + starting_sap=62, + floor_area=69, + is_cavity=True, + current_wall_uvalue=0.7, + is_partial=False, + existing_li_thickness=200, + mainheating={ + 'original_description': 'Ground source heat pump, radiators, electric', + 'clean_description': 'Ground source heat pump, radiators, electric', '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': False, + 'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False, + 'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False, + 'has_community_scheme': False, 'has_ground_source_heat_pump': True, 'has_no_system_present': False, + 'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, + 'has_electric_heat_pump': False, 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, + 'has_exhaust_source_heat_pump': False, 'has_community_heat_pump': False, 'has_hot-water-only': False, + 'has_electric': True, 'has_mains_gas': False, 'has_wood_logs': False, 'has_coal': False, 'has_oil': False, + 'has_wood_pellets': False, 'has_anthracite': False, 'has_dual_fuel_mineral_and_wood': False, + 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False, 'has_mineral_and_wood': False, + 'has_dual_fuel_appliance': False, 'has_assumed': False, 'has_electricaire': False, + 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False + }, + main_fuel={ + 'original_description': 'electricity (not community)', + 'clean_description': 'Electricity not community', 'fuel_type': 'electricity', 'tariff_type': None, + 'is_community': False, 'no_individual_heating_or_community_network': False, + 'complex_fuel_type': None + }, + mainheat_energy_eff="Poor", + ) + + # All should be zero + assert pps == 0 + assert ppf == 0 + assert iu == 0 + assert ups == 0 diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index 73edff53..41785104 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -103,6 +103,7 @@ class HeatingRecommender: self.property.main_heating["has_electric"] or self.property.main_heating["has_electricaire"] ) self.has_ashp = self.property.main_heating["has_air_source_heat_pump"] + self.has_gshp = self.property.main_heating["has_ground_source_heat_pump"] self.has_room_heaters = ( self.property.main_heating["has_room_heaters"] or self.property.main_heating["has_portable_electric_heaters"] @@ -151,8 +152,10 @@ class HeatingRecommender: "underfloor heating" not in self.property.main_heating["clean_description"] ) + # If the property has a ground source heat pump, or air source heat pump, we don't recommend HHRSH + return ( - hhr_suitable and (not ashp_only_heating_recommendation) and not self.has_ashp and + hhr_suitable and (not ashp_only_heating_recommendation) and not self.has_ashp and not self.has_gshp and ("high_heat_retention_storage_heater" in measures) ) @@ -345,7 +348,7 @@ class HeatingRecommender: if ( self.property.is_ashp_valid(measures=measures) and non_invasive_ashp_recommendation["suitable"] and - not self.has_ashp + not self.has_ashp and not self.has_gshp ): self.recommend_air_source_heat_pump( phase=phase,