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/__init__.py b/datatypes/epc/domain/__init__.py new file mode 100644 index 00000000..e69de29b 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..b92a46aa --- /dev/null +++ b/datatypes/epc/domain/epc_property_data.py @@ -0,0 +1,348 @@ +from dataclasses import dataclass +from datetime import date +from typing import List, Optional, Union + +from datatypes.epc.domain.epc import Epc + + +@dataclass +class EnergyElement: + 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: Union[int, str] # int from API, str from site notes + heat_emitter_type: Union[int, str] # int from API, str from site notes + emitter_temperature: Union[int, str] + main_heating_control: Union[int, str] # int from API, str from site notes + fan_flue_present: Optional[bool] = None + 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? + main_heating_number: Optional[int] = None + main_heating_category: Optional[int] = None + main_heating_fraction: Optional[int] = None + main_heating_data_source: Optional[int] = None + + +@dataclass +class ShowerOutlet: + shower_wwhrs: int + shower_outlet_type: int + + +@dataclass +class ShowerOutlets: + # TODO: consolidate ShowerOutlet and ShowerOutlets + shower_outlet: ShowerOutlet + + +@dataclass +class SapHeating: + instantaneous_wwhrs: InstantaneousWwhrs + main_heating_details: List[MainHeatingDetail] + has_fixed_air_conditioning: bool + cylinder_size: Optional[int] = ( + None # int code from API; not directly available from site notes + ) + water_heating_code: Optional[int] = None # TODO: make enum? + water_heating_fuel: Optional[int] = None # TODO: make enum? + immersion_heating_type: Optional[Union[int, str]] = None # TODO: make enum? + 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: Union[int, str] + orientation: Union[int, str] + window_type: Union[int, str] + glazing_type: Union[int, str] + window_width: float + window_height: float + draught_proofed: Union[bool, str] # TODO: make enum/mapping? + window_location: Union[int, str] # TODO: make enum/mapping + window_wall_type: Union[int, str] # TODO: make enum/mapping + permanent_shutters_present: Union[bool, str] # TODO: make enum/mapping + frame_factor: Optional[float] = None + window_transmission_details: Optional[WindowTransmissionDetails] = None + permanent_shutters_insulated: Optional[str] = None + + +@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: Union[ + int, str + ] # int from API, str from site notes TODO: make enum/mapping? + wall_insulation_type: Union[ + int, str + ] # int from API, str from site notes TODO: make enum/mapping? + wall_thickness_measured: bool + party_wall_construction: Union[int, str] # TODO: make enum/mapping? + + # 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 # TODO: make enum/mapping? + ) + + roof_construction: Optional[int] = None + roof_insulation_location: Optional[Union[int, str]] = ( + None # TODO: make enum/mapping? + ) + roof_insulation_thickness: Optional[Union[str, int]] = ( + None # TODO: make enum/mapping? + ) + 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 + dwelling_type: str # TODO: make enum? + inspection_date: date + tenure: str # str in site notes; stringified int (e.g. "1") from API + transaction_type: str # str in site notes; stringified int from API + + # Elements + roofs: List[EnergyElement] + walls: List[EnergyElement] + floors: List[EnergyElement] + main_heating: List[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: float + + # Optional fields + assessment_type: Optional[str] = None # not available from site notes + sap_version: Optional[float] = None # not available from site notes + uprn: Optional[int] = None # not available from site notes + address_line_1: Optional[str] = None # not available from site notes + postcode: Optional[str] = None # not available from site notes + post_town: Optional[str] = None # not available from site notes + status: Optional[str] = None # not available from site notes + window: Optional[EnergyElement] = None # not available from site notes + lighting: Optional[EnergyElement] = None # not available from site notes + hot_water: Optional[EnergyElement] = None # not available from site notes + 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_conservatory: Optional[bool] = None # mapped directly from site notes + 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/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py new file mode 100644 index 00000000..ccc4dd82 --- /dev/null +++ b/datatypes/epc/domain/mapper.py @@ -0,0 +1,1496 @@ +from datetime import date +from typing import List, Sequence, Union + +from datatypes.epc.domain.epc_property_data import ( + EnergyElement, + EpcPropertyData, + InstantaneousWwhrs, + MainHeatingDetail, + PhotovoltaicSupply, + PhotovoltaicSupplyNoneOrNoDetails, + PvBatteries, + PvBattery, + SapAlternativeWall, + SapBuildingPart, + SapEnergySource, + SapFloorDimension, + SapHeating, + SapRoomInRoof, + SapWindow, + ShowerOutlet, + ShowerOutlets, + WindTurbineDetails, + WindowTransmissionDetails, +) +from datatypes.epc.schema.rdsap_schema_17_0 import ( + RdSapSchema17_0, + EnergyElement as EnergyElement_17_0, +) +from datatypes.epc.schema.rdsap_schema_17_1 import ( + RdSapSchema17_1, + EnergyElement as EnergyElement_17_1, +) +from datatypes.epc.schema.rdsap_schema_18_0 import ( + RdSapSchema18_0, + EnergyElement as EnergyElement_18_0, +) +from datatypes.epc.schema.rdsap_schema_19_0 import ( + RdSapSchema19_0, + EnergyElement as EnergyElement_19_0, +) +from datatypes.epc.schema.rdsap_schema_20_0_0 import ( + RdSapSchema20_0_0, + EnergyElement as EnergyElement_20_0, +) +from datatypes.epc.schema.rdsap_schema_21_0_0 import ( + RdSapSchema21_0_0, + EnergyElement as EnergyElement_21_0, +) +from datatypes.epc.schema.rdsap_schema_21_0_1 import ( + RdSapSchema21_0_1, + EnergyElement as EnergyElement_21_0_1, +) +from datatypes.epc.surveys.pashub_rdsap_site_notes import ( + BuildingConstruction, + BuildingMeasurements, + ExtensionConstruction, + ExtensionMeasurements, + FloorMeasurement, + HeatingAndHotWater, + PasHubRdSapSiteNotes, + Ventilation, + Window, +) + +AnyRdSapSchema = Union[ + RdSapSchema17_0, + RdSapSchema17_1, + RdSapSchema18_0, + RdSapSchema19_0, + RdSapSchema20_0_0, + RdSapSchema21_0_0, + RdSapSchema21_0_1, +] + + +class EpcPropertyDataMapper: + + @staticmethod + def from_site_notes(survey: PasHubRdSapSiteNotes) -> EpcPropertyData: + general = survey.general + construction = survey.building_construction + measurements = survey.building_measurements + heating = survey.heating_and_hot_water + ventilation = survey.ventilation + renewables = survey.renewables + room_counts = survey.room_count_elements + + sap_building_parts = [_map_main_building_part(construction, measurements)] + if construction.extensions and measurements.extensions: + for ext_c in construction.extensions: + matching = [m for m in measurements.extensions if m.id == ext_c.id] + if matching: + sap_building_parts.append( + _map_extension_building_part(ext_c, matching[0]) + ) + + total_floor_area = round( + sum( + floor.total_floor_area_m2 + for part in sap_building_parts + for floor in part.sap_floor_dimensions + ), + 2, + ) + + return EpcPropertyData( + dwelling_type=f"{general.detachment_type} {general.property_type.lower()}", + inspection_date=date.fromisoformat(general.inspection_date), + tenure=general.tenure, + transaction_type=general.transaction_type, + roofs=[], + walls=[], + floors=[], + main_heating=[], + door_count=room_counts.number_of_external_doors, + sap_heating=_map_sap_heating(heating, ventilation), + sap_windows=[_map_sap_window(w) for w in survey.windows], + sap_energy_source=SapEnergySource( + mains_gas=general.mains_gas_available, + meter_type=general.electric_meter_type, + pv_battery_count=renewables.number_of_pv_batteries, + wind_turbines_count=0 if not renewables.wind_turbines else 1, + gas_smart_meter_present=general.gas_smart_meter, + is_dwelling_export_capable=general.dwelling_export_capable, + wind_turbines_terrain_type=general.terrain_type, + electricity_smart_meter_present=general.electricity_smart_meter, + ), + sap_building_parts=sap_building_parts, + solar_water_heating=renewables.solar_hot_water, + has_hot_water_cylinder=heating.water_heating.cylinder_size != "No Cylinder", + has_fixed_air_conditioning=ventilation.has_fixed_air_conditioning, + wet_rooms_count=0, # no equivalent in site notes + extensions_count=general.number_of_extensions, + heated_rooms_count=room_counts.number_of_heated_rooms + or 0, # absent in site notes → 0 + open_chimneys_count=room_counts.number_of_open_chimneys, + habitable_rooms_count=room_counts.number_of_habitable_rooms, + insulated_door_count=room_counts.number_of_insulated_external_doors, + cfl_fixed_lighting_bulbs_count=room_counts.number_of_fixed_cfl_bulbs, + led_fixed_lighting_bulbs_count=room_counts.number_of_fixed_led_bulbs, + incandescent_fixed_lighting_bulbs_count=room_counts.number_of_fixed_incandescent_bulbs, + total_floor_area_m2=total_floor_area, + built_form=general.detachment_type, + property_type=general.property_type, + has_conservatory=survey.conservatories.has_conservatory, + blocked_chimneys_count=room_counts.number_of_blocked_chimneys, + draughtproofed_door_count=room_counts.number_of_draughtproofed_external_doors, + ) + + @staticmethod + def from_rdsap_schema_17_0(schema: RdSapSchema17_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type.value, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=None, + fan_flue_present=None, + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=None, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=None, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=None, + secondary_fuel_type=None, + secondary_heating_type=None, + cylinder_insulation_thickness=None, + ), + sap_windows=[], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=None, + floor_construction=None, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=None, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=None, + ) + for bp in schema.sap_building_parts + ], + ) + + @staticmethod + def from_rdsap_schema_17_1(schema: RdSapSchema17_1) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type.value, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=None, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + sap_windows=[], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=None, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=None, + ) + for bp in schema.sap_building_parts + ], + ) + + @staticmethod + def from_rdsap_schema_18_0(schema: RdSapSchema18_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type.value, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + sap_windows=[], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + floor_area=bp.sap_room_in_roof.floor_area.value, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) + + @staticmethod + def from_rdsap_schema_19_0(schema: RdSapSchema19_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type.value, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + # 19.0 has no per-window list; individual window fields are at schema root + sap_windows=[], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + # floor_area is a Measurement in 19.0 + floor_area=bp.sap_room_in_roof.floor_area.value, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) + + @staticmethod + def from_rdsap_schema_20_0_0(schema: RdSapSchema20_0_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=0, + extensions_count=schema.extensions_count, + open_chimneys_count=0, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=None, + led_fixed_lighting_bulbs_count=0, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), + sap_heating=SapHeating( + # 20.0.0 uses room counts not product index numbers; domain fields default to None + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + # 20.0.0 SapWindow lacks frame/gap/draught fields present in later schemas + sap_windows=[ + SapWindow( + pvc_frame="", + glazing_gap=0, + orientation=w.orientation, + window_type=w.window_type, + glazing_type=w.glazing_type, + window_width=0.0, + window_height=0.0, + draught_proofed=False, + window_location=w.window_location, + window_wall_type=0, + permanent_shutters_present=False, + ) + for w in schema.sap_windows + ], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=0, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=False, + is_dwelling_export_capable=False, + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=False, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + floor_area=bp.sap_room_in_roof.floor_area, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) + + @staticmethod + def from_rdsap_schema_21_0_0(schema: RdSapSchema21_0_0) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=schema.wet_rooms_count, + extensions_count=schema.extensions_count, + open_chimneys_count=schema.open_chimneys_count, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=schema.draughtproofed_door_count, + led_fixed_lighting_bulbs_count=schema.led_fixed_lighting_bulbs_count, + cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count, + incandescent_fixed_lighting_bulbs_count=schema.incandescent_fixed_lighting_bulbs_count, + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs( + wwhrs_index_number1=schema.sap_heating.instantaneous_wwhrs.wwhrs_index_number1, + wwhrs_index_number2=schema.sap_heating.instantaneous_wwhrs.wwhrs_index_number2, + ), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + boiler_ignition_type=d.boiler_ignition_type, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + shower_outlets=( + ShowerOutlets( + ShowerOutlet( + shower_wwhrs=schema.sap_heating.shower_outlets.shower_outlet.shower_wwhrs, + shower_outlet_type=schema.sap_heating.shower_outlets.shower_outlet.shower_outlet_type, + ) + ) + if schema.sap_heating.shower_outlets + else None + ), + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + sap_windows=[ + SapWindow( + pvc_frame=w.pvc_frame, + glazing_gap=w.glazing_gap, + orientation=w.orientation, + window_type=w.window_type, + frame_factor=w.frame_factor, + glazing_type=w.glazing_type, + window_width=w.window_width, + window_height=w.window_height, + draught_proofed=w.draught_proofed == "true", + window_location=w.window_location, + window_wall_type=w.window_wall_type, + permanent_shutters_present=w.permanent_shutters_present == "Y", + window_transmission_details=WindowTransmissionDetails( + u_value=w.window_transmission_details.u_value, + data_source=w.window_transmission_details.data_source, + solar_transmittance=w.window_transmission_details.solar_transmittance, + ), + permanent_shutters_insulated=w.permanent_shutters_insulated, + ) + for w in schema.sap_windows + ], + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=es.pv_battery_count, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=es.gas_smart_meter_present == "true", + is_dwelling_export_capable=es.is_dwelling_export_capable == "true", + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=es.electricity_smart_meter_present + == "true", + pv_connection=es.pv_connection, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + wind_turbine_details=( + WindTurbineDetails( + hub_height=es.wind_turbine_details.hub_height, + rotor_diameter=es.wind_turbine_details.rotor_diameter, + ) + if es.wind_turbine_details + else None + ), + pv_batteries=( + PvBatteries( + pv_battery=PvBattery( + battery_capacity=es.pv_batteries.pv_battery.battery_capacity + ) + ) + if es.pv_batteries + else None + ), + ), + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + floor_area=bp.sap_room_in_roof.floor_area, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + sap_alternative_wall_1=( + SapAlternativeWall( + wall_area=bp.sap_alternative_wall_1.wall_area, + wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, + wall_construction=bp.sap_alternative_wall_1.wall_construction, + wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, + wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, + wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, + ) + if bp.sap_alternative_wall_1 + else None + ), + sap_alternative_wall_2=( + SapAlternativeWall( + wall_area=bp.sap_alternative_wall_2.wall_area, + wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, + wall_construction=bp.sap_alternative_wall_2.wall_construction, + wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, + wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, + wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, + ) + if bp.sap_alternative_wall_2 + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) + + @staticmethod + def from_rdsap_schema_21_0_1(schema: RdSapSchema21_0_1) -> EpcPropertyData: + es = schema.sap_energy_source + return EpcPropertyData( + # General + uprn=schema.uprn, + assessment_type=schema.assessment_type, + sap_version=schema.sap_version, + dwelling_type=schema.dwelling_type, + property_type=str(schema.property_type), + built_form=str(schema.built_form), + address_line_1=schema.address_line_1, + address_line_2=schema.address_line_2, + postcode=schema.postcode, + post_town=schema.post_town, + status=schema.status, + tenure=str(schema.tenure), + transaction_type=str(schema.transaction_type), + inspection_date=date.fromisoformat(schema.inspection_date), + completion_date=date.fromisoformat(schema.completion_date), + registration_date=date.fromisoformat(schema.registration_date), + total_floor_area_m2=float(schema.total_floor_area), + # Property flags + solar_water_heating=schema.solar_water_heating == "Y", + has_hot_water_cylinder=schema.has_hot_water_cylinder == "true", + has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", + conservatory_type=schema.conservatory_type, + has_conservatory=schema.conservatory_type != 1, + # Counts + door_count=schema.door_count, + habitable_rooms_count=schema.habitable_room_count, + heated_rooms_count=schema.heated_room_count, + wet_rooms_count=schema.wet_rooms_count, + extensions_count=schema.extensions_count, + open_chimneys_count=schema.open_chimneys_count, + insulated_door_count=schema.insulated_door_count, + draughtproofed_door_count=schema.draughtproofed_door_count, + # Lighting + led_fixed_lighting_bulbs_count=schema.led_fixed_lighting_bulbs_count, + cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count, + incandescent_fixed_lighting_bulbs_count=schema.incandescent_fixed_lighting_bulbs_count, + # Energy elements + roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), + walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), + floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), + main_heating=EpcPropertyDataMapper._map_energy_elements( + schema.main_heating + ), + window=EpcPropertyDataMapper._map_energy_element(schema.window), + lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), + hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), + secondary_heating=EpcPropertyDataMapper._map_energy_element( + schema.secondary_heating + ), + # SAP heating + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs( + wwhrs_index_number1=schema.sap_heating.instantaneous_wwhrs.wwhrs_index_number1, + wwhrs_index_number2=schema.sap_heating.instantaneous_wwhrs.wwhrs_index_number2, + ), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=d.has_fghrs == "Y", + main_fuel_type=d.main_fuel_type, + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_number=d.main_heating_number, + boiler_ignition_type=d.boiler_ignition_type, + main_heating_control=d.main_heating_control, + main_heating_category=d.main_heating_category, + main_heating_fraction=d.main_heating_fraction, + sap_main_heating_code=d.sap_main_heating_code, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_data_source=d.main_heating_data_source, + main_heating_index_number=d.main_heating_index_number, + ) + for d in schema.sap_heating.main_heating_details + ], + has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning + == "true", + cylinder_size=schema.sap_heating.cylinder_size, + water_heating_code=schema.sap_heating.water_heating_code, + water_heating_fuel=schema.sap_heating.water_heating_fuel, + immersion_heating_type=schema.sap_heating.immersion_heating_type, + shower_outlets=( + ShowerOutlets( + ShowerOutlet( + shower_wwhrs=schema.sap_heating.shower_outlets.shower_outlet.shower_wwhrs, + shower_outlet_type=schema.sap_heating.shower_outlets.shower_outlet.shower_outlet_type, + ) + ) + if schema.sap_heating.shower_outlets + else None + ), + cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + secondary_fuel_type=schema.sap_heating.secondary_fuel_type, + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness=schema.sap_heating.cylinder_insulation_thickness, + ), + # SAP windows + sap_windows=[ + SapWindow( + pvc_frame=w.pvc_frame, + glazing_gap=w.glazing_gap, + orientation=w.orientation, + window_type=w.window_type, + frame_factor=w.frame_factor, + glazing_type=w.glazing_type, + window_width=w.window_width, + window_height=w.window_height, + draught_proofed=w.draught_proofed == "true", + window_location=w.window_location, + window_wall_type=w.window_wall_type, + permanent_shutters_present=w.permanent_shutters_present == "Y", + window_transmission_details=WindowTransmissionDetails( + u_value=w.window_transmission_details.u_value, + data_source=w.window_transmission_details.data_source, + solar_transmittance=w.window_transmission_details.solar_transmittance, + ), + permanent_shutters_insulated=w.permanent_shutters_insulated, + ) + for w in schema.sap_windows + ], + # SAP energy source + sap_energy_source=SapEnergySource( + mains_gas=es.mains_gas == "Y", + meter_type=str(es.meter_type), + pv_battery_count=es.pv_battery_count, + wind_turbines_count=es.wind_turbines_count, + gas_smart_meter_present=es.gas_smart_meter_present == "true", + is_dwelling_export_capable=es.is_dwelling_export_capable == "true", + wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), + electricity_smart_meter_present=es.electricity_smart_meter_present + == "true", + pv_connection=es.pv_connection, + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, + ) + ) + if es.photovoltaic_supply + else None + ), + wind_turbine_details=( + WindTurbineDetails( + hub_height=es.wind_turbine_details.hub_height, + rotor_diameter=es.wind_turbine_details.rotor_diameter, + ) + if es.wind_turbine_details + else None + ), + pv_batteries=( + PvBatteries( + pv_battery=PvBattery( + battery_capacity=es.pv_batteries.pv_battery.battery_capacity + ) + ) + if es.pv_batteries + else None + ), + ), + # SAP building parts + sap_building_parts=[ + SapBuildingPart( + identifier=bp.identifier, + construction_age_band=bp.construction_age_band, + wall_construction=bp.wall_construction, + wall_insulation_type=bp.wall_insulation_type, + wall_thickness_measured=bp.wall_thickness_measured == "Y", + party_wall_construction=bp.party_wall_construction, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=fd.room_height.value, + total_floor_area_m2=fd.total_floor_area.value, + party_wall_length_m=( + float(fd.party_wall_length) + if isinstance(fd.party_wall_length, int) + else fd.party_wall_length.value + ), + heat_loss_perimeter_m=fd.heat_loss_perimeter.value, + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + ) + for fd in bp.sap_floor_dimensions + ], + building_part_number=bp.building_part_number, + wall_dry_lined=bp.wall_dry_lined == "Y", + wall_thickness_mm=bp.wall_thickness, + wall_insulation_thickness=bp.wall_insulation_thickness, + floor_heat_loss=bp.floor_heat_loss, + floor_insulation_thickness=bp.floor_insulation_thickness, + roof_construction=bp.roof_construction, + roof_insulation_location=bp.roof_insulation_location, + roof_insulation_thickness=bp.roof_insulation_thickness, + sap_room_in_roof=( + SapRoomInRoof( + floor_area=bp.sap_room_in_roof.floor_area, + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), + sap_alternative_wall_1=( + SapAlternativeWall( + wall_area=bp.sap_alternative_wall_1.wall_area, + wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, + wall_construction=bp.sap_alternative_wall_1.wall_construction, + wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, + wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, + wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, + ) + if bp.sap_alternative_wall_1 + else None + ), + sap_alternative_wall_2=( + SapAlternativeWall( + wall_area=bp.sap_alternative_wall_2.wall_area, + wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, + wall_construction=bp.sap_alternative_wall_2.wall_construction, + wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, + wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, + wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, + ) + if bp.sap_alternative_wall_2 + else None + ), + ) + for bp in schema.sap_building_parts + ], + ) + + @staticmethod + def _map_energy_element( + element: Union[ + EnergyElement_17_0, + EnergyElement_17_1, + EnergyElement_18_0, + EnergyElement_19_0, + EnergyElement_20_0, + EnergyElement_21_0, + EnergyElement_21_0_1, + ], + ) -> EnergyElement: + description = ( + element.description + if isinstance(element.description, str) + else element.description.value + ) + return EnergyElement( + description=description, + energy_efficiency_rating=element.energy_efficiency_rating, + environmental_efficiency_rating=element.environmental_efficiency_rating, + ) + + @staticmethod + def _map_energy_elements( + elements: Sequence[ + Union[ + EnergyElement_17_0, + EnergyElement_17_1, + EnergyElement_18_0, + EnergyElement_19_0, + EnergyElement_20_0, + EnergyElement_21_0, + EnergyElement_21_0_1, + ] + ], + ) -> List[EnergyElement]: + return [EpcPropertyDataMapper._map_energy_element(e) for e in elements] + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + + +def _extract_age_band(age_range: str) -> str: + """Return the letter code from a site-notes age range, e.g. 'I: 1996 - 2002' → 'I'.""" + return age_range.split(":")[0].strip() + + +def _map_floor_dimensions(floors: List[FloorMeasurement]) -> List[SapFloorDimension]: + return [ + SapFloorDimension( + room_height_m=floor.height_m, + total_floor_area_m2=floor.area_m2, + party_wall_length_m=floor.pwl_m, + heat_loss_perimeter_m=floor.heat_loss_perimeter_m, + ) + for floor in floors + ] + + +def _map_main_building_part( + construction: BuildingConstruction, + measurements: BuildingMeasurements, +) -> SapBuildingPart: + main = construction.main_building + return SapBuildingPart( + identifier="main", + construction_age_band=_extract_age_band(main.age_range), + wall_construction=main.walls_construction_type, + wall_insulation_type=main.walls_insulation_type, + wall_thickness_measured=main.wall_thickness_mm > 0, + party_wall_construction=main.party_wall_construction_type, + sap_floor_dimensions=_map_floor_dimensions(measurements.main_building.floors), + wall_thickness_mm=main.wall_thickness_mm, + ) + + +def _map_extension_building_part( + ext_c: ExtensionConstruction, + ext_m: ExtensionMeasurements, +) -> SapBuildingPart: + return SapBuildingPart( + identifier=f"extension_{ext_c.id}", + construction_age_band=_extract_age_band(ext_c.age_range), + wall_construction=ext_c.walls_construction_type, + wall_insulation_type=ext_c.walls_insulation_type, + wall_thickness_measured=ext_c.wall_thickness_mm > 0, + party_wall_construction=ext_c.party_wall_construction_type, + sap_floor_dimensions=_map_floor_dimensions(ext_m.floors), + wall_thickness_mm=ext_c.wall_thickness_mm, + ) + + +def _map_sap_window(window: Window) -> SapWindow: + return SapWindow( + pvc_frame=window.frame_type, + glazing_gap=window.glazing_gap, + orientation=window.orientation, + window_type=window.window_type, + glazing_type=window.glazing_type, + window_width=window.width_m, + window_height=window.height_m, + draught_proofed=window.draught_proofed, + window_location=window.location, + window_wall_type=window.wall_type, + permanent_shutters_present=window.permanent_shutters, + ) + + +def _map_sap_heating( + heating: HeatingAndHotWater, ventilation: Ventilation +) -> SapHeating: + main = heating.main_heating + secondary = heating.secondary_heating + + # secondary_fuel_type is an int code in the domain model; we can't map a + # site-notes string directly, so leave it None unless there is secondary heating. + # The string fuel type is preserved via sap_heating when needed. + secondary_fuel_type = ( + None if secondary.secondary_fuel == "No Secondary Heating" else None + ) + + return SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=main.flue_gas_heat_recovery_system, + main_fuel_type=main.fuel, + heat_emitter_type=main.emitter, + emitter_temperature=main.emitter_temperature, + fan_flue_present=main.fan_assist, + main_heating_control=main.controls, + ) + ], + has_fixed_air_conditioning=ventilation.has_fixed_air_conditioning, + secondary_fuel_type=secondary_fuel_type, + ) diff --git a/datatypes/epc/domain/tests/__init__.py b/datatypes/epc/domain/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py new file mode 100644 index 00000000..9e6fa0b9 --- /dev/null +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -0,0 +1,530 @@ +import json +import os +from datetime import date +from typing import Any, Dict + +import pytest + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from datatypes.epc.schema.rdsap_schema_17_0 import RdSapSchema17_0 +from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 +from datatypes.epc.schema.rdsap_schema_18_0 import RdSapSchema18_0 +from datatypes.epc.schema.rdsap_schema_19_0 import RdSapSchema19_0 +from datatypes.epc.schema.rdsap_schema_20_0_0 import RdSapSchema20_0_0 +from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0 +from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1 +from datatypes.epc.schema.tests.helpers import from_dict + +FIXTURES = os.path.join(os.path.dirname(__file__), "../../schema/tests/fixtures") + + +def load(filename: str) -> Dict[str, Any]: + with open(os.path.join(FIXTURES, filename)) as f: + return json.load(f) # type: ignore[no-any-return] + + +# --------------------------------------------------------------------------- +# Schema 17.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema17_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema17_0, load("17_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_17_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.92 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a localised object in 17.0; mapper extracts the string value + assert result.dwelling_type == "Mid-floor flat" + + def test_tenure(self, result: EpcPropertyData) -> None: + # tenure: 2 — stored as stringified int + assert result.tenure == "2" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 2 + + def test_built_form(self, result: EpcPropertyData) -> None: + assert result.built_form == "2" + + def test_property_type(self, result: EpcPropertyData) -> None: + assert result.property_type == "2" + + +# --------------------------------------------------------------------------- +# Schema 17.1 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema17_1: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema17_1, load("17_1.json")) + return EpcPropertyDataMapper.from_rdsap_schema_17_1(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.92 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a localised object in 17.1; mapper extracts the string value + assert result.dwelling_type == "Detached house" + + def test_tenure(self, result: EpcPropertyData) -> None: + # tenure: 1 + assert result.tenure == "1" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 4 + + def test_built_form(self, result: EpcPropertyData) -> None: + assert result.built_form == "1" + + def test_property_type(self, result: EpcPropertyData) -> None: + assert result.property_type == "0" + + +# --------------------------------------------------------------------------- +# Schema 18.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema18_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema18_0, load("18_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_18_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.92 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a localised object in 18.0; mapper extracts the string value + assert result.dwelling_type == "Mid-terrace house" + + def test_tenure(self, result: EpcPropertyData) -> None: + assert result.tenure == "1" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 2 + + def test_built_form(self, result: EpcPropertyData) -> None: + assert result.built_form == "4" + + def test_property_type(self, result: EpcPropertyData) -> None: + assert result.property_type == "0" + + +# --------------------------------------------------------------------------- +# Schema 19.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema19_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema19_0, load("19_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_19_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.94 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a localised object in 19.0; mapper extracts the string value + assert result.dwelling_type == "Semi-detached house" + + def test_tenure(self, result: EpcPropertyData) -> None: + # tenure: 3 + assert result.tenure == "3" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 1 + + def test_built_form(self, result: EpcPropertyData) -> None: + assert result.built_form == "2" + + def test_property_type(self, result: EpcPropertyData) -> None: + assert result.property_type == "0" + + +# --------------------------------------------------------------------------- +# Schema 20.0.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema20_0_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema20_0_0, load("20_0_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_20_0_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 9.8 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # dwelling_type is a plain string from 20.0.0 onwards + assert result.dwelling_type == "Mid-terrace house" + + def test_tenure(self, result: EpcPropertyData) -> None: + assert result.tenure == "1" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 2 + + def test_built_form(self, result: EpcPropertyData) -> None: + assert result.built_form == "2" + + def test_property_type(self, result: EpcPropertyData) -> None: + assert result.property_type == "0" + + +# --------------------------------------------------------------------------- +# Schema 21.0.0 +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema21_0_0: + + @pytest.fixture + def result(self) -> EpcPropertyData: + schema = from_dict(RdSapSchema21_0_0, load("21_0_0.json")) + return EpcPropertyDataMapper.from_rdsap_schema_21_0_0(schema) + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 10.2 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + assert result.dwelling_type == "Mid-terrace house" + + def test_tenure(self, result: EpcPropertyData) -> None: + assert result.tenure == "1" + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 3 + + def test_built_form(self, result: EpcPropertyData) -> None: + assert result.built_form == "2" + + def test_property_type(self, result: EpcPropertyData) -> None: + assert result.property_type == "0" + + +# --------------------------------------------------------------------------- +# Schema 21.0.1 (most comprehensive — full field coverage) +# --------------------------------------------------------------------------- + + +class TestFromRdSapSchema21_0_1: + + @pytest.fixture + def schema(self) -> RdSapSchema21_0_1: + return from_dict(RdSapSchema21_0_1, load("21_0_1.json")) + + @pytest.fixture + def result(self, schema: RdSapSchema21_0_1) -> EpcPropertyData: + return EpcPropertyDataMapper.from_rdsap_schema_21_0_1(schema) + + # --- general --- + + def test_uprn(self, result: EpcPropertyData) -> None: + assert result.uprn == 12457 + + def test_assessment_type(self, result: EpcPropertyData) -> None: + assert result.assessment_type == "RdSAP" + + def test_sap_version(self, result: EpcPropertyData) -> None: + assert result.sap_version == 10.2 + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + assert result.dwelling_type == "Mid-terrace house" + + def test_property_type(self, result: EpcPropertyData) -> None: + assert result.property_type == "0" + + def test_built_form(self, result: EpcPropertyData) -> None: + assert result.built_form == "2" + + def test_address_line_1(self, result: EpcPropertyData) -> None: + assert result.address_line_1 == "1 Some Street" + + def test_postcode(self, result: EpcPropertyData) -> None: + assert result.postcode == "A0 0AA" + + def test_post_town(self, result: EpcPropertyData) -> None: + assert result.post_town == "Whitbury" + + def test_status(self, result: EpcPropertyData) -> None: + assert result.status == "entered" + + def test_tenure(self, result: EpcPropertyData) -> None: + # tenure: 1 — stored as stringified int + assert result.tenure == "1" + + def test_transaction_type(self, result: EpcPropertyData) -> None: + # transaction_type: 16 — stored as stringified int + assert result.transaction_type == "16" + + def test_inspection_date(self, result: EpcPropertyData) -> None: + assert result.inspection_date == date(2025, 4, 4) + + def test_total_floor_area(self, result: EpcPropertyData) -> None: + assert result.total_floor_area_m2 == 55.0 + + # --- property flags --- + + def test_solar_water_heating(self, result: EpcPropertyData) -> None: + # solar_water_heating: "N" + assert result.solar_water_heating is False + + def test_has_hot_water_cylinder(self, result: EpcPropertyData) -> None: + # has_hot_water_cylinder: "true" + assert result.has_hot_water_cylinder is True + + def test_has_fixed_air_conditioning(self, result: EpcPropertyData) -> None: + # has_fixed_air_conditioning: "false" + assert result.has_fixed_air_conditioning is False + + def test_no_conservatory(self, result: EpcPropertyData) -> None: + # conservatory_type: 1 → no conservatory + assert result.has_conservatory is False + + # --- counts --- + + def test_door_count(self, result: EpcPropertyData) -> None: + assert result.door_count == 3 + + def test_habitable_rooms(self, result: EpcPropertyData) -> None: + assert result.habitable_rooms_count == 5 + + def test_heated_rooms(self, result: EpcPropertyData) -> None: + assert result.heated_rooms_count == 5 + + def test_wet_rooms(self, result: EpcPropertyData) -> None: + assert result.wet_rooms_count == 0 + + def test_extensions_count(self, result: EpcPropertyData) -> None: + assert result.extensions_count == 0 + + def test_open_chimneys(self, result: EpcPropertyData) -> None: + assert result.open_chimneys_count == 1 + + def test_insulated_doors(self, result: EpcPropertyData) -> None: + assert result.insulated_door_count == 2 + + def test_draughtproofed_doors(self, result: EpcPropertyData) -> None: + assert result.draughtproofed_door_count == 1 + + # --- lighting --- + + def test_led_bulbs(self, result: EpcPropertyData) -> None: + assert result.led_fixed_lighting_bulbs_count == 10 + + def test_cfl_bulbs(self, result: EpcPropertyData) -> None: + assert result.cfl_fixed_lighting_bulbs_count == 5 + + def test_incandescent_bulbs(self, result: EpcPropertyData) -> None: + assert result.incandescent_fixed_lighting_bulbs_count == 0 + + # --- energy elements --- + + def test_roof_count(self, result: EpcPropertyData) -> None: + assert len(result.roofs) == 2 + + def test_roof_description(self, result: EpcPropertyData) -> None: + assert result.roofs[0].description == "Pitched, 25 mm loft insulation" + + def test_roof_energy_efficiency_rating(self, result: EpcPropertyData) -> None: + assert result.roofs[0].energy_efficiency_rating == 2 + + def test_wall_count(self, result: EpcPropertyData) -> None: + assert len(result.walls) == 2 + + def test_window_element_description(self, result: EpcPropertyData) -> None: + assert result.window is not None + assert result.window.description == "Fully double glazed" + + def test_window_element_rating(self, result: EpcPropertyData) -> None: + assert result.window is not None + assert result.window.energy_efficiency_rating == 3 + + def test_lighting_element_description(self, result: EpcPropertyData) -> None: + assert result.lighting is not None + assert result.lighting.description == "Low energy lighting in 50% of fixed outlets" + + def test_hot_water_element_description(self, result: EpcPropertyData) -> None: + assert result.hot_water is not None + assert result.hot_water.description == "From main system" + + def test_secondary_heating_element(self, result: EpcPropertyData) -> None: + assert result.secondary_heating is not None + assert result.secondary_heating.description == "Room heaters, electric" + + def test_main_heating_element_count(self, result: EpcPropertyData) -> None: + assert len(result.main_heating) == 2 + + def test_main_heating_element_description(self, result: EpcPropertyData) -> None: + assert result.main_heating[0].description == "Boiler and radiators, anthracite" + + # --- sap energy source --- + + def test_mains_gas(self, result: EpcPropertyData) -> None: + # mains_gas: "Y" + assert result.sap_energy_source.mains_gas is True + + def test_electricity_smart_meter(self, result: EpcPropertyData) -> None: + # electricity_smart_meter_present: "true" + assert result.sap_energy_source.electricity_smart_meter_present is True + + def test_gas_smart_meter(self, result: EpcPropertyData) -> None: + # gas_smart_meter_present: "false" + assert result.sap_energy_source.gas_smart_meter_present is False + + def test_pv_battery_count(self, result: EpcPropertyData) -> None: + assert result.sap_energy_source.pv_battery_count == 1 + + def test_wind_turbines_count(self, result: EpcPropertyData) -> None: + assert result.sap_energy_source.wind_turbines_count == 0 + + # --- sap heating --- + + def test_cylinder_size(self, result: EpcPropertyData) -> None: + assert result.sap_heating.cylinder_size == 1 + + def test_water_heating_code(self, result: EpcPropertyData) -> None: + assert result.sap_heating.water_heating_code == 901 + + def test_water_heating_fuel(self, result: EpcPropertyData) -> None: + assert result.sap_heating.water_heating_fuel == 26 + + def test_secondary_fuel_type(self, result: EpcPropertyData) -> None: + # secondary_fuel_type: 25 + assert result.sap_heating.secondary_fuel_type == 25 + + def test_main_heating_no_fghrs(self, result: EpcPropertyData) -> None: + # has_fghrs: "N" + assert result.sap_heating.main_heating_details[0].has_fghrs is False + + def test_main_heating_fuel_type(self, result: EpcPropertyData) -> None: + # main_fuel_type: 26 + assert result.sap_heating.main_heating_details[0].main_fuel_type == 26 + + def test_main_heating_fan_flue(self, result: EpcPropertyData) -> None: + # fan_flue_present: "N" + assert result.sap_heating.main_heating_details[0].fan_flue_present is False + + def test_main_heating_control(self, result: EpcPropertyData) -> None: + assert result.sap_heating.main_heating_details[0].main_heating_control == 2106 + + def test_main_heating_category(self, result: EpcPropertyData) -> None: + assert result.sap_heating.main_heating_details[0].main_heating_category == 2 + + def test_main_heating_number(self, result: EpcPropertyData) -> None: + assert result.sap_heating.main_heating_details[0].main_heating_number == 1 + + # --- sap windows --- + + def test_window_count(self, result: EpcPropertyData) -> None: + assert len(result.sap_windows) == 1 + + def test_window_height(self, result: EpcPropertyData) -> None: + assert result.sap_windows[0].window_height == 2.0 + + def test_window_width(self, result: EpcPropertyData) -> None: + assert result.sap_windows[0].window_width == 1.2 + + def test_window_draught_proofed(self, result: EpcPropertyData) -> None: + # draught_proofed: "true" + assert result.sap_windows[0].draught_proofed is True + + # --- sap building parts --- + + def test_building_part_count(self, result: EpcPropertyData) -> None: + assert len(result.sap_building_parts) == 1 + + def test_construction_age_band(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].construction_age_band == "M" + + def test_wall_construction(self, result: EpcPropertyData) -> None: + # wall_construction: 4 (int preserved from API) + assert result.sap_building_parts[0].wall_construction == 4 + + def test_wall_insulation_type(self, result: EpcPropertyData) -> None: + # wall_insulation_type: 2 (int preserved from API) + assert result.sap_building_parts[0].wall_insulation_type == 2 + + def test_wall_thickness_not_measured(self, result: EpcPropertyData) -> None: + # wall_thickness_measured: "N" + assert result.sap_building_parts[0].wall_thickness_measured is False + + def test_wall_thickness_mm_absent(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].wall_thickness_mm is None + + def test_roof_insulation_thickness(self, result: EpcPropertyData) -> None: + # roof_insulation_thickness: "200mm" — preserved as-is from schema + assert result.sap_building_parts[0].roof_insulation_thickness == "200mm" + + def test_room_in_roof_present(self, result: EpcPropertyData) -> None: + # sap_room_in_roof is present in the fixture + assert result.sap_building_parts[0].sap_room_in_roof is not None + + # --- floor dimensions --- + + def test_floor_count(self, result: EpcPropertyData) -> None: + assert len(result.sap_building_parts[0].sap_floor_dimensions) == 1 + + def test_floor_area(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 == 45.82 + + def test_floor_height(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.45 + + def test_heat_loss_perimeter(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m == 19.5 + + def test_party_wall_length(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m == 7.9 diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py new file mode 100644 index 00000000..47327ff7 --- /dev/null +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -0,0 +1,499 @@ +import json +import os +from datetime import date +from typing import Any, Dict + +import pytest + +from datatypes.epc.domain.epc_property_data import ( + EpcPropertyData, + InstantaneousWwhrs, + MainHeatingDetail, + SapBuildingPart, + SapEnergySource, + SapFloorDimension, + SapHeating, + SapWindow, +) +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from datatypes.epc.schema.tests.helpers import from_dict +from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes + +FIXTURES = os.path.join( + os.path.dirname(__file__), + "../../surveys/tests/fixtures", +) + + +def load(filename: str) -> Dict[str, Any]: + with open(os.path.join(FIXTURES, filename)) as f: + return json.load(f) # type: ignore[no-any-return] + + +class TestFromSiteNotesExample1: + """ + Fixture: pashub_rdsap_site_notes_example1.json + No extensions, regular boiler with cylinder, natural ventilation. + """ + + @pytest.fixture + def survey(self) -> PasHubRdSapSiteNotes: + return from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json") + ) + + @pytest.fixture + def result(self, survey: PasHubRdSapSiteNotes) -> EpcPropertyData: + return EpcPropertyDataMapper.from_site_notes(survey) + + # --- property details --- + + def test_dwelling_type(self, result: EpcPropertyData) -> None: + # general.property_type + general.detachment_type → "Mid-terrace house" + assert result.dwelling_type == "Mid-terrace house" + + def test_tenure(self, result: EpcPropertyData) -> None: + # general.tenure: "Rented Social" + assert result.tenure == "Rented Social" + + def test_transaction_type(self, result: EpcPropertyData) -> None: + # general.transaction_type: "None of the Above" + assert result.transaction_type == "None of the Above" + + def test_inspection_date(self, result: EpcPropertyData) -> None: + # general.inspection_date: "2026-03-31" + assert result.inspection_date == date(2026, 3, 31) + + def test_built_form(self, result: EpcPropertyData) -> None: + # general.detachment_type: "Mid-terrace" + assert result.built_form == "Mid-terrace" + + def test_property_type(self, result: EpcPropertyData) -> None: + # general.property_type: "House" + assert result.property_type == "House" + + # --- energy elements are not available from site notes --- + + def test_roofs_empty(self, result: EpcPropertyData) -> None: + assert result.roofs == [] + + def test_walls_empty(self, result: EpcPropertyData) -> None: + assert result.walls == [] + + def test_floors_empty(self, result: EpcPropertyData) -> None: + assert result.floors == [] + + def test_main_heating_elements_empty(self, result: EpcPropertyData) -> None: + assert result.main_heating == [] + + def test_window_element_absent(self, result: EpcPropertyData) -> None: + assert result.window is None + + def test_lighting_element_absent(self, result: EpcPropertyData) -> None: + assert result.lighting is None + + def test_hot_water_element_absent(self, result: EpcPropertyData) -> None: + assert result.hot_water is None + + # --- energy source --- + + def test_mains_gas_available(self, result: EpcPropertyData) -> None: + # general.mains_gas_available: true + assert result.sap_energy_source.mains_gas is True + + def test_electricity_smart_meter(self, result: EpcPropertyData) -> None: + # general.electricity_smart_meter: true + assert result.sap_energy_source.electricity_smart_meter_present is True + + def test_gas_smart_meter(self, result: EpcPropertyData) -> None: + # general.gas_smart_meter: true + assert result.sap_energy_source.gas_smart_meter_present is True + + def test_meter_type(self, result: EpcPropertyData) -> None: + # general.electric_meter_type: "Single" + assert result.sap_energy_source.meter_type == "Single" + + def test_dwelling_export_capable(self, result: EpcPropertyData) -> None: + # general.dwelling_export_capable: true + assert result.sap_energy_source.is_dwelling_export_capable is True + + def test_wind_turbines_terrain_type(self, result: EpcPropertyData) -> None: + # general.terrain_type: "Suburban" + assert result.sap_energy_source.wind_turbines_terrain_type == "Suburban" + + def test_no_wind_turbines(self, result: EpcPropertyData) -> None: + # renewables.wind_turbines: false → count 0 + assert result.sap_energy_source.wind_turbines_count == 0 + + def test_no_pv_batteries(self, result: EpcPropertyData) -> None: + # renewables.number_of_pv_batteries: 0 + assert result.sap_energy_source.pv_battery_count == 0 + + # --- renewables --- + + def test_no_solar_hot_water(self, result: EpcPropertyData) -> None: + # renewables.solar_hot_water: false + assert result.solar_water_heating is False + + # --- ventilation --- + + def test_no_fixed_air_conditioning(self, result: EpcPropertyData) -> None: + # ventilation.has_fixed_air_conditioning: false + assert result.has_fixed_air_conditioning is False + + # --- conservatory --- + + def test_no_conservatory(self, result: EpcPropertyData) -> None: + # conservatories.has_conservatory: false + assert result.has_conservatory is False + + # --- hot water / cylinder --- + + def test_has_hot_water_cylinder(self, result: EpcPropertyData) -> None: + # water_heating.cylinder_size is present → cylinder exists + assert result.has_hot_water_cylinder is True + + # --- main heating --- + + def test_main_heating_fuel(self, result: EpcPropertyData) -> None: + # heating_and_hot_water.main_heating.fuel: "Mains gas" + assert result.sap_heating.main_heating_details[0].main_fuel_type == "Mains gas" + + def test_main_heating_emitter(self, result: EpcPropertyData) -> None: + # heating_and_hot_water.main_heating.emitter: "Radiators" + assert ( + result.sap_heating.main_heating_details[0].heat_emitter_type == "Radiators" + ) + + def test_main_heating_no_fghrs(self, result: EpcPropertyData) -> None: + # heating_and_hot_water.main_heating.flue_gas_heat_recovery_system: false + assert result.sap_heating.main_heating_details[0].has_fghrs is False + + def test_main_heating_fan_flue_present(self, result: EpcPropertyData) -> None: + # heating_and_hot_water.main_heating.fan_assist: true + assert result.sap_heating.main_heating_details[0].fan_flue_present is True + + def test_no_secondary_heating(self, result: EpcPropertyData) -> None: + # secondary_heating.secondary_fuel: "No Secondary Heating" → no secondary fuel type + assert result.sap_heating.secondary_fuel_type is None + + # --- windows --- + + def test_window_count(self, result: EpcPropertyData) -> None: + # 4 windows in fixture + assert len(result.sap_windows) == 4 + + def test_window_height(self, result: EpcPropertyData) -> None: + assert result.sap_windows[0].window_height == 1.36 + + def test_window_width(self, result: EpcPropertyData) -> None: + assert result.sap_windows[0].window_width == 1.0 + + def test_window_draught_proofed(self, result: EpcPropertyData) -> None: + # windows[0].draught_proofed: true + assert result.sap_windows[0].draught_proofed is True + + def test_window_orientation(self, result: EpcPropertyData) -> None: + assert result.sap_windows[0].orientation == "South East" + + def test_window_glazing_type(self, result: EpcPropertyData) -> None: + assert ( + result.sap_windows[0].glazing_type == "Double glazing, Unknown install date" + ) + + # --- building parts --- + + def test_building_parts_count(self, result: EpcPropertyData) -> None: + # no extensions → one building part for main building + assert len(result.sap_building_parts) == 1 + + def test_building_part_identifier(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].identifier == "main" + + def test_construction_age_band(self, result: EpcPropertyData) -> None: + # main_building.age_range: "I: 1996 - 2002" → letter "I" + assert result.sap_building_parts[0].construction_age_band == "I" + + def test_wall_construction(self, result: EpcPropertyData) -> None: + # main_building.walls_construction_type: "Cavity" + assert result.sap_building_parts[0].wall_construction == "Cavity" + + def test_wall_insulation_type(self, result: EpcPropertyData) -> None: + # main_building.walls_insulation_type: "As built" + assert result.sap_building_parts[0].wall_insulation_type == "As built" + + def test_wall_thickness_measured(self, result: EpcPropertyData) -> None: + # main_building.wall_thickness_mm: 280 → thickness was measured + assert result.sap_building_parts[0].wall_thickness_measured is True + + def test_wall_thickness_mm(self, result: EpcPropertyData) -> None: + assert result.sap_building_parts[0].wall_thickness_mm == 280 + + # --- floor dimensions --- + + def test_floor_count(self, result: EpcPropertyData) -> None: + # 2 floors in main building + assert len(result.sap_building_parts[0].sap_floor_dimensions) == 2 + + def test_floor_area(self, result: EpcPropertyData) -> None: + # building_measurements.main_building.floors[0].area_m2: 24.78 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 + == 24.78 + ) + + def test_floor_height(self, result: EpcPropertyData) -> None: + # floors[0].height_m: 2.37 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.37 + ) + + def test_heat_loss_perimeter(self, result: EpcPropertyData) -> None: + # floors[0].heat_loss_perimeter_m: 14.21 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m + == 14.21 + ) + + def test_party_wall_length(self, result: EpcPropertyData) -> None: + # floors[0].pwl_m: 6.15 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m + == 6.15 + ) + + def test_total_floor_area(self, result: EpcPropertyData) -> None: + # sum of all floor areas: 24.78 + 24.78 = 49.56 + assert result.total_floor_area_m2 == 49.56 + + # --- room counts --- + + def test_habitable_rooms(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_habitable_rooms: 2 + assert result.habitable_rooms_count == 2 + + def test_external_doors(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_external_doors: 2 + assert result.door_count == 2 + + def test_open_chimneys(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_open_chimneys: 0 + assert result.open_chimneys_count == 0 + + def test_blocked_chimneys(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_blocked_chimneys: 0 + assert result.blocked_chimneys_count == 0 + + def test_insulated_doors(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_insulated_external_doors: 0 + assert result.insulated_door_count == 0 + + def test_draughtproofed_doors(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_draughtproofed_external_doors: 2 + assert result.draughtproofed_door_count == 2 + + def test_extensions_count(self, result: EpcPropertyData) -> None: + # general.number_of_extensions: 0 + assert result.extensions_count == 0 + + def test_heated_rooms_count(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_heated_rooms: 0 + # no equivalent in site notes defaults to 0 + assert result.heated_rooms_count == 0 + + def test_wet_rooms_count_defaults_to_zero(self, result: EpcPropertyData) -> None: + # no equivalent in site notes; mapper must default to 0 + assert result.wet_rooms_count == 0 + + # --- lighting --- + + def test_led_bulbs(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_fixed_led_bulbs: 5 + assert result.led_fixed_lighting_bulbs_count == 5 + + def test_cfl_bulbs(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_fixed_cfl_bulbs: 4 + assert result.cfl_fixed_lighting_bulbs_count == 4 + + def test_incandescent_bulbs(self, result: EpcPropertyData) -> None: + # room_count_elements.number_of_fixed_incandescent_bulbs: 0 + assert result.incandescent_fixed_lighting_bulbs_count == 0 + + # --- api-only fields absent --- + + def test_assessment_type_absent(self, result: EpcPropertyData) -> None: + assert result.assessment_type is None + + def test_sap_version_absent(self, result: EpcPropertyData) -> None: + assert result.sap_version is None + + def test_uprn_absent(self, result: EpcPropertyData) -> None: + assert result.uprn is None + + def test_address_absent(self, result: EpcPropertyData) -> None: + assert result.address_line_1 is None + + def test_postcode_absent(self, result: EpcPropertyData) -> None: + assert result.postcode is None + + def test_post_town_absent(self, result: EpcPropertyData) -> None: + assert result.post_town is None + + def test_status_absent(self, result: EpcPropertyData) -> None: + assert result.status is None + + # --- full object equality --- + + def test_full_mapping(self, survey: PasHubRdSapSiteNotes) -> None: + result = EpcPropertyDataMapper.from_site_notes(survey) + expected = EpcPropertyData( + # General + assessment_type=None, + sap_version=None, + dwelling_type="Mid-terrace house", + uprn=None, + address_line_1=None, + postcode=None, + post_town=None, + inspection_date=date(2026, 3, 31), + status=None, + tenure="Rented Social", + transaction_type="None of the Above", + # Elements (not available from site notes) + roofs=[], + walls=[], + floors=[], + main_heating=[], + window=None, + lighting=None, + hot_water=None, + door_count=2, + # Heating + sap_heating=SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, + main_fuel_type="Mains gas", + heat_emitter_type="Radiators", + emitter_temperature="Unknown", + fan_flue_present=True, + main_heating_control="Programmer, room thermostat and TRVs", + ) + ], + has_fixed_air_conditioning=False, + ), + # Windows + sap_windows=[ + SapWindow( + pvc_frame="Wooden or PVC", + glazing_gap="16 mm or more", + orientation="South East", + window_type="Window", + glazing_type="Double glazing, Unknown install date", + window_width=1.0, + window_height=1.36, + draught_proofed=True, + window_location="Main Building", + window_wall_type="External wall", + permanent_shutters_present=False, + ), + SapWindow( + pvc_frame="Wooden or PVC", + glazing_gap="16 mm or more", + orientation="South East", + window_type="Window", + glazing_type="Double glazing, Unknown install date", + window_width=0.96, + window_height=1.33, + draught_proofed=True, + window_location="Main Building", + window_wall_type="External wall", + permanent_shutters_present=False, + ), + SapWindow( + pvc_frame="Wooden or PVC", + glazing_gap="16 mm or more", + orientation="North West", + window_type="Window", + glazing_type="Double glazing, Unknown install date", + window_width=0.96, + window_height=1.04, + draught_proofed=True, + window_location="Main Building", + window_wall_type="External wall", + permanent_shutters_present=False, + ), + SapWindow( + pvc_frame="Wooden or PVC", + glazing_gap="16 mm or more", + orientation="North West", + window_type="Window", + glazing_type="Double glazing, Unknown install date", + window_width=0.97, + window_height=1.02, + draught_proofed=True, + window_location="Main Building", + window_wall_type="External wall", + permanent_shutters_present=False, + ), + ], + # Energy source + sap_energy_source=SapEnergySource( + mains_gas=True, + meter_type="Single", + pv_battery_count=0, + wind_turbines_count=0, + gas_smart_meter_present=True, + is_dwelling_export_capable=True, + wind_turbines_terrain_type="Suburban", + electricity_smart_meter_present=True, + ), + # Building parts + sap_building_parts=[ + SapBuildingPart( + identifier="main", + construction_age_band="I", + wall_construction="Cavity", + wall_insulation_type="As built", + wall_thickness_measured=True, + party_wall_construction="Cavity Masonry, Unfilled", + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=2.37, + total_floor_area_m2=24.78, + party_wall_length_m=6.15, + heat_loss_perimeter_m=14.21, + ), + SapFloorDimension( + room_height_m=2.35, + total_floor_area_m2=24.78, + party_wall_length_m=6.15, + heat_loss_perimeter_m=14.21, + ), + ], + wall_thickness_mm=280, + ) + ], + solar_water_heating=False, + has_hot_water_cylinder=True, + has_fixed_air_conditioning=False, + # Counts + wet_rooms_count=0, # no equivalent in site notes + extensions_count=0, + heated_rooms_count=0, + open_chimneys_count=0, + habitable_rooms_count=2, + insulated_door_count=0, + cfl_fixed_lighting_bulbs_count=4, + led_fixed_lighting_bulbs_count=5, + incandescent_fixed_lighting_bulbs_count=0, + total_floor_area_m2=49.56, + # Optional fields populated from site notes + built_form="Mid-terrace", + property_type="House", + has_conservatory=False, + blocked_chimneys_count=0, + draughtproofed_door_count=2, + ) + assert result == expected diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 046e4fec..9b3dbd1d 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -33,13 +33,14 @@ class ShowerOutlets: @dataclass class InstantaneousWwhrs: """References WWHRS product index numbers (introduced in 21.0.0).""" + wwhrs_index_number1: Optional[int] = None wwhrs_index_number2: Optional[int] = None @dataclass class MainHeatingDetail: - has_fghrs: str + has_fghrs: str # TODO: make bool main_fuel_type: int heat_emitter_type: int emitter_temperature: Union[int, str] @@ -49,7 +50,7 @@ class MainHeatingDetail: main_heating_fraction: int main_heating_data_source: int boiler_flue_type: Optional[int] = None - fan_flue_present: Optional[str] = None + fan_flue_present: Optional[str] = None # TODO: make bool boiler_ignition_type: Optional[int] = None central_heating_pump_age: Optional[int] = None main_heating_index_number: Optional[int] = None @@ -132,10 +133,10 @@ class SapWindow: glazing_type: int window_width: float window_height: float - draught_proofed: str + draught_proofed: str # TODO: make bool window_location: int window_wall_type: int - permanent_shutters_present: str + permanent_shutters_present: str # TODO: make bool window_transmission_details: WindowTransmissionDetails permanent_shutters_insulated: str diff --git a/datatypes/epc/surveys/__init__.py b/datatypes/epc/surveys/__init__.py new file mode 100644 index 00000000..b71034ba --- /dev/null +++ b/datatypes/epc/surveys/__init__.py @@ -0,0 +1,3 @@ +from .pashub_rdsap_site_notes import PasHubRdSapSiteNotes + +__all__ = ["PasHubRdSapSiteNotes"] diff --git a/datatypes/epc/surveys/pashub_rdsap_site_notes.py b/datatypes/epc/surveys/pashub_rdsap_site_notes.py new file mode 100644 index 00000000..c5b3dbe4 --- /dev/null +++ b/datatypes/epc/surveys/pashub_rdsap_site_notes.py @@ -0,0 +1,291 @@ +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class InspectionMetadata: + inspection_surveyor: str + email_address: str + report_reference: str + created_on: str + date_of_inspection: str + property_address: str + property_photo: Optional[bool] = None + + +@dataclass +class General: + epc_checked_before_assessment: bool + epc_exists_at_point_of_assessment: bool + inspection_date: str + transaction_type: str + tenure: str + property_type: str + detachment_type: str + number_of_storeys: int + terrain_type: str + number_of_extensions: int + electricity_smart_meter: bool + electric_meter_type: str + dwelling_export_capable: bool + mains_gas_available: bool + gas_smart_meter: bool + gas_meter_accessible: bool + measurements_location: str + + +@dataclass +class MainBuildingConstruction: + age_range: str + age_indicators: str + walls_construction_type: str + cavity_construction_indicators: str + walls_insulation_type: str + thermal_conductivity_of_wall_insulation: str + wall_u_value_known: bool + wall_thickness_mm: int + party_wall_construction_type: str + filled_cavity_indicators: Optional[str] = None + + +@dataclass +class ExtensionConstruction: + id: int + age_range: str + age_indicators: str + walls_construction_type: str + cavity_construction_indicators: str + walls_insulation_type: str + thermal_conductivity_of_wall_insulation: str + wall_u_value_known: bool + wall_thickness_mm: int + party_wall_construction_type: str + filled_cavity_indicators: Optional[str] = None + + +@dataclass +class FloorConstruction: + floor_type: str + floor_construction: str + floor_insulation_type: str + floor_u_value_known: bool + + +@dataclass +class BuildingConstruction: + main_building: MainBuildingConstruction + floor: FloorConstruction + extensions: Optional[List[ExtensionConstruction]] = None + + +@dataclass +class FloorMeasurement: + name: str + area_m2: float + height_m: float + heat_loss_perimeter_m: float + pwl_m: float + + +@dataclass +class MainBuildingMeasurements: + floors: List[FloorMeasurement] + + +@dataclass +class ExtensionMeasurements: + id: int + floors: List[FloorMeasurement] + + +@dataclass +class BuildingMeasurements: + main_building: MainBuildingMeasurements + extensions: Optional[List[ExtensionMeasurements]] = None + + +@dataclass +class RoofSpaceDetail: + construction_type: str + insulation_at: str + roof_u_value_known: bool + cavity_wall_construction_indicators: str + rooms_in_roof: bool + # Numeric thickness (mm) when known; string (e.g. "As built") when not measured + insulation_thickness_mm: Optional[int] = None + insulation_thickness: Optional[str] = None + + +@dataclass +class ExtensionRoofSpace: + id: int + construction_type: str + insulation_at: str + roof_u_value_known: bool + cavity_wall_construction_indicators: str + rooms_in_roof: bool + insulation_thickness_mm: Optional[int] = None + insulation_thickness: Optional[str] = None + + +@dataclass +class RoofSpace: + main_building: RoofSpaceDetail + extensions: Optional[List[ExtensionRoofSpace]] = None + + +@dataclass +class Window: + id: int + location: str + wall_type: str + glazing_type: str + window_type: str + frame_type: str + glazing_gap: str + draught_proofed: bool + permanent_shutters: bool + height_m: float + width_m: float + orientation: str + + +@dataclass +class MainHeating: + selection_method: str + system_type: str + product_id: int + manufacturer: str + model: str + orig_manufacturer: str + fuel: str + summer_efficiency: float + type: str + condensing: bool + year: str + mount: str + open_flue: str + fan_assist: bool + status: str + central_heating_pump_age: str + controls: str + flue_gas_heat_recovery_system: bool + weather_compensator: bool + emitter: str + emitter_temperature: str + + +@dataclass +class SecondaryHeating: + secondary_fuel: str + + +@dataclass +class WaterHeating: + type: str + system: str + cylinder_size: str + cylinder_measured_heat_loss: Optional[str] = None + insulation_type: Optional[str] = None + insulation_thickness_mm: Optional[int] = None + has_thermostat: Optional[bool] = None + + +@dataclass +class HeatingAndHotWater: + main_heating: MainHeating + secondary_heating: SecondaryHeating + water_heating: WaterHeating + + +@dataclass +class Ventilation: + ventilation_type: str + has_fixed_air_conditioning: bool + number_of_open_flues: int + number_of_closed_flues: int + number_of_boiler_flues: int + number_of_other_flues: int + number_of_extract_fans: int + number_of_passive_vents: int + number_of_flueless_gas_fires: int + pressure_test: str + draught_lobby: bool + ventilation_in_pcdf_database: Optional[bool] = None + + +@dataclass +class Conservatories: + has_conservatory: bool + + +@dataclass +class Renewables: + wind_turbines: bool + solar_hot_water: bool + photovoltaic_array: bool + number_of_pv_batteries: int + hydro: bool + + +@dataclass +class RoomCountElements: + number_of_habitable_rooms: int + any_unheated_rooms: bool + number_of_external_doors: int + number_of_insulated_external_doors: int + number_of_draughtproofed_external_doors: int + number_of_open_chimneys: int + number_of_blocked_chimneys: int + number_of_fixed_incandescent_bulbs: int + exact_led_cfl_count_known: bool + number_of_fixed_led_bulbs: int + number_of_fixed_cfl_bulbs: int + waste_water_heat_recovery: str + number_of_heated_rooms: Optional[int] = None + + +@dataclass +class Shower: + id: int + outlet_type: str + + +@dataclass +class WaterUse: + number_of_baths: int + number_of_special_features: int + showers: List[Shower] + + +@dataclass +class CustomerResponse: + customer_present: bool + willing_to_answer_satisfaction_survey: bool + + +@dataclass +class SurveyAddendum: + addendum: str + related_party_disclosure: str + hard_to_treat_cavity_access_issues: bool + hard_to_treat_cavity_high_exposure: bool + hard_to_treat_cavity_narrow_cavities: bool + + +@dataclass +class PasHubRdSapSiteNotes: + inspection_metadata: InspectionMetadata + general: General + building_construction: BuildingConstruction + building_measurements: BuildingMeasurements + roof_space: RoofSpace + windows: List[Window] + heating_and_hot_water: HeatingAndHotWater + ventilation: Ventilation + conservatories: Conservatories + renewables: Renewables + room_count_elements: RoomCountElements + water_use: WaterUse + customer_response: CustomerResponse + addendum: SurveyAddendum diff --git a/datatypes/epc/surveys/tests/__init__.py b/datatypes/epc/surveys/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example1.json b/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example1.json new file mode 100644 index 00000000..b5772e24 --- /dev/null +++ b/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example1.json @@ -0,0 +1,232 @@ +{ + "inspection_metadata": { + "inspection_surveyor": "test", + "email_address": "test@test.com", + "report_reference": "49D422A9-0779-44DD-9665-464D35DFF1A8", + "created_on": "2026-03-31", + "date_of_inspection": "2026-03-31", + "property_address": "test" + }, + "general": { + "epc_checked_before_assessment": true, + "epc_exists_at_point_of_assessment": false, + "inspection_date": "2026-03-31", + "transaction_type": "None of the Above", + "tenure": "Rented Social", + "property_type": "House", + "detachment_type": "Mid-terrace", + "number_of_storeys": 2, + "terrain_type": "Suburban", + "number_of_extensions": 0, + "electricity_smart_meter": true, + "electric_meter_type": "Single", + "dwelling_export_capable": true, + "mains_gas_available": true, + "gas_smart_meter": true, + "gas_meter_accessible": true, + "measurements_location": "Internal" + }, + "building_construction": { + "main_building": { + "age_range": "I: 1996 - 2002", + "age_indicators": "local knowledge", + "walls_construction_type": "Cavity", + "cavity_construction_indicators": "stretcher bond", + "walls_insulation_type": "As built", + "thermal_conductivity_of_wall_insulation": "Unknown", + "wall_u_value_known": false, + "wall_thickness_mm": 280, + "party_wall_construction_type": "Cavity Masonry, Unfilled" + }, + "floor": { + "floor_type": "Ground Floor", + "floor_construction": "Suspended, not timber", + "floor_insulation_type": "As Built", + "floor_u_value_known": false + } + }, + "building_measurements": { + "main_building": { + "floors": [ + { + "name": "Floor 1", + "area_m2": 24.78, + "height_m": 2.37, + "heat_loss_perimeter_m": 14.21, + "pwl_m": 6.15 + }, + { + "name": "Floor 0", + "area_m2": 24.78, + "height_m": 2.35, + "heat_loss_perimeter_m": 14.21, + "pwl_m": 6.15 + } + ] + } + }, + "roof_space": { + "main_building": { + "construction_type": "Pitched roof (Slates or tiles), Access to loft", + "insulation_at": "Joists", + "roof_u_value_known": false, + "insulation_thickness_mm": 100, + "cavity_wall_construction_indicators": "No indicator of construction visible", + "rooms_in_roof": false + } + }, + "windows": [ + { + "id": 1, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.36, + "width_m": 1.0, + "orientation": "South East" + }, + { + "id": 2, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.33, + "width_m": 0.96, + "orientation": "South East" + }, + { + "id": 3, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.04, + "width_m": 0.96, + "orientation": "North West" + }, + { + "id": 4, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.02, + "width_m": 0.97, + "orientation": "North West" + } + ], + "heating_and_hot_water": { + "main_heating": { + "selection_method": "PCDF Search", + "system_type": "Boiler with radiators or underfloor heating", + "product_id": 18400, + "manufacturer": "Vaillant", + "model": "ecoFIT sustain 415", + "orig_manufacturer": "Vaillant", + "fuel": "Mains gas", + "summer_efficiency": 0, + "type": "Regular", + "condensing": true, + "year": "2018 - current", + "mount": "Wall", + "open_flue": "Room-sealed", + "fan_assist": true, + "status": "Normal status for an actual product", + "central_heating_pump_age": "Unknown", + "controls": "Programmer, room thermostat and TRVs", + "flue_gas_heat_recovery_system": false, + "weather_compensator": false, + "emitter": "Radiators", + "emitter_temperature": "Unknown" + }, + "secondary_heating": { + "secondary_fuel": "No Secondary Heating" + }, + "water_heating": { + "type": "Regular", + "system": "From main heating 1", + "cylinder_size": "Normal (90-130 litres)", + "cylinder_measured_heat_loss": "Not known", + "insulation_type": "Factory fitted", + "insulation_thickness_mm": 12, + "has_thermostat": true + } + }, + "ventilation": { + "ventilation_type": "Natural", + "has_fixed_air_conditioning": false, + "number_of_open_flues": 0, + "number_of_closed_flues": 0, + "number_of_boiler_flues": 0, + "number_of_other_flues": 0, + "number_of_extract_fans": 2, + "number_of_passive_vents": 0, + "number_of_flueless_gas_fires": 0, + "pressure_test": "No test", + "draught_lobby": false + }, + "conservatories": { + "has_conservatory": false + }, + "renewables": { + "wind_turbines": false, + "solar_hot_water": false, + "photovoltaic_array": false, + "number_of_pv_batteries": 0, + "hydro": false + }, + "room_count_elements": { + "number_of_habitable_rooms": 2, + "any_unheated_rooms": true, + "number_of_heated_rooms": 0, + "number_of_external_doors": 2, + "number_of_insulated_external_doors": 0, + "number_of_draughtproofed_external_doors": 2, + "number_of_open_chimneys": 0, + "number_of_blocked_chimneys": 0, + "number_of_fixed_incandescent_bulbs": 0, + "exact_led_cfl_count_known": true, + "number_of_fixed_led_bulbs": 5, + "number_of_fixed_cfl_bulbs": 4, + "waste_water_heat_recovery": "None" + }, + "water_use": { + "number_of_baths": 1, + "number_of_special_features": 0, + "showers": [ + { + "id": 1, + "outlet_type": "Non-Electric Shower" + } + ] + }, + "customer_response": { + "customer_present": true, + "willing_to_answer_satisfaction_survey": false + }, + "addendum": { + "addendum": "PV Recommended", + "related_party_disclosure": "No related party", + "hard_to_treat_cavity_access_issues": false, + "hard_to_treat_cavity_high_exposure": false, + "hard_to_treat_cavity_narrow_cavities": false + } +} diff --git a/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example2.json b/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example2.json new file mode 100644 index 00000000..1d9c38f5 --- /dev/null +++ b/datatypes/epc/surveys/tests/fixtures/pashub_rdsap_site_notes_example2.json @@ -0,0 +1,330 @@ +{ + "inspection_metadata": { + "inspection_surveyor": "test", + "email_address": "test@test.com", + "report_reference": "6EA2A86D-94CE-4792-8D49-AB495C744EDD", + "created_on": "2025-11-10", + "date_of_inspection": "2025-09-25", + "property_address": "test", + "property_photo": true + }, + "general": { + "epc_checked_before_assessment": true, + "epc_exists_at_point_of_assessment": false, + "inspection_date": "2025-09-25", + "transaction_type": "Grant-Scheme (ECO, RHI, etc.)", + "tenure": "Rented Social", + "property_type": "House", + "detachment_type": "Mid-terrace", + "number_of_storeys": 2, + "terrain_type": "Suburban", + "number_of_extensions": 1, + "electricity_smart_meter": true, + "electric_meter_type": "Single", + "dwelling_export_capable": true, + "mains_gas_available": true, + "gas_smart_meter": true, + "gas_meter_accessible": true, + "measurements_location": "Internal" + }, + "building_construction": { + "main_building": { + "age_range": "1950-1966", + "age_indicators": "local knowledge, enquiries of owner", + "walls_construction_type": "Cavity", + "cavity_construction_indicators": "wall thickness over 270 mm", + "walls_insulation_type": "Filled Cavity", + "filled_cavity_indicators": "evidence of cavity fill drill holes", + "thermal_conductivity_of_wall_insulation": "Unknown", + "wall_u_value_known": false, + "wall_thickness_mm": 310, + "party_wall_construction_type": "Cavity Masonry, Filled" + }, + "extensions": [ + { + "id": 1, + "age_range": "2003-2006", + "age_indicators": "local knowledge, enquiries of owner", + "walls_construction_type": "Cavity", + "cavity_construction_indicators": "wall thickness over 270 mm", + "walls_insulation_type": "As built", + "thermal_conductivity_of_wall_insulation": "Unknown", + "wall_u_value_known": false, + "wall_thickness_mm": 310, + "party_wall_construction_type": "Cavity Masonry, Filled" + } + ], + "floor": { + "floor_type": "Ground Floor", + "floor_construction": "Solid", + "floor_insulation_type": "As Built", + "floor_u_value_known": false + } + }, + "building_measurements": { + "main_building": { + "floors": [ + { + "name": "Floor 1", + "area_m2": 35.68, + "height_m": 2.19, + "heat_loss_perimeter_m": 13.44, + "pwl_m": 10.62 + }, + { + "name": "Floor 0", + "area_m2": 35.68, + "height_m": 2.17, + "heat_loss_perimeter_m": 11.0, + "pwl_m": 10.62 + } + ] + }, + "extensions": [ + { + "id": 1, + "floors": [ + { + "name": "Floor 0", + "area_m2": 3.8, + "height_m": 2.0, + "heat_loss_perimeter_m": 5.7, + "pwl_m": 0.0 + } + ] + } + ] + }, + "roof_space": { + "main_building": { + "construction_type": "Pitched roof (Slates or tiles), Access to loft", + "insulation_at": "Joists", + "roof_u_value_known": false, + "insulation_thickness_mm": 100, + "cavity_wall_construction_indicators": "cavity visible in roof space", + "rooms_in_roof": false + }, + "extensions": [ + { + "id": 1, + "construction_type": "Pitched roof, Sloping ceiling", + "insulation_at": "Sloping ceiling insulation", + "roof_u_value_known": false, + "insulation_thickness": "As built", + "cavity_wall_construction_indicators": "No indicator of construction visible", + "rooms_in_roof": false + } + ] + }, + "windows": [ + { + "id": 1, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.2, + "width_m": 2.3, + "orientation": "North West" + }, + { + "id": 2, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.2, + "width_m": 1.0, + "orientation": "North West" + }, + { + "id": 3, + "location": "Main Building", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 1.0, + "orientation": "North East" + }, + { + "id": 4, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 1.0, + "orientation": "North" + }, + { + "id": 5, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 1.7, + "orientation": "North East" + }, + { + "id": 6, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 2.3, + "orientation": "North West" + }, + { + "id": 7, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 1.0, + "width_m": 1.2, + "orientation": "North West" + }, + { + "id": 8, + "location": "Extension 1", + "wall_type": "External wall", + "glazing_type": "Double glazing, Unknown install date", + "window_type": "Window", + "frame_type": "Wooden or PVC", + "glazing_gap": "16 mm or more", + "draught_proofed": true, + "permanent_shutters": false, + "height_m": 0.9, + "width_m": 1.0, + "orientation": "North East" + } + ], + "heating_and_hot_water": { + "main_heating": { + "selection_method": "PCDF Search", + "system_type": "Boiler with radiators or underfloor heating", + "product_id": 16839, + "manufacturer": "Vaillant", + "model": "ecoTEC pro 28", + "orig_manufacturer": "Vaillant", + "fuel": "Mains gas", + "summer_efficiency": 0, + "type": "Combi", + "condensing": true, + "year": "2005 - 2015", + "mount": "Wall", + "open_flue": "Room-sealed", + "fan_assist": true, + "status": "Normal status for an actual product", + "central_heating_pump_age": "Unknown", + "controls": "Programmer, room thermostat and TRVs", + "flue_gas_heat_recovery_system": false, + "weather_compensator": false, + "emitter": "Radiators", + "emitter_temperature": "Unknown" + }, + "secondary_heating": { + "secondary_fuel": "No Secondary Heating" + }, + "water_heating": { + "type": "Regular", + "system": "From main heating 1", + "cylinder_size": "No Cylinder", + "cylinder_measured_heat_loss": null, + "insulation_type": null, + "insulation_thickness_mm": null, + "has_thermostat": null + } + }, + "ventilation": { + "ventilation_type": "Mechanical Extract - Decentralised", + "ventilation_in_pcdf_database": false, + "has_fixed_air_conditioning": false, + "number_of_open_flues": 0, + "number_of_closed_flues": 0, + "number_of_boiler_flues": 0, + "number_of_other_flues": 0, + "number_of_extract_fans": 0, + "number_of_passive_vents": 0, + "number_of_flueless_gas_fires": 0, + "pressure_test": "No test", + "draught_lobby": false + }, + "conservatories": { + "has_conservatory": false + }, + "renewables": { + "wind_turbines": false, + "solar_hot_water": false, + "photovoltaic_array": false, + "number_of_pv_batteries": 0, + "hydro": false + }, + "room_count_elements": { + "number_of_habitable_rooms": 3, + "any_unheated_rooms": false, + "number_of_heated_rooms": null, + "number_of_external_doors": 2, + "number_of_insulated_external_doors": 0, + "number_of_draughtproofed_external_doors": 2, + "number_of_open_chimneys": 0, + "number_of_blocked_chimneys": 0, + "number_of_fixed_incandescent_bulbs": 4, + "exact_led_cfl_count_known": true, + "number_of_fixed_led_bulbs": 0, + "number_of_fixed_cfl_bulbs": 1, + "waste_water_heat_recovery": "None" + }, + "water_use": { + "number_of_baths": 1, + "number_of_special_features": 0, + "showers": [ + { + "id": 1, + "outlet_type": "Non-Electric Shower" + } + ] + }, + "customer_response": { + "customer_present": true, + "willing_to_answer_satisfaction_survey": false + }, + "addendum": { + "addendum": "None", + "related_party_disclosure": "No related party", + "hard_to_treat_cavity_access_issues": false, + "hard_to_treat_cavity_high_exposure": false, + "hard_to_treat_cavity_narrow_cavities": false + } +} diff --git a/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py b/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py new file mode 100644 index 00000000..d89f989d --- /dev/null +++ b/datatypes/epc/surveys/tests/test_pashub_rdsap_site_notes_loading.py @@ -0,0 +1,366 @@ +import json +import os +from typing import Any, Dict + +import pytest + +from datatypes.epc.schema.tests.helpers import from_dict +from datatypes.epc.surveys.pashub_rdsap_site_notes import ( + ExtensionConstruction, + ExtensionMeasurements, + ExtensionRoofSpace, + PasHubRdSapSiteNotes, +) + +FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") + + +def load(filename: str) -> Dict[str, Any]: + with open(os.path.join(FIXTURES, filename)) as f: + return json.load(f) # type: ignore[no-any-return] + + +class TestExample1: + """No extensions; regular boiler with hot water cylinder; natural ventilation.""" + + @pytest.fixture + def survey(self) -> PasHubRdSapSiteNotes: + return from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json") + ) + + # --- inspection_metadata --- + + def test_report_reference(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.inspection_metadata.report_reference + == "49D422A9-0779-44DD-9665-464D35DFF1A8" + ) + + def test_created_on(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.inspection_metadata.created_on == "2026-03-31" + + def test_property_photo_absent(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.inspection_metadata.property_photo is None + + # --- general --- + + def test_transaction_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.transaction_type == "None of the Above" + + def test_number_of_extensions(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.number_of_extensions == 0 + + def test_smart_meters(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.electricity_smart_meter is True + assert survey.general.gas_smart_meter is True + + # --- building_construction --- + + def test_main_building_wall_thickness(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_construction.main_building.wall_thickness_mm == 280 + + def test_main_building_walls_insulation_type( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert ( + survey.building_construction.main_building.walls_insulation_type + == "As built" + ) + + def test_filled_cavity_indicators_absent( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert ( + survey.building_construction.main_building.filled_cavity_indicators is None + ) + + def test_no_extensions_in_construction(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_construction.extensions is None + + def test_floor_construction(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.building_construction.floor.floor_construction + == "Suspended, not timber" + ) + + # --- building_measurements --- + + def test_main_building_floor_count(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.building_measurements.main_building.floors) == 2 + + def test_floor_area(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_measurements.main_building.floors[0].area_m2 == 24.78 + + def test_no_extension_measurements(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_measurements.extensions is None + + # --- roof_space --- + + def test_roof_insulation_thickness_mm(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.roof_space.main_building.insulation_thickness_mm == 100 + + def test_roof_insulation_thickness_string_absent( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.roof_space.main_building.insulation_thickness is None + + def test_no_extension_roof_spaces(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.roof_space.extensions is None + + def test_rooms_in_roof_false(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.roof_space.main_building.rooms_in_roof is False + + # --- windows --- + + def test_window_count(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.windows) == 4 + + def test_window_dimensions(self, survey: PasHubRdSapSiteNotes) -> None: + w = survey.windows[0] + assert w.height_m == 1.36 + assert w.width_m == 1.0 + + def test_window_orientation(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.windows[0].orientation == "South East" + assert survey.windows[2].orientation == "North West" + + def test_window_glazing_gap(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.windows[0].glazing_gap == "16 mm or more" + + # --- heating_and_hot_water --- + + def test_main_heating_manufacturer(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.manufacturer == "Vaillant" + + def test_main_heating_model(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.model == "ecoFIT sustain 415" + + def test_main_heating_product_id(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.product_id == 18400 + + def test_main_heating_type_regular(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.type == "Regular" + + def test_water_heating_cylinder_present(self, survey: PasHubRdSapSiteNotes) -> None: + wh = survey.heating_and_hot_water.water_heating + assert wh.cylinder_size == "Normal (90-130 litres)" + assert wh.insulation_type == "Factory fitted" + assert wh.insulation_thickness_mm == 12 + assert wh.has_thermostat is True + + def test_secondary_heating_none(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.heating_and_hot_water.secondary_heating.secondary_fuel + == "No Secondary Heating" + ) + + # --- ventilation --- + + def test_ventilation_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.ventilation_type == "Natural" + + def test_ventilation_pcdf_absent(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.ventilation_in_pcdf_database is None + + def test_extract_fans(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.number_of_extract_fans == 2 + + # --- room_count_elements --- + + def test_habitable_rooms(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_habitable_rooms == 2 + + def test_heated_rooms_zero(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_heated_rooms == 0 + + def test_led_bulbs(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_fixed_led_bulbs == 5 + + def test_cfl_bulbs(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_fixed_cfl_bulbs == 4 + + # --- water_use --- + + def test_shower_outlet_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.water_use.showers) == 1 + assert survey.water_use.showers[0].outlet_type == "Non-Electric Shower" + + # --- addendum --- + + def test_addendum_value(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.addendum.addendum == "PV Recommended" + + def test_related_party_disclosure(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.addendum.related_party_disclosure == "No related party" + + def test_hard_to_treat_flags_false(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.addendum.hard_to_treat_cavity_access_issues is False + assert survey.addendum.hard_to_treat_cavity_high_exposure is False + assert survey.addendum.hard_to_treat_cavity_narrow_cavities is False + + +class TestExample2: + """With extensions; combi boiler (no cylinder); mechanical extract ventilation.""" + + @pytest.fixture + def survey(self) -> PasHubRdSapSiteNotes: + return from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example2.json") + ) + + # --- inspection_metadata --- + + def test_report_reference(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.inspection_metadata.report_reference + == "6EA2A86D-94CE-4792-8D49-AB495C744EDD" + ) + + def test_property_photo_present(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.inspection_metadata.property_photo is True + + def test_created_on_differs_from_inspection_date( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.inspection_metadata.created_on == "2025-11-10" + assert survey.inspection_metadata.date_of_inspection == "2025-09-25" + + # --- general --- + + def test_number_of_extensions(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.number_of_extensions == 1 + + def test_transaction_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.general.transaction_type == "Grant-Scheme (ECO, RHI, etc.)" + + # --- building_construction --- + + def test_main_building_filled_cavity_indicators( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert ( + survey.building_construction.main_building.filled_cavity_indicators + == "evidence of cavity fill drill holes" + ) + + def test_extension_construction_present(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_construction.extensions is not None + assert len(survey.building_construction.extensions) == 1 + + def test_extension_construction_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_construction.extensions is not None + ext: ExtensionConstruction = survey.building_construction.extensions[0] + assert ext.id == 1 + assert ext.walls_insulation_type == "As built" + assert ext.wall_thickness_mm == 310 + + def test_extension_no_filled_cavity_indicators( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.building_construction.extensions is not None + assert ( + survey.building_construction.extensions[0].filled_cavity_indicators is None + ) + + # --- building_measurements --- + + def test_main_building_floor_count(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.building_measurements.main_building.floors) == 2 + + def test_extension_measurements_present(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_measurements.extensions is not None + assert len(survey.building_measurements.extensions) == 1 + + def test_extension_floor_area(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.building_measurements.extensions is not None + ext: ExtensionMeasurements = survey.building_measurements.extensions[0] + assert ext.id == 1 + assert len(ext.floors) == 1 + assert ext.floors[0].area_m2 == 3.8 + + # --- roof_space --- + + def test_main_roof_insulation_thickness_mm( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.roof_space.main_building.insulation_thickness_mm == 100 + + def test_extension_roof_spaces_present(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.roof_space.extensions is not None + assert len(survey.roof_space.extensions) == 1 + + def test_extension_roof_uses_string_thickness( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.roof_space.extensions is not None + ext: ExtensionRoofSpace = survey.roof_space.extensions[0] + assert ext.insulation_thickness == "As built" + assert ext.insulation_thickness_mm is None + + def test_extension_roof_construction_type( + self, survey: PasHubRdSapSiteNotes + ) -> None: + assert survey.roof_space.extensions is not None + assert ( + survey.roof_space.extensions[0].construction_type + == "Pitched roof, Sloping ceiling" + ) + + # --- windows --- + + def test_window_count(self, survey: PasHubRdSapSiteNotes) -> None: + assert len(survey.windows) == 8 + + def test_extension_windows(self, survey: PasHubRdSapSiteNotes) -> None: + extension_windows = [w for w in survey.windows if w.location == "Extension 1"] + assert len(extension_windows) == 5 + + def test_window_ids_sequential(self, survey: PasHubRdSapSiteNotes) -> None: + ids = [w.id for w in survey.windows] + assert ids == list(range(1, 9)) + + # --- heating_and_hot_water --- + + def test_main_heating_type_combi(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.type == "Combi" + + def test_main_heating_model(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.heating_and_hot_water.main_heating.model == "ecoTEC pro 28" + + def test_water_heating_no_cylinder(self, survey: PasHubRdSapSiteNotes) -> None: + wh = survey.heating_and_hot_water.water_heating + assert wh.cylinder_size == "No Cylinder" + assert wh.cylinder_measured_heat_loss is None + assert wh.insulation_type is None + assert wh.insulation_thickness_mm is None + assert wh.has_thermostat is None + + # --- ventilation --- + + def test_ventilation_type(self, survey: PasHubRdSapSiteNotes) -> None: + assert ( + survey.ventilation.ventilation_type == "Mechanical Extract - Decentralised" + ) + + def test_ventilation_in_pcdf_database(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.ventilation_in_pcdf_database is False + + def test_no_extract_fans_for_mev(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.ventilation.number_of_extract_fans == 0 + + # --- room_count_elements --- + + def test_habitable_rooms(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_habitable_rooms == 3 + + def test_heated_rooms_null(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_heated_rooms is None + + def test_incandescent_bulbs(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.room_count_elements.number_of_fixed_incandescent_bulbs == 4 + + # --- addendum --- + + def test_addendum_none(self, survey: PasHubRdSapSiteNotes) -> None: + assert survey.addendum.addendum == "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) diff --git a/pytest.ini b/pytest.ini index 4a5327c1..55c2873a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,6 +3,6 @@ pythonpath = . log_cli = true log_cli_level = INFO addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial -testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests +testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests markers = integration: mark a test as an integration test