diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 761786cd..5e23ae0d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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": { diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt index 300b86b0..5e7753a6 100644 --- a/.devcontainer/requirements.txt +++ b/.devcontainer/requirements.txt @@ -17,4 +17,6 @@ sqlmodel # Testing pytest==9.0.2 pytest-cov==7.0.0 -ipykernel>=6.25,<7 \ No newline at end of file +ipykernel>=6.25,<7 +# Formatting +black==26.1.0 \ No newline at end of file diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 5428fe89..95155c86 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -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 }} diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 50ed0772..a9156078 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -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) diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..e3add6e6 --- /dev/null +++ b/conftest.py @@ -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() diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 86062433..60b1d8a2 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -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, diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index ea3056ba..20568360 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -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(( diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py index 284d1d2a..38b206da 100644 --- a/recommendations/WallRecommendations.py +++ b/recommendations/WallRecommendations.py @@ -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", } diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index a4543dbf..d704b3fb 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -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 diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index 0794013e..b1744c69 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -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( diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py index 4b8d74db..752caf8c 100644 --- a/recommendations/tests/test_costs.py +++ b/recommendations/tests/test_costs.py @@ -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) diff --git a/recommendations/tests/test_data/heating_recommendations_data.py b/recommendations/tests/test_data/heating_recommendations_data.py index 37c854c3..1bd58c9e 100644 --- a/recommendations/tests/test_data/heating_recommendations_data.py +++ b/recommendations/tests/test_data/heating_recommendations_data.py @@ -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": { diff --git a/recommendations/tests/test_data/measures_to_optimise.py b/recommendations/tests/test_data/measures_to_optimise.py index cefd36e4..d84111cd 100644 --- a/recommendations/tests/test_data/measures_to_optimise.py +++ b/recommendations/tests/test_data/measures_to_optimise.py @@ -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, diff --git a/recommendations/tests/test_floor_recommendations.py b/recommendations/tests/test_floor_recommendations.py index eb4f30d2..e24312fe 100644 --- a/recommendations/tests/test_floor_recommendations.py +++ b/recommendations/tests/test_floor_recommendations.py @@ -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, diff --git a/recommendations/tests/test_heating_recommendations.py b/recommendations/tests/test_heating_recommendations.py index 93acdefa..b62483ec 100644 --- a/recommendations/tests/test_heating_recommendations.py +++ b/recommendations/tests/test_heating_recommendations.py @@ -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(): diff --git a/recommendations/tests/test_lighting_recommendations.py b/recommendations/tests/test_lighting_recommendations.py index 5fdca9f7..aeaffdb4 100644 --- a/recommendations/tests/test_lighting_recommendations.py +++ b/recommendations/tests/test_lighting_recommendations.py @@ -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 diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py index ea0b5d94..c2927790 100644 --- a/recommendations/tests/test_optimiser_functions.py +++ b/recommendations/tests/test_optimiser_functions.py @@ -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) diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py index ecc6ea56..17e45154 100644 --- a/recommendations/tests/test_optimisers.py +++ b/recommendations/tests/test_optimisers.py @@ -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"]) diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py index fa707b4b..1484a09c 100644 --- a/recommendations/tests/test_recommendation_utils.py +++ b/recommendations/tests/test_recommendation_utils.py @@ -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): diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py index 214ea6c0..2241aeb7 100644 --- a/recommendations/tests/test_roof_recommendations.py +++ b/recommendations/tests/test_roof_recommendations.py @@ -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) diff --git a/recommendations/tests/test_solar_pv_recommendations.py b/recommendations/tests/test_solar_pv_recommendations.py index b16fcc3b..f93cc644 100644 --- a/recommendations/tests/test_solar_pv_recommendations.py +++ b/recommendations/tests/test_solar_pv_recommendations.py @@ -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"] diff --git a/recommendations/tests/test_ventilation_recommendations.py b/recommendations/tests/test_ventilation_recommendations.py index ea87a632..15c9435c 100644 --- a/recommendations/tests/test_ventilation_recommendations.py +++ b/recommendations/tests/test_ventilation_recommendations.py @@ -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 diff --git a/recommendations/tests/test_wall_recommendations.py b/recommendations/tests/test_wall_recommendations.py index a4093e58..18560118 100644 --- a/recommendations/tests/test_wall_recommendations.py +++ b/recommendations/tests/test_wall_recommendations.py @@ -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