From fa3e276dc4e1510cc8eb078dd1bc88bffdacb7e0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 13 Apr 2026 16:18:17 +0000 Subject: [PATCH] define new domain object --- backend/app/db/models/portfolio.py | 11 +- backend/app/db/models/recommendations.py | 6 +- backend/app/domain/records/plan_record.py | 2 +- .../tests/test_plan_is_compliant.py | 3 +- .../tests/test_prioritised_plan_selected.py | 3 +- backend/export/tests/test_export.py | 435 ++++++++++++------ datatypes/epc/domain/epc.py | 11 + datatypes/epc/domain/epc_property_data.py | 336 ++++++++++++++ .../g_rebaselining_installed_measrues.py | 2 +- 9 files changed, 654 insertions(+), 155 deletions(-) create mode 100644 datatypes/epc/domain/epc.py create mode 100644 datatypes/epc/domain/epc_property_data.py diff --git a/backend/app/db/models/portfolio.py b/backend/app/db/models/portfolio.py index a4f9a675..48f8b1ed 100644 --- a/backend/app/db/models/portfolio.py +++ b/backend/app/db/models/portfolio.py @@ -16,6 +16,7 @@ from sqlalchemy import ( from backend.app.db.base import Base from backend.app.db.models.users import UserModel # noqa from backend.app.db.models.materials import MaterialType +from datatypes.epc.domain.epc import Epc class PortfolioStatus(enum.Enum): @@ -100,16 +101,6 @@ class PropertyCreationStatus(enum.Enum): ERROR = "ERROR" -class Epc(enum.Enum): # TODO: Move to domain? - A = "A" - B = "B" - C = "C" - D = "D" - E = "E" - F = "F" - G = "G" - - class PropertyModel(Base): __tablename__ = "property" id = Column(Integer, primary_key=True, autoincrement=True) diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py index 27d03303..096cc1de 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -17,8 +17,8 @@ from datetime import datetime from backend.app.db.base import Base from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel from backend.app.db.models.materials import Material -from backend.app.db.models.portfolio import Epc from datatypes.enums import QuantityUnits +from datatypes.epc.domain.epc import Epc def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]: @@ -54,9 +54,7 @@ class Recommendation(Base): class RecommendationMaterials(Base): __tablename__ = "recommendation_materials" - id: Mapped[int] = mapped_column( - BigInteger, primary_key=True, autoincrement=True - ) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) recommendation_id: Mapped[int] = mapped_column( BigInteger, diff --git a/backend/app/domain/records/plan_record.py b/backend/app/domain/records/plan_record.py index 63a82993..9151439f 100644 --- a/backend/app/domain/records/plan_record.py +++ b/backend/app/domain/records/plan_record.py @@ -2,8 +2,8 @@ from dataclasses import dataclass from datetime import datetime from typing import Optional -from backend.app.db.models.portfolio import Epc from backend.app.db.models.recommendations import PlanTypeEnum +from datatypes.epc.domain.epc import Epc @dataclass(frozen=True) diff --git a/backend/categorisation/tests/test_plan_is_compliant.py b/backend/categorisation/tests/test_plan_is_compliant.py index 62756652..c5658b4e 100644 --- a/backend/categorisation/tests/test_plan_is_compliant.py +++ b/backend/categorisation/tests/test_plan_is_compliant.py @@ -6,7 +6,8 @@ from backend.app.domain.classes.plan import Plan from backend.app.domain.classes.scenario import Scenario from backend.app.domain.records.plan_record import PlanRecord from backend.app.domain.records.scenario_record import ScenarioRecord -from backend.app.db.models.portfolio import Epc, PortfolioGoal +from backend.app.db.models.portfolio import PortfolioGoal +from datatypes.epc.domain.epc import Epc @pytest.fixture diff --git a/backend/categorisation/tests/test_prioritised_plan_selected.py b/backend/categorisation/tests/test_prioritised_plan_selected.py index a9529a53..5cffa01a 100644 --- a/backend/categorisation/tests/test_prioritised_plan_selected.py +++ b/backend/categorisation/tests/test_prioritised_plan_selected.py @@ -6,8 +6,9 @@ from backend.app.domain.classes.plan import Plan from backend.app.domain.classes.scenario import Scenario from backend.app.domain.records.plan_record import PlanRecord from backend.app.domain.records.scenario_record import ScenarioRecord -from backend.app.db.models.portfolio import Epc, PortfolioGoal +from backend.app.db.models.portfolio import PortfolioGoal from backend.categorisation.processor import choose_cheapest_relevant_plan +from datatypes.epc.domain.epc import Epc @pytest.fixture diff --git a/backend/export/tests/test_export.py b/backend/export/tests/test_export.py index af1e83a9..b00d1744 100644 --- a/backend/export/tests/test_export.py +++ b/backend/export/tests/test_export.py @@ -5,11 +5,22 @@ import time from backend.export.property_scenarios.main import process_export from backend.export.property_scenarios.input_schema import ExportRequest -from backend.app.db.models.portfolio import PropertyModel, Epc, Portfolio, PortfolioStatus, PortfolioGoal, \ - PropertyCreationStatus, PropertyDetailsEpcModel -from backend.app.db.models.recommendations import PlanModel, Recommendation, PlanRecommendations, \ - RecommendationMaterials +from backend.app.db.models.portfolio import ( + PropertyModel, + Portfolio, + PortfolioStatus, + PortfolioGoal, + PropertyCreationStatus, + PropertyDetailsEpcModel, +) +from backend.app.db.models.recommendations import ( + PlanModel, + Recommendation, + PlanRecommendations, + RecommendationMaterials, +) from backend.app.db.models.materials import Material +from datatypes.epc.domain.epc import Epc from utils.logger import setup_logger FIXTURE_PATH = Path("backend/export/tests/fixtures") @@ -78,11 +89,13 @@ def test_default_export_integration(db_session): else None ) - prop = PropertyModel(**{ - col: row_dict[col] - for col in PropertyModel.__table__.columns.keys() - if col in row_dict - }) + prop = PropertyModel( + **{ + col: row_dict[col] + for col in PropertyModel.__table__.columns.keys() + if col in row_dict + } + ) prop.creation_status = PropertyCreationStatus[ row_dict["creation_status"].split(".")[-1] @@ -90,9 +103,7 @@ def test_default_export_integration(db_session): prop.status = PortfolioStatus[row_dict["status"].split(".")[-1]] if row_dict.get("current_epc_rating"): - prop.current_epc_rating = Epc[ - row_dict["current_epc_rating"].split(".")[-1] - ] + prop.current_epc_rating = Epc[row_dict["current_epc_rating"].split(".")[-1]] properties.append(prop) @@ -112,7 +123,8 @@ def test_default_export_integration(db_session): epc_data = { col.name: row_dict[col.name] for col in PropertyDetailsEpcModel.__table__.columns.values() - if col.name in row_dict and col.name not in ["id", "property_id", "portfolio_id"] + if col.name in row_dict + and col.name not in ["id", "property_id", "portfolio_id"] } epc = PropertyDetailsEpcModel( @@ -142,11 +154,13 @@ def test_default_export_integration(db_session): row_dict["scenario_id"] = None - plan = PlanModel(**{ - col: row_dict[col] - for col in PlanModel.__table__.columns.keys() - if col in row_dict - }) + plan = PlanModel( + **{ + col: row_dict[col] + for col in PlanModel.__table__.columns.keys() + if col in row_dict + } + ) plans.append(plan) @@ -158,11 +172,13 @@ def test_default_export_integration(db_session): # ---------------------------------------- recs = [ - Recommendation(**{ - col: row[col] - for col in Recommendation.__table__.columns.keys() - if col in row - }) + Recommendation( + **{ + col: row[col] + for col in Recommendation.__table__.columns.keys() + if col in row + } + ) for _, row in recommendations_df.iterrows() ] @@ -203,28 +219,19 @@ def test_default_export_integration(db_session): # ---------------------------------------- logger.info( - "Recommendation count in DB: %s", - db_session.query(Recommendation).count() + "Recommendation count in DB: %s", db_session.query(Recommendation).count() ) - logger.info( - "Property count in DB: %s", - db_session.query(PropertyModel).count() - ) + logger.info("Property count in DB: %s", db_session.query(PropertyModel).count()) logger.info( - "Property EPC in DB: %s", - db_session.query(PropertyDetailsEpcModel).count() + "Property EPC in DB: %s", db_session.query(PropertyDetailsEpcModel).count() ) - logger.info( - "Plan count in DB: %s", - db_session.query(PlanModel).count() - ) + logger.info("Plan count in DB: %s", db_session.query(PlanModel).count()) logger.info( - "PlanRecommendatons count in DB: %s", - db_session.query(PlanModel).count() + "PlanRecommendatons count in DB: %s", db_session.query(PlanModel).count() ) logger.info("Starting process_export") @@ -232,17 +239,23 @@ def test_default_export_integration(db_session): result = process_export(payload, session=db_session) - logger.info("process_export finished in %.2f seconds", time.perf_counter() - process_t0) + logger.info( + "process_export finished in %.2f seconds", time.perf_counter() - process_t0 + ) # ---------------------------------------- # 8) Assertions # ---------------------------------------- - assert "default_plans" in result, "Expected 'default_plans' in export result, got {}".format(result.keys()) + assert ( + "default_plans" in result + ), "Expected 'default_plans' in export result, got {}".format(result.keys()) df = result["default_plans"] - assert df.shape[0] == 10, "Expected 10 properties in the export, got {}".format(df.shape[0]) + assert df.shape[0] == 10, "Expected 10 properties in the export, got {}".format( + df.shape[0] + ) failed = df[df["predicted_post_works_sap"] < 69] failed_property_types = failed["property_type"].value_counts().to_dict() @@ -251,19 +264,28 @@ def test_default_export_integration(db_session): assert failed.shape[0] - assert df["total_retrofit_cost"].sum() == 41706.585999999996, ( - "Expected total retrofit cost to be 10000, got {}".format(df["total_retrofit_cost"].sum()) + assert ( + df["total_retrofit_cost"].sum() == 41706.585999999996 + ), "Expected total retrofit cost to be 10000, got {}".format( + df["total_retrofit_cost"].sum() ) - assert df["predicted_post_works_sap"].sum() == 698.1, ( - "Expected total predicted post works SAP to be 698.1, got {}".format(df["predicted_post_works_sap"].sum()) + assert ( + df["predicted_post_works_sap"].sum() == 698.1 + ), "Expected total predicted post works SAP to be 698.1, got {}".format( + df["predicted_post_works_sap"].sum() ) - assert df["sap_points"].sum() == 100.10000000000001, ( - "Expected total SAP points increase to be 100.10000000000001, got {}".format(df["sap_points"].sum()) + assert ( + df["sap_points"].sum() == 100.10000000000001 + ), "Expected total SAP points increase to be 100.10000000000001, got {}".format( + df["sap_points"].sum() ) - assert df.shape == (10, 100), "Expected dataframe shape to be (10, 100), got {}".format(df.shape) + assert df.shape == ( + 10, + 100, + ), "Expected dataframe shape to be (10, 100), got {}".format(df.shape) def test_solar_with_battery_example(db_session): @@ -271,116 +293,251 @@ def test_solar_with_battery_example(db_session): 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}] + [ + { + "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}] + [ + { + "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} + { + "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} + { + "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}] - ) + 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'} - ] + [ + { + "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, + "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', + "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} + { + "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, + } ] ) @@ -463,7 +620,7 @@ def test_solar_with_battery_example(db_session): already_installed=row.already_installed, sap_points=row.sap_points, type=row.type, - description=row.description + description=row.description, ) db_session.add(rec) db_session.flush() @@ -515,13 +672,15 @@ def test_solar_with_battery_example(db_session): db_session.commit() - payload = ExportRequest.model_validate({ - "task_id": "test", - "subtask_id": "test", - "portfolio_id": test_portfolio_id, - "scenario_ids": [], - "default_plans_only": True, - }) + 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) @@ -534,7 +693,9 @@ def test_solar_with_battery_example(db_session): # 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]) + 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 diff --git a/datatypes/epc/domain/epc.py b/datatypes/epc/domain/epc.py new file mode 100644 index 00000000..e694ba2f --- /dev/null +++ b/datatypes/epc/domain/epc.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class Epc(Enum): + A = "A" + B = "B" + C = "C" + D = "D" + E = "E" + F = "F" + G = "G" diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py new file mode 100644 index 00000000..dc1bd724 --- /dev/null +++ b/datatypes/epc/domain/epc_property_data.py @@ -0,0 +1,336 @@ +from dataclasses import dataclass +from datetime import date +from typing import Any, List, Optional, Union + +from datatypes.epc.domain.epc import Epc + + +@dataclass +class EnergyElement: + # description is a plain string in schema 21.0.0 (no longer a localised object) + description: str + energy_efficiency_rating: int + environmental_efficiency_rating: int + + +@dataclass +class InstantaneousWwhrs: + wwhrs_index_number1: Optional[int] = None + wwhrs_index_number2: Optional[int] = None + + +@dataclass +class MainHeatingDetail: + has_fghrs: bool + main_fuel_type: int # TODO: make enum? + heat_emitter_type: int # TODO: make enum? + emitter_temperature: Union[int, str] + main_heating_number: int + main_heating_control: int + main_heating_category: int + main_heating_fraction: int + main_heating_data_source: int + fan_flue_present: bool + boiler_flue_type: Optional[int] = None # TODO: make enum? + boiler_ignition_type: Optional[int] = None # TODO: make enum? + central_heating_pump_age: Optional[int] = None + main_heating_index_number: Optional[int] = None + sap_main_heating_code: Optional[int] = None # TODO: make enum? + + +@dataclass +class ShowerOutlet: + shower_wwhrs: int + shower_outlet_type: int + + +@dataclass +class ShowerOutlets: + # TODO: consolidate ShowerOutlet and ShowerOutlets + shower_outlet: ShowerOutlet + + +@dataclass +class SapHeating: + cylinder_size: int + water_heating_code: int # TODO: make enum? + water_heating_fuel: int # TODO: make enum? + instantaneous_wwhrs: InstantaneousWwhrs + main_heating_details: List[MainHeatingDetail] + immersion_heating_type: Union[int, str] + has_fixed_air_conditioning: str + shower_outlets: Optional[ShowerOutlets] = None + cylinder_insulation_type: Optional[int] = None + cylinder_thermostat: Optional[str] = None + secondary_fuel_type: Optional[int] = None + secondary_heating_type: Optional[int] = None + cylinder_insulation_thickness: Optional[int] = None + + +@dataclass +class WindowTransmissionDetails: + u_value: float + data_source: int + solar_transmittance: float + + +@dataclass +class SapWindow: + pvc_frame: str + glazing_gap: int + orientation: int + window_type: int + frame_factor: float + glazing_type: int + window_width: float + window_height: float + draught_proofed: str + window_location: int + window_wall_type: int + permanent_shutters_present: str + window_transmission_details: WindowTransmissionDetails + permanent_shutters_insulated: str + + +@dataclass +class PvBattery: + battery_capacity: float + + +@dataclass +class PvBatteries: + pv_battery: PvBattery + + +@dataclass +class WindTurbineDetails: + hub_height: float + rotor_diameter: float + + +@dataclass +class PhotovoltaicSupplyNoneOrNoDetails: + percent_roof_area: int + + +@dataclass +class PhotovoltaicSupply: + none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails + + +@dataclass +class SapEnergySource: + mains_gas: bool + meter_type: str # int in API, str (e.g. "Single") in site notes + pv_battery_count: int + wind_turbines_count: int + gas_smart_meter_present: bool + is_dwelling_export_capable: bool + wind_turbines_terrain_type: str # int in API, str (e.g. "Suburban") in site notes + electricity_smart_meter_present: bool + + pv_connection: Optional[int] = None + photovoltaic_supply: Optional[PhotovoltaicSupply] = None + wind_turbine_details: Optional[WindTurbineDetails] = None + pv_batteries: Optional[PvBatteries] = None + + +@dataclass +class SapFloorDimension: + room_height_m: float + total_floor_area_m2: float + party_wall_length_m: float + heat_loss_perimeter_m: float + + floor: Optional[int] = None + floor_insulation: Optional[int] = None + floor_construction: Optional[int] = None + + +@dataclass +class SapRoomInRoof: + floor_area: Union[int, float] + construction_age_band: str + + +@dataclass +class SapAlternativeWall: + wall_area: float + wall_dry_lined: str + wall_construction: int + wall_insulation_type: int + wall_thickness_measured: str + wall_insulation_thickness: Optional[str] = None + + +@dataclass +class SapBuildingPart: + # General + identifier: str # e.g. "main", "roof" + construction_age_band: str + + # Wall + wall_construction: int + wall_insulation_type: int + wall_thickness_measured: bool + party_wall_construction: Union[int, str] + + # Floor + sap_floor_dimensions: List[ + SapFloorDimension + ] # Not included in site notes; should this be optional? + + # Optional + building_part_number: Optional[int] = ( + None # Not sure how we get this from site notes + ) + wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes + wall_thickness_mm: Optional[int] = None + wall_insulation_thickness: Optional[str] = None + sap_alternative_wall_1: Optional[SapAlternativeWall] = None + sap_alternative_wall_2: Optional[SapAlternativeWall] = None + + floor_heat_loss: Optional[int] = None + floor_insulation_thickness: Optional[str] = None + flat_roof_insulation_thickness: Optional[Union[str, int]] = None + + roof_construction: Optional[int] = None + roof_insulation_location: Optional[Union[int, str]] = None + roof_insulation_thickness: Optional[Union[str, int]] = None + sap_room_in_roof: Optional[SapRoomInRoof] = None + + +@dataclass +class WindowsTransmissionDetails: + u_value: float + data_source: int + solar_transmittance: float + + +@dataclass +class SapFlatDetails: + level: int + top_storey: str + flat_location: int + heat_loss_corridor: int + storey_count: Optional[int] = None + unheated_corridor_length_m: Optional[int] = None + + +@dataclass +class EpcPropertyData: + # General + assessment_type: str # TODO: make enum? + sap_version: float # Optional? + dwelling_type: str # TODO: make enum? + uprn: int + address_line_1: str + postcode: str + post_town: str + inspection_date: date + status: str + tenure: int # How does this map to string? + transaction_type: int # What is this? + + # Elements + roofs: List[EnergyElement] + walls: List[EnergyElement] + floors: List[EnergyElement] + main_heating: List[EnergyElement] + window: EnergyElement + lighting: EnergyElement + hot_water: EnergyElement + door_count: int + sap_heating: SapHeating + sap_windows: List[SapWindow] + sap_energy_source: SapEnergySource + sap_building_parts: List[SapBuildingPart] + solar_water_heating: bool + has_hot_water_cylinder: bool # must be inferred when mapping from site notes + has_fixed_air_conditioning: bool + + # Counts + wet_rooms_count: int # If this isn't provided, should it be 0 or None? + extensions_count: int # If this isn't provided, should it be 0 or None? + heated_rooms_count: int # If this isn't provided, should it be 0 or None? + open_chimneys_count: int + habitable_rooms_count: int + insulated_door_count: ( + int # Called "number_of_insulated_external_doors" in site notes; same thing? + ) + cfl_fixed_lighting_bulbs_count: int + led_fixed_lighting_bulbs_count: int + incandescent_fixed_lighting_bulbs_count: int + + # Measurements + total_floor_area_m2: int + + # Optional fields + schema_type: Optional[str] = None + schema_versions_original: Optional[str] = None + report_type: Optional[str] = None # TODO: make enum? + uprn_source: Optional[str] = None + address_line_2: Optional[str] = None + region_code: Optional[str] = None # TODO: make enum? + country_code: Optional[str] = None + built_form: Optional[str] = None # TODO: make enum? + property_type: Optional[str] = None + pressure_test: Optional[int] = None + language_code: Optional[str] = None + completion_date: Optional[date] = None + registration_date: Optional[date] = None + measurement_type: Optional[int] = None # What is this? + conservatory_type: Optional[int] = ( + None # What is this? site notes have "has_conservatory" flag + ) + has_heated_separate_conservatory: Optional[bool] = None + secondary_heating: Optional[EnergyElement] = ( + None # For site notes, secondary_fuel maps to sap_heating.secondary_fuel_type + ) + blocked_chimneys_count: Optional[int] = None + energy_rating_average: Optional[int] = None + main_heating_controls: Optional[EnergyElement] = ( + None # site notes has heating_and_hot_water.main_heating.controls: str - doesn't map to EnergyElement + ) + current_energy_efficiency_band: Optional[Epc] = None # not available in site notes? + environmental_impact_current: Optional[int] = None + heating_cost_current: Optional[float] = None + co2_emissions_current: Optional[float] = None + energy_consumption_current: Optional[int] = None + energy_rating_current: Optional[int] = None + lighting_cost_current: Optional[float] = None + hot_water_cost_current: Optional[float] = None + insulated_door_u_value: Optional[float] = None # Not available in site notes + mechanical_ventilation: Optional[int] = ( + None # ventilation details present in site notes, but I'm not sure they correspond directly to the integers returned by the API here + ) + percent_draughtproofed: Optional[int] = ( + None # Site notes have draught_proofed: bool field for each window, can we use that to infer percentage? + ) + heating_cost_potential: Optional[float] = None + co2_emissions_potential: Optional[float] = None + energy_consumption_potential: Optional[int] = None + energy_rating_potential: Optional[float] = None + lighting_cost_potential: Optional[float] = None + hot_water_cost_potential: Optional[float] = None + environmental_impact_potential: Optional[int] = None + potential_energy_efficiency_band: Optional[Epc] = ( + None # not available in site notes + ) + # renewable_heat_incentive: Optional[Any] = None # Not sure what this is, skip for now + draughtproofed_door_count: Optional[int] = None + mechanical_vent_duct_type: Optional[int] = None + windows_transmission_details: Optional[WindowsTransmissionDetails] = None + multiple_glazed_propertion: Optional[int] = None + calculation_software_version: Optional[str] = None # Do we care about this? + mechanical_vent_duct_placement: Optional[int] = None + mechanical_vent_duct_insulation: Optional[int] = None + pressure_test_certificate_number: Optional[int] = None + mechanical_ventilation_index_number: Optional[int] = None + mechanical_vent_measured_installation: Optional[str] = None + co2_emissions_current_per_floor_area: Optional[int] = None + low_energy_fixed_lighting_bulbs_count: Optional[int] = None + sap_flat_details: Optional[SapFlatDetails] = None + # survey_addendum: Optional[Any] = None # not sure how to handle, skip for now + fixed_lighting_outlets_count: Optional[int] = None + low_energy_fixed_lighting_outlets_count: Optional[int] = None diff --git a/etl/customers/peabody/Nov 2025 Consulting Project/g_rebaselining_installed_measrues.py b/etl/customers/peabody/Nov 2025 Consulting Project/g_rebaselining_installed_measrues.py index c451938d..cb7e65cd 100644 --- a/etl/customers/peabody/Nov 2025 Consulting Project/g_rebaselining_installed_measrues.py +++ b/etl/customers/peabody/Nov 2025 Consulting Project/g_rebaselining_installed_measrues.py @@ -12,8 +12,8 @@ from backend.app.db.models.recommendations import ( from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcModel from backend.app.utils import sap_to_epc from typing import Dict, List, Set +from datatypes.epc.domain.epc import Epc from recommendations.Costs import Costs -from backend.app.db.models.portfolio import Epc pd.set_option("display.max_rows", 500) pd.set_option("display.max_columns", 500)