debugging backend

This commit is contained in:
Khalim Conn-Kowlessar 2025-11-04 20:55:01 +00:00
parent 611f85c5eb
commit b9a60e10d1
15 changed files with 275 additions and 69 deletions

View file

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

View file

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

View file

@ -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"]
}

View file

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

View file

@ -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 (£)

View file

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

View file

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

View file

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

View file

@ -114,7 +114,7 @@ def app():
"lighting",
"secondary_heating",
"boiler_upgrade",
"high_heat_retention_storage_heater",
"high_heat_retention_storage_heaters",
],
"budget": None,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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