mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Merge branch 'main' into feature/condition-data
This commit is contained in:
commit
b73f6297eb
23 changed files with 429 additions and 986 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
11
.github/workflows/unit_tests.yml
vendored
11
.github/workflows/unit_tests.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
31
conftest.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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((
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue