From 1ac5cb253a9d15cf0d535e332faefdaa997fcbb9 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 31 Mar 2026 14:47:52 +0000 Subject: [PATCH 01/93] make the fix for module name --- infrastructure/terraform/lambda/hubspot_deal_etl/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf index 051c7154..5a529e85 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf @@ -39,6 +39,6 @@ module "hubspot_deal_etl" { } resource "aws_iam_role_policy_attachment" "lambda_s3_policy" { - role = module.lambda.role_name + role = module.hubspot_deal_etl.role_name policy_arn = data.terraform_remote_state.shared.outputs.hubspot_etl_s3_read_and_write_arn } \ No newline at end of file From ea84cf9fd4ae21d938598cc9043b19df67c35298 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 31 Mar 2026 15:51:43 +0000 Subject: [PATCH 02/93] added bulk --- backend/app/db/models/organisation.py | 24 ++++ etl/hubspot/hubspotClient.py | 31 ++++- etl/hubspot/hubspotDataTodB.py | 154 +++++++++++++++++++++-- etl/hubspot/scripts/scraper/bulk_load.py | 33 +++++ etl/hubspot/scripts/scraper/main.py | 2 +- sfr/principal_pitch/2_export_data.py | 8 +- 6 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 etl/hubspot/scripts/scraper/bulk_load.py diff --git a/backend/app/db/models/organisation.py b/backend/app/db/models/organisation.py index e8649cdd..a9718c42 100644 --- a/backend/app/db/models/organisation.py +++ b/backend/app/db/models/organisation.py @@ -40,6 +40,30 @@ class HubspotDealData(SQLModel, table=True): coordination_status: Optional[str] = Field(default=None) design_status: Optional[str] = Field(default=None) + listing_id: Optional[str] = Field(default=None) + pashub_link: Optional[str] = Field(default=None) + sharepoint_link: Optional[str] = Field(default=None) + dampmould_growth: Optional[str] = Field(default=None) + pre_sap: Optional[str] = Field(default=None) + coordinator: Optional[str] = Field(default=None) + mtp_completion_date: Optional[datetime] = Field(default=None) + mtp_re_model_completion_date: Optional[datetime] = Field(default=None) + ioe_v3_completion_date: Optional[datetime] = Field(default=None) + proposed_measures: Optional[str] = Field(default=None) + approved_package: Optional[str] = Field(default=None) + designer: Optional[str] = Field(default=None) + design_completion_date: Optional[datetime] = Field(default=None) + actual_measures_installed: Optional[str] = Field(default=None) + installer: Optional[str] = Field(default=None) + installer_handover: Optional[str] = Field(default=None) + lodgement_status: Optional[str] = Field(default=None) + measures_lodgement_date: Optional[datetime] = Field(default=None) + lodgement_date: Optional[datetime] = Field(default=None) + expected_commencement_date: Optional[datetime] = Field(default=None) + surveyor: Optional[str] = Field(default=None) + confirmed_survey_date: Optional[datetime] = Field(default=None) + confirmed_survey_time: Optional[str] = Field(default=None) + created_at: datetime = Field( sa_column=Column( DateTime(timezone=True), diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index 8bbe8a63..e5461c61 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -189,6 +189,7 @@ class HubspotClient: ) listing_info: dict[str, str] = cast(dict[str, str], listing.properties) # type: ignore[reportUnknownMemberType] + listing_info["listing_id"] = listing_id self.logger.info(f"Listing info for deal {deal_id}: {listing_info}") return listing_info @@ -201,13 +202,35 @@ class HubspotClient: "dealname", "dealstage", "pipeline", - "outcome", # outcome, - "outcome_notes", # outcome notes + "outcome", + "outcome_notes", "project_code", "major_condition_issue_description", "major_condition_issue_photos", - "coordination_status__stage_1_", # Coordiantion Status (Stage 1), - "retrofit_design_status", # Retrofit Design Status + "coordination_status__stage_1_", + "retrofit_design_status", + "pashub_link", + "sharepoint_link", + "dampmould_growth", + "pre_sap", + "coordinator", + "mtp_completion_date", + "mtp_re_model_completion_date", + "ioe_v3_completion_date", + "proposed_measures", + "approved_package", + "designer", + "design_completion_date", + "actual_measures_installed", + "installer", + "installer_handover", + "lodgement_status", + "measures_lodgement_date", + "lodgement_date", + "expected_commencement_date", + "surveyor", + "confirmed_survey_date", + "confirmed_survey_time", ], ) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index f7f79e46..38ec3e35 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -2,7 +2,7 @@ from backend.app.db.connection import db_read_session from backend.app.db.models.organisation import Organisation, HubspotDealData from sqlmodel import select from datetime import datetime, timezone -from typing import TypedDict +from typing import TypedDict, Optional from etl.hubspot.s3_uploader import S3Uploader import hashlib import os @@ -82,6 +82,14 @@ class HubspotDataToDb: .one_or_none() ) + def _parse_hs_date(self, value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + def _sha256(self, file_path: str) -> str: """Compute SHA-256 checksum of a file.""" sha256 = hashlib.sha256() @@ -114,6 +122,10 @@ class HubspotDataToDb: deal_in_db.deal_id == hs_deal.get("hs_object_id"), "deal_id mismatch" ), soft_assert(deal_in_db.company_id == hs_company_id, "company_id mismatch"), + soft_assert( + deal_in_db.listing_id == hs_listing.get("listing_id"), + "listing_id mismatch", + ), soft_assert( deal_in_db.landlord_property_id == hs_listing.get("owner_property_id"), "landlord_property_id mismatch", @@ -157,6 +169,94 @@ class HubspotDataToDb: deal_in_db.design_status == hs_deal.get("retrofit_design_status"), "retrofit design mismatch", ), + soft_assert( + deal_in_db.pashub_link == hs_deal.get("pashub_link"), + "pashub_link mismatch", + ), + soft_assert( + deal_in_db.sharepoint_link == hs_deal.get("sharepoint_link"), + "sharepoint_link mismatch", + ), + soft_assert( + deal_in_db.dampmould_growth == hs_deal.get("dampmould_growth"), + "dampmould_growth mismatch", + ), + soft_assert( + deal_in_db.pre_sap == hs_deal.get("pre_sap"), + "pre_sap mismatch", + ), + soft_assert( + deal_in_db.coordinator == hs_deal.get("coordinator"), + "coordinator mismatch", + ), + soft_assert( + deal_in_db.mtp_completion_date == self._parse_hs_date(hs_deal.get("mtp_completion_date")), + "mtp_completion_date mismatch", + ), + soft_assert( + deal_in_db.mtp_re_model_completion_date == self._parse_hs_date(hs_deal.get("mtp_re_model_completion_date")), + "mtp_re_model_completion_date mismatch", + ), + soft_assert( + deal_in_db.ioe_v3_completion_date == self._parse_hs_date(hs_deal.get("ioe_v3_completion_date")), + "ioe_v3_completion_date mismatch", + ), + soft_assert( + deal_in_db.proposed_measures == hs_deal.get("proposed_measures"), + "proposed_measures mismatch", + ), + soft_assert( + deal_in_db.approved_package == hs_deal.get("approved_package"), + "approved_package mismatch", + ), + soft_assert( + deal_in_db.designer == hs_deal.get("designer"), + "designer mismatch", + ), + soft_assert( + deal_in_db.design_completion_date == self._parse_hs_date(hs_deal.get("design_completion_date")), + "design_completion_date mismatch", + ), + soft_assert( + deal_in_db.actual_measures_installed == hs_deal.get("actual_measures_installed"), + "actual_measures_installed mismatch", + ), + soft_assert( + deal_in_db.installer == hs_deal.get("installer"), + "installer mismatch", + ), + soft_assert( + deal_in_db.installer_handover == hs_deal.get("installer_handover"), + "installer_handover mismatch", + ), + soft_assert( + deal_in_db.lodgement_status == hs_deal.get("lodgement_status"), + "lodgement_status mismatch", + ), + soft_assert( + deal_in_db.measures_lodgement_date == self._parse_hs_date(hs_deal.get("measures_lodgement_date")), + "measures_lodgement_date mismatch", + ), + soft_assert( + deal_in_db.lodgement_date == self._parse_hs_date(hs_deal.get("lodgement_date")), + "lodgement_date mismatch", + ), + soft_assert( + deal_in_db.expected_commencement_date == self._parse_hs_date(hs_deal.get("expected_commencement_date")), + "expected_commencement_date mismatch", + ), + soft_assert( + deal_in_db.surveyor == hs_deal.get("surveyor"), + "surveyor mismatch", + ), + soft_assert( + deal_in_db.confirmed_survey_date == self._parse_hs_date(hs_deal.get("confirmed_survey_date")), + "confirmed_survey_date mismatch", + ), + soft_assert( + deal_in_db.confirmed_survey_time == hs_deal.get("confirmed_survey_time"), + "confirmed_survey_time mismatch", + ), ] # If discrepancies found, update from HubSpot @@ -238,6 +338,7 @@ class HubspotDataToDb: for attr, value in { "dealname": deal_data.get("dealname"), "dealstage": deal_data.get("dealstage"), + "listing_id": listing.get("listing_id"), "landlord_property_id": listing.get("owner_property_id"), "uprn": listing.get("national_uprn"), "outcome": deal_data.get("outcome"), @@ -250,16 +351,32 @@ class HubspotDataToDb: "major_condition_issue_photos": deal_data.get( "major_condition_issue_photos" ), - "major_condition_issue_description": deal_data.get( - "major_condition_issue_description" - ), - "major_condition_issue_photos": deal_data.get( - "major_condition_issue_photos" - ), "coordination_status": deal_data.get( "coordination_status__stage_1_" ), "design_status": deal_data.get("retrofit_design_status"), + "pashub_link": deal_data.get("pashub_link"), + "sharepoint_link": deal_data.get("sharepoint_link"), + "dampmould_growth": deal_data.get("dampmould_growth"), + "pre_sap": deal_data.get("pre_sap"), + "coordinator": deal_data.get("coordinator"), + "mtp_completion_date": self._parse_hs_date(deal_data.get("mtp_completion_date")), + "mtp_re_model_completion_date": self._parse_hs_date(deal_data.get("mtp_re_model_completion_date")), + "ioe_v3_completion_date": self._parse_hs_date(deal_data.get("ioe_v3_completion_date")), + "proposed_measures": deal_data.get("proposed_measures"), + "approved_package": deal_data.get("approved_package"), + "designer": deal_data.get("designer"), + "design_completion_date": self._parse_hs_date(deal_data.get("design_completion_date")), + "actual_measures_installed": deal_data.get("actual_measures_installed"), + "installer": deal_data.get("installer"), + "installer_handover": deal_data.get("installer_handover"), + "lodgement_status": deal_data.get("lodgement_status"), + "measures_lodgement_date": self._parse_hs_date(deal_data.get("measures_lodgement_date")), + "lodgement_date": self._parse_hs_date(deal_data.get("lodgement_date")), + "expected_commencement_date": self._parse_hs_date(deal_data.get("expected_commencement_date")), + "surveyor": deal_data.get("surveyor"), + "confirmed_survey_date": self._parse_hs_date(deal_data.get("confirmed_survey_date")), + "confirmed_survey_time": deal_data.get("confirmed_survey_time"), }.items(): setattr(existing, attr, value or getattr(existing, attr)) @@ -302,6 +419,7 @@ class HubspotDataToDb: deal_id=deal_id, dealname=deal_data.get("dealname"), dealstage=deal_data.get("dealstage"), + listing_id=listing.get("listing_id"), landlord_property_id=listing.get("owner_property_id"), uprn=listing.get("national_uprn"), outcome=deal_data.get("outcome"), @@ -316,6 +434,28 @@ class HubspotDataToDb: ), coordination_status=deal_data.get("coordination_status__stage_1_"), design_status=deal_data.get("retrofit_design_status"), + pashub_link=deal_data.get("pashub_link"), + sharepoint_link=deal_data.get("sharepoint_link"), + dampmould_growth=deal_data.get("dampmould_growth"), + pre_sap=deal_data.get("pre_sap"), + coordinator=deal_data.get("coordinator"), + mtp_completion_date=self._parse_hs_date(deal_data.get("mtp_completion_date")), + mtp_re_model_completion_date=self._parse_hs_date(deal_data.get("mtp_re_model_completion_date")), + ioe_v3_completion_date=self._parse_hs_date(deal_data.get("ioe_v3_completion_date")), + proposed_measures=deal_data.get("proposed_measures"), + approved_package=deal_data.get("approved_package"), + designer=deal_data.get("designer"), + design_completion_date=self._parse_hs_date(deal_data.get("design_completion_date")), + actual_measures_installed=deal_data.get("actual_measures_installed"), + installer=deal_data.get("installer"), + installer_handover=deal_data.get("installer_handover"), + lodgement_status=deal_data.get("lodgement_status"), + measures_lodgement_date=self._parse_hs_date(deal_data.get("measures_lodgement_date")), + lodgement_date=self._parse_hs_date(deal_data.get("lodgement_date")), + expected_commencement_date=self._parse_hs_date(deal_data.get("expected_commencement_date")), + surveyor=deal_data.get("surveyor"), + confirmed_survey_date=self._parse_hs_date(deal_data.get("confirmed_survey_date")), + confirmed_survey_time=deal_data.get("confirmed_survey_time"), ) # Handle upload at insert time diff --git a/etl/hubspot/scripts/scraper/bulk_load.py b/etl/hubspot/scripts/scraper/bulk_load.py new file mode 100644 index 00000000..fabf8a3f --- /dev/null +++ b/etl/hubspot/scripts/scraper/bulk_load.py @@ -0,0 +1,33 @@ +from etl.hubspot.hubspotClient import HubspotClient, Companies, Pipeline +from etl.hubspot.scripts.scraper.main import handler +from tqdm import tqdm + + +PIPELINE_ID = Pipeline.OPERATIONS_SOCIAL_HOUSING.value + +companies = list([Companies.THE_GUINESS_PARTNERSHIP, Companies.SOUTHERN_HOUSING_GROUP]) + + +def bulk_load(companies: list[Companies] | None = None) -> None: + """ + Load all deals from the given companies (defaults to all Companies enum values) + into the database, filtered to the Operations/Social Housing pipeline. + """ + hubspot = HubspotClient() + targets = companies or list(Companies) + + for company in tqdm(targets, desc="Companies"): + company_id = company.value + deal_ids = hubspot.get_deal_ids_from_company(company_id) + + for deal_id in tqdm(deal_ids, desc=f"{company.name}", leave=False): + deal_data = hubspot.from_deal_id_get_info(deal_id) + if deal_data.get("pipeline") != PIPELINE_ID: + continue + + handler({"hubspot_deal_id": deal_id}, context=None) + print(f"Processed deal {deal_id} (company: {company.name})") + + +if __name__ == "__main__": + bulk_load(companies) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 48864b22..f5afef52 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -4,7 +4,7 @@ 3) [completed] Load the db and check if upsert it into the table 4) [completed]Getting working on a AWS lambda 5) [completed] subtask and tasks history -6) [TODO]The new sexy deal properties, move it over +6) [completed]The new sexy deal properties, move it over """ from etl.hubspot.hubspotClient import HubspotClient diff --git a/sfr/principal_pitch/2_export_data.py b/sfr/principal_pitch/2_export_data.py index c89560cb..fece17e0 100644 --- a/sfr/principal_pitch/2_export_data.py +++ b/sfr/principal_pitch/2_export_data.py @@ -26,13 +26,13 @@ from backend.app.db.functions.materials_functions import get_materials from collections import defaultdict from sqlalchemy import func -PORTFOLIO_ID = 639 -SCENARIOS = [1157] +PORTFOLIO_ID = 640 +SCENARIOS = [1154] scenario_names = { - 1157: "EPC C - no EWI solid floor", + 1154: "EPC - 10k Budget", } -project_name = "Instagroup Sample" +project_name = "First Charterhouse Investments" def get_data(portfolio_id, scenario_ids): From fcf25f7cac88dd5790c2fb128d3b7e95e1a98144 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 31 Mar 2026 18:46:37 +0000 Subject: [PATCH 03/93] get rid of photos from local drive when i run it locally so its less verbose --- etl/hubspot/hubspotDataTodB.py | 9 +++++++++ etl/hubspot/scripts/scraper/bulk_load.py | 21 ++++++++++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 38ec3e35..0c38f483 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -311,6 +311,9 @@ class HubspotDataToDb: f"⚠️ Failed to download/upload photo for deal_id {deal_in_db.deal_id}: {e}" ) # Continue without the file — don't crash the entire update + finally: + if "local_file" in locals() and os.path.exists(local_file): + os.remove(local_file) else: print(f"⚠️ Photo URL missing for deal_id {deal_in_db.deal_id}") @@ -405,6 +408,9 @@ class HubspotDataToDb: f"⚠️ Failed to download photo for deal_id {existing.deal_id}: {e}" ) # Continue without the file — don't crash the update + finally: + if "local_file" in locals() and os.path.exists(local_file): + os.remove(local_file) else: print(f"⚠️ Photo URL missing for deal_id {existing.deal_id}") @@ -475,6 +481,9 @@ class HubspotDataToDb: f"⚠️ Failed to download photo for deal_id {new_record.deal_id}: {e}" ) # Continue without the file — don't crash the insert + finally: + if "local_file" in locals() and os.path.exists(local_file): + os.remove(local_file) session.add(new_record) session.commit() diff --git a/etl/hubspot/scripts/scraper/bulk_load.py b/etl/hubspot/scripts/scraper/bulk_load.py index fabf8a3f..6fac23ea 100644 --- a/etl/hubspot/scripts/scraper/bulk_load.py +++ b/etl/hubspot/scripts/scraper/bulk_load.py @@ -16,17 +16,24 @@ def bulk_load(companies: list[Companies] | None = None) -> None: hubspot = HubspotClient() targets = companies or list(Companies) - for company in tqdm(targets, desc="Companies"): + for company in tqdm(targets, desc="Companies", unit="co"): company_id = company.value deal_ids = hubspot.get_deal_ids_from_company(company_id) - for deal_id in tqdm(deal_ids, desc=f"{company.name}", leave=False): - deal_data = hubspot.from_deal_id_get_info(deal_id) - if deal_data.get("pipeline") != PIPELINE_ID: - continue + processed = 0 + with tqdm(deal_ids, desc=company.name, unit="deal", leave=False) as deal_bar: + for deal_id in deal_bar: + deal_data = hubspot.from_deal_id_get_info(deal_id) + if deal_data.get("pipeline") != PIPELINE_ID: + deal_bar.set_postfix({"status": "skip", "deal": deal_id}) + continue - handler({"hubspot_deal_id": deal_id}, context=None) - print(f"Processed deal {deal_id} (company: {company.name})") + deal_bar.set_postfix({"status": "uploading", "deal": deal_id}) + handler({"hubspot_deal_id": deal_id}, context=None) + processed += 1 + deal_bar.set_postfix({"status": "done", "deal": deal_id}) + + tqdm.write(f"[{company.name}] {processed}/{len(deal_ids)} deals in pipeline") if __name__ == "__main__": From 1fee40bfefd99f7e3ff1dd6329af3a77f8723175 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 1 Apr 2026 08:03:29 +0000 Subject: [PATCH 04/93] POC: download file from ecmk using playwright --- scripts/download_ecmk_files.py | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 scripts/download_ecmk_files.py diff --git a/scripts/download_ecmk_files.py b/scripts/download_ecmk_files.py new file mode 100644 index 00000000..d1647b4f --- /dev/null +++ b/scripts/download_ecmk_files.py @@ -0,0 +1,72 @@ +import os +from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError + + +def download_report(username: str, password: str): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + + context = browser.new_context() + page = context.new_page() + + try: + # 1. Go to site + page.goto("https://assessorhub.net/", timeout=30000) + + # 2. Login (UPDATE selectors if needed) + username_input = page.locator("#Username") + password_input = page.locator("#Password") + + username_input.wait_for(state="visible", timeout=10000) + username_input.fill(username) + + password_input.wait_for(state="visible", timeout=10000) + password_input.fill(password) + + # 3. Submit login + with page.expect_navigation(timeout=15000): + page.click("button[type='submit']") + + # 4. Verify login succeeded + if "login" in page.url.lower(): + raise Exception("Login failed") + + print("Login successful:", page.url) + + # 5. Navigate to the assessment detail page + page.goto( + "https://assessorhub.net/Assessments/Assessments/Detail/1bd9fd74-08f6-4fc1-b2f7-3a13a8f9084d?returnUrl=/Companies/Assessments", + timeout=30000, + ) + + # 6. Locate the correct download button + button = page.locator("a.download-report-btn[data-report-type='11']") + + button.wait_for(state="visible", timeout=10000) + + # 7. Click and capture the download + with page.expect_download(timeout=30000) as download_info: + button.click() + + download = download_info.value + + # 8. Save file locally + filename = download.suggested_filename + save_path = os.path.join(os.getcwd(), filename) + + download.save_as(save_path) + + print(f"Downloaded file saved to: {save_path}") + + except PlaywrightTimeoutError as e: + raise Exception(f"Timeout occurred: {str(e)}") + + finally: + context.close() + browser.close() + + +if __name__ == "__main__": + email = "" + password = "" + download_report(email, password) From 3279975d02a57fdc81d0cc8a1819491dfafe019e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 1 Apr 2026 08:18:06 +0000 Subject: [PATCH 05/93] move script to ecmk_fetcher folder --- .../ecmk_fetcher/handler/handler.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) rename scripts/download_ecmk_files.py => backend/ecmk_fetcher/handler/handler.py (84%) diff --git a/scripts/download_ecmk_files.py b/backend/ecmk_fetcher/handler/handler.py similarity index 84% rename from scripts/download_ecmk_files.py rename to backend/ecmk_fetcher/handler/handler.py index d1647b4f..14ca4d4c 100644 --- a/scripts/download_ecmk_files.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -1,8 +1,21 @@ import os +from enum import Enum +from typing import Any, Mapping from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError -def download_report(username: str, password: str): +class file_download_button_types(Enum): + ASSESSOR_HUB_SITENOTE_REPORT = 11 + CERTIFICATE = 9 + SITENOTE_REPORT = 8 + RAW_XML = 7 + SAP_WORK_SHEET = 15 + + +def download_report(): + username = "" + password = "" + with sync_playwright() as p: browser = p.chromium.launch(headless=True) @@ -66,7 +79,10 @@ def download_report(username: str, password: str): browser.close() +def handler(event: Mapping[str, Any], context: Any) -> None: + download_report() + + if __name__ == "__main__": - email = "" - password = "" - download_report(email, password) + event = {"Records": [{"body": "{}"}]} + handler(event, None) From 50d3c74dee83c90358c807dbb2eef56a6db8662b Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Apr 2026 08:50:55 +0000 Subject: [PATCH 06/93] added db credentials --- infrastructure/terraform/lambda/hubspot_deal_etl/main.tf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf index 5a529e85..0f5cc6ba 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf @@ -35,6 +35,9 @@ module "hubspot_deal_etl" { LOG_LEVEL = "info" DB_USERNAME = local.db_credentials.db_assessment_model_username DB_PASSWORD = local.db_credentials.db_assessment_model_password + DB_HOST = var.db_host + DB_NAME = var.db_name + DB_PORT = var.db_port } } From 093b93ec1c2d54809dd5d2f3161f31c088cd68dd Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Apr 2026 09:58:23 +0000 Subject: [PATCH 07/93] forgot to add records for handler --- etl/hubspot/scripts/scraper/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index f5afef52..570461d7 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -13,12 +13,13 @@ from typing import Any # @subtask_handler() TODO: Do this without subtask_handler but task_handler() that creates task_id and subtask_id -def handler(body: dict[str, Any], context: Any, local: bool = False) -> None: +def handler(event: dict[str, Any], context: Any, local: bool = False) -> None: if local is True: body = { - "hubspot_deal_id": "254427203793", + "hubspot_deal_id": "409487859944", } + body = event["Records"][0]["body"] hubspot_deal_id = body.get("hubspot_deal_id", "") if hubspot_deal_id == "": From 0f892a69d989c45d219b70caa3465c4bafe58b6c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Apr 2026 10:15:20 +0000 Subject: [PATCH 08/93] forgot json --- etl/hubspot/scripts/scraper/main.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 570461d7..d7f6add2 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -10,16 +10,25 @@ from etl.hubspot.hubspotClient import HubspotClient from etl.hubspot.hubspotDataTodB import HubspotDataToDb from typing import Any +import json # @subtask_handler() TODO: Do this without subtask_handler but task_handler() that creates task_id and subtask_id -def handler(event: dict[str, Any], context: Any, local: bool = False) -> None: +def handler(body: dict[str, Any], context: Any, local: bool = False) -> None: if local is True: - body = { - "hubspot_deal_id": "409487859944", + event = { + "Records": [ + { + "body": json.dumps( + { + "hubspot_deal_id": "409487859944", + } + ) + } + ] } - body = event["Records"][0]["body"] + body = json.loads(event["Records"][0]["body"]) hubspot_deal_id = body.get("hubspot_deal_id", "") if hubspot_deal_id == "": From 60f3d25c29893cbe1cbec5b9e01f0ebd8285b250 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Apr 2026 10:18:37 +0000 Subject: [PATCH 09/93] forgot to call it eventr --- etl/hubspot/scripts/scraper/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index d7f6add2..459ea5c2 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -14,7 +14,7 @@ import json # @subtask_handler() TODO: Do this without subtask_handler but task_handler() that creates task_id and subtask_id -def handler(body: dict[str, Any], context: Any, local: bool = False) -> None: +def handler(event: dict[str, Any], context: Any, local: bool = False) -> None: if local is True: event = { "Records": [ From 8a9f51f9e6efe1a9c9d250b7f9ebe4508a869b71 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Apr 2026 10:33:06 +0000 Subject: [PATCH 10/93] include hubspot api key --- .github/workflows/_deploy_lambda.yml | 2 ++ .github/workflows/deploy_terraform.yml | 1 + infrastructure/terraform/lambda/hubspot_deal_etl/main.tf | 1 + .../terraform/lambda/hubspot_deal_etl/variables.tf | 5 +++++ 4 files changed, 9 insertions(+) diff --git a/.github/workflows/_deploy_lambda.yml b/.github/workflows/_deploy_lambda.yml index 707c9e00..009b9ff8 100644 --- a/.github/workflows/_deploy_lambda.yml +++ b/.github/workflows/_deploy_lambda.yml @@ -80,6 +80,8 @@ on: required: false TF_VAR_pashub_password: required: false + TF_VAR_hubspot_api_key: + required: false jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/deploy_terraform.yml b/.github/workflows/deploy_terraform.yml index cbcd88c4..fccc6da4 100644 --- a/.github/workflows/deploy_terraform.yml +++ b/.github/workflows/deploy_terraform.yml @@ -518,6 +518,7 @@ jobs: TF_VAR_db_host: ${{ secrets.DEV_DB_HOST }} TF_VAR_db_name: ${{ secrets.DEV_DB_NAME }} TF_VAR_db_port: ${{ secrets.DEV_DB_PORT }} + TF_VAR_hubspot_api_key: ${{ secrets.HUBSPOT_API_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} AWS_REGION: ${{ secrets.DEV_AWS_REGION }} diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf index 0f5cc6ba..6ce7a386 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf @@ -38,6 +38,7 @@ module "hubspot_deal_etl" { DB_HOST = var.db_host DB_NAME = var.db_name DB_PORT = var.db_port + HUBSPOT_API_KEY = var.hubspot_api_key } } diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf index 2e7da609..285b6a4c 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf @@ -41,6 +41,11 @@ variable "db_host" { type = string } +variable "hubspot_api_key" { + type = string +} + + variable "db_name" { type = string } From 9b88eb3a94426cc2fb577ec0dd429d69c6fd20de Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Apr 2026 10:37:26 +0000 Subject: [PATCH 11/93] added to plan and destory --- .github/workflows/_deploy_lambda.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/_deploy_lambda.yml b/.github/workflows/_deploy_lambda.yml index 009b9ff8..3a407c5a 100644 --- a/.github/workflows/_deploy_lambda.yml +++ b/.github/workflows/_deploy_lambda.yml @@ -148,6 +148,7 @@ jobs: TF_VAR_social_housing_wave_3_sharepoint_id: ${{ secrets.TF_VAR_social_housing_wave_3_sharepoint_id }} TF_VAR_pashub_email: ${{ secrets.TF_VAR_pashub_email }} TF_VAR_pashub_password: ${{ secrets.TF_VAR_pashub_password }} + TF_VAR_hubspot_api_key: ${{ secrets.TF_VAR_hubspot_api_key }} run: | ECR_REPO_URL_VAR="" if [[ -n "${{ inputs.ecr_repo }}" ]]; then @@ -193,6 +194,7 @@ jobs: TF_VAR_social_housing_wave_3_sharepoint_id: ${{ secrets.TF_VAR_social_housing_wave_3_sharepoint_id }} TF_VAR_pashub_email: ${{ secrets.TF_VAR_pashub_email }} TF_VAR_pashub_password: ${{ secrets.TF_VAR_pashub_password }} + TF_VAR_hubspot_api_key: ${{ secrets.TF_VAR_hubspot_api_key }} run: | EXTRA_VARS="" if [[ -n "${{ inputs.ecr_repo }}" ]]; then From b121413b22b0a087a7e9a126db8c992de9ec03a3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 1 Apr 2026 11:37:50 +0000 Subject: [PATCH 12/93] read address list from file and find matching rows in ecmk datatable --- backend/ecmk_fetcher/handler/handler.py | 159 +++++++++++++++--- ...n-ra-lite-programme-3103-2026-03-31-2.xlsx | Bin 0 -> 241362 bytes 2 files changed, 137 insertions(+), 22 deletions(-) create mode 100644 backend/ecmk_fetcher/hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx diff --git a/backend/ecmk_fetcher/handler/handler.py b/backend/ecmk_fetcher/handler/handler.py index 14ca4d4c..5c200ab6 100644 --- a/backend/ecmk_fetcher/handler/handler.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -1,7 +1,16 @@ import os from enum import Enum -from typing import Any, Mapping -from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError +from typing import Any, List, Mapping +from openpyxl import load_workbook +from playwright.sync_api import ( + Locator, + sync_playwright, + TimeoutError as PlaywrightTimeoutError, +) + +from utils.logger import setup_logger + +logger = setup_logger() class file_download_button_types(Enum): @@ -12,10 +21,67 @@ class file_download_button_types(Enum): SAP_WORK_SHEET = 15 +def extract_ids_from_spreadsheet(filepath: str) -> List[str]: + wb = load_workbook(filepath, data_only=True) + ws = wb["Southern RA-Lite Programme 3103"] + + ids: List[str] = [] + + header_row = 1 + id_col_index = None + + for col in range(1, ws.max_column + 1): + cell_value = ws.cell(row=header_row, column=col).value + if cell_value and str(cell_value).strip().lower() == "id": + id_col_index = col + break + + if id_col_index is None: + raise Exception("ID column not found in spreadsheet") + + for row in range(2, ws.max_row + 1): + cell_value = ws.cell(row=row, column=id_col_index).value + + if cell_value is None: + continue + + id_str = str(cell_value).strip() + + if id_str: + ids.append(id_str) + + return ids + + +def build_property_id(address: str, postcode: str) -> str: + """ + Extract number from address and concat with postcode + Example: + '9 Random Close', 'AB1 2YZ' → '9AB12YZ' + """ + number = address.split(" ")[0] + + postcode_clean = postcode.replace(" ", "").upper() + + return f"{number}{postcode_clean}" + + def download_report(): username = "" password = "" + property_list_file = ( + "hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx" + ) + BASE_DIR = os.path.dirname(os.path.dirname(__file__)) + filepath = os.path.join( + BASE_DIR, + property_list_file, + ) + property_ids: List[str] = extract_ids_from_spreadsheet(filepath) + + matching_properties: List[str] = [] + with sync_playwright() as p: browser = p.chromium.launch(headless=True) @@ -23,10 +89,9 @@ def download_report(): page = context.new_page() try: - # 1. Go to site + # Log into ECMK with playwright page.goto("https://assessorhub.net/", timeout=30000) - # 2. Login (UPDATE selectors if needed) username_input = page.locator("#Username") password_input = page.locator("#Password") @@ -36,40 +101,90 @@ def download_report(): password_input.wait_for(state="visible", timeout=10000) password_input.fill(password) - # 3. Submit login with page.expect_navigation(timeout=15000): page.click("button[type='submit']") - # 4. Verify login succeeded if "login" in page.url.lower(): raise Exception("Login failed") print("Login successful:", page.url) + page.goto("https://assessorhub.net/Companies/Assessments", timeout=30000) + page.wait_for_selector("#assessmentDatatable tbody tr", timeout=20000) + + while True: + rows = page.locator("#assessmentDatatable tbody tr") + row_count = rows.count() + + logger.info(f"Processing {row_count} rows on current page") + + for i in range(row_count): + row = rows.nth(i) + + try: + cells = row.locator("td") + + address = cells.nth(5).inner_text().strip() + postcode = cells.nth(7).inner_text().strip() + first_name = cells.nth(1).inner_text().strip() + last_name = cells.nth(2).inner_text().strip() + status = cells.nth(9).inner_text().strip() + + if first_name == "Oliver" and last_name == "Stephens": + continue + + if status != "Submitted (not Lodged)": + continue + + property_id = build_property_id(address, postcode) + + if property_id not in property_ids: + continue + + logger.info(f"MATCH FOUND: {property_id}") + matching_properties.append(property_id) + + except PlaywrightTimeoutError as e: + raise Exception(f"Timeout occurred: {str(e)}") + + next_button: Locator = page.locator("#assessmentDatatable_next a") + class_attr = next_button.get_attribute("class") or "" + + if "disabled" in class_attr: + logger.info("No more pages") + break + + # first_row_text = rows.first.inner_text() + + next_button.scroll_into_view_if_needed() + next_button.click() + + page.wait_for_timeout(2000) + # 5. Navigate to the assessment detail page - page.goto( - "https://assessorhub.net/Assessments/Assessments/Detail/1bd9fd74-08f6-4fc1-b2f7-3a13a8f9084d?returnUrl=/Companies/Assessments", - timeout=30000, - ) + # page.goto( + # "https://assessorhub.net/Assessments/Assessments/Detail/1bd9fd74-08f6-4fc1-b2f7-3a13a8f9084d?returnUrl=/Companies/Assessments", + # timeout=30000, + # ) - # 6. Locate the correct download button - button = page.locator("a.download-report-btn[data-report-type='11']") + # # 6. Locate the correct download button + # button = page.locator("a.download-report-btn[data-report-type='11']") - button.wait_for(state="visible", timeout=10000) + # button.wait_for(state="visible", timeout=10000) - # 7. Click and capture the download - with page.expect_download(timeout=30000) as download_info: - button.click() + # # 7. Click and capture the download + # with page.expect_download(timeout=30000) as download_info: + # button.click() - download = download_info.value + # download = download_info.value - # 8. Save file locally - filename = download.suggested_filename - save_path = os.path.join(os.getcwd(), filename) + # # 8. Save file locally + # filename = download.suggested_filename + # save_path = os.path.join(os.getcwd(), filename) - download.save_as(save_path) + # download.save_as(save_path) - print(f"Downloaded file saved to: {save_path}") + # print(f"Downloaded file saved to: {save_path}") except PlaywrightTimeoutError as e: raise Exception(f"Timeout occurred: {str(e)}") diff --git a/backend/ecmk_fetcher/hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx b/backend/ecmk_fetcher/hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ebb8d569ccf13cf33b4d6f0ed3bb7e4b84d137da GIT binary patch literal 241362 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�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 yTNBic%12Yg!pt#wnr!%9_$flnpjvuSqoc;?xaS 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%0DwT5#rAXo`w5?K}f)4&&xgK`4@m0#r3{ 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 literal 0 HcmV?d00001 From cee4e40bb1cc0f82ec45ba974c2ad63953e856ca Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Apr 2026 12:10:28 +0000 Subject: [PATCH 13/93] latest code --- backend/app/db/models/organisation.py | 2 ++ etl/hubspot/hubspotClient.py | 2 ++ etl/hubspot/hubspotDataTodB.py | 12 ++++++++++++ etl/hubspot/scripts/scraper/bulk_load.py | 3 ++- etl/hubspot/scripts/scraper/main.py | 2 +- 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/app/db/models/organisation.py b/backend/app/db/models/organisation.py index a9718c42..3adc8e9c 100644 --- a/backend/app/db/models/organisation.py +++ b/backend/app/db/models/organisation.py @@ -63,6 +63,8 @@ class HubspotDealData(SQLModel, table=True): surveyor: Optional[str] = Field(default=None) confirmed_survey_date: Optional[datetime] = Field(default=None) confirmed_survey_time: Optional[str] = Field(default=None) + surveyed_date: Optional[datetime] = Field(default=None) + design_type: Optional[str] = Field(default=None) created_at: datetime = Field( sa_column=Column( diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index e5461c61..d74a5ed4 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -231,6 +231,8 @@ class HubspotClient: "surveyor", "confirmed_survey_date", "confirmed_survey_time", + "surveyed_date", + "design_type", ], ) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 0c38f483..7f06a29d 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -257,6 +257,14 @@ class HubspotDataToDb: deal_in_db.confirmed_survey_time == hs_deal.get("confirmed_survey_time"), "confirmed_survey_time mismatch", ), + soft_assert( + deal_in_db.surveyed_date == self._parse_hs_date(hs_deal.get("surveyed_date")), + "surveyed_date mismatch", + ), + soft_assert( + deal_in_db.design_type == hs_deal.get("design_type"), + "design_type mismatch", + ), ] # If discrepancies found, update from HubSpot @@ -380,6 +388,8 @@ class HubspotDataToDb: "surveyor": deal_data.get("surveyor"), "confirmed_survey_date": self._parse_hs_date(deal_data.get("confirmed_survey_date")), "confirmed_survey_time": deal_data.get("confirmed_survey_time"), + "surveyed_date": self._parse_hs_date(deal_data.get("surveyed_date")), + "design_type": deal_data.get("design_type"), }.items(): setattr(existing, attr, value or getattr(existing, attr)) @@ -462,6 +472,8 @@ class HubspotDataToDb: surveyor=deal_data.get("surveyor"), confirmed_survey_date=self._parse_hs_date(deal_data.get("confirmed_survey_date")), confirmed_survey_time=deal_data.get("confirmed_survey_time"), + surveyed_date=self._parse_hs_date(deal_data.get("surveyed_date")), + design_type=deal_data.get("design_type"), ) # Handle upload at insert time diff --git a/etl/hubspot/scripts/scraper/bulk_load.py b/etl/hubspot/scripts/scraper/bulk_load.py index 6fac23ea..5dc9570e 100644 --- a/etl/hubspot/scripts/scraper/bulk_load.py +++ b/etl/hubspot/scripts/scraper/bulk_load.py @@ -1,6 +1,7 @@ from etl.hubspot.hubspotClient import HubspotClient, Companies, Pipeline from etl.hubspot.scripts.scraper.main import handler from tqdm import tqdm +import json PIPELINE_ID = Pipeline.OPERATIONS_SOCIAL_HOUSING.value @@ -29,7 +30,7 @@ def bulk_load(companies: list[Companies] | None = None) -> None: continue deal_bar.set_postfix({"status": "uploading", "deal": deal_id}) - handler({"hubspot_deal_id": deal_id}, context=None) + handler({"Records": [{"body": json.dumps({"hubspot_deal_id": deal_id})}]}, context=None) processed += 1 deal_bar.set_postfix({"status": "done", "deal": deal_id}) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 459ea5c2..4525c8cb 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -21,7 +21,7 @@ def handler(event: dict[str, Any], context: Any, local: bool = False) -> None: { "body": json.dumps( { - "hubspot_deal_id": "409487859944", + "hubspot_deal_id": "483651713260", } ) } From 33c4572f4853f9046f2c4306bbe2fe7a8c8b649f Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 1 Apr 2026 13:54:22 +0000 Subject: [PATCH 14/93] download files --- backend/ecmk_fetcher/handler/handler.py | 150 ++++++++++++++++++------ 1 file changed, 113 insertions(+), 37 deletions(-) diff --git a/backend/ecmk_fetcher/handler/handler.py b/backend/ecmk_fetcher/handler/handler.py index 5c200ab6..39842a36 100644 --- a/backend/ecmk_fetcher/handler/handler.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -1,6 +1,7 @@ import os from enum import Enum -from typing import Any, List, Mapping +import re +from typing import Any, Dict, List, Mapping, Optional from openpyxl import load_workbook from playwright.sync_api import ( Locator, @@ -21,36 +22,78 @@ class file_download_button_types(Enum): SAP_WORK_SHEET = 15 -def extract_ids_from_spreadsheet(filepath: str) -> List[str]: +def extract_addresses_from_spreadsheet(filepath: str) -> Dict[str, str]: wb = load_workbook(filepath, data_only=True) ws = wb["Southern RA-Lite Programme 3103"] - ids: List[str] = [] + properties: Dict[str, str] = {} header_row = 1 id_col_index = None + deal_name_col_index = None for col in range(1, ws.max_column + 1): cell_value = ws.cell(row=header_row, column=col).value + if cell_value and str(cell_value).strip().lower() == "id": id_col_index = col + + if cell_value and str(cell_value).strip().lower() == "deal name": + deal_name_col_index = col break if id_col_index is None: raise Exception("ID column not found in spreadsheet") - for row in range(2, ws.max_row + 1): - cell_value = ws.cell(row=row, column=id_col_index).value + if deal_name_col_index is None: + raise Exception("Deal Name column not found in spreadsheet") - if cell_value is None: + for row in range(2, ws.max_row + 1): + id_cell_value = ws.cell(row=row, column=id_col_index).value + deal_name_cell_value = ws.cell(row=row, column=deal_name_col_index).value + + if id_cell_value is None or deal_name_cell_value is None: continue - id_str = str(cell_value).strip() + id_str = str(id_cell_value).strip() + deal_name_str = str(deal_name_cell_value).strip() - if id_str: - ids.append(id_str) + if not id_str: + continue - return ids + sharepoint_address = extract_succinct_address(deal_name_str) + + properties[id_str] = sharepoint_address + + return properties + + +def extract_succinct_address(deal_name: str) -> str: + """ + Input: + '1 My Random Close, Town, AB12 3DC | Retrofit Assessment' + + Output: + '1 My Random Close AB12 3DC' + """ + left_part = deal_name.split("|")[0].strip() + + postcode_match: Optional[re.Match[str]] = re.search( + r"\b([A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2})\b", + left_part, + re.IGNORECASE, + ) + + postcode = None + if postcode_match: + postcode = postcode_match.group(1).replace(" ", "").upper() + + first_part = left_part.split(",")[0].strip() + + if postcode: + return f"{first_part} {postcode}" + else: + return first_part def build_property_id(address: str, postcode: str) -> str: @@ -78,7 +121,10 @@ def download_report(): BASE_DIR, property_list_file, ) - property_ids: List[str] = extract_ids_from_spreadsheet(filepath) + property_id_to_address_map: Dict[str, str] = extract_addresses_from_spreadsheet( + filepath + ) + property_ids: List[str] = list(property_id_to_address_map.keys()) matching_properties: List[str] = [] @@ -140,10 +186,65 @@ def download_report(): if property_id not in property_ids: continue - logger.info(f"MATCH FOUND: {property_id}") matching_properties.append(property_id) + try: + sharepoint_address: str = property_id_to_address_map[ + property_id + ] + except Exception: + logger.error( + f"Unable to find sharepoint address for property ID {property_id}" + ) + continue + + # Go to assessment details page and download files + account_link = cells.nth(0).locator("a") + with page.expect_navigation(): + account_link.click() + + assessment_hub_sitenote_selector = f"a.download-report-btn[data-report-type='{file_download_button_types.ASSESSOR_HUB_SITENOTE_REPORT.value}']" + + page.wait_for_selector( + assessment_hub_sitenote_selector, timeout=10000 + ) + + with page.expect_download() as download_info: + page.click(assessment_hub_sitenote_selector) + + download = download_info.value + + filename = download.suggested_filename + save_path = os.path.join(os.getcwd(), filename) + + download.save_as(save_path) + + logger.info(f"Downloaded: {filename}") + + sitenote_report_selector = f"a.download-report-btn[data-report-type='{file_download_button_types.SITENOTE_REPORT.value}']" + + page.wait_for_selector(sitenote_report_selector, timeout=10000) + + with page.expect_download() as download_info: + page.click(sitenote_report_selector) + + download = download_info.value + + filename = download.suggested_filename + save_path = os.path.join(os.getcwd(), filename) + + download.save_as(save_path) + + logger.info(f"Downloaded: {filename}") + + # stick in sharepoint + + page.go_back() + page.wait_for_selector( + "#assessmentDatatable tbody tr", timeout=15000 + ) + except PlaywrightTimeoutError as e: raise Exception(f"Timeout occurred: {str(e)}") @@ -161,31 +262,6 @@ def download_report(): page.wait_for_timeout(2000) - # 5. Navigate to the assessment detail page - # page.goto( - # "https://assessorhub.net/Assessments/Assessments/Detail/1bd9fd74-08f6-4fc1-b2f7-3a13a8f9084d?returnUrl=/Companies/Assessments", - # timeout=30000, - # ) - - # # 6. Locate the correct download button - # button = page.locator("a.download-report-btn[data-report-type='11']") - - # button.wait_for(state="visible", timeout=10000) - - # # 7. Click and capture the download - # with page.expect_download(timeout=30000) as download_info: - # button.click() - - # download = download_info.value - - # # 8. Save file locally - # filename = download.suggested_filename - # save_path = os.path.join(os.getcwd(), filename) - - # download.save_as(save_path) - - # print(f"Downloaded file saved to: {save_path}") - except PlaywrightTimeoutError as e: raise Exception(f"Timeout occurred: {str(e)}") From 37a12ffb28b1bd3043052af3ce79295edefb7c82 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 1 Apr 2026 14:13:46 +0000 Subject: [PATCH 15/93] refactor with helper functions --- backend/ecmk_fetcher/handler/handler.py | 203 ++++++++++++------------ 1 file changed, 104 insertions(+), 99 deletions(-) diff --git a/backend/ecmk_fetcher/handler/handler.py b/backend/ecmk_fetcher/handler/handler.py index 39842a36..7f26fb69 100644 --- a/backend/ecmk_fetcher/handler/handler.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Mapping, Optional from openpyxl import load_workbook from playwright.sync_api import ( Locator, + Page, sync_playwright, TimeoutError as PlaywrightTimeoutError, ) @@ -109,18 +110,86 @@ def build_property_id(address: str, postcode: str) -> str: return f"{number}{postcode_clean}" -def download_report(): - username = "" - password = "" +def login(page: Page, username: str, password: str) -> None: + page.goto("https://assessorhub.net/", timeout=30000) - property_list_file = ( + username_input: Locator = page.locator("#Username") + password_input: Locator = page.locator("#Password") + + username_input.wait_for(state="visible", timeout=10000) + username_input.fill(username) + + password_input.wait_for(state="visible", timeout=10000) + password_input.fill(password) + + with page.expect_navigation(timeout=15000): + page.click("button[type='submit']") + + if "login" in page.url.lower(): + raise Exception("Login failed") + + logger.info("Login successful") + + +def go_to_assessments(page: Page) -> None: + page.goto("https://assessorhub.net/Companies/Assessments", timeout=30000) + page.wait_for_selector("#assessmentDatatable tbody tr", timeout=20000) + + +def go_to_assessment_details(page: Page, row: Locator) -> None: + account_link: Locator = row.locator("a") + with page.expect_navigation(): + account_link.click() + + +def go_to_next_page(page: Page) -> bool: + next_button: Locator = page.locator("#assessmentDatatable_next a") + + class_attr: Optional[str] = next_button.get_attribute("class") or "" + + if "disabled" in class_attr: + logger.info("No more pages") + return False + + next_button.scroll_into_view_if_needed() + next_button.click() + + page.wait_for_timeout(2000) + return True + + +def build_report_selector(report_type: int) -> str: + return f"a.download-report-btn[data-report-type='{report_type}']" + + +def download_report_by_selector(page: Page, selector: str) -> str: + page.wait_for_selector(selector, timeout=10000) + + with page.expect_download() as download_info: + page.click(selector) + + download = download_info.value + filename: str = download.suggested_filename + + save_path: str = os.path.join(os.getcwd(), filename) + download.save_as(save_path) + + logger.info(f"Downloaded: {filename}") + + return save_path + + +def download_report() -> None: + username: str = "" + password: str = "" + + property_list_file: str = ( "hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx" ) - BASE_DIR = os.path.dirname(os.path.dirname(__file__)) - filepath = os.path.join( - BASE_DIR, - property_list_file, - ) + + BASE_DIR: str = os.path.dirname(os.path.dirname(__file__)) + filepath: str = os.path.join(BASE_DIR, property_list_file) + property_id_to_address_map: Dict[str, str] = extract_addresses_from_spreadsheet( filepath ) @@ -135,46 +204,28 @@ def download_report(): page = context.new_page() try: - # Log into ECMK with playwright - page.goto("https://assessorhub.net/", timeout=30000) - - username_input = page.locator("#Username") - password_input = page.locator("#Password") - - username_input.wait_for(state="visible", timeout=10000) - username_input.fill(username) - - password_input.wait_for(state="visible", timeout=10000) - password_input.fill(password) - - with page.expect_navigation(timeout=15000): - page.click("button[type='submit']") - - if "login" in page.url.lower(): - raise Exception("Login failed") - + login(page, username, password) print("Login successful:", page.url) - page.goto("https://assessorhub.net/Companies/Assessments", timeout=30000) - page.wait_for_selector("#assessmentDatatable tbody tr", timeout=20000) + go_to_assessments(page) while True: - rows = page.locator("#assessmentDatatable tbody tr") - row_count = rows.count() + rows: Locator = page.locator("#assessmentDatatable tbody tr") + row_count: int = rows.count() logger.info(f"Processing {row_count} rows on current page") for i in range(row_count): - row = rows.nth(i) + row: Locator = rows.nth(i) try: - cells = row.locator("td") + cells: Locator = row.locator("td") - address = cells.nth(5).inner_text().strip() - postcode = cells.nth(7).inner_text().strip() - first_name = cells.nth(1).inner_text().strip() - last_name = cells.nth(2).inner_text().strip() - status = cells.nth(9).inner_text().strip() + address: str = cells.nth(5).inner_text().strip() + postcode: str = cells.nth(7).inner_text().strip() + first_name: str = cells.nth(1).inner_text().strip() + last_name: str = cells.nth(2).inner_text().strip() + status: str = cells.nth(9).inner_text().strip() if first_name == "Oliver" and last_name == "Stephens": continue @@ -182,63 +233,28 @@ def download_report(): if status != "Submitted (not Lodged)": continue - property_id = build_property_id(address, postcode) + property_id: str = build_property_id(address, postcode) if property_id not in property_ids: continue + logger.info(f"MATCH FOUND: {property_id}") matching_properties.append(property_id) - try: - sharepoint_address: str = property_id_to_address_map[ - property_id - ] - except Exception: - logger.error( - f"Unable to find sharepoint address for property ID {property_id}" - ) - continue + sharepoint_address: str = property_id_to_address_map[ + property_id + ] + go_to_assessment_details(page, row) - # Go to assessment details page and download files - account_link = cells.nth(0).locator("a") - with page.expect_navigation(): - account_link.click() + report_types: List[int] = [ + file_download_button_types.ASSESSOR_HUB_SITENOTE_REPORT.value, + file_download_button_types.SITENOTE_REPORT.value, + ] - assessment_hub_sitenote_selector = f"a.download-report-btn[data-report-type='{file_download_button_types.ASSESSOR_HUB_SITENOTE_REPORT.value}']" - - page.wait_for_selector( - assessment_hub_sitenote_selector, timeout=10000 - ) - - with page.expect_download() as download_info: - page.click(assessment_hub_sitenote_selector) - - download = download_info.value - - filename = download.suggested_filename - save_path = os.path.join(os.getcwd(), filename) - - download.save_as(save_path) - - logger.info(f"Downloaded: {filename}") - - sitenote_report_selector = f"a.download-report-btn[data-report-type='{file_download_button_types.SITENOTE_REPORT.value}']" - - page.wait_for_selector(sitenote_report_selector, timeout=10000) - - with page.expect_download() as download_info: - page.click(sitenote_report_selector) - - download = download_info.value - - filename = download.suggested_filename - save_path = os.path.join(os.getcwd(), filename) - - download.save_as(save_path) - - logger.info(f"Downloaded: {filename}") - - # stick in sharepoint + for report_type in report_types: + selector: str = build_report_selector(report_type) + download_report_by_selector(page, selector) + # TODO: stick in sharepoint page.go_back() page.wait_for_selector( @@ -248,20 +264,9 @@ def download_report(): except PlaywrightTimeoutError as e: raise Exception(f"Timeout occurred: {str(e)}") - next_button: Locator = page.locator("#assessmentDatatable_next a") - class_attr = next_button.get_attribute("class") or "" - - if "disabled" in class_attr: - logger.info("No more pages") + if not go_to_next_page(page): break - # first_row_text = rows.first.inner_text() - - next_button.scroll_into_view_if_needed() - next_button.click() - - page.wait_for_timeout(2000) - except PlaywrightTimeoutError as e: raise Exception(f"Timeout occurred: {str(e)}") From 955dffd74d2e5877af6bc1eca576c716f15701c4 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Apr 2026 14:59:28 +0000 Subject: [PATCH 16/93] added task handelr --- backend/utils/subtasks.py | 76 +++++++++++++++++++++++++++- etl/hubspot/scripts/scraper/main.py | 21 ++------ sfr/principal_pitch/2_export_data.py | 8 +-- 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/backend/utils/subtasks.py b/backend/utils/subtasks.py index 041494e9..e5668c53 100644 --- a/backend/utils/subtasks.py +++ b/backend/utils/subtasks.py @@ -5,7 +5,8 @@ from typing import Callable, Any from uuid import UUID import json -from backend.app.db.functions.tasks.Tasks import SubTaskInterface +from backend.app.db.functions.tasks.Tasks import SubTaskInterface, TasksInterface +from utils.logger import setup_logger def subtask_handler(): @@ -93,3 +94,76 @@ def subtask_handler(): return wrapper return decorator + + +def task_handler(): + """ + Decorator that wraps a Lambda handler and automatically: + + - Parses body from the first SQS record (or uses the event dict directly) + - Creates a fresh Task + SubTask in the database + - Marks the subtask as in progress + - Executes the handler, passing the parsed body + - Marks complete on success, failed on exception (and re-raises) + """ + + def decorator(func: Callable[..., Any]): + + task_source = f"{func.__module__}.{func.__qualname__}" + + @wraps(func) + def wrapper(event: dict[str, Any], context: Any, *args, **kwargs): + + logger = setup_logger() + + # Parse body: Records-style SQS or plain dict event + if "Records" in event: + raw_body = event["Records"][0].get("body", {}) + if isinstance(raw_body, str): + try: + body = json.loads(raw_body) + except Exception: + body = {} + else: + body = raw_body or {} + else: + body = event + + # Create fresh task + subtask + logger.info("Creating task for source: %s", task_source) + task_id, subtask_id = TasksInterface.create_task( + task_source=task_source, + inputs=body, + ) + logger.info("Created task_id=%s subtask_id=%s", task_id, subtask_id) + + interface = SubTaskInterface() + + interface.update_subtask_status( + subtask_id=subtask_id, + status="in progress", + ) + + try: + result = func(body, context, *args, **kwargs) + + interface.update_subtask_status( + subtask_id=subtask_id, + status="complete", + outputs={"result": result} if result else None, + ) + logger.info("Task %s completed successfully", task_id) + return result + + except Exception as e: + logger.exception("Task %s failed: %s", task_id, e) + interface.update_subtask_status( + subtask_id=subtask_id, + status="failed", + outputs={"error": str(e)}, + ) + raise + + return wrapper + + return decorator diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 4525c8cb..55a7a372 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -9,26 +9,13 @@ from etl.hubspot.hubspotClient import HubspotClient from etl.hubspot.hubspotDataTodB import HubspotDataToDb +from backend.utils.subtasks import task_handler from typing import Any import json -# @subtask_handler() TODO: Do this without subtask_handler but task_handler() that creates task_id and subtask_id -def handler(event: dict[str, Any], context: Any, local: bool = False) -> None: - if local is True: - event = { - "Records": [ - { - "body": json.dumps( - { - "hubspot_deal_id": "483651713260", - } - ) - } - ] - } - - body = json.loads(event["Records"][0]["body"]) +@task_handler() +def handler(body: dict[str, Any], context: Any) -> None: hubspot_deal_id = body.get("hubspot_deal_id", "") if hubspot_deal_id == "": @@ -46,5 +33,3 @@ def handler(event: dict[str, Any], context: Any, local: bool = False) -> None: else: deal, company, listing = hubspot.get_deal_info_for_db(hubspot_deal_id) dbloader.upsert_deal(deal, company, listing, hubspot) - - print("Finsihed running") diff --git a/sfr/principal_pitch/2_export_data.py b/sfr/principal_pitch/2_export_data.py index fece17e0..3baa7a44 100644 --- a/sfr/principal_pitch/2_export_data.py +++ b/sfr/principal_pitch/2_export_data.py @@ -26,13 +26,13 @@ from backend.app.db.functions.materials_functions import get_materials from collections import defaultdict from sqlalchemy import func -PORTFOLIO_ID = 640 -SCENARIOS = [1154] +PORTFOLIO_ID = 656 +SCENARIOS = [1177] scenario_names = { - 1154: "EPC - 10k Budget", + 1177: "EPC C; Proposed Measures", } -project_name = "First Charterhouse Investments" +project_name = "Walsall Council | WH:LG" def get_data(portfolio_id, scenario_ids): From 32e37990d2fc9e18569575e4efc83fb8bc060f50 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 1 Apr 2026 16:03:52 +0000 Subject: [PATCH 17/93] correctly save to sharepoint --- backend/ecmk_fetcher/handler/handler.py | 28 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/backend/ecmk_fetcher/handler/handler.py b/backend/ecmk_fetcher/handler/handler.py index 7f26fb69..48721d14 100644 --- a/backend/ecmk_fetcher/handler/handler.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -11,6 +11,8 @@ from playwright.sync_api import ( ) from utils.logger import setup_logger +from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient +from utils.sharepoint.domna_sites import DomnaSites logger = setup_logger() @@ -87,7 +89,7 @@ def extract_succinct_address(deal_name: str) -> str: postcode = None if postcode_match: - postcode = postcode_match.group(1).replace(" ", "").upper() + postcode = postcode_match.group(1).upper() first_part = left_part.split(",")[0].strip() @@ -197,6 +199,11 @@ def download_report() -> None: matching_properties: List[str] = [] + sharepoint_client = DomnaSharepointClient( + sharepoint_location=DomnaSites.PRIVATE_PAY + ) + sharepoint_base_path = "/Projects/Southern Housing/SH-SURV-26-001/Assessments" + with sync_playwright() as p: browser = p.chromium.launch(headless=True) @@ -221,10 +228,11 @@ def download_report() -> None: try: cells: Locator = row.locator("td") - address: str = cells.nth(5).inner_text().strip() - postcode: str = cells.nth(7).inner_text().strip() first_name: str = cells.nth(1).inner_text().strip() last_name: str = cells.nth(2).inner_text().strip() + address: str = cells.nth(5).inner_text().strip() + postcode: str = cells.nth(7).inner_text().strip() + # uprn: str = cells.nth(8).inner_text().strip() status: str = cells.nth(9).inner_text().strip() if first_name == "Oliver" and last_name == "Stephens": @@ -253,8 +261,18 @@ def download_report() -> None: for report_type in report_types: selector: str = build_report_selector(report_type) - download_report_by_selector(page, selector) - # TODO: stick in sharepoint + file_path: str = download_report_by_selector(page, selector) + try: + sharepoint_client.upload_file( + file_path=file_path, + sharepoint_path=f"{sharepoint_base_path}/{sharepoint_address}/1. Retrofit Assessment/A. Assessment", + file_name=os.path.basename(file_path), + ) + # TODO: stick in s3 + finally: + if os.path.exists(file_path): + os.remove(file_path) + logger.info(f"Deleted local file: {file_path}") page.go_back() page.wait_for_selector( From 231473ecbac2c71c34375a595ffd5b1cbf9ed6f4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 1 Apr 2026 16:24:00 +0000 Subject: [PATCH 18/93] load files to s3 and update db --- backend/app/db/models/uploaded_file.py | 1 + backend/ecmk_fetcher/handler/handler.py | 41 +++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/backend/app/db/models/uploaded_file.py b/backend/app/db/models/uploaded_file.py index 726ed0a3..9b751d34 100644 --- a/backend/app/db/models/uploaded_file.py +++ b/backend/app/db/models/uploaded_file.py @@ -20,6 +20,7 @@ class FileSourceEnum(enum.Enum): PAS_HUB = "pas hub" SHAREPOINT = "sharepoint" HUBSPOT = "hubspot" + ECMK = "ecmk" class UploadedFile(Base): diff --git a/backend/ecmk_fetcher/handler/handler.py b/backend/ecmk_fetcher/handler/handler.py index 48721d14..932b8552 100644 --- a/backend/ecmk_fetcher/handler/handler.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone import os from enum import Enum import re @@ -10,7 +11,10 @@ from playwright.sync_api import ( TimeoutError as PlaywrightTimeoutError, ) +from backend.app.db.connection import db_session +from backend.app.db.models.uploaded_file import FileSourceEnum, UploadedFile from utils.logger import setup_logger +from utils.s3 import upload_file_to_s3 from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient from utils.sharepoint.domna_sites import DomnaSites @@ -181,6 +185,37 @@ def download_report_by_selector(page: Page, selector: str) -> str: return save_path +def upload_job_to_s3_and_update_db(job_files: List[str], uprn: str) -> None: + bucket = "retrofit-energy-assessments-dev" + + base_path = f"documents/uprn/{uprn}" + + uploaded_files: List[UploadedFile] = [] + + for file_path in job_files: + filename = os.path.basename(file_path) + file_key = f"{base_path}/{filename}" + + upload_file_to_s3(file_path, bucket, file_key) + + # load row to db + uploaded_files.append( + UploadedFile( + s3_file_bucket=bucket, + s3_file_key=file_key, + s3_upload_timestamp=datetime.now(timezone.utc), + uprn=int(uprn), + file_source=FileSourceEnum.ECMK.value, + ) + ) + + with db_session() as session: + session.add_all(uploaded_files) + session.commit() + + pass + + def download_report() -> None: username: str = "" password: str = "" @@ -232,7 +267,7 @@ def download_report() -> None: last_name: str = cells.nth(2).inner_text().strip() address: str = cells.nth(5).inner_text().strip() postcode: str = cells.nth(7).inner_text().strip() - # uprn: str = cells.nth(8).inner_text().strip() + uprn: str = cells.nth(8).inner_text().strip() status: str = cells.nth(9).inner_text().strip() if first_name == "Oliver" and last_name == "Stephens": @@ -268,7 +303,9 @@ def download_report() -> None: sharepoint_path=f"{sharepoint_base_path}/{sharepoint_address}/1. Retrofit Assessment/A. Assessment", file_name=os.path.basename(file_path), ) - # TODO: stick in s3 + # TODO: could s3 load happen for all files at once to reduce db roundtrips? + if uprn: + upload_job_to_s3_and_update_db([file_path], uprn) finally: if os.path.exists(file_path): os.remove(file_path) From a886911de45d27eeb836aa313f727cbfdb8e7c50 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Apr 2026 08:29:09 +0000 Subject: [PATCH 19/93] add debugging --- backend/ecmk_fetcher/handler/handler.py | 97 +++++++++++++++++++++---- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/backend/ecmk_fetcher/handler/handler.py b/backend/ecmk_fetcher/handler/handler.py index 932b8552..c4c1385c 100644 --- a/backend/ecmk_fetcher/handler/handler.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -7,6 +7,7 @@ from openpyxl import load_workbook from playwright.sync_api import ( Locator, Page, + Response, sync_playwright, TimeoutError as PlaywrightTimeoutError, ) @@ -29,6 +30,20 @@ class file_download_button_types(Enum): SAP_WORK_SHEET = 15 +def attach_debug_listeners(page: Page) -> None: + def handle_response(response: Response) -> None: + url: str = response.url + status: int = response.status + + if "download" in url or "report" in url: + logger.info(f"[RESPONSE] {status} {url}") + + if status >= 400: + logger.error(f"[ERROR RESPONSE] {status} {url}") + + page.on("response", handle_response) + + def extract_addresses_from_spreadsheet(filepath: str) -> Dict[str, str]: wb = load_workbook(filepath, data_only=True) ws = wb["Southern RA-Lite Programme 3103"] @@ -147,6 +162,12 @@ def go_to_assessment_details(page: Page, row: Locator) -> None: with page.expect_navigation(): account_link.click() + page.wait_for_load_state("networkidle") + + page.wait_for_selector("a.download-report-btn", timeout=10000) + + logger.info("Assessment details page fully loaded") + def go_to_next_page(page: Page) -> bool: next_button: Locator = page.locator("#assessmentDatatable_next a") @@ -168,21 +189,60 @@ def build_report_selector(report_type: int) -> str: return f"a.download-report-btn[data-report-type='{report_type}']" -def download_report_by_selector(page: Page, selector: str) -> str: - page.wait_for_selector(selector, timeout=10000) +def download_report_by_selector(page: Page, selector: str) -> Optional[str]: + try: + element: Locator = page.locator(selector) - with page.expect_download() as download_info: - page.click(selector) + element.wait_for(state="visible", timeout=10000) - download = download_info.value - filename: str = download.suggested_filename + if not element.is_enabled(): + logger.warning(f"Element not enabled: {selector}") + return None - save_path: str = os.path.join(os.getcwd(), filename) - download.save_as(save_path) + element.scroll_into_view_if_needed() - logger.info(f"Downloaded: {filename}") + page.wait_for_timeout(300) - return save_path + logger.info(f"Attempting download via selector: {selector}") + logger.info(f"Current URL: {page.url}") + + with page.expect_download(timeout=15000) as download_info: + element.click() + + download = download_info.value + filename: str = download.suggested_filename + + save_path: str = os.path.join(os.getcwd(), filename) + download.save_as(save_path) + + logger.info(f"Downloaded: {filename}") + + return save_path + + except PlaywrightTimeoutError: + logger.error(f"Download NOT triggered for selector: {selector}") + logger.error(f"Current URL at failure: {page.url}") + + try: + content_snippet = page.content()[:1000] + logger.error(f"Page snippet: {content_snippet}") + except Exception: + pass + + return None + + +def download_with_retry(page: Page, selector: str) -> Optional[str]: + for attempt in range(3): + file_path = download_report_by_selector(page, selector) + + if file_path: + return file_path + + logger.warning(f"Retry {attempt + 1} for {selector}") + page.wait_for_timeout(1500) + + return None def upload_job_to_s3_and_update_db(job_files: List[str], uprn: str) -> None: @@ -244,6 +304,7 @@ def download_report() -> None: context = browser.new_context() page = context.new_page() + attach_debug_listeners(page) try: login(page, username, password) @@ -267,7 +328,7 @@ def download_report() -> None: last_name: str = cells.nth(2).inner_text().strip() address: str = cells.nth(5).inner_text().strip() postcode: str = cells.nth(7).inner_text().strip() - uprn: str = cells.nth(8).inner_text().strip() + # uprn: str = cells.nth(8).inner_text().strip() status: str = cells.nth(9).inner_text().strip() if first_name == "Oliver" and last_name == "Stephens": @@ -296,16 +357,24 @@ def download_report() -> None: for report_type in report_types: selector: str = build_report_selector(report_type) - file_path: str = download_report_by_selector(page, selector) + file_path: Optional[str] = download_with_retry( + page, selector + ) + + if not file_path: + continue try: sharepoint_client.upload_file( file_path=file_path, sharepoint_path=f"{sharepoint_base_path}/{sharepoint_address}/1. Retrofit Assessment/A. Assessment", file_name=os.path.basename(file_path), ) + logger.info( + f"Successfully uploaded file {os.path.basename(file_path)} to sharepoint" + ) # TODO: could s3 load happen for all files at once to reduce db roundtrips? - if uprn: - upload_job_to_s3_and_update_db([file_path], uprn) + # if uprn: + # upload_job_to_s3_and_update_db([file_path], uprn) finally: if os.path.exists(file_path): os.remove(file_path) From acb44dc60ab8fc8ffc3df2e638ef4e0528b6579a Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Apr 2026 10:12:09 +0000 Subject: [PATCH 20/93] added dampandmouldandrepaircomments, more resilence on retries with hubspot api --- backend/app/db/models/organisation.py | 1 + etl/hubspot/hubspotClient.py | 208 ++++++++++++++--------- etl/hubspot/hubspotDataTodB.py | 114 +++++++++---- etl/hubspot/scripts/scraper/bulk_load.py | 20 ++- etl/hubspot/scripts/scraper/main.py | 12 +- 5 files changed, 227 insertions(+), 128 deletions(-) diff --git a/backend/app/db/models/organisation.py b/backend/app/db/models/organisation.py index 3adc8e9c..cc3ef2bc 100644 --- a/backend/app/db/models/organisation.py +++ b/backend/app/db/models/organisation.py @@ -44,6 +44,7 @@ class HubspotDealData(SQLModel, table=True): pashub_link: Optional[str] = Field(default=None) sharepoint_link: Optional[str] = Field(default=None) dampmould_growth: Optional[str] = Field(default=None) + damp_mould_and_repairs_comments: Optional[str] = Field(default=None) pre_sap: Optional[str] = Field(default=None) coordinator: Optional[str] = Field(default=None) mtp_completion_date: Optional[datetime] = Field(default=None) diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index d74a5ed4..20f90944 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -1,9 +1,12 @@ import os +import time from enum import Enum -from typing import Optional, cast +from typing import Optional, cast, Callable, TypeVar from hubspot.client import Client # type: ignore[reportMissingTypeStubs] from hubspot.crm.associations import ApiException # type: ignore[reportMissingTypeStubs] + +T = TypeVar("T") from hubspot.crm.objects import SimplePublicObjectInput # type: ignore[reportMissingTypeStubs] from hubspot.crm.objects.api.basic_api import BasicApi as ObjectsBasicApi # type: ignore[reportMissingTypeStubs] from hubspot.crm.deals.api.basic_api import BasicApi as DealsBasicApi # type: ignore[reportMissingTypeStubs] @@ -83,6 +86,30 @@ class HubspotClient: # Sorry - not sorry but enjoy, Past Junte 13/03/2026 # self.client + def _call_with_retry(self, fn: Callable[[], T], max_retries: int = 2) -> T: + """ + Call fn(), retrying up to max_retries times on 429 rate-limit errors. + Waits the minimal amount: the remaining interval window reported by HubSpot headers. + Falls back to the full interval (10s) if headers are absent. + """ + for attempt in range(max_retries + 1): + try: + return fn() + except ApiException as e: + if e.status != 429 or attempt == max_retries: + raise + headers = e.headers or {} + interval_ms = int( + headers.get("x-hubspot-ratelimit-interval-milliseconds", 10000) + ) + wait_s = interval_ms / 1000.0 + self.logger.warning( + f"HubSpot 429 (attempt {attempt + 1}/{max_retries}), " + f"waiting {wait_s:.1f}s before retry." + ) + time.sleep(wait_s) + raise RuntimeError("Unreachable") # pragma: no cover + def get_deal_ids_from_company(self, company_id: str) -> list[str]: associations_api: AssociationsBasicApi = ( # type: ignore[reportUnknownMemberType] self.client.crm.associations.v4.basic_api # type: ignore[reportUnknownMemberType] @@ -92,12 +119,14 @@ class HubspotClient: after: Optional[str] = None while True: - response: AssociationsPageResponse = associations_api.get_page( # type: ignore[reportUnknownMemberType] - object_type="companies", - object_id=company_id, - to_object_type="deals", - limit=100, - after=after, + response: AssociationsPageResponse = self._call_with_retry( + lambda: associations_api.get_page( # type: ignore[reportUnknownMemberType] + object_type="companies", + object_id=company_id, + to_object_type="deals", + limit=100, + after=after, + ) ) results: list[AssociationsResult] = cast(list[AssociationsResult], response.results) # type: ignore[reportUnknownMemberType] @@ -127,11 +156,13 @@ class HubspotClient: associations_api: AssociationsBasicApi = self.client.crm.associations.v4.basic_api # type: ignore[reportUnknownMemberType] # Fetch associations for this specific deal only - response: AssociationsPageResponse = associations_api.get_page( # type: ignore[reportUnknownMemberType] - object_type="deals", - object_id=deal_id, - to_object_type="companies", - limit=1, # Expect only one associated company + response: AssociationsPageResponse = self._call_with_retry( + lambda: associations_api.get_page( # type: ignore[reportUnknownMemberType] + object_type="deals", + object_id=deal_id, + to_object_type="companies", + limit=1, + ) ) results: list[AssociationsResult] = cast(list[AssociationsResult], response.results) # type: ignore[reportUnknownMemberType] @@ -161,11 +192,13 @@ class HubspotClient: listings_api: ObjectsBasicApi = self.client.crm.objects.basic_api # type: ignore[reportUnknownMemberType] # works for custom objects like "listing" # Fetch associated listing(s) - response: AssociationsPageResponse = associations_api.get_page( # type: ignore[reportUnknownMemberType] - object_type="deals", - object_id=deal_id, - to_object_type="0-420", # <-- to get an listing object - limit=1, + response: AssociationsPageResponse = self._call_with_retry( + lambda: associations_api.get_page( # type: ignore[reportUnknownMemberType] + object_type="deals", + object_id=deal_id, + to_object_type="0-420", + limit=1, + ) ) results: list[AssociationsResult] = cast(list[AssociationsResult], response.results) # type: ignore[reportUnknownMemberType] @@ -178,14 +211,16 @@ class HubspotClient: self.logger.info(f"Associated listing ID for deal {deal_id}: {listing_id}") # Fetch listing details (the "listing information") - listing: HubspotObject = listings_api.get_by_id( # type: ignore[reportUnknownMemberType] - object_type="0-420", # again, must match your HubSpot object name - object_id=listing_id, - properties=[ - "national_uprn", - "domna_property_id", - "owner_property_id", - ], + listing: HubspotObject = self._call_with_retry( + lambda: listings_api.get_by_id( # type: ignore[reportUnknownMemberType] + object_type="0-420", + object_id=listing_id, + properties=[ + "national_uprn", + "domna_property_id", + "owner_property_id", + ], + ) ) listing_info: dict[str, str] = cast(dict[str, str], listing.properties) # type: ignore[reportUnknownMemberType] @@ -196,44 +231,47 @@ class HubspotClient: def from_deal_id_get_info(self, deal_id: str) -> dict[str, str]: deals_api: DealsBasicApi = self.client.crm.deals.basic_api # type: ignore[reportUnknownMemberType] - deal: HubspotObject = deals_api.get_by_id( # type: ignore[reportUnknownMemberType] - deal_id, - properties=[ - "dealname", - "dealstage", - "pipeline", - "outcome", - "outcome_notes", - "project_code", - "major_condition_issue_description", - "major_condition_issue_photos", - "coordination_status__stage_1_", - "retrofit_design_status", - "pashub_link", - "sharepoint_link", - "dampmould_growth", - "pre_sap", - "coordinator", - "mtp_completion_date", - "mtp_re_model_completion_date", - "ioe_v3_completion_date", - "proposed_measures", - "approved_package", - "designer", - "design_completion_date", - "actual_measures_installed", - "installer", - "installer_handover", - "lodgement_status", - "measures_lodgement_date", - "lodgement_date", - "expected_commencement_date", - "surveyor", - "confirmed_survey_date", - "confirmed_survey_time", - "surveyed_date", - "design_type", - ], + deal: HubspotObject = self._call_with_retry( + lambda: deals_api.get_by_id( # type: ignore[reportUnknownMemberType] + deal_id, + properties=[ + "dealname", + "dealstage", + "pipeline", + "outcome", + "outcome_notes", + "project_code", + "major_condition_issue_description", + "major_condition_issue_photos", + "coordination_status__stage_1_", + "retrofit_design_status", + "pashub_link", + "sharepoint_link", + "dampmould_growth", + "damp_mould_and_repairs_comments", + "pre_sap", + "coordinator", + "mtp_completion_date", + "mtp_re_model_completion_date", + "ioe_v3_completion_date", + "proposed_measures", + "approved_package", + "designer", + "design_completion_date", + "actual_measures_installed", + "installer", + "installer_handover", + "lodgement_status", + "measures_lodgement_date", + "lodgement_date", + "expected_commencement_date", + "surveyor", + "confirmed_survey_date", + "confirmed_survey_time", + "surveyed_date", + "design_type", + ], + ) ) deal_info: dict[str, str] = cast(dict[str, str], deal.properties) # type: ignore[reportUnknownMemberType] @@ -260,11 +298,11 @@ class HubspotClient: def get_company_information(self, company_id: str) -> CompanyData: companies_api: CompaniesBasicApi = self.client.crm.companies.basic_api # type: ignore[reportUnknownMemberType] - company: HubspotObject = companies_api.get_by_id( # type: ignore[reportUnknownMemberType] - company_id, - properties=[ - "name", - ], + company: HubspotObject = self._call_with_retry( + lambda: companies_api.get_by_id( # type: ignore[reportUnknownMemberType] + company_id, + properties=["name"], + ) ) company_info: CompanyData = company.properties # type: ignore[reportUnknownMemberType] @@ -401,8 +439,10 @@ class HubspotClient: def create_line_item_from_product(self, product_id: str, quantity: int = 1) -> str: # Fetch product mapping products_api: ProductsBasicApi = self.client.crm.products.basic_api # type: ignore[reportUnknownMemberType] - product: HubspotObject = products_api.get_by_id( # type: ignore[reportUnknownMemberType] - product_id, properties=["name", "price", "hs_price"] + product: HubspotObject = self._call_with_retry( + lambda: products_api.get_by_id( # type: ignore[reportUnknownMemberType] + product_id, properties=["name", "price", "hs_price"] + ) ) properties: dict[str, str] = cast(dict[str, str], product.properties) # type: ignore[reportUnknownMemberType] @@ -423,7 +463,9 @@ class HubspotClient: # Create line item line_items_api: LineItemsBasicApi = self.client.crm.line_items.basic_api # type: ignore[reportUnknownMemberType] - line_item: HubspotObject = line_items_api.create(line_item_input) # type: ignore[reportUnknownMemberType] + line_item: HubspotObject = self._call_with_retry( + lambda: line_items_api.create(line_item_input) # type: ignore[reportUnknownMemberType] + ) return cast(str, line_item.id) # type: ignore[reportUnknownMemberType] def associate_line_item_to_deal(self, line_item_id: str, deal_id: str) -> None: @@ -431,17 +473,19 @@ class HubspotClient: association_api: AssociationsBasicApi = self.client.crm.associations.v4.basic_api # type: ignore[reportUnknownMemberType] - association_api.create( # type: ignore[reportUnknownMemberType] - "0-3", # to object type - deal_id, # to object id - "line_items", # from object type - line_item_id, # from object id - [ - AssociationSpec( - association_category="HUBSPOT_DEFINED", - association_type_id=19, # line_item → deal - ) - ], + self._call_with_retry( + lambda: association_api.create( # type: ignore[reportUnknownMemberType] + "0-3", + deal_id, + "line_items", + line_item_id, + [ + AssociationSpec( + association_category="HUBSPOT_DEFINED", + association_type_id=19, + ) + ], + ) ) def add_product_line_item_to_deal( diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 7f06a29d..ac980649 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -181,6 +181,11 @@ class HubspotDataToDb: deal_in_db.dampmould_growth == hs_deal.get("dampmould_growth"), "dampmould_growth mismatch", ), + soft_assert( + deal_in_db.damp_mould_and_repairs_comments + == hs_deal.get("damp_mould_and_repairs_comments"), + "damp_mould_and_repairs_comments mismatch", + ), soft_assert( deal_in_db.pre_sap == hs_deal.get("pre_sap"), "pre_sap mismatch", @@ -190,15 +195,18 @@ class HubspotDataToDb: "coordinator mismatch", ), soft_assert( - deal_in_db.mtp_completion_date == self._parse_hs_date(hs_deal.get("mtp_completion_date")), + deal_in_db.mtp_completion_date + == self._parse_hs_date(hs_deal.get("mtp_completion_date")), "mtp_completion_date mismatch", ), soft_assert( - deal_in_db.mtp_re_model_completion_date == self._parse_hs_date(hs_deal.get("mtp_re_model_completion_date")), + deal_in_db.mtp_re_model_completion_date + == self._parse_hs_date(hs_deal.get("mtp_re_model_completion_date")), "mtp_re_model_completion_date mismatch", ), soft_assert( - deal_in_db.ioe_v3_completion_date == self._parse_hs_date(hs_deal.get("ioe_v3_completion_date")), + deal_in_db.ioe_v3_completion_date + == self._parse_hs_date(hs_deal.get("ioe_v3_completion_date")), "ioe_v3_completion_date mismatch", ), soft_assert( @@ -214,11 +222,13 @@ class HubspotDataToDb: "designer mismatch", ), soft_assert( - deal_in_db.design_completion_date == self._parse_hs_date(hs_deal.get("design_completion_date")), + deal_in_db.design_completion_date + == self._parse_hs_date(hs_deal.get("design_completion_date")), "design_completion_date mismatch", ), soft_assert( - deal_in_db.actual_measures_installed == hs_deal.get("actual_measures_installed"), + deal_in_db.actual_measures_installed + == hs_deal.get("actual_measures_installed"), "actual_measures_installed mismatch", ), soft_assert( @@ -234,15 +244,18 @@ class HubspotDataToDb: "lodgement_status mismatch", ), soft_assert( - deal_in_db.measures_lodgement_date == self._parse_hs_date(hs_deal.get("measures_lodgement_date")), + deal_in_db.measures_lodgement_date + == self._parse_hs_date(hs_deal.get("measures_lodgement_date")), "measures_lodgement_date mismatch", ), soft_assert( - deal_in_db.lodgement_date == self._parse_hs_date(hs_deal.get("lodgement_date")), + deal_in_db.lodgement_date + == self._parse_hs_date(hs_deal.get("lodgement_date")), "lodgement_date mismatch", ), soft_assert( - deal_in_db.expected_commencement_date == self._parse_hs_date(hs_deal.get("expected_commencement_date")), + deal_in_db.expected_commencement_date + == self._parse_hs_date(hs_deal.get("expected_commencement_date")), "expected_commencement_date mismatch", ), soft_assert( @@ -250,15 +263,18 @@ class HubspotDataToDb: "surveyor mismatch", ), soft_assert( - deal_in_db.confirmed_survey_date == self._parse_hs_date(hs_deal.get("confirmed_survey_date")), + deal_in_db.confirmed_survey_date + == self._parse_hs_date(hs_deal.get("confirmed_survey_date")), "confirmed_survey_date mismatch", ), soft_assert( - deal_in_db.confirmed_survey_time == hs_deal.get("confirmed_survey_time"), + deal_in_db.confirmed_survey_time + == hs_deal.get("confirmed_survey_time"), "confirmed_survey_time mismatch", ), soft_assert( - deal_in_db.surveyed_date == self._parse_hs_date(hs_deal.get("surveyed_date")), + deal_in_db.surveyed_date + == self._parse_hs_date(hs_deal.get("surveyed_date")), "surveyed_date mismatch", ), soft_assert( @@ -369,26 +385,47 @@ class HubspotDataToDb: "pashub_link": deal_data.get("pashub_link"), "sharepoint_link": deal_data.get("sharepoint_link"), "dampmould_growth": deal_data.get("dampmould_growth"), + "damp_mould_and_repairs_comments": deal_data.get("damp_mould_and_repairs_comments"), "pre_sap": deal_data.get("pre_sap"), "coordinator": deal_data.get("coordinator"), - "mtp_completion_date": self._parse_hs_date(deal_data.get("mtp_completion_date")), - "mtp_re_model_completion_date": self._parse_hs_date(deal_data.get("mtp_re_model_completion_date")), - "ioe_v3_completion_date": self._parse_hs_date(deal_data.get("ioe_v3_completion_date")), + "mtp_completion_date": self._parse_hs_date( + deal_data.get("mtp_completion_date") + ), + "mtp_re_model_completion_date": self._parse_hs_date( + deal_data.get("mtp_re_model_completion_date") + ), + "ioe_v3_completion_date": self._parse_hs_date( + deal_data.get("ioe_v3_completion_date") + ), "proposed_measures": deal_data.get("proposed_measures"), "approved_package": deal_data.get("approved_package"), "designer": deal_data.get("designer"), - "design_completion_date": self._parse_hs_date(deal_data.get("design_completion_date")), - "actual_measures_installed": deal_data.get("actual_measures_installed"), + "design_completion_date": self._parse_hs_date( + deal_data.get("design_completion_date") + ), + "actual_measures_installed": deal_data.get( + "actual_measures_installed" + ), "installer": deal_data.get("installer"), "installer_handover": deal_data.get("installer_handover"), "lodgement_status": deal_data.get("lodgement_status"), - "measures_lodgement_date": self._parse_hs_date(deal_data.get("measures_lodgement_date")), - "lodgement_date": self._parse_hs_date(deal_data.get("lodgement_date")), - "expected_commencement_date": self._parse_hs_date(deal_data.get("expected_commencement_date")), + "measures_lodgement_date": self._parse_hs_date( + deal_data.get("measures_lodgement_date") + ), + "lodgement_date": self._parse_hs_date( + deal_data.get("lodgement_date") + ), + "expected_commencement_date": self._parse_hs_date( + deal_data.get("expected_commencement_date") + ), "surveyor": deal_data.get("surveyor"), - "confirmed_survey_date": self._parse_hs_date(deal_data.get("confirmed_survey_date")), + "confirmed_survey_date": self._parse_hs_date( + deal_data.get("confirmed_survey_date") + ), "confirmed_survey_time": deal_data.get("confirmed_survey_time"), - "surveyed_date": self._parse_hs_date(deal_data.get("surveyed_date")), + "surveyed_date": self._parse_hs_date( + deal_data.get("surveyed_date") + ), "design_type": deal_data.get("design_type"), }.items(): setattr(existing, attr, value or getattr(existing, attr)) @@ -435,7 +472,7 @@ class HubspotDataToDb: deal_id=deal_id, dealname=deal_data.get("dealname"), dealstage=deal_data.get("dealstage"), - listing_id=listing.get("listing_id"), + listing_id=listing.get("listing_id", None), landlord_property_id=listing.get("owner_property_id"), uprn=listing.get("national_uprn"), outcome=deal_data.get("outcome"), @@ -453,24 +490,41 @@ class HubspotDataToDb: pashub_link=deal_data.get("pashub_link"), sharepoint_link=deal_data.get("sharepoint_link"), dampmould_growth=deal_data.get("dampmould_growth"), + damp_mould_and_repairs_comments=deal_data.get("damp_mould_and_repairs_comments"), pre_sap=deal_data.get("pre_sap"), coordinator=deal_data.get("coordinator"), - mtp_completion_date=self._parse_hs_date(deal_data.get("mtp_completion_date")), - mtp_re_model_completion_date=self._parse_hs_date(deal_data.get("mtp_re_model_completion_date")), - ioe_v3_completion_date=self._parse_hs_date(deal_data.get("ioe_v3_completion_date")), + mtp_completion_date=self._parse_hs_date( + deal_data.get("mtp_completion_date") + ), + mtp_re_model_completion_date=self._parse_hs_date( + deal_data.get("mtp_re_model_completion_date") + ), + ioe_v3_completion_date=self._parse_hs_date( + deal_data.get("ioe_v3_completion_date") + ), proposed_measures=deal_data.get("proposed_measures"), approved_package=deal_data.get("approved_package"), designer=deal_data.get("designer"), - design_completion_date=self._parse_hs_date(deal_data.get("design_completion_date")), - actual_measures_installed=deal_data.get("actual_measures_installed"), + design_completion_date=self._parse_hs_date( + deal_data.get("design_completion_date") + ), + actual_measures_installed=deal_data.get( + "actual_measures_installed" + ), installer=deal_data.get("installer"), installer_handover=deal_data.get("installer_handover"), lodgement_status=deal_data.get("lodgement_status"), - measures_lodgement_date=self._parse_hs_date(deal_data.get("measures_lodgement_date")), + measures_lodgement_date=self._parse_hs_date( + deal_data.get("measures_lodgement_date") + ), lodgement_date=self._parse_hs_date(deal_data.get("lodgement_date")), - expected_commencement_date=self._parse_hs_date(deal_data.get("expected_commencement_date")), + expected_commencement_date=self._parse_hs_date( + deal_data.get("expected_commencement_date") + ), surveyor=deal_data.get("surveyor"), - confirmed_survey_date=self._parse_hs_date(deal_data.get("confirmed_survey_date")), + confirmed_survey_date=self._parse_hs_date( + deal_data.get("confirmed_survey_date") + ), confirmed_survey_time=deal_data.get("confirmed_survey_time"), surveyed_date=self._parse_hs_date(deal_data.get("surveyed_date")), design_type=deal_data.get("design_type"), diff --git a/etl/hubspot/scripts/scraper/bulk_load.py b/etl/hubspot/scripts/scraper/bulk_load.py index 5dc9570e..de4149ae 100644 --- a/etl/hubspot/scripts/scraper/bulk_load.py +++ b/etl/hubspot/scripts/scraper/bulk_load.py @@ -2,11 +2,18 @@ from etl.hubspot.hubspotClient import HubspotClient, Companies, Pipeline from etl.hubspot.scripts.scraper.main import handler from tqdm import tqdm import json +import time PIPELINE_ID = Pipeline.OPERATIONS_SOCIAL_HOUSING.value -companies = list([Companies.THE_GUINESS_PARTNERSHIP, Companies.SOUTHERN_HOUSING_GROUP]) +companies = list( + [ + # Companies.THE_GUINESS_PARTNERSHIP, + # Companies.SOUTHERN_HOUSING_GROUP, + Companies.CALICO_HOMES, + ] +) def bulk_load(companies: list[Companies] | None = None) -> None: @@ -17,20 +24,23 @@ def bulk_load(companies: list[Companies] | None = None) -> None: hubspot = HubspotClient() targets = companies or list(Companies) - for company in tqdm(targets, desc="Companies", unit="co"): + for company in tqdm(targets, desc="Companies", unit="co", leave=False): company_id = company.value deal_ids = hubspot.get_deal_ids_from_company(company_id) processed = 0 - with tqdm(deal_ids, desc=company.name, unit="deal", leave=False) as deal_bar: + with tqdm(deal_ids, desc=company.name, unit="deal", leave=True, position=0) as deal_bar: for deal_id in deal_bar: deal_data = hubspot.from_deal_id_get_info(deal_id) if deal_data.get("pipeline") != PIPELINE_ID: deal_bar.set_postfix({"status": "skip", "deal": deal_id}) continue - + time.sleep(5) deal_bar.set_postfix({"status": "uploading", "deal": deal_id}) - handler({"Records": [{"body": json.dumps({"hubspot_deal_id": deal_id})}]}, context=None) + handler( + {"Records": [{"body": json.dumps({"hubspot_deal_id": deal_id})}]}, + context=None, + ) processed += 1 deal_bar.set_postfix({"status": "done", "deal": deal_id}) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 55a7a372..99311f6b 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -1,17 +1,9 @@ -""" -1) [completed]Get hubspot deal properties from one deal -2) Put it in some class -3) [completed] Load the db and check if upsert it into the table -4) [completed]Getting working on a AWS lambda -5) [completed] subtask and tasks history -6) [completed]The new sexy deal properties, move it over -""" - from etl.hubspot.hubspotClient import HubspotClient from etl.hubspot.hubspotDataTodB import HubspotDataToDb from backend.utils.subtasks import task_handler from typing import Any import json +import time @task_handler() @@ -25,9 +17,7 @@ def handler(body: dict[str, Any], context: Any) -> None: hubspot: HubspotClient = HubspotClient() dbloader: HubspotDataToDb = HubspotDataToDb() - deal = dbloader.find_deal_with_deal_id(hubspot_deal_id) - if deal: dbloader.update_deal_with_checks(deal, hubspot) else: From e1ea6f79f9389f9db767bded2d3a9c36bb35508c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Apr 2026 10:19:40 +0000 Subject: [PATCH 21/93] added dampandmouldandrepaircomments, more resilence on retries with hubspot api --- etl/hubspot/hubspotClient.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index 20f90944..72cf2b9c 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -91,14 +91,18 @@ class HubspotClient: Call fn(), retrying up to max_retries times on 429 rate-limit errors. Waits the minimal amount: the remaining interval window reported by HubSpot headers. Falls back to the full interval (10s) if headers are absent. + + Note: each HubSpot sub-module (deals, companies, etc.) ships its own ApiException + class with no shared base beyond Exception, so we detect 429s via duck-typing. """ for attempt in range(max_retries + 1): try: return fn() - except ApiException as e: - if e.status != 429 or attempt == max_retries: + except Exception as e: + status = getattr(e, "status", None) + if status != 429 or attempt == max_retries: raise - headers = e.headers or {} + headers = getattr(e, "headers", None) or {} interval_ms = int( headers.get("x-hubspot-ratelimit-interval-milliseconds", 10000) ) From 4c696c3d4c044f81dbe44aedd53fbd3dd28c33f1 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Apr 2026 10:21:22 +0000 Subject: [PATCH 22/93] remove time --- etl/hubspot/scripts/scraper/bulk_load.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/etl/hubspot/scripts/scraper/bulk_load.py b/etl/hubspot/scripts/scraper/bulk_load.py index de4149ae..91aa89e2 100644 --- a/etl/hubspot/scripts/scraper/bulk_load.py +++ b/etl/hubspot/scripts/scraper/bulk_load.py @@ -2,7 +2,6 @@ from etl.hubspot.hubspotClient import HubspotClient, Companies, Pipeline from etl.hubspot.scripts.scraper.main import handler from tqdm import tqdm import json -import time PIPELINE_ID = Pipeline.OPERATIONS_SOCIAL_HOUSING.value @@ -29,13 +28,14 @@ def bulk_load(companies: list[Companies] | None = None) -> None: deal_ids = hubspot.get_deal_ids_from_company(company_id) processed = 0 - with tqdm(deal_ids, desc=company.name, unit="deal", leave=True, position=0) as deal_bar: + with tqdm( + deal_ids, desc=company.name, unit="deal", leave=True, position=0 + ) as deal_bar: for deal_id in deal_bar: deal_data = hubspot.from_deal_id_get_info(deal_id) if deal_data.get("pipeline") != PIPELINE_ID: deal_bar.set_postfix({"status": "skip", "deal": deal_id}) continue - time.sleep(5) deal_bar.set_postfix({"status": "uploading", "deal": deal_id}) handler( {"Records": [{"body": json.dumps({"hubspot_deal_id": deal_id})}]}, From 7f3a99ba6ef48b61f511a242b7c1162906fb6c11 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Apr 2026 10:24:11 +0000 Subject: [PATCH 23/93] use ANy instead --- etl/hubspot/hubspotClient.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index 72cf2b9c..a9ea535d 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -1,12 +1,10 @@ import os import time from enum import Enum -from typing import Optional, cast, Callable, TypeVar +from typing import Optional, cast, Callable, Any from hubspot.client import Client # type: ignore[reportMissingTypeStubs] from hubspot.crm.associations import ApiException # type: ignore[reportMissingTypeStubs] - -T = TypeVar("T") from hubspot.crm.objects import SimplePublicObjectInput # type: ignore[reportMissingTypeStubs] from hubspot.crm.objects.api.basic_api import BasicApi as ObjectsBasicApi # type: ignore[reportMissingTypeStubs] from hubspot.crm.deals.api.basic_api import BasicApi as DealsBasicApi # type: ignore[reportMissingTypeStubs] @@ -86,7 +84,7 @@ class HubspotClient: # Sorry - not sorry but enjoy, Past Junte 13/03/2026 # self.client - def _call_with_retry(self, fn: Callable[[], T], max_retries: int = 2) -> T: + def _call_with_retry(self, fn: Callable[[], Any], max_retries: int = 2) -> Any: """ Call fn(), retrying up to max_retries times on 429 rate-limit errors. Waits the minimal amount: the remaining interval window reported by HubSpot headers. From e73e8ea18e8612f68999b0f54913c77c36354d91 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Apr 2026 10:24:31 +0000 Subject: [PATCH 24/93] memory --- MEMORY.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 MEMORY.md diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 00000000..5229429e --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,22 @@ +# Project Memory + +## HubSpot New Field Addition Process + +When adding a new field from HubSpot to the deal table, touch these 4 locations in order: + +1. **DB model** — `backend/app/db/models/organisation.py` + Add `field_name: Optional[str] = Field(default=None)` to `HubspotDealData`, near related fields. + +2. **HubSpot API fetch** — `etl/hubspot/hubspotClient.py`, `from_deal_id_get_info()` + Add the HubSpot internal property name string to the `properties=[...]` list. + +3. **Soft check** — `etl/hubspot/hubspotDataTodB.py`, `update_deal_with_checks()` + Add a `soft_assert(deal_in_db.field_name == hs_deal.get("hs_property_name"), "field_name mismatch")` entry to the `checks` list. + +4. **Upsert** — `etl/hubspot/hubspotDataTodB.py`, `upsert_deal()` + - **Update branch**: add `"field_name": deal_data.get("hs_property_name")` to the attr dict + - **Insert branch**: add `field_name=deal_data.get("hs_property_name")` to the `HubspotDealData(...)` constructor + +**Notes:** +- Date fields: wrap with `self._parse_hs_date(deal_data.get(...))` in steps 3 & 4. +- If HubSpot property name differs from DB column name (e.g. `coordination_status__stage_1_` → `coordination_status`), use the HS name in `.get()` and the DB name as the key/attr. From 0009a50dc5edfa1808a4b93df7f4d22c25261333 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Apr 2026 10:48:54 +0000 Subject: [PATCH 25/93] check for end of table by inspecting content instead of whether Next button is disabled --- backend/ecmk_fetcher/handler/handler.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/ecmk_fetcher/handler/handler.py b/backend/ecmk_fetcher/handler/handler.py index c4c1385c..4c5299d0 100644 --- a/backend/ecmk_fetcher/handler/handler.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -169,19 +169,25 @@ def go_to_assessment_details(page: Page, row: Locator) -> None: logger.info("Assessment details page fully loaded") +def get_first_row_signature(page: Page) -> str: + first_row = page.locator("#assessmentDatatable tbody tr").first + return first_row.inner_text() + + def go_to_next_page(page: Page) -> bool: - next_button: Locator = page.locator("#assessmentDatatable_next a") + first_signature_before = get_first_row_signature(page) - class_attr: Optional[str] = next_button.get_attribute("class") or "" - - if "disabled" in class_attr: - logger.info("No more pages") - return False - - next_button.scroll_into_view_if_needed() + next_button = page.locator("#assessmentDatatable_next a") next_button.click() page.wait_for_timeout(2000) + + first_signature_after = get_first_row_signature(page) + + if first_signature_before == first_signature_after: + logger.info("No page change detected - reached end of table") + return False + return True From 998a7324280e0ed251ab46d51237ac772a5264da Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Apr 2026 11:16:08 +0000 Subject: [PATCH 26/93] refactor --- backend/ecmk_fetcher/address_list.py | 55 ++++ backend/ecmk_fetcher/browser.py | 98 ++++++ backend/ecmk_fetcher/handler/handler.py | 408 +----------------------- backend/ecmk_fetcher/processor.py | 121 +++++++ backend/ecmk_fetcher/reports.py | 25 ++ backend/ecmk_fetcher/sharepoint.py | 20 ++ 6 files changed, 322 insertions(+), 405 deletions(-) create mode 100644 backend/ecmk_fetcher/address_list.py create mode 100644 backend/ecmk_fetcher/browser.py create mode 100644 backend/ecmk_fetcher/processor.py create mode 100644 backend/ecmk_fetcher/reports.py create mode 100644 backend/ecmk_fetcher/sharepoint.py diff --git a/backend/ecmk_fetcher/address_list.py b/backend/ecmk_fetcher/address_list.py new file mode 100644 index 00000000..d273c45d --- /dev/null +++ b/backend/ecmk_fetcher/address_list.py @@ -0,0 +1,55 @@ +from typing import Dict, Optional +from openpyxl import load_workbook +import re + + +def extract_addresses_from_spreadsheet(filepath: str) -> Dict[str, str]: + wb = load_workbook(filepath, data_only=True) + ws = wb["Southern RA-Lite Programme 3103"] + + properties: Dict[str, str] = {} + + header_row = 1 + id_col_index = None + deal_name_col_index = None + + for col in range(1, ws.max_column + 1): + value = ws.cell(row=header_row, column=col).value + + if value and str(value).strip().lower() == "id": + id_col_index = col + + if value and str(value).strip().lower() == "deal name": + deal_name_col_index = col + break + + if id_col_index is None or deal_name_col_index is None: + raise Exception("Required columns not found") + + for row in range(2, ws.max_row + 1): + id_val = ws.cell(row=row, column=id_col_index).value + deal_name = ws.cell(row=row, column=deal_name_col_index).value + + if not id_val or not deal_name: + continue + + properties[str(id_val).strip()] = extract_succinct_address( + str(deal_name).strip() + ) + + return properties + + +def extract_succinct_address(deal_name: str) -> str: + left_part = deal_name.split("|")[0].strip() + + postcode_match: Optional[re.Match[str]] = re.search( + r"\b([A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2})\b", + left_part, + re.IGNORECASE, + ) + + postcode = postcode_match.group(1).upper() if postcode_match else None + first_part = left_part.split(",")[0].strip() + + return f"{first_part} {postcode}" if postcode else first_part diff --git a/backend/ecmk_fetcher/browser.py b/backend/ecmk_fetcher/browser.py new file mode 100644 index 00000000..6d018537 --- /dev/null +++ b/backend/ecmk_fetcher/browser.py @@ -0,0 +1,98 @@ +import os +from typing import Optional +from playwright.sync_api import Page, Locator, Response +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +from backend.ecmk_fetcher.reports import build_report_selector +from utils.logger import setup_logger + +# from .reports import build_report_selector + +logger = setup_logger() + + +def attach_debug_listeners(page: Page) -> None: + def handle_response(response: Response) -> None: + if "download" in response.url or "report" in response.url: + logger.info(f"[RESPONSE] {response.status} {response.url}") + + page.on("response", handle_response) + + +def login(page: Page, username: str, password: str) -> None: + page.goto("https://assessorhub.net/", timeout=30000) + + page.locator("#Username").fill(username) + page.locator("#Password").fill(password) + + with page.expect_navigation(): + page.click("button[type='submit']") + + if "login" in page.url.lower(): + raise Exception("Login failed") + + logger.info("Login successful") + + +def go_to_assessments(page: Page) -> None: + page.goto("https://assessorhub.net/Companies/Assessments") + page.wait_for_selector("#assessmentDatatable tbody tr") + + +def go_to_assessment_details(page: Page, row: Locator) -> None: + row.locator("a").click() + page.wait_for_load_state("networkidle") + page.wait_for_selector("a.download-report-btn") + + +def get_first_row_signature(page: Page) -> str: + return page.locator("#assessmentDatatable tbody tr").first.inner_text() + + +def go_to_next_page(page: Page) -> bool: + before = get_first_row_signature(page) + + page.locator("#assessmentDatatable_next a").click() + page.wait_for_timeout(2000) + + after = get_first_row_signature(page) + + return before != after + + +def download_report_by_selector(page: Page, selector: str) -> Optional[str]: + try: + element = page.locator(selector) + element.wait_for(state="visible", timeout=10000) + + if not element.is_enabled(): + return None + + element.scroll_into_view_if_needed() + + with page.expect_download(timeout=15000) as download_info: + element.click() + + download = download_info.value + filename = download.suggested_filename + + save_path = os.path.join(os.getcwd(), filename) + download.save_as(save_path) + + return save_path + + except PlaywrightTimeoutError: + logger.error(f"Download failed for {selector}") + return None + + +def download_with_retry(page: Page, report_type: int) -> Optional[str]: + selector: str = build_report_selector(report_type) + + for _ in range(3): + file_path = download_report_by_selector(page, selector) + if file_path: + return file_path + page.wait_for_timeout(1500) + + return None diff --git a/backend/ecmk_fetcher/handler/handler.py b/backend/ecmk_fetcher/handler/handler.py index 4c5299d0..4ce3a949 100644 --- a/backend/ecmk_fetcher/handler/handler.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -1,412 +1,10 @@ -from datetime import datetime, timezone -import os -from enum import Enum -import re -from typing import Any, Dict, List, Mapping, Optional -from openpyxl import load_workbook -from playwright.sync_api import ( - Locator, - Page, - Response, - sync_playwright, - TimeoutError as PlaywrightTimeoutError, -) +from typing import Any, Mapping -from backend.app.db.connection import db_session -from backend.app.db.models.uploaded_file import FileSourceEnum, UploadedFile -from utils.logger import setup_logger -from utils.s3 import upload_file_to_s3 -from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient -from utils.sharepoint.domna_sites import DomnaSites - -logger = setup_logger() - - -class file_download_button_types(Enum): - ASSESSOR_HUB_SITENOTE_REPORT = 11 - CERTIFICATE = 9 - SITENOTE_REPORT = 8 - RAW_XML = 7 - SAP_WORK_SHEET = 15 - - -def attach_debug_listeners(page: Page) -> None: - def handle_response(response: Response) -> None: - url: str = response.url - status: int = response.status - - if "download" in url or "report" in url: - logger.info(f"[RESPONSE] {status} {url}") - - if status >= 400: - logger.error(f"[ERROR RESPONSE] {status} {url}") - - page.on("response", handle_response) - - -def extract_addresses_from_spreadsheet(filepath: str) -> Dict[str, str]: - wb = load_workbook(filepath, data_only=True) - ws = wb["Southern RA-Lite Programme 3103"] - - properties: Dict[str, str] = {} - - header_row = 1 - id_col_index = None - deal_name_col_index = None - - for col in range(1, ws.max_column + 1): - cell_value = ws.cell(row=header_row, column=col).value - - if cell_value and str(cell_value).strip().lower() == "id": - id_col_index = col - - if cell_value and str(cell_value).strip().lower() == "deal name": - deal_name_col_index = col - break - - if id_col_index is None: - raise Exception("ID column not found in spreadsheet") - - if deal_name_col_index is None: - raise Exception("Deal Name column not found in spreadsheet") - - for row in range(2, ws.max_row + 1): - id_cell_value = ws.cell(row=row, column=id_col_index).value - deal_name_cell_value = ws.cell(row=row, column=deal_name_col_index).value - - if id_cell_value is None or deal_name_cell_value is None: - continue - - id_str = str(id_cell_value).strip() - deal_name_str = str(deal_name_cell_value).strip() - - if not id_str: - continue - - sharepoint_address = extract_succinct_address(deal_name_str) - - properties[id_str] = sharepoint_address - - return properties - - -def extract_succinct_address(deal_name: str) -> str: - """ - Input: - '1 My Random Close, Town, AB12 3DC | Retrofit Assessment' - - Output: - '1 My Random Close AB12 3DC' - """ - left_part = deal_name.split("|")[0].strip() - - postcode_match: Optional[re.Match[str]] = re.search( - r"\b([A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2})\b", - left_part, - re.IGNORECASE, - ) - - postcode = None - if postcode_match: - postcode = postcode_match.group(1).upper() - - first_part = left_part.split(",")[0].strip() - - if postcode: - return f"{first_part} {postcode}" - else: - return first_part - - -def build_property_id(address: str, postcode: str) -> str: - """ - Extract number from address and concat with postcode - Example: - '9 Random Close', 'AB1 2YZ' → '9AB12YZ' - """ - number = address.split(" ")[0] - - postcode_clean = postcode.replace(" ", "").upper() - - return f"{number}{postcode_clean}" - - -def login(page: Page, username: str, password: str) -> None: - page.goto("https://assessorhub.net/", timeout=30000) - - username_input: Locator = page.locator("#Username") - password_input: Locator = page.locator("#Password") - - username_input.wait_for(state="visible", timeout=10000) - username_input.fill(username) - - password_input.wait_for(state="visible", timeout=10000) - password_input.fill(password) - - with page.expect_navigation(timeout=15000): - page.click("button[type='submit']") - - if "login" in page.url.lower(): - raise Exception("Login failed") - - logger.info("Login successful") - - -def go_to_assessments(page: Page) -> None: - page.goto("https://assessorhub.net/Companies/Assessments", timeout=30000) - page.wait_for_selector("#assessmentDatatable tbody tr", timeout=20000) - - -def go_to_assessment_details(page: Page, row: Locator) -> None: - account_link: Locator = row.locator("a") - with page.expect_navigation(): - account_link.click() - - page.wait_for_load_state("networkidle") - - page.wait_for_selector("a.download-report-btn", timeout=10000) - - logger.info("Assessment details page fully loaded") - - -def get_first_row_signature(page: Page) -> str: - first_row = page.locator("#assessmentDatatable tbody tr").first - return first_row.inner_text() - - -def go_to_next_page(page: Page) -> bool: - first_signature_before = get_first_row_signature(page) - - next_button = page.locator("#assessmentDatatable_next a") - next_button.click() - - page.wait_for_timeout(2000) - - first_signature_after = get_first_row_signature(page) - - if first_signature_before == first_signature_after: - logger.info("No page change detected - reached end of table") - return False - - return True - - -def build_report_selector(report_type: int) -> str: - return f"a.download-report-btn[data-report-type='{report_type}']" - - -def download_report_by_selector(page: Page, selector: str) -> Optional[str]: - try: - element: Locator = page.locator(selector) - - element.wait_for(state="visible", timeout=10000) - - if not element.is_enabled(): - logger.warning(f"Element not enabled: {selector}") - return None - - element.scroll_into_view_if_needed() - - page.wait_for_timeout(300) - - logger.info(f"Attempting download via selector: {selector}") - logger.info(f"Current URL: {page.url}") - - with page.expect_download(timeout=15000) as download_info: - element.click() - - download = download_info.value - filename: str = download.suggested_filename - - save_path: str = os.path.join(os.getcwd(), filename) - download.save_as(save_path) - - logger.info(f"Downloaded: {filename}") - - return save_path - - except PlaywrightTimeoutError: - logger.error(f"Download NOT triggered for selector: {selector}") - logger.error(f"Current URL at failure: {page.url}") - - try: - content_snippet = page.content()[:1000] - logger.error(f"Page snippet: {content_snippet}") - except Exception: - pass - - return None - - -def download_with_retry(page: Page, selector: str) -> Optional[str]: - for attempt in range(3): - file_path = download_report_by_selector(page, selector) - - if file_path: - return file_path - - logger.warning(f"Retry {attempt + 1} for {selector}") - page.wait_for_timeout(1500) - - return None - - -def upload_job_to_s3_and_update_db(job_files: List[str], uprn: str) -> None: - bucket = "retrofit-energy-assessments-dev" - - base_path = f"documents/uprn/{uprn}" - - uploaded_files: List[UploadedFile] = [] - - for file_path in job_files: - filename = os.path.basename(file_path) - file_key = f"{base_path}/{filename}" - - upload_file_to_s3(file_path, bucket, file_key) - - # load row to db - uploaded_files.append( - UploadedFile( - s3_file_bucket=bucket, - s3_file_key=file_key, - s3_upload_timestamp=datetime.now(timezone.utc), - uprn=int(uprn), - file_source=FileSourceEnum.ECMK.value, - ) - ) - - with db_session() as session: - session.add_all(uploaded_files) - session.commit() - - pass - - -def download_report() -> None: - username: str = "" - password: str = "" - - property_list_file: str = ( - "hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx" - ) - - BASE_DIR: str = os.path.dirname(os.path.dirname(__file__)) - filepath: str = os.path.join(BASE_DIR, property_list_file) - - property_id_to_address_map: Dict[str, str] = extract_addresses_from_spreadsheet( - filepath - ) - property_ids: List[str] = list(property_id_to_address_map.keys()) - - matching_properties: List[str] = [] - - sharepoint_client = DomnaSharepointClient( - sharepoint_location=DomnaSites.PRIVATE_PAY - ) - sharepoint_base_path = "/Projects/Southern Housing/SH-SURV-26-001/Assessments" - - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - - context = browser.new_context() - page = context.new_page() - attach_debug_listeners(page) - - try: - login(page, username, password) - print("Login successful:", page.url) - - go_to_assessments(page) - - while True: - rows: Locator = page.locator("#assessmentDatatable tbody tr") - row_count: int = rows.count() - - logger.info(f"Processing {row_count} rows on current page") - - for i in range(row_count): - row: Locator = rows.nth(i) - - try: - cells: Locator = row.locator("td") - - first_name: str = cells.nth(1).inner_text().strip() - last_name: str = cells.nth(2).inner_text().strip() - address: str = cells.nth(5).inner_text().strip() - postcode: str = cells.nth(7).inner_text().strip() - # uprn: str = cells.nth(8).inner_text().strip() - status: str = cells.nth(9).inner_text().strip() - - if first_name == "Oliver" and last_name == "Stephens": - continue - - if status != "Submitted (not Lodged)": - continue - - property_id: str = build_property_id(address, postcode) - - if property_id not in property_ids: - continue - - logger.info(f"MATCH FOUND: {property_id}") - matching_properties.append(property_id) - - sharepoint_address: str = property_id_to_address_map[ - property_id - ] - go_to_assessment_details(page, row) - - report_types: List[int] = [ - file_download_button_types.ASSESSOR_HUB_SITENOTE_REPORT.value, - file_download_button_types.SITENOTE_REPORT.value, - ] - - for report_type in report_types: - selector: str = build_report_selector(report_type) - file_path: Optional[str] = download_with_retry( - page, selector - ) - - if not file_path: - continue - try: - sharepoint_client.upload_file( - file_path=file_path, - sharepoint_path=f"{sharepoint_base_path}/{sharepoint_address}/1. Retrofit Assessment/A. Assessment", - file_name=os.path.basename(file_path), - ) - logger.info( - f"Successfully uploaded file {os.path.basename(file_path)} to sharepoint" - ) - # TODO: could s3 load happen for all files at once to reduce db roundtrips? - # if uprn: - # upload_job_to_s3_and_update_db([file_path], uprn) - finally: - if os.path.exists(file_path): - os.remove(file_path) - logger.info(f"Deleted local file: {file_path}") - - page.go_back() - page.wait_for_selector( - "#assessmentDatatable tbody tr", timeout=15000 - ) - - except PlaywrightTimeoutError as e: - raise Exception(f"Timeout occurred: {str(e)}") - - if not go_to_next_page(page): - break - - except PlaywrightTimeoutError as e: - raise Exception(f"Timeout occurred: {str(e)}") - - finally: - context.close() - browser.close() +from backend.ecmk_fetcher.processor import run_job def handler(event: Mapping[str, Any], context: Any) -> None: - download_report() + run_job() if __name__ == "__main__": diff --git a/backend/ecmk_fetcher/processor.py b/backend/ecmk_fetcher/processor.py new file mode 100644 index 00000000..1852b867 --- /dev/null +++ b/backend/ecmk_fetcher/processor.py @@ -0,0 +1,121 @@ +import os +from typing import Dict, List + +from playwright.sync_api import ( + sync_playwright, + Locator, + Page, + Browser, + BrowserContext, +) + +from backend.ecmk_fetcher.address_list import extract_addresses_from_spreadsheet +from backend.ecmk_fetcher.browser import ( + attach_debug_listeners, + download_with_retry, + go_to_assessment_details, + go_to_assessments, + go_to_next_page, + login, +) +from backend.ecmk_fetcher.reports import REPORT_TYPES, build_property_id +from backend.ecmk_fetcher.sharepoint import upload_file_to_sharepoint +from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient +from utils.sharepoint.domna_sites import DomnaSites + + +def run_job() -> None: + username: str = "" + password: str = "" + + property_list_file: str = ( + "hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx" + ) + + BASE_DIR: str = os.path.dirname(__file__) + filepath: str = os.path.join(BASE_DIR, property_list_file) + + property_map: Dict[str, str] = extract_addresses_from_spreadsheet(filepath) + property_ids: List[str] = list(property_map.keys()) + + sharepoint_client: DomnaSharepointClient = DomnaSharepointClient( + sharepoint_location=DomnaSites.PRIVATE_PAY + ) + + sharepoint_base_path: str = "/Projects/Southern Housing/SH-SURV-26-001/Assessments" + + with sync_playwright() as p: + browser: Browser = p.chromium.launch(headless=True) + context: BrowserContext = browser.new_context() + page: Page = context.new_page() + + attach_debug_listeners(page) + + try: + login(page, username, password) + go_to_assessments(page) + + while True: + rows: Locator = page.locator("#assessmentDatatable tbody tr") + row_count: int = rows.count() + + for i in range(row_count): + row: Locator = rows.nth(i) + + try: + cells: Locator = row.locator("td") + + first_name: str = cells.nth(1).inner_text().strip() + last_name: str = cells.nth(2).inner_text().strip() + address: str = cells.nth(5).inner_text().strip() + postcode: str = cells.nth(7).inner_text().strip() + status: str = cells.nth(9).inner_text().strip() + + if first_name == "Oliver" and last_name == "Stephens": + continue + + if status != "Submitted (not Lodged)": + continue + + property_id: str = build_property_id(address, postcode) + + if property_id not in property_ids: + continue + + sharepoint_address: str = property_map[property_id] + + go_to_assessment_details(page, row) + + for report_type in REPORT_TYPES: + file_path: str | None = download_with_retry( + page, report_type + ) + + if not file_path: + continue + + try: + upload_file_to_sharepoint( + client=sharepoint_client, + file_path=file_path, + base_path=sharepoint_base_path, + subpath=sharepoint_address, + ) + finally: + if os.path.exists(file_path): + os.remove(file_path) + + page.go_back() + page.wait_for_selector( + "#assessmentDatatable tbody tr", timeout=15000 + ) + + except Exception as e: + raise Exception(f"Row processing failed: {str(e)}") from e + + if not go_to_next_page(page): + break + + finally: + context.close() + browser.close() diff --git a/backend/ecmk_fetcher/reports.py b/backend/ecmk_fetcher/reports.py new file mode 100644 index 00000000..a8f12792 --- /dev/null +++ b/backend/ecmk_fetcher/reports.py @@ -0,0 +1,25 @@ +from enum import Enum + + +class FileDownloadButtonType(Enum): + ASSESSOR_HUB_SITENOTE_REPORT = 11 + CERTIFICATE = 9 + SITENOTE_REPORT = 8 + RAW_XML = 7 + SAP_WORK_SHEET = 15 + + +REPORT_TYPES = [ + FileDownloadButtonType.ASSESSOR_HUB_SITENOTE_REPORT.value, + FileDownloadButtonType.SITENOTE_REPORT.value, +] + + +def build_report_selector(report_type: int) -> str: + return f"a.download-report-btn[data-report-type='{report_type}']" + + +def build_property_id(address: str, postcode: str) -> str: + number = address.split(" ")[0] + postcode_clean = postcode.replace(" ", "").upper() + return f"{number}{postcode_clean}" diff --git a/backend/ecmk_fetcher/sharepoint.py b/backend/ecmk_fetcher/sharepoint.py new file mode 100644 index 00000000..79db1294 --- /dev/null +++ b/backend/ecmk_fetcher/sharepoint.py @@ -0,0 +1,20 @@ +import os + +from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient + + +def upload_file_to_sharepoint( + client: DomnaSharepointClient, + file_path: str, + base_path: str, + subpath: str, +) -> None: + filename = os.path.basename(file_path) + + full_path = f"{base_path}/{subpath}/1. Retrofit Assessment/A. Assessment" + + client.upload_file( + file_path=file_path, + sharepoint_path=full_path, + file_name=filename, + ) From 728913504b4b04542d20763b5e5fe59dcb2b27a7 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Apr 2026 11:19:28 +0000 Subject: [PATCH 27/93] insert was updated but not update --- etl/hubspot/hubspotDataTodB.py | 22 ++++++++++++++-------- etl/hubspot/scripts/scraper/main.py | 3 +-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index ac980649..6325efc2 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -365,9 +365,11 @@ class HubspotDataToDb: for attr, value in { "dealname": deal_data.get("dealname"), "dealstage": deal_data.get("dealstage"), - "listing_id": listing.get("listing_id"), - "landlord_property_id": listing.get("owner_property_id"), - "uprn": listing.get("national_uprn"), + "listing_id": listing.get("listing_id", None) if listing else None, + "landlord_property_id": ( + listing.get("owner_property_id", None) if listing else None + ), + "uprn": listing.get("national_uprn", None) if listing else None, "outcome": deal_data.get("outcome"), "outcome_notes": deal_data.get("outcome_notes"), "project_code": deal_data.get("project_code"), @@ -385,7 +387,9 @@ class HubspotDataToDb: "pashub_link": deal_data.get("pashub_link"), "sharepoint_link": deal_data.get("sharepoint_link"), "dampmould_growth": deal_data.get("dampmould_growth"), - "damp_mould_and_repairs_comments": deal_data.get("damp_mould_and_repairs_comments"), + "damp_mould_and_repairs_comments": deal_data.get( + "damp_mould_and_repairs_comments" + ), "pre_sap": deal_data.get("pre_sap"), "coordinator": deal_data.get("coordinator"), "mtp_completion_date": self._parse_hs_date( @@ -472,9 +476,9 @@ class HubspotDataToDb: deal_id=deal_id, dealname=deal_data.get("dealname"), dealstage=deal_data.get("dealstage"), - listing_id=listing.get("listing_id", None), - landlord_property_id=listing.get("owner_property_id"), - uprn=listing.get("national_uprn"), + listing_id=listing.get("listing_id", None) if listing else None, + landlord_property_id=listing.get("owner_property_id") if listing else None, + uprn=listing.get("national_uprn") if listing else None, outcome=deal_data.get("outcome"), outcome_notes=deal_data.get("outcome_notes"), project_code=deal_data.get("project_code"), @@ -490,7 +494,9 @@ class HubspotDataToDb: pashub_link=deal_data.get("pashub_link"), sharepoint_link=deal_data.get("sharepoint_link"), dampmould_growth=deal_data.get("dampmould_growth"), - damp_mould_and_repairs_comments=deal_data.get("damp_mould_and_repairs_comments"), + damp_mould_and_repairs_comments=deal_data.get( + "damp_mould_and_repairs_comments" + ), pre_sap=deal_data.get("pre_sap"), coordinator=deal_data.get("coordinator"), mtp_completion_date=self._parse_hs_date( diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 99311f6b..4f71c6d0 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -2,8 +2,6 @@ from etl.hubspot.hubspotClient import HubspotClient from etl.hubspot.hubspotDataTodB import HubspotDataToDb from backend.utils.subtasks import task_handler from typing import Any -import json -import time @task_handler() @@ -14,6 +12,7 @@ def handler(body: dict[str, Any], context: Any) -> None: raise RuntimeError( "Missing Hubspot Deal ID in SQS body request, 'hubspot_deal_id'" ) + hubspot_deal_id = "327170793707" hubspot: HubspotClient = HubspotClient() dbloader: HubspotDataToDb = HubspotDataToDb() From 53ef72faab38d082e7134c5ad6664b1b06a4c2e9 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Apr 2026 13:05:16 +0000 Subject: [PATCH 28/93] add change and put it to production --- backend/address2UPRN/main.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/address2UPRN/main.py b/backend/address2UPRN/main.py index d0ba36e6..97e2037a 100644 --- a/backend/address2UPRN/main.py +++ b/backend/address2UPRN/main.py @@ -462,6 +462,15 @@ def handler(event, context, local=False): # Validate postcode before processing if not AddressMatch.is_valid_postcode(postcode): logger.warning(f"Postcode {postcode} is invalid, skipping") + for row in postcode_rows: + results_data.append( + { + **row, + "address2uprn_uprn": "invalid postcode", + "address2uprn_address": "invalid postcode", + "address2uprn_lexiscore": "invalid postcode", + } + ) continue # Fetch EPC data once per postcode From cd7b59a62f4fea2043073db93668270a32c5a8a5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Apr 2026 15:42:56 +0000 Subject: [PATCH 29/93] update spreadsheet with properties that have already been processed --- backend/ecmk_fetcher/address_list.py | 104 +++++++++++++++++++++------ backend/ecmk_fetcher/processor.py | 32 +++++++-- 2 files changed, 108 insertions(+), 28 deletions(-) diff --git a/backend/ecmk_fetcher/address_list.py b/backend/ecmk_fetcher/address_list.py index d273c45d..54c675d1 100644 --- a/backend/ecmk_fetcher/address_list.py +++ b/backend/ecmk_fetcher/address_list.py @@ -1,45 +1,107 @@ -from typing import Dict, Optional -from openpyxl import load_workbook import re +from dataclasses import dataclass +from typing import Any, Dict, Optional, cast +from openpyxl import Workbook, load_workbook +from openpyxl.worksheet.worksheet import Worksheet +from openpyxl.cell.cell import Cell -def extract_addresses_from_spreadsheet(filepath: str) -> Dict[str, str]: - wb = load_workbook(filepath, data_only=True) - ws = wb["Southern RA-Lite Programme 3103"] +@dataclass +class PropertyRow: + row_index: int + address: str + processed: bool - properties: Dict[str, str] = {} - header_row = 1 - id_col_index = None - deal_name_col_index = None +def extract_addresses_from_spreadsheet( + filepath: str, +) -> Dict[str, PropertyRow]: + wb: Workbook = load_workbook(filepath, data_only=True) + ws: Worksheet = wb["Southern RA-Lite Programme 3103"] + header_row: int = 1 + id_col: Optional[int] = None + deal_name_col: Optional[int] = None + processed_col: Optional[int] = None + + # find columns for col in range(1, ws.max_column + 1): - value = ws.cell(row=header_row, column=col).value + raw_value: Any = ws.cell(row=header_row, column=col).value + value: str = str(raw_value).strip().lower() if raw_value else "" - if value and str(value).strip().lower() == "id": - id_col_index = col + if value == "id": + id_col = col + elif value == "deal name": + deal_name_col = col + elif value == "processed": + processed_col = col - if value and str(value).strip().lower() == "deal name": - deal_name_col_index = col - break + if id_col is None or deal_name_col is None: + raise Exception("Missing required columns") - if id_col_index is None or deal_name_col_index is None: - raise Exception("Required columns not found") + # create processed column if missing + if processed_col is None: + processed_col = ws.max_column + 1 + cast(Cell, ws.cell(row=header_row, column=processed_col)).value = "processed" + + properties: Dict[str, PropertyRow] = {} for row in range(2, ws.max_row + 1): - id_val = ws.cell(row=row, column=id_col_index).value - deal_name = ws.cell(row=row, column=deal_name_col_index).value + id_val: Any = ws.cell(row=row, column=id_col).value + deal_name: Any = ws.cell(row=row, column=deal_name_col).value if not id_val or not deal_name: continue - properties[str(id_val).strip()] = extract_succinct_address( - str(deal_name).strip() + processed_val: Any = ws.cell(row=row, column=processed_col).value + processed: bool = str(processed_val).lower() == "true" + + property_id: str = str(id_val).strip() + + properties[property_id] = PropertyRow( + row_index=row, + address=extract_succinct_address(str(deal_name)), + processed=processed, ) return properties +def mark_properties_as_processed( + filepath: str, + property_map: Dict[str, PropertyRow], +) -> None: + wb: Workbook = load_workbook(filepath) + ws: Worksheet = wb["Southern RA-Lite Programme 3103"] + + header_row: int = 1 + + # find processed column + processed_col: int | None = None + + for col in range(1, ws.max_column + 1): + value = ws.cell(row=header_row, column=col).value + if value and str(value).strip().lower() == "processed": + processed_col = col + break + + if processed_col is None: + raise Exception("Processed column not found") + + # update rows + for property_row in property_map.values(): + if property_row.processed: + cast( + Cell, + ws.cell( + row=property_row.row_index, + column=processed_col, + ), + ).value = True + + wb.save(filepath) + + def extract_succinct_address(deal_name: str) -> str: left_part = deal_name.split("|")[0].strip() diff --git a/backend/ecmk_fetcher/processor.py b/backend/ecmk_fetcher/processor.py index 1852b867..dce6c7ef 100644 --- a/backend/ecmk_fetcher/processor.py +++ b/backend/ecmk_fetcher/processor.py @@ -1,6 +1,5 @@ import os -from typing import Dict, List - +from typing import Dict from playwright.sync_api import ( sync_playwright, Locator, @@ -9,7 +8,11 @@ from playwright.sync_api import ( BrowserContext, ) -from backend.ecmk_fetcher.address_list import extract_addresses_from_spreadsheet +from backend.ecmk_fetcher.address_list import ( + PropertyRow, + extract_addresses_from_spreadsheet, + mark_properties_as_processed, +) from backend.ecmk_fetcher.browser import ( attach_debug_listeners, download_with_retry, @@ -35,8 +38,7 @@ def run_job() -> None: BASE_DIR: str = os.path.dirname(__file__) filepath: str = os.path.join(BASE_DIR, property_list_file) - property_map: Dict[str, str] = extract_addresses_from_spreadsheet(filepath) - property_ids: List[str] = list(property_map.keys()) + property_map: Dict[str, PropertyRow] = extract_addresses_from_spreadsheet(filepath) sharepoint_client: DomnaSharepointClient = DomnaSharepointClient( sharepoint_location=DomnaSites.PRIVATE_PAY @@ -79,19 +81,27 @@ def run_job() -> None: property_id: str = build_property_id(address, postcode) - if property_id not in property_ids: + property_row: PropertyRow | None = property_map.get(property_id) + + if not property_row: continue - sharepoint_address: str = property_map[property_id] + if property_row.processed: + continue + + sharepoint_address: str = property_row.address go_to_assessment_details(page, row) + all_uploaded: bool = True + for report_type in REPORT_TYPES: file_path: str | None = download_with_retry( page, report_type ) if not file_path: + all_uploaded = False continue try: @@ -101,10 +111,16 @@ def run_job() -> None: base_path=sharepoint_base_path, subpath=sharepoint_address, ) + except Exception: + all_uploaded = False + raise finally: if os.path.exists(file_path): os.remove(file_path) + if all_uploaded: + property_row.processed = True + page.go_back() page.wait_for_selector( "#assessmentDatatable tbody tr", timeout=15000 @@ -119,3 +135,5 @@ def run_job() -> None: finally: context.close() browser.close() + + mark_properties_as_processed(filepath, property_map) From 23d7b22b548aef03820723eb118f812c70c44b14 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Apr 2026 17:30:38 +0000 Subject: [PATCH 30/93] save for easter weekend --- backend/address2UPRN/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/address2UPRN/README.md b/backend/address2UPRN/README.md index 646fec01..b17b36d1 100644 --- a/backend/address2UPRN/README.md +++ b/backend/address2UPRN/README.md @@ -32,9 +32,9 @@ Step 3) Alright, now lets make the input for postcode-splitter sqs to get the ba postcode-splitter-sqs => https://eu-west-2.console.aws.amazon.com/sqs/v3/home?region=eu-west-2#/queues/https%3A%2F%2Fsqs.eu-west-2.amazonaws.com%2F337213553626%2Fpostcode-splitter-queue-dev { - "task_id": "ea615ac3-ac28-46c4-8bff-2431c5b9c13d", - "sub_task_id": "85a23b67-8f18-4299-9bf0-69bfb87adbc7", - "s3_uri": "s3://retrofit-data-dev/ara_raw_inputs/eon/eon(Sheet1).csv" + "sub_task_id": "c5afbd49-f0cd-4930-82bf-bafc5243a34a", + "task_id": "67a4b3f0-cc7a-4e8a-b314-deb783e0eedb", + "s3_uri": "s3://retrofit-data-dev/ara_raw_inputs/eon/pickering ferens/Pickering Ferens - SHDF W3 Post Bid Stage MDS - Template - Vr4(in).csv" } Each batch of csv should be saved in retrofit-data-dev/ara_postcode_splitter_batches///.csv From 959867f5ee887803b5912355db2a5cc3acf7ebff Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Apr 2026 09:51:26 +0000 Subject: [PATCH 31/93] maximum concurrent --- infrastructure/terraform/lambda/_template/variables.tf | 2 +- infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf | 4 ++-- infrastructure/terraform/lambda/ordnanceSurvey/variables.tf | 2 +- infrastructure/terraform/lambda/pashub_to_ara/variables.tf | 2 +- .../terraform/modules/lambda_sqs_trigger/variables.tf | 2 +- infrastructure/terraform/modules/lambda_with_sqs/variables.tf | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/infrastructure/terraform/lambda/_template/variables.tf b/infrastructure/terraform/lambda/_template/variables.tf index ae588840..c4408e0f 100644 --- a/infrastructure/terraform/lambda/_template/variables.tf +++ b/infrastructure/terraform/lambda/_template/variables.tf @@ -19,7 +19,7 @@ variable "image_digest" { variable "maximum_concurrency" { type = number - default = null + default = 1 description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit." } diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf index 285b6a4c..27c9424b 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf @@ -19,13 +19,13 @@ variable "image_digest" { variable "maximum_concurrency" { type = number - default = null + default = 1 description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit." } variable "batch_size" { type = number - default = 1 + default = 10 } locals { diff --git a/infrastructure/terraform/lambda/ordnanceSurvey/variables.tf b/infrastructure/terraform/lambda/ordnanceSurvey/variables.tf index 936aebc9..e2cfd65e 100644 --- a/infrastructure/terraform/lambda/ordnanceSurvey/variables.tf +++ b/infrastructure/terraform/lambda/ordnanceSurvey/variables.tf @@ -19,7 +19,7 @@ variable "image_digest" { variable "maximum_concurrency" { type = number - default = null + default = 1 description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit." } diff --git a/infrastructure/terraform/lambda/pashub_to_ara/variables.tf b/infrastructure/terraform/lambda/pashub_to_ara/variables.tf index e7646811..fe92d645 100644 --- a/infrastructure/terraform/lambda/pashub_to_ara/variables.tf +++ b/infrastructure/terraform/lambda/pashub_to_ara/variables.tf @@ -19,7 +19,7 @@ variable "image_digest" { variable "maximum_concurrency" { type = number - default = null + default = 1 description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit." } diff --git a/infrastructure/terraform/modules/lambda_sqs_trigger/variables.tf b/infrastructure/terraform/modules/lambda_sqs_trigger/variables.tf index c3127c74..0b8dffc0 100644 --- a/infrastructure/terraform/modules/lambda_sqs_trigger/variables.tf +++ b/infrastructure/terraform/modules/lambda_sqs_trigger/variables.tf @@ -9,6 +9,6 @@ variable "batch_size" { variable "maximum_concurrency" { type = number - default = null + default = 1 description = "Maximum number of concurrent Lambda invocations from SQS. null = no limit." } diff --git a/infrastructure/terraform/modules/lambda_with_sqs/variables.tf b/infrastructure/terraform/modules/lambda_with_sqs/variables.tf index 7c2832d2..6d992f3b 100644 --- a/infrastructure/terraform/modules/lambda_with_sqs/variables.tf +++ b/infrastructure/terraform/modules/lambda_with_sqs/variables.tf @@ -37,6 +37,6 @@ variable "batch_size" { variable "maximum_concurrency" { type = number - default = null + default = 1 description = "Maximum number of concurrent Lambda invocations from SQS. null = no limit." } From 8d6be23084a73e05929c8ccd2f1bb3263f3be6f7 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Apr 2026 09:56:38 +0000 Subject: [PATCH 32/93] maximum concurrency --- infrastructure/terraform/lambda/ordnanceSurvey/variables.tf | 2 +- infrastructure/terraform/lambda/pashub_to_ara/variables.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/lambda/ordnanceSurvey/variables.tf b/infrastructure/terraform/lambda/ordnanceSurvey/variables.tf index e2cfd65e..936aebc9 100644 --- a/infrastructure/terraform/lambda/ordnanceSurvey/variables.tf +++ b/infrastructure/terraform/lambda/ordnanceSurvey/variables.tf @@ -19,7 +19,7 @@ variable "image_digest" { variable "maximum_concurrency" { type = number - default = 1 + default = null description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit." } diff --git a/infrastructure/terraform/lambda/pashub_to_ara/variables.tf b/infrastructure/terraform/lambda/pashub_to_ara/variables.tf index fe92d645..e7646811 100644 --- a/infrastructure/terraform/lambda/pashub_to_ara/variables.tf +++ b/infrastructure/terraform/lambda/pashub_to_ara/variables.tf @@ -19,7 +19,7 @@ variable "image_digest" { variable "maximum_concurrency" { type = number - default = 1 + default = null description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit." } From 2c39f64731382497b3d53fb5e470c4bfb32dc8ae Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Apr 2026 09:58:22 +0000 Subject: [PATCH 33/93] maximum concurrency --- infrastructure/terraform/modules/lambda_with_sqs/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/modules/lambda_with_sqs/variables.tf b/infrastructure/terraform/modules/lambda_with_sqs/variables.tf index 6d992f3b..7c2832d2 100644 --- a/infrastructure/terraform/modules/lambda_with_sqs/variables.tf +++ b/infrastructure/terraform/modules/lambda_with_sqs/variables.tf @@ -37,6 +37,6 @@ variable "batch_size" { variable "maximum_concurrency" { type = number - default = 1 + default = null description = "Maximum number of concurrent Lambda invocations from SQS. null = no limit." } From 469401aabd341089a8a9813decc86f213e2a1a78 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Apr 2026 09:59:41 +0000 Subject: [PATCH 34/93] maximum concurrency --- .../terraform/modules/lambda_sqs_trigger/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/modules/lambda_sqs_trigger/variables.tf b/infrastructure/terraform/modules/lambda_sqs_trigger/variables.tf index 0b8dffc0..c3127c74 100644 --- a/infrastructure/terraform/modules/lambda_sqs_trigger/variables.tf +++ b/infrastructure/terraform/modules/lambda_sqs_trigger/variables.tf @@ -9,6 +9,6 @@ variable "batch_size" { variable "maximum_concurrency" { type = number - default = 1 + default = null description = "Maximum number of concurrent Lambda invocations from SQS. null = no limit." } From acbd0cfc35e3d382b73802e6d5fb6b44a1d73951 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Apr 2026 10:53:57 +0000 Subject: [PATCH 35/93] maximum concurrency to 2 --- infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf index 27c9424b..499f9cc5 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf @@ -19,13 +19,13 @@ variable "image_digest" { variable "maximum_concurrency" { type = number - default = 1 + default = 2 description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit." } variable "batch_size" { type = number - default = 10 + default = 1 } locals { From 426d907ce766059646dce102c710d1fe83d790c0 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Apr 2026 10:54:17 +0000 Subject: [PATCH 36/93] 2 lambda b ut 1 batch size --- infrastructure/terraform/lambda/_template/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/lambda/_template/variables.tf b/infrastructure/terraform/lambda/_template/variables.tf index c4408e0f..0a3092ee 100644 --- a/infrastructure/terraform/lambda/_template/variables.tf +++ b/infrastructure/terraform/lambda/_template/variables.tf @@ -19,7 +19,7 @@ variable "image_digest" { variable "maximum_concurrency" { type = number - default = 1 + default = 2 description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit." } From 25e08f3e964b84e79e9e9b42964ec57d89423cf2 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Apr 2026 11:08:19 +0000 Subject: [PATCH 37/93] 2 lambda b ut 1 batch size --- infrastructure/terraform/lambda/_template/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/lambda/_template/variables.tf b/infrastructure/terraform/lambda/_template/variables.tf index 0a3092ee..63f60cb6 100644 --- a/infrastructure/terraform/lambda/_template/variables.tf +++ b/infrastructure/terraform/lambda/_template/variables.tf @@ -25,7 +25,7 @@ variable "maximum_concurrency" { variable "batch_size" { type = number - default = 1 + default = 5 } locals { From 34414d14a50df31b487778efce885e613bb14039 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Apr 2026 11:10:15 +0000 Subject: [PATCH 38/93] change to sensiable concurrecy and batch size --- infrastructure/terraform/lambda/_template/variables.tf | 2 +- infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/lambda/_template/variables.tf b/infrastructure/terraform/lambda/_template/variables.tf index 63f60cb6..0a3092ee 100644 --- a/infrastructure/terraform/lambda/_template/variables.tf +++ b/infrastructure/terraform/lambda/_template/variables.tf @@ -25,7 +25,7 @@ variable "maximum_concurrency" { variable "batch_size" { type = number - default = 5 + default = 1 } locals { diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf index 499f9cc5..84f0e567 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/variables.tf @@ -25,7 +25,7 @@ variable "maximum_concurrency" { variable "batch_size" { type = number - default = 1 + default = 5 } locals { From ba30bccb07b7f14199a1955a1f60a8f95eed0f12 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Apr 2026 11:29:10 +0000 Subject: [PATCH 39/93] revert spreadsheet update changes. add better logging --- backend/ecmk_fetcher/address_list.py | 51 +--------------------------- backend/ecmk_fetcher/browser.py | 1 + backend/ecmk_fetcher/processor.py | 23 ++++++------- 3 files changed, 13 insertions(+), 62 deletions(-) diff --git a/backend/ecmk_fetcher/address_list.py b/backend/ecmk_fetcher/address_list.py index 54c675d1..a2834366 100644 --- a/backend/ecmk_fetcher/address_list.py +++ b/backend/ecmk_fetcher/address_list.py @@ -1,16 +1,14 @@ import re from dataclasses import dataclass -from typing import Any, Dict, Optional, cast +from typing import Any, Dict, Optional from openpyxl import Workbook, load_workbook from openpyxl.worksheet.worksheet import Worksheet -from openpyxl.cell.cell import Cell @dataclass class PropertyRow: row_index: int address: str - processed: bool def extract_addresses_from_spreadsheet( @@ -22,7 +20,6 @@ def extract_addresses_from_spreadsheet( header_row: int = 1 id_col: Optional[int] = None deal_name_col: Optional[int] = None - processed_col: Optional[int] = None # find columns for col in range(1, ws.max_column + 1): @@ -33,17 +30,10 @@ def extract_addresses_from_spreadsheet( id_col = col elif value == "deal name": deal_name_col = col - elif value == "processed": - processed_col = col if id_col is None or deal_name_col is None: raise Exception("Missing required columns") - # create processed column if missing - if processed_col is None: - processed_col = ws.max_column + 1 - cast(Cell, ws.cell(row=header_row, column=processed_col)).value = "processed" - properties: Dict[str, PropertyRow] = {} for row in range(2, ws.max_row + 1): @@ -53,55 +43,16 @@ def extract_addresses_from_spreadsheet( if not id_val or not deal_name: continue - processed_val: Any = ws.cell(row=row, column=processed_col).value - processed: bool = str(processed_val).lower() == "true" - property_id: str = str(id_val).strip() properties[property_id] = PropertyRow( row_index=row, address=extract_succinct_address(str(deal_name)), - processed=processed, ) return properties -def mark_properties_as_processed( - filepath: str, - property_map: Dict[str, PropertyRow], -) -> None: - wb: Workbook = load_workbook(filepath) - ws: Worksheet = wb["Southern RA-Lite Programme 3103"] - - header_row: int = 1 - - # find processed column - processed_col: int | None = None - - for col in range(1, ws.max_column + 1): - value = ws.cell(row=header_row, column=col).value - if value and str(value).strip().lower() == "processed": - processed_col = col - break - - if processed_col is None: - raise Exception("Processed column not found") - - # update rows - for property_row in property_map.values(): - if property_row.processed: - cast( - Cell, - ws.cell( - row=property_row.row_index, - column=processed_col, - ), - ).value = True - - wb.save(filepath) - - def extract_succinct_address(deal_name: str) -> str: left_part = deal_name.split("|")[0].strip() diff --git a/backend/ecmk_fetcher/browser.py b/backend/ecmk_fetcher/browser.py index 6d018537..de349b92 100644 --- a/backend/ecmk_fetcher/browser.py +++ b/backend/ecmk_fetcher/browser.py @@ -50,6 +50,7 @@ def get_first_row_signature(page: Page) -> str: def go_to_next_page(page: Page) -> bool: + logger.info("Going to next page") before = get_first_row_signature(page) page.locator("#assessmentDatatable_next a").click() diff --git a/backend/ecmk_fetcher/processor.py b/backend/ecmk_fetcher/processor.py index dce6c7ef..e774fc9a 100644 --- a/backend/ecmk_fetcher/processor.py +++ b/backend/ecmk_fetcher/processor.py @@ -11,7 +11,6 @@ from playwright.sync_api import ( from backend.ecmk_fetcher.address_list import ( PropertyRow, extract_addresses_from_spreadsheet, - mark_properties_as_processed, ) from backend.ecmk_fetcher.browser import ( attach_debug_listeners, @@ -23,9 +22,12 @@ from backend.ecmk_fetcher.browser import ( ) from backend.ecmk_fetcher.reports import REPORT_TYPES, build_property_id from backend.ecmk_fetcher.sharepoint import upload_file_to_sharepoint +from utils.logger import setup_logger from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient from utils.sharepoint.domna_sites import DomnaSites +logger = setup_logger() + def run_job() -> None: username: str = "" @@ -86,24 +88,24 @@ def run_job() -> None: if not property_row: continue - if property_row.processed: - continue + logger.info(f"Match found for property {address}") sharepoint_address: str = property_row.address go_to_assessment_details(page, row) - all_uploaded: bool = True - for report_type in REPORT_TYPES: file_path: str | None = download_with_retry( page, report_type ) if not file_path: - all_uploaded = False continue + logger.info( + f"Successfully downloaded file {os.path.basename(file_path)} from ECMK" + ) + try: upload_file_to_sharepoint( client=sharepoint_client, @@ -111,16 +113,15 @@ def run_job() -> None: base_path=sharepoint_base_path, subpath=sharepoint_address, ) + logger.info( + f"Successfully loaded {os.path.basename(file_path)} to sharepoint for {address}" + ) except Exception: - all_uploaded = False raise finally: if os.path.exists(file_path): os.remove(file_path) - if all_uploaded: - property_row.processed = True - page.go_back() page.wait_for_selector( "#assessmentDatatable tbody tr", timeout=15000 @@ -135,5 +136,3 @@ def run_job() -> None: finally: context.close() browser.close() - - mark_properties_as_processed(filepath, property_map) From 849a272974c15c16cb743441a7dad5192af07f4c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Apr 2026 11:47:47 +0000 Subject: [PATCH 40/93] get hubspot listing id from spreadsheet --- backend/ecmk_fetcher/address_list.py | 10 ++++++++-- backend/ecmk_fetcher/processor.py | 3 +++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/ecmk_fetcher/address_list.py b/backend/ecmk_fetcher/address_list.py index a2834366..ba636a70 100644 --- a/backend/ecmk_fetcher/address_list.py +++ b/backend/ecmk_fetcher/address_list.py @@ -9,6 +9,7 @@ from openpyxl.worksheet.worksheet import Worksheet class PropertyRow: row_index: int address: str + listing_id: str def extract_addresses_from_spreadsheet( @@ -20,6 +21,7 @@ def extract_addresses_from_spreadsheet( header_row: int = 1 id_col: Optional[int] = None deal_name_col: Optional[int] = None + listing_id_col: Optional[int] = None # find columns for col in range(1, ws.max_column + 1): @@ -30,8 +32,10 @@ def extract_addresses_from_spreadsheet( id_col = col elif value == "deal name": deal_name_col = col + elif value == "associated listing ids": + listing_id_col = col - if id_col is None or deal_name_col is None: + if id_col is None or deal_name_col is None or listing_id_col is None: raise Exception("Missing required columns") properties: Dict[str, PropertyRow] = {} @@ -39,8 +43,9 @@ def extract_addresses_from_spreadsheet( for row in range(2, ws.max_row + 1): id_val: Any = ws.cell(row=row, column=id_col).value deal_name: Any = ws.cell(row=row, column=deal_name_col).value + listing_id: Any = ws.cell(row=row, column=listing_id_col).value - if not id_val or not deal_name: + if not id_val or not deal_name or not listing_id: continue property_id: str = str(id_val).strip() @@ -48,6 +53,7 @@ def extract_addresses_from_spreadsheet( properties[property_id] = PropertyRow( row_index=row, address=extract_succinct_address(str(deal_name)), + listing_id=listing_id, ) return properties diff --git a/backend/ecmk_fetcher/processor.py b/backend/ecmk_fetcher/processor.py index e774fc9a..4c841a19 100644 --- a/backend/ecmk_fetcher/processor.py +++ b/backend/ecmk_fetcher/processor.py @@ -92,6 +92,9 @@ def run_job() -> None: sharepoint_address: str = property_row.address + # Check whether files have already been processed before continuing with this property + # hubspot_listing_id: str = property_row.listing_id + go_to_assessment_details(page, row) for report_type in REPORT_TYPES: From 15f1fde16a0e2828d56a65d9a8cb374c94b6d7b0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Apr 2026 14:34:33 +0000 Subject: [PATCH 41/93] skip file if already processed according to db --- .../db/functions/uploaded_files_functions.py | 25 +++++++++++++ backend/app/db/models/uploaded_file.py | 2 ++ ...-ra-lite-programme-3103-2026-03-31-2.xlsx# | 1 + backend/ecmk_fetcher/processor.py | 35 ++++++++++++++++--- backend/ecmk_fetcher/reports.py | 12 +++++++ 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 backend/app/db/functions/uploaded_files_functions.py create mode 100644 backend/ecmk_fetcher/.~lock.hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx# diff --git a/backend/app/db/functions/uploaded_files_functions.py b/backend/app/db/functions/uploaded_files_functions.py new file mode 100644 index 00000000..3708813a --- /dev/null +++ b/backend/app/db/functions/uploaded_files_functions.py @@ -0,0 +1,25 @@ +from typing import Optional + +from sqlalchemy import select + +from backend.app.db.connection import db_read_session +from backend.app.db.models.uploaded_file import ( + FileSourceEnum, + FileTypeEnum, + UploadedFile, +) + + +def get_uploaded_file_by_listing_type_and_source( + hubspot_listing_id: int, + file_type: FileTypeEnum, + file_source: FileSourceEnum, +) -> Optional[UploadedFile]: + with db_read_session() as session: + statement = select(UploadedFile).where( + UploadedFile.hubspot_listing_id == hubspot_listing_id, + UploadedFile.file_type == file_type, + UploadedFile.file_source == file_source, + ) + + return session.exec(statement).one_or_none() diff --git a/backend/app/db/models/uploaded_file.py b/backend/app/db/models/uploaded_file.py index 9b751d34..8decfd1b 100644 --- a/backend/app/db/models/uploaded_file.py +++ b/backend/app/db/models/uploaded_file.py @@ -14,6 +14,8 @@ class FileTypeEnum(enum.Enum): PAR_PHOTO_PACK = "par_photo_pack" PAS_2023_PROPERTY = "pas_2023_property" PAS_2023_OCCUPANCY = "pas_2023_occupancy" + ECMK_SITE_NOTE = "ecmk_site_note" + ECMK_RD_SAP_SITE_NOTE = "ecmk_rd_sap_site_note" class FileSourceEnum(enum.Enum): diff --git a/backend/ecmk_fetcher/.~lock.hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx# b/backend/ecmk_fetcher/.~lock.hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx# new file mode 100644 index 00000000..4b57053e --- /dev/null +++ b/backend/ecmk_fetcher/.~lock.hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx# @@ -0,0 +1 @@ +,daniel,daniel-Dell-15-DC15250,07.04.2026 11:47,/home/daniel/snap/onlyoffice-desktopeditors/1067/.local/share/onlyoffice; \ No newline at end of file diff --git a/backend/ecmk_fetcher/processor.py b/backend/ecmk_fetcher/processor.py index 4c841a19..dc52c342 100644 --- a/backend/ecmk_fetcher/processor.py +++ b/backend/ecmk_fetcher/processor.py @@ -8,6 +8,10 @@ from playwright.sync_api import ( BrowserContext, ) +from backend.app.db.functions.uploaded_files_functions import ( + get_uploaded_file_by_listing_type_and_source, +) +from backend.app.db.models.uploaded_file import FileSourceEnum, FileTypeEnum from backend.ecmk_fetcher.address_list import ( PropertyRow, extract_addresses_from_spreadsheet, @@ -20,7 +24,11 @@ from backend.ecmk_fetcher.browser import ( go_to_next_page, login, ) -from backend.ecmk_fetcher.reports import REPORT_TYPES, build_property_id +from backend.ecmk_fetcher.reports import ( + REPORT_TYPES, + build_property_id, + map_report_type_to_db_file_type, +) from backend.ecmk_fetcher.sharepoint import upload_file_to_sharepoint from utils.logger import setup_logger from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient @@ -48,6 +56,8 @@ def run_job() -> None: sharepoint_base_path: str = "/Projects/Southern Housing/SH-SURV-26-001/Assessments" + # s3_bucket: str = "retrofit-energy-assessments-dev" + with sync_playwright() as p: browser: Browser = p.chromium.launch(headless=True) context: BrowserContext = browser.new_context() @@ -92,12 +102,29 @@ def run_job() -> None: sharepoint_address: str = property_row.address - # Check whether files have already been processed before continuing with this property - # hubspot_listing_id: str = property_row.listing_id - go_to_assessment_details(page, row) for report_type in REPORT_TYPES: + hubspot_listing_id: str = property_row.listing_id + try: + db_file_type: FileTypeEnum = ( + map_report_type_to_db_file_type(report_type) + ) + + except ValueError: + logger.error( + f"Unknown report type {report_type}, skipping file" + ) + continue + + if get_uploaded_file_by_listing_type_and_source( + hubspot_listing_id=int(hubspot_listing_id), + file_type=db_file_type, + file_source=FileSourceEnum.ECMK, + ): + logger.debug("File already uploaded to s3, skipping") + continue + file_path: str | None = download_with_retry( page, report_type ) diff --git a/backend/ecmk_fetcher/reports.py b/backend/ecmk_fetcher/reports.py index a8f12792..d8d11d50 100644 --- a/backend/ecmk_fetcher/reports.py +++ b/backend/ecmk_fetcher/reports.py @@ -1,5 +1,7 @@ from enum import Enum +from backend.app.db.models.uploaded_file import FileTypeEnum + class FileDownloadButtonType(Enum): ASSESSOR_HUB_SITENOTE_REPORT = 11 @@ -15,6 +17,16 @@ REPORT_TYPES = [ ] +def map_report_type_to_db_file_type(report_type: int) -> FileTypeEnum: + match report_type: + case FileDownloadButtonType.ASSESSOR_HUB_SITENOTE_REPORT.value: + return FileTypeEnum.ECMK_SITE_NOTE + case FileDownloadButtonType.SITENOTE_REPORT.value: + return FileTypeEnum.ECMK_RD_SAP_SITE_NOTE + case _: + raise ValueError("Unknown report type") + + def build_report_selector(report_type: int) -> str: return f"a.download-report-btn[data-report-type='{report_type}']" From d229e2faf8a675d149fdb8bab0e16ef69f617a7e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Apr 2026 14:55:43 +0000 Subject: [PATCH 42/93] upload file to s3 and update db after doing so --- backend/ecmk_fetcher/processor.py | 16 ++++++++-- backend/ecmk_fetcher/sharepoint.py | 20 ------------ backend/ecmk_fetcher/upload.py | 49 ++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 22 deletions(-) delete mode 100644 backend/ecmk_fetcher/sharepoint.py create mode 100644 backend/ecmk_fetcher/upload.py diff --git a/backend/ecmk_fetcher/processor.py b/backend/ecmk_fetcher/processor.py index dc52c342..0ca53c4c 100644 --- a/backend/ecmk_fetcher/processor.py +++ b/backend/ecmk_fetcher/processor.py @@ -29,7 +29,10 @@ from backend.ecmk_fetcher.reports import ( build_property_id, map_report_type_to_db_file_type, ) -from backend.ecmk_fetcher.sharepoint import upload_file_to_sharepoint +from backend.ecmk_fetcher.upload import ( + upload_file_to_s3_and_update_db, + upload_file_to_sharepoint, +) from utils.logger import setup_logger from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient from utils.sharepoint.domna_sites import DomnaSites @@ -56,7 +59,7 @@ def run_job() -> None: sharepoint_base_path: str = "/Projects/Southern Housing/SH-SURV-26-001/Assessments" - # s3_bucket: str = "retrofit-energy-assessments-dev" + s3_bucket: str = "retrofit-energy-assessments-dev" with sync_playwright() as p: browser: Browser = p.chromium.launch(headless=True) @@ -146,6 +149,15 @@ def run_job() -> None: logger.info( f"Successfully loaded {os.path.basename(file_path)} to sharepoint for {address}" ) + + # Upload to s3 and update db + upload_file_to_s3_and_update_db( + bucket=s3_bucket, + file_path=file_path, + hubspot_listing_id=hubspot_listing_id, + file_type=db_file_type, + ) + except Exception: raise finally: diff --git a/backend/ecmk_fetcher/sharepoint.py b/backend/ecmk_fetcher/sharepoint.py deleted file mode 100644 index 79db1294..00000000 --- a/backend/ecmk_fetcher/sharepoint.py +++ /dev/null @@ -1,20 +0,0 @@ -import os - -from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient - - -def upload_file_to_sharepoint( - client: DomnaSharepointClient, - file_path: str, - base_path: str, - subpath: str, -) -> None: - filename = os.path.basename(file_path) - - full_path = f"{base_path}/{subpath}/1. Retrofit Assessment/A. Assessment" - - client.upload_file( - file_path=file_path, - sharepoint_path=full_path, - file_name=filename, - ) diff --git a/backend/ecmk_fetcher/upload.py b/backend/ecmk_fetcher/upload.py new file mode 100644 index 00000000..00e2ec32 --- /dev/null +++ b/backend/ecmk_fetcher/upload.py @@ -0,0 +1,49 @@ +from datetime import datetime, timezone +import os + +from backend.app.db.connection import db_session +from backend.app.db.models.uploaded_file import ( + FileSourceEnum, + FileTypeEnum, + UploadedFile, +) +from utils.s3 import upload_file_to_s3 +from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient + + +def upload_file_to_sharepoint( + client: DomnaSharepointClient, + file_path: str, + base_path: str, + subpath: str, +) -> None: + filename = os.path.basename(file_path) + + full_path = f"{base_path}/{subpath}/1. Retrofit Assessment/A. Assessment" + + client.upload_file( + file_path=file_path, + sharepoint_path=full_path, + file_name=filename, + ) + + +def upload_file_to_s3_and_update_db( + bucket: str, file_path: str, hubspot_listing_id: str, file_type: FileTypeEnum +) -> None: + key: str = f"documents/hubspot_listing_id/{hubspot_listing_id}" + upload_file_to_s3(file_path, bucket, key) + + uploaded_file = UploadedFile( + s3_file_bucket=bucket, + s3_file_key=key, + s3_upload_timestamp=datetime.now(timezone.utc), + hubspot_listing_id=hubspot_listing_id, + file_source=FileSourceEnum.ECMK.value, + file_type=file_type, + ) + + with db_session() as session: + # TODO: we should do multiple files at once to reduce db trips + session.add(uploaded_file) + session.commit() From 7cd4d4c5b3f55a97c84fdc6e3a5733ee7c76aa00 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 07:42:00 +0000 Subject: [PATCH 43/93] bug fixes to get runner working --- backend/app/db/models/uploaded_file.py | 16 ++++++++++++++-- backend/ecmk_fetcher/upload.py | 6 ++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/app/db/models/uploaded_file.py b/backend/app/db/models/uploaded_file.py index 8decfd1b..71763790 100644 --- a/backend/app/db/models/uploaded_file.py +++ b/backend/app/db/models/uploaded_file.py @@ -39,9 +39,21 @@ class UploadedFile(Base): hubspot_listing_id = Column(BigInteger, nullable=True) file_type = Column( - SqlEnum(FileTypeEnum, name="file_type", create_type=False), nullable=True + SqlEnum( + FileTypeEnum, + name="file_type", + create_type=False, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + ), + nullable=True, ) file_source = Column( - SqlEnum(FileSourceEnum, name="file_source", create_type=False), nullable=True + SqlEnum( + FileSourceEnum, + name="file_source", + create_type=False, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + ), + nullable=True, ) diff --git a/backend/ecmk_fetcher/upload.py b/backend/ecmk_fetcher/upload.py index 00e2ec32..0a744e53 100644 --- a/backend/ecmk_fetcher/upload.py +++ b/backend/ecmk_fetcher/upload.py @@ -31,7 +31,9 @@ def upload_file_to_sharepoint( def upload_file_to_s3_and_update_db( bucket: str, file_path: str, hubspot_listing_id: str, file_type: FileTypeEnum ) -> None: - key: str = f"documents/hubspot_listing_id/{hubspot_listing_id}" + filename: str = os.path.basename(file_path) + key: str = f"documents/hubspot_listing_id/{hubspot_listing_id}/{filename}" + upload_file_to_s3(file_path, bucket, key) uploaded_file = UploadedFile( @@ -40,7 +42,7 @@ def upload_file_to_s3_and_update_db( s3_upload_timestamp=datetime.now(timezone.utc), hubspot_listing_id=hubspot_listing_id, file_source=FileSourceEnum.ECMK.value, - file_type=file_type, + file_type=file_type.value, ) with db_session() as session: From 09ee2699b6eeeccee8c62b8505edea919a8927c5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 07:43:09 +0000 Subject: [PATCH 44/93] remove accidentally committed lock file --- ...rm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx# | 1 - 1 file changed, 1 deletion(-) delete mode 100644 backend/ecmk_fetcher/.~lock.hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx# diff --git a/backend/ecmk_fetcher/.~lock.hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx# b/backend/ecmk_fetcher/.~lock.hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx# deleted file mode 100644 index 4b57053e..00000000 --- a/backend/ecmk_fetcher/.~lock.hubspot-crm-exports-southern-ra-lite-programme-3103-2026-03-31-2.xlsx# +++ /dev/null @@ -1 +0,0 @@ -,daniel,daniel-Dell-15-DC15250,07.04.2026 11:47,/home/daniel/snap/onlyoffice-desktopeditors/1067/.local/share/onlyoffice; \ No newline at end of file From 9471854dfba062be4b371ad8670a04a7bcf40d53 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 08:27:12 +0000 Subject: [PATCH 45/93] dockerfile, requirements, and local handler --- backend/ecmk_fetcher/handler/Dockerfile | 26 +++++++++++++++++++ backend/ecmk_fetcher/handler/handler.py | 4 +++ backend/ecmk_fetcher/handler/requirements.txt | 12 +++++++++ .../local_handler/docker-compose.yml | 11 ++++++++ .../local_handler/invoke_local_lambda.py | 26 +++++++++++++++++++ backend/ecmk_fetcher/processor.py | 3 ++- 6 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 backend/ecmk_fetcher/handler/Dockerfile create mode 100644 backend/ecmk_fetcher/handler/requirements.txt create mode 100644 backend/ecmk_fetcher/local_handler/docker-compose.yml create mode 100644 backend/ecmk_fetcher/local_handler/invoke_local_lambda.py diff --git a/backend/ecmk_fetcher/handler/Dockerfile b/backend/ecmk_fetcher/handler/Dockerfile new file mode 100644 index 00000000..2b6007d9 --- /dev/null +++ b/backend/ecmk_fetcher/handler/Dockerfile @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/playwright/python:v1.58.0-jammy + +# Install AWS Lambda RIE +ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/local/bin/aws-lambda-rie +RUN chmod +x /usr/local/bin/aws-lambda-rie + +# Set working directory (Lambda task root) +WORKDIR /var/task + +COPY backend/ecmk_fetcher/handler/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY utils/ utils/ +COPY backend/ backend/ +COPY datatypes/ datatypes/ + +# Local lambda entrypoint +ENTRYPOINT ["/usr/local/bin/aws-lambda-rie", "python", "-m", "awslambdaric"] + +#AWS lambda entrypoint +# ENTRYPOINT ["python", "-m", "awslambdaric"] + +# ----------------------------- +# Lambda handler +# ----------------------------- +CMD ["backend.ecmk_fetcher.handler.handler.handler"] \ No newline at end of file diff --git a/backend/ecmk_fetcher/handler/handler.py b/backend/ecmk_fetcher/handler/handler.py index 4ce3a949..b777cc9f 100644 --- a/backend/ecmk_fetcher/handler/handler.py +++ b/backend/ecmk_fetcher/handler/handler.py @@ -1,9 +1,13 @@ from typing import Any, Mapping from backend.ecmk_fetcher.processor import run_job +from utils.logger import setup_logger + +logger = setup_logger() def handler(event: Mapping[str, Any], context: Any) -> None: + logger.info("Entered handler") run_job() diff --git a/backend/ecmk_fetcher/handler/requirements.txt b/backend/ecmk_fetcher/handler/requirements.txt new file mode 100644 index 00000000..2692484e --- /dev/null +++ b/backend/ecmk_fetcher/handler/requirements.txt @@ -0,0 +1,12 @@ +awslambdaric +playwright==1.58.0 +msal +openpyxl +sqlalchemy==2.0.36 +sqlmodel +pytz==2024.2 +psycopg2-binary==2.9.10 +pydantic-settings==2.6.0 +boto3==1.35.44 +pandas==2.2.2 +numpy<2.0 \ No newline at end of file diff --git a/backend/ecmk_fetcher/local_handler/docker-compose.yml b/backend/ecmk_fetcher/local_handler/docker-compose.yml new file mode 100644 index 00000000..fd642499 --- /dev/null +++ b/backend/ecmk_fetcher/local_handler/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.9" + +services: + ecmk-fetcher-lambda: + build: + context: ../../../ + dockerfile: backend/ecmk_fetcher/handler/Dockerfile + ports: + - "9000:8080" + env_file: + - ../../../.env \ No newline at end of file diff --git a/backend/ecmk_fetcher/local_handler/invoke_local_lambda.py b/backend/ecmk_fetcher/local_handler/invoke_local_lambda.py new file mode 100644 index 00000000..ba76301e --- /dev/null +++ b/backend/ecmk_fetcher/local_handler/invoke_local_lambda.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +import json +import requests + +HOST = "localhost" +PORT = "9000" + +LAMBDA_URL = f"http://{HOST}:{PORT}/2015-03-31/functions/function/invocations" + +payload = { + "Records": [ + { + "body": json.dumps( + { + "test": 123456, + } + ) + } + ] +} + +response = requests.post(LAMBDA_URL, json=payload) + +print("Status code:", response.status_code) +print("Response:") +print(response.text) diff --git a/backend/ecmk_fetcher/processor.py b/backend/ecmk_fetcher/processor.py index 0ca53c4c..2f122080 100644 --- a/backend/ecmk_fetcher/processor.py +++ b/backend/ecmk_fetcher/processor.py @@ -41,7 +41,8 @@ logger = setup_logger() def run_job() -> None: - username: str = "" + + username: str = "" # TODO: get from github secrets password: str = "" property_list_file: str = ( From 5df2318bb5dfa5812835da530416b91932983fd9 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 08:44:45 +0000 Subject: [PATCH 46/93] start defining infrastructure including ecr --- backend/ecmk_fetcher/handler/Dockerfile | 4 +- .../terraform/lambda/ecmk_to_ara/main.tf | 27 ++++++++++++++ .../terraform/lambda/ecmk_to_ara/provider.tf | 16 ++++++++ .../terraform/lambda/ecmk_to_ara/variables.tf | 37 +++++++++++++++++++ infrastructure/terraform/shared/main.tf | 14 +++++++ 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 infrastructure/terraform/lambda/ecmk_to_ara/main.tf create mode 100644 infrastructure/terraform/lambda/ecmk_to_ara/provider.tf create mode 100644 infrastructure/terraform/lambda/ecmk_to_ara/variables.tf diff --git a/backend/ecmk_fetcher/handler/Dockerfile b/backend/ecmk_fetcher/handler/Dockerfile index 2b6007d9..fa2126fd 100644 --- a/backend/ecmk_fetcher/handler/Dockerfile +++ b/backend/ecmk_fetcher/handler/Dockerfile @@ -15,10 +15,10 @@ COPY backend/ backend/ COPY datatypes/ datatypes/ # Local lambda entrypoint -ENTRYPOINT ["/usr/local/bin/aws-lambda-rie", "python", "-m", "awslambdaric"] +# ENTRYPOINT ["/usr/local/bin/aws-lambda-rie", "python", "-m", "awslambdaric"] #AWS lambda entrypoint -# ENTRYPOINT ["python", "-m", "awslambdaric"] +ENTRYPOINT ["python", "-m", "awslambdaric"] # ----------------------------- # Lambda handler diff --git a/infrastructure/terraform/lambda/ecmk_to_ara/main.tf b/infrastructure/terraform/lambda/ecmk_to_ara/main.tf new file mode 100644 index 00000000..357c2f87 --- /dev/null +++ b/infrastructure/terraform/lambda/ecmk_to_ara/main.tf @@ -0,0 +1,27 @@ +data "terraform_remote_state" "shared" { + backend = "s3" + config = { + bucket = "assessment-model-terraform-state" + key = "env:/${var.stage}/terraform.tfstate" + region = "eu-west-2" + } +} + +module "lambda" { + source = "../../modules/lambda_with_sqs" + + name = "ecmk_to_ara" #"address2uprn" for example + stage = var.stage + + image_uri = local.image_uri + + # Optional: Set maximum_concurrency to limit concurrent SQS-triggered invocations (2-1000) + maximum_concurrency = var.maximum_concurrency + + batch_size = var.batch_size + + environment = { + STAGE = var.stage + LOG_LEVEL = "info" + } +} diff --git a/infrastructure/terraform/lambda/ecmk_to_ara/provider.tf b/infrastructure/terraform/lambda/ecmk_to_ara/provider.tf new file mode 100644 index 00000000..87a94150 --- /dev/null +++ b/infrastructure/terraform/lambda/ecmk_to_ara/provider.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + bucket = "ecmk-to-ara-terraform-state" + key = "terraform.tfstate" + region = "eu-west-2" + } + + required_version = ">= 1.2.0" +} \ No newline at end of file diff --git a/infrastructure/terraform/lambda/ecmk_to_ara/variables.tf b/infrastructure/terraform/lambda/ecmk_to_ara/variables.tf new file mode 100644 index 00000000..984e3908 --- /dev/null +++ b/infrastructure/terraform/lambda/ecmk_to_ara/variables.tf @@ -0,0 +1,37 @@ +variable "lambda_name" { + type = string + description = "Logical name of the lambda (e.g. address2uprn)" +} + +variable "stage" { + description = "Deployment stage (e.g. dev, prod)" + type = string +} +variable "ecr_repo_url" { + type = string + description = "ECR repository URL (no tag, no digest)" +} + +variable "image_digest" { + type = string + description = "Image digest (sha256:...)" +} + +variable "maximum_concurrency" { + type = number + default = 2 + description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit." +} + +variable "batch_size" { + type = number + default = 1 +} + +locals { + image_uri = "${var.ecr_repo_url}@${var.image_digest}" +} + +output "resolved_image_uri" { + value = local.image_uri +} diff --git a/infrastructure/terraform/shared/main.tf b/infrastructure/terraform/shared/main.tf index 9d272eb6..47866c92 100644 --- a/infrastructure/terraform/shared/main.tf +++ b/infrastructure/terraform/shared/main.tf @@ -538,6 +538,20 @@ module "pashub_to_ara_registry" { stage = var.stage } +################################################ +# ECMK to Ara – Lambda +################################################ +module "ecmk_to_ara_state_bucket" { + source = "../modules/tf_state_bucket" + bucket_name = "ecmk-to-ara-terraform-state" +} + +module "ecmk_to_ara_registry" { + source = "../modules/container_registry" + name = "ecmk_to_ara" + stage = var.stage +} + ################################################ # Engine – Lambda ECR ################################################ From 942c2923daf5a18d528ef47847bc2b2b2b8d512e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 10:55:48 +0000 Subject: [PATCH 47/93] initial setup --- .../hubspot_trigger_orchestrator/handler.py | 61 +++++++++++++++++++ .../hubspot_deal_differ.py | 21 +++++++ ...ot_trigger_orchestrator_trigger_request.py | 5 ++ etl/hubspot/hubspotDataTodB.py | 6 +- etl/hubspot/scripts/scraper/main.py | 10 +-- 5 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 backend/hubspot_trigger_orchestrator/handler.py create mode 100644 backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py create mode 100644 backend/hubspot_trigger_orchestrator/hubspot_trigger_orchestrator_trigger_request.py diff --git a/backend/hubspot_trigger_orchestrator/handler.py b/backend/hubspot_trigger_orchestrator/handler.py new file mode 100644 index 00000000..1f83ed80 --- /dev/null +++ b/backend/hubspot_trigger_orchestrator/handler.py @@ -0,0 +1,61 @@ +import json +from typing import Any, Dict, Mapping, Optional + +from backend.app.db.models.organisation import HubspotDealData +from backend.hubspot_trigger_orchestrator.hubspot_deal_differ import HubspotDealDiffer +from backend.hubspot_trigger_orchestrator.hubspot_trigger_orchestrator_trigger_request import ( + HubspotTriggerOrchestratorTriggerRequest, +) +from backend.utils.subtasks import task_handler +from etl.hubspot.hubspotClient import HubspotClient +from etl.hubspot.hubspotDataTodB import HubspotDataToDb +from utils.logger import setup_logger + +logger = setup_logger() + + +@task_handler() +def handler(event: Mapping[str, Any], context: Any) -> None: + + db_client = HubspotDataToDb() + hubspot_client = HubspotClient() + + for record in event.get("Records", []): + body_dict = json.loads(record["body"]) + + logger.debug("Validating request body") + payload = HubspotTriggerOrchestratorTriggerRequest.model_validate(body_dict) + logger.debug("Successfully validated request body") + + hubspot_deal_id: str = payload.hubspot_deal_id + + db_deal: Optional[HubspotDealData] = db_client.find_deal_with_deal_id( + hubspot_deal_id + ) + if not db_deal: + # new hubspot deal, no diffing to do + # TODO: trigger hubspot to db ETL + return + + hubspot_deal: Dict[str, str] + company: Optional[str] + listing: Optional[dict[str, str]] + + hubspot_deal, company, listing = hubspot_client.get_deal_info_for_db( + hubspot_deal_id + ) + + if HubspotDealDiffer.check_for_pashub_trigger( + new_deal=hubspot_deal, old_deal=db_deal + ): + # TODO: trigger pashub file fetcher + return + + if HubspotDealDiffer.check_for_db_update_trigger( + new_deal=hubspot_deal, + new_company=company, + new_listing=listing, + old_deal=db_deal, + ): + # TODO: trigger db upsert + return diff --git a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py new file mode 100644 index 00000000..9d66c637 --- /dev/null +++ b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py @@ -0,0 +1,21 @@ +from typing import Dict, Optional + +from backend.app.db.models.organisation import HubspotDealData + + +class HubspotDealDiffer: + + @staticmethod + def check_for_pashub_trigger( + new_deal: Dict[str, str], old_deal: HubspotDealData + ) -> bool: + raise NotImplementedError + + @staticmethod + def check_for_db_update_trigger( + new_deal: Dict[str, str], + new_company: Optional[str], + new_listing: Optional[Dict[str, str]], + old_deal: HubspotDealData, + ) -> bool: + raise NotImplementedError diff --git a/backend/hubspot_trigger_orchestrator/hubspot_trigger_orchestrator_trigger_request.py b/backend/hubspot_trigger_orchestrator/hubspot_trigger_orchestrator_trigger_request.py new file mode 100644 index 00000000..1adfa07c --- /dev/null +++ b/backend/hubspot_trigger_orchestrator/hubspot_trigger_orchestrator_trigger_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class HubspotTriggerOrchestratorTriggerRequest(BaseModel): + hubspot_deal_id: str diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 6325efc2..36167bf0 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -74,7 +74,7 @@ class HubspotDataToDb: .all() ) - def find_deal_with_deal_id(self, deal_id): + def find_deal_with_deal_id(self, deal_id: str) -> Optional[HubspotDealData]: with db_read_session() as session: return ( session.query(HubspotDealData) @@ -477,7 +477,9 @@ class HubspotDataToDb: dealname=deal_data.get("dealname"), dealstage=deal_data.get("dealstage"), listing_id=listing.get("listing_id", None) if listing else None, - landlord_property_id=listing.get("owner_property_id") if listing else None, + landlord_property_id=( + listing.get("owner_property_id") if listing else None + ), uprn=listing.get("national_uprn") if listing else None, outcome=deal_data.get("outcome"), outcome_notes=deal_data.get("outcome_notes"), diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 4f71c6d0..a003ad28 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -16,9 +16,9 @@ def handler(body: dict[str, Any], context: Any) -> None: hubspot: HubspotClient = HubspotClient() dbloader: HubspotDataToDb = HubspotDataToDb() - deal = dbloader.find_deal_with_deal_id(hubspot_deal_id) - if deal: - dbloader.update_deal_with_checks(deal, hubspot) + db_deal = dbloader.find_deal_with_deal_id(hubspot_deal_id) + if db_deal: + dbloader.update_deal_with_checks(db_deal, hubspot) else: - deal, company, listing = hubspot.get_deal_info_for_db(hubspot_deal_id) - dbloader.upsert_deal(deal, company, listing, hubspot) + hubspot_deal, company, listing = hubspot.get_deal_info_for_db(hubspot_deal_id) + dbloader.upsert_deal(hubspot_deal, company, listing, hubspot) From d730a90246e4cb856c37999274cf58e1723739ca Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 8 Apr 2026 14:47:31 +0000 Subject: [PATCH 48/93] added coordination comments --- backend/address2UPRN/README.md | 6 +++--- backend/app/db/models/organisation.py | 1 + etl/hubspot/hubspotClient.py | 1 + etl/hubspot/hubspotDataTodB.py | 6 ++++++ sfr/principal_pitch/2_export_data.py | 8 ++++---- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/address2UPRN/README.md b/backend/address2UPRN/README.md index b17b36d1..6b0fc753 100644 --- a/backend/address2UPRN/README.md +++ b/backend/address2UPRN/README.md @@ -52,9 +52,9 @@ I uploaded the missing uprn here: s3://retrofit-data-dev/ara_raw_inputs/calico/m ordnance_survey sqs is => https://eu-west-2.console.aws.amazon.com/sqs/v3/home?region=eu-west-2#/queues/https%3A%2F%2Fsqs.eu-west-2.amazonaws.com%2F337213553626%2FordnanceSurvey-queue-dev { - "s3_uri": "s3://retrofit-data-dev/ara_raw_inputs/calico/missinguprn.csv", - "task_id": "a7b70a02-4df4-45b5-a50b-196e095910bb", - "sub_task_id": "567cf73b-1210-4909-9ecc-36ae7e23420e" + "s3_uri": "s3://retrofit-data-dev/ara_raw_inputs/eon/beyond_housing/Book(Sheet1).csv", + "task_id": "ccdec0d1-ebf3-484f-b2ae-397200dd25da", + "sub_task_id": "569d41f6-45cd-4e64-a586-eb8c2097375d" } diff --git a/backend/app/db/models/organisation.py b/backend/app/db/models/organisation.py index cc3ef2bc..784cc4ad 100644 --- a/backend/app/db/models/organisation.py +++ b/backend/app/db/models/organisation.py @@ -38,6 +38,7 @@ class HubspotDealData(SQLModel, table=True): major_condition_issue_evidence_s3_url: Optional[str] = Field(default=None) coordination_status: Optional[str] = Field(default=None) + coordination_comments: Optional[str] = Field(default=None) design_status: Optional[str] = Field(default=None) listing_id: Optional[str] = Field(default=None) diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index a9ea535d..dc2fc7fe 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -246,6 +246,7 @@ class HubspotClient: "major_condition_issue_description", "major_condition_issue_photos", "coordination_status__stage_1_", + "coordination_comments", "retrofit_design_status", "pashub_link", "sharepoint_link", diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 6325efc2..d7b47ef3 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -165,6 +165,10 @@ class HubspotDataToDb: == hs_deal.get("coordination_status__stage_1_"), "coordination stage 1 status mismatch", ), + soft_assert( + deal_in_db.coordination_comments == hs_deal.get("coordination_comments"), + "coordination_comments mismatch", + ), soft_assert( deal_in_db.design_status == hs_deal.get("retrofit_design_status"), "retrofit design mismatch", @@ -383,6 +387,7 @@ class HubspotDataToDb: "coordination_status": deal_data.get( "coordination_status__stage_1_" ), + "coordination_comments": deal_data.get("coordination_comments"), "design_status": deal_data.get("retrofit_design_status"), "pashub_link": deal_data.get("pashub_link"), "sharepoint_link": deal_data.get("sharepoint_link"), @@ -490,6 +495,7 @@ class HubspotDataToDb: "major_condition_issue_photos" ), coordination_status=deal_data.get("coordination_status__stage_1_"), + coordination_comments=deal_data.get("coordination_comments"), design_status=deal_data.get("retrofit_design_status"), pashub_link=deal_data.get("pashub_link"), sharepoint_link=deal_data.get("sharepoint_link"), diff --git a/sfr/principal_pitch/2_export_data.py b/sfr/principal_pitch/2_export_data.py index 3baa7a44..7c80f4dc 100644 --- a/sfr/principal_pitch/2_export_data.py +++ b/sfr/principal_pitch/2_export_data.py @@ -26,13 +26,13 @@ from backend.app.db.functions.materials_functions import get_materials from collections import defaultdict from sqlalchemy import func -PORTFOLIO_ID = 656 -SCENARIOS = [1177] +PORTFOLIO_ID = 632 +SCENARIOS = [1144] scenario_names = { - 1177: "EPC C; Proposed Measures", + 1144: "EPC C", } -project_name = "Walsall Council | WH:LG" +project_name = "Calico Project" def get_data(portfolio_id, scenario_ids): From d6bfef59aff595c9aa3baa9a2aaccd4581d69b4d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 14:48:17 +0000 Subject: [PATCH 49/93] remove db update from hubspot client get method --- .../hubspot_trigger_orchestrator/handler.py | 2 +- etl/hubspot/hubspotClient.py | 10 ++---- etl/hubspot/hubspotDataTodB.py | 23 ++++++++------ etl/hubspot/scripts/scraper/main.py | 31 ++++++++++++++----- .../tests/test_hubspot_client_integration.py | 2 +- 5 files changed, 41 insertions(+), 27 deletions(-) diff --git a/backend/hubspot_trigger_orchestrator/handler.py b/backend/hubspot_trigger_orchestrator/handler.py index 1f83ed80..c79fe2b9 100644 --- a/backend/hubspot_trigger_orchestrator/handler.py +++ b/backend/hubspot_trigger_orchestrator/handler.py @@ -41,7 +41,7 @@ def handler(event: Mapping[str, Any], context: Any) -> None: company: Optional[str] listing: Optional[dict[str, str]] - hubspot_deal, company, listing = hubspot_client.get_deal_info_for_db( + hubspot_deal, company, listing = hubspot_client.get_deal_company_listing( hubspot_deal_id ) diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index a9ea535d..777ad482 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -26,10 +26,10 @@ from hubspot.crm.associations.v4.models import ( # type: ignore[reportMissingTy ForwardPaging as AssociationsPaging, NextPage as AssociationsPagingNext, ) -from etl.hubspot.hubspotDataTodB import CompanyData, HubspotDataToDb from backend.app.config import get_settings +from etl.hubspot.company_data import CompanyData from utils.logger import setup_logger import mimetypes @@ -279,18 +279,12 @@ class HubspotClient: deal_info: dict[str, str] = cast(dict[str, str], deal.properties) # type: ignore[reportUnknownMemberType] return deal_info - def get_deal_info_for_db( + def get_deal_company_listing( self, deal_id: str ) -> tuple[dict[str, str], Optional[str], Optional[dict[str, str]]]: deal: dict[str, str] = self.from_deal_id_get_info(deal_id) company: Optional[str] = self.from_deal_id_get_associated_company_id(deal_id) - - if company: - company_data: CompanyData = self.get_company_information(company) - dbloader: HubspotDataToDb = HubspotDataToDb() - dbloader.upsert_company(company_data) - listing: Optional[dict[str, str]] = self.from_deal_id_get_associated_listing( deal_id ) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 36167bf0..49dd1685 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -2,17 +2,14 @@ from backend.app.db.connection import db_read_session from backend.app.db.models.organisation import Organisation, HubspotDealData from sqlmodel import select from datetime import datetime, timezone -from typing import TypedDict, Optional +from typing import Dict, Optional +from etl.hubspot.company_data import CompanyData +from etl.hubspot.hubspotClient import HubspotClient from etl.hubspot.s3_uploader import S3Uploader import hashlib import os -class CompanyData(TypedDict): - hs_object_id: str - name: str - - class HubspotDataToDb: def __init__(self): self.s3 = S3Uploader( @@ -98,7 +95,9 @@ class HubspotDataToDb: sha256.update(chunk) return sha256.hexdigest() - def update_deal_with_checks(self, deal_in_db, hubspot_client) -> bool: + def update_deal_with_checks( + self, deal_in_db: HubspotDealData, hubspot_client: HubspotClient + ) -> bool: """ Checks if a deal needs updating and syncs it with HubSpot. Also handles major_condition_issue_photos file upload to S3 with integrity check. @@ -112,7 +111,7 @@ class HubspotDataToDb: print(f"🔍 Checking if deal needs updating (deal_id={deal_in_db.deal_id})") - hs_deal, hs_company_id, hs_listing = hubspot_client.get_deal_info_for_db( + hs_deal, hs_company_id, hs_listing = hubspot_client.get_deal_company_listing( deal_in_db.deal_id ) @@ -346,7 +345,13 @@ class HubspotDataToDb: return True - def upsert_deal(self, deal_data, company, listing, hubspot_client): + def upsert_deal( + self, + deal_data: Dict[str, str], + company: Optional[str], + listing: Optional[dict[str, str]], + hubspot_client: HubspotClient, + ): """ Inserts or updates a deal record. Also uploads photos if present and adds S3 URL. diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index a003ad28..e5658a20 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -1,7 +1,8 @@ +from backend.app.db.models.organisation import HubspotDealData from etl.hubspot.hubspotClient import HubspotClient -from etl.hubspot.hubspotDataTodB import HubspotDataToDb +from etl.hubspot.hubspotDataTodB import CompanyData, HubspotDataToDb from backend.utils.subtasks import task_handler -from typing import Any +from typing import Any, Dict, Optional @task_handler() @@ -14,11 +15,25 @@ def handler(body: dict[str, Any], context: Any) -> None: ) hubspot_deal_id = "327170793707" - hubspot: HubspotClient = HubspotClient() - dbloader: HubspotDataToDb = HubspotDataToDb() - db_deal = dbloader.find_deal_with_deal_id(hubspot_deal_id) + hubspot_client = HubspotClient() + db_client = HubspotDataToDb() + db_deal: Optional[HubspotDealData] = db_client.find_deal_with_deal_id( + hubspot_deal_id + ) if db_deal: - dbloader.update_deal_with_checks(db_deal, hubspot) + db_client.update_deal_with_checks(db_deal, hubspot_client) else: - hubspot_deal, company, listing = hubspot.get_deal_info_for_db(hubspot_deal_id) - dbloader.upsert_deal(hubspot_deal, company, listing, hubspot) + hubspot_deal: Dict[str, str] + company: Optional[str] + listing: Optional[dict[str, str]] + + hubspot_deal, company, listing = hubspot_client.get_deal_company_listing( + hubspot_deal_id + ) + + if company: + company_data: CompanyData = hubspot_client.get_company_information(company) + db_client: HubspotDataToDb = HubspotDataToDb() + db_client.upsert_company(company_data) + + db_client.upsert_deal(hubspot_deal, company, listing, hubspot_client) diff --git a/etl/hubspot/tests/test_hubspot_client_integration.py b/etl/hubspot/tests/test_hubspot_client_integration.py index a3d8ae54..d0dd818a 100644 --- a/etl/hubspot/tests/test_hubspot_client_integration.py +++ b/etl/hubspot/tests/test_hubspot_client_integration.py @@ -71,7 +71,7 @@ class TestHubspotClientIntegration: def test_get_deal_info_for_db(self, client: HubspotClient): deal_id: str = "263490768079" - deal, company, listing = client.get_deal_info_for_db(deal_id) + deal, company, listing = client.get_deal_company_listing(deal_id) assert "dealname" in deal assert "dealstage" in deal From b968fbab448c39aaabd21251c406f5ca7c0a8f83 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 14:48:29 +0000 Subject: [PATCH 50/93] include missing file --- etl/hubspot/company_data.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 etl/hubspot/company_data.py diff --git a/etl/hubspot/company_data.py b/etl/hubspot/company_data.py new file mode 100644 index 00000000..13b2ee88 --- /dev/null +++ b/etl/hubspot/company_data.py @@ -0,0 +1,6 @@ +from typing import TypedDict + + +class CompanyData(TypedDict): + hs_object_id: str + name: str From 540054e12f83514aa5baf2861235514e4450bae1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 14:53:11 +0000 Subject: [PATCH 51/93] rename method --- backend/hubspot_trigger_orchestrator/handler.py | 4 ++-- etl/hubspot/hubspotClient.py | 2 +- etl/hubspot/hubspotDataTodB.py | 4 ++-- etl/hubspot/scripts/scraper/main.py | 4 ++-- etl/hubspot/tests/test_hubspot_client_integration.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/hubspot_trigger_orchestrator/handler.py b/backend/hubspot_trigger_orchestrator/handler.py index c79fe2b9..38724812 100644 --- a/backend/hubspot_trigger_orchestrator/handler.py +++ b/backend/hubspot_trigger_orchestrator/handler.py @@ -41,8 +41,8 @@ def handler(event: Mapping[str, Any], context: Any) -> None: company: Optional[str] listing: Optional[dict[str, str]] - hubspot_deal, company, listing = hubspot_client.get_deal_company_listing( - hubspot_deal_id + hubspot_deal, company, listing = ( + hubspot_client.get_deal_and_company_and_listing(hubspot_deal_id) ) if HubspotDealDiffer.check_for_pashub_trigger( diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index 777ad482..cedaa7f3 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -279,7 +279,7 @@ class HubspotClient: deal_info: dict[str, str] = cast(dict[str, str], deal.properties) # type: ignore[reportUnknownMemberType] return deal_info - def get_deal_company_listing( + def get_deal_and_company_and_listing( self, deal_id: str ) -> tuple[dict[str, str], Optional[str], Optional[dict[str, str]]]: diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 49dd1685..e7008618 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -111,8 +111,8 @@ class HubspotDataToDb: print(f"🔍 Checking if deal needs updating (deal_id={deal_in_db.deal_id})") - hs_deal, hs_company_id, hs_listing = hubspot_client.get_deal_company_listing( - deal_in_db.deal_id + hs_deal, hs_company_id, hs_listing = ( + hubspot_client.get_deal_and_company_and_listing(deal_in_db.deal_id) ) # Soft compare key fields diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index e5658a20..d8d4a357 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -27,8 +27,8 @@ def handler(body: dict[str, Any], context: Any) -> None: company: Optional[str] listing: Optional[dict[str, str]] - hubspot_deal, company, listing = hubspot_client.get_deal_company_listing( - hubspot_deal_id + hubspot_deal, company, listing = ( + hubspot_client.get_deal_and_company_and_listing(hubspot_deal_id) ) if company: diff --git a/etl/hubspot/tests/test_hubspot_client_integration.py b/etl/hubspot/tests/test_hubspot_client_integration.py index d0dd818a..0f4b425c 100644 --- a/etl/hubspot/tests/test_hubspot_client_integration.py +++ b/etl/hubspot/tests/test_hubspot_client_integration.py @@ -71,7 +71,7 @@ class TestHubspotClientIntegration: def test_get_deal_info_for_db(self, client: HubspotClient): deal_id: str = "263490768079" - deal, company, listing = client.get_deal_company_listing(deal_id) + deal, company, listing = client.get_deal_and_company_and_listing(deal_id) assert "dealname" in deal assert "dealstage" in deal From 21ca0d7649bc2ff9be36dd236d80b3abed391894 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 15:25:50 +0000 Subject: [PATCH 52/93] =?UTF-8?q?diff=20checker=20for=20pashub=20trigger?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pytest.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest.ini b/pytest.ini index db7afaf5..792b27e0 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 +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 markers = integration: mark a test as an integration test From 39f37f1668907db733ffa602e1a2b84c9e766fd0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 15:26:32 +0000 Subject: [PATCH 53/93] =?UTF-8?q?diff=20checker=20for=20pashub=20trigger?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_hubspot_deal_differ.py | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py diff --git a/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py new file mode 100644 index 00000000..ddca766a --- /dev/null +++ b/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py @@ -0,0 +1,424 @@ +from datetime import datetime +from typing import Dict +import uuid + +from backend.app.db.models.organisation import HubspotDealData +from backend.hubspot_trigger_orchestrator.hubspot_deal_differ import HubspotDealDiffer + + +def test_pashub_trigger__outcome_note_added__returns_false() -> None: + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "outcome_notes": "test note", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = False + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__pashub_link_changed__returns_true() -> None: + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + pashub_link="www.google.co.uk", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "pashub_link": "www.bbc.co.uk", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = True + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__coordination_completed_and_pashub_link_set__returns_true() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + pashub_link="www.google.co.uk", + coordination_status="random", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "coordination_status": "v1 ioe/mtp complete", + "pashub_link": "www.google.co.uk", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = True + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__coordination_completed_and_pashub_link_set__returns_true_2() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + pashub_link="www.google.co.uk", + coordination_status="random", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "coordination_status": "v2 ioe/mtp complete", + "pashub_link": "www.google.co.uk", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = True + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__coordination_completed_and_pashub_link_not_set__returns_false() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + coordination_status="random", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "coordination_status": "v2 ioe/mtp complete", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = False + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__coordination_status_not_completed_and_pashub_link_set__returns_false() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + pashub_link="www.google.co.uk", + coordination_status="random", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "coordination_status": "not complete", + "pashub_link": "www.google.co.uk", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = False + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__design_completed_and_pashub_link_set__returns_true() -> None: + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + pashub_link="www.google.co.uk", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "pashub_link": "www.google.co.uk", + "design_status": "uploaded", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = True + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__design_completed_and_pashub_link_not_set__returns_false() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "design_status": "uploaded", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = False + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__design_not_completed_and_pashub_link_set__returns_false() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + pashub_link="www.google.co.uk", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "pashub_link": "www.google.co.uk", + "design_status": "not uploaded", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = False + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__lodgement_completed_and_pashub_link_set__returns_true() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + pashub_link="www.google.co.uk", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "pashub_link": "www.google.co.uk", + "lodgement_status": "lodgement complete", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = True + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__lodgement_completed_and_pashub_link_set__returns_true_2() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + pashub_link="www.google.co.uk", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "pashub_link": "www.google.co.uk", + "lodgement_status": "measures lodged", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = True + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__lodgement_completed_and_pashub_link_not_set__returns_false() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "design_status": "lodgement complete", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = False + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output + + +def test_pashub_trigger__lodgement_not_completed_and_pashub_link_set__returns_false() -> ( + None +): + # arrange + deal_id = uuid.uuid4() + + old_deal = HubspotDealData( + id=deal_id, + deal_id="1", + pashub_link="www.google.co.uk", + created_at=datetime(2025, 12, 1, 12, 0, 0), + updated_at=datetime(2025, 12, 1, 12, 0, 0), + ) + new_deal: Dict[str, str] = { + "id": str(deal_id), + "deal_id": "1", + "pashub_link": "www.google.co.uk", + "lodgement_status": "lodgement not complete", + "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + } + + expected_output = False + + # act + actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, old_deal=old_deal + ) + + # assert + assert actual_output == expected_output From 9f7448ac438cbfd6ece4f91d556fa58f2798abce Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 15:35:42 +0000 Subject: [PATCH 54/93] =?UTF-8?q?pashub=20trigger=20true=20if=20pashub=20l?= =?UTF-8?q?ink=20is=20changed=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hubspot_deal_differ.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py index 9d66c637..50f3af04 100644 --- a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py +++ b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py @@ -9,6 +9,23 @@ class HubspotDealDiffer: def check_for_pashub_trigger( new_deal: Dict[str, str], old_deal: HubspotDealData ) -> bool: + """ + Case 1: PasHub Link is updated + Case 2: Coordination is completed (and PasHub Link is populated) + Case 3: Design is completed (and PasHub Link is populated) + Case 4: Lodgement is completed (and PasHub Link is populated) + """ + new_pashub_link: Optional[str] = new_deal["pashub_link"] + # Case 1 + if not new_pashub_link: + return False + + if not old_deal.pashub_link: + return True + + if old_deal.pashub_link != new_pashub_link: + return True + raise NotImplementedError @staticmethod From ad2c979b155840f36a14b239aa1cccaa20e361ca Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 15:35:52 +0000 Subject: [PATCH 55/93] =?UTF-8?q?pashub=20trigger=20false=20if=20pashub=20?= =?UTF-8?q?link=20not=20set=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py index 50f3af04..ab2b667e 100644 --- a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py +++ b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py @@ -15,7 +15,7 @@ class HubspotDealDiffer: Case 3: Design is completed (and PasHub Link is populated) Case 4: Lodgement is completed (and PasHub Link is populated) """ - new_pashub_link: Optional[str] = new_deal["pashub_link"] + new_pashub_link: Optional[str] = new_deal.get("pashub_link", "") # Case 1 if not new_pashub_link: return False From 832bcd96e457a71453e1d4d2aa97a73dcba1b243 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 15:42:29 +0000 Subject: [PATCH 56/93] =?UTF-8?q?pashub=20trigger=20true=20if=20coordinati?= =?UTF-8?q?on=20complete=20and=20pashub=20link=20set=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hubspot_deal_differ.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py index ab2b667e..77208432 100644 --- a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py +++ b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Dict, List, Optional from backend.app.db.models.organisation import HubspotDealData @@ -15,7 +15,11 @@ class HubspotDealDiffer: Case 3: Design is completed (and PasHub Link is populated) Case 4: Lodgement is completed (and PasHub Link is populated) """ - new_pashub_link: Optional[str] = new_deal.get("pashub_link", "") + new_pashub_link: str = new_deal.get("pashub_link", "") + COORDINATION_COMPLETE: List[str] = [ + "v1 ioe/mtp complete", + "v2 ioe/mtp complete", + ] # Case 1 if not new_pashub_link: return False @@ -26,6 +30,16 @@ class HubspotDealDiffer: if old_deal.pashub_link != new_pashub_link: return True + # Case 2 + new_coordination_status: str = new_deal.get("coordination_status", "") + + if ( + new_coordination_status + and new_coordination_status in COORDINATION_COMPLETE + and new_coordination_status != old_deal.coordination_status + ): + return True + raise NotImplementedError @staticmethod From 0dfd3f5238e969d4d233135c857f2084461b84c5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 15:45:41 +0000 Subject: [PATCH 57/93] =?UTF-8?q?pashub=20trigger=20true=20if=20design=20c?= =?UTF-8?q?omplete=20and=20pashub=20link=20set=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hubspot_deal_differ.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py index 77208432..ad20aca7 100644 --- a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py +++ b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py @@ -15,11 +15,14 @@ class HubspotDealDiffer: Case 3: Design is completed (and PasHub Link is populated) Case 4: Lodgement is completed (and PasHub Link is populated) """ - new_pashub_link: str = new_deal.get("pashub_link", "") COORDINATION_COMPLETE: List[str] = [ "v1 ioe/mtp complete", "v2 ioe/mtp complete", ] + RETROFIT_DESIGN_COMPLETE = "uploaded" + + new_pashub_link: str = new_deal.get("pashub_link", "") + # Case 1 if not new_pashub_link: return False @@ -40,6 +43,16 @@ class HubspotDealDiffer: ): return True + # Case 3 + new_design_status: str = new_deal.get("design_status", "") + + if ( + new_design_status + and new_design_status == RETROFIT_DESIGN_COMPLETE + and new_design_status != old_deal.design_status + ): + return True + raise NotImplementedError @staticmethod From 9da0cabb0ffcbb7338d5dd0c9796234202acbb0a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 15:52:41 +0000 Subject: [PATCH 58/93] =?UTF-8?q?pashub=20trigger=20true=20if=20lodgement?= =?UTF-8?q?=20complete=20and=20pashub=20link=20set=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hubspot_deal_differ.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py index ad20aca7..8f96ce73 100644 --- a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py +++ b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py @@ -20,6 +20,7 @@ class HubspotDealDiffer: "v2 ioe/mtp complete", ] RETROFIT_DESIGN_COMPLETE = "uploaded" + LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"] new_pashub_link: str = new_deal.get("pashub_link", "") @@ -53,7 +54,17 @@ class HubspotDealDiffer: ): return True - raise NotImplementedError + # Case 4 + new_lodgement_status: str = new_deal.get("lodgement_status", "") + + if ( + new_lodgement_status + and new_lodgement_status in LODGEMENT_COMPLETE + and new_lodgement_status != old_deal.lodgement_status + ): + return True + + return False @staticmethod def check_for_db_update_trigger( From 2d0bc67731239d045c6f7106ba87e6a7b640b2ff Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 15:59:29 +0000 Subject: [PATCH 59/93] =?UTF-8?q?diff=20checker=20for=20pashub=20trigger?= =?UTF-8?q?=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hubspot_deal_differ.py | 133 ++++++++++-------- 1 file changed, 72 insertions(+), 61 deletions(-) diff --git a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py index 8f96ce73..1dd4ed51 100644 --- a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py +++ b/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py @@ -4,67 +4,12 @@ from backend.app.db.models.organisation import HubspotDealData class HubspotDealDiffer: - - @staticmethod - def check_for_pashub_trigger( - new_deal: Dict[str, str], old_deal: HubspotDealData - ) -> bool: - """ - Case 1: PasHub Link is updated - Case 2: Coordination is completed (and PasHub Link is populated) - Case 3: Design is completed (and PasHub Link is populated) - Case 4: Lodgement is completed (and PasHub Link is populated) - """ - COORDINATION_COMPLETE: List[str] = [ - "v1 ioe/mtp complete", - "v2 ioe/mtp complete", - ] - RETROFIT_DESIGN_COMPLETE = "uploaded" - LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"] - - new_pashub_link: str = new_deal.get("pashub_link", "") - - # Case 1 - if not new_pashub_link: - return False - - if not old_deal.pashub_link: - return True - - if old_deal.pashub_link != new_pashub_link: - return True - - # Case 2 - new_coordination_status: str = new_deal.get("coordination_status", "") - - if ( - new_coordination_status - and new_coordination_status in COORDINATION_COMPLETE - and new_coordination_status != old_deal.coordination_status - ): - return True - - # Case 3 - new_design_status: str = new_deal.get("design_status", "") - - if ( - new_design_status - and new_design_status == RETROFIT_DESIGN_COMPLETE - and new_design_status != old_deal.design_status - ): - return True - - # Case 4 - new_lodgement_status: str = new_deal.get("lodgement_status", "") - - if ( - new_lodgement_status - and new_lodgement_status in LODGEMENT_COMPLETE - and new_lodgement_status != old_deal.lodgement_status - ): - return True - - return False + COORDINATION_COMPLETE: List[str] = [ + "v1 ioe/mtp complete", + "v2 ioe/mtp complete", + ] + RETROFIT_DESIGN_COMPLETE = "uploaded" + LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"] @staticmethod def check_for_db_update_trigger( @@ -74,3 +19,69 @@ class HubspotDealDiffer: old_deal: HubspotDealData, ) -> bool: raise NotImplementedError + + @staticmethod + def check_for_pashub_trigger( + new_deal: Dict[str, str], old_deal: HubspotDealData + ) -> bool: + new_pashub_link: str = new_deal.get("pashub_link", "") + + if not HubspotDealDiffer._has_valid_pashub_link(new_pashub_link): + return False + + if HubspotDealDiffer._new_or_updated_pashub_link(new_pashub_link, old_deal): + return True + + if HubspotDealDiffer._coordination_completed(new_deal, old_deal): + return True + + if HubspotDealDiffer._design_completed(new_deal, old_deal): + return True + + if HubspotDealDiffer._lodgement_completed(new_deal, old_deal): + return True + + return False + + @staticmethod + def _has_valid_pashub_link(new_pashub_link: str) -> bool: + return bool(new_pashub_link) + + @staticmethod + def _new_or_updated_pashub_link( + new_pashub_link: str, old_deal: HubspotDealData + ) -> bool: + if not old_deal.pashub_link: + return True + return old_deal.pashub_link != new_pashub_link + + @staticmethod + def _coordination_completed( + new_deal: Dict[str, str], old_deal: HubspotDealData + ) -> bool: + new_status: str = new_deal.get("coordination_status", "") + return ( + new_status != "" + and new_status in HubspotDealDiffer.COORDINATION_COMPLETE + and new_status != old_deal.coordination_status + ) + + @staticmethod + def _design_completed(new_deal: Dict[str, str], old_deal: HubspotDealData) -> bool: + new_status: str = new_deal.get("design_status", "") + return ( + new_status != "" + and new_status == HubspotDealDiffer.RETROFIT_DESIGN_COMPLETE + and new_status != old_deal.design_status + ) + + @staticmethod + def _lodgement_completed( + new_deal: Dict[str, str], old_deal: HubspotDealData + ) -> bool: + new_status: str = new_deal.get("lodgement_status", "") + return ( + new_status != "" + and new_status in HubspotDealDiffer.LODGEMENT_COMPLETE + and new_status != old_deal.lodgement_status + ) From f719149c03fe1827499fbc66df1facc0ab325676 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Apr 2026 16:04:30 +0000 Subject: [PATCH 60/93] =?UTF-8?q?replace=20incorrect=20tests=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_hubspot_deal_differ.py | 73 +------------------ 1 file changed, 4 insertions(+), 69 deletions(-) diff --git a/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py index ddca766a..ba6b80e4 100644 --- a/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py +++ b/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py @@ -165,40 +165,6 @@ def test_pashub_trigger__coordination_completed_and_pashub_link_not_set__returns assert actual_output == expected_output -def test_pashub_trigger__coordination_status_not_completed_and_pashub_link_set__returns_false() -> ( - None -): - # arrange - deal_id = uuid.uuid4() - - old_deal = HubspotDealData( - id=deal_id, - deal_id="1", - pashub_link="www.google.co.uk", - coordination_status="random", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "coordination_status": "not complete", - "pashub_link": "www.google.co.uk", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = False - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal - ) - - # assert - assert actual_output == expected_output - - def test_pashub_trigger__design_completed_and_pashub_link_set__returns_true() -> None: # arrange deal_id = uuid.uuid4() @@ -261,39 +227,6 @@ def test_pashub_trigger__design_completed_and_pashub_link_not_set__returns_false assert actual_output == expected_output -def test_pashub_trigger__design_not_completed_and_pashub_link_set__returns_false() -> ( - None -): - # arrange - deal_id = uuid.uuid4() - - old_deal = HubspotDealData( - id=deal_id, - deal_id="1", - pashub_link="www.google.co.uk", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "pashub_link": "www.google.co.uk", - "design_status": "not uploaded", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = False - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal - ) - - # assert - assert actual_output == expected_output - - def test_pashub_trigger__lodgement_completed_and_pashub_link_set__returns_true() -> ( None ): @@ -391,7 +324,7 @@ def test_pashub_trigger__lodgement_completed_and_pashub_link_not_set__returns_fa assert actual_output == expected_output -def test_pashub_trigger__lodgement_not_completed_and_pashub_link_set__returns_false() -> ( +def test_pashub_trigger__coordination_design_lodgement_not_completed_and_pashub_link_set__returns_false() -> ( None ): # arrange @@ -408,7 +341,9 @@ def test_pashub_trigger__lodgement_not_completed_and_pashub_link_set__returns_fa "id": str(deal_id), "deal_id": "1", "pashub_link": "www.google.co.uk", - "lodgement_status": "lodgement not complete", + "coordination_status": "not uploaded", + "design_status": "not uploaded", + "lodgement_status": "not uploaded", "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), } From dd0522713e85456b0b93fbb7a1d114795b1cb56d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 08:30:28 +0000 Subject: [PATCH 61/93] refactor upsert_deal by introducing helper methods --- etl/hubspot/hubspotDataTodB.py | 383 +++++++++++++++++---------------- 1 file changed, 198 insertions(+), 185 deletions(-) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index e7008618..f0beeee8 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -366,109 +366,9 @@ class HubspotDataToDb: if existing: print(f"🔄 Updating existing deal (deal_id={deal_id})") + self._update_existing_deal(existing, deal_data, listing, company) - for attr, value in { - "dealname": deal_data.get("dealname"), - "dealstage": deal_data.get("dealstage"), - "listing_id": listing.get("listing_id", None) if listing else None, - "landlord_property_id": ( - listing.get("owner_property_id", None) if listing else None - ), - "uprn": listing.get("national_uprn", None) if listing else None, - "outcome": deal_data.get("outcome"), - "outcome_notes": deal_data.get("outcome_notes"), - "project_code": deal_data.get("project_code"), - "company_id": company, - "major_condition_issue_description": deal_data.get( - "major_condition_issue_description" - ), - "major_condition_issue_photos": deal_data.get( - "major_condition_issue_photos" - ), - "coordination_status": deal_data.get( - "coordination_status__stage_1_" - ), - "design_status": deal_data.get("retrofit_design_status"), - "pashub_link": deal_data.get("pashub_link"), - "sharepoint_link": deal_data.get("sharepoint_link"), - "dampmould_growth": deal_data.get("dampmould_growth"), - "damp_mould_and_repairs_comments": deal_data.get( - "damp_mould_and_repairs_comments" - ), - "pre_sap": deal_data.get("pre_sap"), - "coordinator": deal_data.get("coordinator"), - "mtp_completion_date": self._parse_hs_date( - deal_data.get("mtp_completion_date") - ), - "mtp_re_model_completion_date": self._parse_hs_date( - deal_data.get("mtp_re_model_completion_date") - ), - "ioe_v3_completion_date": self._parse_hs_date( - deal_data.get("ioe_v3_completion_date") - ), - "proposed_measures": deal_data.get("proposed_measures"), - "approved_package": deal_data.get("approved_package"), - "designer": deal_data.get("designer"), - "design_completion_date": self._parse_hs_date( - deal_data.get("design_completion_date") - ), - "actual_measures_installed": deal_data.get( - "actual_measures_installed" - ), - "installer": deal_data.get("installer"), - "installer_handover": deal_data.get("installer_handover"), - "lodgement_status": deal_data.get("lodgement_status"), - "measures_lodgement_date": self._parse_hs_date( - deal_data.get("measures_lodgement_date") - ), - "lodgement_date": self._parse_hs_date( - deal_data.get("lodgement_date") - ), - "expected_commencement_date": self._parse_hs_date( - deal_data.get("expected_commencement_date") - ), - "surveyor": deal_data.get("surveyor"), - "confirmed_survey_date": self._parse_hs_date( - deal_data.get("confirmed_survey_date") - ), - "confirmed_survey_time": deal_data.get("confirmed_survey_time"), - "surveyed_date": self._parse_hs_date( - deal_data.get("surveyed_date") - ), - "design_type": deal_data.get("design_type"), - }.items(): - setattr(existing, attr, value or getattr(existing, attr)) - - # Upload if photo exists but S3 link missing - if ( - existing.major_condition_issue_photos - and not existing.major_condition_issue_evidence_s3_url - ): - # Fetch fresh URL from HubSpot instead of using potentially expired stored URL - fresh_deal = hubspot_client.from_deal_id_get_info(existing.deal_id) - photo_url = fresh_deal.get("major_condition_issue_photos") - - if photo_url: - try: - local_file = hubspot_client.download_file_from_url( - photo_url - ) - s3_url = self.s3.upload_file( - local_file, - "retrofit-data-dev", - prefix="hubspot/awaabs_law_evidence/", - ) - existing.major_condition_issue_evidence_s3_url = s3_url - except Exception as e: - print( - f"⚠️ Failed to download photo for deal_id {existing.deal_id}: {e}" - ) - # Continue without the file — don't crash the update - finally: - if "local_file" in locals() and os.path.exists(local_file): - os.remove(local_file) - else: - print(f"⚠️ Photo URL missing for deal_id {existing.deal_id}") + self._handle_existing_photo_upload(existing, hubspot_client) session.add(existing) session.commit() @@ -477,94 +377,207 @@ class HubspotDataToDb: else: print(f"🆕 Inserting new deal (deal_id={deal_id})") - new_record = HubspotDealData( - deal_id=deal_id, - dealname=deal_data.get("dealname"), - dealstage=deal_data.get("dealstage"), - listing_id=listing.get("listing_id", None) if listing else None, - landlord_property_id=( - listing.get("owner_property_id") if listing else None - ), - uprn=listing.get("national_uprn") if listing else None, - outcome=deal_data.get("outcome"), - outcome_notes=deal_data.get("outcome_notes"), - project_code=deal_data.get("project_code"), - company_id=company, - major_condition_issue_description=deal_data.get( - "major_condition_issue_description" - ), - major_condition_issue_photos=deal_data.get( - "major_condition_issue_photos" - ), - coordination_status=deal_data.get("coordination_status__stage_1_"), - design_status=deal_data.get("retrofit_design_status"), - pashub_link=deal_data.get("pashub_link"), - sharepoint_link=deal_data.get("sharepoint_link"), - dampmould_growth=deal_data.get("dampmould_growth"), - damp_mould_and_repairs_comments=deal_data.get( - "damp_mould_and_repairs_comments" - ), - pre_sap=deal_data.get("pre_sap"), - coordinator=deal_data.get("coordinator"), - mtp_completion_date=self._parse_hs_date( - deal_data.get("mtp_completion_date") - ), - mtp_re_model_completion_date=self._parse_hs_date( - deal_data.get("mtp_re_model_completion_date") - ), - ioe_v3_completion_date=self._parse_hs_date( - deal_data.get("ioe_v3_completion_date") - ), - proposed_measures=deal_data.get("proposed_measures"), - approved_package=deal_data.get("approved_package"), - designer=deal_data.get("designer"), - design_completion_date=self._parse_hs_date( - deal_data.get("design_completion_date") - ), - actual_measures_installed=deal_data.get( - "actual_measures_installed" - ), - installer=deal_data.get("installer"), - installer_handover=deal_data.get("installer_handover"), - lodgement_status=deal_data.get("lodgement_status"), - measures_lodgement_date=self._parse_hs_date( - deal_data.get("measures_lodgement_date") - ), - lodgement_date=self._parse_hs_date(deal_data.get("lodgement_date")), - expected_commencement_date=self._parse_hs_date( - deal_data.get("expected_commencement_date") - ), - surveyor=deal_data.get("surveyor"), - confirmed_survey_date=self._parse_hs_date( - deal_data.get("confirmed_survey_date") - ), - confirmed_survey_time=deal_data.get("confirmed_survey_time"), - surveyed_date=self._parse_hs_date(deal_data.get("surveyed_date")), - design_type=deal_data.get("design_type"), + new_record: HubspotDealData = self._build_new_deal( + deal_id, deal_data, listing, company ) # Handle upload at insert time - if new_record.major_condition_issue_photos: - try: - local_file = hubspot_client.download_file_from_url( - new_record.major_condition_issue_photos - ) - s3_url = self.s3.upload_file( - local_file, - "retrofit-data-dev", - prefix="hubspot/awaabs_law_evidence/", - ) - new_record.major_condition_issue_evidence_s3_url = s3_url - except Exception as e: - print( - f"⚠️ Failed to download photo for deal_id {new_record.deal_id}: {e}" - ) - # Continue without the file — don't crash the insert - finally: - if "local_file" in locals() and os.path.exists(local_file): - os.remove(local_file) + self._handle_new_photo_upload(new_record, hubspot_client) session.add(new_record) session.commit() session.refresh(new_record) return new_record + + def _update_existing_deal( + self, + existing: HubspotDealData, + deal_data: Dict[str, str], + listing: Optional[dict[str, str]], + company: Optional[str], + ): + for attr, value in { + "dealname": deal_data.get("dealname"), + "dealstage": deal_data.get("dealstage"), + "listing_id": listing.get("listing_id", None) if listing else None, + "landlord_property_id": ( + listing.get("owner_property_id", None) if listing else None + ), + "uprn": listing.get("national_uprn", None) if listing else None, + "outcome": deal_data.get("outcome"), + "outcome_notes": deal_data.get("outcome_notes"), + "project_code": deal_data.get("project_code"), + "company_id": company, + "major_condition_issue_description": deal_data.get( + "major_condition_issue_description" + ), + "major_condition_issue_photos": deal_data.get( + "major_condition_issue_photos" + ), + "coordination_status": deal_data.get("coordination_status__stage_1_"), + "design_status": deal_data.get("retrofit_design_status"), + "pashub_link": deal_data.get("pashub_link"), + "sharepoint_link": deal_data.get("sharepoint_link"), + "dampmould_growth": deal_data.get("dampmould_growth"), + "damp_mould_and_repairs_comments": deal_data.get( + "damp_mould_and_repairs_comments" + ), + "pre_sap": deal_data.get("pre_sap"), + "coordinator": deal_data.get("coordinator"), + "mtp_completion_date": self._parse_hs_date( + deal_data.get("mtp_completion_date") + ), + "mtp_re_model_completion_date": self._parse_hs_date( + deal_data.get("mtp_re_model_completion_date") + ), + "ioe_v3_completion_date": self._parse_hs_date( + deal_data.get("ioe_v3_completion_date") + ), + "proposed_measures": deal_data.get("proposed_measures"), + "approved_package": deal_data.get("approved_package"), + "designer": deal_data.get("designer"), + "design_completion_date": self._parse_hs_date( + deal_data.get("design_completion_date") + ), + "actual_measures_installed": deal_data.get("actual_measures_installed"), + "installer": deal_data.get("installer"), + "installer_handover": deal_data.get("installer_handover"), + "lodgement_status": deal_data.get("lodgement_status"), + "measures_lodgement_date": self._parse_hs_date( + deal_data.get("measures_lodgement_date") + ), + "lodgement_date": self._parse_hs_date(deal_data.get("lodgement_date")), + "expected_commencement_date": self._parse_hs_date( + deal_data.get("expected_commencement_date") + ), + "surveyor": deal_data.get("surveyor"), + "confirmed_survey_date": self._parse_hs_date( + deal_data.get("confirmed_survey_date") + ), + "confirmed_survey_time": deal_data.get("confirmed_survey_time"), + "surveyed_date": self._parse_hs_date(deal_data.get("surveyed_date")), + "design_type": deal_data.get("design_type"), + }.items(): + setattr(existing, attr, value or getattr(existing, attr)) + + def _build_new_deal( + self, + deal_id: str, + deal_data: Dict[str, str], + listing: Optional[dict[str, str]], + company: Optional[str], + ) -> HubspotDealData: + return HubspotDealData( + deal_id=deal_id, + dealname=deal_data.get("dealname"), + dealstage=deal_data.get("dealstage"), + listing_id=listing.get("listing_id") if listing else None, + landlord_property_id=( + listing.get("owner_property_id") if listing else None + ), + uprn=listing.get("national_uprn") if listing else None, + outcome=deal_data.get("outcome"), + outcome_notes=deal_data.get("outcome_notes"), + project_code=deal_data.get("project_code"), + company_id=company, + major_condition_issue_description=deal_data.get( + "major_condition_issue_description" + ), + major_condition_issue_photos=deal_data.get("major_condition_issue_photos"), + coordination_status=deal_data.get("coordination_status__stage_1_"), + design_status=deal_data.get("retrofit_design_status"), + pashub_link=deal_data.get("pashub_link"), + sharepoint_link=deal_data.get("sharepoint_link"), + dampmould_growth=deal_data.get("dampmould_growth"), + damp_mould_and_repairs_comments=deal_data.get( + "damp_mould_and_repairs_comments" + ), + pre_sap=deal_data.get("pre_sap"), + coordinator=deal_data.get("coordinator"), + mtp_completion_date=self._parse_hs_date( + deal_data.get("mtp_completion_date") + ), + mtp_re_model_completion_date=self._parse_hs_date( + deal_data.get("mtp_re_model_completion_date") + ), + ioe_v3_completion_date=self._parse_hs_date( + deal_data.get("ioe_v3_completion_date") + ), + proposed_measures=deal_data.get("proposed_measures"), + approved_package=deal_data.get("approved_package"), + designer=deal_data.get("designer"), + design_completion_date=self._parse_hs_date( + deal_data.get("design_completion_date") + ), + actual_measures_installed=deal_data.get("actual_measures_installed"), + installer=deal_data.get("installer"), + installer_handover=deal_data.get("installer_handover"), + lodgement_status=deal_data.get("lodgement_status"), + measures_lodgement_date=self._parse_hs_date( + deal_data.get("measures_lodgement_date") + ), + lodgement_date=self._parse_hs_date(deal_data.get("lodgement_date")), + expected_commencement_date=self._parse_hs_date( + deal_data.get("expected_commencement_date") + ), + surveyor=deal_data.get("surveyor"), + confirmed_survey_date=self._parse_hs_date( + deal_data.get("confirmed_survey_date") + ), + confirmed_survey_time=deal_data.get("confirmed_survey_time"), + surveyed_date=self._parse_hs_date(deal_data.get("surveyed_date")), + design_type=deal_data.get("design_type"), + ) + + def _handle_existing_photo_upload( + self, + existing: HubspotDealData, + hubspot_client: HubspotClient, + ): + if ( + existing.major_condition_issue_photos + and not existing.major_condition_issue_evidence_s3_url + ): + fresh_deal = hubspot_client.from_deal_id_get_info(existing.deal_id) + photo_url = fresh_deal.get("major_condition_issue_photos") + + if not photo_url: + print(f"⚠️ Photo URL missing for deal_id {existing.deal_id}") + return + + self._upload_photo_to_s3(existing, photo_url, hubspot_client) + + def _handle_new_photo_upload( + self, + record: HubspotDealData, + hubspot_client: HubspotClient, + ): + if record.major_condition_issue_photos: + self._upload_photo_to_s3( + record, + record.major_condition_issue_photos, + hubspot_client, + ) + + def _upload_photo_to_s3( + self, + record: HubspotDealData, + photo_url: str, + hubspot_client: HubspotClient, + ): + try: + local_file = hubspot_client.download_file_from_url(photo_url) + + s3_url = self.s3.upload_file( + local_file, + "retrofit-data-dev", + prefix="hubspot/awaabs_law_evidence/", + ) + + record.major_condition_issue_evidence_s3_url = s3_url + + except Exception as e: + print(f"⚠️ Failed to upload photo for deal_id {record.deal_id}: {e}") + finally: + if "local_file" in locals() and os.path.exists(local_file): + os.remove(local_file) From 8ce76190442e5a7f8a9c1af038651521b7edb105 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 09:14:20 +0000 Subject: [PATCH 62/93] refactor pashub trigger tests --- .../tests/test_hubspot_deal_differ.py | 470 ++++++++---------- etl/hubspot/hubspotDataTodB.py | 72 ++- 2 files changed, 233 insertions(+), 309 deletions(-) diff --git a/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py b/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py index ba6b80e4..75fa7927 100644 --- a/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py +++ b/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py @@ -1,359 +1,295 @@ from datetime import datetime -from typing import Dict +from typing import Any, Dict import uuid +import pytest + from backend.app.db.models.organisation import HubspotDealData from backend.hubspot_trigger_orchestrator.hubspot_deal_differ import HubspotDealDiffer -def test_pashub_trigger__outcome_note_added__returns_false() -> None: - # arrange - deal_id = uuid.uuid4() +BASE_TIME = datetime(2025, 12, 1, 12, 0, 0) - old_deal = HubspotDealData( - id=deal_id, + +def make_old_deal(**overrides: Any) -> HubspotDealData: + return HubspotDealData( + id=overrides.get("id", uuid.uuid4()), deal_id="1", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), + created_at=BASE_TIME, + updated_at=BASE_TIME, + **{k: v for k, v in overrides.items() if k != "id"}, ) - new_deal: Dict[str, str] = { + + +def make_new_deal(deal_id: uuid.UUID, **overrides: Any) -> Dict[str, str]: + return { "id": str(deal_id), "deal_id": "1", - "outcome_notes": "test note", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), + "created_at": BASE_TIME.isoformat(), "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), + **overrides, } - expected_output = False - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal +# ------------------------------------- +# Random change we aren't interested in +# ------------------------------------- + + +@pytest.mark.parametrize( + "new_overrides,expected", + [ + ({"outcome_notes": "test note"}, False), + ], +) +def test_pashub_trigger__outcome_note_added__returns_false( + new_overrides: Dict[str, str], + expected: bool, +) -> None: + deal_id = uuid.uuid4() + old_deal = make_old_deal(id=deal_id) + new_deal = make_new_deal(deal_id, **new_overrides) + + assert ( + HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, + old_deal=old_deal, + ) + == expected ) - # assert - assert actual_output == expected_output + +# ------------------------- +# Pashub link changes +# ------------------------- -def test_pashub_trigger__pashub_link_changed__returns_true() -> None: - # arrange +@pytest.mark.parametrize( + "old_overrides,new_overrides,expected", + [ + ( + {"pashub_link": "www.google.co.uk"}, + {"pashub_link": "www.bbc.co.uk"}, + True, + ), + ], +) +def test_pashub_trigger__pashub_link_changed__returns_true( + old_overrides: Dict[str, str], + new_overrides: Dict[str, str], + expected: bool, +) -> None: + deal_id = uuid.uuid4() + old_deal = make_old_deal(id=deal_id, **old_overrides) + new_deal = make_new_deal(deal_id, **new_overrides) + + assert ( + HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, + old_deal=old_deal, + ) + == expected + ) + + +# ------------------------- +# Coordination +# ------------------------- + + +@pytest.mark.parametrize( + "coordination_status,expected", + [ + ("v1 ioe/mtp complete", True), + ("v2 ioe/mtp complete", True), + ], +) +def test_pashub_trigger__coordination_completed_and_pashub_link_set__returns_true( + coordination_status: str, + expected: bool, +) -> None: deal_id = uuid.uuid4() - old_deal = HubspotDealData( + old_deal = make_old_deal( id=deal_id, - deal_id="1", - pashub_link="www.google.co.uk", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "pashub_link": "www.bbc.co.uk", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = True - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal - ) - - # assert - assert actual_output == expected_output - - -def test_pashub_trigger__coordination_completed_and_pashub_link_set__returns_true() -> ( - None -): - # arrange - deal_id = uuid.uuid4() - - old_deal = HubspotDealData( - id=deal_id, - deal_id="1", pashub_link="www.google.co.uk", coordination_status="random", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "coordination_status": "v1 ioe/mtp complete", - "pashub_link": "www.google.co.uk", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = True - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal ) - # assert - assert actual_output == expected_output - - -def test_pashub_trigger__coordination_completed_and_pashub_link_set__returns_true_2() -> ( - None -): - # arrange - deal_id = uuid.uuid4() - - old_deal = HubspotDealData( - id=deal_id, - deal_id="1", + new_deal = make_new_deal( + deal_id, pashub_link="www.google.co.uk", - coordination_status="random", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "coordination_status": "v2 ioe/mtp complete", - "pashub_link": "www.google.co.uk", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = True - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal + coordination_status=coordination_status, ) - # assert - assert actual_output == expected_output + assert ( + HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, + old_deal=old_deal, + ) + == expected + ) def test_pashub_trigger__coordination_completed_and_pashub_link_not_set__returns_false() -> ( None ): - # arrange deal_id = uuid.uuid4() - old_deal = HubspotDealData( + old_deal = make_old_deal( id=deal_id, - deal_id="1", coordination_status="random", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "coordination_status": "v2 ioe/mtp complete", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = False - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal ) - # assert - assert actual_output == expected_output + new_deal = make_new_deal( + deal_id, + coordination_status="v2 ioe/mtp complete", + ) + + assert ( + HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, + old_deal=old_deal, + ) + is False + ) + + +# ------------------------- +# Design +# ------------------------- def test_pashub_trigger__design_completed_and_pashub_link_set__returns_true() -> None: - # arrange deal_id = uuid.uuid4() - old_deal = HubspotDealData( + old_deal = make_old_deal( id=deal_id, - deal_id="1", pashub_link="www.google.co.uk", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "pashub_link": "www.google.co.uk", - "design_status": "uploaded", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = True - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal ) - # assert - assert actual_output == expected_output + new_deal = make_new_deal( + deal_id, + pashub_link="www.google.co.uk", + design_status="uploaded", + ) + + assert ( + HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, + old_deal=old_deal, + ) + is True + ) def test_pashub_trigger__design_completed_and_pashub_link_not_set__returns_false() -> ( None ): - # arrange deal_id = uuid.uuid4() - old_deal = HubspotDealData( - id=deal_id, - deal_id="1", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "design_status": "uploaded", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } + old_deal = make_old_deal(id=deal_id) - expected_output = False - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal + new_deal = make_new_deal( + deal_id, + design_status="uploaded", ) - # assert - assert actual_output == expected_output + assert ( + HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, + old_deal=old_deal, + ) + is False + ) -def test_pashub_trigger__lodgement_completed_and_pashub_link_set__returns_true() -> ( - None -): - # arrange +# ------------------------- +# Lodgement +# ------------------------- + + +@pytest.mark.parametrize( + "lodgement_status,expected", + [ + ("lodgement complete", True), + ("measures lodged", True), + ], +) +def test_pashub_trigger__lodgement_completed_and_pashub_link_set__returns_true( + lodgement_status: str, + expected: bool, +) -> None: deal_id = uuid.uuid4() - old_deal = HubspotDealData( + old_deal = make_old_deal( id=deal_id, - deal_id="1", pashub_link="www.google.co.uk", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "pashub_link": "www.google.co.uk", - "lodgement_status": "lodgement complete", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = True - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal ) - # assert - assert actual_output == expected_output - - -def test_pashub_trigger__lodgement_completed_and_pashub_link_set__returns_true_2() -> ( - None -): - # arrange - deal_id = uuid.uuid4() - - old_deal = HubspotDealData( - id=deal_id, - deal_id="1", + new_deal = make_new_deal( + deal_id, pashub_link="www.google.co.uk", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "pashub_link": "www.google.co.uk", - "lodgement_status": "measures lodged", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = True - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal + lodgement_status=lodgement_status, ) - # assert - assert actual_output == expected_output + assert ( + HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, + old_deal=old_deal, + ) + == expected + ) def test_pashub_trigger__lodgement_completed_and_pashub_link_not_set__returns_false() -> ( None ): - # arrange deal_id = uuid.uuid4() - old_deal = HubspotDealData( - id=deal_id, - deal_id="1", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "design_status": "lodgement complete", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } + old_deal = make_old_deal(id=deal_id) - expected_output = False - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal + new_deal = make_new_deal( + deal_id, + design_status="lodgement complete", ) - # assert - assert actual_output == expected_output + assert ( + HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, + old_deal=old_deal, + ) + is False + ) + + +# ------------------------- +# Negative case +# ------------------------- def test_pashub_trigger__coordination_design_lodgement_not_completed_and_pashub_link_set__returns_false() -> ( None ): - # arrange deal_id = uuid.uuid4() - old_deal = HubspotDealData( + old_deal = make_old_deal( id=deal_id, - deal_id="1", pashub_link="www.google.co.uk", - created_at=datetime(2025, 12, 1, 12, 0, 0), - updated_at=datetime(2025, 12, 1, 12, 0, 0), - ) - new_deal: Dict[str, str] = { - "id": str(deal_id), - "deal_id": "1", - "pashub_link": "www.google.co.uk", - "coordination_status": "not uploaded", - "design_status": "not uploaded", - "lodgement_status": "not uploaded", - "created_at": datetime(2025, 12, 1, 12, 0, 0).isoformat(), - "updated_at": datetime(2025, 12, 1, 12, 30, 0).isoformat(), - } - - expected_output = False - - # act - actual_output: bool = HubspotDealDiffer.check_for_pashub_trigger( - new_deal=new_deal, old_deal=old_deal ) - # assert - assert actual_output == expected_output + new_deal = make_new_deal( + deal_id, + pashub_link="www.google.co.uk", + coordination_status="not uploaded", + design_status="not uploaded", + lodgement_status="not uploaded", + ) + + assert ( + HubspotDealDiffer.check_for_pashub_trigger( + new_deal=new_deal, + old_deal=old_deal, + ) + is False + ) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index f0beeee8..06cc3be9 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -291,54 +291,33 @@ class HubspotDataToDb: return False # Handle photo upload if it exists but S3 URL is missing - if ( - deal_in_db.major_condition_issue_photos - and not deal_in_db.major_condition_issue_evidence_s3_url - ): + if self._needs_photo_upload(deal_in_db): print( f"🖼️ Found photo for deal_id {deal_in_db.deal_id} — uploading to S3..." ) photo_url = hs_deal.get("major_condition_issue_photos") + if photo_url: - try: - # Download from HubSpot using fresh URL from hs_deal (not stale DB URL) - local_file = hubspot_client.download_file_from_url(photo_url) + self._upload_photo_to_s3( + deal_in_db, + photo_url, + hubspot_client, + verify=True, # 👈 key difference + ) - # Upload to S3 - bucket = "retrofit-data-dev" - s3_url = self.s3.upload_file( - local_file, bucket, prefix="hubspot/awaabs_law_evidence/" + # persist change + with db_read_session() as session: + db_record = session.get(HubspotDealData, deal_in_db.id) + db_record.major_condition_issue_evidence_s3_url = ( + deal_in_db.major_condition_issue_evidence_s3_url ) + session.add(db_record) + session.commit() - # Download again to verify integrity - downloaded = self.s3.download_from_url(s3_url) - if self._sha256(local_file) == self._sha256(downloaded): - print("✅ SHA256 match verified — upload successful.") - else: - print("❌ SHA256 mismatch — integrity check failed.") - raise ValueError("File integrity check failed after S3 upload.") - - # Update DB record with S3 URL - with db_read_session() as session: - db_record = session.get(HubspotDealData, deal_in_db.id) - db_record.major_condition_issue_evidence_s3_url = s3_url - session.add(db_record) - session.commit() - print( - f"✅ Updated DB with S3 URL for deal_id={deal_in_db.deal_id}" - ) - return False - except Exception as e: - print( - f"⚠️ Failed to download/upload photo for deal_id {deal_in_db.deal_id}: {e}" - ) - # Continue without the file — don't crash the entire update - finally: - if "local_file" in locals() and os.path.exists(local_file): - os.remove(local_file) + return False else: - print(f"⚠️ Photo URL missing for deal_id {deal_in_db.deal_id}") + print(f"⚠️ Photo URL missing for deal_id {deal_in_db.deal_id}") else: print(f"✅ No update or upload required for deal_id {deal_in_db.deal_id}.") @@ -534,10 +513,7 @@ class HubspotDataToDb: existing: HubspotDealData, hubspot_client: HubspotClient, ): - if ( - existing.major_condition_issue_photos - and not existing.major_condition_issue_evidence_s3_url - ): + if self._needs_photo_upload(existing): fresh_deal = hubspot_client.from_deal_id_get_info(existing.deal_id) photo_url = fresh_deal.get("major_condition_issue_photos") @@ -564,6 +540,7 @@ class HubspotDataToDb: record: HubspotDealData, photo_url: str, hubspot_client: HubspotClient, + verify: bool = False, ): try: local_file = hubspot_client.download_file_from_url(photo_url) @@ -574,6 +551,11 @@ class HubspotDataToDb: prefix="hubspot/awaabs_law_evidence/", ) + if verify: + downloaded = self.s3.download_from_url(s3_url) + if self._sha256(local_file) != self._sha256(downloaded): + raise ValueError("File integrity check failed after S3 upload.") + record.major_condition_issue_evidence_s3_url = s3_url except Exception as e: @@ -581,3 +563,9 @@ class HubspotDataToDb: finally: if "local_file" in locals() and os.path.exists(local_file): os.remove(local_file) + + def _needs_photo_upload(self, deal: HubspotDealData) -> bool: + return bool( + deal.major_condition_issue_photos + and not deal.major_condition_issue_evidence_s3_url + ) From c439d5f55794f8b1c8f9041d568f28d44f43d0fd Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 09:41:43 +0000 Subject: [PATCH 63/93] move everything to etl/hubspot/ --- .../hubspot_trigger_orchestrator/handler.py | 61 -------------- .../hubspot}/hubspot_deal_differ.py | 0 ...ot_trigger_orchestrator_trigger_request.py | 0 etl/hubspot/scripts/scraper/main.py | 80 +++++++++++++------ .../tests/test_hubspot_deal_differ.py | 2 +- 5 files changed, 57 insertions(+), 86 deletions(-) delete mode 100644 backend/hubspot_trigger_orchestrator/handler.py rename {backend/hubspot_trigger_orchestrator => etl/hubspot}/hubspot_deal_differ.py (100%) rename {backend/hubspot_trigger_orchestrator => etl/hubspot}/hubspot_trigger_orchestrator_trigger_request.py (100%) rename {backend/hubspot_trigger_orchestrator => etl/hubspot}/tests/test_hubspot_deal_differ.py (98%) diff --git a/backend/hubspot_trigger_orchestrator/handler.py b/backend/hubspot_trigger_orchestrator/handler.py deleted file mode 100644 index 38724812..00000000 --- a/backend/hubspot_trigger_orchestrator/handler.py +++ /dev/null @@ -1,61 +0,0 @@ -import json -from typing import Any, Dict, Mapping, Optional - -from backend.app.db.models.organisation import HubspotDealData -from backend.hubspot_trigger_orchestrator.hubspot_deal_differ import HubspotDealDiffer -from backend.hubspot_trigger_orchestrator.hubspot_trigger_orchestrator_trigger_request import ( - HubspotTriggerOrchestratorTriggerRequest, -) -from backend.utils.subtasks import task_handler -from etl.hubspot.hubspotClient import HubspotClient -from etl.hubspot.hubspotDataTodB import HubspotDataToDb -from utils.logger import setup_logger - -logger = setup_logger() - - -@task_handler() -def handler(event: Mapping[str, Any], context: Any) -> None: - - db_client = HubspotDataToDb() - hubspot_client = HubspotClient() - - for record in event.get("Records", []): - body_dict = json.loads(record["body"]) - - logger.debug("Validating request body") - payload = HubspotTriggerOrchestratorTriggerRequest.model_validate(body_dict) - logger.debug("Successfully validated request body") - - hubspot_deal_id: str = payload.hubspot_deal_id - - db_deal: Optional[HubspotDealData] = db_client.find_deal_with_deal_id( - hubspot_deal_id - ) - if not db_deal: - # new hubspot deal, no diffing to do - # TODO: trigger hubspot to db ETL - return - - hubspot_deal: Dict[str, str] - company: Optional[str] - listing: Optional[dict[str, str]] - - hubspot_deal, company, listing = ( - hubspot_client.get_deal_and_company_and_listing(hubspot_deal_id) - ) - - if HubspotDealDiffer.check_for_pashub_trigger( - new_deal=hubspot_deal, old_deal=db_deal - ): - # TODO: trigger pashub file fetcher - return - - if HubspotDealDiffer.check_for_db_update_trigger( - new_deal=hubspot_deal, - new_company=company, - new_listing=listing, - old_deal=db_deal, - ): - # TODO: trigger db upsert - return diff --git a/backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py similarity index 100% rename from backend/hubspot_trigger_orchestrator/hubspot_deal_differ.py rename to etl/hubspot/hubspot_deal_differ.py diff --git a/backend/hubspot_trigger_orchestrator/hubspot_trigger_orchestrator_trigger_request.py b/etl/hubspot/hubspot_trigger_orchestrator_trigger_request.py similarity index 100% rename from backend/hubspot_trigger_orchestrator/hubspot_trigger_orchestrator_trigger_request.py rename to etl/hubspot/hubspot_trigger_orchestrator_trigger_request.py diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index d8d4a357..8c4af1a7 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -1,39 +1,71 @@ from backend.app.db.models.organisation import HubspotDealData from etl.hubspot.hubspotClient import HubspotClient -from etl.hubspot.hubspotDataTodB import CompanyData, HubspotDataToDb + +# from etl.hubspot.hubspotDataTodB import CompanyData, HubspotDataToDb +from etl.hubspot.hubspotDataTodB import HubspotDataToDb from backend.utils.subtasks import task_handler from typing import Any, Dict, Optional +from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer +from etl.hubspot.hubspot_trigger_orchestrator_trigger_request import ( + HubspotTriggerOrchestratorTriggerRequest, +) + @task_handler() def handler(body: dict[str, Any], context: Any) -> None: - hubspot_deal_id = body.get("hubspot_deal_id", "") - - if hubspot_deal_id == "": - raise RuntimeError( - "Missing Hubspot Deal ID in SQS body request, 'hubspot_deal_id'" - ) - hubspot_deal_id = "327170793707" - - hubspot_client = HubspotClient() db_client = HubspotDataToDb() + hubspot_client = HubspotClient() + + payload = HubspotTriggerOrchestratorTriggerRequest.model_validate(body) + hubspot_deal_id: str = payload.hubspot_deal_id + db_deal: Optional[HubspotDealData] = db_client.find_deal_with_deal_id( hubspot_deal_id ) - if db_deal: - db_client.update_deal_with_checks(db_deal, hubspot_client) - else: - hubspot_deal: Dict[str, str] - company: Optional[str] - listing: Optional[dict[str, str]] - hubspot_deal, company, listing = ( - hubspot_client.get_deal_and_company_and_listing(hubspot_deal_id) - ) + if not db_deal: + # New hubspot deal, no diffing to do + # TODO: Trigger hubspot to db ETL + return - if company: - company_data: CompanyData = hubspot_client.get_company_information(company) - db_client: HubspotDataToDb = HubspotDataToDb() - db_client.upsert_company(company_data) + hubspot_deal: Dict[str, str] + company: Optional[str] + listing: Optional[dict[str, str]] - db_client.upsert_deal(hubspot_deal, company, listing, hubspot_client) + hubspot_deal, company, listing = hubspot_client.get_deal_and_company_and_listing( + hubspot_deal_id + ) + + if HubspotDealDiffer.check_for_pashub_trigger( + new_deal=hubspot_deal, old_deal=db_deal + ): + # TODO: trigger pashub file fetcher + return + + if HubspotDealDiffer.check_for_db_update_trigger( + new_deal=hubspot_deal, + new_company=company, + new_listing=listing, + old_deal=db_deal, + ): + # TODO: trigger db upsert + return + + # if db_deal: + # db_client.update_deal_with_checks(db_deal, hubspot_client) + # else: + # hubspot_deal: Dict[str, str] + # company: Optional[str] + # listing: Optional[dict[str, str]] + + # hubspot_deal, company, listing = ( + # hubspot_client.get_deal_and_company_and_listing(hubspot_deal_id) + # ) + + # if company: + # company_data: CompanyData = hubspot_client.get_company_information(company) + # db_client: HubspotDataToDb = HubspotDataToDb() + # db_client.upsert_company(company_data) + + # db_client.upsert_deal(hubspot_deal, company, listing, hubspot_client) diff --git a/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py similarity index 98% rename from backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py rename to etl/hubspot/tests/test_hubspot_deal_differ.py index 75fa7927..12c5a288 100644 --- a/backend/hubspot_trigger_orchestrator/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -5,7 +5,7 @@ import uuid import pytest from backend.app.db.models.organisation import HubspotDealData -from backend.hubspot_trigger_orchestrator.hubspot_deal_differ import HubspotDealDiffer +from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer BASE_TIME = datetime(2025, 12, 1, 12, 0, 0) From 605652b30969156a445bc5aebf8cf083246aabc9 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 09:56:58 +0000 Subject: [PATCH 64/93] =?UTF-8?q?diff=20checker=20for=20db=20load=20trigge?= =?UTF-8?q?r=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etl/hubspot/hubspotDataTodB.py | 8 +- etl/hubspot/tests/test_hubspot_deal_differ.py | 138 ++++++++++++++---- 2 files changed, 116 insertions(+), 30 deletions(-) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 06cc3be9..4f43f1f7 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -2,7 +2,7 @@ from backend.app.db.connection import db_read_session from backend.app.db.models.organisation import Organisation, HubspotDealData from sqlmodel import select from datetime import datetime, timezone -from typing import Dict, Optional +from typing import Dict, Optional, Tuple from etl.hubspot.company_data import CompanyData from etl.hubspot.hubspotClient import HubspotClient from etl.hubspot.s3_uploader import S3Uploader @@ -103,7 +103,7 @@ class HubspotDataToDb: Also handles major_condition_issue_photos file upload to S3 with integrity check. """ - def soft_assert(condition, message="Assertion Failed"): + def soft_assert(condition: bool, message: str = "Assertion Failed"): if not condition: print(f"⚠️ Soft Assert Failed: {message}") return False @@ -111,6 +111,10 @@ class HubspotDataToDb: print(f"🔍 Checking if deal needs updating (deal_id={deal_in_db.deal_id})") + hs_deal: Dict[str, str] + hs_company_id: Optional[str] + hs_listing: Optional[Dict[str, str]] + hs_deal, hs_company_id, hs_listing = ( hubspot_client.get_deal_and_company_and_listing(deal_in_db.deal_id) ) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 12c5a288..876fcab9 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -31,9 +31,9 @@ def make_new_deal(deal_id: uuid.UUID, **overrides: Any) -> Dict[str, str]: } -# ------------------------------------- -# Random change we aren't interested in -# ------------------------------------- +# ==================== +# PASHUB TRIGGER TESTS +# ==================== @pytest.mark.parametrize( @@ -59,11 +59,6 @@ def test_pashub_trigger__outcome_note_added__returns_false( ) -# ------------------------- -# Pashub link changes -# ------------------------- - - @pytest.mark.parametrize( "old_overrides,new_overrides,expected", [ @@ -92,11 +87,6 @@ def test_pashub_trigger__pashub_link_changed__returns_true( ) -# ------------------------- -# Coordination -# ------------------------- - - @pytest.mark.parametrize( "coordination_status,expected", [ @@ -155,11 +145,6 @@ def test_pashub_trigger__coordination_completed_and_pashub_link_not_set__returns ) -# ------------------------- -# Design -# ------------------------- - - def test_pashub_trigger__design_completed_and_pashub_link_set__returns_true() -> None: deal_id = uuid.uuid4() @@ -204,11 +189,6 @@ def test_pashub_trigger__design_completed_and_pashub_link_not_set__returns_false ) -# ------------------------- -# Lodgement -# ------------------------- - - @pytest.mark.parametrize( "lodgement_status,expected", [ @@ -263,11 +243,6 @@ def test_pashub_trigger__lodgement_completed_and_pashub_link_not_set__returns_fa ) -# ------------------------- -# Negative case -# ------------------------- - - def test_pashub_trigger__coordination_design_lodgement_not_completed_and_pashub_link_set__returns_false() -> ( None ): @@ -293,3 +268,110 @@ def test_pashub_trigger__coordination_design_lodgement_not_completed_and_pashub_ ) is False ) + + +# ======================= +# DB UPDATE TRIGGER TESTS +# ======================= + + +def test_db_update_trigger__no_changes__returns_false() -> None: + deal_id = uuid.uuid4() + + old_deal = make_old_deal( + id=deal_id, + dealname="Test Deal", + dealstage="stage_1", + outcome="won", + ) + + new_deal = make_new_deal( + deal_id, + hs_object_id="1", + dealname="Test Deal", + dealstage="stage_1", + outcome="won", + ) + + result = HubspotDealDiffer.check_for_db_update_trigger( + new_deal=new_deal, + new_company=None, + new_listing=None, + old_deal=old_deal, + ) + + assert result is False + + +def test_db_update_trigger__dealname_changed__returns_true() -> None: + deal_id = uuid.uuid4() + + old_deal = make_old_deal( + id=deal_id, + dealname="Old Name", + ) + + new_deal = make_new_deal( + deal_id, + hs_object_id="1", + dealname="New Name", + ) + + result = HubspotDealDiffer.check_for_db_update_trigger( + new_deal=new_deal, + new_company=None, + new_listing=None, + old_deal=old_deal, + ) + + assert result is True + + +def test_db_update_trigger__company_changed__returns_true() -> None: + deal_id = uuid.uuid4() + + old_deal = make_old_deal( + id=deal_id, + company_id="old_company", + ) + + new_deal = make_new_deal( + deal_id, + hs_object_id="1", + ) + + new_company = "new_company" + + result = HubspotDealDiffer.check_for_db_update_trigger( + new_deal=new_deal, + new_company=new_company, + new_listing=None, + old_deal=old_deal, + ) + + assert result is True + + +def test_db_update_trigger__listing_changed__returns_true() -> None: + deal_id = uuid.uuid4() + + old_deal = make_old_deal( + id=deal_id, + listing_id="abc", + ) + + new_deal = make_new_deal( + deal_id, + hs_object_id="1", + ) + + new_listing = {"listing_id": "xyz"} + + result = HubspotDealDiffer.check_for_db_update_trigger( + new_deal=new_deal, + new_company=None, + new_listing=new_listing, + old_deal=old_deal, + ) + + assert result is True From 01636514aaa58f8749af194fac0ac31cd8a79284 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 10:32:59 +0000 Subject: [PATCH 65/93] pull diffing logic out of loading method --- etl/hubspot/hubspotDataTodB.py | 181 ++++++++++++++-------------- etl/hubspot/scripts/scraper/main.py | 37 ++++-- 2 files changed, 118 insertions(+), 100 deletions(-) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 4f43f1f7..1c4b6b54 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -96,13 +96,103 @@ class HubspotDataToDb: return sha256.hexdigest() def update_deal_with_checks( - self, deal_in_db: HubspotDealData, hubspot_client: HubspotClient + self, + deal_in_db: HubspotDealData, + hubspot_client: HubspotClient, + hs_deal: Dict[str, str], + hs_company_id: Optional[str], + hs_listing: Optional[Dict[str, str]], ) -> bool: """ - Checks if a deal needs updating and syncs it with HubSpot. - Also handles major_condition_issue_photos file upload to S3 with integrity check. + Updates deal in database and handles major_condition_issue_photos file upload to S3 with integrity check. """ + self.upsert_deal(hs_deal, hs_company_id, hs_listing, hubspot_client) + # Handle photo upload if it exists but S3 URL is missing + if self._needs_photo_upload(deal_in_db): + print( + f"🖼️ Found photo for deal_id {deal_in_db.deal_id} — uploading to S3..." + ) + + photo_url = hs_deal.get("major_condition_issue_photos") + + if photo_url: + self._upload_photo_to_s3( + deal_in_db, + photo_url, + hubspot_client, + verify=True, + ) + + # persist change + with db_read_session() as session: + db_record = session.get(HubspotDealData, deal_in_db.id) + db_record.major_condition_issue_evidence_s3_url = ( + deal_in_db.major_condition_issue_evidence_s3_url + ) + session.add(db_record) + session.commit() + + return False + else: + print(f"⚠️ Photo URL missing for deal_id {deal_in_db.deal_id}") + + else: + print(f"✅ No update or upload required for deal_id {deal_in_db.deal_id}.") + + return True + + def upsert_deal( + self, + deal_data: Dict[str, str], + company: Optional[str], + listing: Optional[dict[str, str]], + hubspot_client: HubspotClient, + ): + """ + Inserts or updates a deal record. + Also uploads photos if present and adds S3 URL. + """ + with db_read_session() as session: + deal_id = deal_data.get("hs_object_id") + + statement = select(HubspotDealData).where( + HubspotDealData.deal_id == deal_id + ) + existing = session.exec(statement).first() + + if existing: + print(f"🔄 Updating existing deal (deal_id={deal_id})") + self._update_existing_deal(existing, deal_data, listing, company) + + self._handle_existing_photo_upload(existing, hubspot_client) + + session.add(existing) + session.commit() + session.refresh(existing) + return existing + + else: + print(f"🆕 Inserting new deal (deal_id={deal_id})") + new_record: HubspotDealData = self._build_new_deal( + deal_id, deal_data, listing, company + ) + + # Handle upload at insert time + self._handle_new_photo_upload(new_record, hubspot_client) + + session.add(new_record) + session.commit() + session.refresh(new_record) + return new_record + + def _deprecated_diff( + self, + deal_in_db: HubspotDealData, + hs_deal: Dict[str, str], + hs_company_id: Optional[str], + hs_listing: Optional[Dict[str, str]], + ): def soft_assert(condition: bool, message: str = "Assertion Failed"): if not condition: print(f"⚠️ Soft Assert Failed: {message}") @@ -111,14 +201,6 @@ class HubspotDataToDb: print(f"🔍 Checking if deal needs updating (deal_id={deal_in_db.deal_id})") - hs_deal: Dict[str, str] - hs_company_id: Optional[str] - hs_listing: Optional[Dict[str, str]] - - hs_deal, hs_company_id, hs_listing = ( - hubspot_client.get_deal_and_company_and_listing(deal_in_db.deal_id) - ) - # Soft compare key fields checks = [ soft_assert( @@ -291,87 +373,10 @@ class HubspotDataToDb: print( f"❗ Discrepancies found for deal_id {deal_in_db.deal_id} — syncing with HubSpot." ) - self.upsert_deal(hs_deal, hs_company_id, hs_listing, hubspot_client) return False - # Handle photo upload if it exists but S3 URL is missing - if self._needs_photo_upload(deal_in_db): - print( - f"🖼️ Found photo for deal_id {deal_in_db.deal_id} — uploading to S3..." - ) - - photo_url = hs_deal.get("major_condition_issue_photos") - - if photo_url: - self._upload_photo_to_s3( - deal_in_db, - photo_url, - hubspot_client, - verify=True, # 👈 key difference - ) - - # persist change - with db_read_session() as session: - db_record = session.get(HubspotDealData, deal_in_db.id) - db_record.major_condition_issue_evidence_s3_url = ( - deal_in_db.major_condition_issue_evidence_s3_url - ) - session.add(db_record) - session.commit() - - return False - else: - print(f"⚠️ Photo URL missing for deal_id {deal_in_db.deal_id}") - - else: - print(f"✅ No update or upload required for deal_id {deal_in_db.deal_id}.") - return True - def upsert_deal( - self, - deal_data: Dict[str, str], - company: Optional[str], - listing: Optional[dict[str, str]], - hubspot_client: HubspotClient, - ): - """ - Inserts or updates a deal record. - Also uploads photos if present and adds S3 URL. - """ - with db_read_session() as session: - deal_id = deal_data.get("hs_object_id") - - statement = select(HubspotDealData).where( - HubspotDealData.deal_id == deal_id - ) - existing = session.exec(statement).first() - - if existing: - print(f"🔄 Updating existing deal (deal_id={deal_id})") - self._update_existing_deal(existing, deal_data, listing, company) - - self._handle_existing_photo_upload(existing, hubspot_client) - - session.add(existing) - session.commit() - session.refresh(existing) - return existing - - else: - print(f"🆕 Inserting new deal (deal_id={deal_id})") - new_record: HubspotDealData = self._build_new_deal( - deal_id, deal_data, listing, company - ) - - # Handle upload at insert time - self._handle_new_photo_upload(new_record, hubspot_client) - - session.add(new_record) - session.commit() - session.refresh(new_record) - return new_record - def _update_existing_deal( self, existing: HubspotDealData, diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 8c4af1a7..768a86eb 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -1,8 +1,7 @@ from backend.app.db.models.organisation import HubspotDealData from etl.hubspot.hubspotClient import HubspotClient -# from etl.hubspot.hubspotDataTodB import CompanyData, HubspotDataToDb -from etl.hubspot.hubspotDataTodB import HubspotDataToDb +from etl.hubspot.hubspotDataTodB import CompanyData, HubspotDataToDb from backend.utils.subtasks import task_handler from typing import Any, Dict, Optional @@ -24,11 +23,6 @@ def handler(body: dict[str, Any], context: Any) -> None: hubspot_deal_id ) - if not db_deal: - # New hubspot deal, no diffing to do - # TODO: Trigger hubspot to db ETL - return - hubspot_deal: Dict[str, str] company: Optional[str] listing: Optional[dict[str, str]] @@ -37,10 +31,14 @@ def handler(body: dict[str, Any], context: Any) -> None: hubspot_deal_id ) - if HubspotDealDiffer.check_for_pashub_trigger( - new_deal=hubspot_deal, old_deal=db_deal - ): - # TODO: trigger pashub file fetcher + if not db_deal: + # New hubspot deal, no diffing to do + if company: + company_data: CompanyData = hubspot_client.get_company_information(company) + db_client: HubspotDataToDb = HubspotDataToDb() + db_client.upsert_company(company_data) + + db_client.upsert_deal(hubspot_deal, company, listing, hubspot_client) return if HubspotDealDiffer.check_for_db_update_trigger( @@ -49,7 +47,22 @@ def handler(body: dict[str, Any], context: Any) -> None: new_listing=listing, old_deal=db_deal, ): - # TODO: trigger db upsert + db_client.update_deal_with_checks( + deal_in_db=db_deal, + hubspot_client=hubspot_client, + hs_deal=hubspot_deal, + hs_company_id=company, + hs_listing=listing, + ) + return + + # ============================== + # Orchestration of other lambdas + # ============================== + if HubspotDealDiffer.check_for_pashub_trigger( + new_deal=hubspot_deal, old_deal=db_deal + ): + # TODO: trigger pashub file fetcher return # if db_deal: From 125527baa9a56573c3f8120fbf9daca805d8b20e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 10:46:52 +0000 Subject: [PATCH 66/93] move HubspotDealData object to dedicated file --- backend/app/db/models/hubspot_deal_data.py | 77 +++++++++++++++++++ backend/app/db/models/organisation.py | 77 +------------------ etl/hubspot/hubspotDataTodB.py | 10 ++- etl/hubspot/hubspot_deal_differ.py | 2 +- etl/hubspot/scripts/scraper/main.py | 9 +-- etl/hubspot/tests/test_hubspot_deal_differ.py | 2 +- 6 files changed, 91 insertions(+), 86 deletions(-) create mode 100644 backend/app/db/models/hubspot_deal_data.py diff --git a/backend/app/db/models/hubspot_deal_data.py b/backend/app/db/models/hubspot_deal_data.py new file mode 100644 index 00000000..d5a51ace --- /dev/null +++ b/backend/app/db/models/hubspot_deal_data.py @@ -0,0 +1,77 @@ +import uuid +from sqlmodel import SQLModel, Field, Column, text +from datetime import datetime +from typing import Optional +from sqlalchemy import DateTime +from sqlalchemy.sql import func + + +class HubspotDealData(SQLModel, table=True): + __tablename__ = "hubspot_deal_data" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + + # HubSpot Deal identifiers + deal_id: str = Field(index=True, nullable=False) + dealname: Optional[str] = Field(default=None) + dealstage: Optional[str] = Field(default=None) + company_id: Optional[str] = Field(default=None) + project_code: Optional[str] = Field(default=None) + + # HubSpot custom properties + landlord_property_id: Optional[str] = Field(default=None) + uprn: Optional[str] = Field(default=None) + outcome: Optional[str] = Field(default=None) + outcome_notes: Optional[str] = Field(default=None) + + major_condition_issue_description: Optional[str] = Field(default=None) + major_condition_issue_photos: Optional[str] = Field(default=None) + major_condition_issue_evidence_s3_url: Optional[str] = Field(default=None) + + coordination_status: Optional[str] = Field(default=None) + coordination_comments: Optional[str] = Field(default=None) + design_status: Optional[str] = Field(default=None) + + listing_id: Optional[str] = Field(default=None) + pashub_link: Optional[str] = Field(default=None) + sharepoint_link: Optional[str] = Field(default=None) + dampmould_growth: Optional[str] = Field(default=None) + damp_mould_and_repairs_comments: Optional[str] = Field(default=None) + pre_sap: Optional[str] = Field(default=None) + coordinator: Optional[str] = Field(default=None) + mtp_completion_date: Optional[datetime] = Field(default=None) + mtp_re_model_completion_date: Optional[datetime] = Field(default=None) + ioe_v3_completion_date: Optional[datetime] = Field(default=None) + proposed_measures: Optional[str] = Field(default=None) + approved_package: Optional[str] = Field(default=None) + designer: Optional[str] = Field(default=None) + design_completion_date: Optional[datetime] = Field(default=None) + actual_measures_installed: Optional[str] = Field(default=None) + installer: Optional[str] = Field(default=None) + installer_handover: Optional[str] = Field(default=None) + lodgement_status: Optional[str] = Field(default=None) + measures_lodgement_date: Optional[datetime] = Field(default=None) + lodgement_date: Optional[datetime] = Field(default=None) + expected_commencement_date: Optional[datetime] = Field(default=None) + surveyor: Optional[str] = Field(default=None) + confirmed_survey_date: Optional[datetime] = Field(default=None) + confirmed_survey_time: Optional[str] = Field(default=None) + surveyed_date: Optional[datetime] = Field(default=None) + design_type: Optional[str] = Field(default=None) + + created_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + server_default=text("(NOW() AT TIME ZONE 'utc')"), + nullable=False, + ) + ) + + updated_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + server_default=text("(NOW() AT TIME ZONE 'utc')"), + onupdate=func.now(), + nullable=False, + ) + ) diff --git a/backend/app/db/models/organisation.py b/backend/app/db/models/organisation.py index 784cc4ad..8afc5d63 100644 --- a/backend/app/db/models/organisation.py +++ b/backend/app/db/models/organisation.py @@ -1,9 +1,7 @@ -from sqlmodel import SQLModel, Field, Column, text +import uuid +from sqlmodel import SQLModel, Field from datetime import datetime, timezone from typing import Optional -from sqlalchemy import DateTime -from sqlalchemy.sql import func -import uuid class Organisation(SQLModel, table=True): @@ -13,74 +11,3 @@ class Organisation(SQLModel, table=True): updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) hubspot_company_id: Optional[str] = None name: Optional[str] = None - - -class HubspotDealData(SQLModel, table=True): - __tablename__ = "hubspot_deal_data" - - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - - # HubSpot Deal identifiers - deal_id: str = Field(index=True, nullable=False) - dealname: Optional[str] = Field(default=None) - dealstage: Optional[str] = Field(default=None) - company_id: Optional[str] = Field(default=None) - project_code: Optional[str] = Field(default=None) - - # HubSpot custom properties - landlord_property_id: Optional[str] = Field(default=None) - uprn: Optional[str] = Field(default=None) - outcome: Optional[str] = Field(default=None) - outcome_notes: Optional[str] = Field(default=None) - - major_condition_issue_description: Optional[str] = Field(default=None) - major_condition_issue_photos: Optional[str] = Field(default=None) - major_condition_issue_evidence_s3_url: Optional[str] = Field(default=None) - - coordination_status: Optional[str] = Field(default=None) - coordination_comments: Optional[str] = Field(default=None) - design_status: Optional[str] = Field(default=None) - - listing_id: Optional[str] = Field(default=None) - pashub_link: Optional[str] = Field(default=None) - sharepoint_link: Optional[str] = Field(default=None) - dampmould_growth: Optional[str] = Field(default=None) - damp_mould_and_repairs_comments: Optional[str] = Field(default=None) - pre_sap: Optional[str] = Field(default=None) - coordinator: Optional[str] = Field(default=None) - mtp_completion_date: Optional[datetime] = Field(default=None) - mtp_re_model_completion_date: Optional[datetime] = Field(default=None) - ioe_v3_completion_date: Optional[datetime] = Field(default=None) - proposed_measures: Optional[str] = Field(default=None) - approved_package: Optional[str] = Field(default=None) - designer: Optional[str] = Field(default=None) - design_completion_date: Optional[datetime] = Field(default=None) - actual_measures_installed: Optional[str] = Field(default=None) - installer: Optional[str] = Field(default=None) - installer_handover: Optional[str] = Field(default=None) - lodgement_status: Optional[str] = Field(default=None) - measures_lodgement_date: Optional[datetime] = Field(default=None) - lodgement_date: Optional[datetime] = Field(default=None) - expected_commencement_date: Optional[datetime] = Field(default=None) - surveyor: Optional[str] = Field(default=None) - confirmed_survey_date: Optional[datetime] = Field(default=None) - confirmed_survey_time: Optional[str] = Field(default=None) - surveyed_date: Optional[datetime] = Field(default=None) - design_type: Optional[str] = Field(default=None) - - created_at: datetime = Field( - sa_column=Column( - DateTime(timezone=True), - server_default=text("(NOW() AT TIME ZONE 'utc')"), - nullable=False, - ) - ) - - updated_at: datetime = Field( - sa_column=Column( - DateTime(timezone=True), - server_default=text("(NOW() AT TIME ZONE 'utc')"), - onupdate=func.now(), - nullable=False, - ) - ) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 3c017f0e..5ebc8c73 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -1,13 +1,15 @@ -from backend.app.db.connection import db_read_session -from backend.app.db.models.organisation import Organisation, HubspotDealData +import hashlib +import os from sqlmodel import select from datetime import datetime, timezone from typing import Dict, Optional + +from backend.app.db.models.hubspot_deal_data import HubspotDealData from etl.hubspot.company_data import CompanyData from etl.hubspot.hubspotClient import HubspotClient from etl.hubspot.s3_uploader import S3Uploader -import hashlib -import os +from backend.app.db.connection import db_read_session +from backend.app.db.models.organisation import Organisation class HubspotDataToDb: diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 1dd4ed51..dd992243 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional -from backend.app.db.models.organisation import HubspotDealData +from backend.app.db.models.hubspot_deal_data import HubspotDealData class HubspotDealDiffer: diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 768a86eb..826d7e05 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -1,14 +1,13 @@ -from backend.app.db.models.organisation import HubspotDealData -from etl.hubspot.hubspotClient import HubspotClient - -from etl.hubspot.hubspotDataTodB import CompanyData, HubspotDataToDb -from backend.utils.subtasks import task_handler from typing import Any, Dict, Optional +from etl.hubspot.hubspotClient import HubspotClient +from etl.hubspot.hubspotDataTodB import CompanyData, HubspotDataToDb from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer from etl.hubspot.hubspot_trigger_orchestrator_trigger_request import ( HubspotTriggerOrchestratorTriggerRequest, ) +from backend.utils.subtasks import task_handler +from backend.app.db.models.hubspot_deal_data import HubspotDealData @task_handler() diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 876fcab9..74d3f057 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -4,7 +4,7 @@ import uuid import pytest -from backend.app.db.models.organisation import HubspotDealData +from backend.app.db.models.hubspot_deal_data import HubspotDealData from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer From 36aaabb3cfa05d776484f2af8fa53973936dc5b5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 11:19:33 +0000 Subject: [PATCH 67/93] =?UTF-8?q?diff=20checker=20for=20db=20load=20trigge?= =?UTF-8?q?r=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/db/models/hubspot_deal_data.py | 10 +- etl/hubspot/hubspotDataTodB.py | 247 ++------------------- etl/hubspot/hubspot_deal_differ.py | 90 +++++++- etl/hubspot/scripts/scraper/main.py | 18 -- etl/hubspot/utils.py | 11 + 5 files changed, 127 insertions(+), 249 deletions(-) create mode 100644 etl/hubspot/utils.py diff --git a/backend/app/db/models/hubspot_deal_data.py b/backend/app/db/models/hubspot_deal_data.py index d5a51ace..1d7607e0 100644 --- a/backend/app/db/models/hubspot_deal_data.py +++ b/backend/app/db/models/hubspot_deal_data.py @@ -59,19 +59,21 @@ class HubspotDealData(SQLModel, table=True): surveyed_date: Optional[datetime] = Field(default=None) design_type: Optional[str] = Field(default=None) - created_at: datetime = Field( + created_at: Optional[datetime] = Field( sa_column=Column( DateTime(timezone=True), server_default=text("(NOW() AT TIME ZONE 'utc')"), nullable=False, - ) + ), + default=None, # Nullable in db but optional here as value is set on db save for new record ) - updated_at: datetime = Field( + updated_at: Optional[datetime] = Field( sa_column=Column( DateTime(timezone=True), server_default=text("(NOW() AT TIME ZONE 'utc')"), onupdate=func.now(), nullable=False, - ) + ), + default=None, # Nullable in db but optional here as value is set on db save for new record ) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 5ebc8c73..210c9593 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -10,6 +10,7 @@ from etl.hubspot.hubspotClient import HubspotClient from etl.hubspot.s3_uploader import S3Uploader from backend.app.db.connection import db_read_session from backend.app.db.models.organisation import Organisation +from etl.hubspot.utils import parse_hs_date class HubspotDataToDb: @@ -60,11 +61,7 @@ class HubspotDataToDb: session.commit() return record - def new_record_to_hubspot_data(self, deal_data, company, listing, hubspot_client): - print("⚠️ Deprecated — use the new interface instead.") - return self.upsert_deal(deal_data, company, listing, hubspot_client) - - def find_all_deals_with_company_id(self, company_id): + def find_all_deals_with_company_id(self, company_id: str): """Returns a list of deals for a given company_id.""" with db_read_session() as session: return ( @@ -137,7 +134,7 @@ class HubspotDataToDb: return False else: - print(f"⚠️ Photo URL missing for deal_id {deal_in_db.deal_id}") + print(f"⚠️ Photo URL missing for deal_id {deal_in_db.deal_id}") else: print(f"✅ No update or upload required for deal_id {deal_in_db.deal_id}.") @@ -188,202 +185,6 @@ class HubspotDataToDb: session.refresh(new_record) return new_record - def _deprecated_diff( - self, - deal_in_db: HubspotDealData, - hs_deal: Dict[str, str], - hs_company_id: Optional[str], - hs_listing: Optional[Dict[str, str]], - ): - def soft_assert(condition: bool, message: str = "Assertion Failed"): - if not condition: - print(f"⚠️ Soft Assert Failed: {message}") - return False - return True - - print(f"🔍 Checking if deal needs updating (deal_id={deal_in_db.deal_id})") - - # Soft compare key fields - checks = [ - soft_assert( - deal_in_db.deal_id == hs_deal.get("hs_object_id"), "deal_id mismatch" - ), - soft_assert(deal_in_db.company_id == hs_company_id, "company_id mismatch"), - soft_assert( - deal_in_db.listing_id == hs_listing.get("listing_id"), - "listing_id mismatch", - ), - soft_assert( - deal_in_db.landlord_property_id == hs_listing.get("owner_property_id"), - "landlord_property_id mismatch", - ), - soft_assert( - deal_in_db.outcome == hs_deal.get("outcome"), "outcome mismatch" - ), - soft_assert( - deal_in_db.dealstage == hs_deal.get("dealstage"), "dealstage mismatch" - ), - soft_assert( - deal_in_db.dealname == hs_deal.get("dealname"), "dealname mismatch" - ), - soft_assert( - deal_in_db.project_code == hs_deal.get("project_code"), - "project_code mismatch", - ), - soft_assert( - deal_in_db.uprn == hs_listing.get("national_uprn"), "uprn mismatch" - ), - soft_assert( - deal_in_db.outcome_notes == hs_deal.get("outcome_notes"), - "outcome_notes mismatch", - ), - soft_assert( - deal_in_db.major_condition_issue_description - == hs_deal.get("major_condition_issue_description"), - "major condition description mismatch", - ), - soft_assert( - deal_in_db.major_condition_issue_photos - == hs_deal.get("major_condition_issue_photos"), - "major condition issue photos mismatch", - ), - soft_assert( - deal_in_db.coordination_status - == hs_deal.get("coordination_status__stage_1_"), - "coordination stage 1 status mismatch", - ), - soft_assert( - deal_in_db.coordination_comments - == hs_deal.get("coordination_comments"), - "coordination_comments mismatch", - ), - soft_assert( - deal_in_db.design_status == hs_deal.get("retrofit_design_status"), - "retrofit design mismatch", - ), - soft_assert( - deal_in_db.pashub_link == hs_deal.get("pashub_link"), - "pashub_link mismatch", - ), - soft_assert( - deal_in_db.sharepoint_link == hs_deal.get("sharepoint_link"), - "sharepoint_link mismatch", - ), - soft_assert( - deal_in_db.dampmould_growth == hs_deal.get("dampmould_growth"), - "dampmould_growth mismatch", - ), - soft_assert( - deal_in_db.damp_mould_and_repairs_comments - == hs_deal.get("damp_mould_and_repairs_comments"), - "damp_mould_and_repairs_comments mismatch", - ), - soft_assert( - deal_in_db.pre_sap == hs_deal.get("pre_sap"), - "pre_sap mismatch", - ), - soft_assert( - deal_in_db.coordinator == hs_deal.get("coordinator"), - "coordinator mismatch", - ), - soft_assert( - deal_in_db.mtp_completion_date - == self._parse_hs_date(hs_deal.get("mtp_completion_date")), - "mtp_completion_date mismatch", - ), - soft_assert( - deal_in_db.mtp_re_model_completion_date - == self._parse_hs_date(hs_deal.get("mtp_re_model_completion_date")), - "mtp_re_model_completion_date mismatch", - ), - soft_assert( - deal_in_db.ioe_v3_completion_date - == self._parse_hs_date(hs_deal.get("ioe_v3_completion_date")), - "ioe_v3_completion_date mismatch", - ), - soft_assert( - deal_in_db.proposed_measures == hs_deal.get("proposed_measures"), - "proposed_measures mismatch", - ), - soft_assert( - deal_in_db.approved_package == hs_deal.get("approved_package"), - "approved_package mismatch", - ), - soft_assert( - deal_in_db.designer == hs_deal.get("designer"), - "designer mismatch", - ), - soft_assert( - deal_in_db.design_completion_date - == self._parse_hs_date(hs_deal.get("design_completion_date")), - "design_completion_date mismatch", - ), - soft_assert( - deal_in_db.actual_measures_installed - == hs_deal.get("actual_measures_installed"), - "actual_measures_installed mismatch", - ), - soft_assert( - deal_in_db.installer == hs_deal.get("installer"), - "installer mismatch", - ), - soft_assert( - deal_in_db.installer_handover == hs_deal.get("installer_handover"), - "installer_handover mismatch", - ), - soft_assert( - deal_in_db.lodgement_status == hs_deal.get("lodgement_status"), - "lodgement_status mismatch", - ), - soft_assert( - deal_in_db.measures_lodgement_date - == self._parse_hs_date(hs_deal.get("measures_lodgement_date")), - "measures_lodgement_date mismatch", - ), - soft_assert( - deal_in_db.lodgement_date - == self._parse_hs_date(hs_deal.get("lodgement_date")), - "lodgement_date mismatch", - ), - soft_assert( - deal_in_db.expected_commencement_date - == self._parse_hs_date(hs_deal.get("expected_commencement_date")), - "expected_commencement_date mismatch", - ), - soft_assert( - deal_in_db.surveyor == hs_deal.get("surveyor"), - "surveyor mismatch", - ), - soft_assert( - deal_in_db.confirmed_survey_date - == self._parse_hs_date(hs_deal.get("confirmed_survey_date")), - "confirmed_survey_date mismatch", - ), - soft_assert( - deal_in_db.confirmed_survey_time - == hs_deal.get("confirmed_survey_time"), - "confirmed_survey_time mismatch", - ), - soft_assert( - deal_in_db.surveyed_date - == self._parse_hs_date(hs_deal.get("surveyed_date")), - "surveyed_date mismatch", - ), - soft_assert( - deal_in_db.design_type == hs_deal.get("design_type"), - "design_type mismatch", - ), - ] - - # If discrepancies found, update from HubSpot - if not all(checks): - print( - f"❗ Discrepancies found for deal_id {deal_in_db.deal_id} — syncing with HubSpot." - ) - return False - - return True - def _update_existing_deal( self, existing: HubspotDealData, @@ -420,38 +221,36 @@ class HubspotDataToDb: ), "pre_sap": deal_data.get("pre_sap"), "coordinator": deal_data.get("coordinator"), - "mtp_completion_date": self._parse_hs_date( - deal_data.get("mtp_completion_date") - ), - "mtp_re_model_completion_date": self._parse_hs_date( + "mtp_completion_date": parse_hs_date(deal_data.get("mtp_completion_date")), + "mtp_re_model_completion_date": parse_hs_date( deal_data.get("mtp_re_model_completion_date") ), - "ioe_v3_completion_date": self._parse_hs_date( + "ioe_v3_completion_date": parse_hs_date( deal_data.get("ioe_v3_completion_date") ), "proposed_measures": deal_data.get("proposed_measures"), "approved_package": deal_data.get("approved_package"), "designer": deal_data.get("designer"), - "design_completion_date": self._parse_hs_date( + "design_completion_date": parse_hs_date( deal_data.get("design_completion_date") ), "actual_measures_installed": deal_data.get("actual_measures_installed"), "installer": deal_data.get("installer"), "installer_handover": deal_data.get("installer_handover"), "lodgement_status": deal_data.get("lodgement_status"), - "measures_lodgement_date": self._parse_hs_date( + "measures_lodgement_date": parse_hs_date( deal_data.get("measures_lodgement_date") ), - "lodgement_date": self._parse_hs_date(deal_data.get("lodgement_date")), - "expected_commencement_date": self._parse_hs_date( + "lodgement_date": parse_hs_date(deal_data.get("lodgement_date")), + "expected_commencement_date": parse_hs_date( deal_data.get("expected_commencement_date") ), "surveyor": deal_data.get("surveyor"), - "confirmed_survey_date": self._parse_hs_date( + "confirmed_survey_date": parse_hs_date( deal_data.get("confirmed_survey_date") ), "confirmed_survey_time": deal_data.get("confirmed_survey_time"), - "surveyed_date": self._parse_hs_date(deal_data.get("surveyed_date")), + "surveyed_date": parse_hs_date(deal_data.get("surveyed_date")), "design_type": deal_data.get("design_type"), }.items(): setattr(existing, attr, value or getattr(existing, attr)) @@ -491,38 +290,34 @@ class HubspotDataToDb: ), pre_sap=deal_data.get("pre_sap"), coordinator=deal_data.get("coordinator"), - mtp_completion_date=self._parse_hs_date( - deal_data.get("mtp_completion_date") - ), - mtp_re_model_completion_date=self._parse_hs_date( + mtp_completion_date=parse_hs_date(deal_data.get("mtp_completion_date")), + mtp_re_model_completion_date=parse_hs_date( deal_data.get("mtp_re_model_completion_date") ), - ioe_v3_completion_date=self._parse_hs_date( + ioe_v3_completion_date=parse_hs_date( deal_data.get("ioe_v3_completion_date") ), proposed_measures=deal_data.get("proposed_measures"), approved_package=deal_data.get("approved_package"), designer=deal_data.get("designer"), - design_completion_date=self._parse_hs_date( + design_completion_date=parse_hs_date( deal_data.get("design_completion_date") ), actual_measures_installed=deal_data.get("actual_measures_installed"), installer=deal_data.get("installer"), installer_handover=deal_data.get("installer_handover"), lodgement_status=deal_data.get("lodgement_status"), - measures_lodgement_date=self._parse_hs_date( + measures_lodgement_date=parse_hs_date( deal_data.get("measures_lodgement_date") ), - lodgement_date=self._parse_hs_date(deal_data.get("lodgement_date")), - expected_commencement_date=self._parse_hs_date( + lodgement_date=parse_hs_date(deal_data.get("lodgement_date")), + expected_commencement_date=parse_hs_date( deal_data.get("expected_commencement_date") ), surveyor=deal_data.get("surveyor"), - confirmed_survey_date=self._parse_hs_date( - deal_data.get("confirmed_survey_date") - ), + confirmed_survey_date=parse_hs_date(deal_data.get("confirmed_survey_date")), confirmed_survey_time=deal_data.get("confirmed_survey_time"), - surveyed_date=self._parse_hs_date(deal_data.get("surveyed_date")), + surveyed_date=parse_hs_date(deal_data.get("surveyed_date")), design_type=deal_data.get("design_type"), ) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index dd992243..42def3b2 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -1,6 +1,7 @@ from typing import Dict, List, Optional from backend.app.db.models.hubspot_deal_data import HubspotDealData +from etl.hubspot.utils import parse_hs_date class HubspotDealDiffer: @@ -18,7 +19,94 @@ class HubspotDealDiffer: new_listing: Optional[Dict[str, str]], old_deal: HubspotDealData, ) -> bool: - raise NotImplementedError + """ + Returns True if ANY difference exists between HubSpot data and DB. + Returns False if everything matches (i.e. no update needed). + """ + + # --- Deal ID --- + if str(old_deal.deal_id) != str(new_deal.get("hs_object_id")): + return True + + # --- Company --- + if new_company is not None: + if old_deal.company_id != new_company: + return True + + # --- Listing --- + hs_listing = new_listing or {} + + if old_deal.listing_id != hs_listing.get("listing_id"): + return True + + if old_deal.landlord_property_id != hs_listing.get("owner_property_id"): + return True + + if old_deal.uprn != hs_listing.get("national_uprn"): + return True + + # --- Field mappings --- + FIELD_MAP = { + "outcome": "outcome", + "dealstage": "dealstage", + "dealname": "dealname", + "project_code": "project_code", + "outcome_notes": "outcome_notes", + "major_condition_issue_description": "major_condition_issue_description", + "major_condition_issue_photos": "major_condition_issue_photos", + "coordination_status__stage_1_": "coordination_status", + "coordination_comments": "coordination_comments", + "retrofit_design_status": "design_status", + "pashub_link": "pashub_link", + "sharepoint_link": "sharepoint_link", + "dampmould_growth": "dampmould_growth", + "damp_mould_and_repairs_comments": "damp_mould_and_repairs_comments", + "pre_sap": "pre_sap", + "coordinator": "coordinator", + "proposed_measures": "proposed_measures", + "approved_package": "approved_package", + "designer": "designer", + "actual_measures_installed": "actual_measures_installed", + "installer": "installer", + "installer_handover": "installer_handover", + "lodgement_status": "lodgement_status", + "design_type": "design_type", + "surveyor": "surveyor", + } + + for hs_field, db_field in FIELD_MAP.items(): + old_value = getattr(old_deal, db_field) + new_value = new_deal.get(hs_field) + + if old_value != new_value: + return True + + # --- Date fields --- + date_fields = [ + ("mtp_completion_date", "mtp_completion_date"), + ("mtp_re_model_completion_date", "mtp_re_model_completion_date"), + ("ioe_v3_completion_date", "ioe_v3_completion_date"), + ("design_completion_date", "design_completion_date"), + ("measures_lodgement_date", "measures_lodgement_date"), + ("lodgement_date", "lodgement_date"), + ("expected_commencement_date", "expected_commencement_date"), + ("confirmed_survey_date", "confirmed_survey_date"), + ("surveyed_date", "surveyed_date"), + ] + + for hs_field, db_field in date_fields: + old_value = getattr(old_deal, db_field) + new_value = parse_hs_date(new_deal.get(hs_field)) + + if old_value != new_value: + return True + + # --- Time field --- + if old_deal.confirmed_survey_time != new_deal.get("confirmed_survey_time"): + return True + + # No differences found + return False @staticmethod def check_for_pashub_trigger( diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 826d7e05..5d5b2b26 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -63,21 +63,3 @@ def handler(body: dict[str, Any], context: Any) -> None: ): # TODO: trigger pashub file fetcher return - - # if db_deal: - # db_client.update_deal_with_checks(db_deal, hubspot_client) - # else: - # hubspot_deal: Dict[str, str] - # company: Optional[str] - # listing: Optional[dict[str, str]] - - # hubspot_deal, company, listing = ( - # hubspot_client.get_deal_and_company_and_listing(hubspot_deal_id) - # ) - - # if company: - # company_data: CompanyData = hubspot_client.get_company_information(company) - # db_client: HubspotDataToDb = HubspotDataToDb() - # db_client.upsert_company(company_data) - - # db_client.upsert_deal(hubspot_deal, company, listing, hubspot_client) diff --git a/etl/hubspot/utils.py b/etl/hubspot/utils.py new file mode 100644 index 00000000..9fbeae62 --- /dev/null +++ b/etl/hubspot/utils.py @@ -0,0 +1,11 @@ +from datetime import datetime +from typing import Optional + + +def parse_hs_date(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None From f572dfd2b316c17636061c72d323803394f0343c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 12:53:48 +0000 Subject: [PATCH 68/93] trigger pashub to ara lambda if necessary --- etl/hubspot/scripts/scraper/main.py | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 5d5b2b26..18e425a4 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -1,3 +1,5 @@ +import json +import boto3 from typing import Any, Dict, Optional from etl.hubspot.hubspotClient import HubspotClient @@ -8,6 +10,9 @@ from etl.hubspot.hubspot_trigger_orchestrator_trigger_request import ( ) from backend.utils.subtasks import task_handler from backend.app.db.models.hubspot_deal_data import HubspotDealData +from utils.logger import setup_logger + +logger = setup_logger() @task_handler() @@ -15,6 +20,9 @@ def handler(body: dict[str, Any], context: Any) -> None: db_client = HubspotDataToDb() hubspot_client = HubspotClient() + sqs_client = boto3.client("sqs") + PASHUB_TRIGGER_QUEUE_URL = "pashub_to_ara-queue-dev" # TODO: get from env var + payload = HubspotTriggerOrchestratorTriggerRequest.model_validate(body) hubspot_deal_id: str = payload.hubspot_deal_id @@ -40,6 +48,9 @@ def handler(body: dict[str, Any], context: Any) -> None: db_client.upsert_deal(hubspot_deal, company, listing, hubspot_client) return + deal_unchanged = True + + # Deal already in db, check whether anything has changed if HubspotDealDiffer.check_for_db_update_trigger( new_deal=hubspot_deal, new_company=company, @@ -53,6 +64,9 @@ def handler(body: dict[str, Any], context: Any) -> None: hs_company_id=company, hs_listing=listing, ) + deal_unchanged = False + + if deal_unchanged: return # ============================== @@ -62,4 +76,21 @@ def handler(body: dict[str, Any], context: Any) -> None: new_deal=hubspot_deal, old_deal=db_deal ): # TODO: trigger pashub file fetcher + message_body: Dict[str, Optional[str]] = { + "pashub_link": hubspot_deal["pashub_link"], + "address": None, # can we get this? + "sharepoint_link": hubspot_deal["sharepoint_link"], + "uprn": hubspot_deal["national_uprn"], + "landlord_property_id": hubspot_deal["owner_property_id"], + "deal_stage": hubspot_deal["deal_stage"], + } + + response = sqs_client.send_message( + QueueUrl=PASHUB_TRIGGER_QUEUE_URL, MessageBody=json.dumps(message_body) + ) + + logger.info( + f"Sent message to Pashub To Ara queue. MessageId: {response['MessageId']}" + ) + return From c718e36c1180a0fd2413a6a5d6cf93562eebc462 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 12:54:01 +0000 Subject: [PATCH 69/93] trigger pashub to ara lambda if necessary --- etl/hubspot/scripts/scraper/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 18e425a4..0bc285a7 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -92,5 +92,3 @@ def handler(body: dict[str, Any], context: Any) -> None: logger.info( f"Sent message to Pashub To Ara queue. MessageId: {response['MessageId']}" ) - - return From 2b93e06629e146c8d536d019414946cf958bf405 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 13:07:13 +0000 Subject: [PATCH 70/93] add todo --- etl/hubspot/hubspotClient.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index 8053b41f..6bdf71ed 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -230,7 +230,9 @@ class HubspotClient: self.logger.info(f"Listing info for deal {deal_id}: {listing_info}") return listing_info - def from_deal_id_get_info(self, deal_id: str) -> dict[str, str]: + def from_deal_id_get_info( + self, deal_id: str + ) -> dict[str, str]: # TODO: add dataclass for this deals_api: DealsBasicApi = self.client.crm.deals.basic_api # type: ignore[reportUnknownMemberType] deal: HubspotObject = self._call_with_retry( From ff0027dbc46f04c2383a2a8630f476c0c9b071f8 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 13:09:48 +0000 Subject: [PATCH 71/93] remove todo --- etl/hubspot/scripts/scraper/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 0bc285a7..cec03da8 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -75,7 +75,6 @@ def handler(body: dict[str, Any], context: Any) -> None: if HubspotDealDiffer.check_for_pashub_trigger( new_deal=hubspot_deal, old_deal=db_deal ): - # TODO: trigger pashub file fetcher message_body: Dict[str, Optional[str]] = { "pashub_link": hubspot_deal["pashub_link"], "address": None, # can we get this? From 96ac34f42209daaa0a33364ceaae61fcc7f65152 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Apr 2026 14:47:54 +0100 Subject: [PATCH 72/93] adding hacky handling for matching on lmk key or uprn --- backend/SearchEpc.py | 26 +- backend/addresses/Address.py | 4 + backend/addresses/Addresses.py | 16 +- backend/engine/engine.py | 14 +- backend/epc_api/api_spec.md | 1 + .../json_samples/CEPC-7.0/cepc+rr.json | 278 +++ .../json_samples/CEPC-7.0/cepc-rr.json | 60 + .../epc_api/json_samples/CEPC-7.0/cepc.json | 285 +++ .../epc_api/json_samples/CEPC-7.0/dec+rr.json | 128 + .../epc_api/json_samples/CEPC-7.0/dec-rr.json | 76 + .../json_samples/CEPC-7.1/cepc+rr.json | 264 +++ .../json_samples/CEPC-7.1/cepc-rr.json | 60 + .../epc_api/json_samples/CEPC-7.1/cepc.json | 288 +++ .../epc_api/json_samples/CEPC-7.1/dec+rr.json | 118 + .../epc_api/json_samples/CEPC-7.1/dec-rr.json | 76 + .../json_samples/CEPC-8.0.0/cepc+rr.json | 468 ++++ .../json_samples/CEPC-8.0.0/cepc-rr.json | 60 + .../epc_api/json_samples/CEPC-8.0.0/cepc.json | 334 +++ .../json_samples/CEPC-8.0.0/dec+rr.json | 131 + .../json_samples/CEPC-8.0.0/dec-rr.json | 76 + .../epc_api/json_samples/CEPC-8.0.0/dec.json | 299 +++ .../json_samples/RdSAP-Schema-17.0/epc.json | 352 +++ .../json_samples/RdSAP-Schema-17.1/epc.json | 472 ++++ .../json_samples/RdSAP-Schema-18.0/epc.json | 413 ++++ .../json_samples/RdSAP-Schema-19.0/epc.json | 386 +++ .../json_samples/RdSAP-Schema-20.0.0/epc.json | 340 +++ .../json_samples/RdSAP-Schema-21.0.0/epc.json | 403 ++++ .../json_samples/RdSAP-Schema-21.0.1/epc.json | 446 ++++ .../json_samples/SAP-Schema-16.0/rdsap.json | 299 +++ .../json_samples/SAP-Schema-16.0/sap.json | 348 +++ .../json_samples/SAP-Schema-16.1/rdsap.json | 400 ++++ .../json_samples/SAP-Schema-16.1/sap.json | 365 +++ .../json_samples/SAP-Schema-16.2/rdsap.json | 931 ++++++++ .../json_samples/SAP-Schema-16.2/sap.json | 2112 +++++++++++++++++ .../json_samples/SAP-Schema-16.3/rdsap.json | 371 +++ .../json_samples/SAP-Schema-16.3/sap.json | 456 ++++ .../json_samples/SAP-Schema-17.0/epc.json | 375 +++ .../json_samples/SAP-Schema-17.1/epc.json | 562 +++++ .../json_samples/SAP-Schema-18.0.0/epc.json | 510 ++++ .../json_samples/SAP-Schema-19.0.0/epc.json | 610 +++++ .../json_samples/SAP-Schema-19.1.0/epc.json | 613 +++++ .../json_samples/SAP-Schema-19.2.0/epc.json | 612 +++++ etl/find_my_epc/RetrieveFindMyEpc.py | 11 +- 43 files changed, 14438 insertions(+), 11 deletions(-) create mode 100644 backend/epc_api/api_spec.md create mode 100644 backend/epc_api/json_samples/CEPC-7.0/cepc+rr.json create mode 100644 backend/epc_api/json_samples/CEPC-7.0/cepc-rr.json create mode 100644 backend/epc_api/json_samples/CEPC-7.0/cepc.json create mode 100644 backend/epc_api/json_samples/CEPC-7.0/dec+rr.json create mode 100644 backend/epc_api/json_samples/CEPC-7.0/dec-rr.json create mode 100644 backend/epc_api/json_samples/CEPC-7.1/cepc+rr.json create mode 100644 backend/epc_api/json_samples/CEPC-7.1/cepc-rr.json create mode 100644 backend/epc_api/json_samples/CEPC-7.1/cepc.json create mode 100644 backend/epc_api/json_samples/CEPC-7.1/dec+rr.json create mode 100644 backend/epc_api/json_samples/CEPC-7.1/dec-rr.json create mode 100644 backend/epc_api/json_samples/CEPC-8.0.0/cepc+rr.json create mode 100644 backend/epc_api/json_samples/CEPC-8.0.0/cepc-rr.json create mode 100644 backend/epc_api/json_samples/CEPC-8.0.0/cepc.json create mode 100644 backend/epc_api/json_samples/CEPC-8.0.0/dec+rr.json create mode 100644 backend/epc_api/json_samples/CEPC-8.0.0/dec-rr.json create mode 100644 backend/epc_api/json_samples/CEPC-8.0.0/dec.json create mode 100644 backend/epc_api/json_samples/RdSAP-Schema-17.0/epc.json create mode 100644 backend/epc_api/json_samples/RdSAP-Schema-17.1/epc.json create mode 100644 backend/epc_api/json_samples/RdSAP-Schema-18.0/epc.json create mode 100644 backend/epc_api/json_samples/RdSAP-Schema-19.0/epc.json create mode 100644 backend/epc_api/json_samples/RdSAP-Schema-20.0.0/epc.json create mode 100644 backend/epc_api/json_samples/RdSAP-Schema-21.0.0/epc.json create mode 100644 backend/epc_api/json_samples/RdSAP-Schema-21.0.1/epc.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-16.0/rdsap.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-16.0/sap.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-16.1/rdsap.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-16.1/sap.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-16.2/rdsap.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-16.2/sap.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-16.3/rdsap.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-16.3/sap.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-17.0/epc.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-17.1/epc.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-18.0.0/epc.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-19.0.0/epc.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-19.1.0/epc.json create mode 100644 backend/epc_api/json_samples/SAP-Schema-19.2.0/epc.json diff --git a/backend/SearchEpc.py b/backend/SearchEpc.py index a633176e..7d3a8b3d 100644 --- a/backend/SearchEpc.py +++ b/backend/SearchEpc.py @@ -2,6 +2,7 @@ import os import time import re +from typing import Optional from urllib.parse import urlencode import usaddress import pandas as pd @@ -162,7 +163,8 @@ class SearchEpc: property_type=None, fast=False, heating_system: [str, None] = None, - associated_uprns: [List[int] | None] = None + associated_uprns: [List[int] | None] = None, + lmk_key: Optional[str] = None, ): """ Address lines 1 and postcode are mandatory fields. The other address lines are optional @@ -225,6 +227,7 @@ class SearchEpc: # Be default, this is set to false. This flag indicates whether we should take the existing EPC, but use # the estimated EPC to clean missings self.clean_missing_on_expired = False + self.lmk_key = lmk_key def set_strict_property_type_search(self): """ @@ -289,18 +292,22 @@ class SearchEpc: else: return None - def _get_epc(self, params, size): + def _get_epc(self, params, size, lmk_key=None): """ To be called by get_epc() - not for external usage """ - url = os.path.join(self.client.domestic.host, "search") - if size: - url += "?" + urlencode({k: v for k, v in {"size": size}.items() if v}) + if lmk_key is not None: + url = os.path.join(self.client.domestic.host, f"certificate/{lmk_key}") + else: + url = os.path.join(self.client.domestic.host, "search") + if size: + url += "?" + urlencode({k: v for k, v in {"size": size}.items() if v}) for retry in range(self.max_retries): try: response = self.client.domestic.call(method="get", url=url, params=params) + if response: self.data = response return { @@ -341,6 +348,15 @@ class SearchEpc: self.data = output["response"] return output["msg"] + if self.lmk_key: + data = {"rows": []} + api_response = self._get_epc(params={}, size=size, lmk_key=self.lmk_key) + data["rows"].extend(api_response["response"]["rows"]) + api_response["msg"] = self.SUCCESS + self.data = data + self.data["rows"][0]["uprn"] = str(self.uprn) + return api_response["msg"] + if not self.uprn and not self.address1 and not self.postcode: raise ValueError("No search parameters provided") diff --git a/backend/addresses/Address.py b/backend/addresses/Address.py index f348b141..0d957679 100644 --- a/backend/addresses/Address.py +++ b/backend/addresses/Address.py @@ -48,6 +48,10 @@ class Address: # domna_full_address: Optional[str] # domna_address_1: Optional[str] + # Identifiers to handle the case where we have erroneous matches based on UPRN due to probelmatic EPC data + lmk_key: Optional[str] + epc_certificate_number: Optional[str] + @property def request_data(self) -> dict[str, Optional[str]]: """ diff --git a/backend/addresses/Addresses.py b/backend/addresses/Addresses.py index c1624522..0496e95e 100644 --- a/backend/addresses/Addresses.py +++ b/backend/addresses/Addresses.py @@ -134,6 +134,16 @@ class Addresses: postcode = str(row.get("postcode", "")).strip().upper() + lmk_key = row.get("lmk_key", None) + # Handle NAN + if pd.isnull(lmk_key): + lmk_key = None + epc_certificate_number = row.get("certificate_number", None) + + landlord_heating_system = row.get("epc_heating_type", None) + if pd.isnull(landlord_heating_system): + landlord_heating_system = None + return Address( uprn=uprn, landlord_property_id=str(row["landlord_property_id"]) if row.get("landlord_property_id") else None, @@ -153,7 +163,7 @@ class Addresses: landlord_roof_construction=None, landlord_floor_construction=None, landlord_windows_type=None, - landlord_heating_system=row.get("epc_heating_type"), + landlord_heating_system=landlord_heating_system, landlord_fuel_type=None, landlord_heating_controls=None, landlord_hot_water_system=None, @@ -168,4 +178,8 @@ class Addresses: landlord_has_sloping_ceiling=None, landlord_multi_glaze_proportion=None, landlord_construction_age_band=None, + + # EPC identifiers which are helpful if UPRN is problematic + lmk_key=lmk_key, + epc_certificate_number=epc_certificate_number ) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index b1b57529..67c9dd4e 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -582,7 +582,8 @@ async def model_engine(body: PlanTriggerRequest): os_api_key="", full_address=addr.full_address, heating_system=addr.landlord_heating_system, - associated_uprns=associated_uprns + associated_uprns=associated_uprns, + lmk_key=addr.lmk_key ) epc_searcher.ordnance_survey_client.built_form = addr.landlord_built_form epc_searcher.ordnance_survey_client.property_type = addr.landlord_property_type @@ -627,7 +628,12 @@ async def model_engine(body: PlanTriggerRequest): # if we have a remote assment data type, we pull the additional data and include it epc_page_source, find_my_epc_components = {}, [] - if (body.event_type == "remote_assessment") and not (epc_searcher.newest_epc.get("estimated")): + if ((body.event_type == "remote_assessment") and not ( + epc_searcher.newest_epc.get("estimated")) + ) or addr.epc_certificate_number: + + if addr.epc_certificate_number: + rrn = addr.epc_certificate_number property_non_invasive_recommendations, patch, epc_page_source, find_my_epc_components = ( RetrieveFindMyEpc.get_from_epc_with_fallback( epc=epc_searcher.newest_epc, @@ -641,6 +647,10 @@ async def model_engine(body: PlanTriggerRequest): epc_records = patch_epc(patch, epc_records) + # Hack - temp while we're planning to rebuild backend + if addr.epc_certificate_number is not None and epc_records["original_epc"].get("estimated"): + epc_records["original_epc"]["estimated"] = False + prepared_epc = EPCRecord( epc_records=epc_records, run_mode="newdata", cleaning_data=cleaning_data, address_metadata=addr ) diff --git a/backend/epc_api/api_spec.md b/backend/epc_api/api_spec.md new file mode 100644 index 00000000..5e1c309d --- /dev/null +++ b/backend/epc_api/api_spec.md @@ -0,0 +1 @@ +Hello World \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.0/cepc+rr.json b/backend/epc_api/json_samples/CEPC-7.0/cepc+rr.json new file mode 100644 index 00000000..876603e7 --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.0/cepc+rr.json @@ -0,0 +1,278 @@ +{ + "ber": 158.9, + "ser": 59.26, + "ter": 39.95, + "tyr": 106.52, + "uprn": 12457, + "status": "entered", + "postcode": "PT42 7AD", + "post_town": "POSTTOWN", + "issue_date": "2013-08-16", + "methodology": "SBEM", + "report_type": 3, + "schema_type": "CEPC-7.0", + "uprn_source": "Energy Assessor", + "valid_until": "2023-08-15", + "asset_rating": 134, + "language_code": 1, + "output_engine": "EPCgen, v4.1.e.5", + "property_type": "A3/A4/A5 Restaurant and Cafes/Drinking Establishments and Hot Food takeaways", + "address_line_2": "Acme Coffee", + "address_line_3": "13 Old Street", + "assessment_type": "CEPC", + "inspection_date": "2013-08-10", + "inspection_type": "Physical", + "ac_questionnaire": { + "ac_present": "No", + "ac_rated_output": { + "ac_rating_unknown_flag": 1 + }, + "ac_inspection_commissioned": 4 + }, + "calculation_tool": "CLG, iSBEM, v4.1.e, SBEM, v4.1.e.5", + "is_heritage_site": "N", + "transaction_type": 1, + "registration_date": "2013-08-15", + "building_complexity": "Level 3", + "new_build_benchmark": 34, + "location_description": "Located in main shopping area in Posttown town centre", + "technical_information": { + "floor_area": 314, + "building_level": 3, + "main_heating_fuel": "Grid Supplied Electricity", + "building_environment": "Heating and Natural Ventilation" + }, + "summary_of_performance": { + "building_data": [ + { + "area": 313.8, + "weather": "BEL", + "activities": [ + { + "id": 1010, + "area": 142.6 + }, + { + "id": 1030, + "area": 66.9 + }, + { + "id": 1032, + "area": 58.4 + }, + { + "id": 1036, + "area": 4.8 + }, + { + "id": 1031, + "area": 41.1 + } + ], + "building_w_k": 1317.44, + "hvac_systems": [ + { + "area": 142.6, + "type": "No Heating or Cooling", + "fuel_type": "Oil", + "activities": [ + { + "id": 1010, + "area": 142.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 0, + "heating_sseff": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 0, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 0, + "mj_m2_cooling_dem": 47.0757, + "mj_m2_heating_dem": 604.144 + }, + { + "area": 171.2, + "type": "Other local room heater - unfanned", + "fuel_type": "Grid Supplied Electricity", + "activities": [ + { + "id": 1030, + "area": 66.9 + }, + { + "id": 1032, + "area": 58.4 + }, + { + "id": 1036, + "area": 4.8 + }, + { + "id": 1031, + "area": 41.1 + } + ], + "heat_source": "Room heater", + "cooling_sseer": 0, + "heating_sseff": 0.8, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 255.695, + "cooling_gen_seer": 0, + "heating_gen_seff": 1, + "kwh_m2_auxiliary": 1.95411, + "mj_m2_cooling_dem": 683.721, + "mj_m2_heating_dem": 736.402 + } + ], + "analysis_type": "ACTUAL", + "area_exterior": 873.7, + "building_alpha": 3.81869, + "building_w_m2k": 1.50789, + "q50_infiltration": 25, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 27.3554, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 139.499, + "kwh_m2_lighting": 139.423, + "kwh_m2_supplied": 307.344, + "kwh_m2_auxiliary": 1.06611, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 66.704, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 0, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 313.8, + "weather": "BEL", + "activities": [ + { + "id": 1010, + "area": 142.6 + }, + { + "id": 1030, + "area": 66.9 + }, + { + "id": 1032, + "area": 58.4 + }, + { + "id": 1036, + "area": 4.8 + }, + { + "id": 1031, + "area": 41.1 + } + ], + "building_w_k": 301.464, + "hvac_systems": [ + { + "area": 142.6, + "type": "No Heating or Cooling", + "fuel_type": "Oil", + "activities": [ + { + "id": 1010, + "area": 142.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 0, + "heating_sseff": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 0, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 0, + "mj_m2_cooling_dem": 18.7903, + "mj_m2_heating_dem": 179.595 + }, + { + "area": 171.2, + "type": "Other local room heater - unfanned", + "fuel_type": "Oil", + "activities": [ + { + "id": 1030, + "area": 66.9 + }, + { + "id": 1032, + "area": 58.4 + }, + { + "id": 1036, + "area": 4.8 + }, + { + "id": 1031, + "area": 41.1 + } + ], + "heat_source": "Room heater", + "cooling_sseer": 0, + "heating_sseff": 0.792, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 104.881, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 0.521096, + "mj_m2_cooling_dem": 287.737, + "mj_m2_heating_dem": 299.036 + } + ], + "analysis_type": "NOTIONAL", + "area_exterior": 873.7, + "building_alpha": 42.6344, + "building_w_m2k": 0.345043, + "q50_infiltration": 6.0896, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 32.702, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 89.9222, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 57.2199, + "kwh_m2_lighting": 25.9805, + "kwh_m2_supplied": 26.2648, + "kwh_m2_auxiliary": 0.284295, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 66.704, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 0, + "kwh_m2_district_heating": 0 + } + } + ] + }, + "existing_stock_benchmark": 90, + "related_certificate_number": "4444-5555-6666-7777-8889", + "current_energy_efficiency_band": "F" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.0/cepc-rr.json b/backend/epc_api/json_samples/CEPC-7.0/cepc-rr.json new file mode 100644 index 00000000..9e594325 --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.0/cepc-rr.json @@ -0,0 +1,60 @@ +{ + "uprn": 12457, + "status": "cancelled", + "postcode": "SW1A 2AA", + "post_town": "Fulchester", + "issue_date": "2020-05-14", + "report_type": 4, + "schema_type": "CEPC-7.0", + "uprn_source": "Energy Assessor", + "valid_until": "2021-05-03", + "long_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Consider installing an air source heat pump.", + "recommendation_code": "EPC-R5" + } + ], + "language_code": 1, + "other_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Consider installing PV.", + "recommendation_code": "EPC-R4" + } + ], + "property_type": "B1 Offices and Workshop businesses", + "short_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Consider replacing T8 lamps with retrofit T5 conversion kit.", + "recommendation_code": "ECP-L5" + }, + { + "co2_impact": "LOW", + "recommendation": "Introduce HF (high frequency) ballasts for fluorescent tubes: Reduced number of fittings required.", + "recommendation_code": "EPC-L7" + } + ], + "address_line_1": "Some Unit", + "address_line_2": "2 Lonely Street", + "address_line_3": "Some Area", + "address_line_4": "Some County", + "medium_payback": [ + { + "co2_impact": "MEDIUM", + "recommendation": "Add optimum start/stop to the heating system.", + "recommendation_code": "EPC-H7" + } + ], + "assessment_type": "CEPC-RR", + "inspection_date": "2020-05-04", + "calculation_tool": "CEPC Compute v0.2", + "formatted_report": "ZGVmYXVsdA==", + "registration_date": "2020-05-05", + "technical_information": { + "floor_area": 10, + "building_environment": "Natural Ventilation Only" + }, + "related_certificate_number": "0000-0000-0000-0000-0001" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.0/cepc.json b/backend/epc_api/json_samples/CEPC-7.0/cepc.json new file mode 100644 index 00000000..d65c34bf --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.0/cepc.json @@ -0,0 +1,285 @@ +{ + "ber": 67.09, + "ser": 42.07, + "ter": 23.2, + "tyr": 67.98, + "uprn": 12457, + "status": "entered", + "postcode": "SW1A 2AA", + "post_town": "Whitbury", + "issue_date": "2020-05-14", + "report_type": 3, + "schema_type": "CEPC-7.0", + "uprn_source": "Energy Assessor", + "valid_until": "2026-05-04", + "asset_rating": 80, + "language_code": 1, + "property_type": "B1 Offices and Workshop businesses", + "address_line_1": "Some Unit", + "address_line_2": "2 Lonely Street", + "address_line_3": "Some Area", + "address_line_4": "Some County", + "assessment_type": "CEPC", + "inspection_date": "2020-05-04", + "ac_questionnaire": { + "ac_present": "No", + "ac_rated_output": { + "ac_kw_rating": 100 + }, + "ac_estimated_output": 3, + "ac_inspection_commissioned": 1 + }, + "calculation_tool": "Casio fx-39", + "is_heritage_site": "N", + "or_building_data": { + "hvac_system": "Yes", + "other_hvac_system": "Yes", + "internal_environment": "Yes", + "assessment_period_alignment": "Yes" + }, + "transaction_type": 1, + "or_benchmark_data": { + "benchmarks": [ + { + "floor_area": 403, + "benchmark_id": 1 + } + ] + }, + "registration_date": "2020-05-04", + "building_complexity": "Level 3", + "new_build_benchmark": 28, + "or_usable_floor_area": { + "ufa_1": { + "floor_area": 100 + } + }, + "or_energy_consumption": { + "anthracite": { + "end_date": "2030-12-25", + "estimate": 3, + "start_date": "2019-12-25", + "consumption": 12.6 + } + }, + "technical_information": { + "floor_area": 403, + "building_level": 3, + "main_heating_fuel": "Natural Gas", + "renewable_sources": "Renewable sources test", + "special_energy_uses": "Test sp", + "building_environment": "Air Conditioning", + "or_availability_date": "2020-01-04", + "other_fuel_description": "Test" + }, + "or_assessment_end_date": "2030-05-14", + "summary_of_performance": { + "building_data": [ + { + "area": 402.6, + "weather": "LON", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "building_w_k": 411.86, + "hvac_systems": [ + { + "area": 402.6, + "type": "Fan coil systems", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 1.963, + "heating_sseff": 0.827, + "kwh_m2_cooling": 25.3865, + "kwh_m2_heating": 41.0355, + "cooling_gen_seer": 2.5, + "heating_gen_seff": 0.89, + "kwh_m2_auxiliary": 39.0832, + "mj_m2_cooling_dem": 179.401, + "mj_m2_heating_dem": 122.171 + } + ], + "analysis_type": "ACTUAL", + "area_exterior": 1058.32, + "building_alpha": 18.3412, + "building_w_m2k": 0.389164, + "q50_infiltration": 10, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 2.77834, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 3.07665, + "kwh_m2_ses": 0.801605, + "kwh_m2_coal": 0, + "kwh_m2_wind": 3.02777, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 25.3865, + "kwh_m2_heating": 41.0355, + "kwh_m2_lighting": 54.0787, + "kwh_m2_supplied": 121.327, + "kwh_m2_auxiliary": 39.0832, + "kwh_m2_displaced": 6.10442, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 42.185, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 41.0355, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 402.6, + "weather": "LON", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "building_w_k": 362.524, + "hvac_systems": [ + { + "area": 402.6, + "type": "Fan coil systems", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 3.6, + "heating_sseff": 0.819, + "kwh_m2_cooling": 7.46769, + "kwh_m2_heating": 18.1581, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 14.0716, + "mj_m2_cooling_dem": 96.7814, + "mj_m2_heating_dem": 53.5375 + } + ], + "analysis_type": "NOTIONAL", + "area_exterior": 1058.32, + "building_alpha": 11.2489, + "building_w_m2k": 0.342547, + "q50_infiltration": 3, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 3.33761, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 3.33761, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 7.46769, + "kwh_m2_heating": 18.1582, + "kwh_m2_lighting": 14.4489, + "kwh_m2_supplied": 35.9883, + "kwh_m2_auxiliary": 14.0716, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 42.185, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 18.1582, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 402.6, + "weather": "LON", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "building_w_k": 718.761, + "hvac_systems": [ + { + "area": 402.6, + "type": "Fan coil systems", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 2.25, + "heating_sseff": 0.73, + "kwh_m2_cooling": 25.6538, + "kwh_m2_heating": 71.9516, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 2.16062, + "mj_m2_cooling_dem": 207.795, + "mj_m2_heating_dem": 189.088 + } + ], + "analysis_type": "REFERENCE", + "area_exterior": 1058.32, + "building_alpha": 10, + "building_w_m2k": 0.679153, + "q50_infiltration": 10, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 6.41192, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 25.6538, + "kwh_m2_heating": 71.9516, + "kwh_m2_lighting": 45.54, + "kwh_m2_supplied": 73.3544, + "kwh_m2_auxiliary": 2.16062, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 42.185, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 78.3634, + "kwh_m2_district_heating": 0 + } + } + ] + }, + "renewable_energy_source": [ + { + "name": "Solar panel", + "end_date": "2030-12-25", + "generation": 20, + "start_date": "2019-12-25", + "energy_type": 2 + } + ], + "existing_stock_benchmark": 81, + "or_assessment_start_date": "2020-05-14", + "related_certificate_number": "4192-1535-8427-8844-6702", + "current_energy_efficiency_band": "D" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.0/dec+rr.json b/backend/epc_api/json_samples/CEPC-7.0/dec+rr.json new file mode 100644 index 00000000..ed560a1b --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.0/dec+rr.json @@ -0,0 +1,128 @@ +{ + "uprn": 12457, + "status": "entered", + "postcode": "PT4 1SK", + "post_town": "POSTTOWN", + "dec_status": 0, + "issue_date": "2016-02-23", + "methodology": "ORCalc", + "reason_type": 2, + "report_type": 1, + "schema_type": "CEPC-7.0", + "uprn_source": "Energy Assessor", + "valid_until": "2017-04-24", + "language_code": 1, + "output_engine": "ORGen v3.6.2", + "property_type": "Schools And Seasonal Public Buildings; Swimming Pool Centre", + "address_line_1": "Mr Blobby's Sports Academy", + "address_line_2": "Mr Blobby's Academy", + "address_line_3": "Blobby Custard Lane", + "assessment_type": "DEC", + "inspection_date": "2016-04-11", + "this_assessment": { + "heating_co2": 153, + "energy_rating": 77, + "nominated_date": "2016-02-23", + "renewables_co2": 0, + "electricity_co2": 163 + }, + "ac_questionnaire": { + "ac_present": "Yes", + "ac_rated_output": { + "ac_rating_unknown_flag": 1 + }, + "ac_estimated_output": 2, + "ac_inspection_commissioned": 4 + }, + "calculation_tool": "Property-Tectonics Ltd, Lifespan DEC, v3.6.1", + "or_building_data": { + "hvac_system": "Radiators", + "internal_environment": "Mixed-mode with Natural Ventilation", + "assessment_period_alignment": "End Of Main Heating Fuel Period" + }, + "or_previous_data": { + "asset_rating": 0, + "previous_rating_1": { + "or": 95, + "ormm": 11, + "oryyyy": 2014 + }, + "previous_rating_2": { + "or": 113, + "ormm": 11, + "oryyyy": 2013 + } + }, + "year1_assessment": { + "heating_co2": 176, + "energy_rating": 95, + "nominated_date": "2014-11-30", + "renewables_co2": 0, + "electricity_co2": 266 + }, + "year2_assessment": { + "heating_co2": 238, + "energy_rating": 113, + "nominated_date": "2013-11-30", + "renewables_co2": 0, + "electricity_co2": 333 + }, + "building_category": "S3; H6", + "or_benchmark_data": { + "benchmarks": [ + { + "name": "Secondary school", + "tufa": 4646.48, + "benchmark": "Schools And Seasonal Public Buildings", + "floor_area": 4646.48, + "area_metric": "Gross floor area measured as RICS Gross Internal Area (GIA)", + "benchmark_id": 1, + "occupancy_level": "Extended Occupancy" + }, + { + "name": "Swimming pool", + "tufa": 254.52, + "benchmark": "Swimming Pool Centre", + "floor_area": 254.52, + "area_metric": "Gross floor area measured as RICS Gross Internal Area (GIA)", + "benchmark_id": 2, + "occupancy_level": "Extended Occupancy" + } + ], + "main_benchmark": "Schools And Seasonal Public Buildings" + }, + "registration_date": "2016-04-25", + "or_energy_consumption": { + "gas": { + "end_date": "2016-01-01", + "estimate": 0, + "start_date": "2015-01-01", + "consumption": 786655 + }, + "electricity": { + "end_date": "2015-12-31", + "estimate": 0, + "start_date": "2015-01-01", + "consumption": 296820 + } + }, + "technical_information": { + "floor_area": 4901, + "main_heating_fuel": "Natural Gas", + "building_environment": "Mixed-mode with Natural Ventilation", + "separately_metered_electric_heating": 0 + }, + "or_assessment_end_date": "2016-01-01", + "or_assessment_start_date": "2015-01-01", + "dec_annual_energy_summary": { + "typical_thermal_use": 244, + "renewables_electrical": 0, + "typical_electrical_use": 67, + "renewables_fuel_thermal": 0, + "annual_energy_use_electrical": 61, + "annual_energy_use_fuel_thermal": 161 + }, + "related_certificate_number": "3333-4444-5555-6666-7778", + "dec_related_party_disclosure": 3, + "current_energy_efficiency_band": "D" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.0/dec-rr.json b/backend/epc_api/json_samples/CEPC-7.0/dec-rr.json new file mode 100644 index 00000000..1974d031 --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.0/dec-rr.json @@ -0,0 +1,76 @@ +{ + "uprn": 12457, + "status": "entered", + "postcode": "SW1A 2AA", + "post_town": "Fulchester", + "issue_date": "2020-05-04", + "report_type": 2, + "schema_type": "CEPC-7.0", + "uprn_source": "Energy Assessor", + "valid_until": "2028-05-03", + "long_payback": [ + { + "co2_impact": "LOW", + "recommendation": "Consider replacing or improving glazing", + "recommendation_code": "ECP-F4" + } + ], + "language_code": 1, + "other_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Add a big wind turbine", + "recommendation_code": "ECP-H2" + } + ], + "property_type": "University campus", + "short_payback": [ + { + "co2_impact": "MEDIUM", + "recommendation": "Consider thinking about maybe possibly getting a solar panel but only one.", + "recommendation_code": "ECP-L5" + }, + { + "co2_impact": "LOW", + "recommendation": "Consider introducing variable speed drives (VSD) for fans, pumps and compressors.", + "recommendation_code": "EPC-L7" + } + ], + "site_services": { + "service_1": { + "quantity": 751445, + "description": "Electricity" + }, + "service_2": { + "quantity": 72956, + "description": "Gas" + }, + "service_3": { + "quantity": 0, + "description": "Not used" + } + }, + "address_line_1": "Some Unit", + "address_line_2": "2 Lonely Street", + "address_line_3": "Some Area", + "address_line_4": "Some County", + "medium_payback": [ + { + "co2_impact": "LOW", + "recommendation": "Engage experts to propose specific measures to reduce hot waterwastage and plan to carry this out.", + "recommendation_code": "ECP-C1" + } + ], + "assessment_type": "DEC-RR", + "inspection_date": "2020-05-04", + "inspection_type": "Physical", + "calculation_tool": "DCLG, ORCalc, v3.6.2", + "formatted_report": "ZGVmYXVsdA==", + "registration_date": "2020-05-04", + "technical_information": { + "floor_area": 10, + "renewable_sources": "Renewable source", + "special_energy_uses": "Special discount", + "building_environment": "Air Conditioning" + } +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.1/cepc+rr.json b/backend/epc_api/json_samples/CEPC-7.1/cepc+rr.json new file mode 100644 index 00000000..a41ca736 --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.1/cepc+rr.json @@ -0,0 +1,264 @@ +{ + "ber": 135.65, + "ser": 72.8, + "ter": 39.05, + "tyr": 114.44, + "uprn": 12457, + "status": "entered", + "postcode": "A1 1AA", + "post_town": "New Town", + "energy_use": { + "energy_consumption_current": 802.38 + }, + "issue_date": "2017-10-13", + "methodology": "SBEM", + "report_type": 3, + "schema_type": "CEPC-7.1", + "uprn_source": "Energy Assessor", + "valid_until": "2027-10-12", + "asset_rating": 93, + "language_code": 1, + "output_engine": "EPCgen, v5.3.a.0", + "property_type": "A1/A2 Retail and Financial/Professional services", + "address_line_1": "99a Address Street", + "address_line_2": "Place Location", + "assessment_type": "CEPC", + "inspection_date": "2017-10-10", + "inspection_type": "Physical", + "ac_questionnaire": { + "ac_present": "No", + "ac_rated_output": { + "ac_rating_unknown_flag": 1 + }, + "ac_inspection_commissioned": 4 + }, + "calculation_tool": "CLG, iSBEM, v5.3.a, SBEM, v5.3.a.0", + "is_heritage_site": "N", + "transaction_type": 6, + "registration_date": "2017-10-13", + "building_complexity": "Level 3", + "new_build_benchmark": 27, + "technical_information": { + "floor_area": 222, + "building_level": 3, + "main_heating_fuel": "Grid Supplied Electricity", + "building_environment": "Heating and Natural Ventilation" + }, + "summary_of_performance": { + "building_data": [ + { + "area": 221.66, + "weather": "NEW", + "activities": [ + { + "id": 1078, + "area": 6.84 + }, + { + "id": 1077, + "area": 3.42 + }, + { + "id": 1074, + "area": 43.89 + }, + { + "id": 1305, + "area": 167.51 + } + ], + "building_w_k": 756.843, + "hvac_systems": [ + { + "area": 54.15, + "type": "Other local room heater - fanned", + "fuel_type": "Grid Supplied Electricity", + "activities": [ + { + "id": 1078, + "area": 6.84 + }, + { + "id": 1077, + "area": 3.42 + }, + { + "id": 1074, + "area": 43.89 + } + ], + "heat_source": "Room heater", + "cooling_sseer": 0, + "heating_sseff": 0.8, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 221.413, + "cooling_gen_seer": 0, + "heating_gen_seff": 1, + "kwh_m2_auxiliary": 8.58942, + "mj_m2_cooling_dem": 82.8076, + "mj_m2_heating_dem": 637.668 + }, + { + "area": 167.51, + "type": "Other local room heater - unfanned", + "fuel_type": "Grid Supplied Electricity", + "activities": [ + { + "id": 1305, + "area": 167.51 + } + ], + "heat_source": "Room heater", + "cooling_sseer": 0, + "heating_sseff": 0.8, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 143.224, + "cooling_gen_seer": 0, + "heating_gen_seff": 1, + "kwh_m2_auxiliary": 0, + "mj_m2_cooling_dem": 277.439, + "mj_m2_heating_dem": 412.486 + } + ], + "analysis_type": "ACTUAL", + "area_exterior": 578.13, + "building_alpha": 5.48977, + "building_w_m2k": 1.30912, + "q50_infiltration": 25, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 1.42751, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 162.325, + "kwh_m2_lighting": 95.5093, + "kwh_m2_supplied": 261.361, + "kwh_m2_auxiliary": 2.09834, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 17.0788, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 0, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 221.66, + "weather": "NEW", + "activities": [ + { + "id": 1078, + "area": 6.84 + }, + { + "id": 1077, + "area": 3.42 + }, + { + "id": 1074, + "area": 43.89 + }, + { + "id": 1305, + "area": 167.51 + } + ], + "building_w_k": 141.605, + "hvac_systems": [ + { + "area": 54.15, + "type": "Other local room heater - fanned", + "fuel_type": "Oil", + "activities": [ + { + "id": 1078, + "area": 6.84 + }, + { + "id": 1077, + "area": 3.42 + }, + { + "id": 1074, + "area": 43.89 + } + ], + "heat_source": "Room heater", + "cooling_sseer": 0, + "heating_sseff": 0.819, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 83.8955, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 2.29051, + "mj_m2_cooling_dem": 32.988, + "mj_m2_heating_dem": 247.357 + }, + { + "area": 167.51, + "type": "Other local room heater - unfanned", + "fuel_type": "Oil", + "activities": [ + { + "id": 1305, + "area": 167.51 + } + ], + "heat_source": "Room heater", + "cooling_sseer": 0, + "heating_sseff": 0.819, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 18.2439, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 0, + "mj_m2_cooling_dem": 153.68, + "mj_m2_heating_dem": 53.7903 + } + ], + "analysis_type": "NOTIONAL", + "area_exterior": 578.13, + "building_alpha": 12.3616, + "building_w_m2k": 0.244936, + "q50_infiltration": 5, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 1.64373, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 35.9259, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 34.2821, + "kwh_m2_lighting": 53.9687, + "kwh_m2_supplied": 54.5286, + "kwh_m2_auxiliary": 0.559555, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 17.0788, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 0, + "kwh_m2_district_heating": 0 + } + } + ] + }, + "existing_stock_benchmark": 79, + "related_certificate_number": "9999-9999-3333-9999-2222", + "current_energy_efficiency_band": "D" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.1/cepc-rr.json b/backend/epc_api/json_samples/CEPC-7.1/cepc-rr.json new file mode 100644 index 00000000..be34f330 --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.1/cepc-rr.json @@ -0,0 +1,60 @@ +{ + "uprn": 12457, + "status": "cancelled", + "postcode": "SW1A 2AA", + "post_town": "Fulchester", + "issue_date": "2020-05-14", + "report_type": 4, + "schema_type": "CEPC-7.1", + "uprn_source": "Energy Assessor", + "valid_until": "2021-05-03", + "long_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Consider installing an air source heat pump.", + "recommendation_code": "EPC-R5" + } + ], + "language_code": 1, + "other_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Consider installing PV.", + "recommendation_code": "EPC-R4" + } + ], + "property_type": "B1 Offices and Workshop businesses", + "short_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Consider replacing T8 lamps with retrofit T5 conversion kit.", + "recommendation_code": "ECP-L5" + }, + { + "co2_impact": "LOW", + "recommendation": "Introduce HF (high frequency) ballasts for fluorescent tubes: Reduced number of fittings required.", + "recommendation_code": "EPC-L7" + } + ], + "address_line_1": "Some Unit", + "address_line_2": "2 Lonely Street", + "address_line_3": "Some Area", + "address_line_4": "Some County", + "medium_payback": [ + { + "co2_impact": "MEDIUM", + "recommendation": "Add optimum start/stop to the heating system.", + "recommendation_code": "EPC-H7" + } + ], + "assessment_type": "CEPC-RR", + "inspection_date": "2020-05-04", + "calculation_tool": "CEPC Compute v0.2", + "formatted_report": "ZGVmYXVsdA==", + "registration_date": "2020-05-05", + "technical_information": { + "floor_area": 10, + "building_environment": "Natural Ventilation Only" + }, + "related_certificate_number": "0000-0000-0000-0000-0001" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.1/cepc.json b/backend/epc_api/json_samples/CEPC-7.1/cepc.json new file mode 100644 index 00000000..f89954e6 --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.1/cepc.json @@ -0,0 +1,288 @@ +{ + "ber": 67.09, + "ser": 42.07, + "ter": 23.2, + "tyr": 67.98, + "uprn": 12457, + "status": "entered", + "postcode": "BT4 3SR", + "post_town": "Whitbury", + "energy_use": { + "energy_consumption_current": 413.22 + }, + "issue_date": "2020-05-14", + "report_type": 3, + "schema_type": "CEPC-7.1", + "uprn_source": "Energy Assessor", + "valid_until": "2026-05-04", + "asset_rating": 80, + "language_code": 1, + "property_type": "B1 Offices and Workshop businesses", + "address_line_1": "Some Unit", + "address_line_2": "2 Lonely Street", + "address_line_3": "Some Area", + "address_line_4": "Some County", + "assessment_type": "CEPC", + "inspection_date": "2020-05-04", + "ac_questionnaire": { + "ac_present": "No", + "ac_rated_output": { + "ac_kw_rating": 100 + }, + "ac_estimated_output": 3, + "ac_inspection_commissioned": 1 + }, + "calculation_tool": "Casio fx-39", + "is_heritage_site": "N", + "or_building_data": { + "hvac_system": "Yes", + "other_hvac_system": "Yes", + "internal_environment": "Yes", + "assessment_period_alignment": "Yes" + }, + "transaction_type": 1, + "or_benchmark_data": { + "benchmarks": [ + { + "floor_area": 403, + "benchmark_id": 1 + } + ] + }, + "registration_date": "2020-05-04", + "building_complexity": "Level 3", + "new_build_benchmark": 28, + "or_usable_floor_area": { + "ufa_1": { + "floor_area": 100 + } + }, + "or_energy_consumption": { + "anthracite": { + "end_date": "2030-12-25", + "estimate": 3, + "start_date": "2019-12-25", + "consumption": 12.6 + } + }, + "technical_information": { + "floor_area": 403, + "building_level": 3, + "main_heating_fuel": "Natural Gas", + "renewable_sources": "Renewable sources test", + "special_energy_uses": "Test sp", + "building_environment": "Air Conditioning", + "or_availability_date": "2020-01-04", + "other_fuel_description": "Test" + }, + "or_assessment_end_date": "2030-05-14", + "summary_of_performance": { + "building_data": [ + { + "area": 402.6, + "weather": "LON", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "building_w_k": 411.86, + "hvac_systems": [ + { + "area": 402.6, + "type": "Fan coil systems", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 1.963, + "heating_sseff": 0.827, + "kwh_m2_cooling": 25.3865, + "kwh_m2_heating": 41.0355, + "cooling_gen_seer": 2.5, + "heating_gen_seff": 0.89, + "kwh_m2_auxiliary": 39.0832, + "mj_m2_cooling_dem": 179.401, + "mj_m2_heating_dem": 122.171 + } + ], + "analysis_type": "ACTUAL", + "area_exterior": 1058.32, + "building_alpha": 18.3412, + "building_w_m2k": 0.389164, + "q50_infiltration": 10, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 2.77834, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 3.07665, + "kwh_m2_ses": 0.801605, + "kwh_m2_coal": 0, + "kwh_m2_wind": 3.02777, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 25.3865, + "kwh_m2_heating": 41.0355, + "kwh_m2_lighting": 54.0787, + "kwh_m2_supplied": 121.327, + "kwh_m2_auxiliary": 39.0832, + "kwh_m2_displaced": 6.10442, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 42.185, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 41.0355, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 402.6, + "weather": "LON", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "building_w_k": 362.524, + "hvac_systems": [ + { + "area": 402.6, + "type": "Fan coil systems", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 3.6, + "heating_sseff": 0.819, + "kwh_m2_cooling": 7.46769, + "kwh_m2_heating": 18.1581, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 14.0716, + "mj_m2_cooling_dem": 96.7814, + "mj_m2_heating_dem": 53.5375 + } + ], + "analysis_type": "NOTIONAL", + "area_exterior": 1058.32, + "building_alpha": 11.2489, + "building_w_m2k": 0.342547, + "q50_infiltration": 3, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 3.33761, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 3.33761, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 7.46769, + "kwh_m2_heating": 18.1582, + "kwh_m2_lighting": 14.4489, + "kwh_m2_supplied": 35.9883, + "kwh_m2_auxiliary": 14.0716, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 42.185, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 18.1582, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 402.6, + "weather": "LON", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "building_w_k": 718.761, + "hvac_systems": [ + { + "area": 402.6, + "type": "Fan coil systems", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 2.25, + "heating_sseff": 0.73, + "kwh_m2_cooling": 25.6538, + "kwh_m2_heating": 71.9516, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 2.16062, + "mj_m2_cooling_dem": 207.795, + "mj_m2_heating_dem": 189.088 + } + ], + "analysis_type": "REFERENCE", + "area_exterior": 1058.32, + "building_alpha": 10, + "building_w_m2k": 0.679153, + "q50_infiltration": 10, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 6.41192, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 25.6538, + "kwh_m2_heating": 71.9516, + "kwh_m2_lighting": 45.54, + "kwh_m2_supplied": 73.3544, + "kwh_m2_auxiliary": 2.16062, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 42.185, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 78.3634, + "kwh_m2_district_heating": 0 + } + } + ] + }, + "renewable_energy_source": [ + { + "name": "Solar panel", + "end_date": "2030-12-25", + "generation": 20, + "start_date": "2019-12-25", + "energy_type": 2 + } + ], + "existing_stock_benchmark": 81, + "or_assessment_start_date": "2020-05-14", + "related_certificate_number": "4192-1535-8427-8844-6702", + "current_energy_efficiency_band": "D" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.1/dec+rr.json b/backend/epc_api/json_samples/CEPC-7.1/dec+rr.json new file mode 100644 index 00000000..f03bd052 --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.1/dec+rr.json @@ -0,0 +1,118 @@ +{ + "uprn": 12457, + "status": "entered", + "postcode": "A1 1AA", + "post_town": "Town", + "dec_status": 0, + "issue_date": "2015-12-14", + "methodology": "ORCalc", + "reason_type": 1, + "report_type": 1, + "schema_type": "CEPC-7.1", + "uprn_source": "Energy Assessor", + "valid_until": "2016-12-29", + "language_code": 1, + "output_engine": "ORGen v3.6.2", + "property_type": "Schools And Seasonal Public Buildings", + "address_line_2": "Place Early Years Centre", + "address_line_3": "Address Road", + "assessment_type": "DEC", + "inspection_date": "2015-12-09", + "this_assessment": { + "heating_co2": 28, + "energy_rating": 80, + "nominated_date": "2015-12-30", + "renewables_co2": 0, + "electricity_co2": 33 + }, + "ac_questionnaire": { + "ac_present": "No", + "ac_rated_output": { + "ac_rating_unknown_flag": 1 + }, + "ac_inspection_commissioned": 4 + }, + "calculation_tool": "DCLG, ORCalc, v3.6.2", + "or_building_data": { + "hvac_system": "Convectors", + "internal_environment": "Heating and Natural Ventilation", + "assessment_period_alignment": "End Of Main Heating Fuel Period" + }, + "or_previous_data": { + "previous_rating_1": { + "or": 75, + "ormm": 12, + "oryyyy": 2014 + }, + "previous_rating_2": { + "or": 75, + "ormm": 12, + "oryyyy": 2013 + } + }, + "year1_assessment": { + "heating_co2": 24, + "energy_rating": 75, + "nominated_date": "2014-12-01", + "renewables_co2": 0, + "electricity_co2": 30 + }, + "year2_assessment": { + "heating_co2": 29, + "energy_rating": 75, + "nominated_date": "2013-12-01", + "renewables_co2": 0, + "electricity_co2": 31 + }, + "building_category": "S3;", + "or_benchmark_data": { + "benchmarks": [ + { + "name": "Nursery or kindergarten", + "tufa": 1219.2, + "benchmark": "Schools And Seasonal Public Buildings", + "floor_area": 1219.2, + "area_metric": "Gross floor area measured as RICS Gross Internal Area (GIA)", + "benchmark_id": 1, + "occupancy_level": "Extended Occupancy", + "total_equivalent": 2437.5 + } + ], + "main_benchmark": "Schools And Seasonal Public Buildings" + }, + "registration_date": "2015-12-14", + "location_description": "Refurbished and extended nursery school building with flat and pitched roof areas, double glazed windows and cavity walls. The building is heated by gas fired boilers.", + "or_energy_consumption": { + "gas": { + "end_date": "2015-09-30", + "estimate": 0, + "start_date": "2014-09-30", + "consumption": 143533 + }, + "electricity": { + "end_date": "2015-10-01", + "estimate": 0, + "start_date": "2014-10-01", + "consumption": 59477 + } + }, + "technical_information": { + "floor_area": 1219.2, + "main_heating_fuel": "Natural Gas", + "building_environment": "Heating and Natural Ventilation", + "separately_metered_electric_heating": 0 + }, + "or_assessment_end_date": "2015-09-30", + "or_assessment_start_date": "2014-09-30", + "dec_annual_energy_summary": { + "typical_thermal_use": 176, + "renewables_electrical": 0, + "typical_electrical_use": 51, + "renewables_fuel_thermal": 0, + "annual_energy_use_electrical": 49, + "annual_energy_use_fuel_thermal": 118 + }, + "related_certificate_number": "0000-0000-0000-0000-0006", + "dec_related_party_disclosure": 4, + "current_energy_efficiency_band": "D" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-7.1/dec-rr.json b/backend/epc_api/json_samples/CEPC-7.1/dec-rr.json new file mode 100644 index 00000000..95accca6 --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-7.1/dec-rr.json @@ -0,0 +1,76 @@ +{ + "uprn": 12457, + "status": "entered", + "postcode": "SW1A 2AA", + "post_town": "Fulchester", + "issue_date": "2020-05-04", + "report_type": 2, + "schema_type": "CEPC-7.1", + "uprn_source": "Energy Assessor", + "valid_until": "2028-05-03", + "long_payback": [ + { + "co2_impact": "LOW", + "recommendation": "Consider replacing or improving glazing", + "recommendation_code": "ECP-F4" + } + ], + "language_code": 1, + "other_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Add a big wind turbine", + "recommendation_code": "ECP-H2" + } + ], + "property_type": "University campus", + "short_payback": [ + { + "co2_impact": "MEDIUM", + "recommendation": "Consider thinking about maybe possibly getting a solar panel but only one.", + "recommendation_code": "ECP-L5" + }, + { + "co2_impact": "LOW", + "recommendation": "Consider introducing variable speed drives (VSD) for fans, pumps and compressors.", + "recommendation_code": "EPC-L7" + } + ], + "site_services": { + "service_1": { + "quantity": 751445, + "description": "Electricity" + }, + "service_2": { + "quantity": 72956, + "description": "Gas" + }, + "service_3": { + "quantity": 0, + "description": "Not used" + } + }, + "address_line_1": "Some Unit", + "address_line_2": "2 Lonely Street", + "address_line_3": "Some Area", + "address_line_4": "Some County", + "medium_payback": [ + { + "co2_impact": "LOW", + "recommendation": "Engage experts to propose specific measures to reduce hot waterwastage and plan to carry this out.", + "recommendation_code": "ECP-C1" + } + ], + "assessment_type": "DEC-RR", + "inspection_date": "2020-05-04", + "inspection_type": "Physical", + "calculation_tool": "DCLG, ORCalc, v3.6.2", + "formatted_report": "ZGVmYXVsdA==", + "registration_date": "2020-05-04", + "technical_information": { + "floor_area": 10, + "renewable_sources": "Renewable source", + "special_energy_uses": "Special discount", + "building_environment": "Air Conditioning" + } +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-8.0.0/cepc+rr.json b/backend/epc_api/json_samples/CEPC-8.0.0/cepc+rr.json new file mode 100644 index 00000000..9ff6ca3a --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-8.0.0/cepc+rr.json @@ -0,0 +1,468 @@ +{ + "ber": 76.29, + "ser": 45.61, + "ter": 31.05, + "tyr": 90.98, + "uprn": 12457, + "status": "entered", + "postcode": "NE0 0AA", + "post_town": "Big Rock", + "energy_use": { + "energy_consumption_current": 451.27 + }, + "issue_date": "2021-03-19", + "methodology": "SBEM", + "report_type": 3, + "schema_type": "CEPC-8.0.0", + "uprn_source": "Energy Assessor", + "valid_until": "2031-03-18", + "asset_rating": 84, + "language_code": 1, + "output_engine": "EPCgen, v5.6.b.0", + "property_type": "A1/A2 Retail and Financial/Professional services", + "address_line_1": "60 Maple Syrup Road", + "address_line_2": "Candy Mountain", + "assessment_type": "CEPC", + "inspection_date": "2021-03-19", + "inspection_type": "Physical", + "ac_questionnaire": { + "ac_present": "No", + "ac_rated_output": { + "ac_rating_unknown_flag": 1 + }, + "ac_inspection_commissioned": 4 + }, + "calculation_tool": "G-ISBEM Ltd, G-ISBEM, v24.0, SBEM, v5.6.b.0", + "is_heritage_site": "N", + "transaction_type": 1, + "registration_date": "2021-03-19", + "building_complexity": "Level 3", + "new_build_benchmark": 34, + "technical_information": { + "floor_area": 951, + "building_level": 3, + "main_heating_fuel": "Grid Supplied Electricity", + "building_environment": "Air Conditioning" + }, + "summary_of_performance": { + "building_data": [ + { + "area": 951.34, + "weather": "NEW", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + }, + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + }, + { + "id": 1077, + "area": 30.09 + } + ], + "building_w_k": 314.665, + "hvac_systems": [ + { + "area": 353.64, + "type": "No Heating or Cooling", + "fuel_type": "Oil", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 0, + "heating_sseff": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 0, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 4.9793, + "mj_m2_cooling_dem": 44.0312, + "mj_m2_heating_dem": 79.1732 + }, + { + "area": 567.61, + "type": "Split or multi-split system", + "fuel_type": "Grid Supplied Electricity", + "activities": [ + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + } + ], + "heat_source": "Heat pump: air source", + "cooling_sseer": 2.457, + "heating_sseff": 3.466, + "kwh_m2_cooling": 65.3618, + "kwh_m2_heating": 2.17225, + "cooling_gen_seer": 3.46, + "heating_gen_seff": 3.72, + "kwh_m2_auxiliary": 6.9318, + "mj_m2_cooling_dem": 578.138, + "mj_m2_heating_dem": 27.1043 + }, + { + "area": 30.09, + "type": "Other local room heater - unfanned", + "fuel_type": "Grid Supplied Electricity", + "activities": [ + { + "id": 1077, + "area": 30.09 + } + ], + "heat_source": "Direct or storage electric heater", + "cooling_sseer": 0, + "heating_sseff": 0.8, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 101.261, + "cooling_gen_seer": 0, + "heating_gen_seff": 1, + "kwh_m2_auxiliary": 28.4481, + "mj_m2_cooling_dem": 72.0834, + "mj_m2_heating_dem": 291.63 + } + ], + "analysis_type": "ACTUAL", + "area_exterior": 509.17, + "building_alpha": 4.09978, + "building_w_m2k": 0.617996, + "q50_infiltration": 25, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 1.61017, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 38.9976, + "kwh_m2_heating": 4.49883, + "kwh_m2_lighting": 94.9991, + "kwh_m2_supplied": 146.993, + "kwh_m2_auxiliary": 6.88654, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 19.5261, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 0, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 951.34, + "weather": "NEW", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + }, + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + }, + { + "id": 1077, + "area": 30.09 + } + ], + "building_w_k": 106.722, + "hvac_systems": [ + { + "area": 353.64, + "type": "No Heating or Cooling", + "fuel_type": "Oil", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 0, + "heating_sseff": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 0, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 2.98759, + "mj_m2_cooling_dem": 22.3547, + "mj_m2_heating_dem": 24.8966 + }, + { + "area": 567.61, + "type": "Split or multi-split system", + "fuel_type": "Grid Supplied Electricity", + "activities": [ + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + } + ], + "heat_source": "Heat pump: air source", + "cooling_sseer": 3.6, + "heating_sseff": 2.43, + "kwh_m2_cooling": 23.8278, + "kwh_m2_heating": 0.00251615, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 3.77382, + "mj_m2_cooling_dem": 308.809, + "mj_m2_heating_dem": 0.0220112 + }, + { + "area": 30.09, + "type": "Other local room heater - unfanned", + "fuel_type": "Oil", + "activities": [ + { + "id": 1077, + "area": 30.09 + } + ], + "heat_source": "Direct or storage electric heater", + "cooling_sseer": 0, + "heating_sseff": 0.819, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 35.9561, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 7.58617, + "mj_m2_cooling_dem": 78.2393, + "mj_m2_heating_dem": 106.013 + } + ], + "analysis_type": "NOTIONAL", + "area_exterior": 509.17, + "building_alpha": 12.5855, + "building_w_m2k": 0.2096, + "q50_infiltration": 3, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 1.45546, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 2.59272, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 14.2167, + "kwh_m2_heating": 1.13875, + "kwh_m2_lighting": 41.9026, + "kwh_m2_supplied": 59.7228, + "kwh_m2_auxiliary": 3.60214, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 19.5261, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 0, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 951.34, + "weather": "NEW", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + }, + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + }, + { + "id": 1077, + "area": 30.09 + } + ], + "building_w_k": 193.204, + "hvac_systems": [ + { + "area": 353.64, + "type": "No Heating or Cooling", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 0, + "heating_sseff": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 0, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 0, + "mj_m2_cooling_dem": 13.1255, + "mj_m2_heating_dem": 71.1616 + }, + { + "area": 567.61, + "type": "Split or multi-split system", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 2.25, + "heating_sseff": 0.73, + "kwh_m2_cooling": 49.5997, + "kwh_m2_heating": 6.49072, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 2.47934, + "mj_m2_cooling_dem": 401.756, + "mj_m2_heating_dem": 17.0576 + }, + { + "area": 30.09, + "type": "Other local room heater - unfanned", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1077, + "area": 30.09 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 2.25, + "heating_sseff": 0.73, + "kwh_m2_cooling": 25.4056, + "kwh_m2_heating": 84.553, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 12.1545, + "mj_m2_cooling_dem": 205.785, + "mj_m2_heating_dem": 222.205 + } + ], + "analysis_type": "REFERENCE", + "area_exterior": 509.17, + "building_alpha": 10, + "building_w_m2k": 0.379449, + "q50_infiltration": 10, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 2.7961, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 30.3968, + "kwh_m2_heating": 6.54699, + "kwh_m2_lighting": 78.7393, + "kwh_m2_supplied": 110.999, + "kwh_m2_auxiliary": 1.86372, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 19.5261, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 9.34308, + "kwh_m2_district_heating": 0 + } + } + ] + }, + "existing_stock_benchmark": 100, + "related_certificate_number": "0000-0000-0000-0000-0001", + "current_energy_efficiency_band": "D" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-8.0.0/cepc-rr.json b/backend/epc_api/json_samples/CEPC-8.0.0/cepc-rr.json new file mode 100644 index 00000000..065f54bb --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-8.0.0/cepc-rr.json @@ -0,0 +1,60 @@ +{ + "uprn": 12457, + "status": "cancelled", + "postcode": "SW1A 2AA", + "post_town": "Fulchester", + "issue_date": "2020-05-14", + "report_type": 4, + "schema_type": "CEPC-8.0.0", + "uprn_source": "Energy Assessor", + "valid_until": "2021-05-03", + "long_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Consider installing an air source heat pump.", + "recommendation_code": "EPC-R5" + } + ], + "language_code": 1, + "other_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Consider installing PV.", + "recommendation_code": "EPC-R4" + } + ], + "property_type": "B1 Offices and Workshop businesses", + "short_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Consider replacing T8 lamps with retrofit T5 conversion kit.", + "recommendation_code": "ECP-L5" + }, + { + "co2_impact": "LOW", + "recommendation": "Introduce HF (high frequency) ballasts for fluorescent tubes: Reduced number of fittings required.", + "recommendation_code": "EPC-L7" + } + ], + "address_line_1": "Some Unit", + "address_line_2": "2 Lonely Street", + "address_line_3": "Some Area", + "address_line_4": "Some County", + "medium_payback": [ + { + "co2_impact": "MEDIUM", + "recommendation": "Add optimum start/stop to the heating system.", + "recommendation_code": "EPC-H7" + } + ], + "assessment_type": "CEPC-RR", + "inspection_date": "2020-05-04", + "calculation_tool": "CEPC Compute v0.2", + "formatted_report": "ZGVmYXVsdA==", + "registration_date": "2020-05-05", + "technical_information": { + "floor_area": 10, + "building_environment": "Natural Ventilation Only" + }, + "related_certificate_number": "0000-0000-0000-0000-0001" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-8.0.0/cepc.json b/backend/epc_api/json_samples/CEPC-8.0.0/cepc.json new file mode 100644 index 00000000..ec747ee7 --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-8.0.0/cepc.json @@ -0,0 +1,334 @@ +{ + "ber": 76.29, + "ser": 45.61, + "ter": 31.05, + "tyr": 90.98, + "uprn": 12457, + "status": "entered", + "postcode": "NE0 0AA", + "post_town": "Big Rock", + "energy_use": { + "energy_consumption_current": 451.27 + }, + "issue_date": "2021-03-19", + "methodology": "SBEM", + "report_type": 3, + "schema_type": "CEPC-8.0.0", + "uprn_source": "Energy Assessor", + "valid_until": "2031-03-18", + "asset_rating": 84, + "language_code": 1, + "output_engine": "EPCgen, v5.6.b.0", + "property_type": "A1/A2 Retail and Financial/Professional services", + "address_line_1": "60 Maple Syrup Road", + "address_line_2": "Candy Mountain", + "assessment_type": "CEPC", + "inspection_date": "2021-03-19", + "inspection_type": "Physical", + "ac_questionnaire": { + "ac_present": "No", + "ac_rated_output": { + "ac_kw_rating": 100, + "ac_rating_unknown_flag": 0 + }, + "ac_estimated_output": 3, + "ac_inspection_commissioned": 4 + }, + "calculation_tool": "G-ISBEM Ltd, G-ISBEM, v24.0, SBEM, v5.6.b.0", + "is_heritage_site": "N", + "transaction_type": 1, + "registration_date": "2021-03-19", + "building_complexity": "Level 3", + "new_build_benchmark": 34, + "technical_information": { + "floor_area": 951, + "building_level": 3, + "main_heating_fuel": "Grid Supplied Electricity", + "renewable_sources": "Renewable sources test", + "special_energy_uses": "Test sp", + "building_environment": "Air Conditioning", + "other_fuel_description": "Other fuel test" + }, + "summary_of_performance": { + "building_data": [ + { + "area": 951.34, + "weather": "NEW", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + }, + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + }, + { + "id": 1077, + "area": 30.09 + } + ], + "building_w_k": 193.204, + "hvac_systems": [ + { + "area": 353.64, + "type": "No Heating or Cooling", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 0, + "heating_sseff": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 0, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 0, + "mj_m2_cooling_dem": 13.1255, + "mj_m2_heating_dem": 71.1616 + }, + { + "area": 567.61, + "type": "Split or multi-split system", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 2.25, + "heating_sseff": 0.73, + "kwh_m2_cooling": 49.5997, + "kwh_m2_heating": 6.49072, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 2.47934, + "mj_m2_cooling_dem": 401.756, + "mj_m2_heating_dem": 17.0576 + }, + { + "area": 30.09, + "type": "Other local room heater - unfanned", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1077, + "area": 30.09 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 2.25, + "heating_sseff": 0.73, + "kwh_m2_cooling": 25.4056, + "kwh_m2_heating": 84.553, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 12.1545, + "mj_m2_cooling_dem": 205.785, + "mj_m2_heating_dem": 222.205 + } + ], + "analysis_type": "REFERENCE", + "area_exterior": 509.17, + "building_alpha": 10, + "building_w_m2k": 0.379449, + "q50_infiltration": 10, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 2.7961, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 30.3968, + "kwh_m2_heating": 6.54699, + "kwh_m2_lighting": 78.7393, + "kwh_m2_supplied": 110.999, + "kwh_m2_auxiliary": 1.86372, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 19.5261, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 9.34308, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 951.34, + "weather": "NEW", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + }, + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + }, + { + "id": 1077, + "area": 30.09 + } + ], + "building_w_k": 193.204, + "hvac_systems": [ + { + "area": 353.64, + "type": "No Heating or Cooling", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1067, + "area": 81.32 + }, + { + "id": 1070, + "area": 13.9 + }, + { + "id": 1074, + "area": 258.42 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 0, + "heating_sseff": 0, + "kwh_m2_cooling": 0, + "kwh_m2_heating": 0, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 0, + "mj_m2_cooling_dem": 13.1255, + "mj_m2_heating_dem": 71.1616 + }, + { + "area": 567.61, + "type": "Split or multi-split system", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1078, + "area": 76.99 + }, + { + "id": 1071, + "area": 490.62 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 2.25, + "heating_sseff": 0.73, + "kwh_m2_cooling": 49.5997, + "kwh_m2_heating": 6.49072, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 2.47934, + "mj_m2_cooling_dem": 401.756, + "mj_m2_heating_dem": 17.0576 + }, + { + "area": 30.09, + "type": "Other local room heater - unfanned", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1077, + "area": 30.09 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 2.25, + "heating_sseff": 0.73, + "kwh_m2_cooling": 25.4056, + "kwh_m2_heating": 84.553, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 12.1545, + "mj_m2_cooling_dem": 205.785, + "mj_m2_heating_dem": 222.205 + } + ], + "analysis_type": "ACTUAL", + "area_exterior": 509.17, + "building_alpha": 10, + "building_w_m2k": 0.379449, + "q50_infiltration": 10, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 2.7961, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 30.3968, + "kwh_m2_heating": 6.54699, + "kwh_m2_lighting": 78.7393, + "kwh_m2_supplied": 110.999, + "kwh_m2_auxiliary": 1.86372, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 19.5261, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 9.34308, + "kwh_m2_district_heating": 0 + } + } + ] + }, + "existing_stock_benchmark": 100, + "current_energy_efficiency_band": "D" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-8.0.0/dec+rr.json b/backend/epc_api/json_samples/CEPC-8.0.0/dec+rr.json new file mode 100644 index 00000000..83cfac0c --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-8.0.0/dec+rr.json @@ -0,0 +1,131 @@ +{ + "uprn": 12457, + "status": "entered", + "postcode": "A00 0AA", + "post_town": "Floatering", + "dec_status": 0, + "issue_date": "2021-10-12", + "methodology": "ORCalc", + "reason_type": 1, + "report_type": 1, + "schema_type": "CEPC-8.0.0", + "uprn_source": "Energy Assessor", + "valid_until": "2022-01-31", + "language_code": 1, + "output_engine": "ORGen v4.0.4", + "property_type": "Fitness And Health Centre; Swimming Pool Centre", + "address_line_1": "Swim & Fitness Centre", + "address_line_2": "Swimming Lane", + "assessment_type": "DEC", + "inspection_date": "2021-09-02", + "this_assessment": { + "heating_co2": 156, + "energy_rating": 42, + "nominated_date": "2021-09-01", + "renewables_co2": 13, + "electricity_co2": 70 + }, + "ac_questionnaire": { + "ac_present": "Yes", + "ac_rated_output": { + "ac_kw_rating": 30 + }, + "ac_inspection_commissioned": 1 + }, + "calculation_tool": "CLG, ORCalc, v4.0.4", + "or_building_data": { + "hvac_system": "Radiators", + "internal_environment": "Heating and Mechanical Ventilation", + "assessment_period_alignment": "End Of Main Heating Fuel Period" + }, + "or_previous_data": { + "asset_rating": 45 + }, + "building_category": "H7; H6;", + "or_benchmark_data": { + "benchmarks": [ + { + "name": "Gymnasium", + "tufa": 110.382, + "benchmark": "Fitness And Health Centre", + "floor_area": 110.382, + "area_metric": "Gross floor area measured as RICS Gross Internal Area (GIA)", + "benchmark_id": 1, + "occupancy_level": "Standard Occupancy" + }, + { + "name": "Swimming pool", + "tufa": 1358.936, + "benchmark": "Swimming Pool Centre", + "floor_area": 1358.936, + "area_metric": "Gross floor area measured as RICS Gross Internal Area (GIA)", + "benchmark_id": 2, + "occupancy_level": "Standard Occupancy" + } + ], + "main_benchmark": "Swimming Pool Centre" + }, + "registration_date": "2021-10-12", + "location_description": "Swimming pool with gumnasium.", + "or_usable_floor_area": { + "ufa_1": { + "name": "Basement Plant", + "floor_area": 118.16 + }, + "ufa_2": { + "name": "Basement void around pool", + "floor_area": 179.673 + }, + "ufa_3": { + "name": "Ground Plant", + "floor_area": 146.223 + }, + "ufa_4": { + "name": "First Floor Plant", + "floor_area": 49.807 + }, + "total_ufa": 493.863 + }, + "or_energy_consumption": { + "gas": { + "end_date": "2021-08-31", + "estimate": 0, + "start_date": "2020-09-01", + "consumption": 805167 + }, + "electricity": { + "end_date": "2021-08-31", + "estimate": 0, + "start_date": "2020-09-01", + "consumption": 126161 + } + }, + "technical_information": { + "floor_area": 1469.318, + "main_heating_fuel": "Natural Gas", + "building_environment": "Heating and Mechanical Ventilation", + "separately_metered_electric_heating": 0 + }, + "or_assessment_end_date": "2021-08-31", + "renewable_energy_source": [ + { + "name": "CHP", + "end_date": "2021-08-31", + "generation": 24589, + "start_date": "2020-09-01", + "energy_type": 0 + } + ], + "or_assessment_start_date": "2020-08-31", + "dec_annual_energy_summary": { + "typical_thermal_use": 1196.24, + "renewables_electrical": 16.3, + "typical_electrical_use": 238.61, + "renewables_fuel_thermal": 0, + "annual_energy_use_electrical": 86.1, + "annual_energy_use_fuel_thermal": 548.82 + }, + "related_certificate_number": "0000-0000-0000-0000-0001", + "dec_related_party_disclosure": 1, + "current_energy_efficiency_band": "B" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-8.0.0/dec-rr.json b/backend/epc_api/json_samples/CEPC-8.0.0/dec-rr.json new file mode 100644 index 00000000..94d8b78a --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-8.0.0/dec-rr.json @@ -0,0 +1,76 @@ +{ + "uprn": 12457, + "status": "entered", + "postcode": "SW1A 2AA", + "post_town": "Fulchester", + "issue_date": "2020-05-04", + "report_type": 2, + "schema_type": "CEPC-8.0.0", + "uprn_source": "Energy Assessor", + "valid_until": "2028-05-03", + "long_payback": [ + { + "co2_impact": "LOW", + "recommendation": "Consider replacing or improving glazing", + "recommendation_code": "ECP-F4" + } + ], + "language_code": 1, + "other_payback": [ + { + "co2_impact": "HIGH", + "recommendation": "Add a big wind turbine", + "recommendation_code": "ECP-H2" + } + ], + "property_type": "University campus", + "short_payback": [ + { + "co2_impact": "MEDIUM", + "recommendation": "Consider thinking about maybe possibly getting a solar panel but only one.", + "recommendation_code": "ECP-L5" + }, + { + "co2_impact": "LOW", + "recommendation": "Consider introducing variable speed drives (VSD) for fans, pumps and compressors.", + "recommendation_code": "EPC-L7" + } + ], + "site_services": { + "service_1": { + "quantity": 751445, + "description": "Electricity" + }, + "service_2": { + "quantity": 72956, + "description": "Gas" + }, + "service_3": { + "quantity": 0, + "description": "Not used" + } + }, + "address_line_1": "Some Unit", + "address_line_2": "2 Lonely Street", + "address_line_3": "Some Area", + "address_line_4": "Some County", + "medium_payback": [ + { + "co2_impact": "LOW", + "recommendation": "Engage experts to propose specific measures to reduce hot waterwastage and plan to carry this out.", + "recommendation_code": "ECP-C1" + } + ], + "assessment_type": "DEC-RR", + "inspection_date": "2020-05-04", + "inspection_type": "Physical", + "calculation_tool": "DCLG, ORCalc, v3.6.2", + "formatted_report": "ZGVmYXVsdA==", + "registration_date": "2020-05-04", + "technical_information": { + "floor_area": 10, + "renewable_sources": "Renewable source", + "special_energy_uses": "Special discount", + "building_environment": "Air Conditioning" + } +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/CEPC-8.0.0/dec.json b/backend/epc_api/json_samples/CEPC-8.0.0/dec.json new file mode 100644 index 00000000..898e34cb --- /dev/null +++ b/backend/epc_api/json_samples/CEPC-8.0.0/dec.json @@ -0,0 +1,299 @@ +{ + "uprn": 12457, + "status": "entered", + "postcode": "SW1A 2AA", + "post_town": "Whitbury", + "dec_status": 1, + "issue_date": "2020-05-14", + "reason_type": 6, + "report_type": 1, + "schema_type": "CEPC-8.0.0", + "uprn_source": "Energy Assessor", + "valid_until": "2026-05-04", + "language_code": 1, + "output_engine": "MWW-91.1.1", + "property_type": "B1 Offices and Workshop businesses", + "address_line_1": "Some Unit", + "address_line_2": "2 Lonely Street", + "address_line_3": "Some Area", + "address_line_4": "Some County", + "assessment_type": "DEC", + "inspection_date": "2020-05-04", + "this_assessment": { + "heating_co2": 3, + "energy_rating": 1, + "nominated_date": "2020-01-01", + "renewables_co2": 0, + "electricity_co2": 7 + }, + "ac_questionnaire": { + "ac_present": "Yes", + "ac_rated_output": { + "ac_kw_rating": 1 + }, + "ac_estimated_output": 1, + "ac_inspection_commissioned": 1 + }, + "calculation_tool": "DCLG, ORCalc, v3.6.3", + "is_heritage_site": "N", + "or_previous_data": { + "asset_rating": 100 + }, + "year1_assessment": { + "heating_co2": 5, + "energy_rating": 24, + "nominated_date": "2019-01-01", + "renewables_co2": 1, + "electricity_co2": 10 + }, + "year2_assessment": { + "heating_co2": 10, + "energy_rating": 40, + "nominated_date": "2018-01-01", + "renewables_co2": 2, + "electricity_co2": 15 + }, + "building_category": "C1", + "or_benchmark_data": { + "benchmarks": [ + { + "name": "Library", + "tufa": 1840, + "floor_area": 15, + "benchmark_id": 1, + "occupancy_level": "level" + } + ] + }, + "registration_date": "2020-05-04", + "building_complexity": "Level 3", + "or_energy_consumption": { + "gas": { + "end_date": "2007-12-18", + "estimate": 0, + "start_date": "2007-01-18", + "consumption": 310400 + }, + "electricity": { + "end_date": "2008-01-31", + "estimate": 1, + "start_date": "2007-01-31", + "consumption": 422480 + } + }, + "technical_information": { + "floor_area": 99, + "main_heating_fuel": "Natural Gas", + "special_energy_uses": "special", + "building_environment": "Heating and Natural Ventilation", + "other_fuel_description": "other" + }, + "or_assessment_end_date": "2020-05-01", + "summary_of_performance": { + "building_data": [ + { + "area": 402.6, + "weather": "LON", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "building_w_k": 411.86, + "hvac_systems": [ + { + "area": 402.6, + "type": "Fan coil systems", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 1.963, + "heating_sseff": 0.827, + "kwh_m2_cooling": 25.3865, + "kwh_m2_heating": 41.0355, + "cooling_gen_seer": 2.5, + "heating_gen_seff": 0.89, + "kwh_m2_auxiliary": 39.0832, + "mj_m2_cooling_dem": 179.401, + "mj_m2_heating_dem": 122.171 + } + ], + "analysis_type": "ACTUAL", + "area_exterior": 1058.32, + "building_alpha": 18.3412, + "building_w_m2k": 0.389164, + "q50_infiltration": 10, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 2.77834, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 3.07665, + "kwh_m2_ses": 0.801605, + "kwh_m2_coal": 0, + "kwh_m2_wind": 3.02777, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 25.3865, + "kwh_m2_heating": 41.0355, + "kwh_m2_lighting": 54.0787, + "kwh_m2_supplied": 121.327, + "kwh_m2_auxiliary": 39.0832, + "kwh_m2_displaced": 6.10442, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 42.185, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 41.0355, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 402.6, + "weather": "LON", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "building_w_k": 362.524, + "hvac_systems": [ + { + "area": 402.6, + "type": "Fan coil systems", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 3.6, + "heating_sseff": 0.819, + "kwh_m2_cooling": 7.46769, + "kwh_m2_heating": 18.1581, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 14.0716, + "mj_m2_cooling_dem": 96.7814, + "mj_m2_heating_dem": 53.5375 + } + ], + "analysis_type": "NOTIONAL", + "area_exterior": 1058.32, + "building_alpha": 11.2489, + "building_w_m2k": 0.342547, + "q50_infiltration": 3, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 3.33761, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 3.33761, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 7.46769, + "kwh_m2_heating": 18.1582, + "kwh_m2_lighting": 14.4489, + "kwh_m2_supplied": 35.9883, + "kwh_m2_auxiliary": 14.0716, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 42.185, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 18.1582, + "kwh_m2_district_heating": 0 + } + }, + { + "area": 402.6, + "weather": "LON", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "building_w_k": 718.761, + "hvac_systems": [ + { + "area": 402.6, + "type": "Fan coil systems", + "fuel_type": "Natural Gas", + "activities": [ + { + "id": 1005, + "area": 402.6 + } + ], + "heat_source": "LTHW boiler", + "cooling_sseer": 2.25, + "heating_sseff": 0.73, + "kwh_m2_cooling": 25.6538, + "kwh_m2_heating": 71.9516, + "cooling_gen_seer": 0, + "heating_gen_seff": 0, + "kwh_m2_auxiliary": 2.16062, + "mj_m2_cooling_dem": 207.795, + "mj_m2_heating_dem": 189.088 + } + ], + "analysis_type": "REFERENCE", + "area_exterior": 1058.32, + "building_alpha": 10, + "building_w_m2k": 0.679153, + "q50_infiltration": 10, + "global_performance": { + "kwh_m2_chp": 0, + "kwh_m2_dhw": 6.41192, + "kwh_m2_lpg": 0, + "kwh_m2_oil": 0, + "kwh_m2_pvs": 0, + "kwh_m2_ses": 0, + "kwh_m2_coal": 0, + "kwh_m2_wind": 0, + "kwh_m2_biogas": 0, + "kwh_m2_biomass": 0, + "kwh_m2_cooling": 25.6538, + "kwh_m2_heating": 71.9516, + "kwh_m2_lighting": 45.54, + "kwh_m2_supplied": 73.3544, + "kwh_m2_auxiliary": 2.16062, + "kwh_m2_displaced": 0, + "kwh_m2_dual_fuel": 0, + "kwh_m2_equipment": 42.185, + "kwh_m2_smokeless": 0, + "kwh_m2_anthracite": 0, + "kwh_m2_waste_heat": 0, + "kwh_m2_natural_gas": 78.3634, + "kwh_m2_district_heating": 0 + } + } + ] + }, + "or_assessment_start_date": "2020-05-01", + "dec_annual_energy_summary": { + "typical_thermal_use": 1, + "renewables_electrical": 1, + "typical_electrical_use": 1, + "renewables_fuel_thermal": 1, + "annual_energy_use_electrical": 1, + "annual_energy_use_fuel_thermal": 1 + }, + "dec_related_party_disclosure": 4, + "current_energy_efficiency_band": "A" +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/RdSAP-Schema-17.0/epc.json b/backend/epc_api/json_samples/RdSAP-Schema-17.0/epc.json new file mode 100644 index 00000000..e1d48ffc --- /dev/null +++ b/backend/epc_api/json_samples/RdSAP-Schema-17.0/epc.json @@ -0,0 +1,352 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": { + "value": "(another dwelling above)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": { + "value": "System built, with internal insulation", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": { + "value": "(another dwelling below)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": { + "value": "Fully double glazed", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": { + "value": "Low energy lighting in 57% of fixed outlets", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "PT5 4RZ", + "hot_water": { + "description": { + "value": "Electric immersion, off-peak", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 2 + }, + "post_town": "POSTTOWN", + "built_form": 2, + "door_count": 2, + "glazed_area": 1, + "glazing_gap": "16+", + "region_code": 3, + "report_type": 2, + "sap_heating": { + "cylinder_size": 2, + "water_heating_code": 903, + "water_heating_fuel": 29, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 29, + "heat_emitter_type": 0, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2401, + "main_heating_category": 7, + "main_heating_fraction": 1, + "sap_main_heating_code": 402, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": 1, + "cylinder_insulation_type": 0, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.92, + "schema_type": "RdSAP-Schema-17.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": { + "value": "Electric storage heaters", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 1 + } + ], + "dwelling_type": { + "value": "Mid-floor flat", + "language": "1" + }, + "language_code": 1, + "property_type": 2, + "address_line_1": "42, Moria Mines Lane", + "assessment_type": "RdSAP", + "completion_date": "2016-01-12", + "inspection_date": "2016-01-12", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 2, + "top_storey": "N", + "flat_location": 1, + "heat_loss_corridor": 0 + }, + "total_floor_area": 55, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 1, + "pvc_window_frames": "true", + "registration_date": "2016-01-12", + "sap_energy_source": { + "mains_gas": "N", + "meter_type": 1, + "photovoltaic_supply": { + "none_or_no_details": { + "pv_connection": 0, + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": { + "value": "Portable electric heaters (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 240, + "floor_heat_loss": 6, + "roof_construction": 3, + "wall_construction": 8, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "total_floor_area": { + "value": 54.6, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.3, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 23.3, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 3, + "construction_age_band": "D", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": "ND", + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "50mm" + } + ], + "low_energy_lighting": 57, + "solar_water_heating": "N", + "habitable_room_count": 3, + "heating_cost_current": { + "value": 214, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 3.9, + "energy_rating_average": 60, + "energy_rating_current": 66, + "lighting_cost_current": { + "value": 61, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": { + "value": "Manual charge control", + "language": "1" + }, + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 216, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 396, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 158, + "currency": "GBP" + }, + "indicative_cost": "£15 - £30", + "improvement_type": "C", + "improvement_details": { + "improvement_number": 1 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 60 + }, + { + "sequence": 2, + "typical_saving": { + "value": 14, + "currency": "GBP" + }, + "indicative_cost": "£15", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 60 + }, + { + "sequence": 3, + "typical_saving": { + "value": 64, + "currency": "GBP" + }, + "indicative_cost": "£1,200 - £1,800", + "improvement_type": "L2", + "improvement_details": { + "improvement_number": 60 + }, + "improvement_category": 5, + "energy_performance_rating": 78, + "environmental_impact_rating": 64 + }, + { + "sequence": 4, + "typical_saving": { + "value": 22, + "currency": "GBP" + }, + "indicative_cost": "£1,000", + "improvement_type": "X", + "improvement_details": { + "improvement_number": 48 + }, + "improvement_category": 5, + "energy_performance_rating": 79, + "environmental_impact_rating": 66 + } + ], + "co2_emissions_potential": 2.5, + "energy_rating_potential": 79, + "lighting_cost_potential": { + "value": 42, + "currency": "GBP" + }, + "schema_version_original": "LIG-17.0", + "alternative_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 141, + "currency": "GBP" + }, + "improvement_type": "J2", + "improvement_details": { + "improvement_number": 54 + }, + "improvement_category": 6, + "energy_performance_rating": 81, + "environmental_impact_rating": 96 + }, + { + "sequence": 2, + "typical_saving": { + "value": 118, + "currency": "GBP" + }, + "improvement_type": "Z1", + "improvement_details": { + "improvement_number": 51 + }, + "improvement_category": 6, + "energy_performance_rating": 80, + "environmental_impact_rating": 83 + } + ], + "hot_water_cost_potential": { + "value": 154, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 4818, + "space_heating_existing_dwelling": 2415 + }, + "energy_consumption_current": 427, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "9.0.0", + "energy_consumption_potential": 267, + "environmental_impact_current": 48, + "fixed_lighting_outlets_count": 7, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 66, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 72, + "low_energy_fixed_lighting_outlets_count": 4 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/RdSAP-Schema-17.1/epc.json b/backend/epc_api/json_samples/RdSAP-Schema-17.1/epc.json new file mode 100644 index 00000000..35db05fd --- /dev/null +++ b/backend/epc_api/json_samples/RdSAP-Schema-17.1/epc.json @@ -0,0 +1,472 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": { + "value": "Pitched, 100 mm loft insulation", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + { + "description": { + "value": "Pitched, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": { + "value": "Cavity wall, filled cavity", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": { + "value": "Cavity wall, as built, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": { + "value": "Solid, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": { + "value": "Fully double glazed", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": { + "value": "Low energy lighting in 23% of fixed outlets", + "language": "1" + }, + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "postcode": "PT42 5HL", + "hot_water": { + "description": { + "value": "From main system", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "post_town": "POSTTOWN", + "built_form": 1, + "door_count": 4, + "glazed_area": 1, + "glazing_gap": "16+", + "region_code": 17, + "report_type": 2, + "sap_heating": { + "cylinder_size": 2, + "water_heating_code": 901, + "water_heating_fuel": 28, + "cylinder_thermostat": "Y", + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 1 + }, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 28, + "boiler_flue_type": 1, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2104, + "main_heating_category": 2, + "main_heating_fraction": 1, + "mcs_installed_heat_pump": "false", + "main_heating_data_source": 1, + "main_heating_index_number": 9049 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 691, + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 12 + }, + "sap_version": 9.92, + "schema_type": "RdSAP-Schema-17.1", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": { + "value": "Boiler and radiators, oil", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "dwelling_type": { + "value": "Detached house", + "language": "1" + }, + "language_code": 1, + "property_type": 0, + "address_line_1": "15, Hedge Lane", + "address_line_2": "Lower Moria", + "assessment_type": "RdSAP", + "completion_date": "2018-05-29", + "inspection_date": "2018-05-29", + "extensions_count": 1, + "measurement_type": 2, + "total_floor_area": 101, + "transaction_type": 1, + "conservatory_type": 1, + "heated_room_count": 7, + "pvc_window_frames": "true", + "registration_date": "2018-05-27", + "sap_energy_source": { + "mains_gas": "N", + "meter_type": 2, + "photovoltaic_supply": { + "none_or_no_details": { + "pv_connection": 0, + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": { + "value": "Room heaters, electric", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 270, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 48.45, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 0, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 22.3, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "total_floor_area": { + "value": 48.45, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 0, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 28.4, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "G", + "party_wall_construction": "NA", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm" + }, + { + "identifier": "Extension 1", + "wall_dry_lined": "N", + "wall_thickness": 260, + "floor_heat_loss": 7, + "roof_construction": 5, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 21.84, + "quantity": "square metres" + }, + "party_wall_length": 0, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 16.6, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "H", + "party_wall_construction": "NA", + "wall_thickness_measured": "Y", + "roof_insulation_location": 4, + "roof_insulation_thickness": 0, + "wall_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 23, + "solar_water_heating": "N", + "habitable_room_count": 7, + "heating_cost_current": { + "value": 659, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 5.8, + "energy_rating_average": 60, + "energy_rating_current": 53, + "lighting_cost_current": { + "value": 115, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": { + "value": "Programmer and room thermostat", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 470, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 161, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 25, + "currency": "GBP" + }, + "indicative_cost": "£100 - £350", + "improvement_type": "A", + "improvement_details": { + "improvement_number": 5 + }, + "improvement_category": 5, + "energy_performance_rating": 54, + "environmental_impact_rating": 47 + }, + { + "sequence": 2, + "typical_saving": { + "value": 87, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "W2", + "improvement_details": { + "improvement_number": 58 + }, + "improvement_category": 5, + "energy_performance_rating": 58, + "environmental_impact_rating": 52 + }, + { + "sequence": 3, + "typical_saving": { + "value": 16, + "currency": "GBP" + }, + "indicative_cost": "£15 - £30", + "improvement_type": "C", + "improvement_details": { + "improvement_number": 3 + }, + "improvement_category": 5, + "energy_performance_rating": 59, + "environmental_impact_rating": 53 + }, + { + "sequence": 4, + "typical_saving": { + "value": 42, + "currency": "GBP" + }, + "indicative_cost": "£50", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 61, + "environmental_impact_rating": 54 + }, + { + "sequence": 5, + "typical_saving": { + "value": 38, + "currency": "GBP" + }, + "indicative_cost": "£350 - £450", + "improvement_type": "G", + "improvement_details": { + "improvement_number": 13 + }, + "improvement_category": 5, + "energy_performance_rating": 62, + "environmental_impact_rating": 56 + }, + { + "sequence": 6, + "typical_saving": { + "value": 48, + "currency": "GBP" + }, + "indicative_cost": "£2,200 - £3,000", + "improvement_type": "I", + "improvement_details": { + "improvement_number": 20 + }, + "improvement_category": 5, + "energy_performance_rating": 65, + "environmental_impact_rating": 59 + }, + { + "sequence": 7, + "typical_saving": { + "value": 41, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 68, + "environmental_impact_rating": 62 + }, + { + "sequence": 8, + "typical_saving": { + "value": 27, + "currency": "GBP" + }, + "indicative_cost": "£2,000", + "improvement_type": "X", + "improvement_details": { + "improvement_number": 48 + }, + "improvement_category": 5, + "energy_performance_rating": 69, + "environmental_impact_rating": 64 + }, + { + "sequence": 9, + "typical_saving": { + "value": 297, + "currency": "GBP" + }, + "indicative_cost": "£5,000 - £8,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 78, + "environmental_impact_rating": 72 + } + ], + "co2_emissions_potential": 2.7, + "energy_rating_potential": 78, + "lighting_cost_potential": { + "value": 65, + "currency": "GBP" + }, + "schema_version_original": "LIG-17.1", + "hot_water_cost_potential": { + "value": 77, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 3301, + "impact_of_loft_insulation": -565, + "space_heating_existing_dwelling": 11351 + }, + "energy_consumption_current": 234, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "v92.0.1.1", + "energy_consumption_potential": 95, + "environmental_impact_current": 46, + "fixed_lighting_outlets_count": 13, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 72, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 57, + "low_energy_fixed_lighting_outlets_count": 3 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/RdSAP-Schema-18.0/epc.json b/backend/epc_api/json_samples/RdSAP-Schema-18.0/epc.json new file mode 100644 index 00000000..44761b86 --- /dev/null +++ b/backend/epc_api/json_samples/RdSAP-Schema-18.0/epc.json @@ -0,0 +1,413 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": { + "value": "Pitched, 100 mm loft insulation", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + { + "description": { + "value": "Flat, insulated", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + { + "description": { + "value": "Roof room(s), insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": { + "value": "Solid brick, as built, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + }, + { + "description": { + "value": "Cavity wall, as built, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": { + "value": "Solid, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": { + "value": "Fully double glazed", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": { + "value": "Low energy lighting in 67% of fixed outlets", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "PT11 4RF", + "hot_water": { + "description": { + "value": "From main system", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "POSTTOWN", + "built_form": 4, + "door_count": 2, + "glazed_area": 1, + "glazing_gap": 12, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 16137 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.92, + "schema_type": "RdSAP-Schema-18.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": { + "value": "Boiler and radiators, mains gas", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": { + "value": "Mid-terrace house", + "language": "1" + }, + "language_code": 1, + "property_type": 0, + "address_line_1": "1, Bagshot Lane", + "address_line_2": "Village", + "assessment_type": "RdSAP", + "completion_date": "2017-03-19", + "inspection_date": "2017-03-19", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 93, + "transaction_type": 5, + "conservatory_type": 1, + "heated_room_count": 5, + "pvc_window_frames": "true", + "registration_date": "2017-03-19", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 1, + "photovoltaic_supply": { + "none_or_no_details": { + "pv_connection": 0, + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": { + "value": "None", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 330, + "floor_heat_loss": 7, + "sap_room_in_roof": { + "floor_area": { + "value": 9, + "quantity": "square metres" + }, + "insulation": "AB", + "roof_room_connected": "N", + "construction_age_band": "G" + }, + "roof_construction": 4, + "wall_construction": 3, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 29.12, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 11.2, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 5.2, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "total_floor_area": { + "value": 29.12, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 11.2, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 10.4, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "C", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm", + "wall_insulation_thickness": "NI" + }, + { + "identifier": "Extension", + "wall_dry_lined": "N", + "wall_thickness": 290, + "floor_heat_loss": 7, + "roof_construction": 1, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 15.6, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 6, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 5.2, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "G", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 6, + "wall_insulation_thickness": "NI", + "flat_roof_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 67, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": { + "value": 619, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 3.4, + "energy_rating_average": 60, + "energy_rating_current": 69, + "lighting_cost_current": { + "value": 81, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": { + "value": "Programmer, room thermostat and TRVs", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 534, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 100, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 88, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £14,000", + "improvement_type": "Q", + "improvement_details": { + "improvement_number": 7 + }, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 71 + }, + { + "sequence": 2, + "typical_saving": { + "value": 18, + "currency": "GBP" + }, + "indicative_cost": "£15", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 73, + "environmental_impact_rating": 71 + }, + { + "sequence": 3, + "typical_saving": { + "value": 33, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 73 + }, + { + "sequence": 4, + "typical_saving": { + "value": 302, + "currency": "GBP" + }, + "indicative_cost": "£5,000 - £8,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 85, + "environmental_impact_rating": 83 + } + ], + "co2_emissions_potential": 1.7, + "energy_rating_potential": 85, + "lighting_cost_potential": { + "value": 61, + "currency": "GBP" + }, + "schema_version_original": "LIG-17.0", + "hot_water_cost_potential": { + "value": 68, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2087, + "impact_of_loft_insulation": -214, + "impact_of_solid_wall_insulation": -1864, + "space_heating_existing_dwelling": 10483 + }, + "energy_consumption_current": 230, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "2.0.0.0", + "energy_consumption_potential": 115, + "environmental_impact_current": 66, + "fixed_lighting_outlets_count": 9, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 83, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 41, + "low_energy_fixed_lighting_outlets_count": 6 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/RdSAP-Schema-19.0/epc.json b/backend/epc_api/json_samples/RdSAP-Schema-19.0/epc.json new file mode 100644 index 00000000..ce4e44b4 --- /dev/null +++ b/backend/epc_api/json_samples/RdSAP-Schema-19.0/epc.json @@ -0,0 +1,386 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": { + "value": "Pitched, 150 mm loft insulation", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": { + "value": "Pitched, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": { + "value": "Cavity wall, filled cavity", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + { + "description": { + "value": "Cavity wall, as built, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": { + "value": "Solid, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 3, + "window": { + "description": { + "value": "Fully double glazed", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": { + "value": "Low energy lighting in 87% of fixed outlets", + "language": "1" + }, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "A1 1AA", + "hot_water": { + "description": { + "value": "From main system", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Town", + "built_form": 2, + "door_count": 1, + "glazed_area": 1, + "region_code": 19, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "secondary_fuel_type": 9, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 15274 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 631, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.94, + "schema_type": "RdSAP-Schema-19.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": { + "value": "Boiler and radiators, mains gas", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": { + "value": "Semi-detached house", + "language": "1" + }, + "language_code": 1, + "property_type": 0, + "address_line_1": "15, Address Lane", + "address_line_2": "New Town", + "assessment_type": "RdSAP", + "completion_date": "2020-06-04", + "inspection_date": "2020-06-03", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 94, + "transaction_type": 8, + "conservatory_type": 2, + "heated_room_count": 5, + "pvc_window_frames": "false", + "registration_date": "2020-06-04", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 3, + "photovoltaic_supply": { + "none_or_no_details": { + "pv_connection": 0, + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": { + "value": "Room heaters, dual fuel (mineral and wood)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11, + 9 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.47, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 39.91, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 8.81, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 13.65, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.36, + "quantity": "metres" + }, + "total_floor_area": { + "value": 39.91, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 8.81, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 17.87, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "D", + "party_wall_construction": 1, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "150mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + }, + { + "identifier": "Extension", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 5, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.34, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 13.88, + "quantity": "square metres" + }, + "party_wall_length": 0, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 10.8, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "G", + "party_wall_construction": "NA", + "wall_thickness_measured": "Y", + "roof_insulation_location": 4, + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 87, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": { + "value": 666, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 3.8, + "energy_rating_average": 60, + "energy_rating_current": 66, + "lighting_cost_current": { + "value": 81, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": { + "value": "Programmer, room thermostat and TRVs", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 615, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 107, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 51, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "W2", + "improvement_details": { + "improvement_number": 58 + }, + "improvement_category": 5, + "energy_performance_rating": 68, + "environmental_impact_rating": 65 + }, + { + "sequence": 2, + "typical_saving": { + "value": 33, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 70, + "environmental_impact_rating": 67 + }, + { + "sequence": 3, + "typical_saving": { + "value": 316, + "currency": "GBP" + }, + "indicative_cost": "£3,500 - £5,500", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 79, + "environmental_impact_rating": 75 + } + ], + "co2_emissions_potential": 2.4, + "energy_rating_potential": 79, + "lighting_cost_potential": { + "value": 81, + "currency": "GBP" + }, + "schema_version_original": "LIG-19.0", + "hot_water_cost_potential": { + "value": 74, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2207, + "impact_of_loft_insulation": -394, + "space_heating_existing_dwelling": 9825 + }, + "energy_consumption_current": 222, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "2.1.1.0", + "energy_consumption_potential": 137, + "environmental_impact_current": 62, + "fixed_lighting_outlets_count": 15, + "windows_transmission_details": { + "u_value": 3.1, + "data_source": 2, + "solar_transmittance": 0.76 + }, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 75, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 41, + "low_energy_fixed_lighting_outlets_count": 13 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/RdSAP-Schema-20.0.0/epc.json b/backend/epc_api/json_samples/RdSAP-Schema-20.0.0/epc.json new file mode 100644 index 00000000..bcb3618f --- /dev/null +++ b/backend/epc_api/json_samples/RdSAP-Schema-20.0.0/epc.json @@ -0,0 +1,340 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Pitched, 25 mm loft insulation", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + { + "description": "Pitched, 250 mm loft insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": "Solid brick, as built, no insulation (assumed)", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + }, + { + "description": "Cavity wall, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Suspended, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + { + "description": "Solid, insulated (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "addendum": { + "stone_walls": "true", + "system_build": "true", + "addendum_numbers": [ + 1, + 8 + ] + }, + "lighting": { + "description": "Low energy lighting in 50% of fixed outlets", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "A0 0AA", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Whitbury", + "built_form": 2, + "door_count": 2, + "glazed_area": 1, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "secondary_fuel_type": 25, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 101, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17507 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.8, + "sap_windows": [ + { + "orientation": 1, + "window_area": 200.1, + "window_type": 2, + "glazing_type": 1, + "window_location": 0 + }, + { + "orientation": 2, + "window_area": 180.2, + "window_type": 1, + "glazing_type": 2, + "window_location": 1 + } + ], + "schema_type": "RdSAP-Schema-20.0.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Boiler and radiators, anthracite", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 1 + }, + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "1 Some Street", + "assessment_type": "RdSAP", + "completion_date": "2020-05-04", + "inspection_date": "2020-05-04", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "storey_count": 3, + "flat_location": 1, + "heat_loss_corridor": 2, + "unheated_corridor_length": 10 + }, + "total_floor_area": 55, + "transaction_type": 1, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2020-05-04", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "photovoltaic_supply": { + "none_or_no_details": { + "pv_connection": 0, + "percent_roof_area": 50 + } + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": "Room heaters, electric", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "sap_room_in_roof": { + "floor_area": 100, + "insulation": "AB", + "roof_room_connected": "N", + "construction_age_band": "B" + }, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.45, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 45.82, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.9, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 19.5, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.59, + "quantity": "metres" + }, + "total_floor_area": { + "value": 45.82, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.9, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 19.5, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "K", + "party_wall_construction": 0, + "wall_thickness_measured": "N", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 100, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": 365.98, + "insulated_door_count": 2, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 50, + "lighting_cost_current": 123.45, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": "Time and temperature zone control", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "multiple_glazing_type": 2, + "open_fireplaces_count": 0, + "heating_cost_potential": 250.34, + "hot_water_cost_current": 200.4, + "insulated_door_u_value": 3, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 360, + "indicative_cost": "£100 - £350", + "improvement_type": "Z3", + "improvement_details": { + "improvement_number": 5 + }, + "improvement_category": 6, + "energy_performance_rating": 50, + "environmental_impact_rating": 50 + }, + { + "sequence": 2, + "typical_saving": 99, + "indicative_cost": 2000, + "improvement_type": "Z2", + "improvement_details": { + "improvement_number": 1 + }, + "improvement_category": 2, + "energy_performance_rating": 60, + "environmental_impact_rating": 64 + }, + { + "sequence": 3, + "typical_saving": 99, + "indicative_cost": 1000, + "improvement_type": "Z2", + "improvement_details": { + "improvement_texts": { + "improvement_summary": "An improvement summary", + "improvement_description": "An improvement desc" + } + }, + "improvement_category": 2, + "energy_performance_rating": 60, + "environmental_impact_rating": 64 + } + ], + "co2_emissions_potential": 1.4, + "energy_rating_potential": 72, + "lighting_cost_potential": 84.23, + "schema_version_original": "SAP-19.0", + "hot_water_cost_potential": 180.43, + "renewable_heat_incentive": { + "water_heating": 2285, + "impact_of_loft_insulation": -2114, + "impact_of_cavity_insulation": -122, + "impact_of_solid_wall_insulation": -3560, + "space_heating_existing_dwelling": 13120 + }, + "energy_consumption_current": 230, + "multiple_glazed_proportion": 100, + "calculation_software_version": "13.05r16", + "energy_consumption_potential": 88, + "environmental_impact_current": 52, + "fixed_lighting_outlets_count": 16, + "windows_transmission_details": { + "u_value": 2, + "data_source": 2, + "solar_transmittance": 0.72 + }, + "multiple_glazed_proportion_nr": "NR", + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 74, + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 20, + "low_energy_fixed_lighting_outlets_count": 16 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/RdSAP-Schema-21.0.0/epc.json b/backend/epc_api/json_samples/RdSAP-Schema-21.0.0/epc.json new file mode 100644 index 00000000..3f9793b0 --- /dev/null +++ b/backend/epc_api/json_samples/RdSAP-Schema-21.0.0/epc.json @@ -0,0 +1,403 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Pitched, 25 mm loft insulation", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + { + "description": "Pitched, 250 mm loft insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": "Solid brick, as built, no insulation (assumed)", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + }, + { + "description": "Cavity wall, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Suspended, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + { + "description": "Solid, insulated (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "addendum": { + "stone_walls": "true", + "system_build": "true", + "addendum_numbers": [ + 1, + 8 + ] + }, + "lighting": { + "description": "Low energy lighting in 50% of fixed outlets", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "A0 0AA", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Whitbury", + "built_form": 2, + "door_count": 3, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "shower_outlets": { + "shower_outlet": { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + }, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "wwhrs_index_number1": 1, + "wwhrs_index_number2": 2 + }, + "secondary_fuel_type": 25, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "boiler_ignition_type": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 101, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17507 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "false", + "glazing_gap": 6, + "orientation": 1, + "window_type": 2, + "frame_factor": 1.0, + "glazing_type": 14, + "window_width": 1.2, + "window_height": 2.0, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 6, + "permanent_shutters_present": "N", + "window_transmission_details": { + "u_value": 1.0, + "data_source": 2, + "solar_transmittance": 1.0 + }, + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "false", + "glazing_gap": 6, + "orientation": 1, + "window_type": 2, + "frame_factor": 1.0, + "glazing_type": 1, + "window_width": 1.2, + "window_height": 2.0, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 6, + "permanent_shutters_present": "N", + "window_transmission_details": { + "u_value": 1.0, + "data_source": 2, + "solar_transmittance": 1.0 + }, + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.0", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, anthracite", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 1 + }, + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "pressure_test": 6, + "property_type": 0, + "address_line_1": "1 Some Street", + "assessment_type": "RdSAP", + "completion_date": "2023-12-01", + "inspection_date": "2023-12-01", + "wet_rooms_count": 0, + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "storey_count": 3, + "flat_location": 1, + "heat_loss_corridor": 2, + "unheated_corridor_length": 10 + }, + "total_floor_area": 55, + "transaction_type": 16, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2023-12-01", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_batteries": { + "pv_battery": { + "battery_capacity": 5 + } + }, + "pv_connection": 0, + "pv_battery_count": 1, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "wind_turbine_details": { + "hub_height": 0, + "rotor_diameter": 0 + }, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 4, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": "Room heaters, electric", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "sap_room_in_roof": { + "floor_area": 100, + "construction_age_band": "B" + }, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.45, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 45.82, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.9, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 19.5, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.59, + "quantity": "metres" + }, + "total_floor_area": { + "value": 45.82, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.9, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 19.5, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "M", + "sap_alternative_wall_1": { + "wall_area": 10.4, + "wall_dry_lined": "N", + "wall_construction": 4, + "wall_insulation_type": 2, + "wall_thickness_measured": "N" + }, + "sap_alternative_wall_2": { + "wall_area": 10.8, + "wall_dry_lined": "N", + "wall_construction": 4, + "wall_insulation_type": 2, + "wall_thickness_measured": "N" + }, + "party_wall_construction": 0, + "wall_thickness_measured": "N", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "open_chimneys_count": 1, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": 365.98, + "insulated_door_count": 2, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 50, + "lighting_cost_current": 123.45, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": "Time and temperature zone control", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": 250.34, + "hot_water_cost_current": 200.4, + "insulated_door_u_value": 3, + "mechanical_ventilation": 6, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 360, + "indicative_cost": "£100 - £350", + "improvement_type": "Z3", + "improvement_details": { + "improvement_number": 5 + }, + "improvement_category": 6, + "energy_performance_rating": 50, + "environmental_impact_rating": 50 + }, + { + "sequence": 2, + "typical_saving": 99, + "indicative_cost": 2000, + "improvement_type": "Z2", + "improvement_details": { + "improvement_number": 1 + }, + "improvement_category": 2, + "energy_performance_rating": 60, + "environmental_impact_rating": 64 + }, + { + "sequence": 3, + "typical_saving": 99, + "indicative_cost": 1000, + "improvement_type": "Z2", + "improvement_details": { + "improvement_texts": { + "improvement_description": "Improvement desc" + } + }, + "improvement_category": 2, + "energy_performance_rating": 60, + "environmental_impact_rating": 64 + } + ], + "co2_emissions_potential": 1.4, + "energy_rating_potential": 72, + "lighting_cost_potential": 84.23, + "schema_version_original": "SAP-19.0", + "hot_water_cost_potential": 180.43, + "renewable_heat_incentive": { + "water_heating": 2285, + "impact_of_loft_insulation": -2114, + "impact_of_cavity_insulation": -122, + "impact_of_solid_wall_insulation": -3560, + "space_heating_existing_dwelling": 13120 + }, + "draughtproofed_door_count": 1, + "mechanical_vent_duct_type": 3, + "energy_consumption_current": 230, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "13.05r16", + "energy_consumption_potential": 88, + "environmental_impact_current": 52, + "windows_transmission_details": { + "u_value": 2, + "data_source": 2, + "solar_transmittance": 0.72 + }, + "cfl_fixed_lighting_bulbs_count": 5, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 74, + "led_fixed_lighting_bulbs_count": 10, + "mechanical_vent_duct_placement": 2, + "mechanical_vent_duct_insulation": 2, + "potential_energy_efficiency_band": "C", + "pressure_test_certificate_number": 0, + "mechanical_ventilation_index_number": 12, + "co2_emissions_current_per_floor_area": 20, + "low_energy_fixed_lighting_bulbs_count": 16, + "mechanical_vent_duct_insulation_level": 2, + "mechanical_vent_measured_installation": "false", + "incandescent_fixed_lighting_bulbs_count": 5 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/RdSAP-Schema-21.0.1/epc.json b/backend/epc_api/json_samples/RdSAP-Schema-21.0.1/epc.json new file mode 100644 index 00000000..166e60a0 --- /dev/null +++ b/backend/epc_api/json_samples/RdSAP-Schema-21.0.1/epc.json @@ -0,0 +1,446 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": { + "value": "Pitched, 25 mm loft insulation", + "language": "1" + }, + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + { + "description": { + "value": "Pitched, 250 mm loft insulation", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": { + "value": "Solid brick, as built, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + }, + { + "description": { + "value": "Cavity wall, as built, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": { + "value": "Suspended, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + { + "description": { + "value": "Solid, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": { + "value": "Fully double glazed", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "addendum": { + "stone_walls": "true", + "system_build": "true", + "addendum_numbers": [ + 1, + 13 + ] + }, + "lighting": { + "description": { + "value": "Low energy lighting in 50% of fixed outlets", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "A0 0AA", + "hot_water": { + "description": { + "value": "From main system", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Whitbury", + "built_form": 2, + "door_count": 3, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "shower_outlets": { + "shower_outlet": { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + }, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "wwhrs_index_number1": 1, + "wwhrs_index_number2": 2 + }, + "secondary_fuel_type": 25, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "boiler_ignition_type": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 101, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17507 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "false", + "glazing_gap": 6, + "orientation": 1, + "window_type": 2, + "frame_factor": 1.0, + "glazing_type": 14, + "window_width": 1.2, + "window_height": 2.0, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 6, + "permanent_shutters_present": "N", + "window_transmission_details": { + "u_value": 1.0, + "data_source": 2, + "solar_transmittance": 1.0 + }, + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "false", + "glazing_gap": 6, + "orientation": 1, + "window_type": 2, + "frame_factor": 1.0, + "glazing_type": 1, + "window_width": 1.2, + "window_height": 2.0, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 6, + "permanent_shutters_present": "N", + "window_transmission_details": { + "u_value": 1.0, + "data_source": 2, + "solar_transmittance": 1.0 + }, + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": { + "value": "Boiler and radiators, anthracite", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 1 + }, + { + "description": { + "value": "Boiler and radiators, mains gas", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "pressure_test": 6, + "property_type": 0, + "address_line_1": "1 Some Street", + "assessment_type": "RdSAP", + "completion_date": "2025-04-04", + "inspection_date": "2025-04-04", + "wet_rooms_count": 0, + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "storey_count": 3, + "flat_location": 1, + "heat_loss_corridor": 2, + "unheated_corridor_length": { + "value": 10, + "quantity": "metres" + } + }, + "total_floor_area": 55, + "transaction_type": 16, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2025-04-04", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_batteries": { + "pv_battery": { + "battery_capacity": 5 + } + }, + "pv_connection": 0, + "pv_battery_count": 1, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "wind_turbine_details": { + "hub_height": 0, + "rotor_diameter": 0 + }, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 4, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": { + "value": "Room heaters, electric", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "sap_room_in_roof": { + "floor_area": 100, + "construction_age_band": "B" + }, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.45, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 45.82, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.9, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 19.5, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.59, + "quantity": "metres" + }, + "total_floor_area": { + "value": 45.82, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.9, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 19.5, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "M", + "sap_alternative_wall_1": { + "wall_area": 10.4, + "wall_dry_lined": "N", + "wall_construction": 4, + "wall_insulation_type": 2, + "wall_thickness_measured": "N" + }, + "sap_alternative_wall_2": { + "wall_area": 10.8, + "wall_dry_lined": "N", + "wall_construction": 4, + "wall_insulation_type": 2, + "wall_thickness_measured": "N" + }, + "party_wall_construction": 0, + "wall_thickness_measured": "N", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "open_chimneys_count": 1, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": 365.98, + "insulated_door_count": 2, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 50, + "lighting_cost_current": 123.45, + "main_heating_controls": [ + { + "description": { + "value": "Programmer, room thermostat and TRVs", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": { + "value": "Time and temperature zone control", + "language": "1" + }, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": 250.34, + "hot_water_cost_current": 200.4, + "insulated_door_u_value": 3, + "mechanical_ventilation": 6, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 139, + "indicative_cost": "£220 - £250", + "improvement_type": "G", + "improvement_details": { + "improvement_number": 66 + }, + "improvement_category": 5, + "energy_performance_rating": 70, + "environmental_impact_rating": 70 + }, + { + "sequence": 2, + "typical_saving": 49, + "indicative_cost": "£4,000 - £7,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 73 + }, + { + "sequence": 3, + "typical_saving": 286, + "indicative_cost": "£8,000 - £10,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 83, + "environmental_impact_rating": 75 + } + ], + "co2_emissions_potential": 1.4, + "energy_rating_potential": 72, + "lighting_cost_potential": 84.23, + "schema_version_original": "SAP-19.0", + "hot_water_cost_potential": 180.43, + "renewable_heat_incentive": { + "water_heating": 2285, + "impact_of_loft_insulation": -2114, + "impact_of_cavity_insulation": -122, + "impact_of_solid_wall_insulation": -3560, + "space_heating_existing_dwelling": 13120 + }, + "draughtproofed_door_count": 1, + "mechanical_vent_duct_type": 3, + "energy_consumption_current": 230, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "13.05r16", + "energy_consumption_potential": 88, + "environmental_impact_current": 52, + "windows_transmission_details": { + "u_value": 2, + "data_source": 2, + "solar_transmittance": 0.72 + }, + "cfl_fixed_lighting_bulbs_count": 5, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 74, + "led_fixed_lighting_bulbs_count": 10, + "mechanical_vent_duct_placement": 2, + "mechanical_vent_duct_insulation": 2, + "potential_energy_efficiency_band": "C", + "pressure_test_certificate_number": 0, + "mechanical_ventilation_index_number": 12, + "co2_emissions_current_per_floor_area": 20, + "low_energy_fixed_lighting_bulbs_count": 16, + "mechanical_vent_duct_insulation_level": 2, + "mechanical_vent_measured_installation": "false", + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-16.0/rdsap.json b/backend/epc_api/json_samples/SAP-Schema-16.0/rdsap.json new file mode 100644 index 00000000..f1caacf4 --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-16.0/rdsap.json @@ -0,0 +1,299 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Pitched, 300+ mm loft insulation", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": "Cavity wall, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "windows": [ + { + "description": "Fully double glazed", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "lighting": { + "description": "Low energy lighting in 67% of fixed outlets", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "AA1 1AA", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Town", + "built_form": 1, + "door_count": 2, + "glazed_area": 1, + "region_code": 12, + "report_type": 2, + "sap_heating": { + "wwhrs": { + "rooms_with_bath_and_or_shower": 2, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "boiler_index_number": 10265, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "main_heating_data_source": 1 + } + ], + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.91, + "schema_type": "SAP-Schema-16.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": "Detached bungalow", + "language_code": 1, + "property_type": 1, + "address_line_1": "11, Street Road", + "schema_version": "LIG-16.0", + "assessment_type": "RdSAP", + "completion_date": "2012-09-30", + "inspection_date": "2012-09-27", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 107, + "transaction_type": 1, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2012-09-30", + "restricted_access": 0, + "sap_energy_source": { + "main_gas": "Y", + "meter_type": 2, + "photovoltaic_supply": { + "percent_roof_area": 0 + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.33, + "floor_insulation": 1, + "total_floor_area": 97.96, + "floor_construction": 1, + "heat_loss_perimeter": 37 + } + ], + "wall_insulation_type": 2, + "construction_age_band": "E", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "300mm+" + }, + { + "identifier": "Extension", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "roof_construction": 5, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 3.08, + "floor_insulation": 1, + "total_floor_area": 8.63, + "floor_construction": 1, + "heat_loss_perimeter": 7.42 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "K", + "wall_thickness_measured": "N", + "roof_insulation_location": 4, + "roof_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 67, + "solar_water_heating": "N", + "bedf_revision_number": 329, + "habitable_room_count": 5, + "heating_cost_current": { + "value": 494, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.9, + "energy_rating_average": 60, + "energy_rating_current": 73, + "lighting_cost_current": { + "value": 73, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 2, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 428, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 90, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 68, + "currency": "GBP" + }, + "indicative_cost": "£800 - £1,200", + "improvement_type": "W", + "improvement_details": { + "improvement_number": 47 + }, + "improvement_category": 5, + "energy_performance_rating": 76, + "environmental_impact_rating": 76 + }, + { + "sequence": 2, + "typical_saving": { + "value": 16, + "currency": "GBP" + }, + "indicative_cost": "£25", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 76, + "environmental_impact_rating": 76 + }, + { + "sequence": 3, + "typical_saving": { + "value": 26, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 77, + "environmental_impact_rating": 78 + }, + { + "sequence": 4, + "typical_saving": { + "value": 238, + "currency": "GBP" + }, + "indicative_cost": "£9,000 - £14,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 86, + "environmental_impact_rating": 86 + } + ], + "co2_emissions_potential": 1.4, + "energy_rating_potential": 86, + "lighting_cost_potential": { + "value": 55, + "currency": "GBP" + }, + "hot_water_cost_potential": { + "value": 64, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2264, + "space_heating_existing_dwelling": 9324 + }, + "seller_commission_report": "Y", + "energy_consumption_current": 144, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": 8.0, + "energy_consumption_potential": 64, + "environmental_impact_current": 72, + "fixed_lighting_outlets_count": 15, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 86, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 28, + "low_energy_fixed_lighting_outlets_count": 10 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-16.0/sap.json b/backend/epc_api/json_samples/SAP-Schema-16.0/sap.json new file mode 100644 index 00000000..3fdf06bc --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-16.0/sap.json @@ -0,0 +1,348 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "(other premises above)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.34 W/m²K", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.25 W/m²K", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "status": "entered", + "windows": { + "description": "Fully double glazed", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "lighting": { + "description": "Low energy lighting in 57% of fixed outlets", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "AA1 1AA", + "data_type": 2, + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Town", + "built_form": 1, + "living_area": 31.53, + "orientation": 0, + "region_code": 17, + "report_type": 3, + "sap_heating": { + "thermal_store": 1, + "has_solar_panel": "false", + "water_fuel_type": 1, + "water_heating_code": 901, + "hot_water_store_size": 145, + "main_heating_details": [ + { + "main_fuel_type": 1, + "heat_emitter_type": 1, + "boiler_index_number": 9206, + "is_flue_fan_present": "true", + "main_heating_number": 1, + "main_heating_control": 2106, + "is_interlocked_system": "true", + "main_heating_category": 2, + "main_heating_fraction": 1, + "main_heating_data_source": 1, + "has_delayed_start_thermostat": "false", + "load_or_weather_compensation": 0, + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "true", + "has_cylinder_thermostat": "true", + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1, + "is_cylinder_in_heated_space": "true", + "is_hot_water_separately_timed": "true", + "is_primary_pipework_insulated": "true", + "hot_water_store_insulation_type": 1, + "hot_water_store_heat_loss_source": 3, + "hot_water_store_insulation_thickness": 50 + }, + "sap_version": 9.9, + "schema_type": "SAP-Schema-16.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "Air permeability 2.7 m³/h.m² (as tested)", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "dwelling_type": "Mid-floor flat", + "language_code": 1, + "property_type": 2, + "address_line_1": "28, Place Drive", + "schema_version": "LIG-16.0", + "assessment_date": "2012-09-29", + "assessment_type": "SAP", + "completion_date": "2012-09-29", + "inspection_date": "2012-09-29", + "sap_ventilation": { + "psv_count": 0, + "pressure_test": 1, + "air_permeability": 2.72, + "open_flues_count": 0, + "ventilation_type": 1, + "extract_fans_count": 3, + "open_fireplaces_count": 0, + "sheltered_sides_count": 2, + "flueless_gas_fires_count": 0 + }, + "sap_data_version": 9.81, + "sap_flat_details": { + "level": 2 + }, + "total_floor_area": 80, + "transaction_type": 6, + "conservatory_type": 1, + "registration_date": "2012-09-29", + "restricted_access": 0, + "sap_energy_source": { + "electricity_tariff": 1, + "wind_turbines_count": 0, + "wind_turbine_terrain_type": 1, + "fixed_lighting_outlets_count": 7, + "low_energy_fixed_lighting_outlets_count": 4, + "low_energy_fixed_lighting_outlets_percentage": 57 + }, + "sap_opening_types": [ + { + "name": 1, + "type": 2, + "u_value": 2, + "data_source": 2, + "description": "Door", + "glazing_type": 6 + }, + { + "name": 2, + "type": 4, + "u_value": 1.8, + "data_source": 2, + "description": "Window", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + } + ], + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "sap_walls": [ + { + "name": "Wall 1", + "u_value": 0.35, + "wall_type": 2, + "description": "Wall 5 render", + "total_wall_area": 58.29, + "is_curtain_walling": "false" + }, + { + "name": "Wall 2", + "u_value": 0.27, + "wall_type": 2, + "description": "Wall 2 common area", + "total_wall_area": 9.28, + "is_curtain_walling": "false" + }, + { + "name": "Wall 3", + "u_value": 0.35, + "wall_type": 2, + "description": "Wall 3 lift shaft", + "total_wall_area": 8.9, + "is_curtain_walling": "false" + } + ], + "identifier": "Main Dwelling", + "overshading": 2, + "sap_openings": [ + { + "name": 1, + "type": 1, + "width": 0.91, + "height": 2.1, + "location": "Wall 2", + "orientation": 0 + }, + { + "name": 2, + "type": 2, + "width": 1.15, + "height": 1.4, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 3, + "type": 2, + "width": 0.9, + "height": 1.4, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 4, + "type": 2, + "width": 1.3, + "height": 1.4, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 5, + "type": 2, + "width": 1.3, + "height": 1.4, + "location": "Wall 1", + "orientation": 5 + }, + { + "name": 6, + "type": 2, + "width": 1.15, + "height": 1.4, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 7, + "type": 2, + "width": 0.55, + "height": 1.05, + "location": "Wall 1", + "orientation": 4 + }, + { + "name": 8, + "type": 2, + "width": 1.15, + "height": 1.4, + "location": "Wall 1", + "orientation": 4 + }, + { + "name": 9, + "type": 2, + "width": 1.15, + "height": 1.4, + "location": "Wall 1", + "orientation": 4 + }, + { + "name": 10, + "type": 2, + "width": 1.3, + "height": 1.05, + "location": "Wall 1", + "orientation": 4 + } + ], + "construction_year": 2011, + "sap_thermal_bridges": { + "thermal_bridge_code": 4, + "user_defined_y_value": 0.08, + "calculation_reference": "SAP 2005 record" + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 1, + "u_value": 0.25, + "floor_type": 4, + "description": "Floor 2", + "storey_height": 2.32, + "heat_loss_area": 5.19, + "total_floor_area": 79.9 + } + ], + "thermal_mass_parameter": 250 + } + ], + "bedf_revision_number": 329, + "heating_cost_current": 213, + "co2_emissions_current": 1.3, + "energy_rating_average": 60, + "energy_rating_current": 82, + "lighting_cost_current": 66, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": 215, + "hot_water_cost_current": 94, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 18, + "indicative_cost": "£8", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 1, + "energy_performance_rating": 83, + "environmental_impact_rating": 87 + } + ], + "co2_emissions_potential": 1.3, + "energy_rating_potential": 83, + "lighting_cost_potential": 46, + "hot_water_cost_potential": 94, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 1780, + "water_heating": 2306 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 87, + "has_fixed_air_conditioning": "false", + "calculation_software_version": 5.4, + "energy_consumption_potential": 82, + "environmental_impact_current": 86, + "current_energy_efficiency_band": "B", + "environmental_impact_potential": 87, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 16 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-16.1/rdsap.json b/backend/epc_api/json_samples/SAP-Schema-16.1/rdsap.json new file mode 100644 index 00000000..e6a3bb0a --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-16.1/rdsap.json @@ -0,0 +1,400 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Pitched, 300+ mm loft insulation", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": "System built, as built, no insulation (assumed)", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + }, + { + "description": "Solid brick, as built, no insulation (assumed)", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + } + ], + "floors": [ + { + "description": "Suspended, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "windows": [ + { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "addendum": { + "system_build": "true" + }, + "lighting": { + "description": "Low energy lighting in 36% of fixed outlets", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "postcode": "AA1 1AA", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Town", + "built_form": 4, + "door_count": 2, + "glazed_area": 1, + "region_code": 14, + "report_type": 2, + "sap_heating": { + "wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "cylinder_size": 2, + "water_heating_code": 901, + "water_heating_fuel": 26, + "cylinder_thermostat": "Y", + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "main_heating_number": 1, + "main_heating_control": 2103, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 101, + "main_heating_data_source": 2 + } + ], + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 38 + }, + "sap_version": 9.91, + "schema_type": "SAP-Schema-16.1", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "15, What the Dickens Road", + "schema_version": "LIG-16.1", + "assessment_type": "RdSAP", + "completion_date": "2013-01-13", + "inspection_date": "2013-01-09", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 72, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2013-01-13", + "restricted_access": 0, + "sap_energy_source": { + "main_gas": "Y", + "meter_type": 2, + "photovoltaic_supply": { + "percent_roof_area": 0 + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 1 + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 9 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_alternative_wall": { + "wall_area": 18.9, + "wall_dry_lined": "N", + "wall_construction": 3, + "wall_insulation_type": 4, + "wall_thickness_measured": "N" + }, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.43, + "floor_insulation": 1, + "total_floor_area": 34.2, + "floor_construction": 2, + "heat_loss_perimeter": 14.9 + }, + { + "floor": 1, + "room_height": 2.47, + "total_floor_area": 34.2, + "heat_loss_perimeter": 9.84 + } + ], + "wall_insulation_type": 2, + "construction_age_band": "C", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "300mm+" + }, + { + "identifier": "Extension 1", + "wall_dry_lined": "N", + "wall_thickness": 200, + "floor_heat_loss": 7, + "roof_construction": 1, + "wall_construction": 8, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.26, + "floor_insulation": 0, + "total_floor_area": 3.5, + "floor_construction": 0, + "heat_loss_perimeter": 5.7 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "E", + "wall_thickness_measured": "Y", + "roof_insulation_location": 4, + "roof_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 36, + "solar_water_heating": "N", + "bedf_revision_number": 333, + "habitable_room_count": 5, + "heating_cost_current": { + "value": 546, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 3.4, + "energy_rating_average": 60, + "energy_rating_current": 61, + "lighting_cost_current": { + "value": 69, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Room thermostat only", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 378, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 127, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 66, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £14,000", + "improvement_type": "Q", + "improvement_details": { + "improvement_number": 7 + }, + "improvement_category": 5, + "energy_performance_rating": 65, + "environmental_impact_rating": 63 + }, + { + "sequence": 2, + "typical_saving": { + "value": 36, + "currency": "GBP" + }, + "indicative_cost": "£800 - £1,200", + "improvement_type": "W", + "improvement_details": { + "improvement_number": 47 + }, + "improvement_category": 5, + "energy_performance_rating": 67, + "environmental_impact_rating": 66 + }, + { + "sequence": 3, + "typical_saving": { + "value": 23, + "currency": "GBP" + }, + "indicative_cost": "£35", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 68, + "environmental_impact_rating": 67 + }, + { + "sequence": 4, + "typical_saving": { + "value": 21, + "currency": "GBP" + }, + "indicative_cost": "£350 - £450", + "improvement_type": "G", + "improvement_details": { + "improvement_number": 15 + }, + "improvement_category": 5, + "energy_performance_rating": 69, + "environmental_impact_rating": 68 + }, + { + "sequence": 5, + "typical_saving": { + "value": 75, + "currency": "GBP" + }, + "indicative_cost": "£2,200 - £3,000", + "improvement_type": "I", + "improvement_details": { + "improvement_number": 20 + }, + "improvement_category": 5, + "energy_performance_rating": 73, + "environmental_impact_rating": 73 + }, + { + "sequence": 6, + "typical_saving": { + "value": 34, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 76 + }, + { + "sequence": 7, + "typical_saving": { + "value": 247, + "currency": "GBP" + }, + "indicative_cost": "£9,000 - £14,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 85, + "environmental_impact_rating": 86 + } + ], + "co2_emissions_potential": 1.0, + "energy_rating_potential": 85, + "lighting_cost_potential": { + "value": 42, + "currency": "GBP" + }, + "alternative_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 57, + "currency": "GBP" + }, + "improvement_type": "Z1", + "improvement_details": { + "improvement_number": 51 + }, + "improvement_category": 6, + "energy_performance_rating": 72, + "environmental_impact_rating": 74 + }, + { + "sequence": 2, + "typical_saving": { + "value": 85, + "currency": "GBP" + }, + "improvement_type": "Z3", + "improvement_details": { + "improvement_number": 53 + }, + "improvement_category": 6, + "energy_performance_rating": 73, + "environmental_impact_rating": 72 + } + ], + "hot_water_cost_potential": { + "value": 68, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2530, + "space_heating_existing_dwelling": 8564 + }, + "seller_commission_report": "Y", + "energy_consumption_current": 244, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "6.2.0.6", + "energy_consumption_potential": 66, + "environmental_impact_current": 59, + "fixed_lighting_outlets_count": 11, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 86, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 47, + "low_energy_fixed_lighting_outlets_count": 4 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-16.1/sap.json b/backend/epc_api/json_samples/SAP-Schema-16.1/sap.json new file mode 100644 index 00000000..ac083fe4 --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-16.1/sap.json @@ -0,0 +1,365 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Average thermal transmittance 0.15 W/m²K", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.30 W/m²K", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.18 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "status": "entered", + "windows": { + "description": "Fully double glazed", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "lighting": { + "description": "Low energy lighting in 33% of fixed outlets", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "postcode": "AA1 1AA", + "data_type": 2, + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Town", + "built_form": 4, + "living_area": 12.04, + "orientation": 0, + "region_code": 15, + "report_type": 3, + "sap_heating": { + "thermal_store": 1, + "has_solar_panel": "false", + "water_fuel_type": 1, + "water_heating_code": 901, + "main_heating_details": [ + { + "main_fuel_type": 1, + "heat_emitter_type": 1, + "boiler_index_number": 9901, + "is_flue_fan_present": "true", + "main_heating_number": 1, + "main_heating_control": 2106, + "is_interlocked_system": "true", + "main_heating_category": 2, + "main_heating_fraction": 1, + "main_heating_data_source": 1, + "has_delayed_start_thermostat": "false", + "load_or_weather_compensation": 0, + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "false", + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1 + }, + "sap_version": 9.9, + "schema_type": "SAP-Schema-16.1", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "Air permeability 7.7 m³/h.m² (as tested)", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "28, Place Drive", + "schema_version": "LIG-16.0", + "assessment_date": "2013-01-12", + "assessment_type": "SAP", + "completion_date": "2013-01-12", + "inspection_date": "2013-01-12", + "sap_ventilation": { + "psv_count": 0, + "pressure_test": 1, + "air_permeability": 7.7, + "open_flues_count": 0, + "ventilation_type": 1, + "extract_fans_count": 3, + "open_fireplaces_count": 0, + "sheltered_sides_count": 2, + "flueless_gas_fires_count": 0 + }, + "total_floor_area": 59, + "transaction_type": 6, + "conservatory_type": 1, + "registration_date": "2013-01-12", + "restricted_access": 0, + "sap_energy_source": { + "electricity_tariff": 1, + "wind_turbines_count": 0, + "wind_turbine_terrain_type": 2, + "fixed_lighting_outlets_count": 9, + "low_energy_fixed_lighting_outlets_count": 3, + "low_energy_fixed_lighting_outlets_percentage": 33 + }, + "sap_opening_types": [ + { + "name": 1, + "type": 1, + "u_value": 1.57, + "data_source": 2, + "glazing_type": 1 + }, + { + "name": 2, + "type": 4, + "u_value": 1.9, + "frame_type": 2, + "data_source": 3, + "glazing_gap": 3, + "frame_factor": 0.7, + "glazing_type": 6, + "isargonfilled": "false", + "solar_transmittance": 0.63 + }, + { + "name": 4, + "type": 2, + "u_value": 1.9, + "data_source": 2, + "glazing_type": 6 + } + ], + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 9 + ], + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof 1", + "u_value": 0.15, + "roof_type": 2, + "description": "Roof 1", + "total_roof_area": 29.25 + } + ], + "sap_walls": [ + { + "name": "Wall 1", + "u_value": 0.3, + "wall_type": 2, + "description": "Wall 1", + "total_wall_area": 38.69, + "is_curtain_walling": "false" + }, + { + "name": "Wall 2", + "u_value": 0.3, + "wall_type": 2, + "description": "High step", + "total_wall_area": 2.25, + "is_curtain_walling": "false" + }, + { + "name": "Wall 3", + "u_value": 0.3, + "wall_type": 2, + "description": "low step", + "total_wall_area": 2.25, + "is_curtain_walling": "false" + } + ], + "identifier": "Main Dwelling", + "overshading": 2, + "sap_openings": [ + { + "name": 1, + "type": 1, + "width": 0.93, + "height": 2.1, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 2, + "type": 2, + "width": 1.77, + "height": 1.35, + "location": "Wall 1", + "orientation": 1 + }, + { + "name": 3, + "type": 2, + "width": 1.2, + "height": 1.05, + "location": "Wall 1", + "orientation": 1 + }, + { + "name": 4, + "type": 4, + "width": 0.93, + "height": 2.1, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 5, + "type": 2, + "width": 1.2, + "height": 1.05, + "location": "Wall 1", + "orientation": 5 + }, + { + "name": 6, + "type": 2, + "width": 1.77, + "height": 1.05, + "location": "Wall 1", + "orientation": 5 + } + ], + "construction_year": 2012, + "sap_thermal_bridges": { + "thermal_bridge_code": 4, + "user_defined_y_value": 0.08, + "calculation_reference": "ACDs" + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.18, + "floor_type": 2, + "description": "Floor 1", + "storey_height": 2.37, + "heat_loss_area": 29.25, + "total_floor_area": 29.25 + }, + { + "storey": 1, + "u_value": 0, + "floor_type": 3, + "storey_height": 2.59, + "heat_loss_area": 0, + "total_floor_area": 29.25 + } + ], + "thermal_mass_parameter": 250 + } + ], + "bedf_revision_number": 333, + "heating_cost_current": 236, + "co2_emissions_current": 1.3, + "energy_rating_average": 60, + "energy_rating_current": 79, + "lighting_cost_current": 66, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": 240, + "hot_water_cost_current": 76, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 23, + "indicative_cost": "£15", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 1, + "energy_performance_rating": 80, + "environmental_impact_rating": 84 + }, + { + "sequence": 2, + "typical_saving": 25, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 3, + "energy_performance_rating": 81, + "environmental_impact_rating": 86 + }, + { + "sequence": 3, + "typical_saving": 226, + "indicative_cost": "£11,000 - £20,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 3, + "energy_performance_rating": 94, + "environmental_impact_rating": 98 + }, + { + "sequence": 4, + "typical_saving": 19, + "indicative_cost": "£1,500 - £4,000", + "improvement_type": "V", + "improvement_details": { + "improvement_number": 44 + }, + "improvement_category": 3, + "energy_performance_rating": 95, + "environmental_impact_rating": 99 + } + ], + "co2_emissions_potential": 1.2, + "energy_rating_potential": 80, + "lighting_cost_potential": 39, + "hot_water_cost_potential": 76, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 1867, + "water_heating": 1839 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 119, + "has_fixed_air_conditioning": "false", + "calculation_software_version": 5.4, + "energy_consumption_potential": 110, + "environmental_impact_current": 83, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 84, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 22 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-16.2/rdsap.json b/backend/epc_api/json_samples/SAP-Schema-16.2/rdsap.json new file mode 100644 index 00000000..cea3d9c2 --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-16.2/rdsap.json @@ -0,0 +1,931 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Pitched, 75 mm loft insulation", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Cavity wall, as built, no insulation (assumed)", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + { + "description": "Cavity wall, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Suspended, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + { + "description": "To unheated space, limited insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "windows": [ + { + "description": "Partial double glazing", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "addendum": { + "cavity_fill_recommended": "true" + }, + "lighting": { + "description": "Low energy lighting in 13% of fixed outlets", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "postcode": "AA1 1AA", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Town", + "built_form": 1, + "door_count": 4, + "glazed_area": 4, + "region_code": 3, + "report_type": 2, + "sap_heating": { + "wwhrs": { + "rooms_with_bath_and_or_shower": 3, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 3 + }, + "cylinder_size": 4, + "water_heating_code": 901, + "water_heating_fuel": 26, + "cylinder_thermostat": "Y", + "secondary_fuel_type": 33, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 0.5, + "sap_main_heating_code": 101, + "main_heating_data_source": 2 + }, + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "main_heating_number": 2, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 0.5, + "sap_main_heating_code": 101, + "main_heating_data_source": 2 + } + ], + "secondary_heating_type": 631, + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 50 + }, + "sap_version": 9.91, + "sap_windows": [ + { + "u_value": 3.1, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 2, + "solar_transmittance": 0.76 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 2, + "solar_transmittance": 0.76 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 2, + "solar_transmittance": 0.76 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 2, + "solar_transmittance": 0.76 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 1, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 2, + "solar_transmittance": 0.76 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 1, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 0, + "solar_transmittance": 0.76 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 3, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 2, + "solar_transmittance": 0.76 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 3, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 2, + "solar_transmittance": 0.76 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 3, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 2, + "solar_transmittance": 0.76 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 3, + "window_area": 1, + "window_type": 1, + "glazing_type": 3, + "window_location": 2, + "solar_transmittance": 0.76 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 2, + "window_type": 1, + "glazing_type": 5, + "window_location": 2, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 2, + "window_type": 1, + "glazing_type": 5, + "window_location": 2, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 2, + "window_type": 1, + "glazing_type": 5, + "window_location": 2, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 2, + "window_type": 1, + "glazing_type": 5, + "window_location": 2, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 2, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 4, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 4, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 4, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 3, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 7, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 5, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 5, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 3, + "window_area": 1, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 3.1, + "data_source": 2, + "orientation": 5, + "window_area": 2, + "window_type": 1, + "glazing_type": 3, + "window_location": 0, + "solar_transmittance": 0.76 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 3, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 3, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 3, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 8, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 4.8, + "data_source": 2, + "orientation": 5, + "window_area": 3, + "window_type": 1, + "glazing_type": 5, + "window_location": 0, + "solar_transmittance": 0.85 + }, + { + "u_value": 2, + "data_source": 2, + "orientation": 3, + "window_area": 2, + "window_type": 1, + "glazing_type": 2, + "window_location": 0, + "solar_transmittance": 0.72 + }, + { + "u_value": 2, + "data_source": 2, + "orientation": 3, + "window_area": 3, + "window_type": 1, + "glazing_type": 2, + "window_location": 0, + "solar_transmittance": 0.72 + }, + { + "u_value": 2, + "data_source": 2, + "orientation": 3, + "window_area": 3, + "window_type": 1, + "glazing_type": 2, + "window_location": 0, + "solar_transmittance": 0.72 + }, + { + "u_value": 2, + "data_source": 2, + "orientation": 7, + "window_area": 6, + "window_type": 1, + "glazing_type": 2, + "window_location": 0, + "solar_transmittance": 0.72 + }, + { + "u_value": 2, + "data_source": 2, + "orientation": 7, + "window_area": 8, + "window_type": 1, + "glazing_type": 2, + "window_location": 0, + "solar_transmittance": 0.72 + }, + { + "u_value": 2, + "data_source": 2, + "orientation": 7, + "window_area": 8, + "window_type": 1, + "glazing_type": 2, + "window_location": 0, + "solar_transmittance": 0.72 + }, + { + "u_value": 2, + "data_source": 2, + "orientation": 5, + "window_area": 9, + "window_type": 1, + "glazing_type": 2, + "window_location": 0, + "solar_transmittance": 0.72 + } + ], + "schema_type": "SAP-Schema-16.2", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": "Detached house", + "language_code": 1, + "property_type": 0, + "address_line_1": "11, Some Close", + "address_line_2": "Long Itchington", + "schema_version": "LIG-16.1", + "assessment_type": "RdSAP", + "completion_date": "2013-07-12", + "inspection_date": "2013-06-27", + "extensions_count": 2, + "measurement_type": 1, + "total_floor_area": 1563, + "transaction_type": 5, + "conservatory_type": 2, + "heated_room_count": 15, + "registration_date": "2013-07-12", + "restricted_access": 0, + "sap_energy_source": { + "main_gas": "Y", + "meter_type": 1, + "photovoltaic_supply": { + "percent_roof_area": 0 + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 3 + }, + "secondary_heating": { + "description": "Room heaters, coal", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 9 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 320, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.52, + "floor_insulation": 1, + "total_floor_area": 668.41, + "floor_construction": 2, + "heat_loss_perimeter": 96.43 + }, + { + "floor": 1, + "room_height": 2.87, + "total_floor_area": 691.43, + "heat_loss_perimeter": 106.57 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "C", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "75mm" + }, + { + "identifier": "Extension 1", + "wall_dry_lined": "N", + "wall_thickness": 500, + "floor_heat_loss": 7, + "roof_construction": 5, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.54, + "floor_insulation": 1, + "total_floor_area": 82.03, + "floor_construction": 1, + "heat_loss_perimeter": 34.27 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "H", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "75mm" + }, + { + "identifier": "Extension 2", + "wall_dry_lined": "N", + "wall_thickness": 280, + "floor_heat_loss": 2, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 3, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 3.21, + "floor_insulation": 1, + "total_floor_area": 121.31, + "floor_construction": 2, + "heat_loss_perimeter": 37.71 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "H", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "75mm" + } + ], + "low_energy_lighting": 13, + "solar_water_heating": "N", + "bedf_revision_number": 340, + "habitable_room_count": 15, + "heating_cost_current": { + "value": 13144, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 78, + "energy_rating_average": 60, + "energy_rating_current": 52, + "lighting_cost_current": { + "value": 635, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": "ND", + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 8582, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 188, + "currency": "GBP" + }, + "mechanical_ventilation": 2, + "percent_draughtproofed": 0, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 637.67, + "currency": "GBP" + }, + "indicative_cost": "£100 - £350", + "improvement_type": "A", + "improvement_details": { + "improvement_number": 5 + }, + "improvement_category": 5, + "energy_performance_rating": 55, + "environmental_impact_rating": 42 + }, + { + "sequence": 2, + "typical_saving": { + "value": 1224.21, + "currency": "GBP" + }, + "indicative_cost": "£500 - £1,500", + "improvement_type": "B", + "improvement_details": { + "improvement_number": 6 + }, + "improvement_category": 5, + "energy_performance_rating": 59, + "environmental_impact_rating": 46 + }, + { + "sequence": 3, + "typical_saving": { + "value": 476.28, + "currency": "GBP" + }, + "indicative_cost": "£800 - £1,200", + "improvement_type": "W", + "improvement_details": { + "improvement_number": 47 + }, + "improvement_category": 5, + "energy_performance_rating": 60, + "environmental_impact_rating": 48 + }, + { + "sequence": 4, + "typical_saving": { + "value": 787.42, + "currency": "GBP" + }, + "indicative_cost": "£80 - £120", + "improvement_type": "D", + "improvement_details": { + "improvement_number": 10 + }, + "improvement_category": 5, + "energy_performance_rating": 63, + "environmental_impact_rating": 51 + }, + { + "sequence": 5, + "typical_saving": { + "value": 234.75, + "currency": "GBP" + }, + "indicative_cost": "£305", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 64, + "environmental_impact_rating": 51 + }, + { + "sequence": 6, + "typical_saving": { + "value": 1219.36, + "currency": "GBP" + }, + "indicative_cost": "£2,200 - £3,000", + "improvement_type": "I", + "improvement_details": { + "improvement_number": 20 + }, + "improvement_category": 5, + "energy_performance_rating": 68, + "environmental_impact_rating": 56 + }, + { + "sequence": 7, + "typical_saving": { + "value": 310.6, + "currency": "GBP" + }, + "indicative_cost": "£3,300 - £6,500", + "improvement_type": "O", + "improvement_details": { + "improvement_number": 8 + }, + "improvement_category": 5, + "energy_performance_rating": 69, + "environmental_impact_rating": 58 + } + ], + "co2_emissions_potential": 51, + "energy_rating_potential": 69, + "lighting_cost_potential": { + "value": 344, + "currency": "GBP" + }, + "hot_water_cost_potential": { + "value": 151, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 3640, + "impact_of_loft_insulation": -12585, + "impact_of_cavity_insulation": -21982, + "space_heating_existing_dwelling": 207769 + }, + "seller_commission_report": "Y", + "energy_consumption_current": 239, + "has_fixed_air_conditioning": "false", + "calculation_software_version": "1.4.1.0", + "energy_consumption_potential": 153, + "environmental_impact_current": 40, + "fixed_lighting_outlets_count": 70, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 58, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 50, + "low_energy_fixed_lighting_outlets_count": 9 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-16.2/sap.json b/backend/epc_api/json_samples/SAP-Schema-16.2/sap.json new file mode 100644 index 00000000..b6db5a2b --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-16.2/sap.json @@ -0,0 +1,2112 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Average thermal transmittance 0.14 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.17 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.13 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "status": "entered", + "windows": { + "description": "High performance glazing", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "lighting": { + "description": "Low energy lighting in all fixed outlets", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "AA1 1AA", + "data_type": 2, + "hot_water": { + "description": "From main system, plus solar", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 5 + }, + "post_town": "Town", + "built_form": 1, + "living_area": 44.4, + "orientation": 0, + "region_code": 16, + "report_type": 3, + "sap_heating": { + "has_solar_panel": "true", + "water_fuel_type": 39, + "solar_store_volume": 100, + "water_heating_code": 901, + "secondary_fuel_type": 20, + "hot_water_store_size": 300, + "main_heating_details": [ + { + "main_fuel_type": 39, + "heat_emitter_type": 2, + "main_heating_code": 201, + "main_heating_number": 1, + "main_heating_control": 2207, + "main_heating_category": 4, + "main_heating_fraction": 1, + "main_heating_data_source": 3, + "has_delayed_start_thermostat": "false", + "load_or_weather_compensation": 0, + "underfloor_heat_emitter_type": 2, + "is_main_heating_hetas_approved": "false", + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "true", + "has_solar_powered_pump": "false", + "secondary_heating_code": 633, + "has_cylinder_thermostat": "true", + "solar_panel_aperture_area": 5.26, + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 10, + "solar_panel_collector_type": 3, + "is_cylinder_in_heated_space": "true", + "solar_panel_collector_pitch": 3, + "is_hot_water_separately_timed": "true", + "is_primary_pipework_insulated": "true", + "secondary_heating_data_source": 3, + "hot_water_store_insulation_type": 1, + "hot_water_store_heat_loss_source": 3, + "is_solar_store_combined_cylinder": "true", + "solar_panel_collector_data_source": 2, + "solar_panel_collector_orientation": 3, + "solar_panel_collector_overshading": 1, + "is_heat_pump_assisted_by_immersion": "false", + "is_secondary_heating_hetas_approved": "false", + "hot_water_store_insulation_thickness": 80, + "solar_panel_collector_heat_loss_rate": 3, + "solar_panel_collector_zero_loss_efficiency": 60 + }, + "sap_version": 9.9, + "schema_type": "SAP-Schema-16.2", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Ground source heat pump, underfloor, electric", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 5 + } + ], + "air_tightness": { + "description": "Air permeability 6.4 m³/h.m² (as tested)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "dwelling_type": "Detached house", + "language_code": 1, + "property_type": 0, + "address_line_1": "17, Street Terrace", + "schema_version": "LIG-16.0", + "assessment_date": "2013-02-06", + "assessment_type": "SAP", + "completion_date": "2013-02-06", + "inspection_date": "2013-02-06", + "sap_ventilation": { + "psv_count": 0, + "pressure_test": 1, + "air_permeability": 6.373, + "open_flues_count": 0, + "ventilation_type": 1, + "extract_fans_count": 9, + "open_fireplaces_count": 0, + "sheltered_sides_count": 0, + "flueless_gas_fires_count": 0 + }, + "total_floor_area": 709, + "transaction_type": 6, + "conservatory_type": 1, + "registration_date": "2013-02-06", + "restricted_access": 0, + "sap_energy_source": { + "pv_arrays": [ + { + "pitch": 3, + "peak_power": 3.6, + "orientation": 5, + "overshading": 1 + } + ], + "electricity_tariff": 1, + "wind_turbines_count": 0, + "wind_turbine_terrain_type": 3, + "fixed_lighting_outlets_count": 40, + "low_energy_fixed_lighting_outlets_count": 40, + "low_energy_fixed_lighting_outlets_percentage": 100 + }, + "sap_opening_types": [ + { + "name": 1, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D7", + "glazing_type": 6 + }, + { + "name": 2, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D12", + "glazing_type": 6 + }, + { + "name": 3, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W27", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 4, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W28A", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 5, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W29A", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 6, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W30", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 7, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W31", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 8, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W32", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 9, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W25", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 10, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W26", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 11, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D3", + "glazing_type": 6 + }, + { + "name": 12, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D11", + "glazing_type": 6 + }, + { + "name": 13, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W34", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 14, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W33", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 15, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W20", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 16, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W21", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 17, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W35", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 18, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W36", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 19, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W37", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 20, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W22", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 21, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W23", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 22, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W24", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 23, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W24A", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 24, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W38", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 25, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W39", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 26, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W40", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 27, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W41", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 28, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W42", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 29, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W1A", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 30, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W2A", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 31, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W1B", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 32, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W2B", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 33, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W18", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 34, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W19", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 35, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D4", + "glazing_type": 6 + }, + { + "name": 36, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D6", + "glazing_type": 6 + }, + { + "name": 37, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D5", + "glazing_type": 6 + }, + { + "name": 38, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D9", + "glazing_type": 6 + }, + { + "name": 39, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D10", + "glazing_type": 6 + }, + { + "name": 40, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W17", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 41, + "type": 5, + "u_value": 1.4, + "data_source": 2, + "description": "W14", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 42, + "type": 5, + "u_value": 1.4, + "data_source": 2, + "description": "W16", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 43, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D1", + "glazing_type": 6 + }, + { + "name": 44, + "type": 2, + "u_value": 1.4, + "data_source": 2, + "description": "D2", + "glazing_type": 6 + }, + { + "name": 45, + "type": 1, + "u_value": 1.4, + "data_source": 2, + "description": "D8", + "glazing_type": 1 + }, + { + "name": 46, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W9", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 47, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W29", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 48, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W28", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 49, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W13", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 50, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W9A", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 51, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W15", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 52, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WF", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 53, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WG", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 54, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WH", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 55, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WI", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 56, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WJ", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 57, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W30A", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 58, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W31A", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 59, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WLL", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 60, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WMM", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 61, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WAA", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 62, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WBB", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 63, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WCC", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 64, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WDD", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 65, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WO", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 66, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WP", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 67, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WQ", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 68, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W1", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 69, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W10", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 70, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W2", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 71, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W3", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 72, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W4", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 73, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W5", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 74, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W6", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 75, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W7", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 76, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W11", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 77, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "W12", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 78, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WA", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 79, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WB", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 80, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WC", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 81, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WD", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 82, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WE", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 83, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WK", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 84, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WL", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 85, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WM", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 86, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WN", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 87, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WFF", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 88, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WGG", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 89, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WHH", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 90, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WII", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 91, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WJJ", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 92, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WKK", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 93, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WR", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 94, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WS", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 95, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WT", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 96, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WU", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 97, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WV", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 98, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WW", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 99, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WX", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 100, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WY", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + }, + { + "name": 101, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "WZ", + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + } + ], + "secondary_heating": { + "description": "Room heaters, wood logs", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 4, + 7, + 10, + 11 + ], + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof 1", + "u_value": 0.129, + "roof_type": 2, + "description": "Roof 1 ZINC FLAT", + "total_roof_area": 145 + }, + { + "name": "Roof 2", + "u_value": 0.139, + "roof_type": 2, + "description": "Roof 2 THATCH ROOF", + "total_roof_area": 93.03 + }, + { + "name": "Roof 3", + "u_value": 0.19, + "roof_type": 2, + "description": "Roof 3 DORMER", + "total_roof_area": 4.8 + }, + { + "name": "Roof 4", + "u_value": 0.151, + "roof_type": 2, + "description": "Roof 4 SLATE", + "total_roof_area": 157.54 + } + ], + "sap_walls": [ + { + "name": "Wall 1", + "u_value": 0.166, + "wall_type": 2, + "description": "Wall 1 BRICK & FLINT", + "total_wall_area": 407.31, + "is_curtain_walling": "false" + }, + { + "name": "Wall 2", + "u_value": 0.168, + "wall_type": 1, + "description": "Wall 2 CONCRETE", + "total_wall_area": 122.42, + "is_curtain_walling": "false" + }, + { + "name": "Wall 3", + "u_value": 0.181, + "wall_type": 2, + "description": "Wall 3 INT WALL", + "total_wall_area": 17.33, + "is_curtain_walling": "false" + }, + { + "name": "Wall 4", + "u_value": 0.158, + "wall_type": 2, + "description": "Wall 4 TF", + "total_wall_area": 236.58, + "is_curtain_walling": "false" + } + ], + "identifier": "Main Dwelling", + "overshading": 1, + "sap_openings": [ + { + "name": 1, + "type": 1, + "width": 0.91, + "height": 2.1, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 2, + "type": 2, + "width": 0.91, + "height": 2.1, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 3, + "type": 3, + "width": 3.99, + "height": 0.78, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 4, + "type": 4, + "width": 3.33, + "height": 0.97, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 5, + "type": 5, + "width": 3.17, + "height": 0.97, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 6, + "type": 6, + "width": 1.28, + "height": 0.97, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 7, + "type": 7, + "width": 2.31, + "height": 0.97, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 8, + "type": 8, + "width": 2.31, + "height": 0.97, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 9, + "type": 9, + "width": 5.86, + "height": 1.36, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 10, + "type": 10, + "width": 3.95, + "height": 1.36, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 11, + "type": 11, + "width": 1.68, + "height": 2.75, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 12, + "type": 12, + "width": 1.99, + "height": 2.2, + "location": "Wall 4", + "orientation": 0 + }, + { + "name": 13, + "type": 13, + "width": 1.24, + "height": 0.95, + "location": "Wall 1", + "orientation": 8 + }, + { + "name": 14, + "type": 14, + "width": 1.99, + "height": 0.95, + "location": "Wall 1", + "orientation": 2 + }, + { + "name": 15, + "type": 15, + "width": 2.92, + "height": 2.09, + "location": "Wall 4", + "orientation": 2 + }, + { + "name": 16, + "type": 16, + "width": 1.38, + "height": 2.09, + "location": "Wall 4", + "orientation": 4 + }, + { + "name": 17, + "type": 17, + "width": 1.36, + "height": 2.4, + "location": "Wall 4", + "orientation": 1 + }, + { + "name": 18, + "type": 18, + "width": 2.09, + "height": 2.4, + "location": "Wall 4", + "orientation": 3 + }, + { + "name": 19, + "type": 19, + "width": 1.88, + "height": 2.4, + "location": "Wall 4", + "orientation": 3 + }, + { + "name": 20, + "type": 20, + "width": 0.68, + "height": 0.99, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 21, + "type": 21, + "width": 0.68, + "height": 0.99, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 22, + "type": 22, + "width": 0.68, + "height": 0.99, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 23, + "type": 23, + "width": 0.68, + "height": 0.99, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 24, + "type": 24, + "width": 0.54, + "height": 0.99, + "location": "Wall 1", + "orientation": 5 + }, + { + "name": 25, + "type": 25, + "width": 0.54, + "height": 0.99, + "location": "Wall 1", + "orientation": 5 + }, + { + "name": 26, + "type": 26, + "width": 0.54, + "height": 0.99, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 27, + "type": 27, + "width": 0.54, + "height": 0.99, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 28, + "type": 28, + "width": 0.54, + "height": 0.99, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 29, + "type": 29, + "width": 1.45, + "height": 1.42, + "location": "Wall 4", + "orientation": 3 + }, + { + "name": 30, + "type": 30, + "width": 1.68, + "height": 1.42, + "location": "Wall 4", + "orientation": 3 + }, + { + "name": 31, + "type": 31, + "width": 1.45, + "height": 1.42, + "location": "Wall 4", + "orientation": 5 + }, + { + "name": 32, + "type": 32, + "width": 1.68, + "height": 1.42, + "location": "Wall 4", + "orientation": 1 + }, + { + "name": 33, + "type": 33, + "width": 1.35, + "height": 2.09, + "location": "Wall 4", + "orientation": 2 + }, + { + "name": 34, + "type": 34, + "width": 1.35, + "height": 2.09, + "location": "Wall 4", + "orientation": 2 + }, + { + "name": 35, + "type": 35, + "width": 3.84, + "height": 2.8, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 36, + "type": 36, + "width": 4.74, + "height": 2.8, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 37, + "type": 37, + "width": 2.81, + "height": 2.8, + "location": "Wall 4", + "orientation": 0 + }, + { + "name": 38, + "type": 38, + "width": 6.45, + "height": 2.4, + "location": "Wall 4", + "orientation": 0 + }, + { + "name": 39, + "type": 39, + "width": 3.84, + "height": 2.4, + "location": "Wall 4", + "orientation": 0 + }, + { + "name": 40, + "type": 40, + "width": 1.4, + "height": 2.76, + "location": "Wall 1", + "orientation": 2 + }, + { + "name": 41, + "type": 41, + "width": 0.6, + "height": 0.89, + "location": "Roof 2", + "orientation": 8 + }, + { + "name": 42, + "type": 42, + "width": 7.25, + "height": 0.89, + "location": "Roof 2", + "orientation": 4 + }, + { + "name": 43, + "type": 43, + "width": 1.81, + "height": 2.1, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 44, + "type": 44, + "width": 2.49, + "height": 2.1, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 45, + "type": 45, + "width": 3.08, + "height": 2.62, + "location": "Wall 1", + "orientation": 0 + }, + { + "name": 46, + "type": 46, + "width": 1.68, + "height": 1.36, + "location": "Wall 1", + "orientation": 2 + }, + { + "name": 47, + "type": 47, + "width": 1.18, + "height": 2.44, + "location": "Wall 4", + "orientation": 3 + }, + { + "name": 48, + "type": 48, + "width": 1.18, + "height": 1.23, + "location": "Wall 4", + "orientation": 3 + }, + { + "name": 49, + "type": 49, + "width": 4.27, + "height": 2.85, + "location": "Wall 1", + "orientation": 2 + }, + { + "name": 50, + "type": 50, + "width": 1.68, + "height": 4.15, + "location": "Wall 1", + "orientation": 2 + }, + { + "name": 51, + "type": 51, + "width": 1.74, + "height": 2.62, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 52, + "type": 52, + "width": 2.36, + "height": 0.38, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 53, + "type": 53, + "width": 1.5, + "height": 0.38, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 54, + "type": 54, + "width": 0.7, + "height": 0.38, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 55, + "type": 55, + "width": 0.92, + "height": 0.38, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 56, + "type": 56, + "width": 0.9, + "height": 0.38, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 57, + "type": 57, + "width": 1.18, + "height": 2.98, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 58, + "type": 58, + "width": 1.18, + "height": 2.98, + "location": "Wall 1", + "orientation": 3 + }, + { + "name": 59, + "type": 59, + "width": 0.62, + "height": 0.38, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 60, + "type": 60, + "width": 1.69, + "height": 0.38, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 61, + "type": 61, + "width": 2.38, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 62, + "type": 62, + "width": 1.13, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 63, + "type": 63, + "width": 0.73, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 64, + "type": 64, + "width": 1.94, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 65, + "type": 65, + "width": 1.39, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 66, + "type": 66, + "width": 1.59, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 67, + "type": 67, + "width": 1.65, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 68, + "type": 68, + "width": 0.46, + "height": 0.54, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 69, + "type": 69, + "width": 0.46, + "height": 0.54, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 70, + "type": 70, + "width": 1.14, + "height": 1.06, + "location": "Wall 1", + "orientation": 8 + }, + { + "name": 71, + "type": 71, + "width": 1.14, + "height": 1.06, + "location": "Wall 1", + "orientation": 8 + }, + { + "name": 72, + "type": 72, + "width": 1.14, + "height": 1.06, + "location": "Wall 1", + "orientation": 8 + }, + { + "name": 73, + "type": 73, + "width": 1.14, + "height": 1.06, + "location": "Wall 1", + "orientation": 8 + }, + { + "name": 74, + "type": 74, + "width": 1.14, + "height": 1.06, + "location": "Wall 1", + "orientation": 4 + }, + { + "name": 75, + "type": 75, + "width": 1.14, + "height": 1.06, + "location": "Wall 1", + "orientation": 4 + }, + { + "name": 76, + "type": 76, + "width": 2, + "height": 1.09, + "location": "Wall 4", + "orientation": 8 + }, + { + "name": 77, + "type": 77, + "width": 2, + "height": 1.09, + "location": "Wall 4", + "orientation": 8 + }, + { + "name": 78, + "type": 78, + "width": 0.76, + "height": 0.38, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 79, + "type": 79, + "width": 0.76, + "height": 0.38, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 80, + "type": 80, + "width": 0.76, + "height": 0.38, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 81, + "type": 81, + "width": 2.57, + "height": 0.38, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 82, + "type": 82, + "width": 2.57, + "height": 0.38, + "location": "Wall 4", + "orientation": 6 + }, + { + "name": 83, + "type": 83, + "width": 0.88, + "height": 0.38, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 84, + "type": 84, + "width": 0.88, + "height": 0.38, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 85, + "type": 85, + "width": 0.88, + "height": 0.38, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 86, + "type": 86, + "width": 0.88, + "height": 0.38, + "location": "Wall 1", + "orientation": 6 + }, + { + "name": 87, + "type": 87, + "width": 1.28, + "height": 0.38, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 88, + "type": 88, + "width": 1.28, + "height": 0.38, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 89, + "type": 89, + "width": 1.28, + "height": 0.38, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 90, + "type": 90, + "width": 1.35, + "height": 0.38, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 91, + "type": 91, + "width": 1.35, + "height": 0.38, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 92, + "type": 92, + "width": 1.35, + "height": 0.38, + "location": "Wall 4", + "orientation": 7 + }, + { + "name": 93, + "type": 93, + "width": 0.55, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 94, + "type": 94, + "width": 0.55, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 95, + "type": 95, + "width": 0.55, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 96, + "type": 96, + "width": 0.55, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 97, + "type": 97, + "width": 0.55, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 98, + "type": 98, + "width": 0.92, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 99, + "type": 99, + "width": 0.92, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 100, + "type": 100, + "width": 1.23, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 101, + "type": 101, + "width": 1.23, + "height": 0.38, + "location": "Wall 1", + "orientation": 7 + } + ], + "construction_year": 2010, + "sap_thermal_bridges": { + "thermal_bridge_code": 1 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.13, + "floor_type": 1, + "description": "Floor 1", + "storey_height": 2.65, + "heat_loss_area": 341.2, + "total_floor_area": 127.88 + }, + { + "storey": 1, + "u_value": 0, + "floor_type": 3, + "storey_height": 2.88, + "heat_loss_area": 0, + "total_floor_area": 213.32 + }, + { + "storey": 2, + "u_value": 0, + "floor_type": 3, + "storey_height": 3.07, + "heat_loss_area": 0, + "total_floor_area": 347.69 + }, + { + "storey": 3, + "u_value": 0, + "floor_type": 3, + "storey_height": 2.3, + "heat_loss_area": 0, + "total_floor_area": 20.45 + } + ], + "thermal_mass_parameter": 250 + } + ], + "bedf_revision_number": 333, + "heating_cost_current": 2132, + "co2_emissions_current": 6.5, + "energy_rating_average": 60, + "energy_rating_current": 85, + "lighting_cost_current": 154, + "main_heating_controls": [ + { + "description": "Time and temperature zone control", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": 2132, + "hot_water_cost_current": 105, + "co2_emissions_potential": 6.5, + "energy_rating_potential": 85, + "lighting_cost_potential": 154, + "hot_water_cost_potential": 105, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 41938, + "water_heating": 2876 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 63, + "has_fixed_air_conditioning": "false", + "calculation_software_version": 5.4, + "energy_consumption_potential": 63, + "environmental_impact_current": 88, + "current_energy_efficiency_band": "B", + "environmental_impact_potential": 88, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 9 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-16.3/rdsap.json b/backend/epc_api/json_samples/SAP-Schema-16.3/rdsap.json new file mode 100644 index 00000000..caa7b681 --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-16.3/rdsap.json @@ -0,0 +1,371 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Pitched, 200 mm loft insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": "Cavity wall, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Suspended, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "windows": [ + { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "lighting": { + "description": "Low energy lighting in 71% of fixed outlets", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "AA1 1AA", + "hot_water": { + "description": "Electric immersion, off-peak", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 1 + }, + "post_town": "Town", + "built_form": 2, + "door_count": 2, + "glazed_area": 1, + "region_code": 6, + "report_type": 2, + "sap_heating": { + "wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "cylinder_size": 4, + "water_heating_code": 903, + "water_heating_fuel": 29, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 29, + "heat_emitter_type": 0, + "main_heating_number": 1, + "main_heating_control": 2401, + "main_heating_category": 7, + "main_heating_fraction": 1, + "sap_main_heating_code": 401, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": 1, + "secondary_heating_type": 691, + "cylinder_insulation_type": 2, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 25 + }, + "sap_version": 9.91, + "schema_type": "SAP-Schema-16.3", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Electric storage heaters", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 1 + } + ], + "dwelling_type": "Semi-detached house", + "language_code": 1, + "property_type": 0, + "address_line_1": "11, Some Close", + "address_line_2": "Long Itchington", + "schema_version": "LIG-16.1", + "assessment_type": "RdSAP", + "completion_date": "2014-12-06", + "inspection_date": "2014-12-05", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 70, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 3, + "registration_date": "2014-12-06", + "restricted_access": 0, + "sap_energy_source": { + "main_gas": "N", + "meter_type": 1, + "photovoltaic_supply": { + "percent_roof_area": 0 + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": "Room heaters, electric", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 9 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 270, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.3, + "floor_insulation": 1, + "total_floor_area": 35.159, + "floor_construction": 3, + "heat_loss_perimeter": 16.837 + }, + { + "floor": 1, + "room_height": 2.3, + "total_floor_area": 35.159, + "heat_loss_perimeter": 16.837 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "H", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm" + } + ], + "low_energy_lighting": 71, + "solar_water_heating": "N", + "bedf_revision_number": 370, + "habitable_room_count": 3, + "heating_cost_current": { + "value": 611, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 5.9, + "energy_rating_average": 60, + "energy_rating_current": 59, + "lighting_cost_current": { + "value": 65, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Manual charge control", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "multiple_glazing_type": 1, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 471, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 226, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 88, + "currency": "GBP" + }, + "indicative_cost": "£800 - £1,200", + "improvement_type": "W", + "improvement_details": { + "improvement_number": 47 + }, + "improvement_category": 5, + "energy_performance_rating": 63, + "environmental_impact_rating": 41 + }, + { + "sequence": 2, + "typical_saving": { + "value": 35, + "currency": "GBP" + }, + "indicative_cost": "£15 - £30", + "improvement_type": "C", + "improvement_details": { + "improvement_number": 2 + }, + "improvement_category": 5, + "energy_performance_rating": 65, + "environmental_impact_rating": 43 + }, + { + "sequence": 3, + "typical_saving": { + "value": 11, + "currency": "GBP" + }, + "indicative_cost": "£10", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 65, + "environmental_impact_rating": 44 + }, + { + "sequence": 4, + "typical_saving": { + "value": 100, + "currency": "GBP" + }, + "indicative_cost": "£900 - £1,200", + "improvement_type": "L", + "improvement_details": { + "improvement_number": 25 + }, + "improvement_category": 5, + "energy_performance_rating": 69, + "environmental_impact_rating": 47 + }, + { + "sequence": 5, + "typical_saving": { + "value": 44, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 71, + "environmental_impact_rating": 52 + }, + { + "sequence": 6, + "typical_saving": { + "value": 261, + "currency": "GBP" + }, + "indicative_cost": "£9,000 - £14,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 83, + "environmental_impact_rating": 62 + }, + { + "sequence": 7, + "typical_saving": { + "value": 22, + "currency": "GBP" + }, + "indicative_cost": "£1,500 - £4,000", + "improvement_type": "V", + "improvement_details": { + "improvement_number": 44 + }, + "improvement_category": 5, + "energy_performance_rating": 84, + "environmental_impact_rating": 63 + } + ], + "co2_emissions_potential": 3.1, + "energy_rating_potential": 84, + "lighting_cost_potential": { + "value": 51, + "currency": "GBP" + }, + "alternative_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 50, + "currency": "GBP" + }, + "improvement_type": "J2", + "improvement_details": { + "improvement_number": 54 + }, + "improvement_category": 6, + "energy_performance_rating": 68, + "environmental_impact_rating": 88 + }, + { + "sequence": 2, + "typical_saving": { + "value": 178, + "currency": "GBP" + }, + "improvement_type": "Z1", + "improvement_details": { + "improvement_number": 51 + }, + "improvement_category": 6, + "energy_performance_rating": 75, + "environmental_impact_rating": 76 + }, + { + "sequence": 3, + "typical_saving": { + "value": 179, + "currency": "GBP" + }, + "improvement_type": "Z3", + "improvement_details": { + "improvement_number": 53 + }, + "improvement_category": 6, + "energy_performance_rating": 73, + "environmental_impact_rating": 72 + } + ], + "hot_water_cost_potential": { + "value": 105, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 3424, + "space_heating_existing_dwelling": 7554 + }, + "seller_commission_report": "Y", + "energy_consumption_current": 473, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": 8.3, + "energy_consumption_potential": 247, + "environmental_impact_current": 37, + "fixed_lighting_outlets_count": 7, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 63, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 84, + "low_energy_fixed_lighting_outlets_count": 5 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-16.3/sap.json b/backend/epc_api/json_samples/SAP-Schema-16.3/sap.json new file mode 100644 index 00000000..d5005cc7 --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-16.3/sap.json @@ -0,0 +1,456 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Average thermal transmittance 0.12 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.28 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.17 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "status": "entered", + "windows": { + "description": "High performance glazing", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "lighting": { + "description": "Low energy lighting in all fixed outlets", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "AA1 1AA", + "data_type": 2, + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Town", + "built_form": 4, + "living_area": 20.15, + "orientation": 7, + "region_code": 3, + "report_type": 3, + "sap_heating": { + "thermal_store": 1, + "has_solar_panel": "false", + "water_fuel_type": 1, + "water_heating_code": 901, + "hot_water_store_size": 180, + "main_heating_details": [ + { + "burner_control": 3, + "main_fuel_type": 1, + "heat_emitter_type": 1, + "boiler_index_number": 16179, + "is_flue_fan_present": "true", + "main_heating_number": 1, + "main_heating_control": 2110, + "is_interlocked_system": "true", + "main_heating_category": 2, + "main_heating_fraction": 1, + "main_heating_flue_type": 2, + "main_heating_data_source": 1, + "has_delayed_start_thermostat": "true", + "load_or_weather_compensation": 2, + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "true", + "has_cylinder_thermostat": "true", + "hot_water_store_heat_loss": 1.63, + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1, + "is_cylinder_in_heated_space": "true", + "is_hot_water_separately_timed": "true", + "is_primary_pipework_insulated": "true", + "hot_water_store_heat_loss_source": 2 + }, + "sap_version": 9.9, + "schema_type": "SAP-Schema-16.3", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "Air permeability 3.9 m³/h.m² (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "17, Street Terrace", + "schema_version": "LIG-16.0", + "assessment_date": "2014-04-04", + "assessment_type": "SAP", + "completion_date": "2014-04-04", + "inspection_date": "2014-04-04", + "sap_ventilation": { + "psv_count": 0, + "pressure_test": 1, + "air_permeability": 3.89, + "open_flues_count": 0, + "ventilation_type": 1, + "extract_fans_count": 3, + "open_fireplaces_count": 0, + "sheltered_sides_count": 2, + "flueless_gas_fires_count": 0 + }, + "design_water_use": 1, + "total_floor_area": 96, + "transaction_type": 6, + "conservatory_type": 1, + "registration_date": "2014-04-05", + "restricted_access": 0, + "sap_energy_source": { + "electricity_tariff": 1, + "wind_turbines_count": 0, + "wind_turbine_terrain_type": 1, + "fixed_lighting_outlets_count": 16, + "low_energy_fixed_lighting_outlets_count": 16, + "low_energy_fixed_lighting_outlets_percentage": 100 + }, + "sap_opening_types": [ + { + "name": "Doors (1)", + "type": 1, + "u_value": 1.4, + "data_source": 3, + "glazing_type": 1 + }, + { + "name": "Doors (2)", + "type": 1, + "u_value": 1.5, + "data_source": 2, + "glazing_type": 1 + }, + { + "name": "Windows (1)", + "type": 4, + "u_value": 1.5, + "data_source": 2, + "frame_factor": 0.7, + "glazing_type": 6, + "solar_transmittance": 0.63 + } + ], + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 9 + ], + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof (1)", + "u_value": 0.11, + "roof_type": 2, + "description": "Pitched roof insulation at ceiling level", + "kappa_value": 8.75, + "total_roof_area": 41.96 + }, + { + "name": "Roof (2)", + "u_value": 0.2, + "roof_type": 2, + "description": "Flat roof - internal insulation", + "kappa_value": 8.75, + "total_roof_area": 4.69 + } + ], + "sap_walls": [ + { + "name": "Walls (1)", + "u_value": 0.29, + "wall_type": 2, + "description": "Masonry cavity wall", + "kappa_value": 46.25, + "total_wall_area": 26.5, + "is_curtain_walling": "false" + }, + { + "name": "Walls (2)", + "u_value": 0.28, + "wall_type": 2, + "description": "Timber frame wall", + "kappa_value": 8.75, + "total_wall_area": 48, + "is_curtain_walling": "false" + }, + { + "name": "Walls (3)", + "u_value": 0.22, + "wall_type": 3, + "description": "Insulated wall to garage", + "kappa_value": 8.75, + "total_wall_area": 7.29, + "is_curtain_walling": "false" + }, + { + "name": "Party wall (1)", + "u_value": 0, + "wall_type": 4, + "kappa_value": 17.5, + "total_wall_area": 48.6 + } + ], + "identifier": "Main Dwelling", + "overshading": 2, + "sap_openings": [ + { + "name": 1, + "type": "Doors (2)", + "width": 2.69, + "height": 2.1, + "location": "Walls (2)", + "orientation": 7 + }, + { + "name": 2, + "type": "Windows (1)", + "width": 0.46, + "height": 1.88, + "location": "Walls (2)", + "orientation": 7 + }, + { + "name": 3, + "type": "Windows (1)", + "width": 0.67, + "height": 0.9, + "location": "Walls (2)", + "orientation": 3 + }, + { + "name": 4, + "type": "Windows (1)", + "width": 0.46, + "height": 1.88, + "location": "Walls (2)", + "orientation": 7 + }, + { + "name": 5, + "type": "Windows (1)", + "width": 1.45, + "height": 1.35, + "location": "Walls (2)", + "orientation": 3 + }, + { + "name": 6, + "type": "Windows (1)", + "width": 1.45, + "height": 1.35, + "location": "Walls (2)", + "orientation": 3 + }, + { + "name": 7, + "type": "Doors (1)", + "width": 1.01, + "height": 2, + "location": "Walls (3)", + "orientation": 0 + }, + { + "name": 8, + "type": "Doors (2)", + "width": 1.74, + "height": 2.1, + "location": "Walls (2)", + "orientation": 7 + }, + { + "name": 9, + "type": "Doors (2)", + "width": 1.79, + "height": 2.1, + "location": "Walls (2)", + "orientation": 3 + }, + { + "name": 10, + "type": "Doors (1)", + "width": 1.01, + "height": 2.1, + "location": "Walls (1)", + "orientation": 0 + } + ], + "construction_year": 2013, + "sap_thermal_bridges": { + "thermal_bridges": [ + { + "length": 9.8, + "psi_value": 0.06, + "psi_value_source": 2, + "thermal_bridge_type": 10 + }, + { + "length": 4.2, + "psi_value": 0.04, + "psi_value_source": 2, + "thermal_bridge_type": 19 + }, + { + "length": 2.6, + "psi_value": 0.28, + "psi_value_source": 2, + "thermal_bridge_type": 20 + }, + { + "length": 6, + "psi_value": 0.08, + "psi_value_source": 2, + "thermal_bridge_type": 22 + }, + { + "length": 33.6, + "psi_value": 0, + "psi_value_source": 2, + "thermal_bridge_type": 23 + }, + { + "length": 25.86, + "psi_value": 0.05, + "psi_value_source": 2, + "thermal_bridge_type": 4 + }, + { + "length": 12.73, + "psi_value": 0.3, + "psi_value_source": 2, + "thermal_bridge_type": 2 + }, + { + "length": 12.73, + "psi_value": 0.04, + "psi_value_source": 2, + "thermal_bridge_type": 3 + } + ], + "thermal_bridge_code": 5 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.16, + "floor_type": 2, + "kappa_value": 30, + "storey_height": 2.4, + "heat_loss_area": 11.8, + "total_floor_area": 11.72 + }, + { + "storey": 1, + "u_value": 0.17, + "floor_type": 3, + "kappa_value": 30, + "storey_height": 2.4, + "heat_loss_area": 24.68, + "total_floor_area": 41.95 + }, + { + "storey": 2, + "u_value": 0, + "floor_type": 4, + "kappa_value": 0, + "storey_height": 2.4, + "heat_loss_area": 0, + "total_floor_area": 41.95 + } + ] + } + ], + "bedf_revision_number": 333, + "heating_cost_current": 260, + "co2_emissions_current": 1.5, + "energy_rating_average": 60, + "energy_rating_current": 83, + "lighting_cost_current": 64, + "main_heating_controls": [ + { + "description": "Time and temperature zone control", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": 260, + "hot_water_cost_current": 93, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 37, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 3, + "energy_performance_rating": 84, + "environmental_impact_rating": 88 + }, + { + "sequence": 2, + "typical_saving": 226, + "indicative_cost": "£11,000 - £20,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 3, + "energy_performance_rating": 94, + "environmental_impact_rating": 96 + } + ], + "co2_emissions_potential": 1.5, + "energy_rating_potential": 83, + "lighting_cost_potential": 64, + "hot_water_cost_potential": 93, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 3127, + "water_heating": 2230 + } + }, + "seller_commission_report": "N", + "energy_consumption_current": 85, + "has_fixed_air_conditioning": "false", + "calculation_software_version": 3.59, + "energy_consumption_potential": 85, + "environmental_impact_current": 85, + "current_energy_efficiency_band": "B", + "environmental_impact_potential": 85, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 16 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-17.0/epc.json b/backend/epc_api/json_samples/SAP-Schema-17.0/epc.json new file mode 100644 index 00000000..12957001 --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-17.0/epc.json @@ -0,0 +1,375 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Average thermal transmittance 0.14 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.28 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.19 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "status": "entered", + "tenure": "ND", + "windows": { + "description": "High performance glazing", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "lighting": { + "description": "Low energy lighting in all fixed outlets", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "PT34 5BG", + "data_type": 2, + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "POSTTOWN", + "built_form": 1, + "living_area": 18.13, + "orientation": 1, + "region_code": 6, + "report_type": 3, + "sap_heating": { + "thermal_store": 1, + "water_fuel_type": 1, + "water_heating_code": 901, + "hot_water_store_size": 180, + "main_heating_details": [ + { + "main_fuel_type": 1, + "heat_emitter_type": 1, + "emitter_temperature": 1, + "is_flue_fan_present": "true", + "main_heating_number": 1, + "main_heating_control": 2110, + "is_interlocked_system": "true", + "main_heating_category": 2, + "main_heating_fraction": 1, + "main_heating_flue_type": 2, + "central_heating_pump_age": 2, + "main_heating_data_source": 1, + "main_heating_index_number": 10321, + "has_separate_delayed_start": "false", + "load_or_weather_compensation": 0, + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "true", + "has_cylinder_thermostat": "true", + "hot_water_store_heat_loss": 1.48, + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1, + "is_cylinder_in_heated_space": "true", + "primary_pipework_insulation": 4, + "is_hot_water_separately_timed": "true", + "hot_water_store_heat_loss_source": 2 + }, + "sap_version": 9.92, + "schema_type": "SAP-Schema-17.0", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "Air permeability 4.9 m³/h.m² (as tested)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "dwelling_type": "Detached house", + "language_code": 1, + "property_type": 0, + "address_line_1": "2, Place Park", + "address_line_2": "Central District", + "assessment_date": "2013-07-08", + "assessment_type": "SAP", + "completion_date": "2015-06-30", + "inspection_date": "2015-06-30", + "sap_ventilation": { + "psv_count": 0, + "pressure_test": 1, + "air_permeability": 4.94, + "open_flues_count": 0, + "ventilation_type": 1, + "extract_fans_count": 5, + "open_fireplaces_count": 0, + "sheltered_sides_count": 2, + "flueless_gas_fires_count": 0 + }, + "sap_data_version": 9.9, + "sap_flat_details": { + "level": 1 + }, + "total_floor_area": 139, + "transaction_type": 6, + "conservatory_type": 1, + "registration_date": "2015-06-30", + "sap_energy_source": { + "electricity_tariff": 1, + "wind_turbines_count": 0, + "wind_turbine_terrain_type": 1, + "fixed_lighting_outlets_count": 20, + "low_energy_fixed_lighting_outlets_count": 20, + "low_energy_fixed_lighting_outlets_percentage": 100 + }, + "sap_opening_types": [ + { + "name": "Opening Type 1", + "type": 4, + "u_value": 1.5, + "data_source": 2, + "frame_factor": 0.7, + "glazing_type": 3, + "solar_transmittance": 0.76 + }, + { + "name": "Opening Type 2", + "type": 5, + "u_value": 1.5, + "data_source": 2, + "frame_factor": 0.7, + "glazing_type": 3, + "solar_transmittance": 0.76 + } + ], + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 10 + ], + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof 1", + "u_value": 0.14, + "roof_type": 2, + "description": "External Roof 1", + "total_roof_area": 69.63 + } + ], + "sap_walls": [ + { + "name": "External Wall 1", + "u_value": 0.28, + "wall_type": 2, + "description": "External Wall 1", + "total_wall_area": 197.76, + "is_curtain_walling": "false" + } + ], + "identifier": "Main Dwelling", + "overshading": 2, + "sap_openings": [ + { + "name": "Opening 1", + "type": "Opening Type 1", + "width": 7.47, + "height": 1, + "location": "External Wall 1", + "orientation": 1 + }, + { + "name": "Opening 2", + "type": "Opening Type 1", + "width": 6.88, + "height": 1, + "location": "External Wall 1", + "orientation": 5 + }, + { + "name": "Opening 3", + "type": "Opening Type 1", + "width": 5.44, + "height": 1, + "location": "External Wall 1", + "orientation": 7 + }, + { + "name": "Opening 4", + "type": "Opening Type 1", + "width": 6.88, + "height": 1, + "location": "External Wall 1", + "orientation": 3 + }, + { + "name": "Opening 5", + "type": "Opening Type 2", + "pitch": 35, + "width": 1.77, + "height": 1, + "location": "Roof 1", + "orientation": 7 + }, + { + "name": "Opening 6", + "type": "Opening Type 2", + "pitch": 35, + "width": 0.59, + "height": 1, + "location": "Roof 1", + "orientation": 3 + } + ], + "construction_year": 2013, + "sap_thermal_bridges": { + "thermal_bridges": [ + { + "length": 41.2, + "psi_value": 0.32, + "psi_value_source": 4, + "thermal_bridge_type": "E5" + }, + { + "length": 41.2, + "psi_value": 0.14, + "psi_value_source": 4, + "thermal_bridge_type": "E6" + }, + { + "length": 19.2, + "psi_value": 0.18, + "psi_value_source": 4, + "thermal_bridge_type": "E16" + } + ], + "thermal_bridge_code": 5 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.19, + "floor_type": 2, + "description": "Heat Loss Floor 1", + "storey_height": 2.4, + "heat_loss_area": 69.63, + "total_floor_area": 69.63 + }, + { + "storey": 1, + "u_value": 0, + "floor_type": 3, + "storey_height": 2.4, + "heat_loss_area": 0, + "total_floor_area": 69.63 + } + ], + "thermal_mass_parameter": 100 + } + ], + "heating_cost_current": { + "value": 378, + "currency": "GBP" + }, + "co2_emissions_current": 2.2, + "energy_rating_average": 60, + "energy_rating_current": 84, + "lighting_cost_current": { + "value": 73, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Time and temperature zone control", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 379, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 114, + "currency": "GBP" + }, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 52, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 85, + "environmental_impact_rating": 86 + }, + { + "sequence": 2, + "typical_saving": { + "value": 275, + "currency": "GBP" + }, + "indicative_cost": "£5,000 - £8,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 92, + "environmental_impact_rating": 92 + } + ], + "co2_emissions_potential": 1.0, + "energy_rating_potential": 92, + "lighting_cost_potential": { + "value": 73, + "currency": "GBP" + }, + "schema_version_original": "LIG-17.0", + "hot_water_cost_potential": { + "value": 62, + "currency": "GBP" + }, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 5622, + "water_heating": 2279 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 91, + "has_fixed_air_conditioning": "false", + "multiple_glazed_percentage": 100, + "calculation_software_version": "4.03r03", + "energy_consumption_potential": 39, + "environmental_impact_current": 84, + "current_energy_efficiency_band": "B", + "environmental_impact_potential": 92, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "A", + "co2_emissions_current_per_floor_area": 16 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-17.1/epc.json b/backend/epc_api/json_samples/SAP-Schema-17.1/epc.json new file mode 100644 index 00000000..b690d220 --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-17.1/epc.json @@ -0,0 +1,562 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Average thermal transmittance 0.11 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.27 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.14 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "status": "entered", + "tenure": "ND", + "windows": { + "description": "High performance glazing", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "lighting": { + "description": "Low energy lighting in all fixed outlets", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "A1 1AA", + "data_type": 2, + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Town", + "built_form": 1, + "living_area": 20.36, + "orientation": 1, + "region_code": 6, + "report_type": 3, + "sap_heating": { + "thermal_store": 1, + "water_fuel_type": 1, + "water_heating_code": 901, + "hot_water_store_size": 180, + "main_heating_details": [ + { + "main_fuel_type": 1, + "heat_emitter_type": 1, + "emitter_temperature": 1, + "is_flue_fan_present": "true", + "main_heating_number": 1, + "main_heating_control": 2110, + "is_interlocked_system": "true", + "main_heating_category": 2, + "main_heating_fraction": 1, + "main_heating_flue_type": 2, + "central_heating_pump_age": 2, + "main_heating_data_source": 1, + "main_heating_index_number": 18042, + "has_separate_delayed_start": "true", + "load_or_weather_compensation": 0, + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "true", + "has_cylinder_thermostat": "true", + "hot_water_store_heat_loss": 1.2, + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1, + "is_cylinder_in_heated_space": "true", + "primary_pipework_insulation": 4, + "is_hot_water_separately_timed": "true", + "hot_water_store_heat_loss_source": 2 + }, + "sap_version": 9.92, + "schema_type": "SAP-Schema-17.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "Air permeability 4.9 m³/h.m² (as tested)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "dwelling_type": "Detached house", + "language_code": 1, + "property_type": 0, + "address_line_1": "47, Address Lane", + "address_line_2": "Long Itchington", + "assessment_date": "2020-08-17", + "assessment_type": "SAP", + "completion_date": "2020-08-17", + "inspection_date": "2020-08-17", + "sap_ventilation": { + "psv_count": 0, + "pressure_test": 1, + "air_permeability": 4.94, + "open_flues_count": 0, + "ventilation_type": 1, + "extract_fans_count": 5, + "open_fireplaces_count": 0, + "sheltered_sides_count": 1, + "flueless_gas_fires_count": 0 + }, + "design_water_use": 1, + "sap_data_version": 9.92, + "sap_flat_details": { + "level": 1 + }, + "total_floor_area": 146, + "transaction_type": 6, + "conservatory_type": 1, + "registration_date": "2020-08-17", + "sap_energy_source": { + "electricity_tariff": 1, + "wind_turbines_count": 0, + "wind_turbine_terrain_type": 2, + "fixed_lighting_outlets_count": 14, + "low_energy_fixed_lighting_outlets_count": 14, + "low_energy_fixed_lighting_outlets_percentage": 100 + }, + "sap_opening_types": [ + { + "name": "Opening Type 1", + "type": 2, + "u_value": 1.5, + "data_source": 2, + "glazing_type": 4 + }, + { + "name": "Opening Type 2", + "type": 4, + "u_value": 1.41, + "data_source": 2, + "frame_factor": 0.7, + "glazing_type": 4, + "solar_transmittance": 0.71 + }, + { + "name": "Opening Type 4", + "type": 4, + "u_value": 1.41, + "data_source": 2, + "frame_factor": 0.7, + "glazing_type": 4, + "solar_transmittance": 0.72 + } + ], + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof 1", + "u_value": 0.11, + "roof_type": 2, + "description": "400mm Mineral Wool", + "total_roof_area": 72.6 + }, + { + "name": "Roof 2", + "u_value": 0.17, + "roof_type": 2, + "description": "Flat Roof", + "total_roof_area": 1.07 + } + ], + "sap_walls": [ + { + "name": "External Wall 1", + "u_value": 0.27, + "wall_type": 2, + "description": "50mm A Platinum", + "total_wall_area": 186.21, + "is_curtain_walling": "false" + } + ], + "identifier": "Main Dwelling", + "overshading": 2, + "sap_openings": [ + { + "name": "D1", + "type": "Opening Type 1", + "width": 1.01, + "height": 2.33, + "location": "External Wall 1", + "orientation": 0 + }, + { + "name": "D2", + "type": "Opening Type 2", + "width": 2.4, + "height": 2.1, + "location": "External Wall 1", + "orientation": 6 + }, + { + "name": "D3", + "type": "Opening Type 2", + "width": 2.4, + "height": 2.1, + "location": "External Wall 1", + "orientation": 4 + }, + { + "name": "W1", + "type": "Opening Type 4", + "width": 1.14, + "height": 1.5, + "location": "External Wall 1", + "orientation": 2 + }, + { + "name": "W1a", + "type": "Opening Type 4", + "width": 0.56, + "height": 1.5, + "location": "External Wall 1", + "orientation": 3 + }, + { + "name": "W1b", + "type": "Opening Type 4", + "width": 0.56, + "height": 1.5, + "location": "External Wall 1", + "orientation": 1 + }, + { + "name": "W2", + "type": "Opening Type 4", + "width": 1.14, + "height": 1.5, + "location": "External Wall 1", + "orientation": 2 + }, + { + "name": "W2a", + "type": "Opening Type 4", + "width": 0.56, + "height": 1.5, + "location": "External Wall 1", + "orientation": 3 + }, + { + "name": "W2b", + "type": "Opening Type 4", + "width": 0.56, + "height": 1.5, + "location": "External Wall 1", + "orientation": 1 + }, + { + "name": "W3", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 8 + }, + { + "name": "W4", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 8 + }, + { + "name": "W5", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 8 + }, + { + "name": "W6", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 6 + }, + { + "name": "W7", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 2 + }, + { + "name": "W8", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.2, + "location": "External Wall 1", + "orientation": 2 + }, + { + "name": "W9", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 2 + }, + { + "name": "W10", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 8 + }, + { + "name": "W11", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 8 + }, + { + "name": "W12", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 8 + }, + { + "name": "W13", + "type": "Opening Type 4", + "width": 0.63, + "height": 1.05, + "location": "External Wall 1", + "orientation": 4 + }, + { + "name": "W14", + "type": "Opening Type 4", + "width": 1.02, + "height": 1.35, + "location": "External Wall 1", + "orientation": 6 + } + ], + "construction_year": 2017, + "sap_thermal_bridges": { + "thermal_bridges": [ + { + "length": 21.8, + "psi_value": 0.211, + "psi_value_source": 3, + "thermal_bridge_type": "E2" + }, + { + "length": 6.12, + "psi_value": 0.049, + "psi_value_source": 3, + "thermal_bridge_type": "E2" + }, + { + "length": 20.79, + "psi_value": 0.023, + "psi_value_source": 3, + "thermal_bridge_type": "E3" + }, + { + "length": 63.76, + "psi_value": 0.028, + "psi_value_source": 3, + "thermal_bridge_type": "E4" + }, + { + "length": 38.46, + "psi_value": 0.047, + "psi_value_source": 3, + "thermal_bridge_type": "E5" + }, + { + "length": 3.48, + "psi_value": 0.14, + "psi_value_source": 4, + "thermal_bridge_type": "E6" + }, + { + "length": 34.33, + "psi_value": 0.003, + "psi_value_source": 3, + "thermal_bridge_type": "E6" + }, + { + "length": 26.68, + "psi_value": 0.094, + "psi_value_source": 3, + "thermal_bridge_type": "E10" + }, + { + "length": 9.4, + "psi_value": 0.049, + "psi_value_source": 3, + "thermal_bridge_type": "E12" + }, + { + "length": 4.41, + "psi_value": 0.08, + "psi_value_source": 4, + "thermal_bridge_type": "E14" + }, + { + "length": 34.2, + "psi_value": 0.047, + "psi_value_source": 3, + "thermal_bridge_type": "E16" + }, + { + "length": 15.36, + "psi_value": -0.097, + "psi_value_source": 3, + "thermal_bridge_type": "E17" + } + ], + "thermal_bridge_code": 5 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.14, + "floor_type": 2, + "description": "B&B", + "storey_height": 2.33, + "heat_loss_area": 73.67, + "total_floor_area": 73.67 + }, + { + "storey": 1, + "u_value": 0, + "floor_type": 3, + "storey_height": 2.56, + "heat_loss_area": 0, + "total_floor_area": 72.6 + } + ], + "thermal_mass_parameter": 134.43 + } + ], + "heating_cost_current": { + "value": 339, + "currency": "GBP" + }, + "co2_emissions_current": 2.1, + "energy_rating_average": 60, + "energy_rating_current": 85, + "lighting_cost_current": { + "value": 93, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Time and temperature zone control", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 340, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 99, + "currency": "GBP" + }, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 43, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 86, + "environmental_impact_rating": 87 + }, + { + "sequence": 2, + "typical_saving": { + "value": 340, + "currency": "GBP" + }, + "indicative_cost": "£3,500 - £5,500", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 93, + "environmental_impact_rating": 93 + } + ], + "co2_emissions_potential": 0.9, + "energy_rating_potential": 93, + "lighting_cost_potential": { + "value": 93, + "currency": "GBP" + }, + "schema_version_original": "LIG-17.0", + "hot_water_cost_potential": { + "value": 55, + "currency": "GBP" + }, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 5361, + "water_heating": 2143 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 82, + "has_fixed_air_conditioning": "false", + "multiple_glazed_percentage": 100, + "calculation_software_version": "4.12r02", + "energy_consumption_potential": 34, + "environmental_impact_current": 85, + "current_energy_efficiency_band": "B", + "environmental_impact_potential": 93, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "A", + "co2_emissions_current_per_floor_area": 14 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-18.0.0/epc.json b/backend/epc_api/json_samples/SAP-Schema-18.0.0/epc.json new file mode 100644 index 00000000..b2d27bbf --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-18.0.0/epc.json @@ -0,0 +1,510 @@ +{ + "name": "-", + "uprn": 12457, + "roofs": [ + { + "description": "Average thermal transmittance 0.10 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.10 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.09 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "status": "entered", + "tenure": "ND", + "windows": { + "description": "High performance glazing", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "addendum": { + "stone_walls": "true", + "system_build": "true" + }, + "lighting": { + "description": "Low energy lighting in all fixed outlets", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "LS0 0AA", + "data_type": 2, + "hot_water": { + "description": "Electric immersion, standard tariff", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 2 + }, + "post_town": "Town", + "built_form": 4, + "living_area": 38.91, + "orientation": 6, + "region_code": 3, + "report_type": 3, + "sap_heating": { + "water_fuel_type": 39, + "water_heating_code": 903, + "hot_water_store_size": 210, + "main_heating_details": [ + { + "main_fuel_type": 39, + "main_heating_code": 691, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2603, + "main_heating_category": 10, + "main_heating_fraction": 1, + "main_heating_data_source": 3 + } + ], + "has_hot_water_cylinder": "true", + "immersion_heating_type": 1, + "has_cylinder_thermostat": "true", + "hot_water_store_heat_loss": 1.31, + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1, + "is_cylinder_in_heated_space": "true", + "hot_water_store_heat_loss_source": 2 + }, + "sap_version": 9.92, + "schema_type": "SAP-Schema-18.0.0", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Room heaters, electric", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 2 + } + ], + "air_tightness": { + "description": "Air permeability 0.7 m³/h.m² (as tested)", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "30 Lovely Place", + "address_line_2": "Nice Road", + "assessment_date": "2021-01-01", + "assessment_type": "SAP", + "completion_date": "2021-01-01", + "inspection_date": "2021-01-01", + "sap_ventilation": { + "psv_count": 0, + "pressure_test": 1, + "wet_rooms_count": 3, + "air_permeability": 0.69, + "open_flues_count": 0, + "ventilation_type": 8, + "extract_fans_count": 0, + "open_fireplaces_count": 0, + "sheltered_sides_count": 3, + "flueless_gas_fires_count": 0, + "mechanical_vent_duct_type": 2, + "mechanical_vent_duct_insulation": 2, + "mechanical_ventilation_data_source": 1, + "mechanical_vent_system_index_number": 500482, + "is_mechanical_vent_approved_installer_scheme": "true" + }, + "design_water_use": 1, + "sap_data_version": 9.92, + "sap_flat_details": { + "level": 1 + }, + "total_floor_area": 117, + "transaction_type": 6, + "conservatory_type": 1, + "registration_date": "2021-01-01", + "sap_energy_source": { + "pv_arrays": [ + { + "pitch": 2, + "peak_power": 0.75, + "orientation": 6, + "overshading": 1, + "pv_connection": 2 + } + ], + "electricity_tariff": 1, + "wind_turbines_count": 0, + "wind_turbine_terrain_type": 1, + "fixed_lighting_outlets_count": 20, + "low_energy_fixed_lighting_outlets_count": 20, + "low_energy_fixed_lighting_outlets_percentage": 100 + }, + "sap_opening_types": [ + { + "name": "Doors", + "type": 1, + "u_value": 0.75, + "data_source": 2, + "glazing_type": 1 + }, + { + "name": "Windows - W-001A", + "type": 4, + "u_value": 0.72, + "data_source": 2, + "frame_factor": 0.85, + "glazing_type": 12, + "solar_transmittance": 0.52 + }, + { + "name": "Windows - W-001B", + "type": 4, + "u_value": 0.73, + "data_source": 2, + "frame_factor": 0.84, + "glazing_type": 12, + "solar_transmittance": 0.52 + }, + { + "name": "W-003 Louvre system", + "type": 4, + "u_value": 1.02, + "data_source": 2, + "frame_factor": 0.01, + "glazing_type": 12, + "solar_transmittance": 0 + }, + { + "name": "Window - W-003R", + "type": 4, + "u_value": 0.82, + "data_source": 2, + "frame_factor": 0.78, + "glazing_type": 12, + "solar_transmittance": 0.53 + }, + { + "name": "Sliding door - EX002A", + "type": 4, + "u_value": 0.86, + "data_source": 2, + "frame_factor": 0.85, + "glazing_type": 12, + "solar_transmittance": 0.52 + }, + { + "name": "EX002B Louvre system", + "type": 4, + "u_value": 1, + "data_source": 2, + "frame_factor": 0.01, + "glazing_type": 12, + "solar_transmittance": 0 + }, + { + "name": "Window - EX002B", + "type": 4, + "u_value": 0.76, + "data_source": 2, + "frame_factor": 0.84, + "glazing_type": 12, + "solar_transmittance": 0.53 + } + ], + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11, + 9 + ], + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof 1", + "u_value": 0.1, + "roof_type": 2, + "description": "External Roof 1", + "total_roof_area": 38.91 + } + ], + "sap_walls": [ + { + "name": "External Wall 1", + "u_value": 0.1, + "wall_type": 2, + "description": "External Wall 1", + "total_wall_area": 77.31, + "is_curtain_walling": "false" + }, + { + "name": "Party Wall 0", + "u_value": 0, + "wall_type": 4, + "total_wall_area": 129.44 + } + ], + "identifier": "Main Dwelling", + "overshading": 2, + "sap_openings": [ + { + "name": "Opening 1", + "type": "Doors", + "width": 2.23, + "height": 1, + "location": "External Wall 1", + "orientation": 0 + }, + { + "name": "Opening 2", + "type": "Windows - W-001A", + "width": 2.3, + "height": 1, + "location": "External Wall 1", + "orientation": 6 + }, + { + "name": "Opening 3", + "type": "W-003 Louvre system", + "width": 0.63, + "height": 1, + "location": "External Wall 1", + "orientation": 6 + }, + { + "name": "Opening 4", + "type": "Window - W-003R", + "width": 2.38, + "height": 1, + "location": "External Wall 1", + "orientation": 6 + }, + { + "name": "Opening 5", + "type": "Sliding door - EX002A", + "width": 5.37, + "height": 1, + "location": "External Wall 1", + "orientation": 6 + }, + { + "name": "Opening 6", + "type": "Windows - W-001A", + "width": 2.3, + "height": 1, + "location": "External Wall 1", + "orientation": 2 + }, + { + "name": "Opening 7", + "type": "Windows - W-001B", + "width": 2.06, + "height": 1, + "location": "External Wall 1", + "orientation": 2 + }, + { + "name": "Opening 8", + "type": "Window - W-003R", + "width": 1.18, + "height": 1, + "location": "External Wall 1", + "orientation": 2 + }, + { + "name": "Opening 9", + "type": "EX002B Louvre system", + "width": 0.95, + "height": 1, + "location": "External Wall 1", + "orientation": 2 + }, + { + "name": "Opening 10", + "type": "Window - EX002B", + "width": 4.73, + "height": 1, + "location": "External Wall 1", + "orientation": 2 + } + ], + "construction_year": 2017, + "sap_thermal_bridges": { + "thermal_bridges": [ + { + "length": 13.13, + "psi_value": 0.07, + "psi_value_source": 3, + "thermal_bridge_type": "E2" + }, + { + "length": 9.7, + "psi_value": 0.055, + "psi_value_source": 3, + "thermal_bridge_type": "E3" + }, + { + "length": 31.33, + "psi_value": 0.066, + "psi_value_source": 3, + "thermal_bridge_type": "E4" + }, + { + "length": 9.64, + "psi_value": 0.074, + "psi_value_source": 3, + "thermal_bridge_type": "E20" + }, + { + "length": 19.28, + "psi_value": 0.049, + "psi_value_source": 3, + "thermal_bridge_type": "E6" + }, + { + "length": 9.84, + "psi_value": 0.097, + "psi_value_source": 3, + "thermal_bridge_type": "E15" + }, + { + "length": 32.08, + "psi_value": 0.02, + "psi_value_source": 3, + "thermal_bridge_type": "E18" + }, + { + "length": 32.28, + "psi_value": 0, + "psi_value_source": 4, + "thermal_bridge_type": "P2" + }, + { + "length": 16.14, + "psi_value": 0.16, + "psi_value_source": 4, + "thermal_bridge_type": "P7" + }, + { + "length": 16.14, + "psi_value": 0.005, + "psi_value_source": 3, + "thermal_bridge_type": "P4" + } + ], + "thermal_bridge_code": 5 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.09, + "floor_type": 3, + "description": "Heat Loss Floor 1", + "storey_height": 2.48, + "heat_loss_area": 38.91, + "total_floor_area": 38.91 + }, + { + "storey": 1, + "u_value": 0, + "floor_type": 3, + "storey_height": 2.8, + "heat_loss_area": 0, + "total_floor_area": 38.91 + }, + { + "storey": 2, + "u_value": 0, + "floor_type": 3, + "storey_height": 2.74, + "heat_loss_area": 0, + "total_floor_area": 38.91 + } + ], + "thermal_mass_parameter": 100 + } + ], + "heating_cost_current": { + "value": 133, + "currency": "GBP" + }, + "co2_emissions_current": 1.3, + "energy_rating_average": 60, + "energy_rating_current": 88, + "lighting_cost_current": { + "value": 86, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer and appliance thermostats", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 136, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 361, + "currency": "GBP" + }, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 184, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 93, + "environmental_impact_rating": 94 + } + ], + "co2_emissions_potential": 0.8, + "energy_rating_potential": 93, + "lighting_cost_potential": { + "value": 86, + "currency": "GBP" + }, + "schema_version_original": "18.0.0", + "hot_water_cost_potential": { + "value": 175, + "currency": "GBP" + }, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 413, + "water_heating": 1890 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 64, + "has_fixed_air_conditioning": "false", + "multiple_glazed_percentage": 100, + "calculation_software_version": "4.14r16", + "energy_consumption_potential": 39, + "environmental_impact_current": 89, + "current_energy_efficiency_band": "B", + "environmental_impact_potential": 94, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "A", + "co2_emissions_current_per_floor_area": 11 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-19.0.0/epc.json b/backend/epc_api/json_samples/SAP-Schema-19.0.0/epc.json new file mode 100644 index 00000000..66e58247 --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-19.0.0/epc.json @@ -0,0 +1,610 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Average thermal transmittance 0.13 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.18 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.12 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "status": "entered", + "tenure": 1, + "windows": { + "description": "High performance glazing", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 5 + }, + "addendum": { + "stone_walls": "true" + }, + "lighting": { + "description": "Energy saving bulbs", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "postcode": "A0 0AA", + "data_type": 1, + "hot_water": { + "description": "From main system, waste water heat recovery", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 3 + }, + "post_town": "Whitbury", + "built_form": 4, + "living_area": 41.35, + "orientation": 0, + "region_code": 16, + "report_type": 3, + "sap_heating": { + "thermal_store": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_flow_rate": 7, + "shower_outlet_type": 1 + }, + { + "shower_wwhrs": 2, + "shower_flow_rate": 7, + "shower_outlet_type": 1 + } + ], + "water_fuel_type": 39, + "water_heating_code": 901, + "instantaneous_wwhrs": { + "wwhrs_index_number1": 491123 + }, + "hot_water_store_size": 600, + "main_heating_details": [ + { + "has_fghrs": "false", + "main_fuel_type": 39, + "combi_boiler_type": 4, + "heat_emitter_type": 1, + "main_heating_code": 192, + "main_heating_number": 1, + "is_condensing_boiler": "false", + "main_heating_control": 2106, + "is_interlocked_system": "false", + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 2, + "main_heating_data_source": 3, + "has_separate_delayed_start": "true", + "is_oil_pump_in_heated_space": "false", + "is_main_heating_hetas_approved": "false", + "electric_cpsu_operating_temperature": 80, + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "true", + "has_cylinder_thermostat": "true", + "hot_water_store_heat_loss": 2.45, + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1, + "is_immersion_for_summer_use": "false", + "is_hot_water_separately_timed": "false", + "hot_water_store_heat_loss_source": 2, + "is_heat_pump_assisted_by_immersion": "false" + }, + "sap_version": 10.2, + "schema_type": "SAP-Schema-19.0.0", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, electric", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 2 + } + ], + "sap_lighting": [ + [ + { + "lighting_power": 60, + "lighting_outlets": 1, + "lighting_efficacy": 11.2 + }, + { + "lighting_power": 14, + "lighting_outlets": 10, + "lighting_efficacy": 66.9 + }, + { + "lighting_power": 15, + "lighting_outlets": 7, + "lighting_efficacy": 69.9 + } + ] + ], + "terrain_type": 1, + "air_tightness": { + "description": "Air permeability 2.0 m³/h.m² (assumed)", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "1 Some Street", + "address_line_2": "Some Area", + "address_line_3": "Some County", + "assessment_date": "2022-05-09", + "assessment_type": "SAP", + "completion_date": "2022-05-09", + "inspection_date": "2022-05-09", + "sap_ventilation": { + "psv_count": 0, + "wall_type": 2, + "pressure_test": 2, + "wet_rooms_count": 2, + "air_permeability": 2, + "open_flues_count": 0, + "ventilation_type": 8, + "has_draught_lobby": "false", + "other_flues_count": 0, + "closed_flues_count": 0, + "extract_fans_count": 0, + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "sheltered_sides_count": 2, + "blocked_chimneys_count": 0, + "flueless_gas_fires_count": 0, + "mechanical_vent_duct_type": 2, + "mechanical_vent_duct_placement": 1, + "mechanical_ventilation_data_source": 1, + "mechanical_vent_system_index_number": 500206, + "mechanical_vent_duct_insulation_level": 2, + "is_mechanical_vent_approved_installer_scheme": "true" + }, + "sap_data_version": 10.2, + "sap_flat_details": { + "level": 1, + "storeys": 1 + }, + "total_floor_area": 165, + "transaction_type": 1, + "cold_water_source": 1, + "conservatory_type": 1, + "registration_date": "2022-05-09", + "sap_energy_source": { + "wind_turbines": [ + { + "wind_turbine_hub_height": 3, + "wind_turbine_rotor_diameter": 1.7 + } + ], + "electricity_tariff": 4 + }, + "sap_opening_types": [ + { + "name": "Doors", + "type": 1, + "u_value": 1.5, + "data_source": 2, + "glazing_type": 1, + "isargonfilled": "false" + }, + { + "name": "Windows (1)", + "type": 4, + "u_value": 1.4, + "frame_type": 1, + "data_source": 3, + "glazing_gap": 3, + "frame_factor": 0.7, + "glazing_type": 11, + "isargonfilled": "true", + "solar_transmittance": 0.57 + }, + { + "name": "Windows (2)", + "type": 4, + "u_value": 1.5, + "frame_type": 1, + "data_source": 3, + "glazing_gap": 3, + "frame_factor": 0.7, + "glazing_type": 9, + "isargonfilled": "true", + "solar_transmittance": 0.64 + } + ], + "secondary_heating": { + "description": "Electric heater", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lowest_storey_area": 57.4, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof (1)", + "u_value": 0.13, + "roof_type": 2, + "kappa_value": 9, + "total_roof_area": 57.4 + } + ], + "sap_walls": [ + { + "name": "Walls (1)", + "u_value": 0.18, + "wall_type": 2, + "kappa_value": 60, + "total_wall_area": 111.34, + "is_curtain_walling": "false" + }, + { + "name": "Party wall", + "u_value": 0, + "wall_type": 4, + "kappa_value": 0, + "total_wall_area": 167, + "is_curtain_walling": "false" + }, + { + "name": "Internal wall (1)", + "u_value": 0, + "wall_type": 5, + "kappa_value": 0, + "total_wall_area": 320, + "is_curtain_walling": "false" + } + ], + "identifier": "Main Dwelling", + "sap_openings": [ + { + "name": 1, + "type": "Doors", + "width": 1, + "height": 2.25, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 2, + "type": "Doors", + "width": 0.8, + "height": 2.1, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 3, + "type": "Windows (2)", + "width": 1.6, + "height": 2.7, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 4, + "type": "Windows (1)", + "width": 1.1, + "height": 1.75, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 5, + "type": "Windows (1)", + "width": 1.1, + "height": 1.75, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 6, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 7, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 8, + "type": "Windows (1)", + "width": 1.1, + "height": 1.8, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 9, + "type": "Windows (1)", + "width": 1.1, + "height": 1.75, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 10, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 11, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 12, + "type": "Windows (1)", + "width": 0.6, + "height": 0.8, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 13, + "type": "Windows (1)", + "width": 0.8, + "height": 1.25, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 14, + "type": "Windows (1)", + "width": 0.8, + "height": 1.25, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 15, + "type": "Windows (1)", + "width": 1.1, + "height": 1.25, + "location": "Walls (1)", + "orientation": 7 + } + ], + "construction_year": 1750, + "sap_thermal_bridges": { + "thermal_bridges": [ + { + "length": 15.5, + "psi_value": 0.3, + "psi_value_source": 2, + "thermal_bridge_type": "E2" + }, + { + "length": 12.1, + "psi_value": 0.04, + "psi_value_source": 2, + "thermal_bridge_type": "E3" + }, + { + "length": 52.1, + "psi_value": 0.05, + "psi_value_source": 2, + "thermal_bridge_type": "E4" + }, + { + "length": 14.4, + "psi_value": 0.16, + "psi_value_source": 2, + "thermal_bridge_type": "E5" + }, + { + "length": 25.8, + "psi_value": 0.07, + "psi_value_source": 2, + "thermal_bridge_type": "E6" + }, + { + "length": 10.4, + "psi_value": 0.06, + "psi_value_source": 2, + "thermal_bridge_type": "E10" + }, + { + "length": 5.4, + "psi_value": 0.06, + "psi_value_source": 3, + "thermal_bridge_type": "E14" + }, + { + "length": 5.8, + "psi_value": 0.09, + "psi_value_source": 2, + "thermal_bridge_type": "E16" + }, + { + "length": 5.8, + "psi_value": -0.09, + "psi_value_source": 2, + "thermal_bridge_type": "E17" + }, + { + "length": 34, + "psi_value": 0.06, + "psi_value_source": 2, + "thermal_bridge_type": "E18" + }, + { + "length": 20.6, + "psi_value": 0.12, + "psi_value_source": 3, + "thermal_bridge_type": "P1" + }, + { + "length": 20.6, + "psi_value": 0, + "psi_value_source": 4, + "thermal_bridge_type": "P2" + }, + { + "length": 20.6, + "psi_value": 0.12, + "psi_value_source": 3, + "thermal_bridge_type": "P4" + } + ], + "thermal_bridge_code": 5 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.12, + "floor_type": 2, + "kappa_value": 80, + "storey_height": 2.8, + "heat_loss_area": 57.4, + "total_floor_area": 57.4 + }, + { + "storey": 1, + "u_value": 0, + "floor_type": 3, + "kappa_value": 18, + "storey_height": 3, + "heat_loss_area": 0, + "total_floor_area": 57.4, + "kappa_value_from_below": 9 + }, + { + "storey": 2, + "u_value": 0, + "floor_type": 3, + "kappa_value": 19, + "storey_height": 2.7, + "heat_loss_area": 0, + "total_floor_area": 57.4, + "kappa_value_from_below": 9 + } + ], + "construction_age_band": "A" + } + ], + "user_interface_name": "BRE SAP interface 10.2", + "windows_overshading": 2, + "heating_cost_current": { + "value": 365.98, + "currency": "GBP" + }, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 72, + "lighting_cost_current": { + "value": 123.45, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 250.34, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 200.4, + "currency": "GBP" + }, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 88, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 94 + }, + { + "sequence": 2, + "typical_saving": { + "value": 88, + "currency": "GBP" + }, + "indicative_cost": "£9,000 - £14,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 80, + "environmental_impact_rating": 96 + } + ], + "user_interface_version": "1.0.1-alpha", + "co2_emissions_potential": 1.4, + "energy_rating_potential": 72, + "gas_smart_meter_present": "false", + "lighting_cost_potential": { + "value": 84.23, + "currency": "GBP" + }, + "schema_version_original": "SAP-Schema-19.0.0", + "hot_water_cost_potential": { + "value": 180.43, + "currency": "GBP" + }, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 13120, + "water_heating": 2285 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 59, + "has_fixed_air_conditioning": "false", + "is_dwelling_export_capable": "true", + "multiple_glazed_percentage": 100, + "calculation_software_version": "13.05r16", + "energy_consumption_potential": 53, + "environmental_impact_current": 94, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 96, + "electricity_smart_meter_present": "false", + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 5.6 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-19.1.0/epc.json b/backend/epc_api/json_samples/SAP-Schema-19.1.0/epc.json new file mode 100644 index 00000000..2e94810f --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-19.1.0/epc.json @@ -0,0 +1,613 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Average thermal transmittance 0.13 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.18 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.12 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "status": "entered", + "tenure": 1, + "windows": { + "description": "High performance glazing", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "lighting": { + "description": "Low energy lighting in 91% of fixed outlets", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "A0 0AA", + "data_type": 1, + "hot_water": { + "description": "From main system, waste water heat recovery", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 3 + }, + "post_town": "Whitbury", + "built_form": 4, + "living_area": 41.35, + "orientation": 0, + "region_code": 16, + "report_type": 3, + "sap_heating": { + "thermal_store": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_flow_rate": 7, + "shower_outlet_type": 1 + }, + { + "shower_wwhrs": 2, + "shower_flow_rate": 7, + "shower_outlet_type": 1 + } + ], + "water_fuel_type": 39, + "water_heating_code": 901, + "instantaneous_wwhrs": { + "wwhrs_index_number1": 491123 + }, + "hot_water_store_size": 600, + "main_heating_details": [ + { + "has_fghrs": "false", + "main_fuel_type": 47, + "combi_boiler_type": 4, + "heat_emitter_type": 1, + "main_heating_code": 192, + "main_heating_number": 1, + "is_condensing_boiler": "false", + "main_heating_control": 2106, + "is_interlocked_system": "false", + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 2, + "main_heating_data_source": 3, + "has_separate_delayed_start": "true", + "is_oil_pump_in_heated_space": "false", + "is_main_heating_hetas_approved": "false", + "electric_cpsu_operating_temperature": 80, + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "true", + "has_cylinder_thermostat": "true", + "hot_water_store_heat_loss": 2.45, + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1, + "is_immersion_for_summer_use": "false", + "is_hot_water_separately_timed": "false", + "sap_community_heating_systems": [ + { + "sub_network_name": "Test 1", + "community_heating_use": 3, + "heat_network_existing": "true", + "heat_network_index_number": 496402, + "heat_network_assessed_as_new": "true", + "community_heating_distribution_type": 5, + "community_heating_distribution_loss_factor": 1.26 + } + ], + "hot_water_store_heat_loss_source": 2, + "is_heat_pump_assisted_by_immersion": "false" + }, + "sap_version": 10.2, + "schema_type": "SAP-Schema-19.1.0", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, electric", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 2 + } + ], + "sap_lighting": [ + [ + { + "lighting_power": 60, + "lighting_outlets": 1, + "lighting_efficacy": 11.2 + }, + { + "lighting_power": 14, + "lighting_outlets": 10, + "lighting_efficacy": 66.9 + } + ] + ], + "terrain_type": 1, + "air_tightness": { + "description": "Air permeability 2.0 m³/h.m² (assumed)", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "1 Some Street", + "address_line_2": "Some Area", + "address_line_3": "Some County", + "assessment_date": "2022-05-09", + "assessment_type": "SAP", + "completion_date": "2022-05-09", + "inspection_date": "2022-05-09", + "sap_ventilation": { + "psv_count": 0, + "wall_type": 2, + "pressure_test": 2, + "wet_rooms_count": 2, + "air_permeability": 2, + "open_flues_count": 0, + "ventilation_type": 8, + "has_draught_lobby": "false", + "other_flues_count": 0, + "closed_flues_count": 0, + "extract_fans_count": 0, + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "sheltered_sides_count": 2, + "blocked_chimneys_count": 0, + "flueless_gas_fires_count": 0, + "mechanical_vent_duct_type": 2, + "mechanical_vent_duct_placement": 1, + "mechanical_ventilation_data_source": 1, + "mechanical_vent_system_index_number": 500206, + "mechanical_vent_duct_insulation_level": 2, + "is_mechanical_vent_approved_installer_scheme": "true" + }, + "sap_data_version": 10.2, + "sap_flat_details": { + "level": 1, + "storeys": 1 + }, + "total_floor_area": 165, + "transaction_type": 1, + "cold_water_source": 1, + "conservatory_type": 1, + "registration_date": "2022-05-09", + "sap_energy_source": { + "wind_turbines": [ + { + "wind_turbine_hub_height": 3, + "wind_turbine_rotor_diameter": 1.7 + }, + { + "wind_turbine_hub_height": 3, + "wind_turbine_rotor_diameter": 1.7 + } + ], + "electricity_tariff": 5 + }, + "sap_opening_types": [ + { + "name": "Doors", + "type": 1, + "u_value": 1.5, + "data_source": 2, + "glazing_type": 1, + "isargonfilled": "false" + }, + { + "name": "Windows (1)", + "type": 4, + "u_value": 1.4, + "frame_type": 1, + "data_source": 3, + "glazing_gap": 3, + "frame_factor": 0.7, + "glazing_type": 11, + "isargonfilled": "true", + "solar_transmittance": 0.57 + }, + { + "name": "Windows (2)", + "type": 4, + "u_value": 1.5, + "frame_type": 1, + "data_source": 3, + "glazing_gap": 3, + "frame_factor": 0.7, + "glazing_type": 9, + "isargonfilled": "true", + "solar_transmittance": 0.64 + } + ], + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lowest_storey_area": 57.4, + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof (1)", + "u_value": 0.13, + "roof_type": 2, + "kappa_value": 9, + "total_roof_area": 57.4 + } + ], + "sap_walls": [ + { + "name": "Walls (1)", + "u_value": 0.18, + "wall_type": 2, + "kappa_value": 60, + "total_wall_area": 111.34, + "is_curtain_walling": "false" + }, + { + "name": "Party wall", + "u_value": 0, + "wall_type": 4, + "kappa_value": 0, + "total_wall_area": 167, + "is_curtain_walling": "false" + }, + { + "name": "Internal wall (1)", + "u_value": 0, + "wall_type": 5, + "kappa_value": 0, + "total_wall_area": 320, + "is_curtain_walling": "false" + } + ], + "identifier": "Main Dwelling", + "sap_openings": [ + { + "name": 1, + "type": "Doors", + "width": 1, + "height": 2.25, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 2, + "type": "Doors", + "width": 0.8, + "height": 2.1, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 3, + "type": "Windows (2)", + "width": 1.6, + "height": 2.7, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 4, + "type": "Windows (1)", + "width": 1.1, + "height": 1.75, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 5, + "type": "Windows (1)", + "width": 1.1, + "height": 1.75, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 6, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 7, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 8, + "type": "Windows (1)", + "width": 1.1, + "height": 1.8, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 9, + "type": "Windows (1)", + "width": 1.1, + "height": 1.75, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 10, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 11, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 12, + "type": "Windows (1)", + "width": 0.6, + "height": 0.8, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 13, + "type": "Windows (1)", + "width": 0.8, + "height": 1.25, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 14, + "type": "Windows (1)", + "width": 0.8, + "height": 1.25, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 15, + "type": "Windows (1)", + "width": 1.1, + "height": 1.25, + "location": "Walls (1)", + "orientation": 7 + } + ], + "sap_thermal_bridges": { + "thermal_bridges": [ + { + "length": 15.5, + "psi_value": 0.3, + "psi_value_source": 2, + "thermal_bridge_type": "E2" + }, + { + "length": 12.1, + "psi_value": 0.04, + "psi_value_source": 2, + "thermal_bridge_type": "E3" + }, + { + "length": 52.1, + "psi_value": 0.05, + "psi_value_source": 2, + "thermal_bridge_type": "E4" + }, + { + "length": 14.4, + "psi_value": 0.16, + "psi_value_source": 2, + "thermal_bridge_type": "E5" + }, + { + "length": 25.8, + "psi_value": 0.07, + "psi_value_source": 2, + "thermal_bridge_type": "E6" + }, + { + "length": 10.4, + "psi_value": 0.06, + "psi_value_source": 2, + "thermal_bridge_type": "E10" + }, + { + "length": 5.4, + "psi_value": 0.06, + "psi_value_source": 3, + "thermal_bridge_type": "E14" + }, + { + "length": 5.8, + "psi_value": 0.09, + "psi_value_source": 2, + "thermal_bridge_type": "E16" + }, + { + "length": 5.8, + "psi_value": -0.09, + "psi_value_source": 2, + "thermal_bridge_type": "E17" + }, + { + "length": 34, + "psi_value": 0.06, + "psi_value_source": 2, + "thermal_bridge_type": "E18" + }, + { + "length": 20.6, + "psi_value": 0.12, + "psi_value_source": 3, + "thermal_bridge_type": "P1" + }, + { + "length": 20.6, + "psi_value": 0, + "psi_value_source": 4, + "thermal_bridge_type": "P2" + }, + { + "length": 20.6, + "psi_value": 0.12, + "psi_value_source": 3, + "thermal_bridge_type": "P4" + } + ], + "thermal_bridge_code": 5 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.12, + "floor_type": 2, + "kappa_value": 80, + "storey_height": 2.8, + "heat_loss_area": 57.4, + "total_floor_area": 57.4 + }, + { + "storey": 1, + "u_value": 0, + "floor_type": 3, + "kappa_value": 18, + "storey_height": 3, + "heat_loss_area": 0, + "total_floor_area": 57.4, + "kappa_value_from_below": 9 + }, + { + "storey": 2, + "u_value": 0, + "floor_type": 3, + "kappa_value": 19, + "storey_height": 2.7, + "heat_loss_area": 0, + "total_floor_area": 57.4, + "kappa_value_from_below": 9 + } + ], + "construction_age_band": "A" + } + ], + "user_interface_name": "BRE SAP interface 10.2", + "windows_overshading": 2, + "heating_cost_current": { + "value": 365.98, + "currency": "GBP" + }, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 72, + "lighting_cost_current": { + "value": 123.45, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 250.34, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 200.4, + "currency": "GBP" + }, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 88, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 94 + }, + { + "sequence": 2, + "typical_saving": { + "value": 88, + "currency": "GBP" + }, + "indicative_cost": "£9,000 - £14,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 80, + "environmental_impact_rating": 96 + } + ], + "user_interface_version": "1.0.1-alpha", + "co2_emissions_potential": 1.4, + "energy_rating_potential": 72, + "gas_smart_meter_present": "false", + "lighting_cost_potential": { + "value": 84.23, + "currency": "GBP" + }, + "schema_version_original": "SAP-Schema-19.1.0", + "hot_water_cost_potential": { + "value": 180.43, + "currency": "GBP" + }, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 2666, + "water_heating": 2650 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 59, + "has_fixed_air_conditioning": "false", + "is_dwelling_export_capable": "true", + "multiple_glazed_percentage": 100, + "calculation_software_version": "13.05r16", + "energy_consumption_potential": 53, + "environmental_impact_current": 94, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 96, + "electricity_smart_meter_present": "false", + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 5.6 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/SAP-Schema-19.2.0/epc.json b/backend/epc_api/json_samples/SAP-Schema-19.2.0/epc.json new file mode 100644 index 00000000..26a1cd4d --- /dev/null +++ b/backend/epc_api/json_samples/SAP-Schema-19.2.0/epc.json @@ -0,0 +1,612 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": "Average thermal transmittance 0.13 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.18 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "floors": [ + { + "description": "Average thermal transmittance 0.12 W/m²K", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "status": "entered", + "tenure": 1, + "windows": { + "description": "High performance glazing", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "lighting": { + "description": "Low energy lighting in 91% of fixed outlets", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "A0 0AA", + "data_type": 1, + "hot_water": { + "description": "From main system, waste water heat recovery", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 3 + }, + "post_town": "Whitbury", + "built_form": 4, + "living_area": 41.35, + "orientation": 0, + "region_code": 16, + "report_type": 3, + "sap_heating": { + "thermal_store": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_flow_rate": 7, + "shower_outlet_type": 1 + }, + { + "shower_wwhrs": 2, + "shower_flow_rate": 7, + "shower_outlet_type": 1 + } + ], + "water_fuel_type": 39, + "water_heating_code": 901, + "instantaneous_wwhrs": { + "wwhrs_index_number1": 491123 + }, + "hot_water_store_size": 600, + "main_heating_details": [ + { + "has_fghrs": "false", + "main_fuel_type": 5, + "combi_boiler_type": 4, + "heat_emitter_type": 1, + "main_heating_code": 192, + "main_heating_number": 1, + "is_condensing_boiler": "false", + "main_heating_control": 2106, + "is_interlocked_system": "false", + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 2, + "main_heating_data_source": 3, + "has_separate_delayed_start": "true", + "is_oil_pump_in_heated_space": "false", + "is_main_heating_hetas_approved": "false", + "electric_cpsu_operating_temperature": 80, + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "true", + "has_cylinder_thermostat": "true", + "hot_water_store_heat_loss": 2.45, + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1, + "is_immersion_for_summer_use": "false", + "is_hot_water_separately_timed": "false", + "sap_community_heating_systems": [ + { + "sub_network_name": "Test 1", + "heat_network_sleeved": 1, + "community_heating_use": 3, + "heat_network_existing": "true", + "heat_network_index_number": 496402, + "heat_network_assessed_as_new": "true", + "heat_network_community_heating": 0, + "community_heating_distribution_type": 5, + "community_heating_distribution_loss_factor": 1.26 + } + ], + "hot_water_store_heat_loss_source": 2, + "is_heat_pump_assisted_by_immersion": "false" + }, + "sap_version": 10.3, + "schema_type": "SAP-Schema-19.2.0", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, electric", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 2 + } + ], + "sap_lighting": [ + [ + { + "lighting_power": 60, + "lighting_outlets": 1, + "lighting_efficacy": 11.2 + }, + { + "lighting_power": 14, + "lighting_outlets": 10, + "lighting_efficacy": 66.9 + } + ] + ], + "terrain_type": 1, + "air_tightness": { + "description": "Air permeability 2.0 m³/h.m² (assumed)", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "1 Some Street", + "address_line_2": "Some Area", + "address_line_3": "Some County", + "assessment_date": "2022-05-09", + "assessment_type": "SAP", + "completion_date": "2022-05-09", + "inspection_date": "2022-05-09", + "sap_ventilation": { + "psv_count": 0, + "wall_type": 2, + "pressure_test": 2, + "wet_rooms_count": 2, + "air_permeability": 2, + "open_flues_count": 0, + "ventilation_type": 8, + "has_draught_lobby": "false", + "other_flues_count": 0, + "closed_flues_count": 0, + "extract_fans_count": 0, + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "sheltered_sides_count": 2, + "blocked_chimneys_count": 0, + "flueless_gas_fires_count": 0, + "mechanical_vent_duct_type": 2, + "mechanical_vent_duct_placement": 1, + "mechanical_ventilation_data_source": 1, + "mechanical_vent_system_index_number": 500206, + "mechanical_vent_duct_insulation_level": 2, + "is_mechanical_vent_approved_installer_scheme": "true" + }, + "sap_data_version": 10.3, + "total_floor_area": 165, + "transaction_type": 1, + "cold_water_source": 1, + "conservatory_type": 1, + "registration_date": "2022-05-09", + "sap_energy_source": { + "wind_turbines": [ + { + "wind_turbine_hub_height": 3, + "wind_turbine_rotor_diameter": 1.7 + }, + { + "wind_turbine_hub_height": 3, + "wind_turbine_rotor_diameter": 1.7 + } + ], + "electricity_tariff": 5 + }, + "sap_opening_types": [ + { + "name": "Doors", + "type": 1, + "u_value": 1.5, + "data_source": 2, + "glazing_type": 1, + "isargonfilled": "false" + }, + { + "name": "Windows (1)", + "type": 4, + "u_value": 1.4, + "frame_type": 1, + "data_source": 3, + "glazing_gap": 3, + "frame_factor": 0.7, + "glazing_type": 11, + "isargonfilled": "true", + "solar_transmittance": 0.57 + }, + { + "name": "Windows (2)", + "type": 4, + "u_value": 1.5, + "frame_type": 1, + "data_source": 3, + "glazing_gap": 3, + "frame_factor": 0.7, + "glazing_type": 9, + "isargonfilled": "true", + "solar_transmittance": 0.64 + } + ], + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lowest_storey_area": 57.4, + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof (1)", + "u_value": 0.13, + "roof_type": 2, + "kappa_value": 9, + "total_roof_area": 57.4 + } + ], + "sap_walls": [ + { + "name": "Walls (1)", + "u_value": 0.18, + "wall_type": 2, + "kappa_value": 60, + "total_wall_area": 111.34, + "is_curtain_walling": "false" + }, + { + "name": "Party wall", + "u_value": 0, + "wall_type": 4, + "kappa_value": 0, + "total_wall_area": 167, + "is_curtain_walling": "false" + }, + { + "name": "Internal wall (1)", + "u_value": 0, + "wall_type": 5, + "kappa_value": 0, + "total_wall_area": 320, + "is_curtain_walling": "false" + } + ], + "identifier": "Main Dwelling", + "sap_openings": [ + { + "name": 1, + "type": "Doors", + "width": 1, + "height": 2.25, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 2, + "type": "Doors", + "width": 0.8, + "height": 2.1, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 3, + "type": "Windows (2)", + "width": 1.6, + "height": 2.7, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 4, + "type": "Windows (1)", + "width": 1.1, + "height": 1.75, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 5, + "type": "Windows (1)", + "width": 1.1, + "height": 1.75, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 6, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 7, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 8, + "type": "Windows (1)", + "width": 1.1, + "height": 1.8, + "location": "Walls (1)", + "orientation": 3 + }, + { + "name": 9, + "type": "Windows (1)", + "width": 1.1, + "height": 1.75, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 10, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 11, + "type": "Windows (1)", + "width": 1.1, + "height": 1.85, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 12, + "type": "Windows (1)", + "width": 0.6, + "height": 0.8, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 13, + "type": "Windows (1)", + "width": 0.8, + "height": 1.25, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 14, + "type": "Windows (1)", + "width": 0.8, + "height": 1.25, + "location": "Walls (1)", + "orientation": 7 + }, + { + "name": 15, + "type": "Windows (1)", + "width": 1.1, + "height": 1.25, + "location": "Walls (1)", + "orientation": 7 + } + ], + "sap_thermal_bridges": { + "thermal_bridges": [ + { + "length": 15.5, + "psi_value": 0.3, + "psi_value_source": 2, + "thermal_bridge_type": "E2" + }, + { + "length": 12.1, + "psi_value": 0.04, + "psi_value_source": 2, + "thermal_bridge_type": "E3" + }, + { + "length": 52.1, + "psi_value": 0.05, + "psi_value_source": 2, + "thermal_bridge_type": "E4" + }, + { + "length": 14.4, + "psi_value": 0.16, + "psi_value_source": 2, + "thermal_bridge_type": "E5" + }, + { + "length": 25.8, + "psi_value": 0.07, + "psi_value_source": 2, + "thermal_bridge_type": "E6" + }, + { + "length": 10.4, + "psi_value": 0.06, + "psi_value_source": 2, + "thermal_bridge_type": "E10" + }, + { + "length": 5.4, + "psi_value": 0.06, + "psi_value_source": 3, + "thermal_bridge_type": "E14" + }, + { + "length": 5.8, + "psi_value": 0.09, + "psi_value_source": 2, + "thermal_bridge_type": "E16" + }, + { + "length": 5.8, + "psi_value": -0.09, + "psi_value_source": 2, + "thermal_bridge_type": "E17" + }, + { + "length": 34, + "psi_value": 0.06, + "psi_value_source": 2, + "thermal_bridge_type": "E18" + }, + { + "length": 20.6, + "psi_value": 0.12, + "psi_value_source": 3, + "thermal_bridge_type": "P1" + }, + { + "length": 20.6, + "psi_value": 0, + "psi_value_source": 4, + "thermal_bridge_type": "P2" + }, + { + "length": 20.6, + "psi_value": 0.12, + "psi_value_source": 3, + "thermal_bridge_type": "P4" + } + ], + "thermal_bridge_code": 5 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.12, + "floor_type": 2, + "kappa_value": 80, + "storey_height": 2.8, + "heat_loss_area": 57.4, + "total_floor_area": 57.4 + }, + { + "storey": 1, + "u_value": 0, + "floor_type": 3, + "kappa_value": 18, + "storey_height": 3, + "heat_loss_area": 0, + "total_floor_area": 57.4, + "kappa_value_from_below": 9 + }, + { + "storey": 2, + "u_value": 0, + "floor_type": 3, + "kappa_value": 19, + "storey_height": 2.7, + "heat_loss_area": 0, + "total_floor_area": 57.4, + "kappa_value_from_below": 9 + } + ], + "construction_age_band": "A" + } + ], + "user_interface_name": "BRE SAP interface 10.3", + "windows_overshading": 2, + "heating_cost_current": { + "value": 365.98, + "currency": "GBP" + }, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 72, + "lighting_cost_current": { + "value": 123.45, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 250.34, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 200.4, + "currency": "GBP" + }, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 88, + "currency": "GBP" + }, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 94 + }, + { + "sequence": 2, + "typical_saving": { + "value": 88, + "currency": "GBP" + }, + "indicative_cost": "£9,000 - £14,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 80, + "environmental_impact_rating": 96 + } + ], + "user_interface_version": "1.0.1-alpha", + "co2_emissions_potential": 1.4, + "energy_rating_potential": 72, + "gas_smart_meter_present": "false", + "lighting_cost_potential": { + "value": 84.23, + "currency": "GBP" + }, + "part_o_cooling_required": 1, + "schema_version_original": "SAP-Schema-19.2.0", + "hot_water_cost_potential": { + "value": 180.43, + "currency": "GBP" + }, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 2666, + "water_heating": 2650 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 59, + "has_fixed_air_conditioning": "false", + "is_dwelling_export_capable": "true", + "multiple_glazed_percentage": 100, + "calculation_software_version": "13.05r16", + "energy_consumption_potential": 53, + "environmental_impact_current": 94, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 96, + "electricity_smart_meter_present": "false", + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 5.6 +} \ No newline at end of file diff --git a/etl/find_my_epc/RetrieveFindMyEpc.py b/etl/find_my_epc/RetrieveFindMyEpc.py index 392e6aaa..f5eb0166 100644 --- a/etl/find_my_epc/RetrieveFindMyEpc.py +++ b/etl/find_my_epc/RetrieveFindMyEpc.py @@ -472,8 +472,8 @@ class RetrieveFindMyEpc: address_response = requests.get(chosen_epc, headers=self.HEADERS) epc_page_source = address_response.text address_res = BeautifulSoup(address_response.text, features="html.parser") - elif self.rrn: - epc_certificate = self.rrn + elif self.rrn or rrn: + epc_certificate = self.rrn if self.rrn else rrn chosen_epc = f"{self.BASE_ENERGY_URL}/energy-certificate/{epc_certificate}" address_response = requests.get(chosen_epc, headers=self.HEADERS) epc_page_source = address_response.text @@ -497,7 +497,7 @@ class RetrieveFindMyEpc: current_sap = int(current_rating.split(' ')[-1]) if self.sap_rating: - if current_sap != self.sap_rating: + if current_sap != self.sap_rating and not rrn: # This means we likely have the wrong data. If we are in this scenario, we return nothing return { "epc_certificate": None, @@ -820,6 +820,10 @@ class RetrieveFindMyEpc: "recommendations": find_epc_data.get("recommendations", []), } + lodgment_date = find_epc_data.get("Date of certificate", None) + if not pd.isnull(lodgment_date): + lodgment_date = str(datetime.strptime(str(lodgment_date), "%d %B %Y")) + # We need to add the patch information patch = { "current-energy-rating": find_epc_data.get("current_epc_rating"), @@ -827,6 +831,7 @@ class RetrieveFindMyEpc: "potential-energy-rating": find_epc_data.get("potential_epc_rating"), "potential-energy-efficiency": find_epc_data.get("potential_epc_efficiency"), **find_epc_data.get("epc_data", {}), + "lodgement-date": lodgment_date } page_source = { From 4425b28d4fd15e8ab23fc20abcb25b1728dcc085 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 14:24:52 +0000 Subject: [PATCH 73/93] address review comments and add logging --- etl/hubspot/hubspotDataTodB.py | 24 +++---- etl/hubspot/scripts/scraper/main.py | 99 ++++++++++++++++------------- 2 files changed, 64 insertions(+), 59 deletions(-) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 210c9593..65fad572 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -78,22 +78,6 @@ class HubspotDataToDb: .one_or_none() ) - def _parse_hs_date(self, value: Optional[str]) -> Optional[datetime]: - if not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - - def _sha256(self, file_path: str) -> str: - """Compute SHA-256 checksum of a file.""" - sha256 = hashlib.sha256() - with open(file_path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - sha256.update(chunk) - return sha256.hexdigest() - def update_deal_with_checks( self, deal_in_db: HubspotDealData, @@ -185,6 +169,14 @@ class HubspotDataToDb: session.refresh(new_record) return new_record + def _sha256(self, file_path: str) -> str: + """Compute SHA-256 checksum of a file.""" + sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + return sha256.hexdigest() + def _update_existing_deal( self, existing: HubspotDealData, diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index cec03da8..8fa71bf7 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -38,56 +38,69 @@ def handler(body: dict[str, Any], context: Any) -> None: hubspot_deal_id ) + deal_changed = False if not db_deal: # New hubspot deal, no diffing to do + logger.info(f"New HubSpot deal of ID {hubspot_deal_id}. Loading to database...") if company: company_data: CompanyData = hubspot_client.get_company_information(company) db_client: HubspotDataToDb = HubspotDataToDb() db_client.upsert_company(company_data) db_client.upsert_deal(hubspot_deal, company, listing, hubspot_client) - return - - deal_unchanged = True - - # Deal already in db, check whether anything has changed - if HubspotDealDiffer.check_for_db_update_trigger( - new_deal=hubspot_deal, - new_company=company, - new_listing=listing, - old_deal=db_deal, - ): - db_client.update_deal_with_checks( - deal_in_db=db_deal, - hubspot_client=hubspot_client, - hs_deal=hubspot_deal, - hs_company_id=company, - hs_listing=listing, - ) - deal_unchanged = False - - if deal_unchanged: - return - - # ============================== - # Orchestration of other lambdas - # ============================== - if HubspotDealDiffer.check_for_pashub_trigger( - new_deal=hubspot_deal, old_deal=db_deal - ): - message_body: Dict[str, Optional[str]] = { - "pashub_link": hubspot_deal["pashub_link"], - "address": None, # can we get this? - "sharepoint_link": hubspot_deal["sharepoint_link"], - "uprn": hubspot_deal["national_uprn"], - "landlord_property_id": hubspot_deal["owner_property_id"], - "deal_stage": hubspot_deal["deal_stage"], - } - - response = sqs_client.send_message( - QueueUrl=PASHUB_TRIGGER_QUEUE_URL, MessageBody=json.dumps(message_body) - ) - + else: + # Deal already in db, check whether anything has changed logger.info( - f"Sent message to Pashub To Ara queue. MessageId: {response['MessageId']}" + f"HubSpot deal {hubspot_deal_id} already in database. Checking for changes..." ) + if HubspotDealDiffer.check_for_db_update_trigger( + new_deal=hubspot_deal, + new_company=company, + new_listing=listing, + old_deal=db_deal, + ): + logger.info( + f"Deal {hubspot_deal_id} has been changed, updating database..." + ) + db_client.update_deal_with_checks( + deal_in_db=db_deal, + hubspot_client=hubspot_client, + hs_deal=hubspot_deal, + hs_company_id=company, + hs_listing=listing, + ) + deal_changed = True + + if not deal_changed: + logger.info(f"No changes to deal {hubspot_deal_id}") + return + + # ============================== + # Orchestration of other lambdas + # ============================== + if HubspotDealDiffer.check_for_pashub_trigger( + new_deal=hubspot_deal, old_deal=db_deal + ): + logger.info( + f"Triggering Pas Hub file fetcher for HubSpot deal ID {hubspot_deal_id}" + ) + message_body: Dict[str, Optional[str]] = { + "pashub_link": hubspot_deal["pashub_link"], + "address": None, # potentially available from Listing, leave as None for now + "sharepoint_link": hubspot_deal["sharepoint_link"], + "uprn": hubspot_deal["national_uprn"], + "landlord_property_id": hubspot_deal["owner_property_id"], + "deal_stage": hubspot_deal["deal_stage"], + } + + response = sqs_client.send_message( + QueueUrl=PASHUB_TRIGGER_QUEUE_URL, MessageBody=json.dumps(message_body) + ) + + logger.info( + f"Sent message to Pashub To Ara queue. MessageId: {response['MessageId']}" + ) + else: + logger.info( + f"Not Triggering PasHub file fetcher for HubSpot deal ID {hubspot_deal_id}" + ) From bd891a7a85365b4fca64ad95ebc7c4fb0ed4b82a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 14:41:14 +0000 Subject: [PATCH 74/93] address JTK review comments --- backend/app/db/models/hubspot_deal_data.py | 8 ++++---- etl/hubspot/hubspotDataTodB.py | 2 +- etl/hubspot/hubspot_deal_differ.py | 6 ++---- etl/hubspot/scripts/onboarding/new_organisation.py | 2 +- etl/hubspot/scripts/scraper/main.py | 2 +- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/backend/app/db/models/hubspot_deal_data.py b/backend/app/db/models/hubspot_deal_data.py index 1d7607e0..758f688d 100644 --- a/backend/app/db/models/hubspot_deal_data.py +++ b/backend/app/db/models/hubspot_deal_data.py @@ -65,8 +65,8 @@ class HubspotDealData(SQLModel, table=True): server_default=text("(NOW() AT TIME ZONE 'utc')"), nullable=False, ), - default=None, # Nullable in db but optional here as value is set on db save for new record - ) + default=func.now(), + ) # Nullable in db but optional here as value is set on db save for new record updated_at: Optional[datetime] = Field( sa_column=Column( @@ -75,5 +75,5 @@ class HubspotDealData(SQLModel, table=True): onupdate=func.now(), nullable=False, ), - default=None, # Nullable in db but optional here as value is set on db save for new record - ) + default=func.now(), + ) # Nullable in db but optional here as value is set on db save for new record diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 65fad572..a50c99da 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -31,7 +31,7 @@ class HubspotDataToDb: records = self.read_org_table(limit) return [org.name for org in records if org.name] - def upsert_company(self, company_data: CompanyData) -> Organisation: + def upsert_organisation(self, company_data: CompanyData) -> Organisation: """Upserts a company record. Updates if hubspot_company_id exists, otherwise creates new.""" with db_read_session() as session: hubspot_id = company_data.get("hs_object_id") diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 42def3b2..4db303ab 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -8,6 +8,7 @@ class HubspotDealDiffer: COORDINATION_COMPLETE: List[str] = [ "v1 ioe/mtp complete", "v2 ioe/mtp complete", + "v3 ioe/mtp complete", ] RETROFIT_DESIGN_COMPLETE = "uploaded" LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"] @@ -72,6 +73,7 @@ class HubspotDealDiffer: "lodgement_status": "lodgement_status", "design_type": "design_type", "surveyor": "surveyor", + "confirmed_survey_time": "confirmed_survey_time", } for hs_field, db_field in FIELD_MAP.items(): @@ -101,10 +103,6 @@ class HubspotDealDiffer: if old_value != new_value: return True - # --- Time field --- - if old_deal.confirmed_survey_time != new_deal.get("confirmed_survey_time"): - return True - # No differences found return False diff --git a/etl/hubspot/scripts/onboarding/new_organisation.py b/etl/hubspot/scripts/onboarding/new_organisation.py index f8c6ba7a..0785949a 100644 --- a/etl/hubspot/scripts/onboarding/new_organisation.py +++ b/etl/hubspot/scripts/onboarding/new_organisation.py @@ -22,7 +22,7 @@ companies_to_add_or_ensure_it_exists = [ for company in companies_to_add_or_ensure_it_exists: company_info: CompanyData = hubspot.get_company_information(company.value) - dbRead.upsert_company(company_info) + dbRead.upsert_organisation(company_info) dbRead = HubspotDataToDb() diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 8fa71bf7..31945705 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -45,7 +45,7 @@ def handler(body: dict[str, Any], context: Any) -> None: if company: company_data: CompanyData = hubspot_client.get_company_information(company) db_client: HubspotDataToDb = HubspotDataToDb() - db_client.upsert_company(company_data) + db_client.upsert_organisation(company_data) db_client.upsert_deal(hubspot_deal, company, listing, hubspot_client) else: From 62fe46adc43d5c9ae98ad3b9de798bfaa1a82374 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 15:00:36 +0000 Subject: [PATCH 75/93] get queue name from settings --- .github/workflows/deploy_terraform.yml | 2 +- backend/app/config.py | 1 + etl/hubspot/scripts/scraper/main.py | 3 ++- .../terraform/lambda/hubspot_deal_etl/main.tf | 10 ++++++++++ .../terraform/lambda/pashub_to_ara/outputs.tf | 4 ++++ 5 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 infrastructure/terraform/lambda/pashub_to_ara/outputs.tf diff --git a/.github/workflows/deploy_terraform.yml b/.github/workflows/deploy_terraform.yml index fccc6da4..22f16fee 100644 --- a/.github/workflows/deploy_terraform.yml +++ b/.github/workflows/deploy_terraform.yml @@ -505,7 +505,7 @@ jobs: # Deploy Hubspot ETL Lambda # ============================================================ hubspot_etl_lambda: - needs: [hubspot_etl_image, determine_stage] + needs: [hubspot_etl_image, determine_stage, pashub_to_ara_lambda] uses: ./.github/workflows/_deploy_lambda.yml with: lambda_name: hubspot-etl-to-ara diff --git a/backend/app/config.py b/backend/app/config.py index 80a2d46a..9532ddd6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -38,6 +38,7 @@ class Settings(BaseSettings): PLAN_TRIGGER_BUCKET: str = "changeme" ENGINE_SQS_URL: str = "changeme" CATEGORISATION_SQS_URL: str = "changeme" + PASHUB_TO_ARA_SQS_URL: str = "changeme" # Third parties EPC_AUTH_TOKEN: str = "changeme" diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index 31945705..ea79bc18 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -2,6 +2,7 @@ import json import boto3 from typing import Any, Dict, Optional +from backend.app.config import get_settings from etl.hubspot.hubspotClient import HubspotClient from etl.hubspot.hubspotDataTodB import CompanyData, HubspotDataToDb from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer @@ -21,7 +22,7 @@ def handler(body: dict[str, Any], context: Any) -> None: hubspot_client = HubspotClient() sqs_client = boto3.client("sqs") - PASHUB_TRIGGER_QUEUE_URL = "pashub_to_ara-queue-dev" # TODO: get from env var + PASHUB_TRIGGER_QUEUE_URL = get_settings().PASHUB_TO_ARA_SQS_URL payload = HubspotTriggerOrchestratorTriggerRequest.model_validate(body) hubspot_deal_id: str = payload.hubspot_deal_id diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf index 6ce7a386..518e1e05 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf @@ -7,6 +7,14 @@ data "terraform_remote_state" "shared" { } } +data "terraform_remote_state" "pashub_to_ara" { + backend = "s3" + config = { + bucket = "pashub-to-ara-terraform-state" + key = "ev:/${var.stage}/terraform.tfstate" + region = "eu-west-2" + } +} data "aws_secretsmanager_secret_version" "db_credentials" { secret_id = "${var.stage}/assessment_model/db_credentials" @@ -39,6 +47,8 @@ module "hubspot_deal_etl" { DB_NAME = var.db_name DB_PORT = var.db_port HUBSPOT_API_KEY = var.hubspot_api_key + + PASHUB_TO_ARA_SQS_URL = data.terraform_remote_state.pashub_to_ara.pashhub_to_ara_queue_url } } diff --git a/infrastructure/terraform/lambda/pashub_to_ara/outputs.tf b/infrastructure/terraform/lambda/pashub_to_ara/outputs.tf new file mode 100644 index 00000000..738aa4fc --- /dev/null +++ b/infrastructure/terraform/lambda/pashub_to_ara/outputs.tf @@ -0,0 +1,4 @@ +output "pashhub_to_ara_queue_url" { + value = module.lambda.queue_url + description = "URL of the PasHub to Ara SQS queue" +} From 3380b6cbc880c248cf0851f500c46101aff35fd6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Apr 2026 16:01:33 +0100 Subject: [PATCH 76/93] hacky fix to pick up lmk key or epc cert # --- backend/addresses/Addresses.py | 3 +++ backend/app/db/functions/epc_functions.py | 2 +- etl/bill_savings/KwhData.py | 6 +++--- etl/find_my_epc/RetrieveFindMyEpc.py | 2 ++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/addresses/Addresses.py b/backend/addresses/Addresses.py index 0496e95e..4c046554 100644 --- a/backend/addresses/Addresses.py +++ b/backend/addresses/Addresses.py @@ -138,7 +138,10 @@ class Addresses: # Handle NAN if pd.isnull(lmk_key): lmk_key = None + epc_certificate_number = row.get("certificate_number", None) + if pd.isnull(epc_certificate_number): + epc_certificate_number = None landlord_heating_system = row.get("epc_heating_type", None) if pd.isnull(landlord_heating_system): diff --git a/backend/app/db/functions/epc_functions.py b/backend/app/db/functions/epc_functions.py index 1dcb92fe..90b2bd02 100644 --- a/backend/app/db/functions/epc_functions.py +++ b/backend/app/db/functions/epc_functions.py @@ -11,7 +11,7 @@ class EpcStoreService: Service layer for EPC data lookup and persistence. """ - FRESHNESS_DAYS = 180 # Upgraded to 180 days + FRESHNESS_DAYS = 30 # Upgraded to 30 days # status labels FRESH = "fresh" diff --git a/etl/bill_savings/KwhData.py b/etl/bill_savings/KwhData.py index 266f4b72..30e11698 100644 --- a/etl/bill_savings/KwhData.py +++ b/etl/bill_savings/KwhData.py @@ -203,7 +203,7 @@ class KwhData: # TODO: New is a temporary parameter, which will transform the epc descriptions to their transformed features # in anticipation of the new model - data["lodgement-date"] = pd.to_datetime(data["lodgement-date"]) + data["lodgement-date"] = pd.to_datetime(data["lodgement-date"], format="mixed", errors="coerce") data["lodgement-year"] = data["lodgement-date"].dt.year data["lodgement-month"] = data["lodgement-date"].dt.month @@ -331,8 +331,8 @@ class KwhData: def prepare_epc(self, input_properties: list[Property]): scoring_data = pd.DataFrame([self._prepare_epc(p) for p in input_properties]) - scoring_data["lodgement-year"] = pd.to_datetime(scoring_data["lodgement-date"]).dt.year - scoring_data["lodgement-month"] = pd.to_datetime(scoring_data["lodgement-date"]).dt.month + scoring_data["lodgement-year"] = pd.to_datetime(scoring_data["lodgement-date"], format="mixed").dt.year + scoring_data["lodgement-month"] = pd.to_datetime(scoring_data["lodgement-date"], format="mixed").dt.month scoring_data["id"] = scoring_data["uprn"].copy() diff --git a/etl/find_my_epc/RetrieveFindMyEpc.py b/etl/find_my_epc/RetrieveFindMyEpc.py index f5eb0166..16b7d8b9 100644 --- a/etl/find_my_epc/RetrieveFindMyEpc.py +++ b/etl/find_my_epc/RetrieveFindMyEpc.py @@ -734,6 +734,8 @@ class RetrieveFindMyEpc: "Step 1:": [], "Step 2:": [], 'Step 3:': [], + 'Step 4:': [], + 'Step 5:': [], "Biomass stove with boiler": [], "Replace boiler with biomass boiler": [], "Heating controls (room thermostat and thermostatic radiator valves)": [ From fbf23bc898052c24f79df7c8dc0f55f20fad444e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Apr 2026 16:31:50 +0100 Subject: [PATCH 77/93] added env impact scores to db --- backend/Property.py | 1 + backend/app/db/models/portfolio.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/backend/Property.py b/backend/Property.py index 5e994cae..1d79b16d 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -890,6 +890,7 @@ class Property: "lodged_co2_emissions": float(self.epc_record.original_epc["co2-emissions-current"]), "lodged_heat_demand": float(self.epc_record.original_epc["energy-consumption-current"]), "has_been_remodelled": self.epc_record.has_been_remodelled, + "environment_impact_current": self.epc_record.environment_impact_current } return property_details_epc diff --git a/backend/app/db/models/portfolio.py b/backend/app/db/models/portfolio.py index c511b6c9..a4f9a675 100644 --- a/backend/app/db/models/portfolio.py +++ b/backend/app/db/models/portfolio.py @@ -263,6 +263,8 @@ class PropertyDetailsEpcModel(Base): lodged_heat_demand = Column(Float) has_been_remodelled = Column(Boolean, default=False) + environment_impact_current = Column(Float) + class PropertyDetailsSpatial(Base): __tablename__ = "property_details_spatial" From f1f3b84cbdadcecd4010658f0b119295a805e4ee Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 15:49:13 +0000 Subject: [PATCH 78/93] simplify photo upload logic --- etl/hubspot/hubspotDataTodB.py | 89 +++++------------------------ etl/hubspot/scripts/scraper/main.py | 9 ++- 2 files changed, 19 insertions(+), 79 deletions(-) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index a50c99da..6763f19c 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -78,53 +78,6 @@ class HubspotDataToDb: .one_or_none() ) - def update_deal_with_checks( - self, - deal_in_db: HubspotDealData, - hubspot_client: HubspotClient, - hs_deal: Dict[str, str], - hs_company_id: Optional[str], - hs_listing: Optional[Dict[str, str]], - ) -> bool: - """ - Updates deal in database and handles major_condition_issue_photos file upload to S3 with integrity check. - """ - self.upsert_deal(hs_deal, hs_company_id, hs_listing, hubspot_client) - - # Handle photo upload if it exists but S3 URL is missing - if self._needs_photo_upload(deal_in_db): - print( - f"🖼️ Found photo for deal_id {deal_in_db.deal_id} — uploading to S3..." - ) - - photo_url = hs_deal.get("major_condition_issue_photos") - - if photo_url: - self._upload_photo_to_s3( - deal_in_db, - photo_url, - hubspot_client, - verify=True, - ) - - # persist change - with db_read_session() as session: - db_record = session.get(HubspotDealData, deal_in_db.id) - db_record.major_condition_issue_evidence_s3_url = ( - deal_in_db.major_condition_issue_evidence_s3_url - ) - session.add(db_record) - session.commit() - - return False - else: - print(f"⚠️ Photo URL missing for deal_id {deal_in_db.deal_id}") - - else: - print(f"✅ No update or upload required for deal_id {deal_in_db.deal_id}.") - - return True - def upsert_deal( self, deal_data: Dict[str, str], @@ -169,14 +122,6 @@ class HubspotDataToDb: session.refresh(new_record) return new_record - def _sha256(self, file_path: str) -> str: - """Compute SHA-256 checksum of a file.""" - sha256 = hashlib.sha256() - with open(file_path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - sha256.update(chunk) - return sha256.hexdigest() - def _update_existing_deal( self, existing: HubspotDealData, @@ -315,18 +260,20 @@ class HubspotDataToDb: def _handle_existing_photo_upload( self, - existing: HubspotDealData, + existing_deal: HubspotDealData, hubspot_client: HubspotClient, ): - if self._needs_photo_upload(existing): - fresh_deal = hubspot_client.from_deal_id_get_info(existing.deal_id) - photo_url = fresh_deal.get("major_condition_issue_photos") + # if self._needs_photo_upload(existing): - if not photo_url: - print(f"⚠️ Photo URL missing for deal_id {existing.deal_id}") - return + fresh_deal = hubspot_client.from_deal_id_get_info(existing_deal.deal_id) + fresh_photo_url = fresh_deal.get("major_condition_issue_photos") - self._upload_photo_to_s3(existing, photo_url, hubspot_client) + if not fresh_photo_url: + print(f"⚠️ Photo URL missing for deal_id {existing_deal.deal_id}") + return + + if fresh_photo_url != existing_deal.major_condition_issue_photos: + self._upload_photo_to_s3(existing_deal, fresh_photo_url, hubspot_client) def _handle_new_photo_upload( self, @@ -343,12 +290,11 @@ class HubspotDataToDb: def _upload_photo_to_s3( self, record: HubspotDealData, - photo_url: str, + hubspot_photo_url: str, hubspot_client: HubspotClient, - verify: bool = False, ): try: - local_file = hubspot_client.download_file_from_url(photo_url) + local_file = hubspot_client.download_file_from_url(hubspot_photo_url) s3_url = self.s3.upload_file( local_file, @@ -356,11 +302,6 @@ class HubspotDataToDb: prefix="hubspot/awaabs_law_evidence/", ) - if verify: - downloaded = self.s3.download_from_url(s3_url) - if self._sha256(local_file) != self._sha256(downloaded): - raise ValueError("File integrity check failed after S3 upload.") - record.major_condition_issue_evidence_s3_url = s3_url except Exception as e: @@ -369,8 +310,8 @@ class HubspotDataToDb: if "local_file" in locals() and os.path.exists(local_file): os.remove(local_file) - def _needs_photo_upload(self, deal: HubspotDealData) -> bool: + def _needs_photo_upload(self, old_deal: HubspotDealData) -> bool: return bool( - deal.major_condition_issue_photos - and not deal.major_condition_issue_evidence_s3_url + old_deal.major_condition_issue_photos + and not old_deal.major_condition_issue_evidence_s3_url ) diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index ea79bc18..f41ef154 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -63,12 +63,11 @@ def handler(body: dict[str, Any], context: Any) -> None: logger.info( f"Deal {hubspot_deal_id} has been changed, updating database..." ) - db_client.update_deal_with_checks( - deal_in_db=db_deal, + db_client.upsert_deal( + deal_data=hubspot_deal, + company=company, + listing=listing, hubspot_client=hubspot_client, - hs_deal=hubspot_deal, - hs_company_id=company, - hs_listing=listing, ) deal_changed = True From a495c930a1bdc0510f7339890467cc0da050268f Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 15:49:37 +0000 Subject: [PATCH 79/93] remove unused import --- etl/hubspot/hubspotDataTodB.py | 1 - 1 file changed, 1 deletion(-) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 6763f19c..c24d5813 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -1,4 +1,3 @@ -import hashlib import os from sqlmodel import select from datetime import datetime, timezone From 757c2241132a6796c5c75133bd5b8c14466340c5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 15:54:33 +0000 Subject: [PATCH 80/93] add image update logging --- etl/hubspot/hubspotDataTodB.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index c24d5813..b7171290 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -10,6 +10,10 @@ from etl.hubspot.s3_uploader import S3Uploader from backend.app.db.connection import db_read_session from backend.app.db.models.organisation import Organisation from etl.hubspot.utils import parse_hs_date +from utils.logger import setup_logger + + +logger = setup_logger() class HubspotDataToDb: @@ -272,7 +276,12 @@ class HubspotDataToDb: return if fresh_photo_url != existing_deal.major_condition_issue_photos: + logger.info( + f"Hubspot image URL changed from {existing_deal.major_condition_issue_photos} to {fresh_photo_url}" + ) self._upload_photo_to_s3(existing_deal, fresh_photo_url, hubspot_client) + else: + logger.info(f"Hubspot iamge URL unchanged: {fresh_photo_url}") def _handle_new_photo_upload( self, From 2fb14858ebaafe5285e19e2623c956e703da5faa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Apr 2026 17:17:41 +0100 Subject: [PATCH 81/93] reduced freshness down to 14 days --- backend/app/db/functions/epc_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/db/functions/epc_functions.py b/backend/app/db/functions/epc_functions.py index 90b2bd02..f43c270b 100644 --- a/backend/app/db/functions/epc_functions.py +++ b/backend/app/db/functions/epc_functions.py @@ -11,7 +11,7 @@ class EpcStoreService: Service layer for EPC data lookup and persistence. """ - FRESHNESS_DAYS = 30 # Upgraded to 30 days + FRESHNESS_DAYS = 14 # Upgraded to 14 days # status labels FRESH = "fresh" @@ -22,7 +22,7 @@ class EpcStoreService: def get_epc_for_uprn(cls, session: Session, uprn: int): """ Query EPC data for a given UPRN and return a dict describing: - - epc_api: only if within last 30 days + - epc_api: only if within last 21 days - epc_page: only if epc_api exists - status: 'fresh', 'expired', or 'missing' """ From 3123723e8b284811d5459befdb277ed2b77e695a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 16:25:44 +0000 Subject: [PATCH 82/93] =?UTF-8?q?differ=20handles=20missing=20timezone=20f?= =?UTF-8?q?rom=20hubspot=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etl/hubspot/hubspotDataTodB.py | 6 ++--- etl/hubspot/scripts/scraper/main.py | 7 +++++ etl/hubspot/tests/test_hubspot_deal_differ.py | 27 ++++++++++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index b7171290..9756833b 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -101,11 +101,11 @@ class HubspotDataToDb: existing = session.exec(statement).first() if existing: + self._handle_existing_photo_upload(existing, hubspot_client) + print(f"🔄 Updating existing deal (deal_id={deal_id})") self._update_existing_deal(existing, deal_data, listing, company) - self._handle_existing_photo_upload(existing, hubspot_client) - session.add(existing) session.commit() session.refresh(existing) @@ -281,7 +281,7 @@ class HubspotDataToDb: ) self._upload_photo_to_s3(existing_deal, fresh_photo_url, hubspot_client) else: - logger.info(f"Hubspot iamge URL unchanged: {fresh_photo_url}") + logger.info(f"Hubspot image URL unchanged: {fresh_photo_url}") def _handle_new_photo_upload( self, diff --git a/etl/hubspot/scripts/scraper/main.py b/etl/hubspot/scripts/scraper/main.py index f41ef154..d754cbb1 100644 --- a/etl/hubspot/scripts/scraper/main.py +++ b/etl/hubspot/scripts/scraper/main.py @@ -104,3 +104,10 @@ def handler(body: dict[str, Any], context: Any) -> None: logger.info( f"Not Triggering PasHub file fetcher for HubSpot deal ID {hubspot_deal_id}" ) + + print("done") + + +if __name__ == "__main__": + handler({"hubspot_deal_id": "371470706915"}, "") + print("beep") diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 74d3f057..9f41a5e6 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict import uuid @@ -352,6 +352,31 @@ def test_db_update_trigger__company_changed__returns_true() -> None: assert result is True +def test_db_update_trigger__missing_hubspot_timezone__returns_false() -> None: + deal_id = uuid.uuid4() + + old_deal = make_old_deal( + id=deal_id, + design_completion_date=datetime(2025, 11, 3, 0, 0, tzinfo=timezone.utc), + ) + + new_deal = make_new_deal( + deal_id, + design_completion_date=datetime(2025, 11, 3, 0, 0), + ) + + new_company = "new_company" + + result = HubspotDealDiffer.check_for_db_update_trigger( + new_deal=new_deal, + new_company=new_company, + new_listing=None, + old_deal=old_deal, + ) + + assert result is False + + def test_db_update_trigger__listing_changed__returns_true() -> None: deal_id = uuid.uuid4() From 9852aa2809ad61667da39c2d612cadb79d55f9b2 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 9 Apr 2026 16:40:47 +0000 Subject: [PATCH 83/93] =?UTF-8?q?differ=20handles=20missing=20timezone=20f?= =?UTF-8?q?rom=20hubspot=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etl/hubspot/hubspot_deal_differ.py | 4 ++++ etl/hubspot/tests/test_hubspot_deal_differ.py | 7 +++---- etl/hubspot/utils.py | 9 +++++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 4db303ab..b95b544c 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -103,6 +103,10 @@ class HubspotDealDiffer: if old_value != new_value: return True + # --- Time field --- + if old_deal.confirmed_survey_time != new_deal.get("confirmed_survey_time"): + return True + # No differences found return False diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 9f41a5e6..69f7668b 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -362,14 +362,13 @@ def test_db_update_trigger__missing_hubspot_timezone__returns_false() -> None: new_deal = make_new_deal( deal_id, - design_completion_date=datetime(2025, 11, 3, 0, 0), + hs_object_id="1", + design_completion_date=datetime(2025, 11, 3, 0, 0).isoformat(), ) - new_company = "new_company" - result = HubspotDealDiffer.check_for_db_update_trigger( new_deal=new_deal, - new_company=new_company, + new_company=None, new_listing=None, old_deal=old_deal, ) diff --git a/etl/hubspot/utils.py b/etl/hubspot/utils.py index 9fbeae62..b7331f94 100644 --- a/etl/hubspot/utils.py +++ b/etl/hubspot/utils.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from typing import Optional @@ -6,6 +6,11 @@ def parse_hs_date(value: Optional[str]) -> Optional[datetime]: if not value: return None try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + + return dt.astimezone(timezone.utc) except ValueError: return None From 025b18a29cac7eb9ee00be225ed747fd2a86abd7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Apr 2026 20:51:04 +0100 Subject: [PATCH 84/93] hot fix --- backend/engine/engine.py | 4 ++++ etl/find_my_epc/RetrieveFindMyEpc.py | 6 ++++++ recommendations/HeatingRecommender.py | 6 ++++++ recommendations/LightingRecommendations.py | 3 +++ recommendations/SecondaryHeating.py | 4 +++- recommendations/WallRecommendations.py | 2 +- recommendations/WindowsRecommendations.py | 3 ++- 7 files changed, 25 insertions(+), 3 deletions(-) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 67c9dd4e..e24a3b95 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -724,6 +724,10 @@ async def model_engine(body: PlanTriggerRequest): # 1) EPC expired 2) Missing EPC 3) Different information from landlord vs EPC needs_rebaselining = p.epc_is_expired | p.epc_is_estimated | (len(p.epc_record.landlord_differences) > 0) + # Hack - skip + if "SAP05" in p.epc_record.walls_description: + continue + if needs_rebaselining: p.create_base_difference_epc_record(cleaned_lookup=cleaned) scoring_data = p.base_difference_record.df.copy() diff --git a/etl/find_my_epc/RetrieveFindMyEpc.py b/etl/find_my_epc/RetrieveFindMyEpc.py index 16b7d8b9..e6e4e5fd 100644 --- a/etl/find_my_epc/RetrieveFindMyEpc.py +++ b/etl/find_my_epc/RetrieveFindMyEpc.py @@ -778,6 +778,12 @@ class RetrieveFindMyEpc: 'Air or ground source heat pump': ["air_source_heat_pump"], "Add PV Battery": ["solar_pv_battery"], "Add PV diverter": ["solar_pv_diverter"], # Don't have a recommendation yet + "Draughtproof single-glazed windows": ["double_glazing"], + "Upgrade heating controls": ["roomstat_programmer_trvs", "time_temperature_zone_control"], + "Low energy lighting recommendation": ["low_energy_lighting"], + "Install cavity wall insulation": ["cavity_wall_insulation"], + "Install solar water heating": ["solar_water_heating"], + 'Install photovoltaics, 25% of roof area': ["solar_pv"], } survey = True diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index 5a7a0e03..6aa5c93a 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -140,6 +140,9 @@ class HeatingRecommender: # All heat systems are in here so we identify whether two of these are true # MainHeatAttributes.HEAT_SYSTEMS + if "sap05" in self.property.main_heating["clean_description"].lower(): + return False + n_trues = 0 for heat_system in MainHeatAttributes.HEAT_SYSTEMS: if self.property.main_heating[f"has_{heat_system.replace(' ', '_')}"]: @@ -318,6 +321,9 @@ class HeatingRecommender: :param measures: A list of measures for the recommendations """ + if "sap05" in self.property.main_heating["clean_description"].lower(): + return + measures = MEASURE_MAP["heating"] if measures is None else measures # if we have a non-invasive ashp recommendation, we get the configuration directly from the property instance diff --git a/recommendations/LightingRecommendations.py b/recommendations/LightingRecommendations.py index 61b1f66a..91db19c9 100644 --- a/recommendations/LightingRecommendations.py +++ b/recommendations/LightingRecommendations.py @@ -100,6 +100,9 @@ class LightingRecommendations: :return: """ + if "sap05" in self.property.lighting["clean_description"].lower(): + return + if self.property.lighting["low_energy_proportion"] >= 1: return diff --git a/recommendations/SecondaryHeating.py b/recommendations/SecondaryHeating.py index c2250e1e..5b7f4ff9 100644 --- a/recommendations/SecondaryHeating.py +++ b/recommendations/SecondaryHeating.py @@ -18,7 +18,9 @@ class SecondaryHeating: def recommend(self, phase: int): # Reset self.recommendation = [] - if self.property.epc_record.secondheat_description in ["None", None]: + if self.property.epc_record.secondheat_description in ["None", None] or ( + "sap05" in self.property.epc_record.secondheat_description.lower() + ): # No secondary heating system, so no recommendation to remove it return diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py index a696e878..6baa85aa 100644 --- a/recommendations/WallRecommendations.py +++ b/recommendations/WallRecommendations.py @@ -169,7 +169,7 @@ class WallRecommendations(Definitions): if ( (insulation_thickness in ["average", "above average"]) or self.property.walls["is_filled_cavity"] - or self.property.walls["clean_description"] is None + or self.property.walls["clean_description"] in [None, "Sap05:walls"] ) and ("cavity_extract_and_refill" not in measures ): return diff --git a/recommendations/WindowsRecommendations.py b/recommendations/WindowsRecommendations.py index ff75e72d..54445eb1 100644 --- a/recommendations/WindowsRecommendations.py +++ b/recommendations/WindowsRecommendations.py @@ -45,7 +45,8 @@ class WindowsRecommendations: measures = MEASURE_MAP["windows"] if measures is None else measures # If we have no windows recs, leave - if not any(x in measures for x in MEASURE_MAP["windows"]): + if not any(x in measures for x in MEASURE_MAP["windows"]) or "sap05" in self.property.windows[ + "clean_description"].lower(): return if self.property.windows["glazing_type"] in ["triple", "high performance"]: From 06503cd989b472b99627ff7c1aca6760cf5a984f Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 10 Apr 2026 10:23:38 +0000 Subject: [PATCH 85/93] correct use of terraform state and fix typo --- infrastructure/terraform/lambda/hubspot_deal_etl/main.tf | 2 +- infrastructure/terraform/lambda/pashub_to_ara/outputs.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf index 518e1e05..516ec282 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf @@ -48,7 +48,7 @@ module "hubspot_deal_etl" { DB_PORT = var.db_port HUBSPOT_API_KEY = var.hubspot_api_key - PASHUB_TO_ARA_SQS_URL = data.terraform_remote_state.pashub_to_ara.pashhub_to_ara_queue_url + PASHUB_TO_ARA_SQS_URL = data.terraform_remote_state.pashub_to_ara.outputs.pashub_to_ara_queue_url } } diff --git a/infrastructure/terraform/lambda/pashub_to_ara/outputs.tf b/infrastructure/terraform/lambda/pashub_to_ara/outputs.tf index 738aa4fc..d44b8763 100644 --- a/infrastructure/terraform/lambda/pashub_to_ara/outputs.tf +++ b/infrastructure/terraform/lambda/pashub_to_ara/outputs.tf @@ -1,4 +1,4 @@ -output "pashhub_to_ara_queue_url" { +output "pashub_to_ara_queue_url" { value = module.lambda.queue_url description = "URL of the PasHub to Ara SQS queue" } From d95be6ce6510746a4db7b7e1cdd1f6fd42d86b3d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 10 Apr 2026 11:33:17 +0000 Subject: [PATCH 86/93] first draft dataclasses with loading tests --- datatypes/epc/schema/__init__.py | 17 + datatypes/epc/schema/common.py | 22 + datatypes/epc/schema/rdsap_schema_17_0.py | 222 ++++++++++ datatypes/epc/schema/rdsap_schema_17_1.py | 234 +++++++++++ datatypes/epc/schema/rdsap_schema_18_0.py | 245 +++++++++++ datatypes/epc/schema/rdsap_schema_19_0.py | 251 ++++++++++++ datatypes/epc/schema/rdsap_schema_20_0_0.py | 282 +++++++++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 339 ++++++++++++++++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 340 ++++++++++++++++ datatypes/epc/schema/tests/__init__.py | 0 datatypes/epc/schema/tests/fixtures/17_0.json | 218 ++++++++++ datatypes/epc/schema/tests/fixtures/17_1.json | 243 +++++++++++ datatypes/epc/schema/tests/fixtures/18_0.json | 251 ++++++++++++ datatypes/epc/schema/tests/fixtures/19_0.json | 213 ++++++++++ .../epc/schema/tests/fixtures/20_0_0.json | 225 +++++++++++ .../epc/schema/tests/fixtures/21_0_0.json | 245 +++++++++++ .../epc/schema/tests/fixtures/21_0_1.json | 222 ++++++++++ datatypes/epc/schema/tests/helpers.py | 73 ++++ .../epc/schema/tests/test_schema_loading.py | 380 ++++++++++++++++++ pytest.ini | 2 +- 20 files changed, 4023 insertions(+), 1 deletion(-) create mode 100644 datatypes/epc/schema/__init__.py create mode 100644 datatypes/epc/schema/common.py create mode 100644 datatypes/epc/schema/rdsap_schema_17_0.py create mode 100644 datatypes/epc/schema/rdsap_schema_17_1.py create mode 100644 datatypes/epc/schema/rdsap_schema_18_0.py create mode 100644 datatypes/epc/schema/rdsap_schema_19_0.py create mode 100644 datatypes/epc/schema/rdsap_schema_20_0_0.py create mode 100644 datatypes/epc/schema/rdsap_schema_21_0_0.py create mode 100644 datatypes/epc/schema/rdsap_schema_21_0_1.py create mode 100644 datatypes/epc/schema/tests/__init__.py create mode 100644 datatypes/epc/schema/tests/fixtures/17_0.json create mode 100644 datatypes/epc/schema/tests/fixtures/17_1.json create mode 100644 datatypes/epc/schema/tests/fixtures/18_0.json create mode 100644 datatypes/epc/schema/tests/fixtures/19_0.json create mode 100644 datatypes/epc/schema/tests/fixtures/20_0_0.json create mode 100644 datatypes/epc/schema/tests/fixtures/21_0_0.json create mode 100644 datatypes/epc/schema/tests/fixtures/21_0_1.json create mode 100644 datatypes/epc/schema/tests/helpers.py create mode 100644 datatypes/epc/schema/tests/test_schema_loading.py diff --git a/datatypes/epc/schema/__init__.py b/datatypes/epc/schema/__init__.py new file mode 100644 index 00000000..e49f2317 --- /dev/null +++ b/datatypes/epc/schema/__init__.py @@ -0,0 +1,17 @@ +from .rdsap_schema_17_0 import RdSapSchema17_0 +from .rdsap_schema_17_1 import RdSapSchema17_1 +from .rdsap_schema_18_0 import RdSapSchema18_0 +from .rdsap_schema_19_0 import RdSapSchema19_0 +from .rdsap_schema_20_0_0 import RdSapSchema20_0_0 +from .rdsap_schema_21_0_0 import RdSapSchema21_0_0 +from .rdsap_schema_21_0_1 import RdSapSchema21_0_1 + +__all__ = [ + "RdSapSchema17_0", + "RdSapSchema17_1", + "RdSapSchema18_0", + "RdSapSchema19_0", + "RdSapSchema20_0_0", + "RdSapSchema21_0_0", + "RdSapSchema21_0_1", +] diff --git a/datatypes/epc/schema/common.py b/datatypes/epc/schema/common.py new file mode 100644 index 00000000..aa9ea512 --- /dev/null +++ b/datatypes/epc/schema/common.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass +class Measurement: + """A numeric value with a physical unit, e.g. {"value": 2.4, "quantity": "metres"}.""" + value: float + quantity: str + + +@dataclass +class DescriptionV1: + """Localised description object used in schemas 17.x, 18.x, 19.x, and 21.0.1.""" + value: str + language: str + + +@dataclass +class CostAmount: + """Monetary amount used in schemas 17.x, 18.x, and 19.x.""" + value: int + currency: str diff --git a/datatypes/epc/schema/rdsap_schema_17_0.py b/datatypes/epc/schema/rdsap_schema_17_0.py new file mode 100644 index 00000000..22aaded4 --- /dev/null +++ b/datatypes/epc/schema/rdsap_schema_17_0.py @@ -0,0 +1,222 @@ +from dataclasses import dataclass +from typing import List, Optional, Union + +from .common import CostAmount, DescriptionV1, Measurement + + +@dataclass +class EnergyElement: + description: DescriptionV1 + energy_efficiency_rating: int + environmental_efficiency_rating: int + + +@dataclass +class InstantaneousWwhrs: + rooms_with_bath_and_or_shower: int + rooms_with_mixer_shower_no_bath: int + rooms_with_bath_and_mixer_shower: int + + +@dataclass +class MainHeatingDetail: + has_fghrs: str + main_fuel_type: int + heat_emitter_type: int + 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 + sap_main_heating_code: Optional[int] = None + + +@dataclass +class SapHeating: + cylinder_size: int + water_heating_code: int + water_heating_fuel: int + instantaneous_wwhrs: InstantaneousWwhrs + main_heating_details: List[MainHeatingDetail] + immersion_heating_type: Union[int, str] + cylinder_insulation_type: int + has_fixed_air_conditioning: str + + +@dataclass +class PhotovoltaicSupplyNoneOrNoDetails: + pv_connection: int + percent_roof_area: int + + +@dataclass +class PhotovoltaicSupply: + none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails + + +@dataclass +class SapEnergySource: + mains_gas: str + meter_type: int + photovoltaic_supply: PhotovoltaicSupply + wind_turbines_count: int + wind_turbines_terrain_type: int + + +@dataclass +class SapFloorDimension: + floor: int + room_height: Measurement + total_floor_area: Measurement + party_wall_length: Measurement + heat_loss_perimeter: Measurement + + +@dataclass +class SapBuildingPart: + identifier: str + wall_dry_lined: str + wall_thickness: int + floor_heat_loss: int + roof_construction: int + wall_construction: int + building_part_number: int + sap_floor_dimensions: List[SapFloorDimension] + wall_insulation_type: int + construction_age_band: str + party_wall_construction: Union[int, str] + wall_thickness_measured: str + roof_insulation_location: Union[int, str] + roof_insulation_thickness: str + wall_insulation_thickness: Optional[str] = None + + +@dataclass +class SapFlatDetails: + level: int + top_storey: str + flat_location: int + heat_loss_corridor: int + + +@dataclass +class ImprovementDetails: + improvement_number: int + + +@dataclass +class SuggestedImprovement: + sequence: int + typical_saving: CostAmount + indicative_cost: str + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class AlternativeImprovement: + sequence: int + typical_saving: CostAmount + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class RenewableHeatIncentive: + water_heating: int + space_heating_existing_dwelling: int + impact_of_loft_insulation: Optional[int] = None + impact_of_solid_wall_insulation: Optional[int] = None + + +@dataclass +class RdSapSchema17_0: + uprn: int + roofs: List[EnergyElement] + walls: List[EnergyElement] + floors: List[EnergyElement] + status: str + tenure: int + window: EnergyElement + lighting: EnergyElement + postcode: str + hot_water: EnergyElement + post_town: str + built_form: int + door_count: int + glazed_area: int + glazing_gap: str + region_code: int + report_type: int + sap_heating: SapHeating + sap_version: float + schema_type: str + uprn_source: str + country_code: str + main_heating: List[EnergyElement] + dwelling_type: DescriptionV1 + language_code: int + property_type: int + address_line_1: str + assessment_type: str + completion_date: str + inspection_date: str + extensions_count: int + measurement_type: int + total_floor_area: int + transaction_type: int + conservatory_type: int + heated_room_count: int + pvc_window_frames: str + registration_date: str + sap_energy_source: SapEnergySource + secondary_heating: EnergyElement + lzc_energy_sources: List[int] + sap_building_parts: List[SapBuildingPart] + low_energy_lighting: int + solar_water_heating: str + habitable_room_count: int + heating_cost_current: CostAmount + insulated_door_count: int + co2_emissions_current: float + energy_rating_average: int + energy_rating_current: int + lighting_cost_current: CostAmount + main_heating_controls: List[EnergyElement] + multiple_glazing_type: int + open_fireplaces_count: int + has_hot_water_cylinder: str + heating_cost_potential: CostAmount + hot_water_cost_current: CostAmount + mechanical_ventilation: int + percent_draughtproofed: int + suggested_improvements: List[SuggestedImprovement] + co2_emissions_potential: float + energy_rating_potential: int + lighting_cost_potential: CostAmount + schema_version_original: str + hot_water_cost_potential: CostAmount + renewable_heat_incentive: RenewableHeatIncentive + energy_consumption_current: int + has_fixed_air_conditioning: str + multiple_glazed_proportion: int + calculation_software_version: str + energy_consumption_potential: int + environmental_impact_current: int + fixed_lighting_outlets_count: int + current_energy_efficiency_band: str + environmental_impact_potential: int + has_heated_separate_conservatory: str + potential_energy_efficiency_band: str + co2_emissions_current_per_floor_area: int + low_energy_fixed_lighting_outlets_count: int + sap_flat_details: Optional[SapFlatDetails] = None + address_line_2: Optional[str] = None + alternative_improvements: Optional[List[AlternativeImprovement]] = None diff --git a/datatypes/epc/schema/rdsap_schema_17_1.py b/datatypes/epc/schema/rdsap_schema_17_1.py new file mode 100644 index 00000000..a4c007ed --- /dev/null +++ b/datatypes/epc/schema/rdsap_schema_17_1.py @@ -0,0 +1,234 @@ +from dataclasses import dataclass +from typing import List, Optional, Union + +from .common import CostAmount, DescriptionV1, Measurement + + +@dataclass +class EnergyElement: + description: DescriptionV1 + energy_efficiency_rating: int + environmental_efficiency_rating: int + + +@dataclass +class InstantaneousWwhrs: + rooms_with_bath_and_or_shower: int + rooms_with_mixer_shower_no_bath: int + rooms_with_bath_and_mixer_shower: int + + +@dataclass +class MainHeatingDetail: + has_fghrs: str + main_fuel_type: int + heat_emitter_type: int + 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 + boiler_flue_type: Optional[int] = None + fan_flue_present: Optional[str] = None + mcs_installed_heat_pump: Optional[str] = None + main_heating_index_number: Optional[int] = None + sap_main_heating_code: Optional[int] = None + + +@dataclass +class SapHeating: + cylinder_size: int + water_heating_code: int + water_heating_fuel: int + instantaneous_wwhrs: InstantaneousWwhrs + main_heating_details: List[MainHeatingDetail] + immersion_heating_type: Union[int, str] + cylinder_insulation_type: int + has_fixed_air_conditioning: str + 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 PhotovoltaicSupplyNoneOrNoDetails: + pv_connection: int + percent_roof_area: int + + +@dataclass +class PhotovoltaicSupply: + none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails + + +@dataclass +class SapEnergySource: + mains_gas: str + meter_type: int + photovoltaic_supply: PhotovoltaicSupply + wind_turbines_count: int + wind_turbines_terrain_type: int + + +@dataclass +class SapFloorDimension: + floor: int + room_height: Measurement + total_floor_area: Measurement + # Can be a Measurement object or 0 (int) for party walls of zero length + party_wall_length: Union[Measurement, int] + heat_loss_perimeter: Measurement + floor_insulation: Optional[int] = None + floor_construction: Optional[int] = None + + +@dataclass +class SapBuildingPart: + identifier: str + wall_dry_lined: str + wall_thickness: int + floor_heat_loss: int + roof_construction: int + wall_construction: int + building_part_number: int + sap_floor_dimensions: List[SapFloorDimension] + wall_insulation_type: int + construction_age_band: str + party_wall_construction: Union[int, str] + wall_thickness_measured: str + roof_insulation_location: Union[int, str] + # Can be a thickness string (e.g. "100mm") or 0 for uninsulated flat roofs + roof_insulation_thickness: Union[str, int] + wall_insulation_thickness: Optional[str] = None + + +@dataclass +class SapFlatDetails: + level: int + top_storey: str + flat_location: int + heat_loss_corridor: int + + +@dataclass +class ImprovementDetails: + improvement_number: int + + +@dataclass +class SuggestedImprovement: + sequence: int + typical_saving: CostAmount + indicative_cost: str + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class AlternativeImprovement: + sequence: int + typical_saving: CostAmount + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class RenewableHeatIncentive: + water_heating: int + space_heating_existing_dwelling: int + impact_of_loft_insulation: Optional[int] = None + impact_of_solid_wall_insulation: Optional[int] = None + + +@dataclass +class RdSapSchema17_1: + uprn: int + roofs: List[EnergyElement] + walls: List[EnergyElement] + floors: List[EnergyElement] + status: str + tenure: int + window: EnergyElement + lighting: EnergyElement + postcode: str + hot_water: EnergyElement + post_town: str + built_form: int + door_count: int + glazed_area: int + glazing_gap: str + region_code: int + report_type: int + sap_heating: SapHeating + sap_version: float + schema_type: str + uprn_source: str + country_code: str + main_heating: List[EnergyElement] + dwelling_type: DescriptionV1 + language_code: int + property_type: int + address_line_1: str + assessment_type: str + completion_date: str + inspection_date: str + extensions_count: int + measurement_type: int + total_floor_area: int + transaction_type: int + conservatory_type: int + heated_room_count: int + pvc_window_frames: str + registration_date: str + sap_energy_source: SapEnergySource + secondary_heating: EnergyElement + lzc_energy_sources: List[int] + sap_building_parts: List[SapBuildingPart] + low_energy_lighting: int + solar_water_heating: str + habitable_room_count: int + heating_cost_current: CostAmount + insulated_door_count: int + co2_emissions_current: float + energy_rating_average: int + energy_rating_current: int + lighting_cost_current: CostAmount + main_heating_controls: List[EnergyElement] + multiple_glazing_type: int + open_fireplaces_count: int + has_hot_water_cylinder: str + heating_cost_potential: CostAmount + hot_water_cost_current: CostAmount + mechanical_ventilation: int + percent_draughtproofed: int + suggested_improvements: List[SuggestedImprovement] + co2_emissions_potential: float + energy_rating_potential: int + lighting_cost_potential: CostAmount + schema_version_original: str + hot_water_cost_potential: CostAmount + renewable_heat_incentive: RenewableHeatIncentive + energy_consumption_current: int + has_fixed_air_conditioning: str + multiple_glazed_proportion: int + calculation_software_version: str + energy_consumption_potential: int + environmental_impact_current: int + fixed_lighting_outlets_count: int + current_energy_efficiency_band: str + environmental_impact_potential: int + has_heated_separate_conservatory: str + potential_energy_efficiency_band: str + co2_emissions_current_per_floor_area: int + low_energy_fixed_lighting_outlets_count: int + sap_flat_details: Optional[SapFlatDetails] = None + address_line_2: Optional[str] = None + alternative_improvements: Optional[List[AlternativeImprovement]] = None diff --git a/datatypes/epc/schema/rdsap_schema_18_0.py b/datatypes/epc/schema/rdsap_schema_18_0.py new file mode 100644 index 00000000..a038dc9b --- /dev/null +++ b/datatypes/epc/schema/rdsap_schema_18_0.py @@ -0,0 +1,245 @@ +from dataclasses import dataclass +from typing import List, Optional, Union + +from .common import CostAmount, DescriptionV1, Measurement + + +@dataclass +class EnergyElement: + description: DescriptionV1 + energy_efficiency_rating: int + environmental_efficiency_rating: int + + +@dataclass +class InstantaneousWwhrs: + rooms_with_bath_and_or_shower: int + rooms_with_mixer_shower_no_bath: int + rooms_with_bath_and_mixer_shower: int + + +@dataclass +class MainHeatingDetail: + has_fghrs: str + main_fuel_type: int + heat_emitter_type: int + 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 + boiler_flue_type: Optional[int] = None + fan_flue_present: Optional[str] = None + central_heating_pump_age: Optional[int] = None + main_heating_index_number: Optional[int] = None + sap_main_heating_code: Optional[int] = None + + +@dataclass +class SapHeating: + cylinder_size: int + water_heating_code: int + water_heating_fuel: int + instantaneous_wwhrs: InstantaneousWwhrs + main_heating_details: List[MainHeatingDetail] + immersion_heating_type: Union[int, str] + has_fixed_air_conditioning: str + 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 PhotovoltaicSupplyNoneOrNoDetails: + pv_connection: int + percent_roof_area: int + + +@dataclass +class PhotovoltaicSupply: + none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails + + +@dataclass +class SapEnergySource: + mains_gas: str + meter_type: int + photovoltaic_supply: PhotovoltaicSupply + wind_turbines_count: int + wind_turbines_terrain_type: int + + +@dataclass +class SapFloorDimension: + floor: int + room_height: Measurement + total_floor_area: Measurement + party_wall_length: Union[Measurement, int] + heat_loss_perimeter: Measurement + floor_insulation: Optional[int] = None + floor_construction: Optional[int] = None + + +@dataclass +class SapRoomInRoof: + """Room-in-roof details. floor_area is a Measurement object in schema 18.0.""" + floor_area: Measurement + insulation: str + roof_room_connected: str + construction_age_band: str + + +@dataclass +class SapBuildingPart: + identifier: str + wall_dry_lined: str + wall_thickness: int + floor_heat_loss: int + roof_construction: int + wall_construction: int + building_part_number: int + sap_floor_dimensions: List[SapFloorDimension] + wall_insulation_type: int + construction_age_band: str + party_wall_construction: Union[int, str] + wall_thickness_measured: str + roof_insulation_location: Union[int, str] + roof_insulation_thickness: Union[str, int] + sap_room_in_roof: Optional[SapRoomInRoof] = None + wall_insulation_thickness: Optional[str] = None + floor_insulation_thickness: Optional[str] = None + flat_roof_insulation_thickness: Optional[Union[str, int]] = None + + +@dataclass +class SapFlatDetails: + level: int + top_storey: str + flat_location: int + heat_loss_corridor: int + + +@dataclass +class ImprovementDetails: + improvement_number: int + + +@dataclass +class SuggestedImprovement: + sequence: int + typical_saving: CostAmount + indicative_cost: str + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class AlternativeImprovement: + sequence: int + typical_saving: CostAmount + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class RenewableHeatIncentive: + water_heating: int + space_heating_existing_dwelling: int + impact_of_loft_insulation: Optional[int] = None + impact_of_solid_wall_insulation: Optional[int] = None + + +@dataclass +class RdSapSchema18_0: + uprn: int + roofs: List[EnergyElement] + walls: List[EnergyElement] + floors: List[EnergyElement] + status: str + tenure: int + window: EnergyElement + lighting: EnergyElement + postcode: str + hot_water: EnergyElement + post_town: str + built_form: int + door_count: int + glazed_area: int + # glazing_gap is an integer in 18.0 (e.g. 12 mm), unlike 17.x where it was a string + glazing_gap: int + region_code: int + report_type: int + sap_heating: SapHeating + sap_version: float + schema_type: str + uprn_source: str + country_code: str + main_heating: List[EnergyElement] + dwelling_type: DescriptionV1 + language_code: int + property_type: int + address_line_1: str + assessment_type: str + completion_date: str + inspection_date: str + extensions_count: int + measurement_type: int + total_floor_area: int + transaction_type: int + conservatory_type: int + heated_room_count: int + pvc_window_frames: str + registration_date: str + sap_energy_source: SapEnergySource + secondary_heating: EnergyElement + lzc_energy_sources: List[int] + sap_building_parts: List[SapBuildingPart] + low_energy_lighting: int + solar_water_heating: str + habitable_room_count: int + heating_cost_current: CostAmount + insulated_door_count: int + co2_emissions_current: float + energy_rating_average: int + energy_rating_current: int + lighting_cost_current: CostAmount + main_heating_controls: List[EnergyElement] + multiple_glazing_type: int + open_fireplaces_count: int + has_hot_water_cylinder: str + heating_cost_potential: CostAmount + hot_water_cost_current: CostAmount + mechanical_ventilation: int + percent_draughtproofed: int + suggested_improvements: List[SuggestedImprovement] + co2_emissions_potential: float + energy_rating_potential: int + lighting_cost_potential: CostAmount + schema_version_original: str + hot_water_cost_potential: CostAmount + renewable_heat_incentive: RenewableHeatIncentive + energy_consumption_current: int + has_fixed_air_conditioning: str + multiple_glazed_proportion: int + calculation_software_version: str + energy_consumption_potential: int + environmental_impact_current: int + fixed_lighting_outlets_count: int + current_energy_efficiency_band: str + environmental_impact_potential: int + has_heated_separate_conservatory: str + potential_energy_efficiency_band: str + co2_emissions_current_per_floor_area: int + low_energy_fixed_lighting_outlets_count: int + sap_flat_details: Optional[SapFlatDetails] = None + address_line_2: Optional[str] = None + alternative_improvements: Optional[List[AlternativeImprovement]] = None diff --git a/datatypes/epc/schema/rdsap_schema_19_0.py b/datatypes/epc/schema/rdsap_schema_19_0.py new file mode 100644 index 00000000..b94d9bb3 --- /dev/null +++ b/datatypes/epc/schema/rdsap_schema_19_0.py @@ -0,0 +1,251 @@ +from dataclasses import dataclass +from typing import List, Optional, Union + +from .common import CostAmount, DescriptionV1, Measurement + + +@dataclass +class EnergyElement: + description: DescriptionV1 + energy_efficiency_rating: int + environmental_efficiency_rating: int + + +@dataclass +class InstantaneousWwhrs: + rooms_with_bath_and_or_shower: int + rooms_with_mixer_shower_no_bath: int + rooms_with_bath_and_mixer_shower: int + + +@dataclass +class MainHeatingDetail: + has_fghrs: str + main_fuel_type: int + heat_emitter_type: int + 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 + boiler_flue_type: Optional[int] = None + fan_flue_present: Optional[str] = None + central_heating_pump_age: Optional[int] = None + main_heating_index_number: Optional[int] = None + sap_main_heating_code: Optional[int] = None + + +@dataclass +class SapHeating: + cylinder_size: int + water_heating_code: int + water_heating_fuel: int + instantaneous_wwhrs: InstantaneousWwhrs + main_heating_details: List[MainHeatingDetail] + immersion_heating_type: Union[int, str] + has_fixed_air_conditioning: str + 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 PhotovoltaicSupplyNoneOrNoDetails: + pv_connection: int + percent_roof_area: int + + +@dataclass +class PhotovoltaicSupply: + none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails + + +@dataclass +class SapEnergySource: + mains_gas: str + meter_type: int + photovoltaic_supply: PhotovoltaicSupply + wind_turbines_count: int + wind_turbines_terrain_type: int + + +@dataclass +class SapFloorDimension: + floor: int + room_height: Measurement + total_floor_area: Measurement + party_wall_length: Union[Measurement, int] + heat_loss_perimeter: Measurement + floor_insulation: Optional[int] = None + floor_construction: Optional[int] = None + + +@dataclass +class SapRoomInRoof: + floor_area: Measurement + insulation: str + roof_room_connected: str + construction_age_band: str + + +@dataclass +class SapBuildingPart: + identifier: str + wall_dry_lined: str + wall_thickness: int + floor_heat_loss: int + roof_construction: int + wall_construction: int + building_part_number: int + sap_floor_dimensions: List[SapFloorDimension] + wall_insulation_type: int + construction_age_band: str + party_wall_construction: Union[int, str] + wall_thickness_measured: str + roof_insulation_location: Union[int, str] + roof_insulation_thickness: Union[str, int] + sap_room_in_roof: Optional[SapRoomInRoof] = None + wall_insulation_thickness: Optional[str] = None + floor_insulation_thickness: Optional[str] = None + flat_roof_insulation_thickness: Optional[Union[str, int]] = None + + +@dataclass +class SapFlatDetails: + level: int + top_storey: str + flat_location: int + heat_loss_corridor: int + + +@dataclass +class WindowsTransmissionDetails: + u_value: float + data_source: int + solar_transmittance: float + + +@dataclass +class ImprovementDetails: + improvement_number: int + + +@dataclass +class SuggestedImprovement: + sequence: int + typical_saving: CostAmount + indicative_cost: str + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class AlternativeImprovement: + sequence: int + typical_saving: CostAmount + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class RenewableHeatIncentive: + water_heating: int + space_heating_existing_dwelling: int + impact_of_loft_insulation: Optional[int] = None + impact_of_solid_wall_insulation: Optional[int] = None + + +@dataclass +class RdSapSchema19_0: + uprn: int + roofs: List[EnergyElement] + walls: List[EnergyElement] + floors: List[EnergyElement] + status: str + tenure: int + window: EnergyElement + lighting: EnergyElement + postcode: str + hot_water: EnergyElement + post_town: str + built_form: int + door_count: int + glazed_area: int + region_code: int + report_type: int + sap_heating: SapHeating + sap_version: float + schema_type: str + uprn_source: str + country_code: str + main_heating: List[EnergyElement] + dwelling_type: DescriptionV1 + language_code: int + property_type: int + address_line_1: str + assessment_type: str + completion_date: str + inspection_date: str + extensions_count: int + measurement_type: int + total_floor_area: int + transaction_type: int + conservatory_type: int + heated_room_count: int + pvc_window_frames: str + registration_date: str + sap_energy_source: SapEnergySource + secondary_heating: EnergyElement + lzc_energy_sources: List[int] + sap_building_parts: List[SapBuildingPart] + low_energy_lighting: int + solar_water_heating: str + habitable_room_count: int + heating_cost_current: CostAmount + insulated_door_count: int + co2_emissions_current: float + energy_rating_average: int + energy_rating_current: int + lighting_cost_current: CostAmount + main_heating_controls: List[EnergyElement] + multiple_glazing_type: int + open_fireplaces_count: int + has_hot_water_cylinder: str + heating_cost_potential: CostAmount + hot_water_cost_current: CostAmount + mechanical_ventilation: int + percent_draughtproofed: int + suggested_improvements: List[SuggestedImprovement] + co2_emissions_potential: float + energy_rating_potential: int + lighting_cost_potential: CostAmount + schema_version_original: str + hot_water_cost_potential: CostAmount + renewable_heat_incentive: RenewableHeatIncentive + windows_transmission_details: WindowsTransmissionDetails + energy_consumption_current: int + has_fixed_air_conditioning: str + multiple_glazed_proportion: int + calculation_software_version: str + energy_consumption_potential: int + environmental_impact_current: int + fixed_lighting_outlets_count: int + current_energy_efficiency_band: str + environmental_impact_potential: int + has_heated_separate_conservatory: str + potential_energy_efficiency_band: str + co2_emissions_current_per_floor_area: int + low_energy_fixed_lighting_outlets_count: int + sap_flat_details: Optional[SapFlatDetails] = None + address_line_2: Optional[str] = None + glazing_gap: Optional[Union[str, int]] = None + alternative_improvements: Optional[List[AlternativeImprovement]] = None diff --git a/datatypes/epc/schema/rdsap_schema_20_0_0.py b/datatypes/epc/schema/rdsap_schema_20_0_0.py new file mode 100644 index 00000000..8f3986a2 --- /dev/null +++ b/datatypes/epc/schema/rdsap_schema_20_0_0.py @@ -0,0 +1,282 @@ +from dataclasses import dataclass +from typing import List, Optional, Union + +from .common import Measurement + + +@dataclass +class EnergyElement: + # description is a plain string in schema 20.0.0 onwards (no longer a localised object) + description: str + energy_efficiency_rating: int + environmental_efficiency_rating: int + + +@dataclass +class Addendum: + addendum_numbers: List[int] + stone_walls: Optional[str] = None + system_build: Optional[str] = None + + +@dataclass +class InstantaneousWwhrs: + rooms_with_bath_and_or_shower: int + rooms_with_mixer_shower_no_bath: int + rooms_with_bath_and_mixer_shower: int + + +@dataclass +class MainHeatingDetail: + has_fghrs: str + main_fuel_type: int + heat_emitter_type: int + 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 + boiler_flue_type: Optional[int] = None + fan_flue_present: Optional[str] = None + central_heating_pump_age: Optional[int] = None + main_heating_index_number: Optional[int] = None + sap_main_heating_code: Optional[int] = None + + +@dataclass +class SapHeating: + cylinder_size: int + water_heating_code: int + water_heating_fuel: int + instantaneous_wwhrs: InstantaneousWwhrs + main_heating_details: List[MainHeatingDetail] + immersion_heating_type: Union[int, str] + has_fixed_air_conditioning: str + 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 PhotovoltaicSupplyNoneOrNoDetails: + pv_connection: int + percent_roof_area: int + + +@dataclass +class PhotovoltaicSupply: + none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails + + +@dataclass +class SapEnergySource: + mains_gas: str + meter_type: int + photovoltaic_supply: PhotovoltaicSupply + wind_turbines_count: int + wind_turbines_terrain_type: int + + +@dataclass +class SapWindow: + orientation: int + window_area: float + window_type: int + glazing_type: int + window_location: int + + +@dataclass +class SapFloorDimension: + floor: int + room_height: Measurement + total_floor_area: Measurement + party_wall_length: Union[Measurement, int] + heat_loss_perimeter: Measurement + floor_insulation: Optional[int] = None + floor_construction: Optional[int] = None + + +@dataclass +class SapRoomInRoof: + """Room-in-roof details. floor_area is a plain number in schema 20.0.0 (not a Measurement object).""" + floor_area: Union[int, float] + insulation: str + roof_room_connected: str + 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: + identifier: str + wall_dry_lined: str + floor_heat_loss: int + roof_construction: int + wall_construction: int + building_part_number: int + sap_floor_dimensions: List[SapFloorDimension] + wall_insulation_type: int + construction_age_band: str + party_wall_construction: Union[int, str] + wall_thickness_measured: str + roof_insulation_location: Union[int, str] + roof_insulation_thickness: Union[str, int] + sap_room_in_roof: Optional[SapRoomInRoof] = None + wall_thickness: Optional[int] = None + wall_insulation_thickness: Optional[str] = None + floor_insulation_thickness: Optional[str] = None + flat_roof_insulation_thickness: Optional[Union[str, int]] = None + + +@dataclass +class SapFlatDetails: + level: int + top_storey: str + flat_location: int + heat_loss_corridor: int + storey_count: Optional[int] = None + unheated_corridor_length: Optional[int] = None + + +@dataclass +class WindowsTransmissionDetails: + u_value: float + data_source: int + solar_transmittance: float + + +@dataclass +class ImprovementTexts: + improvement_description: str + improvement_summary: Optional[str] = None + + +@dataclass +class ImprovementDetails: + improvement_number: Optional[int] = None + improvement_texts: Optional[ImprovementTexts] = None + + +@dataclass +class SuggestedImprovement: + sequence: int + # typical_saving is a plain number in schema 20.0.0 (not a CostAmount object) + typical_saving: float + # indicative_cost can be a formatted string (e.g. "£100 - £350") or a plain integer + indicative_cost: Union[str, int] + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class RenewableHeatIncentive: + water_heating: int + space_heating_existing_dwelling: int + impact_of_loft_insulation: Optional[int] = None + impact_of_cavity_insulation: Optional[int] = None + impact_of_solid_wall_insulation: Optional[int] = None + + +@dataclass +class RdSapSchema20_0_0: + uprn: int + roofs: List[EnergyElement] + walls: List[EnergyElement] + floors: List[EnergyElement] + status: str + tenure: int + window: EnergyElement + lighting: EnergyElement + postcode: str + hot_water: EnergyElement + post_town: str + built_form: int + door_count: int + glazed_area: int + region_code: int + report_type: int + sap_heating: SapHeating + sap_version: float + sap_windows: List[SapWindow] + schema_type: str + uprn_source: str + country_code: str + main_heating: List[EnergyElement] + # dwelling_type is a plain string in schema 20.0.0 onwards + dwelling_type: str + language_code: int + property_type: int + address_line_1: str + assessment_type: str + completion_date: str + inspection_date: str + extensions_count: int + measurement_type: int + total_floor_area: int + transaction_type: int + conservatory_type: int + heated_room_count: int + registration_date: str + sap_energy_source: SapEnergySource + secondary_heating: EnergyElement + lzc_energy_sources: List[int] + sap_building_parts: List[SapBuildingPart] + low_energy_lighting: int + solar_water_heating: str + habitable_room_count: int + heating_cost_current: float + insulated_door_count: int + co2_emissions_current: float + energy_rating_average: int + energy_rating_current: int + lighting_cost_current: float + main_heating_controls: List[EnergyElement] + multiple_glazing_type: int + open_fireplaces_count: int + heating_cost_potential: float + hot_water_cost_current: float + insulated_door_u_value: float + mechanical_ventilation: int + percent_draughtproofed: int + suggested_improvements: List[SuggestedImprovement] + co2_emissions_potential: float + energy_rating_potential: int + lighting_cost_potential: float + schema_version_original: str + hot_water_cost_potential: float + renewable_heat_incentive: RenewableHeatIncentive + windows_transmission_details: WindowsTransmissionDetails + energy_consumption_current: int + multiple_glazed_proportion: int + calculation_software_version: str + energy_consumption_potential: int + environmental_impact_current: int + fixed_lighting_outlets_count: int + multiple_glazed_proportion_nr: Optional[str] + current_energy_efficiency_band: str + environmental_impact_potential: int + potential_energy_efficiency_band: str + co2_emissions_current_per_floor_area: int + low_energy_fixed_lighting_outlets_count: int + sap_flat_details: Optional[SapFlatDetails] = None + addendum: Optional[Addendum] = None + address_line_2: Optional[str] = None + has_hot_water_cylinder: Optional[str] = None + has_fixed_air_conditioning: Optional[str] = None + has_heated_separate_conservatory: Optional[str] = None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py new file mode 100644 index 00000000..eee00cb8 --- /dev/null +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -0,0 +1,339 @@ +from dataclasses import dataclass +from typing import List, Optional, Union + +from .common import Measurement + + +@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 Addendum: + addendum_numbers: List[int] + stone_walls: Optional[str] = None + system_build: Optional[str] = None + + +@dataclass +class ShowerOutlet: + shower_wwhrs: int + shower_outlet_type: int + + +@dataclass +class ShowerOutlets: + shower_outlet: ShowerOutlet + + +@dataclass +class InstantaneousWwhrs: + """Changed in 21.0.0: references WWHRS product index numbers instead of room counts.""" + wwhrs_index_number1: Optional[int] = None + wwhrs_index_number2: Optional[int] = None + + +@dataclass +class MainHeatingDetail: + has_fghrs: str + main_fuel_type: int + heat_emitter_type: int + 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 + boiler_flue_type: Optional[int] = None + fan_flue_present: Optional[str] = None + boiler_ignition_type: Optional[int] = None + central_heating_pump_age: Optional[int] = None + main_heating_index_number: Optional[int] = None + sap_main_heating_code: Optional[int] = None + + +@dataclass +class SapHeating: + cylinder_size: int + water_heating_code: int + water_heating_fuel: int + 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 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: str + meter_type: int + pv_connection: int + pv_battery_count: int + photovoltaic_supply: PhotovoltaicSupply + wind_turbines_count: int + wind_turbine_details: WindTurbineDetails + gas_smart_meter_present: str + is_dwelling_export_capable: str + wind_turbines_terrain_type: int + electricity_smart_meter_present: str + pv_batteries: Optional[PvBatteries] = 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 SapFloorDimension: + floor: int + room_height: Measurement + total_floor_area: Measurement + party_wall_length: Union[Measurement, int] + heat_loss_perimeter: Measurement + floor_insulation: Optional[int] = None + floor_construction: Optional[int] = None + + +@dataclass +class SapRoomInRoof: + """Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0.""" + 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: + identifier: str + wall_dry_lined: str + floor_heat_loss: int + roof_construction: int + wall_construction: int + building_part_number: int + sap_floor_dimensions: List[SapFloorDimension] + wall_insulation_type: int + construction_age_band: str + party_wall_construction: Union[int, str] + wall_thickness_measured: str + roof_insulation_location: Union[int, str] + roof_insulation_thickness: Union[str, int] + sap_room_in_roof: Optional[SapRoomInRoof] = None + sap_alternative_wall_1: Optional[SapAlternativeWall] = None + sap_alternative_wall_2: Optional[SapAlternativeWall] = None + wall_thickness: Optional[int] = None + wall_insulation_thickness: Optional[str] = None + floor_insulation_thickness: Optional[str] = None + flat_roof_insulation_thickness: Optional[Union[str, int]] = None + + +@dataclass +class SapFlatDetails: + level: int + top_storey: str + flat_location: int + heat_loss_corridor: int + storey_count: Optional[int] = None + unheated_corridor_length: Optional[int] = None + + +@dataclass +class WindowsTransmissionDetails: + u_value: float + data_source: int + solar_transmittance: float + + +@dataclass +class ImprovementTexts: + improvement_description: str + improvement_summary: Optional[str] = None + + +@dataclass +class ImprovementDetails: + improvement_number: Optional[int] = None + improvement_texts: Optional[ImprovementTexts] = None + + +@dataclass +class SuggestedImprovement: + sequence: int + typical_saving: float + indicative_cost: Union[str, int] + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class RenewableHeatIncentive: + water_heating: int + space_heating_existing_dwelling: int + impact_of_loft_insulation: Optional[int] = None + impact_of_cavity_insulation: Optional[int] = None + impact_of_solid_wall_insulation: Optional[int] = None + + +@dataclass +class RdSapSchema21_0_0: + uprn: int + roofs: List[EnergyElement] + walls: List[EnergyElement] + floors: List[EnergyElement] + status: str + tenure: int + window: EnergyElement + lighting: EnergyElement + postcode: str + hot_water: EnergyElement + post_town: str + built_form: int + door_count: int + region_code: int + report_type: int + sap_heating: SapHeating + sap_version: float + sap_windows: List[SapWindow] + schema_type: str + uprn_source: str + country_code: str + main_heating: List[EnergyElement] + dwelling_type: str + language_code: int + pressure_test: int + property_type: int + address_line_1: str + assessment_type: str + completion_date: str + inspection_date: str + wet_rooms_count: int + extensions_count: int + measurement_type: int + total_floor_area: int + transaction_type: int + conservatory_type: int + heated_room_count: int + registration_date: str + sap_energy_source: SapEnergySource + secondary_heating: EnergyElement + sap_building_parts: List[SapBuildingPart] + open_chimneys_count: int + solar_water_heating: str + habitable_room_count: int + heating_cost_current: float + insulated_door_count: int + co2_emissions_current: float + energy_rating_average: int + energy_rating_current: int + lighting_cost_current: float + main_heating_controls: List[EnergyElement] + has_hot_water_cylinder: str + heating_cost_potential: float + hot_water_cost_current: float + insulated_door_u_value: float + mechanical_ventilation: int + percent_draughtproofed: int + suggested_improvements: List[SuggestedImprovement] + co2_emissions_potential: float + energy_rating_potential: int + lighting_cost_potential: float + schema_version_original: str + hot_water_cost_potential: float + renewable_heat_incentive: RenewableHeatIncentive + draughtproofed_door_count: int + mechanical_vent_duct_type: int + windows_transmission_details: WindowsTransmissionDetails + cfl_fixed_lighting_bulbs_count: int + energy_consumption_current: int + has_fixed_air_conditioning: str + multiple_glazed_proportion: int + calculation_software_version: str + energy_consumption_potential: int + environmental_impact_current: int + led_fixed_lighting_bulbs_count: int + mechanical_vent_duct_placement: int + mechanical_vent_duct_insulation: int + potential_energy_efficiency_band: str + pressure_test_certificate_number: int + mechanical_ventilation_index_number: int + co2_emissions_current_per_floor_area: int + current_energy_efficiency_band: str + environmental_impact_potential: int + low_energy_fixed_lighting_bulbs_count: int + mechanical_vent_duct_insulation_level: int + mechanical_vent_measured_installation: str + incandescent_fixed_lighting_bulbs_count: int + sap_flat_details: Optional[SapFlatDetails] = None + addendum: Optional[Addendum] = None + address_line_2: Optional[str] = None + has_heated_separate_conservatory: Optional[str] = None + fixed_lighting_outlets_count: Optional[int] = None + low_energy_fixed_lighting_outlets_count: Optional[int] = None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py new file mode 100644 index 00000000..046e4fec --- /dev/null +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -0,0 +1,340 @@ +from dataclasses import dataclass +from typing import List, Optional, Union + +from .common import DescriptionV1, Measurement + + +@dataclass +class EnergyElement: + # Descriptions revert to localised objects in schema 21.0.1 (were plain strings in 21.0.0) + description: DescriptionV1 + energy_efficiency_rating: int + environmental_efficiency_rating: int + + +@dataclass +class Addendum: + addendum_numbers: List[int] + stone_walls: Optional[str] = None + system_build: Optional[str] = None + + +@dataclass +class ShowerOutlet: + shower_wwhrs: int + shower_outlet_type: int + + +@dataclass +class ShowerOutlets: + shower_outlet: ShowerOutlet + + +@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 + main_fuel_type: int + heat_emitter_type: int + 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 + boiler_flue_type: Optional[int] = None + fan_flue_present: Optional[str] = None + boiler_ignition_type: Optional[int] = None + central_heating_pump_age: Optional[int] = None + main_heating_index_number: Optional[int] = None + sap_main_heating_code: Optional[int] = None + + +@dataclass +class SapHeating: + cylinder_size: int + water_heating_code: int + water_heating_fuel: int + 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 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: str + meter_type: int + pv_connection: int + pv_battery_count: int + photovoltaic_supply: PhotovoltaicSupply + wind_turbines_count: int + wind_turbine_details: WindTurbineDetails + gas_smart_meter_present: str + is_dwelling_export_capable: str + wind_turbines_terrain_type: int + electricity_smart_meter_present: str + pv_batteries: Optional[PvBatteries] = 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 SapFloorDimension: + floor: int + room_height: Measurement + total_floor_area: Measurement + party_wall_length: Union[Measurement, int] + heat_loss_perimeter: Measurement + 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: + identifier: str + wall_dry_lined: str + floor_heat_loss: int + roof_construction: int + wall_construction: int + building_part_number: int + sap_floor_dimensions: List[SapFloorDimension] + wall_insulation_type: int + construction_age_band: str + party_wall_construction: Union[int, str] + wall_thickness_measured: str + roof_insulation_location: Union[int, str] + roof_insulation_thickness: Union[str, int] + sap_room_in_roof: Optional[SapRoomInRoof] = None + sap_alternative_wall_1: Optional[SapAlternativeWall] = None + sap_alternative_wall_2: Optional[SapAlternativeWall] = None + wall_thickness: Optional[int] = None + wall_insulation_thickness: Optional[str] = None + floor_insulation_thickness: Optional[str] = None + flat_roof_insulation_thickness: Optional[Union[str, int]] = None + + +@dataclass +class SapFlatDetails: + level: int + top_storey: str + flat_location: int + heat_loss_corridor: int + storey_count: Optional[int] = None + # Changed from plain int in 21.0.0 to a Measurement object in 21.0.1 + unheated_corridor_length: Optional[Union[Measurement, int]] = None + + +@dataclass +class WindowsTransmissionDetails: + u_value: float + data_source: int + solar_transmittance: float + + +@dataclass +class ImprovementTexts: + improvement_description: str + improvement_summary: Optional[str] = None + + +@dataclass +class ImprovementDetails: + improvement_number: Optional[int] = None + improvement_texts: Optional[ImprovementTexts] = None + + +@dataclass +class SuggestedImprovement: + sequence: int + typical_saving: float + indicative_cost: Union[str, int] + improvement_type: str + improvement_details: ImprovementDetails + improvement_category: int + energy_performance_rating: int + environmental_impact_rating: int + + +@dataclass +class RenewableHeatIncentive: + water_heating: int + space_heating_existing_dwelling: int + impact_of_loft_insulation: Optional[int] = None + impact_of_cavity_insulation: Optional[int] = None + impact_of_solid_wall_insulation: Optional[int] = None + + +@dataclass +class RdSapSchema21_0_1: + uprn: int + roofs: List[EnergyElement] + walls: List[EnergyElement] + floors: List[EnergyElement] + status: str + tenure: int + window: EnergyElement + lighting: EnergyElement + postcode: str + hot_water: EnergyElement + post_town: str + built_form: int + door_count: int + region_code: int + report_type: int + sap_heating: SapHeating + sap_version: float + sap_windows: List[SapWindow] + schema_type: str + uprn_source: str + country_code: str + main_heating: List[EnergyElement] + # dwelling_type remains a plain string (not reverted to DescriptionV1) in 21.0.1 + dwelling_type: str + language_code: int + pressure_test: int + property_type: int + address_line_1: str + assessment_type: str + completion_date: str + inspection_date: str + wet_rooms_count: int + extensions_count: int + measurement_type: int + total_floor_area: int + transaction_type: int + conservatory_type: int + heated_room_count: int + registration_date: str + sap_energy_source: SapEnergySource + secondary_heating: EnergyElement + sap_building_parts: List[SapBuildingPart] + open_chimneys_count: int + solar_water_heating: str + habitable_room_count: int + heating_cost_current: float + insulated_door_count: int + co2_emissions_current: float + energy_rating_average: int + energy_rating_current: int + lighting_cost_current: float + main_heating_controls: List[EnergyElement] + has_hot_water_cylinder: str + heating_cost_potential: float + hot_water_cost_current: float + insulated_door_u_value: float + mechanical_ventilation: int + percent_draughtproofed: int + suggested_improvements: List[SuggestedImprovement] + co2_emissions_potential: float + energy_rating_potential: int + lighting_cost_potential: float + schema_version_original: str + hot_water_cost_potential: float + renewable_heat_incentive: RenewableHeatIncentive + draughtproofed_door_count: int + mechanical_vent_duct_type: int + windows_transmission_details: WindowsTransmissionDetails + cfl_fixed_lighting_bulbs_count: int + energy_consumption_current: int + has_fixed_air_conditioning: str + multiple_glazed_proportion: int + calculation_software_version: str + energy_consumption_potential: int + environmental_impact_current: int + led_fixed_lighting_bulbs_count: int + mechanical_vent_duct_placement: int + mechanical_vent_duct_insulation: int + potential_energy_efficiency_band: str + pressure_test_certificate_number: int + mechanical_ventilation_index_number: int + co2_emissions_current_per_floor_area: int + current_energy_efficiency_band: str + environmental_impact_potential: int + low_energy_fixed_lighting_bulbs_count: int + mechanical_vent_duct_insulation_level: int + mechanical_vent_measured_installation: str + incandescent_fixed_lighting_bulbs_count: int + sap_flat_details: Optional[SapFlatDetails] = None + addendum: Optional[Addendum] = None + address_line_2: Optional[str] = None + has_heated_separate_conservatory: Optional[str] = None + fixed_lighting_outlets_count: Optional[int] = None + low_energy_fixed_lighting_outlets_count: Optional[int] = None diff --git a/datatypes/epc/schema/tests/__init__.py b/datatypes/epc/schema/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/datatypes/epc/schema/tests/fixtures/17_0.json b/datatypes/epc/schema/tests/fixtures/17_0.json new file mode 100644 index 00000000..b17ca7f3 --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/17_0.json @@ -0,0 +1,218 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": {"value": "(another dwelling above)", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": {"value": "System built, with internal insulation", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": {"value": "(another dwelling below)", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": {"value": "Fully double glazed", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": {"value": "Low energy lighting in 57% of fixed outlets", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "PT5 4RZ", + "hot_water": { + "description": {"value": "Electric immersion, off-peak", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 2 + }, + "post_town": "POSTTOWN", + "built_form": 2, + "door_count": 2, + "glazed_area": 1, + "glazing_gap": "16+", + "region_code": 3, + "report_type": 2, + "sap_heating": { + "cylinder_size": 2, + "water_heating_code": 903, + "water_heating_fuel": 29, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 29, + "heat_emitter_type": 0, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2401, + "main_heating_category": 7, + "main_heating_fraction": 1, + "sap_main_heating_code": 402, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": 1, + "cylinder_insulation_type": 0, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.92, + "schema_type": "RdSAP-Schema-17.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": {"value": "Electric storage heaters", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 1 + } + ], + "dwelling_type": {"value": "Mid-floor flat", "language": "1"}, + "language_code": 1, + "property_type": 2, + "address_line_1": "42, Moria Mines Lane", + "assessment_type": "RdSAP", + "completion_date": "2016-01-12", + "inspection_date": "2016-01-12", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 2, + "top_storey": "N", + "flat_location": 1, + "heat_loss_corridor": 0 + }, + "total_floor_area": 55, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 1, + "pvc_window_frames": "true", + "registration_date": "2016-01-12", + "sap_energy_source": { + "mains_gas": "N", + "meter_type": 1, + "photovoltaic_supply": { + "none_or_no_details": {"pv_connection": 0, "percent_roof_area": 0} + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": {"value": "Portable electric heaters (assumed)", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [11], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 240, + "floor_heat_loss": 6, + "roof_construction": 3, + "wall_construction": 8, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": {"value": 2.4, "quantity": "metres"}, + "total_floor_area": {"value": 54.6, "quantity": "square metres"}, + "party_wall_length": {"value": 7.3, "quantity": "metres"}, + "heat_loss_perimeter": {"value": 23.3, "quantity": "metres"} + } + ], + "wall_insulation_type": 3, + "construction_age_band": "D", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": "ND", + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "50mm" + } + ], + "low_energy_lighting": 57, + "solar_water_heating": "N", + "habitable_room_count": 3, + "heating_cost_current": {"value": 214, "currency": "GBP"}, + "insulated_door_count": 0, + "co2_emissions_current": 3.9, + "energy_rating_average": 60, + "energy_rating_current": 66, + "lighting_cost_current": {"value": 61, "currency": "GBP"}, + "main_heating_controls": [ + { + "description": {"value": "Manual charge control", "language": "1"}, + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": {"value": 216, "currency": "GBP"}, + "hot_water_cost_current": {"value": 396, "currency": "GBP"}, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": {"value": 158, "currency": "GBP"}, + "indicative_cost": "£15 - £30", + "improvement_type": "C", + "improvement_details": {"improvement_number": 1}, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 60 + } + ], + "co2_emissions_potential": 2.5, + "energy_rating_potential": 79, + "lighting_cost_potential": {"value": 42, "currency": "GBP"}, + "schema_version_original": "LIG-17.0", + "alternative_improvements": [ + { + "sequence": 1, + "typical_saving": {"value": 141, "currency": "GBP"}, + "improvement_type": "J2", + "improvement_details": {"improvement_number": 54}, + "improvement_category": 6, + "energy_performance_rating": 81, + "environmental_impact_rating": 96 + } + ], + "hot_water_cost_potential": {"value": 154, "currency": "GBP"}, + "renewable_heat_incentive": { + "water_heating": 4818, + "space_heating_existing_dwelling": 2415 + }, + "energy_consumption_current": 427, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "9.0.0", + "energy_consumption_potential": 267, + "environmental_impact_current": 48, + "fixed_lighting_outlets_count": 7, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 66, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 72, + "low_energy_fixed_lighting_outlets_count": 4 +} diff --git a/datatypes/epc/schema/tests/fixtures/17_1.json b/datatypes/epc/schema/tests/fixtures/17_1.json new file mode 100644 index 00000000..ef0613d1 --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/17_1.json @@ -0,0 +1,243 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": {"value": "Pitched, 100 mm loft insulation", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + { + "description": {"value": "Pitched, insulated (assumed)", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": {"value": "Cavity wall, filled cavity", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": {"value": "Solid, no insulation (assumed)", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": {"value": "Fully double glazed", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": {"value": "Low energy lighting in 23% of fixed outlets", "language": "1"}, + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "postcode": "PT42 5HL", + "hot_water": { + "description": {"value": "From main system", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "post_town": "POSTTOWN", + "built_form": 1, + "door_count": 4, + "glazed_area": 1, + "glazing_gap": "16+", + "region_code": 17, + "report_type": 2, + "sap_heating": { + "cylinder_size": 2, + "water_heating_code": 901, + "water_heating_fuel": 28, + "cylinder_thermostat": "Y", + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 1 + }, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 28, + "boiler_flue_type": 1, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2104, + "main_heating_category": 2, + "main_heating_fraction": 1, + "mcs_installed_heat_pump": "false", + "main_heating_data_source": 1, + "main_heating_index_number": 9049 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 691, + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 12 + }, + "sap_version": 9.92, + "schema_type": "RdSAP-Schema-17.1", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": {"value": "Boiler and radiators, oil", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "dwelling_type": {"value": "Detached house", "language": "1"}, + "language_code": 1, + "property_type": 0, + "address_line_1": "15, Hedge Lane", + "address_line_2": "Lower Moria", + "assessment_type": "RdSAP", + "completion_date": "2018-05-29", + "inspection_date": "2018-05-29", + "extensions_count": 1, + "measurement_type": 2, + "total_floor_area": 101, + "transaction_type": 1, + "conservatory_type": 1, + "heated_room_count": 7, + "pvc_window_frames": "true", + "registration_date": "2018-05-27", + "sap_energy_source": { + "mains_gas": "N", + "meter_type": 2, + "photovoltaic_supply": { + "none_or_no_details": {"pv_connection": 0, "percent_roof_area": 0} + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": {"value": "Room heaters, electric", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [11], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 270, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": {"value": 2.4, "quantity": "metres"}, + "floor_insulation": 1, + "total_floor_area": {"value": 48.45, "quantity": "square metres"}, + "party_wall_length": {"value": 0, "quantity": "metres"}, + "floor_construction": 1, + "heat_loss_perimeter": {"value": 22.3, "quantity": "metres"} + } + ], + "wall_insulation_type": 2, + "construction_age_band": "G", + "party_wall_construction": "NA", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm" + }, + { + "identifier": "Extension 1", + "wall_dry_lined": "N", + "wall_thickness": 260, + "floor_heat_loss": 7, + "roof_construction": 5, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": {"value": 2.4, "quantity": "metres"}, + "floor_insulation": 1, + "total_floor_area": {"value": 21.84, "quantity": "square metres"}, + "party_wall_length": 0, + "floor_construction": 1, + "heat_loss_perimeter": {"value": 16.6, "quantity": "metres"} + } + ], + "wall_insulation_type": 4, + "construction_age_band": "H", + "party_wall_construction": "NA", + "wall_thickness_measured": "Y", + "roof_insulation_location": 4, + "roof_insulation_thickness": 0, + "wall_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 23, + "solar_water_heating": "N", + "habitable_room_count": 7, + "heating_cost_current": {"value": 659, "currency": "GBP"}, + "insulated_door_count": 0, + "co2_emissions_current": 5.8, + "energy_rating_average": 60, + "energy_rating_current": 53, + "lighting_cost_current": {"value": 115, "currency": "GBP"}, + "main_heating_controls": [ + { + "description": {"value": "Programmer and room thermostat", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": {"value": 470, "currency": "GBP"}, + "hot_water_cost_current": {"value": 161, "currency": "GBP"}, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": {"value": 25, "currency": "GBP"}, + "indicative_cost": "£100 - £350", + "improvement_type": "A", + "improvement_details": {"improvement_number": 5}, + "improvement_category": 5, + "energy_performance_rating": 54, + "environmental_impact_rating": 47 + } + ], + "co2_emissions_potential": 2.7, + "energy_rating_potential": 78, + "lighting_cost_potential": {"value": 65, "currency": "GBP"}, + "schema_version_original": "LIG-17.1", + "hot_water_cost_potential": {"value": 77, "currency": "GBP"}, + "renewable_heat_incentive": { + "water_heating": 3301, + "impact_of_loft_insulation": -565, + "space_heating_existing_dwelling": 11351 + }, + "energy_consumption_current": 234, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "v92.0.1.1", + "energy_consumption_potential": 95, + "environmental_impact_current": 46, + "fixed_lighting_outlets_count": 13, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 72, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 57, + "low_energy_fixed_lighting_outlets_count": 3 +} diff --git a/datatypes/epc/schema/tests/fixtures/18_0.json b/datatypes/epc/schema/tests/fixtures/18_0.json new file mode 100644 index 00000000..502ac329 --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/18_0.json @@ -0,0 +1,251 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": {"value": "Pitched, 100 mm loft insulation", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + { + "description": {"value": "Flat, insulated", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + { + "description": {"value": "Roof room(s), insulated (assumed)", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": {"value": "Solid brick, as built, no insulation (assumed)", "language": "1"}, + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + } + ], + "floors": [ + { + "description": {"value": "Solid, no insulation (assumed)", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": {"value": "Fully double glazed", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": {"value": "Low energy lighting in 67% of fixed outlets", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "PT11 4RF", + "hot_water": { + "description": {"value": "From main system", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "POSTTOWN", + "built_form": 4, + "door_count": 2, + "glazed_area": 1, + "glazing_gap": 12, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 16137 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.92, + "schema_type": "RdSAP-Schema-18.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": {"value": "Boiler and radiators, mains gas", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": {"value": "Mid-terrace house", "language": "1"}, + "language_code": 1, + "property_type": 0, + "address_line_1": "1, Bagshot Lane", + "address_line_2": "Village", + "assessment_type": "RdSAP", + "completion_date": "2017-03-19", + "inspection_date": "2017-03-19", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 93, + "transaction_type": 5, + "conservatory_type": 1, + "heated_room_count": 5, + "pvc_window_frames": "true", + "registration_date": "2017-03-19", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 1, + "photovoltaic_supply": { + "none_or_no_details": {"pv_connection": 0, "percent_roof_area": 0} + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": {"value": "None", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [11], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 330, + "floor_heat_loss": 7, + "sap_room_in_roof": { + "floor_area": {"value": 9, "quantity": "square metres"}, + "insulation": "AB", + "roof_room_connected": "N", + "construction_age_band": "G" + }, + "roof_construction": 4, + "wall_construction": 3, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": {"value": 2.4, "quantity": "metres"}, + "floor_insulation": 1, + "total_floor_area": {"value": 29.12, "quantity": "square metres"}, + "party_wall_length": {"value": 11.2, "quantity": "metres"}, + "floor_construction": 1, + "heat_loss_perimeter": {"value": 5.2, "quantity": "metres"} + } + ], + "wall_insulation_type": 4, + "construction_age_band": "C", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm", + "wall_insulation_thickness": "NI" + }, + { + "identifier": "Extension", + "wall_dry_lined": "N", + "wall_thickness": 290, + "floor_heat_loss": 7, + "roof_construction": 1, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": {"value": 2.4, "quantity": "metres"}, + "floor_insulation": 1, + "total_floor_area": {"value": 15.6, "quantity": "square metres"}, + "party_wall_length": {"value": 6, "quantity": "metres"}, + "floor_construction": 1, + "heat_loss_perimeter": {"value": 5.2, "quantity": "metres"} + } + ], + "wall_insulation_type": 4, + "construction_age_band": "G", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 6, + "roof_insulation_thickness": "NI", + "wall_insulation_thickness": "NI", + "flat_roof_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 67, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": {"value": 619, "currency": "GBP"}, + "insulated_door_count": 0, + "co2_emissions_current": 3.4, + "energy_rating_average": 60, + "energy_rating_current": 69, + "lighting_cost_current": {"value": 81, "currency": "GBP"}, + "main_heating_controls": [ + { + "description": {"value": "Programmer, room thermostat and TRVs", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": {"value": 534, "currency": "GBP"}, + "hot_water_cost_current": {"value": 100, "currency": "GBP"}, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": {"value": 88, "currency": "GBP"}, + "indicative_cost": "£4,000 - £14,000", + "improvement_type": "Q", + "improvement_details": {"improvement_number": 7}, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 71 + } + ], + "co2_emissions_potential": 1.7, + "energy_rating_potential": 85, + "lighting_cost_potential": {"value": 61, "currency": "GBP"}, + "schema_version_original": "LIG-17.0", + "hot_water_cost_potential": {"value": 68, "currency": "GBP"}, + "renewable_heat_incentive": { + "water_heating": 2087, + "impact_of_loft_insulation": -214, + "impact_of_solid_wall_insulation": -1864, + "space_heating_existing_dwelling": 10483 + }, + "energy_consumption_current": 230, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "2.0.0.0", + "energy_consumption_potential": 115, + "environmental_impact_current": 66, + "fixed_lighting_outlets_count": 9, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 83, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 41, + "low_energy_fixed_lighting_outlets_count": 6 +} diff --git a/datatypes/epc/schema/tests/fixtures/19_0.json b/datatypes/epc/schema/tests/fixtures/19_0.json new file mode 100644 index 00000000..f9995286 --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/19_0.json @@ -0,0 +1,213 @@ +{ + "uprn": 12457, + "roofs": [ + { + "description": {"value": "Pitched, 150 mm loft insulation", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": {"value": "Cavity wall, filled cavity", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "floors": [ + { + "description": {"value": "Solid, no insulation (assumed)", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 3, + "window": { + "description": {"value": "Fully double glazed", "language": "1"}, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": {"value": "Low energy lighting in 87% of fixed outlets", "language": "1"}, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "A1 1AA", + "hot_water": { + "description": {"value": "From main system", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "Town", + "built_form": 2, + "door_count": 1, + "glazed_area": 1, + "region_code": 19, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "secondary_fuel_type": 9, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 15274 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 631, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.94, + "schema_type": "RdSAP-Schema-19.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": {"value": "Boiler and radiators, mains gas", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": {"value": "Semi-detached house", "language": "1"}, + "language_code": 1, + "property_type": 0, + "address_line_1": "15, Address Lane", + "address_line_2": "New Town", + "assessment_type": "RdSAP", + "completion_date": "2020-06-04", + "inspection_date": "2020-06-03", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 94, + "transaction_type": 8, + "conservatory_type": 2, + "heated_room_count": 5, + "pvc_window_frames": "false", + "registration_date": "2020-06-04", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 3, + "photovoltaic_supply": { + "none_or_no_details": {"pv_connection": 0, "percent_roof_area": 0} + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": {"value": "Room heaters, dual fuel (mineral and wood)", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [11, 9], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": {"value": 2.47, "quantity": "metres"}, + "floor_insulation": 1, + "total_floor_area": {"value": 39.91, "quantity": "square metres"}, + "party_wall_length": {"value": 8.81, "quantity": "metres"}, + "floor_construction": 1, + "heat_loss_perimeter": {"value": 13.65, "quantity": "metres"} + } + ], + "wall_insulation_type": 2, + "construction_age_band": "D", + "party_wall_construction": 1, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "150mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 87, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": {"value": 666, "currency": "GBP"}, + "insulated_door_count": 0, + "co2_emissions_current": 3.8, + "energy_rating_average": 60, + "energy_rating_current": 66, + "lighting_cost_current": {"value": 81, "currency": "GBP"}, + "main_heating_controls": [ + { + "description": {"value": "Programmer, room thermostat and TRVs", "language": "1"}, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": {"value": 615, "currency": "GBP"}, + "hot_water_cost_current": {"value": 107, "currency": "GBP"}, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": {"value": 51, "currency": "GBP"}, + "indicative_cost": "£4,000 - £6,000", + "improvement_type": "W2", + "improvement_details": {"improvement_number": 58}, + "improvement_category": 5, + "energy_performance_rating": 68, + "environmental_impact_rating": 65 + } + ], + "co2_emissions_potential": 2.4, + "energy_rating_potential": 79, + "lighting_cost_potential": {"value": 81, "currency": "GBP"}, + "schema_version_original": "LIG-19.0", + "hot_water_cost_potential": {"value": 74, "currency": "GBP"}, + "renewable_heat_incentive": { + "water_heating": 2207, + "impact_of_loft_insulation": -394, + "space_heating_existing_dwelling": 9825 + }, + "energy_consumption_current": 222, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "2.1.1.0", + "energy_consumption_potential": 137, + "environmental_impact_current": 62, + "fixed_lighting_outlets_count": 15, + "windows_transmission_details": { + "u_value": 3.1, + "data_source": 2, + "solar_transmittance": 0.76 + }, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 75, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 41, + "low_energy_fixed_lighting_outlets_count": 13 +} diff --git a/datatypes/epc/schema/tests/fixtures/20_0_0.json b/datatypes/epc/schema/tests/fixtures/20_0_0.json new file mode 100644 index 00000000..397c2758 --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/20_0_0.json @@ -0,0 +1,225 @@ +{ + "uprn": 12457, + "roofs": [ + {"description": "Pitched, 25 mm loft insulation", "energy_efficiency_rating": 2, "environmental_efficiency_rating": 2}, + {"description": "Pitched, 250 mm loft insulation", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4} + ], + "walls": [ + {"description": "Solid brick, as built, no insulation (assumed)", "energy_efficiency_rating": 1, "environmental_efficiency_rating": 1}, + {"description": "Cavity wall, as built, insulated (assumed)", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4} + ], + "floors": [ + {"description": "Suspended, no insulation (assumed)", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}, + {"description": "Solid, insulated (assumed)", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0} + ], + "status": "entered", + "tenure": 1, + "window": {"description": "Fully double glazed", "energy_efficiency_rating": 3, "environmental_efficiency_rating": 3}, + "addendum": { + "stone_walls": "true", + "system_build": "true", + "addendum_numbers": [1, 8] + }, + "lighting": {"description": "Low energy lighting in 50% of fixed outlets", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, + "postcode": "A0 0AA", + "hot_water": {"description": "From main system", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, + "post_town": "Whitbury", + "built_form": 2, + "door_count": 2, + "glazed_area": 1, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "secondary_fuel_type": 25, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 101, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17507 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.8, + "sap_windows": [ + {"orientation": 1, "window_area": 200.1, "window_type": 2, "glazing_type": 1, "window_location": 0}, + {"orientation": 2, "window_area": 180.2, "window_type": 1, "glazing_type": 2, "window_location": 1} + ], + "schema_type": "RdSAP-Schema-20.0.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + {"description": "Boiler and radiators, anthracite", "energy_efficiency_rating": 3, "environmental_efficiency_rating": 1}, + {"description": "Boiler and radiators, mains gas", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4} + ], + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "1 Some Street", + "assessment_type": "RdSAP", + "completion_date": "2020-05-04", + "inspection_date": "2020-05-04", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "storey_count": 3, + "flat_location": 1, + "heat_loss_corridor": 2, + "unheated_corridor_length": 10 + }, + "total_floor_area": 55, + "transaction_type": 1, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2020-05-04", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "photovoltaic_supply": { + "none_or_no_details": {"pv_connection": 0, "percent_roof_area": 50} + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": {"description": "Room heaters, electric", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}, + "lzc_energy_sources": [11], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "sap_room_in_roof": { + "floor_area": 100, + "insulation": "AB", + "roof_room_connected": "N", + "construction_age_band": "B" + }, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": {"value": 2.45, "quantity": "metres"}, + "floor_insulation": 1, + "total_floor_area": {"value": 45.82, "quantity": "square metres"}, + "party_wall_length": {"value": 7.9, "quantity": "metres"}, + "floor_construction": 1, + "heat_loss_perimeter": {"value": 19.5, "quantity": "metres"} + } + ], + "wall_insulation_type": 2, + "construction_age_band": "K", + "party_wall_construction": 0, + "wall_thickness_measured": "N", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 100, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": 365.98, + "insulated_door_count": 2, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 50, + "lighting_cost_current": 123.45, + "main_heating_controls": [ + {"description": "Programmer, room thermostat and TRVs", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, + {"description": "Time and temperature zone control", "energy_efficiency_rating": 5, "environmental_efficiency_rating": 5} + ], + "multiple_glazing_type": 2, + "open_fireplaces_count": 0, + "heating_cost_potential": 250.34, + "hot_water_cost_current": 200.4, + "insulated_door_u_value": 3, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 360, + "indicative_cost": "£100 - £350", + "improvement_type": "Z3", + "improvement_details": {"improvement_number": 5}, + "improvement_category": 6, + "energy_performance_rating": 50, + "environmental_impact_rating": 50 + }, + { + "sequence": 2, + "typical_saving": 99, + "indicative_cost": 2000, + "improvement_type": "Z2", + "improvement_details": {"improvement_number": 1}, + "improvement_category": 2, + "energy_performance_rating": 60, + "environmental_impact_rating": 64 + }, + { + "sequence": 3, + "typical_saving": 99, + "indicative_cost": 1000, + "improvement_type": "Z2", + "improvement_details": { + "improvement_texts": { + "improvement_summary": "An improvement summary", + "improvement_description": "An improvement desc" + } + }, + "improvement_category": 2, + "energy_performance_rating": 60, + "environmental_impact_rating": 64 + } + ], + "co2_emissions_potential": 1.4, + "energy_rating_potential": 72, + "lighting_cost_potential": 84.23, + "schema_version_original": "SAP-19.0", + "hot_water_cost_potential": 180.43, + "renewable_heat_incentive": { + "water_heating": 2285, + "impact_of_loft_insulation": -2114, + "impact_of_cavity_insulation": -122, + "impact_of_solid_wall_insulation": -3560, + "space_heating_existing_dwelling": 13120 + }, + "energy_consumption_current": 230, + "multiple_glazed_proportion": 100, + "calculation_software_version": "13.05r16", + "energy_consumption_potential": 88, + "environmental_impact_current": 52, + "fixed_lighting_outlets_count": 16, + "windows_transmission_details": {"u_value": 2, "data_source": 2, "solar_transmittance": 0.72}, + "multiple_glazed_proportion_nr": "NR", + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 74, + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 20, + "low_energy_fixed_lighting_outlets_count": 16 +} diff --git a/datatypes/epc/schema/tests/fixtures/21_0_0.json b/datatypes/epc/schema/tests/fixtures/21_0_0.json new file mode 100644 index 00000000..6781ac6e --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/21_0_0.json @@ -0,0 +1,245 @@ +{ + "uprn": 12457, + "roofs": [ + {"description": "Pitched, 25 mm loft insulation", "energy_efficiency_rating": 2, "environmental_efficiency_rating": 2}, + {"description": "Pitched, 250 mm loft insulation", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4} + ], + "walls": [ + {"description": "Solid brick, as built, no insulation (assumed)", "energy_efficiency_rating": 1, "environmental_efficiency_rating": 1}, + {"description": "Cavity wall, as built, insulated (assumed)", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4} + ], + "floors": [ + {"description": "Suspended, no insulation (assumed)", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}, + {"description": "Solid, insulated (assumed)", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0} + ], + "status": "entered", + "tenure": 1, + "window": {"description": "Fully double glazed", "energy_efficiency_rating": 3, "environmental_efficiency_rating": 3}, + "addendum": {"stone_walls": "true", "system_build": "true", "addendum_numbers": [1, 8]}, + "lighting": {"description": "Low energy lighting in 50% of fixed outlets", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, + "postcode": "A0 0AA", + "hot_water": {"description": "From main system", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, + "post_town": "Whitbury", + "built_form": 2, + "door_count": 3, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "shower_outlets": { + "shower_outlet": {"shower_wwhrs": 1, "shower_outlet_type": 1} + }, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": {"wwhrs_index_number1": 1, "wwhrs_index_number2": 2}, + "secondary_fuel_type": 25, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "boiler_ignition_type": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 101, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17507 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "false", + "glazing_gap": 6, + "orientation": 1, + "window_type": 2, + "frame_factor": 1.0, + "glazing_type": 14, + "window_width": 1.2, + "window_height": 2.0, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 6, + "permanent_shutters_present": "N", + "window_transmission_details": {"u_value": 1.0, "data_source": 2, "solar_transmittance": 1.0}, + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.0", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + {"description": "Boiler and radiators, anthracite", "energy_efficiency_rating": 3, "environmental_efficiency_rating": 1}, + {"description": "Boiler and radiators, mains gas", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4} + ], + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "pressure_test": 6, + "property_type": 0, + "address_line_1": "1 Some Street", + "assessment_type": "RdSAP", + "completion_date": "2023-12-01", + "inspection_date": "2023-12-01", + "wet_rooms_count": 0, + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "storey_count": 3, + "flat_location": 1, + "heat_loss_corridor": 2, + "unheated_corridor_length": 10 + }, + "total_floor_area": 55, + "transaction_type": 16, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2023-12-01", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_batteries": {"pv_battery": {"battery_capacity": 5}}, + "pv_connection": 0, + "pv_battery_count": 1, + "photovoltaic_supply": {"none_or_no_details": {"percent_roof_area": 0}}, + "wind_turbines_count": 0, + "wind_turbine_details": {"hub_height": 0, "rotor_diameter": 0}, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 4, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": {"description": "Room heaters, electric", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "sap_room_in_roof": {"floor_area": 100, "construction_age_band": "B"}, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": {"value": 2.45, "quantity": "metres"}, + "floor_insulation": 1, + "total_floor_area": {"value": 45.82, "quantity": "square metres"}, + "party_wall_length": {"value": 7.9, "quantity": "metres"}, + "floor_construction": 1, + "heat_loss_perimeter": {"value": 19.5, "quantity": "metres"} + } + ], + "wall_insulation_type": 2, + "construction_age_band": "M", + "sap_alternative_wall_1": { + "wall_area": 10.4, + "wall_dry_lined": "N", + "wall_construction": 4, + "wall_insulation_type": 2, + "wall_thickness_measured": "N" + }, + "sap_alternative_wall_2": { + "wall_area": 10.8, + "wall_dry_lined": "N", + "wall_construction": 4, + "wall_insulation_type": 2, + "wall_thickness_measured": "N" + }, + "party_wall_construction": 0, + "wall_thickness_measured": "N", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "open_chimneys_count": 1, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": 365.98, + "insulated_door_count": 2, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 50, + "lighting_cost_current": 123.45, + "main_heating_controls": [ + {"description": "Programmer, room thermostat and TRVs", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, + {"description": "Time and temperature zone control", "energy_efficiency_rating": 5, "environmental_efficiency_rating": 5} + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": 250.34, + "hot_water_cost_current": 200.4, + "insulated_door_u_value": 3, + "mechanical_ventilation": 6, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 360, + "indicative_cost": "£100 - £350", + "improvement_type": "Z3", + "improvement_details": {"improvement_number": 5}, + "improvement_category": 6, + "energy_performance_rating": 50, + "environmental_impact_rating": 50 + }, + { + "sequence": 3, + "typical_saving": 99, + "indicative_cost": 1000, + "improvement_type": "Z2", + "improvement_details": { + "improvement_texts": {"improvement_description": "Improvement desc"} + }, + "improvement_category": 2, + "energy_performance_rating": 60, + "environmental_impact_rating": 64 + } + ], + "co2_emissions_potential": 1.4, + "energy_rating_potential": 72, + "lighting_cost_potential": 84.23, + "schema_version_original": "SAP-19.0", + "hot_water_cost_potential": 180.43, + "renewable_heat_incentive": { + "water_heating": 2285, + "impact_of_loft_insulation": -2114, + "impact_of_cavity_insulation": -122, + "impact_of_solid_wall_insulation": -3560, + "space_heating_existing_dwelling": 13120 + }, + "draughtproofed_door_count": 1, + "mechanical_vent_duct_type": 3, + "energy_consumption_current": 230, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "13.05r16", + "energy_consumption_potential": 88, + "environmental_impact_current": 52, + "windows_transmission_details": {"u_value": 2, "data_source": 2, "solar_transmittance": 0.72}, + "cfl_fixed_lighting_bulbs_count": 5, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 74, + "led_fixed_lighting_bulbs_count": 10, + "mechanical_vent_duct_placement": 2, + "mechanical_vent_duct_insulation": 2, + "potential_energy_efficiency_band": "C", + "pressure_test_certificate_number": 0, + "mechanical_ventilation_index_number": 12, + "co2_emissions_current_per_floor_area": 20, + "low_energy_fixed_lighting_bulbs_count": 16, + "mechanical_vent_duct_insulation_level": 2, + "mechanical_vent_measured_installation": "false", + "incandescent_fixed_lighting_bulbs_count": 5 +} diff --git a/datatypes/epc/schema/tests/fixtures/21_0_1.json b/datatypes/epc/schema/tests/fixtures/21_0_1.json new file mode 100644 index 00000000..45361227 --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/21_0_1.json @@ -0,0 +1,222 @@ +{ + "uprn": 12457, + "roofs": [ + {"description": {"value": "Pitched, 25 mm loft insulation", "language": "1"}, "energy_efficiency_rating": 2, "environmental_efficiency_rating": 2}, + {"description": {"value": "Pitched, 250 mm loft insulation", "language": "1"}, "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4} + ], + "walls": [ + {"description": {"value": "Solid brick, as built, no insulation (assumed)", "language": "1"}, "energy_efficiency_rating": 1, "environmental_efficiency_rating": 1}, + {"description": {"value": "Cavity wall, as built, insulated (assumed)", "language": "1"}, "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4} + ], + "floors": [ + {"description": {"value": "Suspended, no insulation (assumed)", "language": "1"}, "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0} + ], + "status": "entered", + "tenure": 1, + "window": {"description": {"value": "Fully double glazed", "language": "1"}, "energy_efficiency_rating": 3, "environmental_efficiency_rating": 3}, + "addendum": {"stone_walls": "true", "system_build": "true", "addendum_numbers": [1, 13]}, + "lighting": {"description": {"value": "Low energy lighting in 50% of fixed outlets", "language": "1"}, "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, + "postcode": "A0 0AA", + "hot_water": {"description": {"value": "From main system", "language": "1"}, "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, + "post_town": "Whitbury", + "built_form": 2, + "door_count": 3, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "shower_outlets": {"shower_outlet": {"shower_wwhrs": 1, "shower_outlet_type": 1}}, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": {"wwhrs_index_number1": 1, "wwhrs_index_number2": 2}, + "secondary_fuel_type": 25, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "boiler_ignition_type": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 101, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17507 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "false", + "glazing_gap": 6, + "orientation": 1, + "window_type": 2, + "frame_factor": 1.0, + "glazing_type": 14, + "window_width": 1.2, + "window_height": 2.0, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 6, + "permanent_shutters_present": "N", + "window_transmission_details": {"u_value": 1.0, "data_source": 2, "solar_transmittance": 1.0}, + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + {"description": {"value": "Boiler and radiators, anthracite", "language": "1"}, "energy_efficiency_rating": 3, "environmental_efficiency_rating": 1}, + {"description": {"value": "Boiler and radiators, mains gas", "language": "1"}, "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4} + ], + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "pressure_test": 6, + "property_type": 0, + "address_line_1": "1 Some Street", + "assessment_type": "RdSAP", + "completion_date": "2025-04-04", + "inspection_date": "2025-04-04", + "wet_rooms_count": 0, + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "storey_count": 3, + "flat_location": 1, + "heat_loss_corridor": 2, + "unheated_corridor_length": {"value": 10, "quantity": "metres"} + }, + "total_floor_area": 55, + "transaction_type": 16, + "conservatory_type": 1, + "heated_room_count": 5, + "registration_date": "2025-04-04", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_batteries": {"pv_battery": {"battery_capacity": 5}}, + "pv_connection": 0, + "pv_battery_count": 1, + "photovoltaic_supply": {"none_or_no_details": {"percent_roof_area": 0}}, + "wind_turbines_count": 0, + "wind_turbine_details": {"hub_height": 0, "rotor_diameter": 0}, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 4, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": {"value": "Room heaters, electric", "language": "1"}, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "sap_room_in_roof": {"floor_area": 100, "construction_age_band": "B"}, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": {"value": 2.45, "quantity": "metres"}, + "floor_insulation": 1, + "total_floor_area": {"value": 45.82, "quantity": "square metres"}, + "party_wall_length": {"value": 7.9, "quantity": "metres"}, + "floor_construction": 1, + "heat_loss_perimeter": {"value": 19.5, "quantity": "metres"} + } + ], + "wall_insulation_type": 2, + "construction_age_band": "M", + "sap_alternative_wall_1": {"wall_area": 10.4, "wall_dry_lined": "N", "wall_construction": 4, "wall_insulation_type": 2, "wall_thickness_measured": "N"}, + "sap_alternative_wall_2": {"wall_area": 10.8, "wall_dry_lined": "N", "wall_construction": 4, "wall_insulation_type": 2, "wall_thickness_measured": "N"}, + "party_wall_construction": 0, + "wall_thickness_measured": "N", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "open_chimneys_count": 1, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": 365.98, + "insulated_door_count": 2, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 50, + "lighting_cost_current": 123.45, + "main_heating_controls": [ + {"description": {"value": "Programmer, room thermostat and TRVs", "language": "1"}, "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, + {"description": {"value": "Time and temperature zone control", "language": "1"}, "energy_efficiency_rating": 5, "environmental_efficiency_rating": 5} + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": 250.34, + "hot_water_cost_current": 200.4, + "insulated_door_u_value": 3, + "mechanical_ventilation": 6, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 139, + "indicative_cost": "£220 - £250", + "improvement_type": "G", + "improvement_details": {"improvement_number": 66}, + "improvement_category": 5, + "energy_performance_rating": 70, + "environmental_impact_rating": 70 + } + ], + "co2_emissions_potential": 1.4, + "energy_rating_potential": 72, + "lighting_cost_potential": 84.23, + "schema_version_original": "SAP-19.0", + "hot_water_cost_potential": 180.43, + "renewable_heat_incentive": { + "water_heating": 2285, + "impact_of_loft_insulation": -2114, + "impact_of_cavity_insulation": -122, + "impact_of_solid_wall_insulation": -3560, + "space_heating_existing_dwelling": 13120 + }, + "draughtproofed_door_count": 1, + "mechanical_vent_duct_type": 3, + "energy_consumption_current": 230, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "13.05r16", + "energy_consumption_potential": 88, + "environmental_impact_current": 52, + "windows_transmission_details": {"u_value": 2, "data_source": 2, "solar_transmittance": 0.72}, + "cfl_fixed_lighting_bulbs_count": 5, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 74, + "led_fixed_lighting_bulbs_count": 10, + "mechanical_vent_duct_placement": 2, + "mechanical_vent_duct_insulation": 2, + "potential_energy_efficiency_band": "C", + "pressure_test_certificate_number": 0, + "mechanical_ventilation_index_number": 12, + "co2_emissions_current_per_floor_area": 20, + "low_energy_fixed_lighting_bulbs_count": 16, + "mechanical_vent_duct_insulation_level": 2, + "mechanical_vent_measured_installation": "false", + "incandescent_fixed_lighting_bulbs_count": 0 +} diff --git a/datatypes/epc/schema/tests/helpers.py b/datatypes/epc/schema/tests/helpers.py new file mode 100644 index 00000000..677bd8b7 --- /dev/null +++ b/datatypes/epc/schema/tests/helpers.py @@ -0,0 +1,73 @@ +import dataclasses +import typing +from typing import Any, Dict, Type, TypeVar + +T = TypeVar("T") + + +def from_dict(cls: Type[T], data: Dict[str, Any]) -> T: + """ + Recursively convert a plain dict (e.g. from json.loads) into the given + dataclass type, using the field type hints to convert nested structures. + + Handles: + - Nested dataclasses + - List[SomeDataclass] + - Optional[X] / Union[X, None] + - Union[DataclassType, primitive] (e.g. Union[Measurement, int]) + - Primitive pass-through for Union[str, int] etc. + """ + return _from_dict_impl(cls, data) # type: ignore[return-value] + + +def _from_dict_impl(cls: Any, data: Any) -> Any: + hints = typing.get_type_hints(cls) + kwargs: Dict[str, Any] = {} + + for field in dataclasses.fields(cls): # type: ignore[arg-type] + has_default = ( + field.default is not dataclasses.MISSING + or field.default_factory is not dataclasses.MISSING # type: ignore[misc] + ) + if field.name not in data: + if has_default: + continue + raise ValueError(f"{cls.__name__}: missing required field '{field.name}'") + + kwargs[field.name] = _coerce(data[field.name], hints[field.name]) + + return cls(**kwargs) + + +def _coerce(value: Any, hint: Any) -> Any: + if value is None: + return None + + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + # Union (includes Optional[X] which is Union[X, None]) + if origin is typing.Union: + if value is None: + return None + non_none_args = [a for a in args if a is not type(None)] + if len(non_none_args) == 1: + # Optional[X] — recurse so List[X] and nested dataclasses are handled + return _coerce(value, non_none_args[0]) + # Multi-type Union (e.g. Union[Measurement, int]): try dataclasses first + for arg in non_none_args: + if dataclasses.is_dataclass(arg) and isinstance(value, dict): + return _from_dict_impl(arg, value) + # All remaining args are primitives — return value as-is + return value + + # List[X] + if origin is list: + item_hint = args[0] + return [_coerce(item, item_hint) for item in value] + + # Plain dataclass + if dataclasses.is_dataclass(hint) and isinstance(value, dict): + return _from_dict_impl(hint, value) + + return value diff --git a/datatypes/epc/schema/tests/test_schema_loading.py b/datatypes/epc/schema/tests/test_schema_loading.py new file mode 100644 index 00000000..dc80ddc0 --- /dev/null +++ b/datatypes/epc/schema/tests/test_schema_loading.py @@ -0,0 +1,380 @@ +import json +import os +from typing import Any, Dict + +import pytest + +from datatypes.epc.schema import ( + RdSapSchema17_0, + RdSapSchema17_1, + RdSapSchema18_0, + RdSapSchema19_0, + RdSapSchema20_0_0, + RdSapSchema21_0_0, + RdSapSchema21_0_1, +) +from datatypes.epc.schema.common import CostAmount, DescriptionV1, Measurement +from datatypes.epc.schema.tests.helpers import from_dict + +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 TestRdSapSchema17_0: + + @pytest.fixture + def epc(self) -> RdSapSchema17_0: + return from_dict(RdSapSchema17_0, load("17_0.json")) + + def test_schema_type(self, epc: RdSapSchema17_0) -> None: + assert epc.schema_type == "RdSAP-Schema-17.0" + + def test_uprn(self, epc: RdSapSchema17_0) -> None: + assert epc.uprn == 12457 + + def test_description_is_localised_object(self, epc: RdSapSchema17_0) -> None: + assert isinstance(epc.roofs[0].description, DescriptionV1) + assert epc.roofs[0].description.value == "(another dwelling above)" + assert epc.roofs[0].description.language == "1" + + def test_dwelling_type_is_localised_object(self, epc: RdSapSchema17_0) -> None: + assert isinstance(epc.dwelling_type, DescriptionV1) + assert epc.dwelling_type.value == "Mid-floor flat" + + def test_costs_are_cost_amount_objects(self, epc: RdSapSchema17_0) -> None: + assert isinstance(epc.heating_cost_current, CostAmount) + assert epc.heating_cost_current.value == 214 + assert epc.heating_cost_current.currency == "GBP" + + def test_suggested_improvement_saving_is_cost_amount(self, epc: RdSapSchema17_0) -> None: + assert isinstance(epc.suggested_improvements[0].typical_saving, CostAmount) + assert epc.suggested_improvements[0].typical_saving.value == 158 + + def test_flat_details_populated(self, epc: RdSapSchema17_0) -> None: + assert epc.sap_flat_details is not None + assert epc.sap_flat_details.level == 2 + + def test_floor_dimensions_use_measurement(self, epc: RdSapSchema17_0) -> None: + dim = epc.sap_building_parts[0].sap_floor_dimensions[0] + assert isinstance(dim.room_height, Measurement) + assert dim.room_height.value == 2.4 + assert dim.room_height.quantity == "metres" + + def test_alternative_improvements_present(self, epc: RdSapSchema17_0) -> None: + assert epc.alternative_improvements is not None + assert len(epc.alternative_improvements) == 1 + assert epc.alternative_improvements[0].improvement_type == "J2" + + def test_renewable_heat_incentive(self, epc: RdSapSchema17_0) -> None: + assert epc.renewable_heat_incentive.water_heating == 4818 + assert epc.renewable_heat_incentive.space_heating_existing_dwelling == 2415 + assert epc.renewable_heat_incentive.impact_of_loft_insulation is None + + def test_glazing_gap_is_string(self, epc: RdSapSchema17_0) -> None: + assert epc.glazing_gap == "16+" + + def test_lzc_energy_sources(self, epc: RdSapSchema17_0) -> None: + assert epc.lzc_energy_sources == [11] + + +class TestRdSapSchema17_1: + + @pytest.fixture + def epc(self) -> RdSapSchema17_1: + return from_dict(RdSapSchema17_1, load("17_1.json")) + + def test_schema_type(self, epc: RdSapSchema17_1) -> None: + assert epc.schema_type == "RdSAP-Schema-17.1" + + def test_description_is_localised_object(self, epc: RdSapSchema17_1) -> None: + assert isinstance(epc.roofs[0].description, DescriptionV1) + assert epc.roofs[0].description.value == "Pitched, 100 mm loft insulation" + + def test_address_line_2(self, epc: RdSapSchema17_1) -> None: + assert epc.address_line_2 == "Lower Moria" + + def test_cylinder_thermostat(self, epc: RdSapSchema17_1) -> None: + assert epc.sap_heating.cylinder_thermostat == "Y" + + def test_cylinder_insulation_thickness(self, epc: RdSapSchema17_1) -> None: + assert epc.sap_heating.cylinder_insulation_thickness == 12 + + def test_boiler_flue_type_in_heating_detail(self, epc: RdSapSchema17_1) -> None: + detail = epc.sap_heating.main_heating_details[0] + assert detail.boiler_flue_type == 1 + assert detail.main_heating_index_number == 9049 + + def test_extension_party_wall_length_is_int(self, epc: RdSapSchema17_1) -> None: + # Extension floor dimension has party_wall_length as a plain int (0) + extension = epc.sap_building_parts[1] + assert extension.identifier == "Extension 1" + dim = extension.sap_floor_dimensions[0] + assert dim.party_wall_length == 0 + + def test_extension_roof_insulation_thickness_is_int(self, epc: RdSapSchema17_1) -> None: + extension = epc.sap_building_parts[1] + assert extension.roof_insulation_thickness == 0 + + def test_renewable_heat_incentive_has_loft_impact(self, epc: RdSapSchema17_1) -> None: + assert epc.renewable_heat_incentive.impact_of_loft_insulation == -565 + + def test_multiple_roofs(self, epc: RdSapSchema17_1) -> None: + assert len(epc.roofs) == 2 + + +class TestRdSapSchema18_0: + + @pytest.fixture + def epc(self) -> RdSapSchema18_0: + return from_dict(RdSapSchema18_0, load("18_0.json")) + + def test_schema_type(self, epc: RdSapSchema18_0) -> None: + assert epc.schema_type == "RdSAP-Schema-18.0" + + def test_glazing_gap_is_integer(self, epc: RdSapSchema18_0) -> None: + assert epc.glazing_gap == 12 + assert isinstance(epc.glazing_gap, int) + + def test_room_in_roof_present(self, epc: RdSapSchema18_0) -> None: + main = epc.sap_building_parts[0] + assert main.sap_room_in_roof is not None + assert main.sap_room_in_roof.insulation == "AB" + assert main.sap_room_in_roof.construction_age_band == "G" + + def test_room_in_roof_floor_area_is_measurement(self, epc: RdSapSchema18_0) -> None: + room_in_roof = epc.sap_building_parts[0].sap_room_in_roof + assert room_in_roof is not None + assert isinstance(room_in_roof.floor_area, Measurement) + assert room_in_roof.floor_area.value == 9.0 + assert room_in_roof.floor_area.quantity == "square metres" + + def test_flat_roof_insulation_on_extension(self, epc: RdSapSchema18_0) -> None: + extension = epc.sap_building_parts[1] + assert extension.flat_roof_insulation_thickness == "NI" + + def test_description_is_localised_object(self, epc: RdSapSchema18_0) -> None: + assert isinstance(epc.walls[0].description, DescriptionV1) + + def test_three_roofs(self, epc: RdSapSchema18_0) -> None: + assert len(epc.roofs) == 3 + + def test_renewable_heat_incentive_has_solid_wall_impact(self, epc: RdSapSchema18_0) -> None: + assert epc.renewable_heat_incentive.impact_of_solid_wall_insulation == -1864 + + +class TestRdSapSchema19_0: + + @pytest.fixture + def epc(self) -> RdSapSchema19_0: + return from_dict(RdSapSchema19_0, load("19_0.json")) + + def test_schema_type(self, epc: RdSapSchema19_0) -> None: + assert epc.schema_type == "RdSAP-Schema-19.0" + + def test_windows_transmission_details(self, epc: RdSapSchema19_0) -> None: + wtd = epc.windows_transmission_details + assert wtd.u_value == 3.1 + assert wtd.data_source == 2 + assert wtd.solar_transmittance == 0.76 + + def test_description_is_localised_object(self, epc: RdSapSchema19_0) -> None: + assert isinstance(epc.roofs[0].description, DescriptionV1) + assert epc.roofs[0].description.value == "Pitched, 150 mm loft insulation" + + def test_glazing_gap_absent(self, epc: RdSapSchema19_0) -> None: + assert epc.glazing_gap is None + + def test_secondary_heating_type(self, epc: RdSapSchema19_0) -> None: + assert epc.sap_heating.secondary_heating_type == 631 + + def test_multiple_lzc_sources(self, epc: RdSapSchema19_0) -> None: + assert epc.lzc_energy_sources == [11, 9] + + def test_floor_insulation_thickness_on_building_part(self, epc: RdSapSchema19_0) -> None: + assert epc.sap_building_parts[0].floor_insulation_thickness == "NI" + + +class TestRdSapSchema20_0_0: + + @pytest.fixture + def epc(self) -> RdSapSchema20_0_0: + return from_dict(RdSapSchema20_0_0, load("20_0_0.json")) + + def test_schema_type(self, epc: RdSapSchema20_0_0) -> None: + assert epc.schema_type == "RdSAP-Schema-20.0.0" + + def test_description_is_plain_string(self, epc: RdSapSchema20_0_0) -> None: + assert isinstance(epc.roofs[0].description, str) + assert epc.roofs[0].description == "Pitched, 25 mm loft insulation" + + def test_dwelling_type_is_plain_string(self, epc: RdSapSchema20_0_0) -> None: + assert isinstance(epc.dwelling_type, str) + assert epc.dwelling_type == "Mid-terrace house" + + def test_costs_are_floats(self, epc: RdSapSchema20_0_0) -> None: + assert isinstance(epc.heating_cost_current, float) + assert epc.heating_cost_current == pytest.approx(365.98) + + def test_suggested_improvement_saving_is_numeric(self, epc: RdSapSchema20_0_0) -> None: + assert epc.suggested_improvements[0].typical_saving == 360 + + def test_indicative_cost_can_be_int(self, epc: RdSapSchema20_0_0) -> None: + # Second improvement has indicative_cost as a plain integer + assert epc.suggested_improvements[1].indicative_cost == 2000 + + def test_improvement_details_with_texts(self, epc: RdSapSchema20_0_0) -> None: + # Third improvement uses improvement_texts instead of improvement_number + third = epc.suggested_improvements[2] + assert third.improvement_details.improvement_number is None + assert third.improvement_details.improvement_texts is not None + assert third.improvement_details.improvement_texts.improvement_summary == "An improvement summary" + + def test_sap_windows_present(self, epc: RdSapSchema20_0_0) -> None: + assert len(epc.sap_windows) == 2 + assert epc.sap_windows[0].window_area == pytest.approx(200.1) + assert epc.sap_windows[0].glazing_type == 1 + + def test_addendum(self, epc: RdSapSchema20_0_0) -> None: + assert epc.addendum is not None + assert epc.addendum.stone_walls == "true" + assert epc.addendum.addendum_numbers == [1, 8] + + def test_flat_details_has_storey_count(self, epc: RdSapSchema20_0_0) -> None: + assert epc.sap_flat_details is not None + assert epc.sap_flat_details.storey_count == 3 + assert epc.sap_flat_details.unheated_corridor_length == 10 + + def test_room_in_roof_floor_area_is_plain_number(self, epc: RdSapSchema20_0_0) -> None: + room_in_roof = epc.sap_building_parts[0].sap_room_in_roof + assert room_in_roof is not None + assert room_in_roof.floor_area == 100 + assert not isinstance(room_in_roof.floor_area, Measurement) + + def test_renewable_heat_incentive_has_cavity_impact(self, epc: RdSapSchema20_0_0) -> None: + assert epc.renewable_heat_incentive.impact_of_cavity_insulation == -122 + + def test_multiple_glazed_proportion_nr(self, epc: RdSapSchema20_0_0) -> None: + assert epc.multiple_glazed_proportion_nr == "NR" + + +class TestRdSapSchema21_0_0: + + @pytest.fixture + def epc(self) -> RdSapSchema21_0_0: + return from_dict(RdSapSchema21_0_0, load("21_0_0.json")) + + def test_schema_type(self, epc: RdSapSchema21_0_0) -> None: + assert epc.schema_type == "RdSAP-Schema-21.0.0" + + def test_description_is_plain_string(self, epc: RdSapSchema21_0_0) -> None: + assert isinstance(epc.roofs[0].description, str) + + def test_pressure_test(self, epc: RdSapSchema21_0_0) -> None: + assert epc.pressure_test == 6 + + def test_wet_rooms_count(self, epc: RdSapSchema21_0_0) -> None: + assert epc.wet_rooms_count == 0 + + def test_instantaneous_wwhrs_uses_index_numbers(self, epc: RdSapSchema21_0_0) -> None: + wwhrs = epc.sap_heating.instantaneous_wwhrs + assert wwhrs.wwhrs_index_number1 == 1 + assert wwhrs.wwhrs_index_number2 == 2 + + def test_shower_outlets(self, epc: RdSapSchema21_0_0) -> None: + outlets = epc.sap_heating.shower_outlets + assert outlets is not None + assert outlets.shower_outlet.shower_wwhrs == 1 + assert outlets.shower_outlet.shower_outlet_type == 1 + + def test_boiler_ignition_type(self, epc: RdSapSchema21_0_0) -> None: + assert epc.sap_heating.main_heating_details[0].boiler_ignition_type == 1 + + def test_smart_meters(self, epc: RdSapSchema21_0_0) -> None: + src = epc.sap_energy_source + assert src.electricity_smart_meter_present == "true" + assert src.gas_smart_meter_present == "false" + + def test_pv_batteries(self, epc: RdSapSchema21_0_0) -> None: + assert epc.sap_energy_source.pv_batteries is not None + assert epc.sap_energy_source.pv_batteries.pv_battery.battery_capacity == 5 + + def test_alternative_walls(self, epc: RdSapSchema21_0_0) -> None: + part = epc.sap_building_parts[0] + assert part.sap_alternative_wall_1 is not None + assert part.sap_alternative_wall_1.wall_area == pytest.approx(10.4) + assert part.sap_alternative_wall_2 is not None + assert part.sap_alternative_wall_2.wall_area == pytest.approx(10.8) + + def test_detailed_sap_windows(self, epc: RdSapSchema21_0_0) -> None: + win = epc.sap_windows[0] + assert win.glazing_gap == 6 + assert win.window_width == pytest.approx(1.2) + assert win.window_transmission_details.u_value == pytest.approx(1.0) + + def test_mechanical_vent_fields(self, epc: RdSapSchema21_0_0) -> None: + assert epc.mechanical_vent_duct_type == 3 + assert epc.mechanical_ventilation_index_number == 12 + + def test_lighting_bulb_counts(self, epc: RdSapSchema21_0_0) -> None: + assert epc.led_fixed_lighting_bulbs_count == 10 + assert epc.cfl_fixed_lighting_bulbs_count == 5 + assert epc.incandescent_fixed_lighting_bulbs_count == 5 + + def test_open_chimneys_count(self, epc: RdSapSchema21_0_0) -> None: + assert epc.open_chimneys_count == 1 + + def test_room_in_roof_has_no_insulation_field(self, epc: RdSapSchema21_0_0) -> None: + room_in_roof = epc.sap_building_parts[0].sap_room_in_roof + assert room_in_roof is not None + assert room_in_roof.construction_age_band == "B" + assert not hasattr(room_in_roof, "insulation") + + +class TestRdSapSchema21_0_1: + + @pytest.fixture + def epc(self) -> RdSapSchema21_0_1: + return from_dict(RdSapSchema21_0_1, load("21_0_1.json")) + + def test_schema_type(self, epc: RdSapSchema21_0_1) -> None: + assert epc.schema_type == "RdSAP-Schema-21.0.1" + + def test_description_reverts_to_localised_object(self, epc: RdSapSchema21_0_1) -> None: + # Descriptions on energy elements revert to DescriptionV1 in 21.0.1 + assert isinstance(epc.roofs[0].description, DescriptionV1) + assert epc.roofs[0].description.value == "Pitched, 25 mm loft insulation" + + def test_main_heating_description_is_localised_object(self, epc: RdSapSchema21_0_1) -> None: + assert isinstance(epc.main_heating[0].description, DescriptionV1) + assert epc.main_heating[0].description.value == "Boiler and radiators, anthracite" + + def test_dwelling_type_remains_plain_string(self, epc: RdSapSchema21_0_1) -> None: + # dwelling_type does NOT revert — stays as plain str in 21.0.1 + assert isinstance(epc.dwelling_type, str) + assert epc.dwelling_type == "Mid-terrace house" + + def test_unheated_corridor_length_is_measurement(self, epc: RdSapSchema21_0_1) -> None: + # Changed from plain int (21.0.0) to Measurement object in 21.0.1 + assert epc.sap_flat_details is not None + corridor_length = epc.sap_flat_details.unheated_corridor_length + assert isinstance(corridor_length, Measurement) + assert corridor_length.value == 10 + assert corridor_length.quantity == "metres" + + def test_completion_date(self, epc: RdSapSchema21_0_1) -> None: + assert epc.completion_date == "2025-04-04" + + def test_addendum_numbers(self, epc: RdSapSchema21_0_1) -> None: + assert epc.addendum is not None + assert epc.addendum.addendum_numbers == [1, 13] + + def test_pv_battery_capacity(self, epc: RdSapSchema21_0_1) -> None: + assert epc.sap_energy_source.pv_batteries is not None + assert epc.sap_energy_source.pv_batteries.pv_battery.battery_capacity == 5 + + def test_incandescent_bulb_count(self, epc: RdSapSchema21_0_1) -> None: + assert epc.incandescent_fixed_lighting_bulbs_count == 0 diff --git a/pytest.ini b/pytest.ini index 792b27e0..4a5327c1 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 +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 markers = integration: mark a test as an integration test From 9378c417e1240abd8d51d3a3a3d1961728175bb9 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 10 Apr 2026 13:01:37 +0000 Subject: [PATCH 87/93] fix typo in terraform state key --- infrastructure/terraform/lambda/hubspot_deal_etl/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf index 516ec282..e8762337 100644 --- a/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf +++ b/infrastructure/terraform/lambda/hubspot_deal_etl/main.tf @@ -11,7 +11,7 @@ data "terraform_remote_state" "pashub_to_ara" { backend = "s3" config = { bucket = "pashub-to-ara-terraform-state" - key = "ev:/${var.stage}/terraform.tfstate" + key = "env:/${var.stage}/terraform.tfstate" region = "eu-west-2" } } From 67df3a810a634f70a85dc501e64d523fc5a62079 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Apr 2026 15:15:39 +0100 Subject: [PATCH 88/93] switching off rebaselining temp --- backend/engine/engine.py | 3 ++- etl/epc/Record.py | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index e24a3b95..f7a374e0 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -652,7 +652,8 @@ async def model_engine(body: PlanTriggerRequest): epc_records["original_epc"]["estimated"] = False prepared_epc = EPCRecord( - epc_records=epc_records, run_mode="newdata", cleaning_data=cleaning_data, address_metadata=addr + epc_records=epc_records, run_mode="newdata", cleaning_data=cleaning_data, + # address_metadata=addr Switched off to remove injecting landlord inputs ) input_properties.append( diff --git a/etl/epc/Record.py b/etl/epc/Record.py index defe13f4..8dbb5ba5 100644 --- a/etl/epc/Record.py +++ b/etl/epc/Record.py @@ -580,7 +580,22 @@ class EPCRecord: if existing is not None and v is not None and abs(existing - v) > 1: # 1m tolerance self.landlord_differences[k] = v else: - if v != self._prepared_epc.get(k) and (not pd.isnull(v)) and (not pd.isnull(self._prepared_epc.get(k))): + + # Check if something has been cleaned. We want to avoid triggering re-baselining if we cleaned + # a value. In the address meta, it will possibly contain the original value, so we'd pick up a + # diference if the original value was something to be cleaned, we clean that value and then end up + # comparing the original value to the new clean one + cleaned_value = self._prepared_epc.get(k) + original_value = self.original_epc.get(k.replace("_", "-")) + + # We check if the value has been cleaned + if cleaned_value != original_value: + # The thing we want to compare against, is the original value + compare_to = original_value + else: + compare_to = cleaned_value + + if v != compare_to and (not pd.isnull(v)) and (not pd.isnull(self._prepared_epc.get(k))): self.landlord_differences[k] = v self._prepared_epc.update(self.landlord_differences) From 58a5c9f5d0a88f924405ef8801db61f71b16d0f8 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 10 Apr 2026 16:20:00 +0000 Subject: [PATCH 89/93] ensure all events in task handler are processed, plus error handling --- backend/utils/subtasks.py | 83 +++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/backend/utils/subtasks.py b/backend/utils/subtasks.py index e5668c53..6be3a742 100644 --- a/backend/utils/subtasks.py +++ b/backend/utils/subtasks.py @@ -113,12 +113,17 @@ def task_handler(): @wraps(func) def wrapper(event: dict[str, Any], context: Any, *args, **kwargs): - logger = setup_logger() - # Parse body: Records-style SQS or plain dict event - if "Records" in event: - raw_body = event["Records"][0].get("body", {}) + records = event.get("Records", [event]) # fallback for non-SQS + + results = [] + failures = [] + + for record in records: + # Parse body + raw_body = record.get("body", record) + if isinstance(raw_body, str): try: body = json.loads(raw_body) @@ -126,43 +131,55 @@ def task_handler(): body = {} else: body = raw_body or {} - else: - body = event - # Create fresh task + subtask - logger.info("Creating task for source: %s", task_source) - task_id, subtask_id = TasksInterface.create_task( - task_source=task_source, - inputs=body, - ) - logger.info("Created task_id=%s subtask_id=%s", task_id, subtask_id) + # Create task per message + logger.info("Creating task for source: %s", task_source) + task_id, subtask_id = TasksInterface.create_task( + task_source=task_source, + inputs=body, + ) - interface = SubTaskInterface() + logger.info("Created task_id=%s subtask_id=%s", task_id, subtask_id) - interface.update_subtask_status( - subtask_id=subtask_id, - status="in progress", - ) - - try: - result = func(body, context, *args, **kwargs) + interface = SubTaskInterface() interface.update_subtask_status( subtask_id=subtask_id, - status="complete", - outputs={"result": result} if result else None, + status="in progress", ) - logger.info("Task %s completed successfully", task_id) - return result - except Exception as e: - logger.exception("Task %s failed: %s", task_id, e) - interface.update_subtask_status( - subtask_id=subtask_id, - status="failed", - outputs={"error": str(e)}, - ) - raise + try: + result = func(body, context, *args, **kwargs) + + interface.update_subtask_status( + subtask_id=subtask_id, + status="complete", + outputs={"result": result} if result else None, + ) + + logger.info("Task %s completed successfully", task_id) + results.append(result) + + except Exception as e: + logger.exception("Task %s failed: %s", task_id, e) + + interface.update_subtask_status( + subtask_id=subtask_id, + status="failed", + outputs={"error": str(e)}, + ) + + if "Records" in event: + failures.append({"itemIdentifier": record["messageId"]}) + else: + # Handle non-SQS events + raise + + if "Records" in event: + return {"batchItemFailures": failures} + + # Handle non-SQS events + return results return wrapper From 17b8f22840bbf431f440c6795fa5bab6a346db2c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 13 Apr 2026 14:59:00 +0000 Subject: [PATCH 90/93] fix BOM issue --- asset_list/app.py | 18 +++--- backend/address2UPRN/main.py | 9 ++- backend/export/property_scenarios/main.py | 70 ++++++++++++----------- 3 files changed, 52 insertions(+), 45 deletions(-) diff --git a/asset_list/app.py b/asset_list/app.py index 5794eaf3..b0030667 100644 --- a/asset_list/app.py +++ b/asset_list/app.py @@ -74,23 +74,23 @@ def app(): """ data_folder = "/workspaces/model/asset_list" - data_filename = "Calico ARA Upload Review.xlsx" - sheet_name = "Sheet1" - postcode_column = "Postcode" - address1_column = "Units" + data_filename = "Waverley UPRN Match.xlsx" + sheet_name = "in" + postcode_column = "postcode_clean" + address1_column = "domna_found_address" address1_method = None - fulladdress_column = "Units" - address_cols_to_concat = ["Units"] + fulladdress_column = "domna_found_address" + address_cols_to_concat = [] missing_postcodes_method = None landlord_year_built = None - landlord_os_uprn = None - landlord_property_type = None # Good to include if landlord gave + landlord_os_uprn = "domna_found_uprn" + landlord_property_type = "Property Type 1" # Good to include if landlord gave landlord_built_form = None # Good to include if landlord gave landlord_wall_construction = None landlord_roof_construction = None landlord_heating_system = None landlord_existing_pv = None - landlord_property_id = "llid" + landlord_property_id = "WBC Ref" landlord_sap = None outcomes_filename = None outcomes_sheetname = None diff --git a/backend/address2UPRN/main.py b/backend/address2UPRN/main.py index 97e2037a..647d46be 100644 --- a/backend/address2UPRN/main.py +++ b/backend/address2UPRN/main.py @@ -351,9 +351,9 @@ def handler(event, context, local=False): { "body": json.dumps( { - "task_id": "e31f2f21-175b-4a91-a3ec-a6baa325e917", - "sub_task_id": "6a427b6e-1ece-4983-b1e5-9bffccc53d1d", - "s3_uri": "s3://retrofit-data-dev/ara_postcode_splitter_batches/e31f2f21-175b-4a91-a3ec-a6baa325e917/8673913b-1a88-42d7-8578-0449123d94b0/2026-02-18T11:47:00.822579_f95467f5.csv", + "sub_task_id": "d7363c83-2ef7-4474-b30f-980fd587350c", + "task_id": "a042af13-8b57-4709-ad22-ecac1ccca4bd", + "s3_uri": "s3://retrofit-data-dev/ara_raw_inputs/essex/Copy of EPC register Essex(August 2025)(in) (2).csv", } ) } @@ -424,6 +424,9 @@ def handler(event, context, local=False): bucket, key = parse_s3_uri(s3_uri) csv_data = read_csv_from_s3_dict(bucket, key) df = pd.DataFrame(csv_data) + df.columns = [ + c.lstrip("\ufeff") for c in df.columns + ] # strip BOM from column names logger.info(f"Loaded {len(df)} rows from S3") except Exception as s3_error: logger.error(f"Failed to read data from S3: {s3_error}") diff --git a/backend/export/property_scenarios/main.py b/backend/export/property_scenarios/main.py index f3ea0100..b179531d 100644 --- a/backend/export/property_scenarios/main.py +++ b/backend/export/property_scenarios/main.py @@ -26,15 +26,14 @@ def has_solar_with_battery(materials_list: Optional[List[Dict[str, Any]]]) -> bo :return: """ for m in materials_list or []: - if ( - m.get("type") == "solar_pv" - and m.get("includes_battery") is True - ): + if m.get("type") == "solar_pv" and m.get("includes_battery") is True: return True return False -def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, int], pd.DataFrame]: +def process_export( + payload: ExportRequest, session: Session +) -> Dict[Union[str, int], pd.DataFrame]: export_files: Dict[Union[str, int], pd.DataFrame] = {} db_methods = DbMethods(session) @@ -52,7 +51,9 @@ def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, logger.info("Retrieved %s plans for export", len(plans_df)) if plans_df.empty: - logger.info("Empty plans dataframe - no plans to export. Returning empty export.") + logger.info( + "Empty plans dataframe - no plans to export. Returning empty export." + ) return export_files plan_ids: List[int] = plans_df["id"].tolist() recommendations_df: pd.DataFrame = db_methods.get_recommendations(plan_ids) @@ -61,13 +62,12 @@ def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, recommendations_df = db_methods.attach_materials(recommendations_df) - recommendations_df["has_solar_with_battery"] = ( - recommendations_df["materials"].apply(has_solar_with_battery) - ) + recommendations_df["has_solar_with_battery"] = recommendations_df[ + "materials" + ].apply(has_solar_with_battery) - _filter = ( - (recommendations_df["measure_type"] == "solar_pv") - & (recommendations_df["has_solar_with_battery"]) + _filter = (recommendations_df["measure_type"] == "solar_pv") & ( + recommendations_df["has_solar_with_battery"] ) recommendations_df.loc[_filter, "measure_type"] = ( @@ -83,10 +83,13 @@ def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, else: scenario_recs = recommendations_df[ recommendations_df["scenario_id"] == group_key - ] + ] if scenario_recs.empty: - logger.info("No recommendations found for group_key %s - skipping export for this group", group_key) + logger.info( + "No recommendations found for group_key %s - skipping export for this group", + group_key, + ) continue measures_df: pd.DataFrame = scenario_recs[ @@ -99,14 +102,12 @@ def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, values="estimated_cost", ).reset_index() - pivot["total_retrofit_cost"] = ( - pivot.drop(columns=["property_id", "plan_name"]).sum(axis=1) - ) + pivot["total_retrofit_cost"] = pivot.drop( + columns=["property_id", "plan_name"] + ).sum(axis=1) post_sap: pd.DataFrame = ( - scenario_recs.groupby("property_id")[["sap_points"]] - .sum() - .reset_index() + scenario_recs.groupby("property_id")[["sap_points"]].sum().reset_index() ) df: pd.DataFrame = ( @@ -117,7 +118,9 @@ def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, df["sap_points"] = df["sap_points"].fillna(0) df["predicted_post_works_sap"] = df["current_sap_points"] + df["sap_points"] - df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply(sap_to_epc) + df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply( + sap_to_epc + ) export_files[group_key] = df @@ -128,22 +131,17 @@ def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, # Lambda Handler # ============================================================ -def handler(event: Mapping[str, Any], context: Optional[Any]) -> Mapping[str, Union[int, str]]: + +def handler( + event: Mapping[str, Any], context: Optional[Any] +) -> Mapping[str, Union[int, str]]: """ Example event: body_dict = { "task_id": "test", "subtask_id": "test", - "portfolio_id": 655, - "scenario_ids": [], - "default_plans_only": True, - } - - body_dict = { - "task_id": "test", - "subtask_id": "test", - "portfolio_id": 655, - "scenario_ids": [1174], + "portfolio_id": 670, + "scenario_ids": [1199], "default_plans_only": False, } :param event: Lambda event containing export request details @@ -167,8 +165,14 @@ def handler(event: Mapping[str, Any], context: Optional[Any]) -> Mapping[str, Un with db_read_session() as session: exported_files = process_export(payload, session) + output_path = f"/tmp/export_{payload.portfolio_id}.xlsx" + with pd.ExcelWriter(output_path, engine="openpyxl") as writer: + for group_key, df in exported_files.items(): + sheet_name = str(group_key)[:31] # Excel sheet name limit + df.to_excel(writer, sheet_name=sheet_name, index=False) + + logger.info("Exported to %s", output_path) # TODO: Need to handle the exported files - e.g. upload to s3 and email a presigned url - _ = exported_files return { "statusCode": 200, "body": json.dumps({}), From 98c9a1df74f9330ef88542b76918cbc71915f310 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 13 Apr 2026 15:15:42 +0000 Subject: [PATCH 91/93] added fix for utf --- backend/address2UPRN/main.py | 3 --- utils/s3.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/backend/address2UPRN/main.py b/backend/address2UPRN/main.py index 647d46be..79c0de69 100644 --- a/backend/address2UPRN/main.py +++ b/backend/address2UPRN/main.py @@ -424,9 +424,6 @@ def handler(event, context, local=False): bucket, key = parse_s3_uri(s3_uri) csv_data = read_csv_from_s3_dict(bucket, key) df = pd.DataFrame(csv_data) - df.columns = [ - c.lstrip("\ufeff") for c in df.columns - ] # strip BOM from column names logger.info(f"Loaded {len(df)} rows from S3") except Exception as s3_error: logger.error(f"Failed to read data from S3: {s3_error}") diff --git a/utils/s3.py b/utils/s3.py index 242e0db5..930e2e15 100644 --- a/utils/s3.py +++ b/utils/s3.py @@ -330,7 +330,7 @@ def read_csv_from_s3(bucket_name: str, filepath: str) -> list[dict[str, str]]: body = s3_object["Body"].read() # Use StringIO to create a file-like object from the string - csv_data = StringIO(body.decode("utf-8")) + csv_data = StringIO(body.decode("utf-8-sig")) # Use csv library to read it into a list of dictionaries reader = csv.DictReader(csv_data) From ef366f1cd5eade72cd479767699c7bb9cca96df7 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 13 Apr 2026 15:20:10 +0000 Subject: [PATCH 92/93] be the same as main --- backend/export/property_scenarios/main.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/backend/export/property_scenarios/main.py b/backend/export/property_scenarios/main.py index b179531d..0ab59e27 100644 --- a/backend/export/property_scenarios/main.py +++ b/backend/export/property_scenarios/main.py @@ -140,8 +140,16 @@ def handler( body_dict = { "task_id": "test", "subtask_id": "test", - "portfolio_id": 670, - "scenario_ids": [1199], + "portfolio_id": 655, + "scenario_ids": [], + "default_plans_only": True, + } + + body_dict = { + "task_id": "test", + "subtask_id": "test", + "portfolio_id": 655, + "scenario_ids": [1174], "default_plans_only": False, } :param event: Lambda event containing export request details @@ -165,14 +173,8 @@ def handler( with db_read_session() as session: exported_files = process_export(payload, session) - output_path = f"/tmp/export_{payload.portfolio_id}.xlsx" - with pd.ExcelWriter(output_path, engine="openpyxl") as writer: - for group_key, df in exported_files.items(): - sheet_name = str(group_key)[:31] # Excel sheet name limit - df.to_excel(writer, sheet_name=sheet_name, index=False) - - logger.info("Exported to %s", output_path) # TODO: Need to handle the exported files - e.g. upload to s3 and email a presigned url + _ = exported_files return { "statusCode": 200, "body": json.dumps({}), From 9c1181475ea5b4ba41d3b2c2e1f87ca73b3f003b Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 13 Apr 2026 15:20:55 +0000 Subject: [PATCH 93/93] be the same as main --- backend/export/property_scenarios/main.py | 50 ++++++++++------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/backend/export/property_scenarios/main.py b/backend/export/property_scenarios/main.py index 0ab59e27..f3ea0100 100644 --- a/backend/export/property_scenarios/main.py +++ b/backend/export/property_scenarios/main.py @@ -26,14 +26,15 @@ def has_solar_with_battery(materials_list: Optional[List[Dict[str, Any]]]) -> bo :return: """ for m in materials_list or []: - if m.get("type") == "solar_pv" and m.get("includes_battery") is True: + if ( + m.get("type") == "solar_pv" + and m.get("includes_battery") is True + ): return True return False -def process_export( - payload: ExportRequest, session: Session -) -> Dict[Union[str, int], pd.DataFrame]: +def process_export(payload: ExportRequest, session: Session) -> Dict[Union[str, int], pd.DataFrame]: export_files: Dict[Union[str, int], pd.DataFrame] = {} db_methods = DbMethods(session) @@ -51,9 +52,7 @@ def process_export( logger.info("Retrieved %s plans for export", len(plans_df)) if plans_df.empty: - logger.info( - "Empty plans dataframe - no plans to export. Returning empty export." - ) + logger.info("Empty plans dataframe - no plans to export. Returning empty export.") return export_files plan_ids: List[int] = plans_df["id"].tolist() recommendations_df: pd.DataFrame = db_methods.get_recommendations(plan_ids) @@ -62,12 +61,13 @@ def process_export( recommendations_df = db_methods.attach_materials(recommendations_df) - recommendations_df["has_solar_with_battery"] = recommendations_df[ - "materials" - ].apply(has_solar_with_battery) + recommendations_df["has_solar_with_battery"] = ( + recommendations_df["materials"].apply(has_solar_with_battery) + ) - _filter = (recommendations_df["measure_type"] == "solar_pv") & ( - recommendations_df["has_solar_with_battery"] + _filter = ( + (recommendations_df["measure_type"] == "solar_pv") + & (recommendations_df["has_solar_with_battery"]) ) recommendations_df.loc[_filter, "measure_type"] = ( @@ -83,13 +83,10 @@ def process_export( else: scenario_recs = recommendations_df[ recommendations_df["scenario_id"] == group_key - ] + ] if scenario_recs.empty: - logger.info( - "No recommendations found for group_key %s - skipping export for this group", - group_key, - ) + logger.info("No recommendations found for group_key %s - skipping export for this group", group_key) continue measures_df: pd.DataFrame = scenario_recs[ @@ -102,12 +99,14 @@ def process_export( values="estimated_cost", ).reset_index() - pivot["total_retrofit_cost"] = pivot.drop( - columns=["property_id", "plan_name"] - ).sum(axis=1) + pivot["total_retrofit_cost"] = ( + pivot.drop(columns=["property_id", "plan_name"]).sum(axis=1) + ) post_sap: pd.DataFrame = ( - scenario_recs.groupby("property_id")[["sap_points"]].sum().reset_index() + scenario_recs.groupby("property_id")[["sap_points"]] + .sum() + .reset_index() ) df: pd.DataFrame = ( @@ -118,9 +117,7 @@ def process_export( df["sap_points"] = df["sap_points"].fillna(0) df["predicted_post_works_sap"] = df["current_sap_points"] + df["sap_points"] - df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply( - sap_to_epc - ) + df["predicted_post_works_epc"] = df["predicted_post_works_sap"].apply(sap_to_epc) export_files[group_key] = df @@ -131,10 +128,7 @@ def process_export( # Lambda Handler # ============================================================ - -def handler( - event: Mapping[str, Any], context: Optional[Any] -) -> Mapping[str, Union[int, str]]: +def handler(event: Mapping[str, Any], context: Optional[Any]) -> Mapping[str, Union[int, str]]: """ Example event: body_dict = {