from datetime import datetime import pandas as pd import pickle import pytest from backend.Property import Property 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 class TestHeatingRecommendations: @pytest.fixture() def cleaning_data(self): with open("recommendations/tests/test_data/cleaning_data.pkl", "rb") as f: data = pickle.load(f) return data @pytest.fixture() def cleaned(self): with open("recommendations/tests/test_data/cleaned.pkl", "rb") as f: df = pickle.load(f) return df @pytest.fixture() def kwh_client(self): client = KwhData(bucket="retrofit-data-dev", read_consumption_data=False) # We fix this pricing table for these tests client.retail_price_comparison = pd.DataFrame( [ { "Date": datetime.today().strftime("%Y-%m-%d"), 'Average standard variable tariff (Large legacy suppliers)': 1 } ] ) client.retail_price_comparison["Date"] = pd.to_datetime(client.retail_price_comparison["Date"]) return client @pytest.mark.parametrize( "test_case", testing_examples ) def test_recommend(self, test_case, cleaning_data, cleaned, kwh_client): """ With this function, we test out multiple heating descriptions and check which recomendations we retrieve alongside them :return: """ # We patch an old version of cleaned which is missing some attributes for 'mainheat-description' for x in cleaned['mainheat-description']: x["has_hot-water-only"] = False x["has_mineral_and_wood"] = False x["has_dual_fuel_appliance"] = False epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []} epc_record = EPCRecord( epc_records=epc_records, run_mode="newdata", cleaning_data=cleaning_data ) p = Property( id=0, postcode=test_case["epc"]["postcode"], address=test_case["epc"]["address"], epc_record=epc_record, energy_assessment={ "condition": {}, "energy_assessment_is_newer": False } ) # For these tests, this can be fixed kwh_predictions = { "heating_kwh_predictions": pd.DataFrame( [ {"id": p.uprn, "predictions": 12000} ] ), "hotwater_kwh_predictions": pd.DataFrame( [ {"id": p.uprn, "predictions": 3000} ] ), } p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=kwh_predictions) recommender = HeatingRecommender(property_instance=p) # Check they're empty assert not recommender.heating_recommendations recommender.recommend(has_cavity_or_loft_recommendations=False) assert len(recommender.heating_recommendations) == len(test_case["heating_measure_types"]) # Check the exact measure types assert ( {x["measure_type"] for x in recommender.heating_recommendations} == set(test_case["heating_measure_types"]) ) @pytest.mark.parametrize( "floor_area, epc_primary, expected_band, expected_model", [ # Case 1 – Typical pre-2000 house, gas heating ( 93.75, 270.19, (2.5, 4.6), # expected rough band (low, high) 5, # chosen model ), # Case 2 – Efficient new-build (low EPC energy) ( 93.75, 142.28, (1.4, 2.4), 3, # assume 3 or 5 kW model covers this ), ], ) def test_estimate_peak_kw_basic(floor_area, epc_primary, expected_band, expected_model): """ Ensure the peak load estimate is within a sensible range and that the model selection logic picks the correct bracket. """ load_band = HeatingRecommender.estimate_peak_kw( floor_area_m2=floor_area, epc_primary_kwh_per_m2_yr=epc_primary, primary_to_delivered_factor=1.55, # electricity space_heat_fraction_range=(0.35, 0.60), hdd_base_dd=2000.0, t_indoor_C=21.0, t_design_ext_C=-1.0, ) # Assert range sanity assert expected_band[0] * 0.8 <= load_band[0] <= expected_band[1] * 1.2 assert expected_band[0] <= load_band[1] <= expected_band[1] * 1.2 # Pick model model = HeatingRecommender.pick_model(load_band, models_kw=(3, 5, 6, 8.5, 11.2)) assert model == expected_model def test_estimate_peak_kw_with_hlp(): """ Test direct HLP input path (best-quality data). """ hlp = 1.5 # W/m²K typical for semi-detached floor_area = 100 load_band = HeatingRecommender.estimate_peak_kw( floor_area_m2=floor_area, heat_loss_parameter_W_per_m2K=hlp, t_indoor_C=21, t_design_ext_C=-2, ) # Should return identical low/high values since it's direct assert isinstance(load_band, tuple) assert abs(load_band[0] - load_band[1]) < 1e-6 # Expected peak = 1.5 * 100 * 23 / 1000 = 3.45 kW assert pytest.approx(load_band[0], rel=0.05) == 3.45 def test_estimate_peak_kw_with_space_heat_demand(): """ Test the space-heating-demand path. """ floor_area = 120 space_heat_kwh_m2 = 100 load_band = HeatingRecommender.estimate_peak_kw( floor_area_m2=floor_area, space_heat_kwh_per_m2_yr=space_heat_kwh_m2, hdd_base_dd=2100, t_indoor_C=21, t_design_ext_C=-3, ) # Rough expected peak ~ (100*120*1000)/(2100*24) * 24 /1000 = 5.4 kW assert 4.5 < load_band[0] < 6.0 assert abs(load_band[0] - load_band[1]) < 1e-6 def test_pick_model_boundaries(): """ Ensure pick_model correctly selects the smallest model covering the upper band. """ assert HeatingRecommender.pick_model((2.0, 4.9), models_kw=(3, 5, 6, 8.5)) == 5 assert HeatingRecommender.pick_model((5.0, 5.0), models_kw=(3, 5, 6, 8.5)) == 5 assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 6 assert HeatingRecommender.pick_model((8.6, 9.0), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) is None def test_parameter_validation_and_defaults(): """ Validate that the function handles missing or minimal parameters properly. """ # Minimal path using primary energy only load_band = HeatingRecommender.estimate_peak_kw( floor_area_m2=80, epc_primary_kwh_per_m2_yr=250, ) assert isinstance(load_band, tuple) assert load_band[0] < load_band[1]