Merge branch 'main' into feature/condition-data

This commit is contained in:
Daniel Roth 2026-01-22 12:21:25 +00:00
commit b73f6297eb
23 changed files with 429 additions and 986 deletions

View file

@ -11,9 +11,6 @@
],
"customizations": {
"vscode": {
"settings": {
"files.defaultWorkspace": "/workspaces/model"
},
"extensions": [
"ms-python.python",
"ms-toolsai.jupyter",
@ -24,8 +21,17 @@
"fabiospampinato.vscode-todo-plus",
"jgclark.vscode-todo-highlight",
"corentinartaud.pdfpreview",
"ms-python.vscode-python-envs"
]
"ms-python.vscode-python-envs",
"ms-python.black-formatter"
],
"settings": {
"files.defaultWorkspace": "/workspaces/model",
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true
},
"python.formatting.provider": "none"
}
}
},
"containerEnv": {

View file

@ -17,4 +17,6 @@ sqlmodel
# Testing
pytest==9.0.2
pytest-cov==7.0.0
ipykernel>=6.25,<7
ipykernel>=6.25,<7
# Formatting
black==26.1.0

View file

@ -1,9 +1,7 @@
name: Run unit tests
on:
push:
branches:
- main
pull_request:
jobs:
test:
@ -22,13 +20,6 @@ jobs:
run: |
make setup
- name: Set dev AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-2
- name: Run tests with tox via Makefile
env:
EPC_AUTH_TOKEN: ${{ secrets.DEV_EPC_AUTH_TOKEN }}

View file

@ -885,13 +885,11 @@ async def model_engine(body: PlanTriggerRequest):
)
# The materials data could be cached or local so we don't need to make
# consistent requests to the backend for
# the same data
# consistent requests to the backend for the same data
logger.info("Reading in materials and cleaned datasets")
with db_read_session() as session:
materials = db_funcs.materials_functions.get_materials(session)
cleaned = get_cleaned()
# project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes = get_funding_data()
kwh_client = KwhData(bucket=get_settings().DATA_BUCKET, read_consumption_data=True)

31
conftest.py Normal file
View file

@ -0,0 +1,31 @@
import os
from backend.app.config import get_settings
DEFAULT_ENV = {
"API_KEY": "test",
"SECRET_KEY": "test",
"ENVIRONMENT": "test",
"DATA_BUCKET": "test",
"PLAN_TRIGGER_BUCKET": "test",
"ENGINE_SQS_URL": "test",
"EPC_AUTH_TOKEN": "test", # overridden in GitHub Actions
"GOOGLE_SOLAR_API_KEY": "test",
"DB_HOST": "localhost",
"DB_USERNAME": "test",
"DB_PASSWORD": "test",
"DB_PORT": "5432",
"DB_NAME": "test",
"SAP_PREDICTIONS_BUCKET": "test",
"CARBON_PREDICTIONS_BUCKET": "test",
"HEAT_PREDICTIONS_BUCKET": "test",
"HEATING_KWH_PREDICTIONS_BUCKET": "test",
"HOTWATER_KWH_PREDICTIONS_BUCKET": "test",
"ENERGY_ASSESSMENTS_BUCKET": "test",
}
# runs immediately when pytest starts, BEFORE collection
for k, v in DEFAULT_ENV.items():
os.environ.setdefault(k, v)
# clear cached settings AFTER env is final
get_settings.cache_clear()

View file

@ -308,12 +308,64 @@ class Costs:
return {
"total": total_cost,
"contengency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost,
"contingency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost,
"contingency_rate": self.CONTINGENCIES["suspended_floor_insulation"],
"labour_hours": labour_hours,
"labour_days": labour_days,
}
@staticmethod
def _estimate_number_of_days_for_solid_floor(insulation_floor_area: float) -> float:
"""
Estimate the number of labour days required to install solid floor insulation,
based on the floor area being treated.
This is a heuristic (rule-of-thumb) estimate designed for early-stage planning
and costing. It deliberately avoids strict linear scaling because real-world
construction work includes fixed overheads and efficiency gains.
Core assumptions:
- A typical solid floor insulation job covering ~45 m2 takes around 7 working days.
This is based on market guidance (e.g. Checkatrade).
- Very small jobs still require multiple days due to setup, preparation,
curing/drying time, and inspections even if the area is small.
- Larger jobs take longer, but each additional square metre adds slightly less
time than the previous one, because crews become more efficient once work
is underway.
The estimate therefore:
- Scales with floor area
- Applies a minimum realistic duration
- Uses non-linear scaling to reflect economies of scale
:param insulation_floor_area: float - total floor area to be insulated
"""
# Reference case:
# A "typical" job (about half of a 90 m² house) takes ~7 days to complete
base_days = 7
base_area = 45 # m2 of solid floor insulated in the reference case
# Exponent < 1 means sub-linear scaling:
# doubling the area does NOT double the time, because setup costs
# and learning effects reduce the marginal effort per extra m²
labour_exponent = 0.85
# Minimum number of days for any solid floor job.
# Even small areas require mobilisation, preparation, installation,
# and finishing time, so jobs realistically won't complete faster than this.
min_days = 3
# Calculate estimated labour days:
# - Scale relative to the reference job
# - Apply sub-linear scaling for realism
# - Enforce a minimum duration so estimates are not unrealistically low
labour_days = max(
min_days,
base_days * (insulation_floor_area / base_area) ** labour_exponent
)
return labour_days
def solid_floor_insulation(self, insulation_floor_area, material):
"""
based on costing data from installers, produces an estimate for the cost of works. Returns contingency
@ -324,21 +376,9 @@ class Costs:
"""
total_cost = material["total_cost"] * insulation_floor_area
# We assume the average house takes ~7 days to complete at £300/day incl. VAT, as per checkatrade
# which can be seen here: https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost
# Assumptions
base_days = 7 # The quickest it will be completed
base_area = 45 # The area that can be completed in that time (for a typical 90m2 house)
labour_exponent = 0.85 # Non-linear scaling
daily_labour_rate = 300 # Based on checkatrade
min_days = 3 # Fewest days it will take
labour_days = max(
min_days,
base_days * (insulation_floor_area / base_area) ** labour_exponent
)
labour_days = self._estimate_number_of_days_for_solid_floor(insulation_floor_area)
labour_cost = labour_days * daily_labour_rate
total_cost = total_cost + labour_cost
@ -460,7 +500,7 @@ class Costs:
# We estimate the cost of an appliance thermostat at £400, which is the upper end of the range
return {
"total": total_cost,
"contengency": total_cost * self.CONTINGENCY,
"contingency": total_cost * self.CONTINGENCY,
"contingency_rate": self.CONTINGENCY,
"subtotal": subtotal_before_vat,
"vat": vat,

View file

@ -1265,8 +1265,7 @@ class HeatingRecommender:
# We check if there's a mains connection and the hot water is inefficient, as this will improve with a boiler
has_inefficient_water = (
self.property.data["mains-gas-flag"] and
self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor"]
self.property.data["mains-gas-flag"] and self.property.data["hot-water-energy-eff"] in ["Very Poor", "Poor"]
)
non_invasive_recommendation = next((

View file

@ -71,6 +71,7 @@ class WallRecommendations(Definitions):
"Timber frame, as built, no insulation": "Timber frame, with external insulation",
'Timber frame, as built, partial insulation': 'Timber frame, with external insulation',
"Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with external insulation",
"Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with external insulation",
"Sandstone, as built, no insulation": "Sandstone, with external insulation",
"Sandstone, as built, partial insulation": "Sandstone, with external insulation",
}
@ -88,6 +89,7 @@ class WallRecommendations(Definitions):
"Timber frame, as built, no insulation": "Timber frame, with internal insulation",
'Timber frame, as built, partial insulation': 'Timber frame, with internal insulation',
"Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with internal insulation",
"Sandstone or limestone, as built, partial insulation": "Sandstone or limestone, with internal insulation",
"Sandstone, as built, no insulation": "Sandstone, with internal insulation",
"Sandstone, as built, partial insulation": "Sandstone, with internal insulation",
}

View file

@ -236,10 +236,13 @@ def calculate_gain(
if body.goal == "Increasing EPC":
current_sap = int(p.data["current-energy-efficiency"]) + already_installed_gain
target_sap = (
eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None
else epc_to_sap_lower_bound(body.goal_value)
)
if eco_packages is None:
target_sap = epc_to_sap_lower_bound(body.goal_value)
else:
target_sap = (
eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None
else epc_to_sap_lower_bound(body.goal_value)
)
if target_sap <= current_sap:
# We've already met or exceeded the target EPC

View file

@ -488,10 +488,11 @@ def estimate_perimeter(floor_area, num_rooms):
return perimeter
def get_exposed_floor_uvalue(insulation_thickness_str, age_band):
def get_exposed_floor_uvalue(insulation_thickness_str: None | str, age_band: str) -> float:
"""
We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document
:param insulation_thickness_str:
:param insulation_thickness_str: Insulation thickness as defined in the EPC data
:param age_band: Age band of the property
:return:
"""
@ -513,9 +514,15 @@ def get_exposed_floor_uvalue(insulation_thickness_str, age_band):
else:
insulation_thickness = int(insulation_thickness_str.replace("mm", ""))
return s12[s12["age_band"] == age_band][
filtered = s12[s12["age_band"] == age_band][
f"insulation_{insulation_thickness}"
].values[0]
]
if filtered.empty:
# We don't have data so we use the median value
return float(s12[f"insulation_{insulation_thickness}"].median())
return float(filtered.values[0])
def get_floor_u_value(

View file

@ -27,7 +27,8 @@ class TestCosts:
material=cwi_material,
)
assert cwi_results == {'total': 1342.7459938871539, 'labour_hours': 8, 'labour_days': 1}
assert cwi_results["total"] == 1342.7459938871539
assert cwi_results["contingency"] == 134.2745993887154
def test_loft_insulation(self):
mock_property = Mock()
@ -37,6 +38,7 @@ class TestCosts:
costs = Costs(mock_property)
loft_material = {
"type": "loft_insulation",
"description": "Crown Loft Roll 44 glass fibre roll",
"depth": 270,
"thermal_conductivity": 0.044,
@ -50,7 +52,8 @@ class TestCosts:
material=loft_material,
)
assert loft_results == {'total': 368.5, 'labour_hours': 8, 'labour_days': 1}
assert loft_results["total"] == 368.5
assert loft_results["contingency"] == 36.85
def test_internal_wall_insulation(self):
mock_property = Mock()
@ -79,9 +82,8 @@ class TestCosts:
material=iwi_material,
)
assert iwi_results == {
'total': 19182.085626959342, 'labour_hours': 17.263877064263404, 'labour_days': 0.5394961582582314
}
assert iwi_results["total"] == 19182.085626959342
assert iwi_results["contingency"] == 4987.342263009429
def test_suspended_floor_insulation(self):
mock_property = Mock()
@ -98,53 +100,38 @@ class TestCosts:
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0,
'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1,
'plant_cost': 0,
'total_cost': 13.46, 'link': 'SPONs',
'Notes': 'Spons did not contain labour costs so we use values for similar insulations. '
'We use the '
'same values as in Crown loft roll 44, since it is also an insulation roll',
'total_cost': 75,
"is_installer_quote": False
}
sus_floor_non_insulation_materials = [
{'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs',
'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and '
'therefore there is no need for a skip'},
{'type': 'suspended_floor_demolition',
'description': 'Remove boarding; withdraw nails; set aside for reuse; ground level', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 9.34, 'labour_hours_per_unit': 0.3,
'plant_cost': 0, 'total_cost': 9.34, 'link': 'SPONs', 'Notes': 0},
{'type': 'suspended_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48,
'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0},
{'type': 'suspended_floor_redecoration', 'description': 'refix floorboards previously set aside',
'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
'thermal_conductivity_unit': 0, 'prime_material_cost': 0, 'material_cost': 1.54, 'labour_cost': 24.98,
'labour_hours_per_unit': 0.74, 'plant_cost': 0, 'total_cost': 26.52, 'link': 'SPONs', 'Notes': 0},
{'type': 'suspended_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0,
'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37,
'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs',
'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; '
'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, '
'therefore we need just labour rates'}]
sus_floor_results = costs.suspended_floor_insulation(
insulation_floor_area=33.5,
material=sus_floor_material,
non_insulation_materials=sus_floor_non_insulation_materials
)
assert sus_floor_results == {
'total': 3337.07436, 'subtotal': 2780.8953, 'vat': 556.17906, 'contingency': 370.78604,
'preliminaries': 185.39302, 'material': 483.405, 'profit': 370.78604, 'labour_hours': 54.940000000000005,
'labour_days': 2.289166666666667, 'labour_cost': 1370.5252
assert sus_floor_results["total"] == 2512.5
assert sus_floor_results["contingency"] == 502.5
@pytest.mark.parametrize("insulation_floor_area, expected_result", [
(5, 3),
(33.5, 5.446976345666071),
(45, 7),
(70, 10.190623048464415),
(90, 12.617506476551622),
(150, 19.47802744828744),
(200, 24.873843619763473),
])
def test_estimate_estimate_number_of_days_for_solid_floor(
self, insulation_floor_area, expected_result
):
mock_property = Mock()
mock_property.data = {
"county": "Northamptonshire"
}
costs = Costs(mock_property)
assert costs._estimate_number_of_days_for_solid_floor(insulation_floor_area) == expected_result
def test_solid_floor_insulation(self):
mock_property = Mock()
mock_property.data = {
@ -160,75 +147,13 @@ class TestCosts:
'total_cost': 16.42, 'link': 'SPONs', 'Notes': 0, "is_installer_quote": False
}
sol_floor_non_insulation_materials = [
{'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11,
'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs',
'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and '
'therefore there is no need for a skip'},
{'type': 'solid_floor_preparation',
'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14,
'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, {
'type': 'solid_floor_preparation',
'description': 'Clean out crack to '
'form a 20mm×20mm '
'groove and fill with '
'cement: mortar mixed '
'with bonding agent',
'depth': 0, 'depth_unit': 0,
'cost_unit': 0,
'thermal_conductivity': 0,
'thermal_conductivity_unit': 0,
'prime_material_cost': 0,
'material_cost': 6.91,
'labour_cost': 18.99,
'labour_hours_per_unit': 0.61,
'plant_cost': 0.16,
'total_cost': 26.06, 'link': 0,
'Notes': 'This step is the '
'assessment and repair of '
'any damage to the concrete '
'floor such as filling '
'cracks or levelling uneven '
'areas'},
{'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier',
'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0,
'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48,
'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0},
{'type': 'solid_floor_redecoration',
'description': 'Screeded beds; protection to compressible formwork exceeding 600mm wide', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 9.6, 'material_cost': 9.89, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15,
'plant_cost': 0, 'total_cost': 12.56, 'link': 'SPONs',
'Notes': 'This is the screed layer, placed on top of the insulation'},
{'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0,
'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37,
'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs',
'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; '
'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, '
'therefore we need just labour rates'},
{'type': 'solid_floor_redecoration',
'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0,
'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0,
'prime_material_cost': 0, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12,
'plant_cost': 0, 'total_cost': 4.88, 'link': 'SPONs', 'Notes': 0}
]
sol_floor_results = costs.solid_floor_insulation(
insulation_floor_area=33.5,
material=sol_floor_material,
non_insulation_materials=sol_floor_non_insulation_materials
)
assert sol_floor_results == {
'total': 4245.023520000001, 'subtotal': 3537.5196, 'vat': 707.5039200000001, 'contingency': 471.66928,
'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 471.66928, 'labour_hours': 57.285,
'labour_days': 2.386875, 'labour_cost': 1346.6464
}
assert sol_floor_results["total"] == 2184
assert sol_floor_results["contingency"] == 567.84
def test_external_wall_insulation(self):
mock_property = Mock()
@ -253,9 +178,8 @@ class TestCosts:
material=ewi_material,
)
assert ewi_results == {
'total': 28773.12844043901, 'labour_hours': 134.2745993887154, 'labour_days': 4.196081230897356
}
assert ewi_results["total"] == 28773.12844043901
assert ewi_results["contingency"] == 7481.013394514142
def test_flat_roof_insulation(self):
mock_property = Mock()
@ -288,23 +212,27 @@ class TestCosts:
material=flat_roof_material,
)
assert flat_roof_floor_results == {
'total': 2063.935, 'subtotal': 1719.9458333333334, 'vat': 343.9891666666665, 'labour_hours': 8,
'labour_days': 1
}
assert flat_roof_floor_results["total"] == 2063.935
assert flat_roof_floor_results["contingency"] == 536.6231
assert costs.labour_adjustment_factor == 0.88
# Test for different wattages
@pytest.mark.parametrize("n_panels, expected_cost", [
(7, 5458.727999999999),
(10, 6013.139999999999),
(12, 6386.447999999999),
(15, 7594.451999999999),
@pytest.mark.parametrize("solar_product, expected_cost", [
({"total_cost": 5000, "includes_scaffolding": False}, 6000),
({"total_cost": 5000, "includes_scaffolding": True}, 5000),
])
def test_solar_pv_different_wattages(self, n_panels, expected_cost):
def test_solar_pv_different_wattages(self, solar_product, expected_cost):
mock_property = Mock()
mock_property.data = {"county": "Mansfield"}
scaffolding_options = [
{"size": 2, "total_cost": 1000}
]
costs = Costs(mock_property)
result = costs.solar_pv(n_panels)
result = costs.solar_pv(
solar_product=solar_product,
scaffolding_options=scaffolding_options,
n_floors=2
)
assert result['total'] == pytest.approx(expected_cost, rel=0.01)

View file

@ -223,15 +223,16 @@ testing_examples = [
'local-authority-label': 'Lewisham', 'constituency-label': 'Lewisham, Deptford', 'posttown': 'LONDON',
'construction-age-band': 'England and Wales: before 1900', 'lodgement-datetime': '2014-06-26 11:40:50',
'tenure': 'owner-occupied', 'fixed-lighting-outlets-count': 9.0, 'low-energy-fixed-light-count': 5.0,
'uprn': 100021936225.0, 'uprn-source': 'Address Matched',
'uprn': 100021936225, 'uprn-source': 'Address Matched',
},
"heating_measure_types": [
"air_source_heat_pump",
'roomstat_programmer_trvs',
'time_temperature_zone_control'
],
"notes": "Because this property already has a boiler, we don't recommend HHR. We don't recommend an ashp "
"because the home is mid-terraced. Because the heating controls are "
"Programmer, no room thermostat, we have a programmer, room thermostat and trvs recommendation"
"notes": "Because this property already has a boiler, we don't recommend HHR. "
"Because the heating controls are Programmer, no room thermostat, "
"we have a programmer, room thermostat and trvs recommendation"
"for heating controls and for TTZC."
},
{
@ -369,12 +370,13 @@ testing_examples = [
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'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."
"We can recommend a boiler upgrade and high heat retention storage heaters"
"We can recommend a boiler upgrade, high heat retention storage heaters, and an ASHP"
},
{
"epc": {
@ -510,12 +512,12 @@ testing_examples = [
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'boiler_upgrade',
'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."
"notes": "This property has assumed electric heaters. Boiler upgrade, ASHP are recommended. We don't recommend"
"HHRSH since there is potential community heating"
},
{
"epc": {
@ -556,6 +558,7 @@ testing_examples = [
'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'high_heat_retention_storage_heaters',
'boiler_upgrade'
@ -603,12 +606,12 @@ testing_examples = [
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'boiler_upgrade',
'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"
"notes": "This property already has storage heaters with manual charge control"
},
{
"epc": {
@ -1149,6 +1152,7 @@ testing_examples = [
'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None
},
"heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade',
'boiler_upgrade',
'high_heat_retention_storage_heaters'
@ -1193,10 +1197,9 @@ testing_examples = [
'uprn': 100070685908, 'uprn-source': 'Address Matched', 'sheating-energy-eff': None,
'sheating-env-eff': None
},
"heating_measure_types": [],
"notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating"
"don't recommend anything. HHRSH isn't recommended as with underfloor heating, it's quite"
"disruptive"
"heating_measure_types": ["high_heat_retention_storage_heaters"],
"notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating. "
"In this case we just recommend hhrsh as an additional heating system, which would become the primary"
},
{
"epc": {

View file

@ -214,7 +214,7 @@ measures_to_optimise = [
'heat_demand': np.float64(15.400000000000006),
'kwh_savings': np.float64(202.30000000000018),
'energy_cost_savings': np.float64(15.065400000000011)}], [
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0,
@ -226,7 +226,7 @@ measures_to_optimise = [
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2040.8566307499998),
'energy_cost_savings': np.float64(525.1124110919749)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0,
@ -238,7 +238,7 @@ measures_to_optimise = [
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2857.1992830499994),
'energy_cost_savings': np.float64(735.1573755287648)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0,
@ -249,7 +249,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.42834948104),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397),
'energy_cost_savings': np.float64(475.0617304809999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0,
@ -260,7 +260,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.599689273456),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558),
'energy_cost_savings': np.float64(665.0864226734)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0,
@ -271,7 +271,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(1650.2708274),
'energy_cost_savings': np.float64(424.61468389001993)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0,
@ -282,7 +282,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.53600796473952), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(2310.3791583599996),
'energy_cost_savings': np.float64(594.4605574460278)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0,
@ -293,7 +293,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(1453.5933906),
'energy_cost_savings': np.float64(374.00957940138)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0,
@ -304,7 +304,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.47212713326688), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(2035.03074684),
'energy_cost_savings': np.float64(523.6134111619319)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0,
@ -315,7 +315,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1255.12594),
'energy_cost_savings': np.float64(322.94390436199996)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0,
@ -326,7 +326,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.40766490531199995), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1757.1763159999998),
'energy_cost_savings': np.float64(452.1214661067999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0,
@ -336,7 +336,7 @@ measures_to_optimise = [
'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856),
'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1048.341318), 'energy_cost_savings': np.float64(269.7382211214)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
{'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0,

View file

@ -132,11 +132,21 @@ class TestFloorRecommendations:
assert types == {"solid_floor_insulation"}
assert len(recommender.recommendations) == 3
assert recommender.recommendations[2]["total"] == 14604.660000000002
assert recommender.recommendations[2]["new_u_value"] == 0.21
assert recommender.recommendations[2]["parts"][0]["depth"] == 75
assert recommender.recommendations[2]["parts"][0]["depth"] == 75
assert len(recommender.recommendations) == 1
assert (
recommender.recommendations[0]["description"] ==
'Install 75mm Kingspan Thermafloor TF70 High Performance Rigid Floor '
'Insulation insulation on solid floor'
)
assert recommender.recommendations[0]["new_u_value"] == 0.21
assert recommender.recommendations[0]["simulation_config"] == {
'floor_is_assumed_ending': False, 'floor_insulation_thickness_ending': 'average',
'floor_thermal_transmittance_ending': 0.685593
}
assert recommender.recommendations[0]["description_simulation"] == {
'floor-description': 'Solid, insulated'
}
def test_another_dwelling_below(self, input_properties):
"""
@ -172,6 +182,7 @@ class TestFloorRecommendations:
input_property.set_floor_type()
input_property.floor_area = 100
input_property.number_of_floors = 1
input_property.already_installed = []
recommender = FloorRecommendations(
property_instance=input_property,
@ -203,6 +214,7 @@ class TestFloorRecommendations:
input_property2.set_floor_type()
input_property2.insulation_floor_area = 100
input_property2.number_of_floors = 1
input_property2.already_installed = []
recommender2 = FloorRecommendations(
property_instance=input_property2,
@ -235,6 +247,7 @@ class TestFloorRecommendations:
input_property3.set_floor_type()
input_property3.insulation_floor_area = 100
input_property3.number_of_floors = 1
input_property3.already_installed = []
recommender3 = FloorRecommendations(
property_instance=input_property3,

View file

@ -7,6 +7,7 @@ from etl.epc.Record import EPCRecord
from etl.bill_savings.KwhData import KwhData
from recommendations.HeatingRecommender import HeatingRecommender
from recommendations.tests.test_data.heating_recommendations_data import testing_examples
from recommendations.tests.test_data.materials import materials
class TestHeatingRecommendations:
@ -56,6 +57,7 @@ class TestHeatingRecommendations:
x["has_hot-water-only"] = False
x["has_mineral_and_wood"] = False
x["has_dual_fuel_appliance"] = False
x["has_wood_chips"] = False
epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []}
@ -75,6 +77,7 @@ class TestHeatingRecommendations:
"energy_assessment_is_newer": False
}
)
p.already_installed = []
# For these tests, this can be fixed
kwh_predictions = {
@ -92,7 +95,7 @@ class TestHeatingRecommendations:
p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=kwh_predictions)
recommender = HeatingRecommender(property_instance=p)
recommender = HeatingRecommender(property_instance=p, materials=materials)
# Check they're empty
assert not recommender.heating_recommendations
@ -194,9 +197,9 @@ def test_pick_model_boundaries():
"""
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((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 8.5
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
assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 # largest model
def test_parameter_validation_and_defaults():

View file

@ -13,6 +13,7 @@ class TestLightingRecommendations:
epc_record.prepared_epc = {"county": "Greater London Authority"}
input_property0 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property0.lighting = {"low_energy_proportion": 0}
input_property0.already_installed = []
# Test for invalid materials
with pytest.raises(ValueError):
LightingRecommendations(input_property0, [])
@ -23,6 +24,7 @@ class TestLightingRecommendations:
epc_record.prepared_epc = {"county": "Greater London Authority"}
input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property1.lighting = {"low_energy_proportion": 100}
input_property1.already_installed = []
lr = LightingRecommendations(input_property1, materials)
lr.recommend()
@ -35,19 +37,16 @@ class TestLightingRecommendations:
input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property1.lighting = {"low_energy_proportion": 0.80}
input_property1.number_lighting_outlets = 20
input_property1.already_installed = []
lr = LightingRecommendations(input_property1, materials)
lr.recommend()
assert len(lr.recommendation) == 1
# Note - this test may be dependent on the ofgem price caps
assert lr.recommendation == [
{'phase': 0, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
'description': 'Install low energy lighting in 4 outlets', 'starting_u_value': None, 'new_u_value': None,
'already_installed': False, 'sap_points': 0.4, 'kwh_savings': 219.0,
'energy_cost_savings': 56.348699999999994, 'co2_equivalent_savings': 0.035478,
'description_simulation': {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all fixed outlets',
'low-energy-lighting': 100}, 'total': 188.76000000000002, 'subtotal': 157.3,
'vat': 31.460000000000004, 'contingency': 14.3, 'material': 80.0, 'labour_hours': 3.2, 'labour_days': 0.4,
'labour_cost': 63.0, 'survey': False}]
assert lr.recommendation[0]["description_simulation"] == {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all '
'fixed outlets',
'low-energy-lighting': 100}
assert lr.recommendation[0]["description"] == 'Install low energy lighting in 4 outlets'
assert lr.recommendation[0]["total"] == 14

View file

@ -108,7 +108,7 @@ class TestCalculateGain:
body = SimpleNamespace(goal="Increasing EPC", goal_value="C", simulate_sap_10=False)
prop = SimpleNamespace(data={"current-energy-efficiency": "50"})
gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=2)
assert gain == 18.5
assert gain == 17.5
class TestAddRequiredMeasures:
@ -235,7 +235,7 @@ class TestIncreasingEpcE2e:
gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
assert gain == 18.5, "Expected gain to be calculated correctly based on fixed gain and SAP target"
assert gain == 17.5, "Expected gain to be calculated correctly based on fixed gain and SAP target"
optimiser = (
GainOptimiser(
@ -254,7 +254,8 @@ class TestIncreasingEpcE2e:
# Collect selected measure IDs
selected = {r["id"] for r in solution}
assert selected == {'8_phase=7', '5_phase=4', '7_phase=6'}
assert selected == {'7_phase=6', '5_phase=4', '10_phase=7'}
assert float(optimiser.solution_gain) == 17.6
# Add required measures (none here)
solution = optimiser_functions.add_required_measures(
@ -265,11 +266,11 @@ class TestIncreasingEpcE2e:
assert solution == [
{'id': '5_phase=4', 'cost': 58.8, 'gain': 2, 'type': 'low_energy_lighting'},
{'id': '7_phase=6', 'cost': 30.0, 'gain': np.float64(3.6), 'type': 'secondary_heating'},
{'id': '8_phase=7', 'cost': 6013.139999999999, 'gain': np.float64(13.0), 'type': 'solar_pv'}
{'id': '10_phase=7', 'cost': 5826.491999999999, 'gain': np.float64(12.0), 'type': 'solar_pv'}
]
total_optimised_gain = sum(m["gain"] for m in solution)
assert total_optimised_gain == 18.6, "Total gain of optimised measures should meet or exceed target gain"
assert total_optimised_gain == 17.6, "Total gain of optimised measures should meet or exceed target gain"
selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected)

View file

@ -1,52 +1,6 @@
from pandas import Timestamp
from numpy import nan
import datetime
import numpy as np
import pandas as pd
import pytest
from copy import deepcopy
from recommendations.optimiser import optimiser_functions
from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths, build_heat_pump_paths
from backend.Funding import Funding
from backend.app.plan.schemas import WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, ECO4_ELIGIBILE_FABRIC_MEASURES
ALLOWED_FABRIC_TYPES = set(WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES)
@pytest.fixture
def mock_project_scores_matrix():
data = []
floor_segments = ["0-72", "73-97", "98-199", "200"]
bands = [
"Low_G", "High_G", "Low_F", "High_F", "Low_E", "High_E", "Low_D", "High_D", "Low_C", "High_C", "Low_B",
"High_B", "Low_A", "High_A"
]
cost = 50.0
for floor in floor_segments:
for start in bands:
for finish in bands:
if start != finish: # skip identical start/finish (no SAP movement)
data.append({
"Floor Area Segment": floor,
"Starting Band": start,
"Finishing Band": finish,
"Cost Savings": cost
})
cost += 5.0 # increment to create variety
return pd.DataFrame(data)
@pytest.fixture
def mock_partial_scores_matrix():
df = pd.read_csv("backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv")
df.columns = ['Measure category', 'Measure_Type', 'Pre_Main_Heating_Source',
'Post_Main_Heating_Source', 'Total Floor Area Band', 'Starting Band',
'Average Treatable Factor', 'Cost Savings', 'SAP Savings']
return df
from recommendations.optimiser.funding_optimiser import build_heat_pump_paths
class DummyProp:
@ -105,619 +59,6 @@ def p():
return DummyProp()
@pytest.fixture
def funding(monkeypatch, mock_partial_scores_matrix, mock_project_scores_matrix):
"""Simple Funding that returns zero uplift so costs stay as provided."""
# Build the Funding with tiny in-memory frames (avoid test I/O)
f = Funding(
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]),
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="Social"
)
# Keep innovation_uplift simple for the first test
# monkeypatch.setattr(f, "get_innovation_uplift", lambda *args, **kwargs: 0.0)
# If your solar precondition matters, you can force True/False here:
# monkeypatch.setattr(
# __import__("backend").Funding, "check_solar_eligible_heating_system",
# staticmethod(lambda mainheat_description, heating_control_description: False)
# )
return f
@pytest.fixture
def property_recommendations():
"""Short sample; replace with your full block if you want."""
recs = [
[{'phase': 0, 'parts': [{'id': 2466, 'type': 'external_wall_insulation',
'description': 'EWI Pro EPS external wall insulation system with '
'Brick Slip finish',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None,
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 0.0, 'plant_cost': 0.0,
'total_cost': 298.35,
'notes': 'This is the quoted value from SCIS',
'is_installer_quote': True, 'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 19090.810139104888,
'labour_hours': 0.0, 'labour_days': 0.0}],
'type': 'external_wall_insulation', 'measure_type': 'external_wall_insulation',
"innovation_rate": 0,
'description': 'Install 150mm EWI Pro EPS external wall insulation system with Brick '
'Slip finish on external walls',
'starting_u_value': 1.7, 'new_u_value': 0.32, 'already_installed': False,
'sap_points': np.float64(9.6),
'simulation_config': {'is_as_built_ending': False, 'walls_is_assumed_ending': False,
'walls_insulation_thickness_ending': 'average',
'external_insulation_ending': True,
'walls_energy_eff_ending': 'Good',
'walls_thermal_transmittance_ending': 0.23},
'description_simulation': {'walls-description': 'Solid brick, with external insulation',
'walls-energy-eff': 'Good'}, 'total': 19090.810139104888,
'labour_hours': 0.0, 'labour_days': 0.0, 'survey': False,
'recommendation_id': '0_phase=0', 'efficiency': 11229.568317120522,
'co2_equivalent_savings': np.float64(0.5), 'heat_demand': np.float64(37.099999999999994),
'kwh_savings': np.float64(1827.8999999999996),
'energy_cost_savings': np.float64(136.1247882352941)}, {'phase': 0, 'parts': [
{'id': 2373, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish',
'depth': 95.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032,
'thermal_conductivity_unit': None,
'link': 'SCIS', 'created_at': Timestamp('2025-03-16 15:26:22.379496'), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1,
'plant_cost': 0.0, 'total_cost': 89.0, 'notes': None, 'is_installer_quote': True,
'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 5694.929118083911, 'labour_hours': 134.37473199973275,
'labour_days': 4.199210374991648}], 'type': 'internal_wall_insulation',
'measure_type': 'internal_wall_insulation',
"innovation_rate": 0,
'description': 'Install 95mm '
'SWIP EcoBatt & '
'Plastered '
'finish on '
'internal walls',
'starting_u_value': 1.7,
'new_u_value': 0.32,
'already_installed': False,
'sap_points': 6,
'simulation_config': {
'is_as_built_ending': False,
'walls_is_assumed_ending':
False,
'walls_insulation_thickness_ending': 'average',
'internal_insulation_ending': True,
'walls_energy_eff_ending':
'Good',
'walls_thermal_transmittance_ending': 0.29},
'description_simulation': {
'walls-description': 'Solid '
'brick, with internal '
'insulation',
'walls-energy-eff': 'Good'},
'total': 5694.929118083911,
'labour_hours': 134.37473199973275,
'labour_days': 4.199210374991648,
'survey': True,
'recommendation_id': '1_phase=0',
'efficiency': 3349.6383047552417,
'co2_equivalent_savings': np.float64(
0.5),
'heat_demand': np.float64(
35.30000000000001),
'kwh_savings': np.float64(
1432.3999999999996),
'energy_cost_savings': np.float64(
106.67167058823532)}], [
{'phase': 1, 'parts': [{'id': 2351, 'type': 'loft_insulation',
'description': 'Knauf Loft Roll 44 glass fibre roll',
'depth': 300.0, 'depth_unit': 'mm', 'cost': None,
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 0.11, 'plant_cost': 0.0,
'total_cost': 15.0,
'notes': 'This is the cost if there is less than 100mm '
'existing insulation',
'is_installer_quote': True, 'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 645.0, 'labour_hours': 8,
'labour_days': 1}], 'type': 'loft_insulation',
'measure_type': 'loft_insulation',
"innovation_rate": 0,
'description': 'Install 300mm of Knauf Loft Roll 44 glass fibre roll in your loft',
'starting_u_value': 2.3, 'new_u_value': 2.3, 'sap_points': np.float64(2.4),
'already_installed': False,
'simulation_config': {'is_loft_ending': True, 'roof_is_assumed_ending': False,
'roof_insulation_thickness_ending': '300',
'roof_thermal_transmittance_ending': 2.3,
'roof_energy_eff_ending': 'Very Good'},
'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation',
'roof-energy-eff': 'Very Good'}, 'total': 645.0,
'labour_hours': 8, 'labour_days': 1, 'survey': False, 'recommendation_id': '2_phase=1',
'efficiency': 278.1347826086957,
'co2_equivalent_savings': np.float64(0.10000000000000009),
'heat_demand': np.float64(1.5), 'kwh_savings': np.float64(566.1499999999996),
'energy_cost_savings': np.float64(42.16152352941185)}], [{'phase': 2, 'parts': [
{'id': 2329, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation',
'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
'thermal_conductivity_unit': None,
'link': 'SCIS', 'created_at': datetime.datetime(2025, 3, 16, 15, 26, 22, 379496), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0,
'plant_cost': 0.0, 'total_cost': 350.0, 'notes': None, 'is_installer_quote': True, 'total': 700.0,
'quantity': 2,
'quantity_unit': 'part'}], 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation',
"innovation_rate": 0,
'description': 'Install 2 '
'Mechanical '
'Extract '
'Ventilation units',
'starting_u_value': None,
'new_u_value': None,
'already_installed': False,
'sap_points': np.float64(
-0.10000000000000142),
'heat_demand': np.float64(
-3.3999999999999773),
'kwh_savings': np.float64(
-53.80000000000018),
'co2_equivalent_savings': np.float64(
0.0),
'energy_cost_savings': np.float64(
-4.0065176470588995),
'total': 700.0,
'labour_hours': 8,
'labour_days': 1.0,
'simulation_config': {
'mechanical_ventilation_ending':
'mechanical, '
'extract '
'only'},
'description_simulation': {
'mechanical-ventilation': 'mechanical, '
'extract only'},
'recommendation_id': '3_phase=2',
'efficiency': 0}], [
{'phase': 3, 'parts': [{'id': 2409, 'type': 'suspended_floor_insulation',
'description': 'Q-bot underfloor insulation', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2',
'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 1.63, 'plant_cost': 0.0,
'total_cost': 93.75,
'notes': 'Linearly interpolated based on Qbot costs',
'is_installer_quote': True, 'quantity': 43.0,
'quantity_unit': 'm2', 'total': 4031.25,
'labour_hours': 70.08999999999999,
'labour_days': 2.920416666666666}],
'type': 'suspended_floor_insulation', 'measure_type': 'suspended_floor_insulation',
"innovation_rate": 0,
'description': 'Install 75mm Q-bot underfloor insulation insulation in suspended '
'floor',
'starting_u_value': 0.83, 'new_u_value': 0.22, 'sap_points': 2, 'survey': True,
'already_installed': False, 'simulation_config': {'floor_is_assumed_ending': False,
'floor_insulation_thickness_ending': 'average',
'floor_thermal_transmittance_ending': 0.685593},
'description_simulation': {'floor-description': 'Suspended, insulated'},
'total': 4031.25, 'labour_hours': 70.08999999999999, 'labour_days': 2.920416666666666,
'recommendation_id': '4_phase=3', 'efficiency': 4856.707710843373,
'co2_equivalent_savings': np.float64(0.20000000000000018),
'heat_demand': np.float64(33.5), 'kwh_savings': np.float64(1021.1999999999998),
'energy_cost_savings': np.float64(76.04936470588231)}], [
{'phase': 4, 'parts': [], 'type': 'low_energy_lighting',
'measure_type': 'low_energy_lighting',
"innovation_rate": 0,
'description': 'Install low energy lighting in -886 outlets', 'starting_u_value': None,
'new_u_value': None, 'already_installed': False, 'sap_points': 2,
'kwh_savings': -48508.5, 'energy_cost_savings': -12481.237049999998,
'co2_equivalent_savings': -7.858377,
'description_simulation': {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all fixed'
' outlets',
'low-energy-lighting': 100}, 'total': -3411.1000000000004,
'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
'recommendation_id': '5_phase=4', 'efficiency': -1705.5500000000002,
'heat_demand': np.float64(5.099999999999994)}], [
{'type': 'heating', 'phase': 5, 'measure_type': 'time_temperature_zone_control',
"innovation_rate": 0,
'parts': [],
'description': 'Upgrade heating controls to Smart Thermostats, room sensors and '
'smart radiator valves (time & temperature zone control)',
'total': 739.576, 'subtotal': 700.48, 'vat': 39.096000000000004,
'labour_hours': 3.6199999999999997, 'labour_days': np.float64(1.0),
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(2.9),
'already_installed': False, 'simulation_config': {
'thermostatic_control_ending': 'time and temperature zone control',
'switch_system_ending': None, 'trvs_ending': None,
'mainheatc_energy_eff_ending': 'Very Good'}, 'description_simulation': {
'mainheatcont-description': 'Time and temperature zone control',
'mainheatc-energy-eff': 'Very Good'}, 'recommendation_id': '6_phase=5',
'efficiency': 739.576, 'co2_equivalent_savings': np.float64(0.30000000000000027),
'heat_demand': np.float64(6.599999999999994),
'kwh_savings': np.float64(876.8000000000002),
'energy_cost_savings': np.float64(65.29581176470589)}], [
{'phase': 6, 'parts': [], 'type': 'secondary_heating',
'measure_type': 'secondary_heating',
"innovation_rate": 0,
'description': 'Remove the secondary heating system', 'starting_u_value': None,
'new_u_value': None, 'sap_points': np.float64(3.6), 'already_installed': False,
'total': 30.0, 'subtotal': 25.0, 'vat': 5.0, 'labour_hours': 3.0,
'labour_days': np.float64(1.0),
'simulation_config': {'secondheat_description_ending': 'None'},
'description_simulation': {'secondheat-description': 'None'},
'recommendation_id': '7_phase=6', 'efficiency': 30.0,
'co2_equivalent_savings': np.float64(0.10000000000000009),
'heat_demand': np.float64(15.400000000000006),
'kwh_savings': np.float64(196.29999999999927),
'energy_cost_savings': np.float64(14.61857647058821)}], [
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
'description_simulation': {'photo-supply': np.float64(65.0)},
'recommendation_id': '8_phase=7', 'efficiency': np.float64(462.54923076923075),
'co2_equivalent_savings': np.float64(0.47347873833399995),
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2040.8566307499998),
'energy_cost_savings': np.float64(525.1124110919749)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
'description_simulation': {'photo-supply': np.float64(65.0)},
'recommendation_id': '9_phase=7', 'efficiency': np.float64(810.5390769230769),
'co2_equivalent_savings': np.float64(0.6628702336675999),
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2857.1992830499994),
'energy_cost_savings': np.float64(735.1573755287648)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3692.66794),
'description_simulation': {'photo-supply': np.float64(60.0)},
'recommendation_id': '10_phase=7', 'efficiency': np.float64(485.54099999999994),
'co2_equivalent_savings': np.float64(0.42834948104),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397),
'energy_cost_savings': np.float64(475.0617304809999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3692.66794),
'description_simulation': {'photo-supply': np.float64(60.0)},
'recommendation_id': '11_phase=7', 'efficiency': np.float64(862.5299999999999),
'co2_equivalent_savings': np.float64(0.599689273456),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558),
'energy_cost_savings': np.float64(665.0864226734)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
'description_simulation': {'photo-supply': np.float64(55.0)},
'recommendation_id': '12_phase=7', 'efficiency': np.float64(512.964),
'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(1650.2708274),
'energy_cost_savings': np.float64(424.61468389001993)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
'description_simulation': {'photo-supply': np.float64(55.0)},
'recommendation_id': '13_phase=7', 'efficiency': np.float64(924.2247272727273),
'co2_equivalent_savings': np.float64(0.53600796473952),
'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(2310.3791583599996),
'energy_cost_savings': np.float64(594.4605574460278)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
'description_simulation': {'photo-supply': np.float64(45.0)},
'recommendation_id': '14_phase=7', 'efficiency': np.float64(606.5253333333333),
'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(1453.5933906),
'energy_cost_savings': np.float64(374.00957940138)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
'description_simulation': {'photo-supply': np.float64(45.0)},
'recommendation_id': '15_phase=7', 'efficiency': np.float64(1109.1773333333333),
'co2_equivalent_savings': np.float64(0.47212713326688),
'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(2035.03074684),
'energy_cost_savings': np.float64(523.6134111619319)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2510.25188),
'description_simulation': {'photo-supply': np.float64(40.0)},
'recommendation_id': '16_phase=7', 'efficiency': np.float64(659.3565),
'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1255.12594),
'energy_cost_savings': np.float64(322.94390436199996)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2510.25188),
'description_simulation': {'photo-supply': np.float64(40.0)},
'recommendation_id': '17_phase=7', 'efficiency': np.float64(1224.84),
'co2_equivalent_savings': np.float64(0.40766490531199995),
'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1757.1763159999998),
'energy_cost_savings': np.float64(452.1214661067999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2096.682636),
'description_simulation': {'photo-supply': np.float64(35.0)},
'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856),
'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1048.341318),
'energy_cost_savings': np.float64(269.7382211214)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2096.682636),
'description_simulation': {'photo-supply': np.float64(35.0)},
'recommendation_id': '19_phase=7', 'efficiency': np.float64(1373.5491428571427),
'co2_equivalent_savings': np.float64(0.3405012600864), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1467.6778451999999),
'energy_cost_savings': np.float64(377.6335095699599)}]
]
return recs
def _attach_costs_and_uplifts(recs, funding, p):
"""Mimic what your script did: add cost fields & innovation uplift."""
out = deepcopy(recs)
for group in out:
for r in group:
if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]:
(
r["partial_project_score"],
r["partial_project_funding"],
r["innovation_uplift"],
r["uplift_project_score"],
) = (
0, 0, 0, 0
)
continue
(
r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"],
r["uplift_project_score"]
) = funding.get_innovation_uplift(
measure=r,
starting_sap=55,
floor_area=70.0,
is_cavity=False,
current_wall_uvalue=1.7,
is_partial=False,
existing_li_thickness=150,
mainheating=p.main_heating,
main_fuel=p.main_fuel,
mainheat_energy_eff="Very Good",
)
# the optimiser_functions.prepare_input_measures will translate these to input format; but
# for safety add explicit cost fields some downstream code expects:
r["total"] = float(r["total"])
return out
def _to_input_measures(recs, p):
"""Use your own helper so we test the full pipeline."""
property_measure_types = {rec["type"] for grp in recs for rec in grp}
needs_ventilation = any(
x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation
) and not getattr(p, "has_ventilation", False)
# goal="Increasing EPC", add_uplift=True for Social path
return optimiser_functions.prepare_input_measures(
recs, goal="Increasing EPC", needs_ventilation=needs_ventilation, funding=True
)
def _types_of(picked_items):
return {item["type"] for item in picked_items}
def test_social_fabric_only_returns_only_fabric_types(p, funding, property_recommendations, monkeypatch):
# 1) prepare data like your script
recs = _attach_costs_and_uplifts(property_recommendations, funding, p)
input_measures = _to_input_measures(recs, p)
# 2) run optimiser wrapper (budget and target_gain can be modest for the test)
budget = 30000.0
target_gain = 8.0
solutions = optimise_with_funding_paths(
p=p,
input_measures=input_measures,
housing_type="Social",
budget=budget,
target_gain=target_gain,
funding=funding
)
# 3) basic shape assertions
assert isinstance(solutions, pd.DataFrame)
assert not solutions.empty
# 4) find the fabric-only ECO4 row
fabric_rows = solutions[
solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "fabric-only:eco4")]
assert not fabric_rows.empty, "Expected a fabric-only:eco4 solution for Social tenure"
# 5) ensure only fabric measure types are present in that solution
picked_types = _types_of(fabric_rows.iloc[0]["items"])
assert picked_types == {'internal_wall_insulation+mechanical_ventilation',
'suspended_floor_insulation'}, "incorrect types selected"
# 6) respect budget
assert fabric_rows.iloc[0]["total_cost"] <= budget + 1e-9
# (optional) ensure unfunded baseline also appears
unfunded_rows = solutions[
solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "unfunded:all")]
assert not unfunded_rows.empty
def test_private_solid_wall_no_innovation_epc_d(p, funding, mock_project_scores_matrix, mock_partial_scores_matrix):
"""
We have a specific test for this case which was implemented incorrectly originally.
This is an EPC D property and so shouldn't be eligible for ECO4. Instead, only GBIS should be considered.
"""
# Overwrite the data - copied from real example
p2 = deepcopy(p)
p2.data = {
"current-energy-rating": "D",
"current-energy-efficiency": 68,
"mainheat-energy-eff": "Good",
}
p2.walls = {'original_description': 'Sandstone or limestone, as built, no insulation (assumed)',
'clean_description': 'Sandstone or limestone, as built, no insulation', 'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True,
'is_sandstone_or_limestone': True, 'is_park_home': False, 'insulation_thickness': 'none',
'external_insulation': False, 'internal_insulation': False}
funding2 = Funding(
tenure="Private",
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]),
eco4_social_cavity_abs_rate=12.5,
eco4_social_solid_abs_rate=17,
eco4_private_cavity_abs_rate=12.5,
eco4_private_solid_abs_rate=17,
gbis_social_cavity_abs_rate=21,
gbis_social_solid_abs_rate=25,
gbis_private_cavity_abs_rate=21,
gbis_private_solid_abs_rate=28,
)
input_measures = [
[{'id': '0_phase=0', 'cost': np.float64(4441.202499013676), 'gain': np.float64(3.4000000000000057),
'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0),
'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756,
'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3),
'uplift_project_score': np.float64(0.0)}], [
{'id': '2_phase=2', 'cost': np.float64(2280.0), 'gain': np.float64(0.4), 'type': 'secondary_glazing',
'innovation_uplift': np.float64(0.0), 'cost_minus_uplift': np.float64(2280.0),
'raw_cost': np.float64(2280.0), 'partial_project_funding': np.float64(1421.1999999999998),
'partial_project_score': np.float64(83.6), 'uplift_project_score': np.float64(0.0)}], [
{'id': '3_phase=3', 'cost': np.float64(604.5840000000001), 'gain': np.float64(1.2),
'type': 'time_temperature_zone_control', 'innovation_uplift': np.float64(0.0),
'cost_minus_uplift': np.float64(604.5840000000001), 'raw_cost': 604.5840000000001,
'partial_project_funding': np.float64(702.0999999999999), 'partial_project_score': np.float64(41.3),
'uplift_project_score': np.float64(0.0)}], [
{'id': '4_phase=4', 'cost': 60.0, 'gain': np.float64(0.0), 'type': 'secondary_heating',
'innovation_uplift': 0, 'cost_minus_uplift': 60.0, 'raw_cost': 60.0, 'partial_project_funding': 0,
'partial_project_score': 0, 'uplift_project_score': 0}]
]
solutions = optimise_with_funding_paths(
p=p2,
input_measures=input_measures,
housing_type="Private",
budget=None,
target_gain=1.5,
funding=funding2
)
# 3) basic shape assertions
assert isinstance(solutions, pd.DataFrame)
assert not solutions.empty
# We should have 2 rows
assert solutions.shape[0] == 2
# We should only have None or GBIS
assert set(solutions["scheme"].unique()) == {"none", "gbis"}
meets_upgrade_gbis = solutions[solutions["meets_upgrade_target"] & solutions["is_eligible"]]
assert meets_upgrade_gbis.shape[0] == 1
# Check exact result
assert meets_upgrade_gbis.squeeze().to_dict() == {
'fixed_ids': ['0_phase=0'], 'items': [
{'id': '0_phase=0', 'cost': 3881.2024990136756, 'gain': np.float64(3.4000000000000057),
'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0),
'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756,
'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3),
'uplift_project_score': np.float64(0.0)}], 'total_cost': 3881.2024990136756,
'total_gain': 3.4000000000000057, 'path': [{'AND': ['internal_wall_insulation+mechanical_ventilation'],
'reference':
'internal_wall_insulation+mechanical_ventilation:gbis'}],
'scheme': 'gbis', 'is_eligible': True, 'unfunded_items': [], 'meets_upgrade_target': True, 'starting_sap': 68,
'floor_area': 70.0, 'ending_sap': 71.4, 'starting_band': 'High_D', 'ending_band': 'Low_C',
'floor_area_band': '0-72', 'project_score': 540.0, 'full_project_funding': 0.0,
'partial_project_funding': 2300.1000000000004, 'partial_project_score': 135.3, 'total_uplift': 0.0,
'total_uplift_score': 0.0
}
def test_build_heat_pump_paths():
eg1 = build_heat_pump_paths([], ["loft_insulation"])

View file

@ -279,27 +279,34 @@ class TestRecommendationUtils:
# Test with wall_type not in default_wall_thickness
def test_wall_type_not_in_default_wall_thickness(self):
with pytest.raises(IndexError):
recommendation_utils.get_floor_u_value(
floor_type="solid",
area=100,
perimeter=40,
age_band="A",
wall_type="InvalidWallType",
insulation_thickness=None,
)
# THis previously raised an error but because it largely dicates the thickness, often defaulted to
# 300, we just use the default instead of raising an error. We see cases of this in the wild, where we
# estimate EPCs and end up with unusual wall types, so we have fallbacks in place
assert recommendation_utils.get_floor_u_value(
floor_type="solid",
area=100,
perimeter=40,
age_band="A",
wall_type="InvalidWallType",
insulation_thickness=None,
) == 0.6
# Test with age_band not in s11
def test_age_band_not_in_s11(self):
with pytest.raises(IndexError):
recommendation_utils.get_floor_u_value(
floor_type="solid",
area=100,
perimeter=40,
age_band="Z",
wall_type="Cavity",
insulation_thickness=None,
)
# This previously raised an error but because it largely dicates the thickness, often defaulted to
# 300, we just use the default instead of raising an error. We see cases of this in the wild, where we
# might estimate an EPC
recommendation_utils.get_floor_u_value(
floor_type="solid",
area=100,
perimeter=40,
age_band="Z",
wall_type="Cavity",
insulation_thickness=None,
)
def test_age_band_not_in_s11_exposed_floor(self):
recommendation_utils.get_exposed_floor_uvalue(None, "BadValue")
def test_convert_thickness_to_numeric(self):

View file

@ -23,6 +23,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'none', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance.already_installed = []
roof_recommender = RoofRecommendations(property_instance=property_instance, materials=materials)
@ -30,7 +31,7 @@ class TestRoofRecommendations:
roof_recommender.recommend(phase=0)
assert len(roof_recommender.recommendations) == 1
assert len(roof_recommender.recommendations) == 3
assert roof_recommender.recommendations[0]["parts"][0]["depth"] == 300
def test_loft_insulation_recommendation_50mm_insulation(self):
@ -48,6 +49,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance2.already_installed = []
roof_recommender2 = RoofRecommendations(property_instance=property_instance2, materials=materials)
@ -55,11 +57,11 @@ class TestRoofRecommendations:
roof_recommender2.recommend(phase=0)
assert len(roof_recommender2.recommendations) == 1
assert len(roof_recommender2.recommendations) == 3
assert roof_recommender2.recommendations[0]["total"] == 1653
assert roof_recommender2.recommendations[0]["total"] == 2100
assert roof_recommender2.recommendations[0]["new_u_value"] == 0.13
assert roof_recommender2.recommendations[0]["starting_u_value"] == 0.68
assert float(roof_recommender2.recommendations[0]["starting_u_value"]) == 0.68
assert roof_recommender2.recommendations[0]["parts"][0]["depth"] == 300
epc_record = EPCRecord()
@ -76,6 +78,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance3.already_installed = []
roof_recommender3 = RoofRecommendations(property_instance=property_instance3, materials=materials)
@ -84,7 +87,7 @@ class TestRoofRecommendations:
roof_recommender3.recommend(phase=0)
assert roof_recommender3.recommendations
assert len(roof_recommender3.recommendations) == 1
assert len(roof_recommender3.recommendations) == 3
assert roof_recommender3.recommendations[0]["parts"][0]["depth"] == 300.0
def test_loft_insulation_recommendation_150mm_insulation(self):
@ -102,6 +105,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance4.already_installed = []
roof_recommender4 = RoofRecommendations(property_instance=property_instance4, materials=materials)
@ -109,11 +113,11 @@ class TestRoofRecommendations:
roof_recommender4.recommend(phase=0, default_u_values=True)
assert len(roof_recommender4.recommendations) == 1
assert len(roof_recommender4.recommendations) == 3
assert roof_recommender4.recommendations[0]["total"] == 1653.0
assert roof_recommender4.recommendations[0]["new_u_value"] == 0.14
assert roof_recommender4.recommendations[0]["starting_u_value"] == 0.3
assert roof_recommender4.recommendations[0]["total"] == 2100.0
assert float(roof_recommender4.recommendations[0]["new_u_value"]) == 0.14
assert float(roof_recommender4.recommendations[0]["starting_u_value"]) == 0.3
assert roof_recommender4.recommendations[0]["parts"][0]["depth"] == 300
epc_record = EPCRecord()
@ -130,6 +134,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance5.already_installed = []
roof_recommender5 = RoofRecommendations(property_instance=property_instance5, materials=materials)
@ -138,7 +143,7 @@ class TestRoofRecommendations:
roof_recommender5.recommend(phase=0)
assert roof_recommender5.recommendations
assert len(roof_recommender5.recommendations) == 1
assert len(roof_recommender5.recommendations) == 3
assert roof_recommender5.recommendations[0]["parts"][0]["depth"] == 300
def test_loft_insulation_recommendation_270mm_insulation(self):
@ -157,6 +162,7 @@ class TestRoofRecommendations:
'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': '270', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none'
}
property_instance6.already_installed = []
roof_recommender6 = RoofRecommendations(property_instance=property_instance6, materials=materials)
@ -179,6 +185,7 @@ class TestRoofRecommendations:
'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
}
property_instance7.already_installed = []
property_instance7.pitched_roof_area = 110
@ -189,7 +196,7 @@ class TestRoofRecommendations:
roof_recommender7.recommend(phase=0)
assert len(roof_recommender7.recommendations) == 1
assert roof_recommender7.recommendations[0]["new_u_value"] == 0.2
assert roof_recommender7.recommendations[0]["new_u_value"] == 0.18
assert roof_recommender7.recommendations[0]["starting_u_value"] == 0.8
assert roof_recommender7.recommendations[0]["description"] == "Insulate room in roof at rafters and re-decorate"
@ -208,6 +215,7 @@ class TestRoofRecommendations:
'is_assumed': False, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'average'
}
property_instance8.already_installed = []
property_instance8.pitched_roof_area = 110
@ -233,6 +241,7 @@ class TestRoofRecommendations:
'is_roof_room': True, 'is_loft': False, 'is_flat': False, 'is_thatched': False, 'is_at_rafters': False,
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
}
property_instance9.already_installed = []
property_instance9.pitched_roof_area = 110
property_instance9.data = {"county": "Rutland"}
@ -260,6 +269,7 @@ class TestRoofRecommendations:
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True,
'insulation_thickness': 'below average'
}
property_instance10.already_installed = []
property_instance10.pitched_roof_area = 110
@ -271,7 +281,7 @@ class TestRoofRecommendations:
assert len(roof_recommender10.recommendations) == 1
assert roof_recommender10.recommendations[0]["new_u_value"] == 0.19
assert roof_recommender10.recommendations[0]["new_u_value"] == 0.17
assert roof_recommender10.recommendations[0]["starting_u_value"] == 0.68
@ -292,6 +302,7 @@ class TestRoofRecommendations:
'is_roof_room': False, 'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False,
'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'none'
}
property_instance11.already_installed = []
roof_recommender11 = RoofRecommendations(property_instance=property_instance11, materials=materials)
@ -324,6 +335,7 @@ class TestRoofRecommendations:
'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'average'
}
property_instance12.already_installed = []
roof_recommender12 = RoofRecommendations(property_instance=property_instance12, materials=materials)
@ -348,6 +360,7 @@ class TestRoofRecommendations:
'is_loft': False, 'is_flat': True, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': True,
'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': 'below average'
}
property_instance13.already_installed = []
roof_recommender13 = RoofRecommendations(property_instance=property_instance13, materials=materials)
@ -380,6 +393,7 @@ class TestRoofRecommendations:
'is_assumed': False, 'has_dwelling_above': True, 'is_valid': True,
'insulation_thickness': None
}
property_instance14.already_installed = []
roof_recommender14 = RoofRecommendations(property_instance=property_instance14, materials=materials)

View file

@ -1,9 +1,10 @@
import pytest
from recommendations.SolarPvRecommendations import SolarPvRecommendations
from backend.Property import Property
from etl.epc.Record import EPCRecord
import pandas as pd
import numpy as np
import pytest
from backend.Property import Property
from etl.epc.Record import EPCRecord
from recommendations.tests.test_data.materials import materials
from recommendations.SolarPvRecommendations import SolarPvRecommendations
class TestSolarPvRecommendations:
@ -16,6 +17,7 @@ class TestSolarPvRecommendations:
}
property_instance_invalid_type = Property(id=1, address="", postcode="", epc_record=epc_record)
property_instance_invalid_type.roof = {"is_flat": False, "is_pitched": False, "is_roof_room": False}
property_instance_invalid_type.already_installed = []
return property_instance_invalid_type
@pytest.fixture
@ -29,6 +31,7 @@ class TestSolarPvRecommendations:
property_instance_invalid_roof.roof = {
"is_flat": False, "is_pitched": False, "is_roof_room": False, "thermal_transmittance": None
}
property_instance_invalid_roof.already_installed = []
return property_instance_invalid_roof
@pytest.fixture
@ -39,6 +42,7 @@ class TestSolarPvRecommendations:
"property-type": "House"}
property_instance_has_solar_pv = Property(id=1, address="", postcode="", epc_record=epc_record)
property_instance_has_solar_pv.roof = {"is_flat": True, "thermal_transmittance": None}
property_instance_has_solar_pv.already_installed = []
return property_instance_has_solar_pv
@pytest.fixture
@ -50,6 +54,7 @@ class TestSolarPvRecommendations:
property_instance_valid_all.roof_area = 40
property_instance_valid_all.number_of_floors = 2
property_instance_valid_all.roof = {"is_flat": True, "thermal_transmittance": None}
property_instance_valid_all.already_installed = []
property_instance_valid_all.solar_panel_configuration = {
"panel_performance": pd.DataFrame(
[
@ -66,35 +71,32 @@ class TestSolarPvRecommendations:
return property_instance_valid_all
def test_invalid_property_type(self, property_instance_invalid_type):
solar_pv = SolarPvRecommendations(property_instance_invalid_type)
solar_pv = SolarPvRecommendations(property_instance_invalid_type, materials=materials)
solar_pv.recommend(phase=0)
assert not solar_pv.recommendation
def test_invalid_roof_type(self, property_instance_invalid_roof):
solar_pv = SolarPvRecommendations(property_instance_invalid_roof)
solar_pv = SolarPvRecommendations(property_instance_invalid_roof, materials=materials)
solar_pv.recommend(phase=0)
assert not solar_pv.recommendation
def test_existing_solar_pv(self, property_instance_has_solar_pv):
solar_pv = SolarPvRecommendations(property_instance_has_solar_pv)
solar_pv = SolarPvRecommendations(property_instance_has_solar_pv, materials=materials)
solar_pv.recommend(phase=0)
assert not solar_pv.recommendation
def test_valid_all_conditions(self, property_instance_valid_all):
solar_pv = SolarPvRecommendations(property_instance_valid_all)
solar_pv = SolarPvRecommendations(property_instance_valid_all, materials=materials)
solar_pv.recommend(phase=0)
assert len(solar_pv.recommendation) == 2
assert solar_pv.recommendation == [
{'phase': 0, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', 'starting_u_value': None,
'new_u_value': None, 'sap_points': np.float64(10.0), 'already_installed': False,
'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, 'labour_hours': 48, 'labour_days': 2,
'photo_supply': np.float64(50.0), 'has_battery': False, 'initial_ac_kwh_per_year': np.int64(3800),
'description_simulation': {'photo-supply': np.float64(50.0)}},
{'phase': 0, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(10.0), 'already_installed': False,
'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, 'labour_hours': 48, 'labour_days': 2,
'photo_supply': np.float64(50.0), 'has_battery': True, 'initial_ac_kwh_per_year': np.int64(3800),
'description_simulation': {'photo-supply': np.float64(50.0)}}
]
assert len(solar_pv.recommendation) == 11
assert solar_pv.recommendation[0]["description"] == '10 panel system, 400W solar panels - 4.0 kWp system'
assert not solar_pv.recommendation[0]["has_battery"]
assert solar_pv.recommendation[0]["initial_ac_kwh_per_year"] == np.int64(3800)
assert solar_pv.recommendation[0]["description_simulation"] == {'photo-supply': np.float64(50.0)}
assert solar_pv.recommendation[0]["simulation_config"] == {'photo_supply_ending': np.float64(50.0)}
assert (
solar_pv.recommendation[1]["description"] ==
'10 panel system, 400W solar panels, 5.8kw Growatt battery - 4.0 kWp system'
)
assert solar_pv.recommendation[1]["has_battery"]

View file

@ -10,6 +10,7 @@ class TestVentilationRecommendations:
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": "natural"}
input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property1.already_installed = []
recommender = VentilationRecommendations(
property_instance=input_property1,
@ -22,16 +23,18 @@ class TestVentilationRecommendations:
assert len(recommender.recommendation) == 1
assert recommender.recommendation[0]["total"] == 1071.0
assert recommender.recommendation[0]["total"] == 560.0
assert recommender.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender.recommendation[0]["parts"]) == 1
assert recommender.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
assert recommender.recommendation[0]["parts"][0][
"description"] == 'Decentralised mechanical extract ventilation'
assert recommender.recommendation[0]["parts"][0]["quantity"] == 2
def test_missing_ventilation(self):
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": None}
input_property2 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property2.already_installed = []
recommender2 = VentilationRecommendations(
property_instance=input_property2,
@ -44,16 +47,18 @@ class TestVentilationRecommendations:
assert len(recommender2.recommendation) == 1
assert recommender2.recommendation[0]["total"] == 1071.0
assert recommender2.recommendation[0]["total"] == 560.0
assert recommender2.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender2.recommendation[0]["parts"]) == 1
assert recommender2.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
assert recommender2.recommendation[0]["parts"][0][
"description"] == 'Decentralised mechanical extract ventilation'
assert recommender2.recommendation[0]["parts"][0]["quantity"] == 2
def test_nodata_ventilation(self):
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": "NO DATA!!"}
input_property3 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property3.already_installed = []
recommender3 = VentilationRecommendations(
property_instance=input_property3,
@ -66,16 +71,18 @@ class TestVentilationRecommendations:
assert len(recommender3.recommendation) == 1
assert recommender3.recommendation[0]["total"] == 1071.0
assert recommender3.recommendation[0]["total"] == 560.0
assert recommender3.recommendation[0]["type"] == "mechanical_ventilation"
assert len(recommender3.recommendation[0]["parts"]) == 1
assert recommender3.recommendation[0]["parts"][0]["description"] == 'Mechanical Extract Ventilation'
assert recommender3.recommendation[0]["parts"][0][
"description"] == 'Decentralised mechanical extract ventilation'
assert recommender3.recommendation[0]["parts"][0]["quantity"] == 2
def test_existing_ventilation_1(self):
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, extract only"}
input_property4 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property4.already_installed = []
input_property4.identify_ventilation()
assert input_property4.has_ventilation
@ -94,6 +101,7 @@ class TestVentilationRecommendations:
epc_record = EPCRecord()
epc_record.prepared_epc = {"mechanical-ventilation": "mechanical, supply and extract"}
input_property5 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property5.already_installed = []
input_property5.identify_ventilation()
assert input_property5.has_ventilation

View file

@ -3,6 +3,7 @@ import pytest
import pickle
import numpy as np
from unittest.mock import Mock, MagicMock
from recommendations.WallRecommendations import WallRecommendations
from backend.Property import Property
from recommendations.recommendation_utils import is_diminishing_returns
@ -10,23 +11,8 @@ from recommendations.tests.test_data.materials import materials
from etl.epc.Record import EPCRecord
# import inspect
# file_path = inspect.getfile(lambda: None)
# with open(
# os.path.abspath(os.path.dirname(file_path)) + "/recommendations/tests/test_data/input_properties.pkl", "rb"
# ) as f:
# input_properties = pickle.load(f)
class TestWallRecommendations:
@pytest.fixture
def input_properties(self):
with open(
os.path.abspath(os.path.dirname(__file__)) + "/test_data/input_properties.pkl", "rb"
) as f:
return pickle.load(f)
@pytest.fixture
def mock_wall_rec_instance(self):
# Creating a mock instance of WallRecommendations with the necessary attributes
@ -40,17 +26,30 @@ class TestWallRecommendations:
)
return mock_wall_rec_instance
def test_init(self, input_properties):
input_properties[0].insulation_wall_area = 100
def test_init(self):
p = Mock(
id=1,
insulation_wall_area=100,
walls={
'original_description': 'Average thermal transmittance 0.16 W/m-¦K', 'thermal_transmittance': 0.16,
'thermal_transmittance_unit': 'w/m-¦k', 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False,
'is_sandstone_or_limestone': False, 'insulation_thickness': None, 'external_insulation': False,
'internal_insulation': False
},
already_installed=[],
data={"county": "Greater London Authority"}
)
obj = WallRecommendations(
property_instance=input_properties[0],
property_instance=p,
materials=materials
)
assert obj
assert obj.property
def test_uvalue_0_16(self, input_properties):
def test_uvalue_0_16(self):
"""
This tests the wall description Average thermal transmittance 0.16 W/m-¦K
The important data for this recommendation is:
@ -59,13 +58,29 @@ class TestWallRecommendations:
Since epc built after 1990 are typically built with insulation and this property
already has really good insulation, we do NOT recommend any measures for this property
"""
input_properties[0].year_built = 2014
input_properties[0].in_conservation_area = None
input_properties[0].restricted_measures = False
input_properties[0].insulation_wall_area = 100
p = Mock(
id=1,
insulation_wall_area=100,
year_built=2014,
in_conservation_area=None,
restricted_measure=False,
walls={
'original_description': 'Average thermal transmittance 0.16 W/m-¦K',
'clean_description': 'Average thermal transmittance 0.16 W/m-¦K',
'thermal_transmittance': 0.16,
'thermal_transmittance_unit': 'w/m-¦k', 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': False, 'is_cob': False, 'is_assumed': False,
'is_sandstone_or_limestone': False, 'insulation_thickness': None, 'external_insulation': False,
'internal_insulation': False
},
already_installed=[],
data={"county": "Greater London Authority", 'transaction-type': 'new dwelling'}
)
recommender = WallRecommendations(
property_instance=input_properties[0],
property_instance=p,
materials=materials
)
assert recommender.property.walls["original_description"] == "Average thermal transmittance 0.16 W/m-¦K"
@ -73,7 +88,7 @@ class TestWallRecommendations:
# This should be empty
assert recommender.recommendations == []
def test_solid_brick_no_insulation(self, input_properties):
def test_solid_brick_no_insulation(self):
"""
This tests a property with a wall description of Solid brick, as built, no insulation (assumed)
The property was built in 1930, right on the threshold for when cavity walls were introduced
@ -82,25 +97,35 @@ class TestWallRecommendations:
This property is not in a conservation area, however it's a flat so we don't recommend external wall insulation
"""
input_properties[1].year_built = 1930
input_properties[1].insulation_wall_area = 100
input_properties[1].walls["clean_description"] = "Solid brick, as built, no insulation"
input_properties[1].walls["is_sandstone_or_limestone"] = False
input_properties[1].age_band = "A"
input_properties[1].restricted_measures = False
input_properties[1].already_installed = []
input_properties[1].walls["is_park_home"] = False
input_properties[1].construction_age_band = "England and Wales: 1930-1949"
input_properties[1].non_invasive_recommendations = []
p = Mock(
id=2,
year_built=1930,
insulation_wall_area=100,
age_band="A",
restricted_measures=False,
already_installed=[],
construction_age_band="England and Wales: 1930-1949",
in_conservation_area="not_in_conservation_area",
non_invasive_recommendations=[],
walls={
'original_description': 'Solid brick, as built, no insulation (assumed)',
"clean_description": "Solid brick, as built, no insulation",
'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True,
'is_sandstone_or_limestone': False, 'insulation_thickness': 'none', 'external_insulation': False,
'internal_insulation': False, 'is_park_home': False
},
data={"county": "Greater London Authority", 'property-type': 'Flat', 'walls-energy-eff': 'Very Poor'}
)
recommender = WallRecommendations(
property_instance=input_properties[1],
property_instance=p,
materials=materials
)
assert recommender.property.walls["original_description"] == "Solid brick, as built, no insulation (assumed)"
assert not recommender.ewi_valid()
assert recommender.property.in_conservation_area == "not_in_conservation_area"
assert recommender.property.data["property-type"] == "Flat"
recommender.recommend(phase=0)
# This should result in some recommendations, all of which should be internal insulation
@ -115,7 +140,7 @@ class TestWallRecommendations:
recommender.recommendations
)
def test_solid_brick_insulation(self, input_properties):
def test_solid_brick_insulation(self):
"""
This tests a property with a wall description of Solid brick, as built, insulation (assumed)
The property was built in 1991, after cavity walls were introduced
@ -127,19 +152,34 @@ class TestWallRecommendations:
This property is not in a conservation area, however it's a flat so we don't recommend external wall insulation
"""
input_properties[6].year_built = 1991
input_properties[6].restricted_measures = False
input_properties[6].insulation_wall_area = 100
p = Mock(
id=3,
year_built=1991,
restricted_measures=False,
insulation_wall_area=100,
already_installed=[],
in_conservation_area="not_in_conservation_area",
data={'county': 'Greater London Authority', 'property-type': 'Flat'},
walls={
'original_description': 'Solid brick, as built, insulated (assumed)',
'clean_description': 'Solid brick, as built, insulated',
'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': True, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True,
'is_sandstone_or_limestone': False, 'insulation_thickness': 'average', 'external_insulation': False,
'internal_insulation': False
}
)
recommender = WallRecommendations(
property_instance=input_properties[6],
property_instance=p,
materials=materials
)
assert recommender.property.walls["original_description"] == "Solid brick, as built, insulated (assumed)"
assert not recommender.ewi_valid()
assert recommender.property.in_conservation_area == "not_in_conservation_area"
assert recommender.property.data["property-type"] == "Flat"
assert recommender.estimated_u_value is None
recommender.recommend()
@ -260,6 +300,7 @@ class TestCavityWallRecommensations:
input_property.age_band = "C"
input_property.insulation_wall_area = 50
input_property.construction_age_band = "England and Wales: 1930-1949"
input_property.already_installed = []
recommender = WallRecommendations(
property_instance=input_property,
@ -273,7 +314,7 @@ class TestCavityWallRecommensations:
assert recommender.recommendations
assert recommender.estimated_u_value == 1.5
assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.35)
assert np.isclose(recommender.recommendations[0]["total"], 710.5)
assert np.isclose(recommender.recommendations[0]["total"], 925)
def test_fill_partial_filled_cavity(self):
epc_record = EPCRecord()
@ -293,6 +334,7 @@ class TestCavityWallRecommensations:
input_property.age_band = "C"
input_property.insulation_wall_area = 50
input_property.construction_age_band = "England and Wales: 1930-1949"
input_property.already_installed = []
recommender = WallRecommendations(
property_instance=input_property,
@ -306,7 +348,7 @@ class TestCavityWallRecommensations:
assert recommender.recommendations
assert recommender.estimated_u_value == 1.3
assert np.isclose(recommender.recommendations[0]["new_u_value"], 0.41)
assert np.isclose(recommender.recommendations[0]["total"], 710.5)
assert np.isclose(recommender.recommendations[0]["total"], 925.0)
def test_system_built_wall(self):
epc_record = EPCRecord()
@ -329,6 +371,7 @@ class TestCavityWallRecommensations:
input_property2.insulation_wall_area = 120
input_property2.restricted_measures = False
input_property2.construction_age_band = "England and Wales: 1976-1982"
input_property2.already_installed = []
assert input_property2.walls["is_system_built"]
@ -350,7 +393,7 @@ class TestCavityWallRecommensations:
assert recommender2.recommendations[0]["parts"][0]["depth"] == 150
assert np.isclose(recommender2.recommendations[1]["new_u_value"], 0.26)
assert np.isclose(recommender2.recommendations[1]["total"], 29376)
assert np.isclose(recommender2.recommendations[1]["total"], 23400)
assert recommender2.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender2.recommendations[1]["parts"][0]["depth"] == 95
@ -376,6 +419,7 @@ class TestCavityWallRecommensations:
input_property3.insulation_wall_area = 99
input_property3.restricted_measures = False
input_property3.construction_age_band = "England and Wales: 1950-1966"
input_property3.already_installed = []
assert input_property3.walls["is_timber_frame"]
@ -397,7 +441,7 @@ class TestCavityWallRecommensations:
assert recommender3.recommendations[0]["parts"][0]["depth"] == 150.0
assert np.isclose(recommender3.recommendations[1]["new_u_value"], 0.29)
assert np.isclose(recommender3.recommendations[1]["total"], 24235.2)
assert np.isclose(recommender3.recommendations[1]["total"], 19305.0)
assert recommender3.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender3.recommendations[1]["parts"][0]["depth"] == 95.0
@ -423,6 +467,7 @@ class TestCavityWallRecommensations:
input_property4.insulation_wall_area = 223
input_property4.restricted_measures = False
input_property4.construction_age_band = "England and Wales: before 1900"
input_property4.already_installed = []
assert input_property4.walls["is_granite_or_whinstone"]
@ -444,7 +489,7 @@ class TestCavityWallRecommensations:
assert recommender4.recommendations[0]["parts"][0]["depth"] == 150
assert np.isclose(recommender4.recommendations[1]["new_u_value"], 0.3)
assert np.isclose(recommender4.recommendations[1]["total"], 54590.4)
assert np.isclose(recommender4.recommendations[1]["total"], 43485.0)
assert recommender4.recommendations[1]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender4.recommendations[1]["parts"][0]["depth"] == 95
@ -470,6 +515,7 @@ class TestCavityWallRecommensations:
input_property5.insulation_wall_area = 77
input_property5.restricted_measures = False
input_property5.construction_age_band = "England and Wales: 1967-1975"
input_property5.already_installed = []
assert input_property5.walls["is_cob"]
@ -507,8 +553,7 @@ class TestCavityWallRecommensations:
input_property6.insulation_wall_area = 350
input_property6.restricted_measures = False
input_property6.construction_age_band = "England and Wales: 1976-1982"
assert input_property6.walls["is_sandstone_or_limestone"]
input_property6.already_installed = []
recommender6 = WallRecommendations(
property_instance=input_property6,
@ -524,6 +569,6 @@ class TestCavityWallRecommensations:
assert len(recommender6.recommendations) == 1
assert recommender6.estimated_u_value == 1
assert np.isclose(recommender6.recommendations[0]["new_u_value"], 0.26)
assert np.isclose(recommender6.recommendations[0]["total"], 85680.0)
assert np.isclose(recommender6.recommendations[0]["total"], 68250.0)
assert recommender6.recommendations[0]["parts"][0]["type"] == "internal_wall_insulation"
assert recommender6.recommendations[0]["parts"][0]["depth"] == 95