mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
debugging backend
This commit is contained in:
parent
611f85c5eb
commit
b9a60e10d1
15 changed files with 275 additions and 69 deletions
|
|
@ -809,7 +809,7 @@ class Funding:
|
|||
if not has_eligibile_heating:
|
||||
# We check if there is a recommendation for an ASHP or HHRSH
|
||||
if ("air_source_heat_pump" not in measure_types) and (
|
||||
"high_heat_retention_storage_heater" not in measure_types):
|
||||
"high_heat_retention_storage_heaters" not in measure_types):
|
||||
return True, False, True
|
||||
|
||||
# 2) We check if there is a wall insulation measure for this property. If so, we make sure
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ class Property:
|
|||
measures=None,
|
||||
energy_assessment=None,
|
||||
is_new=True,
|
||||
inspections=None,
|
||||
**kwargs
|
||||
):
|
||||
|
||||
|
|
@ -210,6 +211,9 @@ class Property:
|
|||
self.energy_assessment_condition_data = energy_assessment["condition"]
|
||||
self.energy_assessment_is_newer = energy_assessment["energy_assessment_is_newer"]
|
||||
|
||||
# Store inspections
|
||||
self.inspections = inspections
|
||||
|
||||
# TODO: We keep this but only temporarily until we add bathrooms, bedrooms, building id to the condition data
|
||||
self.parse_kwargs(kwargs)
|
||||
|
||||
|
|
@ -1296,9 +1300,16 @@ class Property:
|
|||
self.roof["is_flat"] or self.roof["is_pitched"] or self.roof["is_roof_room"]
|
||||
)
|
||||
# If there is no existing solar PV, the photo-supply field will be None or a missing value
|
||||
has_no_existing_solar_pv = self.data["photo-supply"] in [
|
||||
None, 0, self.DATA_ANOMALY_MATCHES
|
||||
]
|
||||
|
||||
# We use inspections data to tell us this
|
||||
if self.inspections:
|
||||
has_no_existing_solar_pv = self.inspections.roof_orientation.value not in [
|
||||
"already has solar pv", "roof too small", "no roof"
|
||||
]
|
||||
else:
|
||||
has_no_existing_solar_pv = self.data["photo-supply"] in [
|
||||
None, 0, self.DATA_ANOMALY_MATCHES
|
||||
]
|
||||
|
||||
return is_valid_property_type and is_valid_roof_type and has_no_existing_solar_pv
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ ECO4_ELIGIBILE_FABRIC_MEASURES = [
|
|||
"suspended_floor_insulation", "solid_floor_insulation", "double_glazing", "secondary_glazing"
|
||||
]
|
||||
ECO4_ELIGIBLE_HEATING_MEASURES = [
|
||||
"boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump", "solar_pv"
|
||||
"boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "solar_pv"
|
||||
]
|
||||
|
||||
SPECIFIC_MEASURES = (
|
||||
|
|
@ -48,7 +48,7 @@ MEASURE_MAP = {
|
|||
],
|
||||
"roof_insulation": ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"],
|
||||
"floor_insulation": ["suspended_floor_insulation", "solid_floor_insulation"],
|
||||
"heating": ["boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump"],
|
||||
"heating": ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump"],
|
||||
"windows": ["double_glazing", "secondary_glazing"],
|
||||
"heating_controls": ["roomstat_programmer_trvs", "time_temperature_zone_control"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,9 @@ def parse_eco_packages(config: dict[str, Any], prepared_epc) -> tuple[list[str],
|
|||
|
||||
landlord_heating_system = config["landlord_heating_system"]
|
||||
# This is the initial version of tackling "already installed" measures
|
||||
already_installed = ["air_source_heat_pump"] if landlord_heating_system == "air source heat pump" else []
|
||||
already_installed = []
|
||||
if landlord_heating_system == "air source heat pump":
|
||||
already_installed.append("air_source_heat_pump")
|
||||
|
||||
# We map the categories to the desired measures and upgrade targets
|
||||
# We note that the categories are placeholder until we move the standardised asset list
|
||||
|
|
@ -148,7 +150,8 @@ def parse_eco_packages(config: dict[str, Any], prepared_epc) -> tuple[list[str],
|
|||
"plan_type": "solar_eco4"
|
||||
},
|
||||
"Solar Eligible, Needs Heating Upgrade": {
|
||||
"measures": ["solar_pv", "loft_insulation", "high_heat_retention_storage_heater", "mechanical_ventilation"],
|
||||
"measures": ["solar_pv", "loft_insulation", "high_heat_retention_storage_heaters",
|
||||
"mechanical_ventilation"],
|
||||
"target_sap": 86, # High B
|
||||
"plan_type": "solar_hhrsh_eco4"
|
||||
},
|
||||
|
|
@ -193,9 +196,9 @@ def parse_eco_packages(config: dict[str, Any], prepared_epc) -> tuple[list[str],
|
|||
|
||||
# If we have already installed an ASHP, we adjust the measures
|
||||
if "air_source_heat_pump" in already_installed:
|
||||
if "high_heat_retention_storage_heater" in measures:
|
||||
if "high_heat_retention_storage_heaters" in measures:
|
||||
# If we have a HHRSH already, we remove it
|
||||
measures.remove("high_heat_retention_storage_heater")
|
||||
measures.remove("high_heat_retention_storage_heaters")
|
||||
# Add in ASHP (replacing HHRSH if already had)
|
||||
measures.append("air_source_heat_pump")
|
||||
|
||||
|
|
|
|||
|
|
@ -597,6 +597,11 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
# If we have an ECO project, we parse the cavity/solar reasons
|
||||
eco_packages[property_id] = parse_eco_packages(config, prepared_epc)
|
||||
|
||||
# Final step - extract inspections data, if we have it - we inject into property for usage
|
||||
property_inspections = extract_inspection_data(config)
|
||||
if property_inspections:
|
||||
inspections_map[property_id] = property_inspections
|
||||
|
||||
input_properties.append(
|
||||
Property(
|
||||
id=property_id,
|
||||
|
|
@ -608,15 +613,11 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
property_valuation=req_data.valuation,
|
||||
non_invasive_recommendations=property_non_invasive_recommendations,
|
||||
energy_assessment=energy_assessment,
|
||||
inspections=inspections_map.get(property_id),
|
||||
**Property.extract_kwargs(config), # TODO: Depraecate this
|
||||
)
|
||||
)
|
||||
|
||||
# Final step - extract inspections data, if we have it
|
||||
property_inspections = extract_inspection_data(config)
|
||||
if property_inspections:
|
||||
inspections_map[property_id] = property_inspections
|
||||
|
||||
if not input_properties:
|
||||
return Response(status_code=204)
|
||||
|
||||
|
|
@ -898,7 +899,8 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
)
|
||||
|
||||
input_measures = optimiser_functions.prepare_input_measures(
|
||||
measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True
|
||||
measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True,
|
||||
property_eco_packages=eco_packages.get(p.id)
|
||||
)
|
||||
|
||||
# When the goal is Increasing EPC, we can run the funding optimiser
|
||||
|
|
@ -929,6 +931,11 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
|
||||
solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True)
|
||||
|
||||
# If the solution isn't eligible, we can't really consider it
|
||||
solutions = solutions[
|
||||
(solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none")
|
||||
]
|
||||
|
||||
if solutions["meets_upgrade_target"].any():
|
||||
# If we have a solution that meets the upgrade target, we select that one
|
||||
optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0]
|
||||
|
|
@ -940,7 +947,7 @@ async def model_engine(body: PlanTriggerRequest):
|
|||
scheme = optimal_solution["scheme"]
|
||||
funded_measures = optimal_solution["items"] if scheme != "none" else []
|
||||
solution = optimal_solution["items"] + optimal_solution["unfunded_items"]
|
||||
# This is the total amount of funding that the project will produce (including uplifts) (£)
|
||||
# This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£)
|
||||
project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \
|
||||
optimal_solution["partial_project_funding"]
|
||||
# This is the total amount of funding associated to the uplift (£)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class ModelApi:
|
|||
self.timestamp = timestamp
|
||||
self.prediction_buckets = prediction_buckets
|
||||
self.max_retries = max_retries
|
||||
self.semaphore = asyncio.Semaphore(2)
|
||||
self.semaphore = asyncio.Semaphore(3)
|
||||
|
||||
@staticmethod
|
||||
def get_aiohttp_session():
|
||||
|
|
@ -117,7 +117,7 @@ class ModelApi:
|
|||
}
|
||||
|
||||
async with self.semaphore:
|
||||
await asyncio.sleep(random.uniform(0.3, 1.2))
|
||||
# await asyncio.sleep(random.uniform(0.3, 1.2))
|
||||
try:
|
||||
async with session.post(url, json=payload, headers=headers, timeout=120) as response:
|
||||
if response.status != 200:
|
||||
|
|
@ -211,13 +211,14 @@ class ModelApi:
|
|||
response = await self.predict_async(f"s3://{bucket}/" + file_location, model_prefix, session=session)
|
||||
return model_prefix, response
|
||||
|
||||
results = []
|
||||
for coro in asyncio.as_completed([run_model(mp) for mp in model_prefixes]):
|
||||
result = await coro
|
||||
results.append(result)
|
||||
# Run all model calls concurrently
|
||||
results = await asyncio.gather(
|
||||
*(run_model(mp) for mp in model_prefixes),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
for model_prefix, response in results:
|
||||
if response:
|
||||
if response and not isinstance(response, Exception):
|
||||
predictions_bucket = self.prediction_buckets[model_prefix]
|
||||
predictions_df = pd.DataFrame(
|
||||
read_dataframe_from_s3_parquet(
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ innovation_scenarios = [
|
|||
"description": "Innovation PV + HHRSH upgrade, EPC E",
|
||||
"measures": [
|
||||
{"type": "solar_pv", "is_innovation": True, "innovation_uplift": 0.45},
|
||||
{"type": "high_heat_retention_storage_heater", "is_innovation": False, "innovation_uplift": 0}
|
||||
{"type": "high_heat_retention_storage_heaters", "is_innovation": False, "innovation_uplift": 0}
|
||||
],
|
||||
"starting_sap": 50,
|
||||
"mainheat_description": "Electric storage heaters",
|
||||
|
|
@ -45,7 +45,7 @@ innovation_scenarios = [
|
|||
"description": "Innovation PV + HHRSH upgrade, EPC E",
|
||||
"measures": [
|
||||
{"type": "solar_pv", "is_innovation": True, "innovation_uplift": 0.45},
|
||||
{"type": "high_heat_retention_storage_heater", "is_innovation": False, "innovation_uplift": 0}
|
||||
{"type": "high_heat_retention_storage_heaters", "is_innovation": False, "innovation_uplift": 0}
|
||||
],
|
||||
"starting_sap": 50,
|
||||
"mainheat_description": "Electric storage heaters",
|
||||
|
|
|
|||
|
|
@ -477,7 +477,7 @@ def test_eco4_sh_epc_d_requires_innovation(
|
|||
|
||||
measures5 = [
|
||||
{"type": "solar_pv", "is_innovation": True, "innovation_uplift": 0.45},
|
||||
{"type": "high_heat_retention_storage_heater", "is_innovation": False, "innovation_uplift": 0}
|
||||
{"type": "high_heat_retention_storage_heaters", "is_innovation": False, "innovation_uplift": 0}
|
||||
]
|
||||
funding5.check_funding(
|
||||
measures=measures5,
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ def app():
|
|||
"lighting",
|
||||
"secondary_heating",
|
||||
"boiler_upgrade",
|
||||
"high_heat_retention_storage_heater",
|
||||
"high_heat_retention_storage_heaters",
|
||||
],
|
||||
"budget": None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -606,7 +606,7 @@ class RetrieveFindMyEpc:
|
|||
"roomstat_programmer_trvs", "time_temperature_zone_control"
|
||||
],
|
||||
"Change heating to gas condensing boiler": ["boiler_upgrade"],
|
||||
"Fan assisted storage heaters and dual immersion cylinder": ["high_heat_retention_storage_heater"],
|
||||
"Fan assisted storage heaters and dual immersion cylinder": ["high_heat_retention_storage_heaters"],
|
||||
"Flat roof or sloping ceiling insulation": ["flat_roof_insulation"],
|
||||
"Heating controls (room thermostat)": [
|
||||
"roomstat_programmer_trvs", "time_temperature_zone_control"
|
||||
|
|
@ -634,7 +634,7 @@ class RetrieveFindMyEpc:
|
|||
"PV Cells recommendation": [],
|
||||
"Replacement glazing units": ["double_glazing"],
|
||||
"Heating controls (time and temperature zone control)": ["time_temperature_zone_control"],
|
||||
"High heat retention storage heaters": ["high_heat_retention_storage_heater"],
|
||||
"High heat retention storage heaters": ["high_heat_retention_storage_heaters"],
|
||||
"Gas condensing boiler": ["boiler_upgrade"],
|
||||
"Change room heaters to condensing boiler": ["boiler_upgrade"],
|
||||
"Cylinder thermostat": ["cylinder_thermostat"],
|
||||
|
|
@ -677,10 +677,10 @@ class RetrieveFindMyEpc:
|
|||
],
|
||||
"Internal wall insulation": ["internal_wall_insulation"],
|
||||
"High heat retention storage heaters and dual immersion cylinder and dual rate meter": [
|
||||
"high_heat_retention_storage_heater"
|
||||
"high_heat_retention_storage_heaters"
|
||||
],
|
||||
"High heat retention storage heaters and dual rate meter": [
|
||||
"high_heat_retention_storage_heater"
|
||||
"high_heat_retention_storage_heaters"
|
||||
],
|
||||
"Increase loft insulation to 250mm": ["loft_insulation"],
|
||||
"Solar photovoltaics panels, 25% of roof area": ["solar_pv"],
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class HeatingRecommender:
|
|||
# type 1
|
||||
"boiler_upgrade",
|
||||
# type 2
|
||||
"high_heat_retention_storage_heater",
|
||||
"high_heat_retention_storage_heaters",
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -156,7 +156,7 @@ class HeatingRecommender:
|
|||
|
||||
return (
|
||||
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)
|
||||
("high_heat_retention_storage_heaters" in measures)
|
||||
)
|
||||
|
||||
def is_boiler_upgrade_suitable(self, measures, ashp_only_heating_recommendation):
|
||||
|
|
@ -489,6 +489,55 @@ class HeatingRecommender:
|
|||
|
||||
return heat_pump_size
|
||||
|
||||
@staticmethod
|
||||
def estimate_peak_kw(
|
||||
floor_area_m2: float,
|
||||
epc_primary_kwh_per_m2_yr: float | None = None,
|
||||
# Prefer these if available:
|
||||
space_heat_kwh_per_m2_yr: float | None = None, # from EPC/SAP if you can
|
||||
heat_loss_parameter_W_per_m2K: float | None = None, # HLP if available
|
||||
primary_to_delivered_factor: float = 1.0,
|
||||
space_heat_fraction_range=(0.5, 0.75),
|
||||
hdd_base_dd: float = 2100.0, # set per location (base 15.5 °C typical UK)
|
||||
t_indoor_C: float = 21.0,
|
||||
t_design_ext_C: float = -3.0,
|
||||
):
|
||||
ΔT = t_indoor_C - t_design_ext_C
|
||||
|
||||
# 1) Best available path: HLP → direct peak
|
||||
if heat_loss_parameter_W_per_m2K is not None:
|
||||
peak_kw = heat_loss_parameter_W_per_m2K * floor_area_m2 * ΔT / 1000.0
|
||||
return (peak_kw, peak_kw) # no range needed
|
||||
|
||||
# 2) Second-best: space-heating demand → HDD method
|
||||
if space_heat_kwh_per_m2_yr is not None:
|
||||
annual_space_kwh = space_heat_kwh_per_m2_yr * floor_area_m2
|
||||
Htot = annual_space_kwh * 1000.0 / (hdd_base_dd * 24.0) # W/K
|
||||
peak_kw = Htot * ΔT / 1000.0
|
||||
return (peak_kw, peak_kw)
|
||||
|
||||
# 3) Minimal inputs: primary energy + assumed fraction → range
|
||||
assert epc_primary_kwh_per_m2_yr is not None
|
||||
annual_primary = epc_primary_kwh_per_m2_yr * floor_area_m2
|
||||
annual_delivered = annual_primary / primary_to_delivered_factor
|
||||
|
||||
def to_peak(space_fraction):
|
||||
annual_space = annual_delivered * space_fraction
|
||||
Htot = annual_space * 1000.0 / (hdd_base_dd * 24.0)
|
||||
return Htot * ΔT / 1000.0
|
||||
|
||||
low = to_peak(space_heat_fraction_range[0])
|
||||
high = to_peak(space_heat_fraction_range[1])
|
||||
return (low, high)
|
||||
|
||||
@staticmethod
|
||||
def pick_model(peak_kw_range, models_kw=(5, 6, 8.5, 11.2, 14, 17, 20)):
|
||||
target = peak_kw_range[1] # cover the upper end
|
||||
for kw in models_kw:
|
||||
if kw >= target:
|
||||
return kw
|
||||
return None
|
||||
|
||||
def recommend_air_source_heat_pump(self, phase, has_cavity_or_loft_recommendations, _return=False):
|
||||
"""
|
||||
This method will implement the recommendation for an air source heat pump
|
||||
|
|
@ -504,7 +553,19 @@ class HeatingRecommender:
|
|||
|
||||
controls_recommender = HeatingControlRecommender(self.property)
|
||||
controls_recommender.recommend(heating_description="Air source heat pump, radiators, electric", phase=phase)
|
||||
ashp_size = self.size_heat_pump()
|
||||
# ashp_size = self.size_heat_pump()
|
||||
|
||||
# New functions to estimate size of ASHP
|
||||
estimated_load = self.estimate_peak_kw(
|
||||
floor_area_m2=self.property.floor_area,
|
||||
epc_primary_kwh_per_m2_yr=self.property.data["energy-consumption-current"],
|
||||
primary_to_delivered_factor=1.55, # use 1.13 if heating fuel is gas
|
||||
space_heat_fraction_range=(0.35, 0.60),
|
||||
hdd_base_dd=2000.0, # set from location
|
||||
t_indoor_C=21.0,
|
||||
t_design_ext_C=-1.0 # set from local CIBSE table
|
||||
)
|
||||
ashp_size = self.pick_model(estimated_load)
|
||||
|
||||
ashp_costs = self.costs.air_source_heat_pump(ashp_size)
|
||||
if non_intrusive_recommendation:
|
||||
|
|
@ -884,7 +945,7 @@ class HeatingRecommender:
|
|||
# We check if there is a high heat retention non-intrusive recommendation
|
||||
non_intrusive_recommendation = next(
|
||||
(r for r in self.property.non_invasive_recommendations if
|
||||
r["type"] == "high_heat_retention_storage_heater"),
|
||||
r["type"] == "high_heat_retention_storage_heaters"),
|
||||
{}
|
||||
)
|
||||
|
||||
|
|
@ -981,7 +1042,7 @@ class HeatingRecommender:
|
|||
phase=phase,
|
||||
heating_controls_only=heating_controls_only,
|
||||
system_change=system_change,
|
||||
system_type="high_heat_retention_storage_heater",
|
||||
system_type="high_heat_retention_storage_heaters",
|
||||
non_intrusive_recommendation=non_intrusive_recommendation,
|
||||
heating_product=hhrsh_product
|
||||
)
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ def violates_min_insulation(fixed, optimisation_input_measures):
|
|||
# heating (incl. PV) flags
|
||||
is_heating = has_any([
|
||||
"air_source_heat_pump",
|
||||
"high_heat_retention_storage_heater",
|
||||
"high_heat_retention_storage_heaters",
|
||||
"boiler_upgrade",
|
||||
"electric_boiler",
|
||||
"time_temperature_zone_control",
|
||||
|
|
@ -171,7 +171,7 @@ def _prs_solution_ok(items, p, funding):
|
|||
# renewable set:
|
||||
has_ashp = ("air_source_heat_pump" in types) # ASHP alone is renewable
|
||||
has_solar = ("solar_pv" in types)
|
||||
has_hhrsh = ("high_heat_retention_storage_heater" in types) # only counts *with* solar
|
||||
has_hhrsh = ("high_heat_retention_storage_heaters" in types) # only counts *with* solar
|
||||
|
||||
# solar PV qualifies if paired with eligible existing heating
|
||||
solar_ok_existing = has_solar and funding.check_solar_eligible_heating_system(
|
||||
|
|
@ -468,6 +468,7 @@ def optimise_with_funding_paths(p, input_measures, housing_type, funding: Fundin
|
|||
)
|
||||
|
||||
rate = funding.get_eco4_abs_rate(is_cavity=p.walls["is_cavity_wall"])
|
||||
# The full project funding, at this point, does NOT include any uplifts
|
||||
solutions["full_project_funding"] = solutions["project_score"] * rate
|
||||
# if the scheme is not ECO4, we set the funding to 0 with iloc
|
||||
solutions.loc[solutions["scheme"] != "eco4", "full_project_funding"] = 0.0
|
||||
|
|
@ -679,7 +680,7 @@ def parse_types(t):
|
|||
def includes_heating(opt_types):
|
||||
return any(x in opt_types for x in {
|
||||
"air_source_heat_pump",
|
||||
"high_heat_retention_storage_heater",
|
||||
"high_heat_retention_storage_heaters",
|
||||
"time_temperature_zone_control", # controls count as a heating measure in your pipeline
|
||||
"solar_pv" # you treat PV as heating for funding logic
|
||||
})
|
||||
|
|
@ -761,7 +762,7 @@ def _make_solar_heating_funding_paths(
|
|||
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
# We don't include electric boilers as they are not eligible for ECO4 funding
|
||||
solar_heating_combos = [
|
||||
("high_heat_retention_storage_heater", "solar_pv+hhrsh:eco4"),
|
||||
("high_heat_retention_storage_heaters", "solar_pv+hhrsh:eco4"),
|
||||
("air_source_heat_pump", "solar_pv+ashp:eco4"),
|
||||
]
|
||||
if _find_measure(input_measures, "solar_pv"):
|
||||
|
|
@ -790,11 +791,11 @@ def _make_solar_heating_funding_paths(
|
|||
single_heating_measures = ["air_source_heat_pump"]
|
||||
else:
|
||||
single_heating_measures = [
|
||||
"boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump"
|
||||
"boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump"
|
||||
]
|
||||
measure_references = {
|
||||
"boiler_upgrade": "boiler_upgrade",
|
||||
"high_heat_retention_storage_heater": "hhrsh",
|
||||
"high_heat_retention_storage_heaters": "hhrsh",
|
||||
"air_source_heat_pump": "ashp"
|
||||
}
|
||||
for heating_upgrade in single_heating_measures:
|
||||
|
|
@ -881,14 +882,16 @@ def make_funding_paths(p, input_measures, housing_type, funding: Funding):
|
|||
|
||||
if housing_type == "Social" and p.data["current-energy-rating"] == "D":
|
||||
# If the property is currently EPC D, we can only include innovation measures or measures to meet the
|
||||
# minimum insulation requirements
|
||||
# minimum insulation requirements. We make an exception if we have a measure that is
|
||||
# already installed, specifically a heat pump
|
||||
input_measures_innovation = []
|
||||
input_gbis_measures_innovation = []
|
||||
for measures in input_measures:
|
||||
group_of_innovation_measures = []
|
||||
group_of_gbis_innovation_measures = []
|
||||
for measure in measures:
|
||||
if measure["innovation_uplift"] or measure["type"] in remaining_insulation_type:
|
||||
if measure["innovation_uplift"] or measure["type"] in remaining_insulation_type or measure[
|
||||
"already_installed"]:
|
||||
group_of_innovation_measures.append(measure)
|
||||
|
||||
if measure["innovation_uplift"] and measure["type"] in (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ from backend.app.utils import epc_to_sap_lower_bound
|
|||
from recommendations.optimiser.CostOptimiser import CostOptimiser
|
||||
|
||||
|
||||
def prepare_input_measures(property_recommendations, goal, needs_ventilation, funding=False):
|
||||
def prepare_input_measures(
|
||||
property_recommendations, goal, needs_ventilation, funding=False,
|
||||
property_eco_packages=None
|
||||
):
|
||||
"""
|
||||
Prepares a nested list of measure options for optimisation.
|
||||
|
||||
|
|
@ -37,6 +40,9 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation, fu
|
|||
funding: bool, optional
|
||||
If true, the function will include the innovation uplift in the total cost calculation. If false, this is
|
||||
excluded, since innovation uplift cannot be claimed where funding is not available.
|
||||
property_eco_packages: dict, optional
|
||||
Eco package data for the property, if available. If a measure has been specified as part of an eco package
|
||||
(e.g. HHRSH) this function will include that measure in the optimisation, even if it has negative cost savings.
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
|
@ -59,6 +65,8 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation, fu
|
|||
{}
|
||||
)
|
||||
|
||||
eco_measures = property_eco_packages[0] if property_eco_packages else []
|
||||
|
||||
input_measures = []
|
||||
for recs in property_recommendations:
|
||||
|
||||
|
|
@ -71,7 +79,14 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation, fu
|
|||
recs = [r for r in recs if ~r["has_battery"]]
|
||||
|
||||
# Only include measures with non-negative cost savings
|
||||
recs_to_append = [rec for rec in recs if rec["energy_cost_savings"] >= 0]
|
||||
if eco_measures:
|
||||
recs_to_append = [
|
||||
rec for rec in recs if (rec["energy_cost_savings"] >= 0) or (rec["measure_type"] in eco_measures)
|
||||
]
|
||||
else:
|
||||
recs_to_append = [
|
||||
rec for rec in recs if (rec["energy_cost_savings"] >= 0)
|
||||
]
|
||||
if not recs_to_append:
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ testing_examples = [
|
|||
'uprn-source': 'Address Matched',
|
||||
},
|
||||
"heating_measure_types": [
|
||||
"high_heat_retention_storage_heater",
|
||||
"high_heat_retention_storage_heaters",
|
||||
],
|
||||
"notes": "This property has electric room heaters and is off gas so a boiler recommendation is not appropriate."
|
||||
"We would expect a high heat retention storage recommendation. The property is a flat and therefore"
|
||||
|
|
@ -134,7 +134,7 @@ testing_examples = [
|
|||
'tenure': 'owner-occupied', 'fixed-lighting-outlets-count': 6.0, 'low-energy-fixed-light-count': 4.0,
|
||||
'uprn': 100090311351.0, 'uprn-source': 'Address Matched', 'property-type_y': None, 'built-form_y': None,
|
||||
},
|
||||
"heating_measure_types": ['high_heat_retention_storage_heater', 'air_source_heat_pump'],
|
||||
"heating_measure_types": ['high_heat_retention_storage_heaters', 'air_source_heat_pump'],
|
||||
"notes": "This test has electric storage heaters with automatic charge control - we recommend hhr storage"
|
||||
"heaters in this case, but because there are already electic storage heaters in place, we "
|
||||
"note, in the description of the recommendation, that this upgrade may be possible by retrofitting"
|
||||
|
|
@ -275,7 +275,7 @@ testing_examples = [
|
|||
'uprn': 43088770.0, 'uprn-source': 'Address Matched',
|
||||
},
|
||||
"heating_measure_types": [
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
],
|
||||
"notes": "This property is a flat so we don't have an ASHP recommendation. It also doesn't have access to the "
|
||||
"mains and so it can't have a gas boiler. We don't expect any controls recommendations"
|
||||
|
|
@ -370,7 +370,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'boiler_upgrade'
|
||||
],
|
||||
"notes": "This property has assumed electric heating and is mid-terrace house. It has a mains gas connection."
|
||||
|
|
@ -416,7 +416,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'air_source_heat_pump',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
],
|
||||
"notes": "This property has an oil boiler and doesn't have a mains gas connection so we can only recommend"
|
||||
"an air source heat pump and HHR (since if the home has a non-gas boiler, we recommend HHR)"
|
||||
|
|
@ -463,7 +463,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'air_source_heat_pump',
|
||||
'boiler_upgrade' # TTZs
|
||||
],
|
||||
|
|
@ -512,7 +512,7 @@ testing_examples = [
|
|||
"heating_measure_types": [
|
||||
'boiler_upgrade',
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
],
|
||||
"notes": "This property has assumed electric heaters. Boiler upgrade, HHR are recommended. We don't recommend"
|
||||
"an ASHP off of the bat because it's mid-terrace."
|
||||
|
|
@ -557,7 +557,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'boiler_upgrade'
|
||||
],
|
||||
"notes": "This has a form of assumed electric heating and has a mains connection so we recommend HHR, boiler"
|
||||
|
|
@ -605,7 +605,7 @@ testing_examples = [
|
|||
"heating_measure_types": [
|
||||
'boiler_upgrade',
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
],
|
||||
"notes": "This property already has storage heaters with manual charge control. The home is mid terrace so"
|
||||
"the ashp is not suitable"
|
||||
|
|
@ -651,7 +651,7 @@ testing_examples = [
|
|||
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
|
||||
},
|
||||
"heating_measure_types": [
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'air_source_heat_pump',
|
||||
],
|
||||
"notes": "This property has an LFG boiler but it doesn't have a mains gas connection so we can only recommend"
|
||||
|
|
@ -696,7 +696,7 @@ testing_examples = [
|
|||
'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None
|
||||
},
|
||||
"heating_measure_types": [
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'air_source_heat_pump',
|
||||
],
|
||||
"notes": "This property has electric boilers in place, but does not have a mains connection so we don't "
|
||||
|
|
@ -744,7 +744,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'air_source_heat_pump',
|
||||
'high_heat_retention_storage_heater'
|
||||
'high_heat_retention_storage_heaters'
|
||||
],
|
||||
"notes": "This property has a dual fuel boiler and no mains gas connection. We recommend ASHP and HHR, but"
|
||||
"no gas condensing boiler"
|
||||
|
|
@ -788,7 +788,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'air_source_heat_pump',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
],
|
||||
"notes": "This property has a coal boiler and no mains gas connection. We recommend ASHP and HHR, but"
|
||||
"no gas condensing boiler"
|
||||
|
|
@ -835,7 +835,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'air_source_heat_pump',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
],
|
||||
"notes": "This property has a smokeless fuel boiler and no mains gas connection. We recommend ASHP and HHR, but"
|
||||
"no gas condensing boiler"
|
||||
|
|
@ -880,7 +880,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'air_source_heat_pump',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
],
|
||||
"notes": "This property has a wood pellets boiler and no mains gas connection. We recommend ASHP and HHR, but"
|
||||
"no gas condensing boiler"
|
||||
|
|
@ -925,7 +925,7 @@ testing_examples = [
|
|||
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
|
||||
},
|
||||
"heating_measure_types": [
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'air_source_heat_pump',
|
||||
],
|
||||
"notes": "This is an end-terrace house, without mains gas connection, so we recommend is HHR & ASHP"
|
||||
|
|
@ -1010,7 +1010,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'air_source_heat_pump',
|
||||
'high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'time_temperature_zone_control',
|
||||
],
|
||||
"notes": "This property has dual heating. A boiler and electric storage heaters. The heating is efficient so"
|
||||
|
|
@ -1056,8 +1056,8 @@ testing_examples = [
|
|||
"heating_measure_types": [
|
||||
'air_source_heat_pump',
|
||||
'boiler_upgrade',
|
||||
'boiler_upgrade+high_heat_retention_storage_heater',
|
||||
'high_heat_retention_storage_heater',
|
||||
'boiler_upgrade+high_heat_retention_storage_heaters',
|
||||
'high_heat_retention_storage_heaters',
|
||||
'time_temperature_zone_control'
|
||||
],
|
||||
"notes": "This property is a modified version of the previous dual heating property, where we lower the"
|
||||
|
|
@ -1104,7 +1104,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'air_source_heat_pump',
|
||||
'high_heat_retention_storage_heater'
|
||||
'high_heat_retention_storage_heaters'
|
||||
],
|
||||
"notes": "This property has anthracite heating without mains. "
|
||||
"We recommend ASHP and HHR, but no gas condensing boiler"
|
||||
|
|
@ -1151,7 +1151,7 @@ testing_examples = [
|
|||
"heating_measure_types": [
|
||||
'boiler_upgrade',
|
||||
'boiler_upgrade',
|
||||
'high_heat_retention_storage_heater'
|
||||
'high_heat_retention_storage_heaters'
|
||||
],
|
||||
"notes": "This property has room heaters with two different fuel sources, so we recommend HHR, ASHP, and a "
|
||||
"boiler upgrade"
|
||||
|
|
@ -1238,7 +1238,7 @@ testing_examples = [
|
|||
},
|
||||
"heating_measure_types": [
|
||||
'air_source_heat_pump',
|
||||
'high_heat_retention_storage_heater'
|
||||
'high_heat_retention_storage_heaters'
|
||||
],
|
||||
"notes": "The property has warm air electricaire heating, so we recommend ASHP and HHR"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -105,3 +105,108 @@ class TestHeatingRecommendations:
|
|||
{x["measure_type"] for x in recommender.heating_recommendations} ==
|
||||
set(test_case["heating_measure_types"])
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"floor_area, epc_primary, expected_band, expected_model",
|
||||
[
|
||||
# Case 1 – Typical pre-2000 house, gas heating
|
||||
(
|
||||
93.75,
|
||||
270.19,
|
||||
(2.5, 4.6), # expected rough band (low, high)
|
||||
5, # chosen model
|
||||
),
|
||||
# Case 2 – Efficient new-build (low EPC energy)
|
||||
(
|
||||
93.75,
|
||||
142.28,
|
||||
(1.4, 2.4),
|
||||
3, # assume 3 or 5 kW model covers this
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_estimate_peak_kw_basic(floor_area, epc_primary, expected_band, expected_model):
|
||||
"""
|
||||
Ensure the peak load estimate is within a sensible range and
|
||||
that the model selection logic picks the correct bracket.
|
||||
"""
|
||||
|
||||
load_band = HeatingRecommender.estimate_peak_kw(
|
||||
floor_area_m2=floor_area,
|
||||
epc_primary_kwh_per_m2_yr=epc_primary,
|
||||
primary_to_delivered_factor=1.55, # electricity
|
||||
space_heat_fraction_range=(0.35, 0.60),
|
||||
hdd_base_dd=2000.0,
|
||||
t_indoor_C=21.0,
|
||||
t_design_ext_C=-1.0,
|
||||
)
|
||||
|
||||
# Assert range sanity
|
||||
assert expected_band[0] * 0.8 <= load_band[0] <= expected_band[1] * 1.2
|
||||
assert expected_band[0] <= load_band[1] <= expected_band[1] * 1.2
|
||||
|
||||
# Pick model
|
||||
model = HeatingRecommender.pick_model(load_band, models_kw=(3, 5, 6, 8.5, 11.2))
|
||||
assert model == expected_model
|
||||
|
||||
|
||||
def test_estimate_peak_kw_with_hlp():
|
||||
"""
|
||||
Test direct HLP input path (best-quality data).
|
||||
"""
|
||||
hlp = 1.5 # W/m²K typical for semi-detached
|
||||
floor_area = 100
|
||||
load_band = HeatingRecommender.estimate_peak_kw(
|
||||
floor_area_m2=floor_area,
|
||||
heat_loss_parameter_W_per_m2K=hlp,
|
||||
t_indoor_C=21,
|
||||
t_design_ext_C=-2,
|
||||
)
|
||||
# Should return identical low/high values since it's direct
|
||||
assert isinstance(load_band, tuple)
|
||||
assert abs(load_band[0] - load_band[1]) < 1e-6
|
||||
# Expected peak = 1.5 * 100 * 23 / 1000 = 3.45 kW
|
||||
assert pytest.approx(load_band[0], rel=0.05) == 3.45
|
||||
|
||||
|
||||
def test_estimate_peak_kw_with_space_heat_demand():
|
||||
"""
|
||||
Test the space-heating-demand path.
|
||||
"""
|
||||
floor_area = 120
|
||||
space_heat_kwh_m2 = 100
|
||||
load_band = HeatingRecommender.estimate_peak_kw(
|
||||
floor_area_m2=floor_area,
|
||||
space_heat_kwh_per_m2_yr=space_heat_kwh_m2,
|
||||
hdd_base_dd=2100,
|
||||
t_indoor_C=21,
|
||||
t_design_ext_C=-3,
|
||||
)
|
||||
# Rough expected peak ~ (100*120*1000)/(2100*24) * 24 /1000 = 5.4 kW
|
||||
assert 4.5 < load_band[0] < 6.0
|
||||
assert abs(load_band[0] - load_band[1]) < 1e-6
|
||||
|
||||
|
||||
def test_pick_model_boundaries():
|
||||
"""
|
||||
Ensure pick_model correctly selects the smallest model covering the upper band.
|
||||
"""
|
||||
assert HeatingRecommender.pick_model((2.0, 4.9), models_kw=(3, 5, 6, 8.5)) == 5
|
||||
assert HeatingRecommender.pick_model((5.0, 5.0), models_kw=(3, 5, 6, 8.5)) == 5
|
||||
assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 6
|
||||
assert HeatingRecommender.pick_model((8.6, 9.0), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2
|
||||
assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) is None
|
||||
|
||||
|
||||
def test_parameter_validation_and_defaults():
|
||||
"""
|
||||
Validate that the function handles missing or minimal parameters properly.
|
||||
"""
|
||||
# Minimal path using primary energy only
|
||||
load_band = HeatingRecommender.estimate_peak_kw(
|
||||
floor_area_m2=80,
|
||||
epc_primary_kwh_per_m2_yr=250,
|
||||
)
|
||||
assert isinstance(load_band, tuple)
|
||||
assert load_band[0] < load_band[1]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue