define new domain object

This commit is contained in:
Daniel Roth 2026-04-13 16:18:17 +00:00
parent 44df08b549
commit fa3e276dc4
9 changed files with 654 additions and 155 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -0,0 +1,336 @@
from dataclasses import dataclass
from datetime import date
from typing import Any, List, Optional, Union
from datatypes.epc.domain.epc import Epc
@dataclass
class EnergyElement:
# description is a plain string in schema 21.0.0 (no longer a localised object)
description: str
energy_efficiency_rating: int
environmental_efficiency_rating: int
@dataclass
class InstantaneousWwhrs:
wwhrs_index_number1: Optional[int] = None
wwhrs_index_number2: Optional[int] = None
@dataclass
class MainHeatingDetail:
has_fghrs: bool
main_fuel_type: int # TODO: make enum?
heat_emitter_type: int # TODO: make enum?
emitter_temperature: Union[int, str]
main_heating_number: int
main_heating_control: int
main_heating_category: int
main_heating_fraction: int
main_heating_data_source: int
fan_flue_present: bool
boiler_flue_type: Optional[int] = None # TODO: make enum?
boiler_ignition_type: Optional[int] = None # TODO: make enum?
central_heating_pump_age: Optional[int] = None
main_heating_index_number: Optional[int] = None
sap_main_heating_code: Optional[int] = None # TODO: make enum?
@dataclass
class ShowerOutlet:
shower_wwhrs: int
shower_outlet_type: int
@dataclass
class ShowerOutlets:
# TODO: consolidate ShowerOutlet and ShowerOutlets
shower_outlet: ShowerOutlet
@dataclass
class SapHeating:
cylinder_size: int
water_heating_code: int # TODO: make enum?
water_heating_fuel: int # TODO: make enum?
instantaneous_wwhrs: InstantaneousWwhrs
main_heating_details: List[MainHeatingDetail]
immersion_heating_type: Union[int, str]
has_fixed_air_conditioning: str
shower_outlets: Optional[ShowerOutlets] = None
cylinder_insulation_type: Optional[int] = None
cylinder_thermostat: Optional[str] = None
secondary_fuel_type: Optional[int] = None
secondary_heating_type: Optional[int] = None
cylinder_insulation_thickness: Optional[int] = None
@dataclass
class WindowTransmissionDetails:
u_value: float
data_source: int
solar_transmittance: float
@dataclass
class SapWindow:
pvc_frame: str
glazing_gap: int
orientation: int
window_type: int
frame_factor: float
glazing_type: int
window_width: float
window_height: float
draught_proofed: str
window_location: int
window_wall_type: int
permanent_shutters_present: str
window_transmission_details: WindowTransmissionDetails
permanent_shutters_insulated: str
@dataclass
class PvBattery:
battery_capacity: float
@dataclass
class PvBatteries:
pv_battery: PvBattery
@dataclass
class WindTurbineDetails:
hub_height: float
rotor_diameter: float
@dataclass
class PhotovoltaicSupplyNoneOrNoDetails:
percent_roof_area: int
@dataclass
class PhotovoltaicSupply:
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
@dataclass
class SapEnergySource:
mains_gas: bool
meter_type: str # int in API, str (e.g. "Single") in site notes
pv_battery_count: int
wind_turbines_count: int
gas_smart_meter_present: bool
is_dwelling_export_capable: bool
wind_turbines_terrain_type: str # int in API, str (e.g. "Suburban") in site notes
electricity_smart_meter_present: bool
pv_connection: Optional[int] = None
photovoltaic_supply: Optional[PhotovoltaicSupply] = None
wind_turbine_details: Optional[WindTurbineDetails] = None
pv_batteries: Optional[PvBatteries] = None
@dataclass
class SapFloorDimension:
room_height_m: float
total_floor_area_m2: float
party_wall_length_m: float
heat_loss_perimeter_m: float
floor: Optional[int] = None
floor_insulation: Optional[int] = None
floor_construction: Optional[int] = None
@dataclass
class SapRoomInRoof:
floor_area: Union[int, float]
construction_age_band: str
@dataclass
class SapAlternativeWall:
wall_area: float
wall_dry_lined: str
wall_construction: int
wall_insulation_type: int
wall_thickness_measured: str
wall_insulation_thickness: Optional[str] = None
@dataclass
class SapBuildingPart:
# General
identifier: str # e.g. "main", "roof"
construction_age_band: str
# Wall
wall_construction: int
wall_insulation_type: int
wall_thickness_measured: bool
party_wall_construction: Union[int, str]
# Floor
sap_floor_dimensions: List[
SapFloorDimension
] # Not included in site notes; should this be optional?
# Optional
building_part_number: Optional[int] = (
None # Not sure how we get this from site notes
)
wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes
wall_thickness_mm: Optional[int] = None
wall_insulation_thickness: Optional[str] = None
sap_alternative_wall_1: Optional[SapAlternativeWall] = None
sap_alternative_wall_2: Optional[SapAlternativeWall] = None
floor_heat_loss: Optional[int] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None
roof_construction: Optional[int] = None
roof_insulation_location: Optional[Union[int, str]] = None
roof_insulation_thickness: Optional[Union[str, int]] = None
sap_room_in_roof: Optional[SapRoomInRoof] = None
@dataclass
class WindowsTransmissionDetails:
u_value: float
data_source: int
solar_transmittance: float
@dataclass
class SapFlatDetails:
level: int
top_storey: str
flat_location: int
heat_loss_corridor: int
storey_count: Optional[int] = None
unheated_corridor_length_m: Optional[int] = None
@dataclass
class EpcPropertyData:
# General
assessment_type: str # TODO: make enum?
sap_version: float # Optional?
dwelling_type: str # TODO: make enum?
uprn: int
address_line_1: str
postcode: str
post_town: str
inspection_date: date
status: str
tenure: int # How does this map to string?
transaction_type: int # What is this?
# Elements
roofs: List[EnergyElement]
walls: List[EnergyElement]
floors: List[EnergyElement]
main_heating: List[EnergyElement]
window: EnergyElement
lighting: EnergyElement
hot_water: EnergyElement
door_count: int
sap_heating: SapHeating
sap_windows: List[SapWindow]
sap_energy_source: SapEnergySource
sap_building_parts: List[SapBuildingPart]
solar_water_heating: bool
has_hot_water_cylinder: bool # must be inferred when mapping from site notes
has_fixed_air_conditioning: bool
# Counts
wet_rooms_count: int # If this isn't provided, should it be 0 or None?
extensions_count: int # If this isn't provided, should it be 0 or None?
heated_rooms_count: int # If this isn't provided, should it be 0 or None?
open_chimneys_count: int
habitable_rooms_count: int
insulated_door_count: (
int # Called "number_of_insulated_external_doors" in site notes; same thing?
)
cfl_fixed_lighting_bulbs_count: int
led_fixed_lighting_bulbs_count: int
incandescent_fixed_lighting_bulbs_count: int
# Measurements
total_floor_area_m2: int
# Optional fields
schema_type: Optional[str] = None
schema_versions_original: Optional[str] = None
report_type: Optional[str] = None # TODO: make enum?
uprn_source: Optional[str] = None
address_line_2: Optional[str] = None
region_code: Optional[str] = None # TODO: make enum?
country_code: Optional[str] = None
built_form: Optional[str] = None # TODO: make enum?
property_type: Optional[str] = None
pressure_test: Optional[int] = None
language_code: Optional[str] = None
completion_date: Optional[date] = None
registration_date: Optional[date] = None
measurement_type: Optional[int] = None # What is this?
conservatory_type: Optional[int] = (
None # What is this? site notes have "has_conservatory" flag
)
has_heated_separate_conservatory: Optional[bool] = None
secondary_heating: Optional[EnergyElement] = (
None # For site notes, secondary_fuel maps to sap_heating.secondary_fuel_type
)
blocked_chimneys_count: Optional[int] = None
energy_rating_average: Optional[int] = None
main_heating_controls: Optional[EnergyElement] = (
None # site notes has heating_and_hot_water.main_heating.controls: str - doesn't map to EnergyElement
)
current_energy_efficiency_band: Optional[Epc] = None # not available in site notes?
environmental_impact_current: Optional[int] = None
heating_cost_current: Optional[float] = None
co2_emissions_current: Optional[float] = None
energy_consumption_current: Optional[int] = None
energy_rating_current: Optional[int] = None
lighting_cost_current: Optional[float] = None
hot_water_cost_current: Optional[float] = None
insulated_door_u_value: Optional[float] = None # Not available in site notes
mechanical_ventilation: Optional[int] = (
None # ventilation details present in site notes, but I'm not sure they correspond directly to the integers returned by the API here
)
percent_draughtproofed: Optional[int] = (
None # Site notes have draught_proofed: bool field for each window, can we use that to infer percentage?
)
heating_cost_potential: Optional[float] = None
co2_emissions_potential: Optional[float] = None
energy_consumption_potential: Optional[int] = None
energy_rating_potential: Optional[float] = None
lighting_cost_potential: Optional[float] = None
hot_water_cost_potential: Optional[float] = None
environmental_impact_potential: Optional[int] = None
potential_energy_efficiency_band: Optional[Epc] = (
None # not available in site notes
)
# renewable_heat_incentive: Optional[Any] = None # Not sure what this is, skip for now
draughtproofed_door_count: Optional[int] = None
mechanical_vent_duct_type: Optional[int] = None
windows_transmission_details: Optional[WindowsTransmissionDetails] = None
multiple_glazed_propertion: Optional[int] = None
calculation_software_version: Optional[str] = None # Do we care about this?
mechanical_vent_duct_placement: Optional[int] = None
mechanical_vent_duct_insulation: Optional[int] = None
pressure_test_certificate_number: Optional[int] = None
mechanical_ventilation_index_number: Optional[int] = None
mechanical_vent_measured_installation: Optional[str] = None
co2_emissions_current_per_floor_area: Optional[int] = None
low_energy_fixed_lighting_bulbs_count: Optional[int] = None
sap_flat_details: Optional[SapFlatDetails] = None
# survey_addendum: Optional[Any] = None # not sure how to handle, skip for now
fixed_lighting_outlets_count: Optional[int] = None
low_energy_fixed_lighting_outlets_count: Optional[int] = None

View file

@ -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)