mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
created test to cover off the case of solar pv + battery
This commit is contained in:
parent
facc81d741
commit
261e0769e3
3 changed files with 323 additions and 14 deletions
|
|
@ -15,6 +15,7 @@ from backend.app.db.models.portfolio import (
|
||||||
PropertyModel,
|
PropertyModel,
|
||||||
PropertyDetailsEpcModel,
|
PropertyDetailsEpcModel,
|
||||||
)
|
)
|
||||||
|
from backend.app.db.models.materials import Material
|
||||||
from utils.logger import setup_logger
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
logger = setup_logger()
|
logger = setup_logger()
|
||||||
|
|
@ -192,31 +193,35 @@ class DbMethods:
|
||||||
recommendations_df["materials"] = []
|
recommendations_df["materials"] = []
|
||||||
return recommendations_df
|
return recommendations_df
|
||||||
|
|
||||||
rec_ids: List[int] = [int(x) for x in recommendations_df["id"].tolist()]
|
rec_ids: List[int] = recommendations_df["id"].astype(int).tolist()
|
||||||
|
|
||||||
stmt = select(RecommendationMaterials).where(
|
stmt = (
|
||||||
RecommendationMaterials.recommendation_id.in_(rec_ids)
|
select(RecommendationMaterials, Material)
|
||||||
|
.join(Material, RecommendationMaterials.material_id == Material.id)
|
||||||
|
.where(RecommendationMaterials.recommendation_id.in_(rec_ids))
|
||||||
)
|
)
|
||||||
|
|
||||||
materials_query: Sequence[RecommendationMaterials] = (
|
rows: Sequence[Tuple[RecommendationMaterials, Material]] = (
|
||||||
self.session.scalars(stmt).all()
|
self.session.execute(stmt).tuples().all()
|
||||||
)
|
)
|
||||||
|
|
||||||
materials_map: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
materials_map: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||||
|
|
||||||
for m in materials_query:
|
for rec_mat, material in rows:
|
||||||
materials_map[m.recommendation_id].append(
|
materials_map[rec_mat.recommendation_id].append(
|
||||||
{
|
{
|
||||||
"material_id": m.material_id,
|
"material_id": rec_mat.material_id,
|
||||||
"depth": m.depth,
|
"depth": rec_mat.depth,
|
||||||
"quantity": m.quantity,
|
"quantity": rec_mat.quantity,
|
||||||
"quantity_unit": m.quantity_unit,
|
"quantity_unit": rec_mat.quantity_unit,
|
||||||
"estimated_cost": m.estimated_cost,
|
"estimated_cost": rec_mat.estimated_cost,
|
||||||
|
"type": material.type.value if material.type else None,
|
||||||
|
"includes_battery": material.includes_battery,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
recommendations_df["materials"] = recommendations_df["id"].astype(int).apply(
|
recommendations_df["materials"] = recommendations_df["id"].astype(int).apply(
|
||||||
lambda x: materials_map.get(int(x), [])
|
lambda x: materials_map.get(x, [])
|
||||||
)
|
)
|
||||||
|
|
||||||
return recommendations_df
|
return recommendations_df
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,21 @@ def choose_group_keys(payload: ExportRequest) -> List[Union[int, str]]:
|
||||||
return payload.scenario_ids
|
return payload.scenario_ids
|
||||||
|
|
||||||
|
|
||||||
|
def has_solar_with_battery(materials_list: Optional[List[Dict[str, Any]]]) -> bool:
|
||||||
|
"""
|
||||||
|
Simple check to determine if any material in the list is a solar PV measure that includes a battery.
|
||||||
|
:param materials_list:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
for m in materials_list or []:
|
||||||
|
if (
|
||||||
|
m.get("type") == "solar_pv"
|
||||||
|
and m.get("includes_battery") is True
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, int], pd.DataFrame]:
|
def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, int], pd.DataFrame]:
|
||||||
export_files: Dict[Union[str, int], pd.DataFrame] = {}
|
export_files: Dict[Union[str, int], pd.DataFrame] = {}
|
||||||
|
|
||||||
|
|
@ -46,6 +61,19 @@ def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str,
|
||||||
|
|
||||||
recommendations_df = db_methods.attach_materials(recommendations_df)
|
recommendations_df = db_methods.attach_materials(recommendations_df)
|
||||||
|
|
||||||
|
recommendations_df["has_solar_with_battery"] = (
|
||||||
|
recommendations_df["materials"].apply(has_solar_with_battery)
|
||||||
|
)
|
||||||
|
|
||||||
|
_filter = (
|
||||||
|
(recommendations_df["measure_type"] == "solar_pv")
|
||||||
|
& (recommendations_df["has_solar_with_battery"])
|
||||||
|
)
|
||||||
|
|
||||||
|
recommendations_df.loc[_filter, "measure_type"] = (
|
||||||
|
recommendations_df.loc[_filter, "measure_type"] + "_with_battery"
|
||||||
|
)
|
||||||
|
|
||||||
group_keys: List[Union[str, int]] = choose_group_keys(payload)
|
group_keys: List[Union[str, int]] = choose_group_keys(payload)
|
||||||
|
|
||||||
for group_key in group_keys:
|
for group_key in group_keys:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ from backend.export.property_scenarios.main import process_export
|
||||||
from backend.export.property_scenarios.input_schema import ExportRequest
|
from backend.export.property_scenarios.input_schema import ExportRequest
|
||||||
from backend.app.db.models.portfolio import PropertyModel, Epc, Portfolio, PortfolioStatus, PortfolioGoal, \
|
from backend.app.db.models.portfolio import PropertyModel, Epc, Portfolio, PortfolioStatus, PortfolioGoal, \
|
||||||
PropertyCreationStatus, PropertyDetailsEpcModel
|
PropertyCreationStatus, PropertyDetailsEpcModel
|
||||||
from backend.app.db.models.recommendations import PlanModel, Recommendation, PlanRecommendations
|
from backend.app.db.models.recommendations import PlanModel, Recommendation, PlanRecommendations, \
|
||||||
|
RecommendationMaterials
|
||||||
|
from backend.app.db.models.materials import Material
|
||||||
from utils.logger import setup_logger
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
FIXTURE_PATH = Path("backend/export/tests/fixtures")
|
FIXTURE_PATH = Path("backend/export/tests/fixtures")
|
||||||
|
|
@ -262,3 +264,277 @@ def test_default_export_integration(db_session):
|
||||||
)
|
)
|
||||||
|
|
||||||
assert df.shape == (10, 95), "Expected dataframe shape to be (10, 11), got {}".format(df.shape)
|
assert df.shape == (10, 95), "Expected dataframe shape to be (10, 11), got {}".format(df.shape)
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_with_battery_example(db_session):
|
||||||
|
test_portfolio_id = 1
|
||||||
|
test_property_id = 1
|
||||||
|
|
||||||
|
portfolio_df = pd.DataFrame(
|
||||||
|
[{'id': test_portfolio_id, 'name': 'Example', 'budget': None,
|
||||||
|
'status': 'PortfolioStatus.SCOPING', 'goal': 'PortfolioGoal.NONE', 'cost': None, 'number_of_properties': None,
|
||||||
|
'co2_equivalent_savings': None, 'energy_savings': None, 'energy_cost_savings': None,
|
||||||
|
'property_valuation_increase': None, 'rental_yield_increase': None, 'total_work_hours': None,
|
||||||
|
'labour_days': None, 'created_at': '2026-02-12 21:23:37.862000+00:00',
|
||||||
|
'updated_at': '2026-02-12 21:23:37.862000+00:00', 'epc_breakdown_pre_retrofit': None,
|
||||||
|
'epc_breakdown_post_retrofit': None, 'n_units_to_retrofit': None, 'co2_per_unit_pre_retrofit': None,
|
||||||
|
'co2_per_unit_post_retrofit': None, 'energy_bill_per_unit_pre_retrofit': None,
|
||||||
|
'energy_bill_per_unit_post_retrofit': None, 'energy_consumption_per_unit_pre_retrofit': None,
|
||||||
|
'energy_consumption_per_unit_post_retrofit': None, 'valuation_improvement_per_unit': None,
|
||||||
|
'cost_per_unit': None, 'cost_per_co2_saved': None, 'cost_per_sap_point': None,
|
||||||
|
'valuation_return_on_investment': None}]
|
||||||
|
)
|
||||||
|
|
||||||
|
properties_df = pd.DataFrame(
|
||||||
|
[{'id': test_property_id, 'portfolio_id': test_portfolio_id, 'creation_status': 'PropertyCreationStatus.READY',
|
||||||
|
'uprn': 100090438731, 'landlord_property_id': 'BARR052', 'building_reference_number': 3460742868.0,
|
||||||
|
'status': 'PortfolioStatus.ASSESSMENT', 'address': '52, Barrack Street', 'postcode': 'CO1 2LR',
|
||||||
|
'has_pre_condition_report': True, 'has_recommendations': True, 'created_at': '2026-02-12 21:59:02.744427',
|
||||||
|
'updated_at': '2026-02-19 16:18:57.941443', 'property_type': 'House', 'built_form': 'End-Terrace',
|
||||||
|
'local_authority': 'Colchester', 'constituency': 'Colchester', 'number_of_rooms': 4.0, 'year_built': 1900.0,
|
||||||
|
'tenure': 'rental (private)', 'current_epc_rating': 'Epc.E', 'current_sap_points': 53.0,
|
||||||
|
'current_valuation': 0.0, 'installed_measures_sap_point_adjustment': 0.0,
|
||||||
|
'is_sap_points_adjusted_for_installed_measures': False, 'original_sap_points': 53.0}]
|
||||||
|
)
|
||||||
|
|
||||||
|
property_details_epc_df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{'id': 1534934, 'property_id': test_property_id, 'portfolio_id': test_portfolio_id,
|
||||||
|
'full_address': '48, Medcalf Road', 'lodgement_date': '2018-09-05', 'is_expired': False,
|
||||||
|
'total_floor_area': 68.0, 'walls': 'Solid brick, as built, no insulation', 'walls_rating': 1,
|
||||||
|
'roof': 'Pitched, no insulation', 'roof_rating': 1.0, 'floor': 'Solid, no insulation',
|
||||||
|
'floor_rating': None,
|
||||||
|
'windows': 'Fully double glazed', 'windows_rating': 4, 'heating': 'Boiler and radiators, mains gas',
|
||||||
|
'heating_rating': 4, 'heating_controls': 'Programmer, room thermostat and trvs',
|
||||||
|
'heating_controls_rating': 4,
|
||||||
|
'hot_water': 'From main system', 'hot_water_rating': 4,
|
||||||
|
'lighting': 'Low energy lighting in all fixed outlets', 'lighting_rating': 5,
|
||||||
|
'mainfuel': 'Mains gas not community', 'ventilation': 'natural', 'solar_pv': 0.0, 'solar_hot_water': False,
|
||||||
|
'wind_turbine': 0.0, 'floor_height': 2.55, 'number_heated_rooms': None, 'heat_loss_corridor': False,
|
||||||
|
'unheated_corridor_length': None, 'number_of_open_fireplaces': 0, 'number_of_extensions': 0,
|
||||||
|
'number_of_storeys': None, 'mains_gas': True, 'energy_tariff': 'Single',
|
||||||
|
'primary_energy_consumption': 278.0,
|
||||||
|
'co2_emissions': 3.81, 'current_energy_demand': 14643.366,
|
||||||
|
'current_energy_demand_heating_hotwater': 12185.6,
|
||||||
|
'estimated': False, 'sap_05_overwritten': False, 'sap_05_score': None, 'sap_05_epc_rating': None,
|
||||||
|
'heating_cost_current': 711.0628, 'hot_water_cost_current': 139.06198, 'lighting_cost_current': 70.770935,
|
||||||
|
'appliances_cost_current': 609.7844, 'gas_standing_charge': 128.0785,
|
||||||
|
'electricity_standing_charge': 199.8375,
|
||||||
|
'original_co2_emissions': 3.81, 'original_primary_energy_consumption': 278.0,
|
||||||
|
'original_current_energy_demand': 14643.366, 'original_current_energy_demand_heating_hotwater': 12185.6,
|
||||||
|
'installed_measures_co2_adjustment': 0.0, 'installed_measures_energy_demand_adjustment': 0.0,
|
||||||
|
'installed_measures_total_energy_bill_adjustment': 0.0, 'installed_measures_heat_demand_adjustment': 0.0,
|
||||||
|
'is_epc_adjusted_for_installed_measures': False}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
plans_df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{'id': 0, 'name': None, 'portfolio_id': test_portfolio_id, 'property_id': test_property_id,
|
||||||
|
'scenario_id': 1060, 'created_at': '2026-02-19 16:14:45.560816', 'is_default': True,
|
||||||
|
'valuation_increase_lower_bound': 0.0302,
|
||||||
|
'valuation_increase_upper_bound': 0.07, 'valuation_increase_average': 0.048226666, 'plan_type': None,
|
||||||
|
'post_sap_points': 71.5, 'post_epc_rating': 'Epc.C', 'post_co2_emissions': 4.1813498,
|
||||||
|
'co2_savings': 0.71865046, 'post_energy_bill': 1447.5204, 'energy_bill_savings': 691.6662,
|
||||||
|
'post_energy_consumption': 15303.688, 'energy_consumption_savings': 3276.7622,
|
||||||
|
'valuation_post_retrofit': None, 'valuation_increase': None, 'cost_of_works': 6984.568,
|
||||||
|
'contingency_cost': 1003.9568}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
plan_recs_df = pd.DataFrame(
|
||||||
|
[{'id': 0, 'plan_id': 0, 'recommendation_id': 0}]
|
||||||
|
)
|
||||||
|
|
||||||
|
recommendations_df = pd.DataFrame(
|
||||||
|
[{'id': 0, 'property_id': test_property_id, 'created_at': '2026-02-19 16:14:45.560816',
|
||||||
|
'type': 'solar_pv', 'measure_type': 'solar_pv',
|
||||||
|
'description': 'Fit solar',
|
||||||
|
'estimated_cost': 10000, 'default': True, 'starting_u_value': None, 'new_u_value': None, 'sap_points': 1.5,
|
||||||
|
'heat_demand': 14.9, 'kwh_savings': 1041.2, 'co2_equivalent_savings': 0.2, 'energy_savings': 14.9,
|
||||||
|
'energy_cost_savings': 72.639015, 'property_valuation_increase': None, 'rental_yield_increase': None,
|
||||||
|
'total_work_hours': 4.16, 'labour_days': 1.0, 'already_installed': False, 'plan_name': 'whatever'}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
recommendations_materials_df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 0, "recommendation_id": 0, "material_id": 0, "depth": None, "quantity": 1.0,
|
||||||
|
"quantity_unit": "part",
|
||||||
|
"estimated_cost": 10000, "created_at": '2026-02-19 16:14:45.560816',
|
||||||
|
"updated_at": '2026-02-19 16:14:45.560816',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
materials_df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{'id': 0, 'type': 'solar_pv', 'description': 'Some solar product',
|
||||||
|
'depth': 75.0,
|
||||||
|
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.030303031,
|
||||||
|
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.033,
|
||||||
|
'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'link': 'Test',
|
||||||
|
'created_at': "'2026-02-19 16:14:45.560816", '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': 10000,
|
||||||
|
'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.25, 'size': None, 'size_unit': None,
|
||||||
|
'includes_scaffolding': True, 'includes_battery': True, 'battery_size': 5.8}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load into db
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Insert Portfolio
|
||||||
|
# -------------------------------------------------
|
||||||
|
for row in portfolio_df.itertuples(index=False):
|
||||||
|
db_session.add(
|
||||||
|
Portfolio(
|
||||||
|
id=row.id,
|
||||||
|
name=row.name,
|
||||||
|
status=PortfolioStatus[row.status.split(".")[-1]],
|
||||||
|
goal=PortfolioGoal[row.goal.split(".")[-1]],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Insert Property
|
||||||
|
# -------------------------------------------------
|
||||||
|
for row in properties_df.itertuples(index=False):
|
||||||
|
prop = PropertyModel(
|
||||||
|
id=row.id,
|
||||||
|
portfolio_id=row.portfolio_id,
|
||||||
|
creation_status=PropertyCreationStatus[row.creation_status.split(".")[-1]],
|
||||||
|
status=PortfolioStatus[row.status.split(".")[-1]],
|
||||||
|
uprn=row.uprn,
|
||||||
|
property_type=row.property_type,
|
||||||
|
current_sap_points=row.current_sap_points,
|
||||||
|
current_epc_rating=Epc[row.current_epc_rating.split(".")[-1]],
|
||||||
|
)
|
||||||
|
db_session.add(prop)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Insert EPC Details
|
||||||
|
# -------------------------------------------------
|
||||||
|
for row in property_details_epc_df.itertuples(index=False):
|
||||||
|
epc = PropertyDetailsEpcModel(
|
||||||
|
property_id=row.property_id,
|
||||||
|
portfolio_id=row.portfolio_id,
|
||||||
|
full_address=row.full_address,
|
||||||
|
total_floor_area=row.total_floor_area,
|
||||||
|
walls=row.walls,
|
||||||
|
roof=row.roof,
|
||||||
|
windows=row.windows,
|
||||||
|
heating=row.heating,
|
||||||
|
solar_pv=row.solar_pv,
|
||||||
|
)
|
||||||
|
db_session.add(epc)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Insert Plan (default)
|
||||||
|
# -------------------------------------------------
|
||||||
|
for row in plans_df.itertuples(index=False):
|
||||||
|
plan = PlanModel(
|
||||||
|
id=row.id,
|
||||||
|
portfolio_id=row.portfolio_id,
|
||||||
|
property_id=row.property_id,
|
||||||
|
scenario_id=None, # default mode
|
||||||
|
is_default=row.is_default,
|
||||||
|
)
|
||||||
|
db_session.add(plan)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# IMPORTANT: Force recommendation to be solar_pv
|
||||||
|
# -------------------------------------------------
|
||||||
|
recommendations_df.loc[0, "measure_type"] = "solar_pv"
|
||||||
|
|
||||||
|
for row in recommendations_df.itertuples(index=False):
|
||||||
|
rec = Recommendation(
|
||||||
|
id=row.id,
|
||||||
|
property_id=row.property_id,
|
||||||
|
measure_type=row.measure_type,
|
||||||
|
estimated_cost=row.estimated_cost,
|
||||||
|
default=row.default,
|
||||||
|
already_installed=row.already_installed,
|
||||||
|
sap_points=row.sap_points,
|
||||||
|
type=row.type,
|
||||||
|
description=row.description
|
||||||
|
)
|
||||||
|
db_session.add(rec)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Link Plan -> Recommendation
|
||||||
|
# -------------------------------------------------
|
||||||
|
for row in plan_recs_df.itertuples(index=False):
|
||||||
|
db_session.add(
|
||||||
|
PlanRecommendations(
|
||||||
|
plan_id=row.plan_id,
|
||||||
|
recommendation_id=row.recommendation_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Insert Material (includes_battery=True)
|
||||||
|
# -------------------------------------------------
|
||||||
|
for row in materials_df.itertuples(index=False):
|
||||||
|
material = Material(
|
||||||
|
id=row.id,
|
||||||
|
type=row.type,
|
||||||
|
description=row.description,
|
||||||
|
depth_unit=row.depth_unit,
|
||||||
|
cost_unit=row.cost_unit,
|
||||||
|
r_value_unit=row.r_value_unit,
|
||||||
|
thermal_conductivity_unit=row.thermal_conductivity_unit,
|
||||||
|
includes_battery=row.includes_battery,
|
||||||
|
is_active=row.is_active,
|
||||||
|
)
|
||||||
|
db_session.add(material)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Link Recommendation -> Material
|
||||||
|
# -------------------------------------------------
|
||||||
|
for row in recommendations_materials_df.itertuples(index=False):
|
||||||
|
db_session.add(
|
||||||
|
RecommendationMaterials(
|
||||||
|
recommendation_id=row.recommendation_id,
|
||||||
|
material_id=row.material_id,
|
||||||
|
depth=row.depth or 0.0,
|
||||||
|
quantity=row.quantity,
|
||||||
|
quantity_unit=row.quantity_unit,
|
||||||
|
estimated_cost=row.estimated_cost,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
payload = ExportRequest.model_validate({
|
||||||
|
"task_id": "test",
|
||||||
|
"subtask_id": "test",
|
||||||
|
"portfolio_id": test_portfolio_id,
|
||||||
|
"scenario_ids": [],
|
||||||
|
"default_plans_only": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
result = process_export(payload, session=db_session)
|
||||||
|
|
||||||
|
assert "default_plans" in result
|
||||||
|
|
||||||
|
df = result["default_plans"]
|
||||||
|
|
||||||
|
assert "solar_pv_with_battery" in df.columns
|
||||||
|
|
||||||
|
# solar_pv should NOT exist
|
||||||
|
assert "solar_pv" not in df.columns
|
||||||
|
|
||||||
|
assert df.shape[0] == 1, "Expected 1 property in the export, got {}".format(df.shape[0])
|
||||||
|
|
||||||
|
# Cost should land in correct column
|
||||||
|
assert df["solar_pv_with_battery"].iloc[0] == 10000
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue