mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge remote-tracking branch 'origin/main' into feature/per-cert-mapper-validation
# Conflicts: # datatypes/epc/domain/epc_property_data.py # datatypes/epc/domain/mapper.py # datatypes/epc/domain/tests/test_from_rdsap_schema.py
This commit is contained in:
commit
5b2cf5edc7
429 changed files with 33890 additions and 413077 deletions
|
|
@ -9,7 +9,7 @@
|
|||
// Optional, just makes getting from Downloads (local env) easier
|
||||
"source=${localEnv:HOME},target=/home/vscode,type=bind"
|
||||
],
|
||||
"forwardPorts": [8081],
|
||||
"forwardPorts": ["model-sal:8080"],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ services:
|
|||
networks:
|
||||
- model-net
|
||||
ports:
|
||||
- "8081:8080"
|
||||
# Host port left unspecified so Docker assigns a free one — lets multiple
|
||||
# worktrees of this repo run at once without colliding. VS Code's
|
||||
# forwardPorts (in devcontainer.json) forwards container :8080 to your machine.
|
||||
- "8080"
|
||||
|
||||
networks:
|
||||
model-net:
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@
|
|||
"containerEnv": {
|
||||
"PYTHONFLAGS": "-Xfrozen_modules=off"
|
||||
},
|
||||
"forwardPorts": [8000],
|
||||
"forwardPorts": ["model-backend:8000"],
|
||||
"portsAttributes": {
|
||||
"8000": {
|
||||
"model-backend:8000": {
|
||||
"label": "FastAPI",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ services:
|
|||
USER_GID: ${GID:-1000}
|
||||
command: sleep infinity
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# Host port left unspecified so Docker assigns a free one — lets multiple
|
||||
# worktrees of this repo run at once without colliding. VS Code's
|
||||
# forwardPorts (in devcontainer.json) forwards container :8000 to your machine.
|
||||
- "8000"
|
||||
volumes:
|
||||
- ../../:/workspaces/model
|
||||
- ~/.gitconfig:/home/vscode/.gitconfig:ro
|
||||
|
|
@ -30,7 +33,10 @@ services:
|
|||
image: postgres:17.4
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 5432:5432
|
||||
# Dynamic host port (see model-backend above) so a second worktree's db
|
||||
# doesn't collide on 5432. Reach it from inside the container via host
|
||||
# "db:5432"; from your machine, use the forwarded port VS Code reports.
|
||||
- "5432"
|
||||
environment:
|
||||
- PGDATABASE=tech_team_local_db
|
||||
- POSTGRES_USER=postgres
|
||||
|
|
|
|||
38
.github/workflows/deploy_terraform.yml
vendored
38
.github/workflows/deploy_terraform.yml
vendored
|
|
@ -631,7 +631,7 @@ jobs:
|
|||
uses: ./.github/workflows/_build_image.yml
|
||||
with:
|
||||
ecr_repo: magic-plan-${{ needs.determine_stage.outputs.stage }}
|
||||
dockerfile_path: backend/magic_plan/handler/Dockerfile
|
||||
dockerfile_path: applications/magic_plan/handler/Dockerfile
|
||||
build_context: .
|
||||
secrets:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
|
||||
|
|
@ -661,6 +661,42 @@ jobs:
|
|||
TF_VAR_magicplan_customer_id: ${{ secrets.MAGICPLAN_CUSTOMER_ID }}
|
||||
TF_VAR_magicplan_api_key: ${{ secrets.MAGICPLAN_API_KEY }}
|
||||
|
||||
# ============================================================
|
||||
# Build Audit Generator image
|
||||
# ============================================================
|
||||
audit_generator_image:
|
||||
needs: [determine_stage, shared_terraform]
|
||||
uses: ./.github/workflows/_build_image.yml
|
||||
with:
|
||||
ecr_repo: audit-generator-${{ needs.determine_stage.outputs.stage }}
|
||||
dockerfile_path: applications/audit_generator/handler/Dockerfile
|
||||
build_context: .
|
||||
secrets:
|
||||
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 }}
|
||||
|
||||
# ============================================================
|
||||
# Deploy Audit Generator Lambda
|
||||
# ============================================================
|
||||
audit_generator_lambda:
|
||||
needs: [audit_generator_image, determine_stage]
|
||||
uses: ./.github/workflows/_deploy_lambda.yml
|
||||
with:
|
||||
lambda_name: audit_generator
|
||||
lambda_path: deployment/terraform/lambda/audit_generator
|
||||
stage: ${{ needs.determine_stage.outputs.stage }}
|
||||
ecr_repo: audit-generator-${{ needs.determine_stage.outputs.stage }}
|
||||
image_digest: ${{ needs.audit_generator_image.outputs.image_digest }}
|
||||
terraform_apply: ${{ needs.determine_stage.outputs.terraform_apply }}
|
||||
secrets:
|
||||
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 }}
|
||||
TF_VAR_db_host: ${{ secrets.DEV_DB_HOST }}
|
||||
TF_VAR_db_name: ${{ secrets.DEV_DB_NAME }}
|
||||
TF_VAR_db_port: ${{ secrets.DEV_DB_PORT }}
|
||||
|
||||
# ============================================================
|
||||
# Deploy Hubspot ETL Lambda
|
||||
# ============================================================
|
||||
|
|
|
|||
12
.github/workflows/lambda_smoke_tests.yml
vendored
12
.github/workflows/lambda_smoke_tests.yml
vendored
|
|
@ -119,10 +119,20 @@ jobs:
|
|||
magic_plan_smoke_test:
|
||||
uses: ./.github/workflows/_smoke_test_lambda.yml
|
||||
with:
|
||||
dockerfile_path: backend/magic_plan/handler/Dockerfile
|
||||
dockerfile_path: applications/magic_plan/handler/Dockerfile
|
||||
build_context: .
|
||||
service_name: magic-plan
|
||||
|
||||
# ============================================================
|
||||
# Audit Generator
|
||||
# ============================================================
|
||||
audit_generator_smoke_test:
|
||||
uses: ./.github/workflows/_smoke_test_lambda.yml
|
||||
with:
|
||||
dockerfile_path: applications/audit_generator/handler/Dockerfile
|
||||
build_context: .
|
||||
service_name: audit-generator
|
||||
|
||||
# ============================================================
|
||||
# HubSpot Scraper
|
||||
# ============================================================
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -283,6 +283,10 @@ cache/
|
|||
*.csv
|
||||
# Tracked reference CSV: SAP enum codes (gov api /api/codes) co-located with EpcPropertyData.
|
||||
!datatypes/epc/domain/epc_codes.csv
|
||||
# Generated property-inspection report artifacts (and any fetched EPC dump).
|
||||
property_report.md
|
||||
modelling_e2e.md
|
||||
epc_dump*/
|
||||
*.xlsx
|
||||
# *.pdf
|
||||
**/Chunks/
|
||||
|
|
@ -298,4 +302,7 @@ pyrightconfig.json
|
|||
backlog/*
|
||||
|
||||
# Local Claude config files
|
||||
.claude/*
|
||||
.claude/*modelling_cohort.csv
|
||||
|
||||
# Local EPC debug cache (scripts/eon)
|
||||
scripts/eon/epc_cache.pkl
|
||||
|
|
|
|||
139
CONTEXT.md
139
CONTEXT.md
|
|
@ -65,6 +65,16 @@ _Avoid_: user input, raw address, user_inputed_address
|
|||
The reference cohort matched to a target Property by both geographic proximity (postcode prefix / UPRN range) and physical similarity (property type, built form, age band); used by the EPC Prediction Service for gap-filling and anomaly detection.
|
||||
_Avoid_: neighbours, similar properties, peer set
|
||||
|
||||
### Survey documents
|
||||
|
||||
**Ventilation Audit**:
|
||||
A machine-generated `.xlsx` spreadsheet produced by the `audit-generator` Lambda from a property's parsed **MagicPlan Plan**. Written fields per room: room name, width, length, area. Per window: dimensions, opening type, number of openings, percent openable (`pct_openable`), trickle vent count and area per vent. Per door: width and undercut. Internal doors appear once per room they connect (so typically twice). Columns requiring human knowledge (Blocked, Pictured, FP reference numbers, door location labels) are left blank for the coordinator to complete. Recorded in `uploaded_files` with `file_type = VENTILATION_AUDIT` and `file_source = AUDIT_GENERATOR`. Distinct from a PAS 2023 Ventilation document, which is externally uploaded by a human.
|
||||
_Avoid_: ventilation report, audit report, PAS ventilation (that is the external survey form)
|
||||
|
||||
**PAS 2023 Ventilation**:
|
||||
An externally-uploaded ventilation survey document produced by a human assessor and ingested from an external source (e.g. Coordination Hub). Recorded in `uploaded_files` with `file_type = PAS_2023_VENTILATION`. Distinct from a **Ventilation Audit**, which is machine-generated from MagicPlan floor plan data.
|
||||
_Avoid_: ventilation audit (that is the generated output)
|
||||
|
||||
### Source data
|
||||
|
||||
**Site Notes**:
|
||||
|
|
@ -78,15 +88,19 @@ _Avoid_: patches (deprecated), corrections, manual EPC, edits
|
|||
### Modelling
|
||||
|
||||
**Effective EPC**:
|
||||
The EpcPropertyData scored by the modelling pipeline for a single Property, derived from either Site Notes alone or the public EPC with Landlord Overrides applied; carries source-derived physical fields and originally recorded performance values, with model-rebaselined performance held separately in Baseline Performance.
|
||||
The assembled `EpcPropertyData` picture the modelling pipeline scores for a single Property. Assembled from whichever source applies: Site Notes alone; or the public EPC with **Landlord Overrides** applied; or — when the EPC is **old** — its schema re-mapped to current via **Reduced-Field Synthesis** (deterministic, from the cert plus calibrated coefficients — no neighbour data); or — when there is **no EPC** — components **estimated from surrounding properties** (a separate neighbour-prediction ML mechanism, not yet implemented). Carries source-derived physical fields and originally recorded performance values; the performance scored from this picture is held separately in **Baseline Performance**.
|
||||
_Avoid_: modelling EPC, working EPC, resolved EPC, derived EPC
|
||||
|
||||
**Rebaselining**:
|
||||
Re-predicting a Property's SAP score, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh via **SAP10 Calculation** (the deterministic `Sap10Calculator`, which superseded the old ML-API rebaseliner; an ML residual head over the calculator is future — ADR-0009/0013) so the modelling pipeline scores it against the current SAP10 methodology. Triggered when either (a) the Effective EPC was lodged under a methodology the calculator supersedes (`sap_version < 10.2`, the calculator's target spec), so the recorded scores reflect a superseded methodology, or (b) Site Notes / Landlord Overrides changed the physical state of the Property (walls / heating / windows / etc.) so the lodged scores no longer reflect what's installed. Both triggers may fire together. Produces Effective Performance; Lodged Performance is preserved unchanged. kWh is included as ML targets per ADR-0007 — see [[epc-ml-transform]].
|
||||
Establishing a Property's **Effective Performance** (SAP score, EPC Band, CO2, Primary Energy Intensity, space-heating & hot-water kWh) by **assembling the Effective EPC picture and scoring it** through **SAP10 Calculation** (the deterministic `Sap10Calculator`, which superseded the old ML-API rebaseliner; an ML residual head over the calculator is future — ADR-0009/0013). The *assembly* is the substance: apply **Landlord Overrides** (e.g. boiler → ASHP, wall insulated) as a simulation on the `EpcPropertyData`; re-map an old-schema EPC to current via **Reduced-Field Synthesis** (deterministic, cert-only); estimate components from surrounding properties when there is no EPC (neighbour-prediction gap-fill — a separate ML mechanism, not yet implemented). The calculator is the **scoring engine at the tail**, not the whole of Rebaselining — so its call lives inside the Rebaseliner, after assembly. Triggered whenever the assembled picture differs from the lodged record: (a) the EPC was lodged under a methodology the calculator supersedes (`sap_version < 10.2`), (b) Overrides / Site Notes changed the physical state (walls / heating / windows / etc.), or (c) the picture is estimated or remapped rather than a real current EPC. Produces Effective Performance; Lodged Performance is preserved unchanged. The same single scoring also yields the per-end-use kWh that **Bill Derivation** prices — one scoring, two products. kWh is an ML target per ADR-0007 — see [[epc-ml-transform]].
|
||||
_Avoid_: re-scoring, re-prediction, performance recomputation, refresh (for cache-freshness)
|
||||
|
||||
**Reduced-Field Synthesis**:
|
||||
Deterministically translating an **old / reduced-data EPC schema** into the current `EpcPropertyData`, synthesising the *measured* fields the target expects from the source's *reduced or categorical* fields, using only the cert itself plus fixed calibrated coefficients — never neighbour data. Used when re-mapping a **pre-SAP10** cert (e.g. `RdSAP-Schema-20.0.0`) as part of assembling the **Effective EPC**: e.g. a glazing-area *band* + floor area → window m²; bath/shower *room counts* → bath and shower counts. A *best attempt* with no ground truth to validate against (per the **Validation Cohort** rule, a pre-SAP10 cert has no same-spec lodged figure to check), so each synthesis assumption is recorded explicitly in code and tests to keep it debuggable. Distinct from **neighbour-prediction gap-fill** (ML estimation of genuinely-absent fields from surrounding properties — the no-EPC path, a separate mechanism not yet implemented) and from the calculator's own RdSAP Table-5 defaulting in `cert_to_inputs` (which expands `EpcPropertyData` into the full SAP input set downstream).
|
||||
_Avoid_: gap-fill (means the neighbour-ML path), reduced-data expansion (overloaded with the calculator's Table-5 step), remapping (the schema-translation part only)
|
||||
|
||||
**Baseline Performance**:
|
||||
A Property's current performance aggregate, holding both Lodged Performance and Effective Performance plus the energy block: delivered kWh **per end use** (heating, hot water, lighting, appliances, cooking, pumps/fans, …) and the **annual bill** composed into per-section costs plus a total, produced by **Bill Derivation** from SAP10 Calculation's per-end-use kWh × current Fuel Rates. Persisted as one row (flat typed columns, per-section kWh + cost + total); surfaced as one block in the UI.
|
||||
A Property's current performance aggregate, holding both Lodged Performance and Effective Performance plus the energy block: delivered kWh **per end use** (heating, hot water, lighting, appliances, cooking, pumps/fans, cooling) and the **annual bill** composed into per-section costs plus a total, produced by **Bill Derivation** from SAP10 Calculation's per-end-use kWh × current Fuel Rates. Persisted as one row (flat typed columns, per-section kWh + cost + total); surfaced as one block in the UI.
|
||||
_Avoid_: baseline predictions, predicted baseline, rebaselined values
|
||||
|
||||
**Lodged Performance**:
|
||||
|
|
@ -114,11 +128,11 @@ The subset of corpus certs used to validate **SAP10 Calculation** against **Lodg
|
|||
_Avoid_: parity cohort, validation set, corpus sample
|
||||
|
||||
**Measure Application**:
|
||||
The process that translates an Optimised Package into cert-field changes and produces the "ending state snapshot" EpcPropertyData that Plan Phase persists. Implemented by the `MeasureApplicator` service class in `domain/sap/` (or a sibling package). Each Measure Type's translation rules (e.g. `loft_insulation` → `roof_insulation_thickness_mm = 270mm`, `ashp` → `main_heating_details[0]` replacement) live here. Pure function — does not run SAP10 Calculation itself; the caller chains `MeasureApplicator.apply(epc, package) → Sap10Calculator.calculate(post_epc)`. ADR-0009.
|
||||
The process that translates an Optimised Package into cert-field changes and produces the "ending state snapshot" EpcPropertyData that the **Plan** persists. Implemented by the `MeasureApplicator` service class in `domain/sap/` (or a sibling package). Each Measure Type's translation rules (e.g. `loft_insulation` → `roof_insulation_thickness_mm = 270mm`, `ashp` → `main_heating_details[0]` replacement) live here. Pure function — does not run SAP10 Calculation itself; the caller chains `MeasureApplicator.apply(epc, package) → Sap10Calculator.calculate(post_epc)`. ADR-0009.
|
||||
_Avoid_: measure overrides (rejected during ADR-0009 grill — phantom mid-layer), package applier, retrofit simulator
|
||||
|
||||
**Bill Derivation**:
|
||||
The deterministic process that derives a Property's annual energy **bill**, composed into per-end-use sections (heating, hot water, lighting, appliances, cooking, pumps/fans, …) plus a **total**, by pricing **SAP10 Calculation**'s delivered kWh per end use at **current Fuel Rates** — each end use billed at its fuel's rate, rolled up per fuel for **standing charges** (metered fuels only — gas/electricity; oil/LPG/solid have none) minus **SEG** export credit on PV. Implemented by `BillDerivation` in `domain/property_baseline/` (deterministic, ADR-0006). Reads Fuel Rates from a committed static snapshot via `FuelRatesRepository` (no live ETL yet). **Distinct from the calculator's `total_fuel_cost_gbp`**, which is the SAP-rating notional cost at RdSAP Table 32 standardised prices (~half the real electricity price) — not what the household pays. Raises on a fuel it has no rate for (e.g. house coal, heat network). ADR-0014.
|
||||
The deterministic process that derives a Property's annual energy **bill**, composed into per-end-use sections (heating, hot water, lighting, appliances, cooking, pumps/fans, …) plus a **total**, by pricing **SAP10 Calculation**'s delivered kWh per end use at **current Fuel Rates** — each end use billed at its fuel's rate, rolled up per fuel for **standing charges** (metered fuels only — gas/electricity; oil/LPG/solid have none) minus **SEG** export credit on PV. Implemented by `BillDerivation` in `domain/billing/` (a cross-stage concern — the Baseline stage derives the current bill, the Modelling stage re-runs it on the post-package end-state for post-retrofit bills; deterministic, ADR-0006). Reads Fuel Rates from a committed static snapshot via `FuelRatesRepository` (no live ETL yet). **Distinct from the calculator's `total_fuel_cost_gbp`**, which is the SAP-rating notional cost at RdSAP Table 32 standardised prices (~half the real electricity price) — not what the household pays. Raises on a fuel it has no rate for (e.g. house coal, heat network). ADR-0014.
|
||||
_Avoid_: EPC Energy Derivation (renamed), EpcEnergyDerivationService (no "service" suffix), kWh prediction, baseline kWh, energy estimation
|
||||
|
||||
**UCL Correction**:
|
||||
|
|
@ -142,7 +156,7 @@ The second stage. Reads the persisted source data from repos, hydrates the **Pro
|
|||
_Avoid_: rebaseline (that is a specific ML trigger — see Rebaselining), enrichment
|
||||
|
||||
**Modelling** (stage):
|
||||
The third stage. Takes the baselined Property plus a set of **Scenarios** and produces **Recommendations** → an **Optimised Package** per **Scenario Phase** → **Plans**, persisted to repos. A separate orchestrator from Baseline so the single-property flow can stop after Baseline and only run Modelling when the user hits "play".
|
||||
The third stage. Takes the baselined Property plus a set of **Scenarios** and produces **Recommendations** → an **Optimised Package** → **Plans**, persisted to repos. A separate orchestrator from Baseline so the single-property flow can stop after Baseline and only run Modelling when the user hits "play".
|
||||
_Avoid_: scoring (overloaded), recommendation engine
|
||||
|
||||
**First Run**:
|
||||
|
|
@ -184,41 +198,110 @@ _Avoid_: emission factors (ambiguous), CO2 rates
|
|||
### Outputs
|
||||
|
||||
**Scenario**:
|
||||
A named portfolio-level retrofit plan, built by a user in the scenario-builder UI and persisted before any modelling fires; carries the overall goal (e.g. Increasing EPC), budget, exclusions, housing type, and an ordered list of Scenario Phases. The model is triggered against one or more Scenarios at once; each Scenario yields one Plan per Property.
|
||||
A named portfolio-level retrofit plan, built by a user in the scenario-builder UI and persisted before any modelling fires; carries the overall goal (e.g. Increasing EPC), budget, exclusions, housing type, and the set of measure types it permits. The model is triggered against one or more Scenarios at once; each Scenario yields one Plan per Property.
|
||||
_Avoid_: project, batch, run-set
|
||||
|
||||
**Scenario Phase**:
|
||||
One ordered step inside a Scenario, carrying a measure-type allowlist (e.g. "loft insulation and walls in phase 1; ASHP in phase 2"), an optional phase budget, and an optional phase target. A single-phase Scenario is one Scenario Phase with all measure types allowed and the full budget on it — there is no special-case path.
|
||||
_Avoid_: scenario stage, scenario step, tranche
|
||||
|
||||
**Scenario Snapshot**:
|
||||
A frozen copy of a Scenario pinned at trigger time, keyed by (task, scenario); used by the modelling pipeline so mid-run edits to the live Scenario do not affect an in-flight job. Snapshots are read-only and may be garbage-collected after the task completes.
|
||||
_Avoid_: scenario version, frozen scenario, pinned scenario
|
||||
|
||||
**Plan**:
|
||||
The per-Property output of one Scenario's modelling run; carries an ordered list of Plan Phases matching the Scenario's Phase shape. A Property modelled against N Scenarios in one trigger ends up with N Plans.
|
||||
The per-Property output of one Scenario's modelling run; carries the **Optimised Package** selected for the Property (its **Plan Measures**) and the Property's post-retrofit figures (SAP / kWh / CO₂ / bills). A Property modelled against N Scenarios in one trigger ends up with N Plans.
|
||||
_Avoid_: recommendation set, output, result
|
||||
|
||||
**Plan Phase**:
|
||||
The per-Property output of one Scenario Phase: the Optimised Package selected for that phase, the ending state snapshot (the Property's SAP / kWh / bills after the package is applied), and any Rolled-over Options that flow as candidates into the next Plan Phase.
|
||||
_Avoid_: plan stage, plan step
|
||||
|
||||
**Rolled-over Options**:
|
||||
Recommendations generated but not selected by the Optimiser in a given Plan Phase, that remain eligible as candidates in subsequent Plan Phases. Exact roll-over rule (automatic vs user-marked) is under design.
|
||||
_Avoid_: deferred measures, leftover recommendations
|
||||
**Plan Measure**:
|
||||
One selected **Measure Option** as persisted inside a **Plan** — the single Option the Optimiser kept for a given **Recommendation**, recorded with its installed **Cost** and its **final-package (role-3) attributed impact** (the SAP points and CO₂ / energy savings that telescope exactly to the Plan's package total, per ADR-0016). It is the *output* counterpart to a Recommendation's *candidate* Option: a Recommendation proposes mutually-exclusive Options carrying no stored impact, whereas a Plan Measure is the one that was chosen with its truthful attributed impact frozen in. The persisted set of a Plan's Plan Measures **is** its Optimised Package.
|
||||
_Avoid_: recommendation (that is the candidate — never persist an output as a Recommendation), installed measure, selected measure (that names the package, not the line), plan item, plan recommendation
|
||||
|
||||
**Recommendation**:
|
||||
A single proposed retrofit measure for a Property, with its cost, SAP impact, kWh savings, carbon savings, and parts list.
|
||||
_Avoid_: suggestion, option
|
||||
The finding that a Property needs work on a given **target surface** — a building part (the MAIN wall, an extension roof…) or a system (heating + hot water + controls, treated as one). Carries one or more mutually-exclusive **Measure Options**; the Optimiser selects at most one. The target itself is encoded in each Option's **Simulation Overlay** (which addresses a building part, a specific window, or a system) — never as a typed key on the Recommendation, so the type stays stable as new surfaces land. Recommendations **partition** the modifiable surface of EpcPropertyData: no two Recommendations write the same field of the same target, so selected Options never collide. Exclusivity between competing treatments (cavity-fill vs EWI; a boiler bundle vs an ASHP) is captured *within* one Recommendation, never across them.
|
||||
_Avoid_: suggestion, recommendation engine, keying by measure type (a Recommendation can span measure types — e.g. a heating + hot-water bundle), the persisted selected-measure output line (that is a **Plan Measure**, which carries impact; a Recommendation never does)
|
||||
|
||||
**Measure Option**:
|
||||
One mutually-exclusive way to satisfy a **Recommendation** — possibly a **bundle** of sub-measures (e.g. "new condensing boiler + cylinder insulation"), possibly a single intervention at a chosen size/product (a 4 kWp PV array of product X). Carries its total cost and a **Simulation Overlay** for its combined effect on the target surface. Cost is intrinsic to the Option; SAP / kWh / carbon impact is **not** — impact is cascade-conditional (depends on what is already installed) and is produced by scoring, never stored on the Option. Two Options under one Recommendation may share an identical Simulation Overlay (differing only on cost/product) or differ (e.g. PV kWp), so scoring runs per distinct Overlay.
|
||||
_Avoid_: option (too generic), variant, SKU
|
||||
|
||||
**Simulation Overlay** (type `EpcSimulation`):
|
||||
The change a single **Measure Option** makes to a Property's EpcPropertyData, expressed as an all-optional partial mirror of EpcPropertyData and its nested types — covering only the retrofit-relevant surface (walls/roofs/floors, windows, heating + controls, hot water, ventilation, lighting, PV, draughtproofing), never identity/location fields. Targets a specific building part by `BuildingPartIdentifier` (MAIN, EXTENSION_1..4) so "insulate the cavity wall" addresses the exact `SapBuildingPart`; targets a specific **window by its index** in `sap_windows` (the PDF's W1/W2/W3) — glazing measures address windows directly by number, regardless of which wall they sit on; the window's building-part association is carried separately via `window_location` (resolved by `_window_bp_index`), not used for targeting; and targets whole-dwelling systems (e.g. `sap_heating`) directly. Carries no scores. It is **not** an EpcPropertyData (composition, not inheritance — an all-`None` overlay is not a valid EPC). A domain operation folds a baseline EpcPropertyData + an ordered set of Overlays into a throwaway EpcPropertyData handed to the calculator; only the score is kept, the EPD is discarded.
|
||||
_Avoid_: simulation config (the legacy EPC-API flag object), patch, delta, diff
|
||||
|
||||
**Product**:
|
||||
A catalogue entry a **Measure Option** installs — insulation, glazing units, heat pumps, boilers, cylinders, PV panels, inverters, batteries — carrying the data to price an Option and shape its **Simulation Overlay**. Named *Product*, not *material*: the catalogue is dominated by equipment and appliances, and a heat pump is not a building material. Read via `ProductRepository`, which for now combines two inputs — the Products in the database plus a committed costs file holding what the ETL does not yet supply. Single-source unification (ETL-supplied costs) is separate, queued work; legacy `Costs.py` is retained but queued for deletion.
|
||||
_Avoid_: material, building material (inaccurate for appliances), part (the per-Option installed line item), SKU
|
||||
|
||||
**Products** (the catalogue collection):
|
||||
The rich in-memory domain collection over **Product** — an iterable the **ProductRepository** yields, carrying the cost-composition behaviour a single `Product` row cannot. Where a simple measure prices as one row (unit cost × area), a composite measure (an **ASHP bundle**) prices by *selecting and summing many priced line items* — so `Products` exposes per-measure cost methods (e.g. `ashp_bundle_cost`) that filter the relevant catalogue rows and sum them into a **Cost**. The split is load-bearing: `Products` owns the **catalogue math** (line-item lookup + summation from clean cost-inputs) and stays free of `EpcPropertyData` / the `Sap10Calculator`; the **dwelling interpretation** that produces those inputs (sizing a heat pump from heat loss, proxying beds/radiators, detecting a reusable wet system) lives in the modelling layer, which may depend on the calculator. ProductRepository = fetch; Products = behaviour.
|
||||
_Avoid_: ProductRepository (that is the IO port, not the domain collection), putting sizing/EPC logic on Products
|
||||
|
||||
**Cost** (of a Measure Option):
|
||||
A single **fully-loaded total** — products + labour + preliminaries + VAT + margin rolled into one figure — **plus a separately-carried Contingency**. Only contingency is broken out; the rest is not decomposed, as that breakdown proved unhelpful.
|
||||
|
||||
**Contingency**:
|
||||
A per-**Measure-Type** percentage uplift on an Option's cost covering job-specific risk (e.g. cavity-wall 10%, internal/external wall 26%, ASHP 25% — cf. legacy `Costs.CONTINGENCIES`). The one cost component carried separately from the fully-loaded total, because the rate is measure-type-specific and meaningful to surface.
|
||||
_Avoid_: preliminaries (a different, rolled-in 10%), margin
|
||||
|
||||
**Measure Dependency**:
|
||||
A "selecting A requires B" edge between **Recommendations**, for couplings that are real but that the Optimiser would not choose on its own — e.g. wall (and possibly roof) insulation requires adequate ventilation. The required Option is excluded from the optimiser's candidate pool (it is mandatory-when-triggered, not a free choice) but is **injected into the Optimised Package before the package re-score**, so its real SAP contribution — which for ventilation is *negative* — is captured in the true package score and in the undershoot/repair loop. Trigger set is held as **data** (cf. legacy `assumptions.measures_needing_ventilation`), not control flow, so extending the triggers (e.g. to roof insulation) is a data edit. Distinct from the legacy post-optimisation best-practice add, which tacked cost on *after* scoring and so undershot.
|
||||
_Avoid_: best-practice measure (legacy term), forced measure
|
||||
|
||||
**Optimised Package**:
|
||||
The subset of a Property's Recommendations selected by the Optimiser Service for installation, chosen to satisfy the Scenario's goal subject to budget.
|
||||
The subset of a Property's Recommendations selected by the Optimiser Service for installation. For an **Increasing EPC** goal the objective is **least-cost-to-target**: the cheapest package that reaches the goal band — so it **stops at the target and does not overshoot** into a higher band, leaving surplus budget unspent. When the target is **unreachable within budget**, it falls back to the **maximum improvement the budget buys** (best effort, below target). With **no budget** it is simply the cheapest package that reaches the target. Reaching the target is judged on the **true whole-package re-score** (ADR-0016), not on summed per-measure scores. (Other goals — Energy Savings, Reducing CO₂ — don't yet set a target and currently maximise improvement within budget; future work.)
|
||||
_Avoid_: selected measures, default measures, optimal solution, recommended bundle
|
||||
|
||||
**Measure Type**:
|
||||
The catalogue classification of a retrofit measure (e.g. `solar_pv`, `loft_insulation`, `ashp`); one or more Recommendations reference the same Measure Type with property-specific cost and impact.
|
||||
_Avoid_: measure (ambiguous), category
|
||||
|
||||
**Solar Potential**:
|
||||
The typed domain projection over a Property's Google Solar `buildingInsights` response (fetched by **Ingestion**, persisted whole as JSONB, read by Modelling — never re-fetched). It carries the per-**roof-segment** geometry the PV simulation needs — `azimuthDegrees` (→ SAP orientation octant), `pitchDegrees` (→ SAP pitch), per-segment panel counts, panel capacity, and `sunshineQuantiles` (→ overshading) — plus the candidate panel layouts (`solarPanelConfigs`). It is the source of the **PV Overlay**: the solar Recommendation Generator reads the Solar Potential (NOT the EPC's `photovoltaic_arrays`, which is the dwelling's *existing* PV) and builds competing PV Options from it. Distinct from existing/installed PV.
|
||||
_Avoid_: solar config (ambiguous — the API response vs the chosen array set), Google insights (the raw JSON, not the typed projection)
|
||||
|
||||
**Solar PV Recommendation**:
|
||||
The single "Solar PV" **Recommendation** for a Property, carrying competing whole-array **Measure Options** built from the **Solar Potential** (ADR-0026). Up to **five conservatively-sized array configs** (capped, ranked by energy generation as the size-suitability proxy; deliberately *not* full-roof — imagery can miss obstructions, so a coverage/edge-setback haircut applies per MCS), each offered **with and without a battery** (≤ 10 Options). Each Option is priced at a **single price point** from the rate sheet (kWp band + scaffolding-by-elevation + optional battery/diverter) — no multi-product price variants. The PV Overlay sets `photovoltaic_arrays` (one per segment: peak_power, orientation, pitch, overshading) plus `pv_diverter_present`, `pv_connection`, and **`is_dwelling_export_capable=True`** (an export meter is ensured post-install); the battery variant adds `pv_batteries`.
|
||||
_Avoid_: solar bundle (PV is competing sized Options, not one fixed bundle like ASHP)
|
||||
|
||||
**Solar PV Eligibility**:
|
||||
The rule fixing whether the **Solar PV Recommendation** is offered (ADR-0026): a **house or bungalow**, **not listed and not heritage**, with **no existing PV**, and a **feasible Solar Potential** (the Google Solar API returned usable, non-north roof segments). Crucially a **conservation area does NOT block PV** — panels are offered (installed sympathetically), so the planning gate is `not blocks_internal` (listed/heritage only), **not** `blocks_external`; this is the opposite of an external fabric measure like EWI, and is deliberate (legacy + planning practice allow conservation-area PV on non-prominent roofs). Flats/maisonettes (building-level shared roof) are deferred.
|
||||
_Avoid_: blocking conservation-area PV (only listed/heritage block), roof-area-from-floor-area estimate (eligibility uses the real Solar Potential)
|
||||
|
||||
**External Wall Insulation (EWI)** / **Internal Wall Insulation (IWI)**:
|
||||
The two competing **Measure Options** for insulating a *solid* (non-cavity) main wall — insulation fixed to the outside face (`wall_insulation_type = 1`) or the room side (`wall_insulation_type = 3`), both 100 mm at λ = 0.04 W/m·K; the calculator **derives** the post-insulation U-value from the type + thickness (the lodged cert carries no U-value). IWI additionally lowers the wall's thermal-mass parameter (changing heating demand); EWI does not.
|
||||
_Avoid_: solid wall insulation (that names the pair, not one Option), cladding, drylining
|
||||
|
||||
**Wall Insulation Eligibility**:
|
||||
The rule fixing which wall Option(s) the main-wall **Recommendation** offers, by wall construction then planning status. By construction: **cavity** → cavity fill only (never solid insulation); **solid brick** / **system-built** → IWI + EWI; **timber-frame** → IWI only (EWI is not constructable); **cob** / **granite or whinstone** / **sandstone or limestone** → none (breathable fabric — standard insulation risks trapping moisture). Then, as planning gates: a **conservation area** or **flat** removes EWI (external-appearance / whole-block constraints), and a **listed** or **heritage** building removes **both** EWI and IWI (protected fabric).
|
||||
_Avoid_: restricted measures (legacy collapsed conservation/listed/heritage into one boolean — they now gate different Options, so keep them distinct)
|
||||
|
||||
**Roof Insulation Eligibility**:
|
||||
The rule fixing which single roof Measure the main-roof **Recommendation** offers, by roof type. One Measure per roof — never a menu the Optimiser chooses between (ADR-0021): **pitched with an accessible loft** (incl. **thatch** — the covering doesn't block insulating the loft floor) → **loft insulation** (laid flat at the ceiling joists, 300 mm); **pitched with a sloping ceiling** → **sloping-ceiling insulation** (at the rafters, 100 mm); **flat roof** → **flat-roof insulation** (200 mm); **pitched, no access** → none (can't reach the void). A **room-in-roof** takes neither loft nor sloping-ceiling insulation — it is insulated at its own slopes/stud-walls (rafter-area, not floor-area, quantity) as a distinct **room-in-roof insulation** Measure, currently **deferred** (pending retrofit-specialist examples). A Measure is offered only when the roof is genuinely uninsulated ("As Built" / "None" / 0 mm).
|
||||
_Avoid_: "roof insulation" (name the specific Measure — loft / sloping-ceiling / flat-roof / room-in-roof); "joist insulation" (use **loft insulation**, the established Measure Type)
|
||||
|
||||
**Glazing Eligibility**:
|
||||
The rule fixing the single glazing Measure the **Windows** Recommendation offers. We upgrade **only single-glazed windows** (a pragmatic scope — already-double/secondary/triple windows are left alone), **all of them together** as one Measure. Planning status **hard-picks** the Measure (not a choice the Optimiser makes — ADR-0022): unrestricted → **double glazing** (replace the units); a **conservation area** / **listed** / **heritage** building → **secondary glazing** (an internal second pane, since the external units can't be replaced). Priced at a flat **average price per window** × the count of single-glazed windows (we have per-window areas but no size-varying prices, so size is ignored). When the dwelling has no single-glazed windows, no Recommendation is offered.
|
||||
_Avoid_: "windows" as a Measure (name **double glazing** / **secondary glazing**); pricing glazing by area (it's per-window count × average)
|
||||
|
||||
**Lighting Eligibility**:
|
||||
The rule fixing the single lighting Measure the **Lighting** Recommendation offers. We convert **all non-LED bulbs** (incandescent + CFL + low-energy-unknown) to **LED** — all the way to LED, not the legacy "fill to low energy", because SAP rates LED efficacy above CFL (ADR-0023). One Measure, no planning gate (lighting isn't planning-restricted). Offered only when the dwelling lodges at least one non-LED bulb; a dwelling already all-LED, or one that lodged **no** bulb counts (nothing to size against), gets no Recommendation. Unlike the fabric measures it is a **whole-dwelling** Measure — its **Simulation Overlay** writes the four top-level bulb counts directly (`led = total`, others 0), the first overlay surface that isn't a building part / window / system sub-object. Priced at a flat **average price per bulb** × the count of non-LED bulbs replaced. A free Optimiser candidate (it *improves* SAP), contrast the forced ventilation **Measure Dependency**.
|
||||
_Avoid_: "low energy lighting" as the upgrade target (we go to **LED**); treating it as a forced dependency (it is a free candidate); pricing by floor area (it's per-bulb count × average)
|
||||
|
||||
**Heating Eligibility**:
|
||||
The rule fixing which **Measure Options** the single **Heating & Hot Water** Recommendation offers (ADR-0024, expanded). The competing Options are **mutually-exclusive** (the Optimiser picks at most one) and fall in two families: **whole-system replacements** — `high_heat_retention_storage_heaters`, `air_source_heat_pump` — which change main heating + **controls + fuel + meter + the implied hot water** at once (never a separate HW measure; the legacy heating-vs-HW split double-counted); and, for a dwelling keeping a serviceable wet boiler, **partial upgrades** — `gas_boiler_upgrade` (a like-for-like condensing **gas** boiler: gas→gas, or non-gas→gas only where mains gas is present; combi or regular-plus-cylinder, shaped by the dwelling) and the **system tune-up** (keep the boiler; install better **controls** + fix the **cylinder**), the tune-up offered at two competing control levels: `system_tune_up` (standard, SAP code 2106) and `system_tune_up_zoned` (time-and-temperature zone control, 2110 — more SAP uplift, more cost). Each Option is a **fixed, real, contractor-installable end-state** (ASHP via a fixed PCDB heat-pump index; HHR storage via `sap_main_heating_code=409`; the gas boiler via Table 4b code 102/104; controls via 2106/2110), not a derived ideal; **Product** stays cost-only, but a partial/bundle cost is **composed per dwelling** from the components the overlay installs (ADR-0025/0027), not a flat scalar. Eligibility encodes **only physical/planning installability** — the **Optimiser owns the economics**, so it must not re-gate on cost proxies: **ASHP** → houses/bungalows that are not **listed**/**heritage** and not already a heat pump (flats excluded — individual siting needs a survey; a **conservation area** still gets the offer, unlike glazing); **HHR storage** → off-gas or currently-electric dwellings, not community-heated or already HHR; **boiler upgrade / tune-up** → an existing (non-electric) wet boiler, the gas end-state gated on a mains-gas connection, a partial control upgrade offered only when it genuinely improves the existing control (never a downgrade or no-op). Floor area, fabric, fuel, and built form are **not** gates (the legacy ASHP built-form / 120 m² rule is dropped — no authoritative basis). A free Optimiser candidate, not a forced **Measure Dependency**.
|
||||
_Avoid_: separate "heating" and "hot water" recommendations (HW folds into each Option); gating ASHP on floor area / built form / fabric (eligibility is physical/planning only — the Optimiser decides cost-effectiveness); treating the whole-system replacements and the partial boiler/tune-up upgrades as **separate** Recommendations (they are mutually-exclusive Options within the one heating rec — separate recs would let the Optimiser co-select and double-charge); a standalone hot-water-only or controls-only Recommendation (controls + cylinder fold into the boiler/tune-up Option)
|
||||
|
||||
**Secondary Heating Removal**:
|
||||
The rule fixing the single Measure the **Secondary Heating Removal** Recommendation offers — strip the dwelling's lodged secondary heating system so the main system serves 100% of space heating (ADR-0028). A **standalone, co-selectable** Recommendation, **not** an Option in the Heating & Hot Water rec: removing a secondary heater is independent of (and combinable with) a tune-up or boiler upgrade, so it must not be made mutually-exclusive with them. Eligibility is purely physical — offered **iff a secondary is lodged** (`secondary_heating_type` is set); since RdSAP only records a secondary when a **fixed** emitter is present (portable plug-in heaters are ignored), a lodged secondary is by definition a fixed unit worth removing. There is **no effectiveness gate**: on an electric-storage main, RdSAP §A.2.2 *forces a default secondary back*, so removal yields zero SAP change — the **Optimiser** de-selects those (it owns the economics), eligibility does not pre-filter them. The change is a dedicated **Simulation Overlay** (`SecondaryHeatingOverlay`) that *clears* the secondary fields — the one overlay that sets a value to absent rather than to a target state. Priced at a **flat per-dwelling decommission cost** (one electrician visit to disconnect a fixed/hard-wired heater + localised making-good), not scaled by room count (the EPC carries no heater count).
|
||||
_Avoid_: making it an Option inside the Heating & Hot Water rec (it is independent, not mutually-exclusive); gating out electric-storage dwellings where removal is a no-op (that is the Optimiser's call, not eligibility's); pricing by room count (the legacy room proxy — the EPC lodges one secondary system with no count); "secondary heating" as the Measure name (name the action: **Secondary Heating Removal**)
|
||||
|
||||
### Valuation
|
||||
|
||||
**Property Valuation**:
|
||||
The current open-market value of a Property — an externally-sourced **Baseline** attribute (customer upload or, later, an estimate), **absent for most Properties** and never derived from the EPC.
|
||||
_Avoid_: valuation (ambiguous with Valuation Uplift), market price, current value, house price
|
||||
|
||||
**Valuation Uplift**:
|
||||
The estimated increase in a Property's market value produced by a **Plan's** retrofit — **plan-conditional** (it depends on the Plan's target **EPC Band**) and **percentage-primary**: always expressible as a % from the Band jump (current → target), and as an absolute £ amount **only when a Property Valuation is known**. Capped so the £ uplift never exceeds twice the Plan's cost (the cap can only bite once a Property Valuation supplies the £ form — see ADR-0018).
|
||||
_Avoid_: valuation increase, value gain, financial uplift, property_valuation_increase (pick one — Valuation Uplift is canonical)
|
||||
|
||||
### Address matching
|
||||
|
||||
**Lexiscore**:
|
||||
|
|
@ -279,10 +362,10 @@ _Avoid_: API key, auth token, secret
|
|||
- **Rebaselining** produces **Effective Performance** by ML re-prediction across SAP score, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh, when either (a) the Effective EPC was lodged under a pre-SAP10 schema, or (b) the Effective EPC's physical state diverges from the lodged EPC. **Lodged Performance** is never overwritten.
|
||||
- **Bill Derivation** derives **fuel split** and **bills** from kWh values (sourced from the EPC's `renewable_heat_incentive` fields for baseline SAP10 properties, or from ML when Rebaselining fires), reading current **Fuel Rates** and **Carbon Factors** from their respective repos.
|
||||
- The **EPC Prediction Service** uses **Comparable Properties** for both gap-filling and producing **EPC Anomaly Flags**.
|
||||
- A **Scenario** carries one or more ordered **Scenario Phases**. Triggering the model against N Scenarios produces N **Plans** per Property; each Plan carries an ordered list of **Plan Phases** matching the Scenario's shape.
|
||||
- Each **Plan Phase** holds its **Optimised Package**, the ending state snapshot, and any **Rolled-over Options** that flow as candidates into the next Plan Phase. A single-phase Scenario is one Scenario Phase with all measure types allowed; the same machinery handles it.
|
||||
- Triggering the model against N **Scenarios** produces N **Plans** per Property. Each **Plan** holds one **Optimised Package** — its selected **Plan Measures** — plus the Property's post-retrofit figures.
|
||||
- A **Scenario Snapshot** is pinned at trigger time per (task, scenario) so mid-run edits to the live Scenario do not affect an in-flight modelling job.
|
||||
- A **Recommendation** references one **Measure Type** and carries property-specific cost and impact.
|
||||
- A **Property Valuation** (current market value) is a Baseline attribute and is mostly absent; a **Valuation Uplift** is a Plan output, always a percentage from the **EPC Band** jump and an absolute £ only when a Property Valuation exists.
|
||||
- **Address Matching** uses a **User Address** and **Postcode** to find a **UPRN** by scoring **UPRN Candidates** from an EPC search. A **Lexirank** of 1 with no **Ambiguous Match** and a **Lexiscore** ≥ the **Score Threshold** produces a **Best Match**.
|
||||
|
||||
## Example dialogue
|
||||
|
|
@ -307,10 +390,6 @@ _Avoid_: API key, auth token, secret
|
|||
>
|
||||
> **Domain expert:** "Those are **Lodged Performance** and **Effective Performance**. **Lodged** is what the gov register says — the EPC was rated under SAP 2012. **Effective** is what we scored against — we ran **Rebaselining** to predict the SAP10-equivalent rating because the methodology changed. Both stay on the **Baseline Performance** so users can see what's on record and what we're modelling against."
|
||||
|
||||
> **Dev:** "A landlord wants a 3-year retrofit plan — fabric work this year, heat pump next, solar after. How do we model that?"
|
||||
>
|
||||
> **Domain expert:** "Three **Scenario Phases** in one **Scenario**. Phase 1 allows fabric measures with this year's budget, phase 2 allows the heat pump with next year's budget, phase 3 allows solar. When we model, the **Optimiser Service** runs per phase against the rolling state — the heat pump is scored against the post-insulation property, not the original one. Each **Plan Phase** captures the **Optimised Package** plus the ending SAP / bills, and any **Rolled-over Options** that didn't make this phase's budget become candidates next phase."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- **"property"** was historically warned against in favour of "dwelling"; that has been inverted. **Property** is now canonical for the Ara domain aggregate. Legacy code still uses "dwelling" in places — treat as alias.
|
||||
|
|
@ -322,5 +401,7 @@ _Avoid_: API key, auth token, secret
|
|||
- **"user_inputed_address"** in `backend/address2UPRN/main.py` is a misspelling and a synonym for **User Address** — the canonical term. New code should use `user_address`.
|
||||
- **"EPC"** is overloaded as both the document and the rating band letter. Use **EPC** for the document, **EPC Band** for the letter.
|
||||
- **"re-scoring"** has two meanings in the codebase — **Rebaselining** (re-predicting baseline performance after an EPC change) and post-optimisation measure re-prediction. Prefer **Rebaselining** for the former; for the latter, the **Optimiser Service** step does its own scoring without a special name.
|
||||
- **"phase"** appears in two unrelated contexts: as cut-over timeline language in the PRD ("Phase 0 — Status quo", "Phase 1 — Forced cut-over") and as a domain concept in **Scenario Phase** / **Plan Phase**. Only the latter is a glossary term; cut-over phases are project-management vocabulary that does not enter code.
|
||||
- **"phase"** (sequencing measures into ordered steps within a Scenario/Plan) was a speculative, prospective-client feature and is **deferred — out of scope** (see ADR-0005). It is *not* a current domain term: a **Scenario** carries one set of measures, a **Plan** one **Optimised Package**. The only live use of "phase" is cut-over timeline language in the PRD ("Phase 0 — Status quo"), which is project-management vocabulary and does not enter code.
|
||||
- **"valuation"** was used for both a Property's current market value and the increase a retrofit produces — resolved into two distinct terms: **Property Valuation** (current value, a Baseline attribute) and **Valuation Uplift** (the plan-conditional, percentage-primary increase). The bare word "valuation" should be qualified to one of these.
|
||||
- **"stale"** appears in two senses: cache-freshness ("a Repo record is stale and the orchestrator should refetch") — a legitimate operational concept; and as loose shorthand for the EPC's recorded cost fields being unusable. The cost fields are not stale — they are pinned to the inspection-date fuel rates by design. Use "pinned to inspection date" or "pre-SAP10 schema" (whichever applies) instead.
|
||||
- **"restricted_measures"** (legacy `backend/Property.py`) collapsed `in_conservation_area`, `is_listed_building`, and `is_heritage_building` into one boolean that blocked EWI only. Resolved: the rebuild keeps the three flags **distinct**, because they gate different Options — a **conservation area** blocks EWI but allows IWI, whereas **listed/heritage** block both (see **Wall Insulation Eligibility**). Don't reintroduce a single collapsed flag.
|
||||
|
|
|
|||
|
|
@ -23,10 +23,11 @@ from orchestration.ingestion_orchestrator import (
|
|||
)
|
||||
from orchestration.modelling_orchestrator import ModellingOrchestrator
|
||||
from orchestration.task_orchestrator import TaskOrchestrator
|
||||
from repositories.fuel_rates.fuel_rates_static_file_repository import (
|
||||
FuelRatesStaticFileRepository,
|
||||
)
|
||||
from repositories.geospatial.geospatial_repository import GeospatialRepository
|
||||
from repositories.materials.materials_repository import MaterialsRepository
|
||||
from repositories.postgres_unit_of_work import PostgresUnitOfWork
|
||||
from repositories.scenario.scenario_repository import ScenarioRepository
|
||||
from repositories.unit_of_work import UnitOfWork
|
||||
from utilities.aws_lambda.subtask_handler import subtask_handler
|
||||
|
||||
|
|
@ -69,8 +70,7 @@ def build_first_run_pipeline(
|
|||
|
||||
Each stage opens its own unit(s) and commits per batch (ADR-0012); the
|
||||
handler no longer holds a session. The source clients are passed in because
|
||||
their config is not settled — see ``_source_clients_from_env``. Modelling is
|
||||
stubbed (#1136); its Scenario / Materials ports are seams.
|
||||
their config is not settled — see ``_source_clients_from_env``.
|
||||
"""
|
||||
return AraFirstRunPipeline(
|
||||
ingestion=IngestionOrchestrator(
|
||||
|
|
@ -85,10 +85,12 @@ def build_first_run_pipeline(
|
|||
# certs, lodged + divergence-logged at/above 10.2; a raise aborts the
|
||||
# batch (ADR-0013 amendment).
|
||||
rebaseliner=CalculatorRebaseliner(Sap10Calculator()),
|
||||
fuel_rates=FuelRatesStaticFileRepository(),
|
||||
),
|
||||
modelling=ModellingOrchestrator(
|
||||
scenario_repo=ScenarioRepository(),
|
||||
materials_repo=MaterialsRepository(),
|
||||
unit_of_work=unit_of_work,
|
||||
calculator=Sap10Calculator(),
|
||||
fuel_rates=FuelRatesStaticFileRepository(),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,9 @@
|
|||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AuditGeneratorTriggerRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
task_id: str
|
||||
sub_task_id: str
|
||||
hubspot_deal_id: str
|
||||
42
applications/audit_generator/handler.py
Normal file
42
applications/audit_generator/handler.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import boto3
|
||||
|
||||
from applications.audit_generator.audit_generator_trigger_request import (
|
||||
AuditGeneratorTriggerRequest,
|
||||
)
|
||||
from infrastructure.postgres.config import PostgresConfig
|
||||
from infrastructure.postgres.engine import make_engine, make_session
|
||||
from infrastructure.s3.s3_client import S3Client
|
||||
from orchestration.audit_generator_orchestrator import AuditGeneratorOrchestrator
|
||||
from orchestration.audit_generator_unit_of_work import AuditGeneratorUnitOfWork
|
||||
from utilities.aws_lambda.subtask_handler import subtask_handler
|
||||
|
||||
|
||||
@subtask_handler(pass_task_orchestrator=False)
|
||||
def handler(body: dict[str, Any], context: Any) -> None:
|
||||
trigger = AuditGeneratorTriggerRequest.model_validate(body)
|
||||
|
||||
boto3_client: Any = (
|
||||
boto3.client
|
||||
) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
boto_s3: Any = boto3_client("s3")
|
||||
bucket = os.environ["S3_BUCKET_NAME"]
|
||||
s3_client = S3Client(boto_s3_client=boto_s3, bucket=bucket)
|
||||
|
||||
engine = make_engine(PostgresConfig.from_env(os.environ))
|
||||
|
||||
def session_factory() -> Any:
|
||||
return make_session(engine)
|
||||
|
||||
def uow_factory() -> AuditGeneratorUnitOfWork:
|
||||
return AuditGeneratorUnitOfWork(session_factory)
|
||||
|
||||
AuditGeneratorOrchestrator(
|
||||
hubspot_deal_id=trigger.hubspot_deal_id,
|
||||
s3_client=s3_client,
|
||||
uow_factory=uow_factory,
|
||||
).run()
|
||||
17
applications/audit_generator/handler/Dockerfile
Normal file
17
applications/audit_generator/handler/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM public.ecr.aws/lambda/python:3.11
|
||||
|
||||
WORKDIR /var/task
|
||||
|
||||
COPY applications/audit_generator/handler/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY utilities/ utilities/
|
||||
COPY backend/ backend/
|
||||
COPY applications/ applications/
|
||||
COPY domain/ domain/
|
||||
COPY datatypes/ datatypes/
|
||||
COPY orchestration/ orchestration/
|
||||
COPY repositories/ repositories/
|
||||
COPY infrastructure/ infrastructure/
|
||||
|
||||
CMD ["applications.audit_generator.handler.handler"]
|
||||
7
applications/audit_generator/handler/requirements.txt
Normal file
7
applications/audit_generator/handler/requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
awslambdaric
|
||||
sqlalchemy==2.0.36
|
||||
sqlmodel
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic-settings==2.6.0
|
||||
boto3==1.35.44
|
||||
openpyxl
|
||||
|
|
@ -1,15 +1,26 @@
|
|||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class BulkUploadFinaliserTriggerBody(BaseModel):
|
||||
"""Trigger body for the bulk_upload_finaliser Lambda (ADR-0013).
|
||||
"""Trigger body for the bulk_upload_finaliser Lambda (ADR-0013, extended in
|
||||
ADR-0006).
|
||||
|
||||
Dispatched by the Next.js Finalise action via
|
||||
``POST /v1/bulk-uploads/trigger-finaliser``. ``s3_uri`` is the combiner output
|
||||
(``combined_output_s3_uri``) — the same address/UPRN CSV the old synchronous
|
||||
``/finalize`` route read.
|
||||
|
||||
v2 adds the inputs for the ``property_overrides`` write:
|
||||
- ``classifier_s3_uri``: the ``{uploadId}-classifier.csv`` (raw descriptions,
|
||||
joined to the combiner output by ``source_row_id``). ``None`` when no
|
||||
classifier columns were mapped → no overrides written.
|
||||
- ``multi_entry_ordering``: confirmed permutations keyed by entry-count
|
||||
(``{count: [file positions]}``). ``{}`` when not multi-entry.
|
||||
- ``column_mapping``: classifier category → source CSV header, so the
|
||||
finaliser knows which classifier-CSV column feeds each override_component.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
|
@ -20,3 +31,6 @@ class BulkUploadFinaliserTriggerBody(BaseModel):
|
|||
# bigint in the FE schema; Python int is unbounded so Pydantic stays simple.
|
||||
portfolio_id: int
|
||||
bulk_upload_id: UUID
|
||||
classifier_s3_uri: Optional[str] = None
|
||||
multi_entry_ordering: dict[str, list[int]] = Field(default_factory=dict)
|
||||
column_mapping: dict[str, str] = Field(default_factory=dict)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ from infrastructure.postgres.config import PostgresConfig
|
|||
from infrastructure.postgres.engine import commit_scope, make_engine, make_session
|
||||
from infrastructure.s3.csv_s3_client import CsvS3Client
|
||||
from infrastructure.s3.s3_uri import parse_s3_uri
|
||||
from infrastructure.landlord_overrides.landlord_override_reader_postgres_repository import (
|
||||
LandlordOverrideReaderPostgresRepository,
|
||||
)
|
||||
from orchestration.bulk_upload_finaliser_orchestrator import (
|
||||
BulkUploadFinaliserOrchestrator,
|
||||
)
|
||||
|
|
@ -33,6 +36,9 @@ from orchestration.task_orchestrator import TaskOrchestrator
|
|||
from repositories.bulk_upload.bulk_upload_status_writer_postgres import (
|
||||
BulkUploadStatusWriterPostgresRepository,
|
||||
)
|
||||
from repositories.property.property_override_postgres_repository import (
|
||||
PropertyOverridePostgresRepository,
|
||||
)
|
||||
from repositories.property.property_postgres_repository import (
|
||||
PropertyPostgresRepository,
|
||||
)
|
||||
|
|
@ -42,25 +48,44 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def _run(engine: Engine, trigger: BulkUploadFinaliserTriggerBody) -> int:
|
||||
bucket, _key = parse_s3_uri(trigger.s3_uri)
|
||||
|
||||
boto3_client: Any = boto3.client # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
boto_s3: Any = boto3_client("s3")
|
||||
|
||||
bucket, _key = parse_s3_uri(trigger.s3_uri)
|
||||
rows = CsvS3Client(boto_s3, bucket).read_rows(trigger.s3_uri)
|
||||
|
||||
# v2 (ADR-0006): the classifier CSV carries the raw descriptions, joined to the
|
||||
# combiner rows by `source_row_id`. Absent when no classifier columns were
|
||||
# mapped → the orchestrator simply writes no property_overrides.
|
||||
classifier_rows: list[dict[str, str]] | None = None
|
||||
if trigger.classifier_s3_uri:
|
||||
c_bucket, _c_key = parse_s3_uri(trigger.classifier_s3_uri)
|
||||
classifier_rows = CsvS3Client(boto_s3, c_bucket).read_rows(
|
||||
trigger.classifier_s3_uri
|
||||
)
|
||||
|
||||
session = make_session(engine)
|
||||
try:
|
||||
orchestrator = BulkUploadFinaliserOrchestrator(
|
||||
# Write-only path: no EpcRepository needed for inserts.
|
||||
property_repo=PropertyPostgresRepository(session),
|
||||
status_writer=BulkUploadStatusWriterPostgresRepository(session),
|
||||
property_override_repo=PropertyOverridePostgresRepository(session),
|
||||
landlord_override_reader=LandlordOverrideReaderPostgresRepository(session),
|
||||
)
|
||||
# Atomic finalise: the orchestrator inserts properties and marks `complete`
|
||||
# via its injected writers; the transaction here makes them land together —
|
||||
# a failure in either rolls back both, leaving the row for the failure path.
|
||||
# Atomic finalise: the orchestrator inserts properties, writes the
|
||||
# property_overrides, and marks `complete` via its injected writers; the
|
||||
# transaction here makes them land together — a failure in any (including an
|
||||
# unresolved description, which raises) rolls back all, leaving the row for
|
||||
# the failure path.
|
||||
with commit_scope(session):
|
||||
inserted = orchestrator.finalise(
|
||||
rows, trigger.portfolio_id, trigger.task_id
|
||||
rows,
|
||||
trigger.portfolio_id,
|
||||
trigger.task_id,
|
||||
classifier_rows=classifier_rows,
|
||||
multi_entry_ordering=trigger.multi_entry_ordering,
|
||||
column_mapping=trigger.column_mapping,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import re
|
||||
from typing import Optional
|
||||
|
||||
from datatypes.magicplan.api.response import PlanSummary
|
||||
from domain.magicplan.api.response import PlanSummary
|
||||
|
||||
_UK_POSTCODE_RE = re.compile(r"[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}", re.IGNORECASE)
|
||||
|
||||
50
applications/magic_plan/handler.py
Normal file
50
applications/magic_plan/handler.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import os
|
||||
import boto3
|
||||
from typing import Any, Optional
|
||||
|
||||
from applications.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
|
||||
from infrastructure.magic_plan.config import MagicPlanConfig
|
||||
from infrastructure.magic_plan.magic_plan_client import MagicPlanClient
|
||||
from infrastructure.s3.s3_client import S3Client
|
||||
from orchestration.magic_plan_orchestrator import MagicPlanOrchestrator
|
||||
from domain.magicplan.models import Plan
|
||||
from domain.tasks.tasks import Source
|
||||
from utilities.aws_lambda.task_handler import task_handler
|
||||
from utilities.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
@task_handler(task_source="magic_plan", source=Source.HUBSPOT_DEAL)
|
||||
def handler(body: dict[str, Any], context: Any) -> Optional[str]:
|
||||
config = MagicPlanConfig.from_env(os.environ)
|
||||
payload = MagicPlanTriggerRequest.model_validate(body)
|
||||
client = MagicPlanClient(
|
||||
customer_id=config.customer_id,
|
||||
api_key=config.api_key,
|
||||
)
|
||||
|
||||
boto3_client: Any = boto3.client # type: ignore
|
||||
boto_s3: Any = boto3_client("s3")
|
||||
s3_client = S3Client(
|
||||
boto_s3_client=boto_s3, bucket="retrofit-energy-assessments-dev"
|
||||
)
|
||||
# TODO: read s3_bucket from env var so staging/prod use the correct bucket
|
||||
|
||||
plan: Optional[Plan] = MagicPlanOrchestrator(client, s3_client).run(payload)
|
||||
if plan:
|
||||
logger.info("Saved MagicPlan plan uid=%s", plan.uid)
|
||||
return plan.uid
|
||||
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
event = {
|
||||
"Records": [
|
||||
{
|
||||
"body": '{"address": "2 Laburnum Way Bromley BR2 8BZ", "hubspot_deal_id": "local-test-deal"}',
|
||||
}
|
||||
]
|
||||
}
|
||||
handler(event, None)
|
||||
17
applications/magic_plan/handler/Dockerfile
Normal file
17
applications/magic_plan/handler/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM public.ecr.aws/lambda/python:3.11
|
||||
|
||||
WORKDIR /var/task
|
||||
|
||||
COPY applications/magic_plan/handler/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY utilities/ utilities/
|
||||
COPY backend/ backend/
|
||||
COPY applications/ applications/
|
||||
COPY domain/ domain/
|
||||
COPY datatypes/ datatypes/
|
||||
COPY orchestration/ orchestration/
|
||||
COPY repositories/ repositories/
|
||||
COPY infrastructure/ infrastructure/
|
||||
|
||||
CMD ["applications.magic_plan.handler.handler"]
|
||||
|
|
@ -4,7 +4,7 @@ services:
|
|||
ecmk-fetcher-lambda:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: backend/magic_plan/handler/Dockerfile
|
||||
dockerfile: applications/magic_plan/handler/Dockerfile
|
||||
ports:
|
||||
- "9000:8080"
|
||||
env_file:
|
||||
36
applications/magic_plan/local_handler/invoke_local_lambda.py
Normal file
36
applications/magic_plan/local_handler/invoke_local_lambda.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/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": [
|
||||
{
|
||||
"messageId": "test-message-id",
|
||||
"body": json.dumps(
|
||||
{
|
||||
# "task_id": "00000000-0000-0000-0000-000000000001",
|
||||
# "sub_task_id": "00000000-0000-0000-0000-000000000002",
|
||||
"address": "63 Dunkery Road, Wythenshawe, M22 0WR | EPC",
|
||||
"hubspot_deal_id": "501851906250",
|
||||
}
|
||||
# {
|
||||
# "task_id": "00000000-0000-0000-0000-000000000001",
|
||||
# "sub_task_id": "00000000-0000-0000-0000-000000000002",
|
||||
# "address": "33 Wallaby Way, Sydney",
|
||||
# "hubspot_deal_id": "123456789",
|
||||
# }
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(LAMBDA_URL, json=payload)
|
||||
|
||||
print("Status code:", response.status_code)
|
||||
print("Response:")
|
||||
print(response.text)
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run MagicPlanOrchestrator directly, bypassing @subtask_handler.
|
||||
|
||||
Loads credentials from the repo-root .env file so no DB task/subtask rows
|
||||
are needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import boto3
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from utilities.logger import setup_logger
|
||||
|
||||
setup_logger()
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
load_dotenv(REPO_ROOT / ".env")
|
||||
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from infrastructure.magic_plan.config import MagicPlanConfig
|
||||
from infrastructure.magic_plan.magic_plan_client import MagicPlanClient
|
||||
from infrastructure.s3.s3_client import S3Client
|
||||
from orchestration.magic_plan_orchestrator import MagicPlanOrchestrator
|
||||
from applications.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
|
||||
|
||||
ADDRESS = "20 Larch Way, Bromley, BR2 8DU | Retrofit Assessment"
|
||||
HUBSPOT_DEAL_ID = "500328089833"
|
||||
# ADDRESS = "33 Wallaby Way, Sydney"
|
||||
# HUBSPOT_DEAL_ID = "123456789"
|
||||
|
||||
config = MagicPlanConfig.from_env(os.environ)
|
||||
client = MagicPlanClient(customer_id=config.customer_id, api_key=config.api_key)
|
||||
|
||||
boto3_client = boto3.client # type: ignore[attr-defined]
|
||||
s3_client = S3Client(
|
||||
boto_s3_client=boto3_client("s3"),
|
||||
bucket="retrofit-energy-assessments-dev",
|
||||
)
|
||||
|
||||
request = MagicPlanTriggerRequest(address=ADDRESS, hubspot_deal_id=HUBSPOT_DEAL_ID)
|
||||
|
||||
print(f"Running MagicPlanOrchestrator for: {ADDRESS!r}")
|
||||
plan = MagicPlanOrchestrator(client, s3_client).run(request)
|
||||
print(f"Done")
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from datatypes.magicplan.api.response import PlanSummary
|
||||
from backend.magic_plan.address_matcher import find_matching_plan, _extract_postcode
|
||||
from domain.magicplan.api.response import PlanSummary
|
||||
from applications.magic_plan.address_matcher import find_matching_plan, _extract_postcode
|
||||
|
||||
|
||||
def _make_plan(
|
||||
|
|
@ -11,7 +11,6 @@ from backend.app.db.models.portfolio import PropertyModel, PropertyDetailsEpcMod
|
|||
from backend.app.db.models.recommendations import (
|
||||
Recommendation,
|
||||
PlanModel,
|
||||
PlanRecommendations,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -124,20 +123,15 @@ class Outputs:
|
|||
return plans_data
|
||||
|
||||
def get_recommendations_from_db(self, plan_ids):
|
||||
# Get recommendations through PlanRecommendations for those plans and that are default
|
||||
# Get default recommendations for those plans, linked by recommendation.plan_id
|
||||
recommendations_query = (
|
||||
self.session.query(Recommendation, PlanModel.scenario_id)
|
||||
.join(
|
||||
PlanRecommendations,
|
||||
Recommendation.id == PlanRecommendations.recommendation_id,
|
||||
)
|
||||
.join(
|
||||
PlanModel,
|
||||
PlanModel.id
|
||||
== PlanRecommendations.plan_id, # Join with Plan to access scenario_id
|
||||
PlanModel.id == Recommendation.plan_id, # access scenario_id
|
||||
)
|
||||
.filter(
|
||||
PlanRecommendations.plan_id.in_(plan_ids),
|
||||
Recommendation.plan_id.in_(plan_ids),
|
||||
Recommendation.default == True, # Filtering for default recommendations
|
||||
)
|
||||
.all()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
FROM public.ecr.aws/lambda/python:3.10
|
||||
# 3.11 required: domain.modelling.measure_type uses enum.StrEnum (added in 3.11).
|
||||
FROM public.ecr.aws/lambda/python:3.11
|
||||
# FROM python:3.11.10-bullseye
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -510,6 +510,17 @@ def handler(event, context, local=False):
|
|||
# Create results DataFrame
|
||||
result_df = pd.DataFrame(results_data)
|
||||
|
||||
# The UPRN is integer-valued, but the no-match rows append None, so the
|
||||
# mixed column lands as float64 and would serialise as "100020933699.0".
|
||||
# Coerce to a nullable integer so it round-trips as "100020933699"
|
||||
# (empty when missing) — the form the finaliser and the combined-results
|
||||
# UI expect. `to_numeric(errors="coerce")` also folds the
|
||||
# "invalid postcode" sentinel + blanks to NA (read back as missing).
|
||||
if "address2uprn_uprn" in result_df.columns:
|
||||
result_df["address2uprn_uprn"] = pd.to_numeric(
|
||||
result_df["address2uprn_uprn"], errors="coerce"
|
||||
).astype("Int64")
|
||||
|
||||
# Save results to S3
|
||||
try:
|
||||
save_results_to_s3(result_df, str(task_id), str(subtask_id))
|
||||
|
|
|
|||
370
backend/address2UPRN/tests/backup_test_data.csv
Normal file
370
backend/address2UPRN/tests/backup_test_data.csv
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
User Input,Postcode,Manual UPRN Code
|
||||
47 The Fairway,OX16 0RR,100120771697
|
||||
11 REGENT COURT,SL1 3LG,100081041562
|
||||
3/137a Windmill Road,TW8 9NH,100021516998
|
||||
Flat 33,SW18 4BE,100023328943
|
||||
FLAT 1 Brendon Grove,N2 8JE,200013412
|
||||
Flat 15,KT8 2NE,100062123759
|
||||
FLAT 5 Stonehill Road,W4 3AH,100021589829
|
||||
10 Douglas Court,SL7 1UQ,100081278099
|
||||
1 Windmill Road,HP17 8JA,766034606
|
||||
31 Denewood,HP13 7LH,100081095964
|
||||
"10, Greenways Drive",TW4 5DD,10091597009
|
||||
Flat 10,W4 3AH,"100021589834"
|
||||
Flat 11,TW4 5DD,10091597010
|
||||
Flat 11,W4 3AH,100021589835
|
||||
"12, Greenways Drive",TW4 5DD,10091597011
|
||||
"Flat 12, Forbes House",W4 3AH,100021589836
|
||||
FLAT 1 Goodstone Court,HA1 4FL,10070269053
|
||||
Flat 13,TW4 5DD,10091597012
|
||||
Flat 13,W4 3AH,100021589837
|
||||
Flat 14,TW4 5DD,10091597013
|
||||
Flat 14,W4 3AH,100021589838
|
||||
Flat 15,TW4 5DD,10091597014
|
||||
Flat 15,W4 3AH,100021589839
|
||||
Flat 16,TW4 5DD,"10091597015"
|
||||
Flat 16,W4 3AH,100021589840
|
||||
Flat 17,TW4 5DD,10091597016
|
||||
Flat 17,W4 3AH,100021589841
|
||||
Flat 18,TW4 5DD,10091597017
|
||||
Flat 19,W4 3AH,100021589843
|
||||
Flat 20,W4 3AH,100021589844
|
||||
Flat 21,W4 3AH,100021589845
|
||||
Flat 22,W4 3AH,100021589846
|
||||
FLAT 2 Goodstone Court,HA1 4FL,10070269054
|
||||
Flat 23,W4 3AH,100021589847
|
||||
Flat 24,W4 3AH,100021589848
|
||||
"30c, Bosanquet Close",UB8 3PE,100021475316
|
||||
"30e, Bosanquet Close",UB8 3PE,100021475318
|
||||
FLAT 3 Goodstone Court,HA1 4FL,10070269055
|
||||
FLAT 4 Goodstone Court,HA1 4FL,10070269056
|
||||
FLAT 5 Goodstone Court,HA1 4FL,10070269057
|
||||
FLAT 6 Goodstone Court,HA1 4FL,10070269058
|
||||
FLAT 7 Goodstone Court,HA1 4FL,10070269059
|
||||
FLAT 8 Goodstone Court,HA1 4FL,10070269060
|
||||
FLAT 9 Goodstone Court,HA1 4FL,10070269061
|
||||
FLAT 10 Goodstone Court,HA1 4FL,10070269062
|
||||
FLAT 11 Goodstone Court,HA1 4FL,10070269063
|
||||
FLAT 12 Goodstone Court,HA1 4FL,10070269064
|
||||
FLAT 13 Goodstone Court,HA1 4FL,10070269065
|
||||
FLAT 14 Goodstone Court,HA1 4FL,10070269066
|
||||
FLAT 15 Goodstone Court,HA1 4FL,10070269067
|
||||
FLAT 16 Goodstone Court,HA1 4FL,10070269068
|
||||
FLAT 17 Goodstone Court,HA1 4FL,10070269069
|
||||
FLAT 18 Goodstone Court,HA1 4FL,10070269070
|
||||
FLAT 19 Goodstone Court,HA1 4FL,10070269071
|
||||
FLAT 20 Goodstone Court,HA1 4FL,10070269072
|
||||
FLAT 21 Goodstone Court,HA1 4FL,10070269073
|
||||
FLAT 22 Goodstone Court,HA1 4FL,10070269074
|
||||
FLAT 23 Goodstone Court,HA1 4FL,10070269075
|
||||
FLAT 24 Goodstone Court,HA1 4FL,10070269076
|
||||
FLAT 25 Goodstone Court,HA1 4FL,10070269077
|
||||
FLAT 26 Goodstone Court,HA1 4FL,10070269078
|
||||
FLAT 27 Goodstone Court,HA1 4FL,10070269079
|
||||
FLAT 28 Goodstone Court,HA1 4FL,10070269080
|
||||
FLAT 29 Goodstone Court,HA1 4FL,10070269081
|
||||
FLAT 30 Goodstone Court,HA1 4FL,10070269082
|
||||
FLAT 31 Goodstone Court,HA1 4FL,10070269083
|
||||
FLAT 32 Goodstone Court,HA1 4FL,10070269084
|
||||
FLAT 33 Goodstone Court,HA1 4FL,10070269085
|
||||
FLAT 34 Goodstone Court,HA1 4FL,10070269086
|
||||
FLAT 35 Goodstone Court,HA1 4FL,10070269087
|
||||
FLAT 36 Goodstone Court,HA1 4FL,10070269088
|
||||
FLAT 37 Goodstone Court,HA1 4FL,10070269089
|
||||
FLAT 38 Goodstone Court,HA1 4FL,10070269090
|
||||
FLAT 39 Goodstone Court,HA1 4FL,10070269091
|
||||
FLAT 40 Goodstone Court,HA1 4FL,10070269092
|
||||
FLAT 41 Goodstone Court,HA1 4FL,10070269093
|
||||
FLAT 42 Goodstone Court,HA1 4FL,10070269094
|
||||
FLAT 43 Goodstone Court,HA1 4FL,10070269095
|
||||
"13 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778260
|
||||
"14 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778259
|
||||
"15 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778258
|
||||
"16 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778263
|
||||
"17 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778262
|
||||
"18 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778261
|
||||
"19 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778266
|
||||
"20 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778265
|
||||
"21 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778264
|
||||
90a Murray Road,W5 4DA,12135293
|
||||
"Flat 1, 6 Wolverton Gardens",W5 3LJ,"12119972"
|
||||
"1, Monsted House",UB1 1FG,12189944
|
||||
"10, Monsted House",UB1 1FG,12189953
|
||||
"20, Monsted House",UB1 1FG,12189963
|
||||
"2, Monsted House",UB1 1FG,12189945
|
||||
"3, Monsted House",UB1 1FG,12189946
|
||||
"4, Monsted House",UB1 1FG,12189947
|
||||
"5, Monsted House",UB1 1FG,12189948
|
||||
"6, Monsted House",UB1 1FG,12189949
|
||||
"7, Monsted House",UB1 1FG,12189950
|
||||
"8, Monsted House",UB1 1FG,12189951
|
||||
"9, Monsted House",UB1 1FG,12189952
|
||||
"1 Cullis House, 1, Accolade Avenue",UB1 1FH,12189904
|
||||
"2 Cullis House, 1, Accolade Avenue",UB1 1FH,12189905
|
||||
"3 Cullis House, 1, Accolade Avenue",UB1 1FH,12189906
|
||||
"4 Cullis House, 1, Accolade Avenue",UB1 1FH,12189907
|
||||
"5 Cullis House, 1, Accolade Avenue",UB1 1FH,12189908
|
||||
"6 Cullis House, 1, Accolade Avenue",UB1 1FH,12189909
|
||||
1 Genteel House Samara Drive,UB1 1FJ,12189835
|
||||
2 Genteel House Samara Drive,UB1 1FJ,12189836
|
||||
3 Genteel House Samara Drive,UB1 1FJ,12189837
|
||||
4 Genteel House Samara Drive,UB1 1FJ,12189838
|
||||
5 Genteel House Samara Drive,UB1 1FJ,12189839
|
||||
6 Genteel House Samara Drive,UB1 1FJ,12189840
|
||||
7 Genteel House Samara Drive,UB1 1FJ,12189841
|
||||
8 Genteel House Samara Drive,UB1 1FJ,12189842
|
||||
9 Genteel House Samara Drive,UB1 1FJ,12189843
|
||||
10 Genteel House Samara Drive,UB1 1FJ,12189844
|
||||
1 ASH TREE HOUSE,SE5 0TE,None
|
||||
"Flat 1 Ash Tree House, 2, Thompson Avenue",SE5 0TE,10009803979
|
||||
3 ASH TREE HOUSE,SE5 0TE,None
|
||||
Flat 3 ASH TREE HOUSE,SE5 0TE,10009803981
|
||||
5 ASH TREE HOUSE,SE5 0TE,None
|
||||
Flat 5 ASH TREE HOUSE,SE5 0TE,10009803983
|
||||
Flat 8 ASH TREE HOUSE,SE5 0TE,10009803986
|
||||
8 ASH TREE HOUSE,SE5 0TE,None
|
||||
Flat 12 ASH TREE HOUSE,SE5 0TE,10009803990
|
||||
12 ASH TREE HOUSE,SE5 0TE,None
|
||||
FLAT 1 599 HARROW ROAD,W10 4RA,217113930
|
||||
FLAT 2 599 HARROW ROAD,W10 4RA,217113931
|
||||
FLAT 3 599 HARROW ROAD,W10 4RA,None
|
||||
FLAT 4 599 HARROW ROAD,W10 4RA,None
|
||||
FLAT 5 599 HARROW ROAD,W10 4RA,217113934
|
||||
FLAT 6 599 HARROW ROAD,W10 4RA,None
|
||||
FLAT 7 599 HARROW ROAD,W10 4RA,None
|
||||
FLAT 8 599 HARROW ROAD,W10 4RA,None
|
||||
"Flat 1, Ohio Building",SE13 7RX,10023226256
|
||||
"Flat 2, Ohio Building",SE13 7RX,10023226257
|
||||
"Apartment 1 Block B, 105, Benwell Road",N7 7BW,10012792307
|
||||
"Apartment 2 Block B, 105, Benwell Road",N7 7BW,10012792308
|
||||
"Apartment 3 Block B, 105, Benwell Road",N7 7BW,10012792309
|
||||
"Apartment 4 Block B, 105, Benwell Road",N7 7BW,10012792310
|
||||
"Apartment 5 Block B, 105, Benwell Road",N7 7BW,10012792311
|
||||
"Apartment 6 Block B, 105, Benwell Road",N7 7BW,10012792312
|
||||
"Apartment 7 Block B, 105, Benwell Road",N7 7BW,10012792313
|
||||
"Apartment 8 Block B, 105, Benwell Road",N7 7BW,10012792314
|
||||
"Apartment 9 Block B, 105, Benwell Road",N7 7BW,10012792315
|
||||
"Apartment 10 Block B, 105, Benwell Road",N7 7BW,10012792316
|
||||
"Apartment 11 Block B, 105, Benwell Road",N7 7BW,10012792317
|
||||
"Apartment 12 Block B, 105, Benwell Road",N7 7BW,10012792318
|
||||
"Apartment 13 Block B, 105, Benwell Road",N7 7BW,10012792319
|
||||
"Apartment 1 Block D, 32, Hornsey Road",N7 7AT,10012792366
|
||||
"Apartment 2 Block D, 32, Hornsey Road",N7 7AT,10012792367
|
||||
"Apartment 3 Block D, 32, Hornsey Road",N7 7AT,10012792368
|
||||
"Apartment 4 Block D, 32, Hornsey Road",N7 7AT,10012792369
|
||||
"Apartment 5 Block D, 32, Hornsey Road",N7 7AT,10012792370
|
||||
"Apartment 6 Block D, 32, Hornsey Road",N7 7AT,"10012792371"
|
||||
"Apartment 7 Block D, 32, Hornsey Road",N7 7AT,10012792372
|
||||
"Apartment 8 Block D, 32, Hornsey Road",N7 7AT,10012792373
|
||||
"Apartment 9 Block D, 32, Hornsey Road",N7 7AT,10012792374
|
||||
"Apartment 10 Block D, 32, Hornsey Road",N7 7AT,10012792375
|
||||
"Apartment 11 Block D, 32, Hornsey Road",N7 7AT,10012792376
|
||||
"Apartment 12 Block D, 32, Hornsey Road",N7 7AT,10012792377
|
||||
"Apartment 13 Block D, 32, Hornsey Road",N7 7AT,10012792378
|
||||
"Apartment 14 Block D, 32, Hornsey Road",N7 7AT,10012792379
|
||||
"Apartment 15 Block D, 32, Hornsey Road",N7 7AT,10012792380
|
||||
"Apartment 16 Block D, 32, Hornsey Road",N7 7AT,"10012792381"
|
||||
"Apartment 17Block D, 32, Hornsey Road",N7 7AT,10012792382
|
||||
"Apartment 18 Block D, 32, Hornsey Road",N7 7AT,10012792383
|
||||
24b Honley Road,SE6 2HZ,None
|
||||
FLAT B 158 LEAHURST ROAD,SE13 5NL,100021976974
|
||||
2 COLLEGE HOUSE,CM7 1JS,None
|
||||
3 COLLEGE HOUSE,CM7 1JS,None
|
||||
1 Anita Street,M4 5DU,None
|
||||
2 Anita Street,M4 5DU,77123061
|
||||
5 Anita Street,M4 5DU,77123081
|
||||
6 Anita Street,M4 5DU,77123082
|
||||
8 Anita Street,M4 5DU,None
|
||||
9 Anita Street,M4 5DU,None
|
||||
10 Anita Street,M4 5DU,77123051
|
||||
12 Anita Street,M4 5DU,77123053
|
||||
19 Anita Street,M4 5DU,None
|
||||
22 Anita Street,M4 5DU,None
|
||||
26 Anita Street,M4 5DU,77123068
|
||||
28 Anita Street,M4 5DU,None
|
||||
30 Anita Street,M4 5DU,None
|
||||
32 Anita Street,M4 5DU,None
|
||||
33 Anita Street,M4 5DU,77123076
|
||||
34 Anita Street,M4 5DU,None
|
||||
35 Anita Street,M4 5DU,77123078
|
||||
36 Anita Street,M4 5DU,77123079
|
||||
23 George Leigh Street,M4 5DR,77123171
|
||||
25 George Leigh Street,M4 5DR,None
|
||||
35 George Leigh Street,M4 5DR,77123177
|
||||
39 George Leigh Street,M4 5DR,77123179
|
||||
41 George Leigh Street,M4 5DR,None
|
||||
43 George Leigh Street,M4 5DR,None
|
||||
49 George Leigh Street,M4 5DR,None
|
||||
51 George Leigh Street,M4 5DR,77123185
|
||||
55 George Leigh Street,M4 5DR,None
|
||||
57 George Leigh Street,M4 5DR,None
|
||||
"1a, Victoria Square",M4 5DX,77211153
|
||||
2a Victoria Square ,M4 5DX,None
|
||||
"4a, Victoria Square",M4 5DX,77211155
|
||||
5a Victoria Square,M4 5DX,77211156
|
||||
6a Victoria Square,M4 5DX,77211157
|
||||
7a Victoria Square,M4 5DX,77211158
|
||||
8a Victoria Square,M4 5DX,77211159
|
||||
9a Victoria Square,M4 5DX,77211160
|
||||
10a Victoria Square,M4 5DX,77211161
|
||||
11a Victoria Square,M4 5DX,77211162
|
||||
12a Victoria Square,M4 5DX,77211163
|
||||
13a Victoria Square,M4 5DX,77211164
|
||||
14a Victoria Square,M4 5DX,77211165
|
||||
15a Victoria Square,M4 5DX,77211166
|
||||
16a Victoria Square,M4 5DX,77211167
|
||||
17a Victoria Square,M4 5DX,77211168
|
||||
18a Victoria Square,M4 5DX,77211169
|
||||
19a Victoria Square,M4 5DX,77211170
|
||||
20a Victoria Square,M4 5DX,77211171
|
||||
21a Victoria Square,M4 5DY,77211172
|
||||
22a Victoria Square,M4 5DY,None
|
||||
23a Victoria Square,M4 5DY,77211174
|
||||
24a Victoria Square,M4 5DY,77211175
|
||||
25a Victoria Square,M4 5DY,77211176
|
||||
26a Victoria Square,M4 5DY,77211177
|
||||
27a Victoria Square,M4 5DY,77211178
|
||||
28a Victoria Square,M4 5DY,None
|
||||
29a Victoria Square,M4 5DY,77211180
|
||||
30a Victoria Square,M4 5DY,77211181
|
||||
31a Victoria Square,M4 5DY,77211182
|
||||
32a Victoria Square,M4 5DY,77211183
|
||||
33a Victoria Square,M4 5DY,77211184
|
||||
34a Victoria Square,M4 5DY,77211185
|
||||
35a Victoria Square,M4 5DY,None
|
||||
36a Victoria Square,M4 5DY,77211187
|
||||
37a Victoria Square,M4 5DY,77211188
|
||||
38a Victoria Square,M4 5DY,77211189
|
||||
39a Victoria Square,M4 5DY,77211190
|
||||
40a Victoria Square,M4 5DY,None
|
||||
41a Victoria Square,M4 5DY,77211192
|
||||
42a Victoria Square,M4 5DY,77211193
|
||||
43a Victoria Square,M4 5DY,77211194
|
||||
44a Victoria Square,M4 5DY,77211195
|
||||
45a Victoria Square,M4 5DY,77211196
|
||||
46a Victoria Square,M4 5DY,77211197
|
||||
47a Victoria Square,M4 5DY,77211198
|
||||
48a Victoria Square,M4 5DY,77211199
|
||||
49a Victoria Square,M4 5DY,77211200
|
||||
50a Victoria Square,M4 5DY,77211201
|
||||
51a Victoria Square,M4 5DY,77211202
|
||||
52a Victoria Square,M4 5DY,77211203
|
||||
53a Victoria Square,M4 5DY,77211204
|
||||
54a Victoria Square,M4 5DY,77211205
|
||||
55a Victoria Square,M4 5DY,77211206
|
||||
56a Victoria Square,M4 5DZ,77211207
|
||||
57a Victoria Square,M4 5DZ,None
|
||||
58a Victoria Square,M4 5DZ,77211209
|
||||
59a Victoria Square,M4 5DZ,77211210
|
||||
60a Victoria Square,M4 5DZ,77211211
|
||||
61a Victoria Square,M4 5DZ,77211212
|
||||
62a Victoria Square,M4 5DZ,77211213
|
||||
63a Victoria Square,M4 5DZ,None
|
||||
64a Victoria Square,M4 5DZ,77211215
|
||||
65a Victoria Square,M4 5DZ,77211216
|
||||
66a Victoria Square,M4 5DZ,None
|
||||
67a Victoria Square,M4 5DZ,None
|
||||
68a Victoria Square,M4 5DZ,77211219
|
||||
69a Victoria Square,M4 5DZ,77211220
|
||||
70a Victoria Square,M4 5DZ,77211221
|
||||
71a Victoria Square,M4 5DZ,77211222
|
||||
72a Victoria Square,M4 5DZ,77211223
|
||||
73a Victoria Square,M4 5DZ,77211224
|
||||
74a Victoria Square,M4 5DZ,None
|
||||
75a Victoria Square,M4 5DZ,77211226
|
||||
76a Victoria Square,M4 5DZ,77211227
|
||||
77a Victoria Square,M4 5DZ,None
|
||||
78a Victoria Square,M4 5DZ,77211229
|
||||
79a Victoria Square,M4 5DZ,77211230
|
||||
80a Victoria Square,M4 5DZ,77211231
|
||||
81a Victoria Square,M4 5DZ,77211232
|
||||
82 Victoria Square,M4 5DZ,None
|
||||
82a Victoria Square,M4 5DZ,77211233
|
||||
83a Victoria Square,M4 5DZ,77211234
|
||||
84a Victoria Square,M4 5DZ,None
|
||||
85a Victoria Square,M4 5DZ,77211236
|
||||
86a Victoria Square,M4 5DZ,77211237
|
||||
87a Victoria Square,M4 5DZ,77211238
|
||||
88a Victoria Square,M4 5DZ,None
|
||||
89a Victoria Square,M4 5DZ,77211240
|
||||
90a Victoria Square,M4 5DZ,77211241
|
||||
91a Victoria Square,M4 5DZ,77211242
|
||||
92a Victoria Square,M4 5DZ,77211243
|
||||
93a Victoria Square,M4 5EA,77211244
|
||||
94a Victoria Square,M4 5EA,None
|
||||
95a Victoria Square,M4 5EA,77211246
|
||||
96a Victoria Square,M4 5EA,77211247
|
||||
97a Victoria Square,M4 5EA,77211248
|
||||
98a Victoria Square,M4 5EA,77211249
|
||||
99a Victoria Square,M4 5EA,77211250
|
||||
100a Victoria Square,M4 5EA,77211251
|
||||
101a Victoria Square,M4 5EA,None
|
||||
102a Victoria Square,M4 5EA,None
|
||||
103a Victoria Square,M4 5EA,77211254
|
||||
104a Victoria Square,M4 5EA,77211255
|
||||
105a Victoria Square,M4 5EA,None
|
||||
106a Victoria Square,M4 5EA,77211257
|
||||
107a Victoria Square,M4 5EA,77211258
|
||||
108a Victoria Square,M4 5EA,77211259
|
||||
109a Victoria Square,M4 5EA,77211260
|
||||
110a Victoria Square,M4 5EA,77211261
|
||||
111a Victoria Square,M4 5EA,77211262
|
||||
112a Victoria Square,M4 5EA,None
|
||||
113a Victoria Square,M4 5EA,77211264
|
||||
114a Victoria Square,M4 5EA,77211265
|
||||
115a Victoria Square,M4 5EA,77211266
|
||||
116a Victoria Square,M4 5EA,77211267
|
||||
117a Victoria Square,M4 5EA,None
|
||||
118a Victoria Square,M4 5EA,None
|
||||
119a Victoria Square,M4 5EA,77211270
|
||||
120a Victoria Square,M4 5EA,77211271
|
||||
121a Victoria Square,M4 5EA,77211272
|
||||
122a Victoria Square,M4 5EA,77211273
|
||||
123a Victoria Square,M4 5EA,77211274
|
||||
124a Victoria Square,M4 5EA,None
|
||||
125a Victoria Square,M4 5EA,77211276
|
||||
126a Victoria Square,M4 5EA,77211277
|
||||
127a Victoria Square,M4 5EA,77211278
|
||||
128a Victoria Square,M4 5EA,77211279
|
||||
129a Victoria Square,M4 5EA,77211280
|
||||
130a Victoria Square,M4 5FA,77211281
|
||||
131a Victoria Square,M4 5FA,77211282
|
||||
132a Victoria Square,M4 5FA,77211283
|
||||
133a Victoria Square,M4 5FA,None
|
||||
134a Victoria Square,M4 5FA,77211285
|
||||
135a Victoria Square,M4 5FA,77211286
|
||||
136a Victoria Square,M4 5FA,77211287
|
||||
137a Victoria Square,M4 5FA,77211288
|
||||
138a Victoria Square,M4 5FA,77211289
|
||||
139a Victoria Square,M4 5FA,77211290
|
||||
140a Victoria Square,M4 5FA,77211291
|
||||
141a Victoria Square,M4 5FA,77211292
|
||||
142a Victoria Square,M4 5FA,77211293
|
||||
143a Victoria Square,M4 5FA,77211294
|
||||
144a Victoria Square,M4 5FA,77211295
|
||||
145a Victoria Square,M4 5FA,None
|
||||
146a Victoria Square,M4 5FA,77211297
|
||||
147a Victoria Square,M4 5FA,77211298
|
||||
148a Victoria Square,M4 5FA,77211299
|
||||
149a Victoria Square,M4 5FA,77211300
|
||||
150a Victoria Square,M4 5FA,77211301
|
||||
151a Victoria Square,M4 5FA,None
|
||||
152a Victoria Square,M4 5FA,77211303
|
||||
153a Victoria Square,M4 5FA,None
|
||||
154a Victoria Square,M4 5FA,77211305
|
||||
155a Victoria Square,M4 5FA,None
|
||||
156a Victoria Square,M4 5FA,77211307
|
||||
157a Victoria Square,M4 5FA,77211308
|
||||
158a Victoria Square,M4 5FA,77211309
|
||||
159a Victoria Square,M4 5FA,None
|
||||
160a Victoria Square,M4 5FA,77211311
|
||||
161a Victoria Square,M4 5FA,None
|
||||
162a Victoria Square,M4 5FA,None
|
||||
163a Victoria Square,M4 5FA,77211314
|
||||
164a Victoria Square,M4 5FA,77211315
|
||||
165a Victoria Square,M4 5FA,77211316
|
||||
166a Victoria Square,M4 5FA,None
|
||||
"FLAT 3; 42 MORETON ROAD, SOUTH CROYDON, SURREY",CR2 7DL,None
|
||||
71A Stoneleigh Avenue,NE12 8NP,None
|
||||
71B Stoneleigh Avenue,NE12 8NP,None
|
||||
71 Stoneleigh Avenue,NE12 8NP,47086009
|
||||
|
|
|
@ -3,368 +3,3 @@ User Input,Postcode,Manual UPRN Code
|
|||
11 REGENT COURT,SL1 3LG,100081041562
|
||||
3/137a Windmill Road,TW8 9NH,100021516998
|
||||
Flat 33,SW18 4BE,100023328943
|
||||
FLAT 1 Brendon Grove,N2 8JE,200013412
|
||||
Flat 15,KT8 2NE,100062123759
|
||||
FLAT 5 Stonehill Road,W4 3AH,100021589829
|
||||
10 Douglas Court,SL7 1UQ,100081278099
|
||||
1 Windmill Road,HP17 8JA,766034606
|
||||
31 Denewood,HP13 7LH,100081095964
|
||||
"10, Greenways Drive",TW4 5DD,10091597009
|
||||
Flat 10,W4 3AH,"100021589834"
|
||||
Flat 11,TW4 5DD,10091597010
|
||||
Flat 11,W4 3AH,100021589835
|
||||
"12, Greenways Drive",TW4 5DD,10091597011
|
||||
"Flat 12, Forbes House",W4 3AH,100021589836
|
||||
FLAT 1 Goodstone Court,HA1 4FL,10070269053
|
||||
Flat 13,TW4 5DD,10091597012
|
||||
Flat 13,W4 3AH,100021589837
|
||||
Flat 14,TW4 5DD,10091597013
|
||||
Flat 14,W4 3AH,100021589838
|
||||
Flat 15,TW4 5DD,10091597014
|
||||
Flat 15,W4 3AH,100021589839
|
||||
Flat 16,TW4 5DD,"10091597015"
|
||||
Flat 16,W4 3AH,100021589840
|
||||
Flat 17,TW4 5DD,10091597016
|
||||
Flat 17,W4 3AH,100021589841
|
||||
Flat 18,TW4 5DD,10091597017
|
||||
Flat 19,W4 3AH,100021589843
|
||||
Flat 20,W4 3AH,100021589844
|
||||
Flat 21,W4 3AH,100021589845
|
||||
Flat 22,W4 3AH,100021589846
|
||||
FLAT 2 Goodstone Court,HA1 4FL,10070269054
|
||||
Flat 23,W4 3AH,100021589847
|
||||
Flat 24,W4 3AH,100021589848
|
||||
"30c, Bosanquet Close",UB8 3PE,100021475316
|
||||
"30e, Bosanquet Close",UB8 3PE,100021475318
|
||||
FLAT 3 Goodstone Court,HA1 4FL,10070269055
|
||||
FLAT 4 Goodstone Court,HA1 4FL,10070269056
|
||||
FLAT 5 Goodstone Court,HA1 4FL,10070269057
|
||||
FLAT 6 Goodstone Court,HA1 4FL,10070269058
|
||||
FLAT 7 Goodstone Court,HA1 4FL,10070269059
|
||||
FLAT 8 Goodstone Court,HA1 4FL,10070269060
|
||||
FLAT 9 Goodstone Court,HA1 4FL,10070269061
|
||||
FLAT 10 Goodstone Court,HA1 4FL,10070269062
|
||||
FLAT 11 Goodstone Court,HA1 4FL,10070269063
|
||||
FLAT 12 Goodstone Court,HA1 4FL,10070269064
|
||||
FLAT 13 Goodstone Court,HA1 4FL,10070269065
|
||||
FLAT 14 Goodstone Court,HA1 4FL,10070269066
|
||||
FLAT 15 Goodstone Court,HA1 4FL,10070269067
|
||||
FLAT 16 Goodstone Court,HA1 4FL,10070269068
|
||||
FLAT 17 Goodstone Court,HA1 4FL,10070269069
|
||||
FLAT 18 Goodstone Court,HA1 4FL,10070269070
|
||||
FLAT 19 Goodstone Court,HA1 4FL,10070269071
|
||||
FLAT 20 Goodstone Court,HA1 4FL,10070269072
|
||||
FLAT 21 Goodstone Court,HA1 4FL,10070269073
|
||||
FLAT 22 Goodstone Court,HA1 4FL,10070269074
|
||||
FLAT 23 Goodstone Court,HA1 4FL,10070269075
|
||||
FLAT 24 Goodstone Court,HA1 4FL,10070269076
|
||||
FLAT 25 Goodstone Court,HA1 4FL,10070269077
|
||||
FLAT 26 Goodstone Court,HA1 4FL,10070269078
|
||||
FLAT 27 Goodstone Court,HA1 4FL,10070269079
|
||||
FLAT 28 Goodstone Court,HA1 4FL,10070269080
|
||||
FLAT 29 Goodstone Court,HA1 4FL,10070269081
|
||||
FLAT 30 Goodstone Court,HA1 4FL,10070269082
|
||||
FLAT 31 Goodstone Court,HA1 4FL,10070269083
|
||||
FLAT 32 Goodstone Court,HA1 4FL,10070269084
|
||||
FLAT 33 Goodstone Court,HA1 4FL,10070269085
|
||||
FLAT 34 Goodstone Court,HA1 4FL,10070269086
|
||||
FLAT 35 Goodstone Court,HA1 4FL,10070269087
|
||||
FLAT 36 Goodstone Court,HA1 4FL,10070269088
|
||||
FLAT 37 Goodstone Court,HA1 4FL,10070269089
|
||||
FLAT 38 Goodstone Court,HA1 4FL,10070269090
|
||||
FLAT 39 Goodstone Court,HA1 4FL,10070269091
|
||||
FLAT 40 Goodstone Court,HA1 4FL,10070269092
|
||||
FLAT 41 Goodstone Court,HA1 4FL,10070269093
|
||||
FLAT 42 Goodstone Court,HA1 4FL,10070269094
|
||||
FLAT 43 Goodstone Court,HA1 4FL,10070269095
|
||||
"13 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778260
|
||||
"14 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778259
|
||||
"15 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778258
|
||||
"16 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778263
|
||||
"17 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778262
|
||||
"18 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778261
|
||||
"19 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778266
|
||||
"20 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778265
|
||||
"21 Stubwick Court, Old Saw Mill Place",HP6 6FF,10013778264
|
||||
90a Murray Road,W5 4DA,12135293
|
||||
"Flat 1, 6 Wolverton Gardens",W5 3LJ,"12119972"
|
||||
"1, Monsted House",UB1 1FG,12189944
|
||||
"10, Monsted House",UB1 1FG,12189953
|
||||
"20, Monsted House",UB1 1FG,12189963
|
||||
"2, Monsted House",UB1 1FG,12189945
|
||||
"3, Monsted House",UB1 1FG,12189946
|
||||
"4, Monsted House",UB1 1FG,12189947
|
||||
"5, Monsted House",UB1 1FG,12189948
|
||||
"6, Monsted House",UB1 1FG,12189949
|
||||
"7, Monsted House",UB1 1FG,12189950
|
||||
"8, Monsted House",UB1 1FG,12189951
|
||||
"9, Monsted House",UB1 1FG,12189952
|
||||
"1 Cullis House, 1, Accolade Avenue",UB1 1FH,12189904
|
||||
"2 Cullis House, 1, Accolade Avenue",UB1 1FH,12189905
|
||||
"3 Cullis House, 1, Accolade Avenue",UB1 1FH,12189906
|
||||
"4 Cullis House, 1, Accolade Avenue",UB1 1FH,12189907
|
||||
"5 Cullis House, 1, Accolade Avenue",UB1 1FH,12189908
|
||||
"6 Cullis House, 1, Accolade Avenue",UB1 1FH,12189909
|
||||
1 Genteel House Samara Drive,UB1 1FJ,12189835
|
||||
2 Genteel House Samara Drive,UB1 1FJ,12189836
|
||||
3 Genteel House Samara Drive,UB1 1FJ,12189837
|
||||
4 Genteel House Samara Drive,UB1 1FJ,12189838
|
||||
5 Genteel House Samara Drive,UB1 1FJ,12189839
|
||||
6 Genteel House Samara Drive,UB1 1FJ,12189840
|
||||
7 Genteel House Samara Drive,UB1 1FJ,12189841
|
||||
8 Genteel House Samara Drive,UB1 1FJ,12189842
|
||||
9 Genteel House Samara Drive,UB1 1FJ,12189843
|
||||
10 Genteel House Samara Drive,UB1 1FJ,12189844
|
||||
1 ASH TREE HOUSE,SE5 0TE,None
|
||||
"Flat 1 Ash Tree House, 2, Thompson Avenue",SE5 0TE,10009803979
|
||||
3 ASH TREE HOUSE,SE5 0TE,None
|
||||
Flat 3 ASH TREE HOUSE,SE5 0TE,10009803981
|
||||
5 ASH TREE HOUSE,SE5 0TE,None
|
||||
Flat 5 ASH TREE HOUSE,SE5 0TE,10009803983
|
||||
Flat 8 ASH TREE HOUSE,SE5 0TE,10009803986
|
||||
8 ASH TREE HOUSE,SE5 0TE,None
|
||||
Flat 12 ASH TREE HOUSE,SE5 0TE,10009803990
|
||||
12 ASH TREE HOUSE,SE5 0TE,None
|
||||
FLAT 1 599 HARROW ROAD,W10 4RA,217113930
|
||||
FLAT 2 599 HARROW ROAD,W10 4RA,217113931
|
||||
FLAT 3 599 HARROW ROAD,W10 4RA,None
|
||||
FLAT 4 599 HARROW ROAD,W10 4RA,None
|
||||
FLAT 5 599 HARROW ROAD,W10 4RA,217113934
|
||||
FLAT 6 599 HARROW ROAD,W10 4RA,None
|
||||
FLAT 7 599 HARROW ROAD,W10 4RA,None
|
||||
FLAT 8 599 HARROW ROAD,W10 4RA,None
|
||||
"Flat 1, Ohio Building",SE13 7RX,10023226256
|
||||
"Flat 2, Ohio Building",SE13 7RX,10023226257
|
||||
"Apartment 1 Block B, 105, Benwell Road",N7 7BW,10012792307
|
||||
"Apartment 2 Block B, 105, Benwell Road",N7 7BW,10012792308
|
||||
"Apartment 3 Block B, 105, Benwell Road",N7 7BW,10012792309
|
||||
"Apartment 4 Block B, 105, Benwell Road",N7 7BW,10012792310
|
||||
"Apartment 5 Block B, 105, Benwell Road",N7 7BW,10012792311
|
||||
"Apartment 6 Block B, 105, Benwell Road",N7 7BW,10012792312
|
||||
"Apartment 7 Block B, 105, Benwell Road",N7 7BW,10012792313
|
||||
"Apartment 8 Block B, 105, Benwell Road",N7 7BW,10012792314
|
||||
"Apartment 9 Block B, 105, Benwell Road",N7 7BW,10012792315
|
||||
"Apartment 10 Block B, 105, Benwell Road",N7 7BW,10012792316
|
||||
"Apartment 11 Block B, 105, Benwell Road",N7 7BW,10012792317
|
||||
"Apartment 12 Block B, 105, Benwell Road",N7 7BW,10012792318
|
||||
"Apartment 13 Block B, 105, Benwell Road",N7 7BW,10012792319
|
||||
"Apartment 1 Block D, 32, Hornsey Road",N7 7AT,10012792366
|
||||
"Apartment 2 Block D, 32, Hornsey Road",N7 7AT,10012792367
|
||||
"Apartment 3 Block D, 32, Hornsey Road",N7 7AT,10012792368
|
||||
"Apartment 4 Block D, 32, Hornsey Road",N7 7AT,10012792369
|
||||
"Apartment 5 Block D, 32, Hornsey Road",N7 7AT,10012792370
|
||||
"Apartment 6 Block D, 32, Hornsey Road",N7 7AT,"10012792371"
|
||||
"Apartment 7 Block D, 32, Hornsey Road",N7 7AT,10012792372
|
||||
"Apartment 8 Block D, 32, Hornsey Road",N7 7AT,10012792373
|
||||
"Apartment 9 Block D, 32, Hornsey Road",N7 7AT,10012792374
|
||||
"Apartment 10 Block D, 32, Hornsey Road",N7 7AT,10012792375
|
||||
"Apartment 11 Block D, 32, Hornsey Road",N7 7AT,10012792376
|
||||
"Apartment 12 Block D, 32, Hornsey Road",N7 7AT,10012792377
|
||||
"Apartment 13 Block D, 32, Hornsey Road",N7 7AT,10012792378
|
||||
"Apartment 14 Block D, 32, Hornsey Road",N7 7AT,10012792379
|
||||
"Apartment 15 Block D, 32, Hornsey Road",N7 7AT,10012792380
|
||||
"Apartment 16 Block D, 32, Hornsey Road",N7 7AT,"10012792381"
|
||||
"Apartment 17Block D, 32, Hornsey Road",N7 7AT,10012792382
|
||||
"Apartment 18 Block D, 32, Hornsey Road",N7 7AT,10012792383
|
||||
24b Honley Road,SE6 2HZ,None
|
||||
FLAT B 158 LEAHURST ROAD,SE13 5NL,100021976974
|
||||
2 COLLEGE HOUSE,CM7 1JS,None
|
||||
3 COLLEGE HOUSE,CM7 1JS,None
|
||||
1 Anita Street,M4 5DU,None
|
||||
2 Anita Street,M4 5DU,77123061
|
||||
5 Anita Street,M4 5DU,77123081
|
||||
6 Anita Street,M4 5DU,77123082
|
||||
8 Anita Street,M4 5DU,None
|
||||
9 Anita Street,M4 5DU,None
|
||||
10 Anita Street,M4 5DU,77123051
|
||||
12 Anita Street,M4 5DU,77123053
|
||||
19 Anita Street,M4 5DU,None
|
||||
22 Anita Street,M4 5DU,None
|
||||
26 Anita Street,M4 5DU,77123068
|
||||
28 Anita Street,M4 5DU,None
|
||||
30 Anita Street,M4 5DU,None
|
||||
32 Anita Street,M4 5DU,None
|
||||
33 Anita Street,M4 5DU,77123076
|
||||
34 Anita Street,M4 5DU,None
|
||||
35 Anita Street,M4 5DU,77123078
|
||||
36 Anita Street,M4 5DU,77123079
|
||||
23 George Leigh Street,M4 5DR,77123171
|
||||
25 George Leigh Street,M4 5DR,None
|
||||
35 George Leigh Street,M4 5DR,77123177
|
||||
39 George Leigh Street,M4 5DR,77123179
|
||||
41 George Leigh Street,M4 5DR,None
|
||||
43 George Leigh Street,M4 5DR,None
|
||||
49 George Leigh Street,M4 5DR,None
|
||||
51 George Leigh Street,M4 5DR,77123185
|
||||
55 George Leigh Street,M4 5DR,None
|
||||
57 George Leigh Street,M4 5DR,None
|
||||
"1a, Victoria Square",M4 5DX,77211153
|
||||
2a Victoria Square ,M4 5DX,None
|
||||
"4a, Victoria Square",M4 5DX,77211155
|
||||
5a Victoria Square,M4 5DX,77211156
|
||||
6a Victoria Square,M4 5DX,77211157
|
||||
7a Victoria Square,M4 5DX,77211158
|
||||
8a Victoria Square,M4 5DX,77211159
|
||||
9a Victoria Square,M4 5DX,77211160
|
||||
10a Victoria Square,M4 5DX,77211161
|
||||
11a Victoria Square,M4 5DX,77211162
|
||||
12a Victoria Square,M4 5DX,77211163
|
||||
13a Victoria Square,M4 5DX,77211164
|
||||
14a Victoria Square,M4 5DX,77211165
|
||||
15a Victoria Square,M4 5DX,77211166
|
||||
16a Victoria Square,M4 5DX,77211167
|
||||
17a Victoria Square,M4 5DX,77211168
|
||||
18a Victoria Square,M4 5DX,77211169
|
||||
19a Victoria Square,M4 5DX,77211170
|
||||
20a Victoria Square,M4 5DX,77211171
|
||||
21a Victoria Square,M4 5DY,77211172
|
||||
22a Victoria Square,M4 5DY,None
|
||||
23a Victoria Square,M4 5DY,77211174
|
||||
24a Victoria Square,M4 5DY,77211175
|
||||
25a Victoria Square,M4 5DY,77211176
|
||||
26a Victoria Square,M4 5DY,77211177
|
||||
27a Victoria Square,M4 5DY,77211178
|
||||
28a Victoria Square,M4 5DY,None
|
||||
29a Victoria Square,M4 5DY,77211180
|
||||
30a Victoria Square,M4 5DY,77211181
|
||||
31a Victoria Square,M4 5DY,77211182
|
||||
32a Victoria Square,M4 5DY,77211183
|
||||
33a Victoria Square,M4 5DY,77211184
|
||||
34a Victoria Square,M4 5DY,77211185
|
||||
35a Victoria Square,M4 5DY,None
|
||||
36a Victoria Square,M4 5DY,77211187
|
||||
37a Victoria Square,M4 5DY,77211188
|
||||
38a Victoria Square,M4 5DY,77211189
|
||||
39a Victoria Square,M4 5DY,77211190
|
||||
40a Victoria Square,M4 5DY,None
|
||||
41a Victoria Square,M4 5DY,77211192
|
||||
42a Victoria Square,M4 5DY,77211193
|
||||
43a Victoria Square,M4 5DY,77211194
|
||||
44a Victoria Square,M4 5DY,77211195
|
||||
45a Victoria Square,M4 5DY,77211196
|
||||
46a Victoria Square,M4 5DY,77211197
|
||||
47a Victoria Square,M4 5DY,77211198
|
||||
48a Victoria Square,M4 5DY,77211199
|
||||
49a Victoria Square,M4 5DY,77211200
|
||||
50a Victoria Square,M4 5DY,77211201
|
||||
51a Victoria Square,M4 5DY,77211202
|
||||
52a Victoria Square,M4 5DY,77211203
|
||||
53a Victoria Square,M4 5DY,77211204
|
||||
54a Victoria Square,M4 5DY,77211205
|
||||
55a Victoria Square,M4 5DY,77211206
|
||||
56a Victoria Square,M4 5DZ,77211207
|
||||
57a Victoria Square,M4 5DZ,None
|
||||
58a Victoria Square,M4 5DZ,77211209
|
||||
59a Victoria Square,M4 5DZ,77211210
|
||||
60a Victoria Square,M4 5DZ,77211211
|
||||
61a Victoria Square,M4 5DZ,77211212
|
||||
62a Victoria Square,M4 5DZ,77211213
|
||||
63a Victoria Square,M4 5DZ,None
|
||||
64a Victoria Square,M4 5DZ,77211215
|
||||
65a Victoria Square,M4 5DZ,77211216
|
||||
66a Victoria Square,M4 5DZ,None
|
||||
67a Victoria Square,M4 5DZ,None
|
||||
68a Victoria Square,M4 5DZ,77211219
|
||||
69a Victoria Square,M4 5DZ,77211220
|
||||
70a Victoria Square,M4 5DZ,77211221
|
||||
71a Victoria Square,M4 5DZ,77211222
|
||||
72a Victoria Square,M4 5DZ,77211223
|
||||
73a Victoria Square,M4 5DZ,77211224
|
||||
74a Victoria Square,M4 5DZ,None
|
||||
75a Victoria Square,M4 5DZ,77211226
|
||||
76a Victoria Square,M4 5DZ,77211227
|
||||
77a Victoria Square,M4 5DZ,None
|
||||
78a Victoria Square,M4 5DZ,77211229
|
||||
79a Victoria Square,M4 5DZ,77211230
|
||||
80a Victoria Square,M4 5DZ,77211231
|
||||
81a Victoria Square,M4 5DZ,77211232
|
||||
82 Victoria Square,M4 5DZ,None
|
||||
82a Victoria Square,M4 5DZ,77211233
|
||||
83a Victoria Square,M4 5DZ,77211234
|
||||
84a Victoria Square,M4 5DZ,None
|
||||
85a Victoria Square,M4 5DZ,77211236
|
||||
86a Victoria Square,M4 5DZ,77211237
|
||||
87a Victoria Square,M4 5DZ,77211238
|
||||
88a Victoria Square,M4 5DZ,None
|
||||
89a Victoria Square,M4 5DZ,77211240
|
||||
90a Victoria Square,M4 5DZ,77211241
|
||||
91a Victoria Square,M4 5DZ,77211242
|
||||
92a Victoria Square,M4 5DZ,77211243
|
||||
93a Victoria Square,M4 5EA,77211244
|
||||
94a Victoria Square,M4 5EA,None
|
||||
95a Victoria Square,M4 5EA,77211246
|
||||
96a Victoria Square,M4 5EA,77211247
|
||||
97a Victoria Square,M4 5EA,77211248
|
||||
98a Victoria Square,M4 5EA,77211249
|
||||
99a Victoria Square,M4 5EA,77211250
|
||||
100a Victoria Square,M4 5EA,77211251
|
||||
101a Victoria Square,M4 5EA,None
|
||||
102a Victoria Square,M4 5EA,None
|
||||
103a Victoria Square,M4 5EA,77211254
|
||||
104a Victoria Square,M4 5EA,77211255
|
||||
105a Victoria Square,M4 5EA,None
|
||||
106a Victoria Square,M4 5EA,77211257
|
||||
107a Victoria Square,M4 5EA,77211258
|
||||
108a Victoria Square,M4 5EA,77211259
|
||||
109a Victoria Square,M4 5EA,77211260
|
||||
110a Victoria Square,M4 5EA,77211261
|
||||
111a Victoria Square,M4 5EA,77211262
|
||||
112a Victoria Square,M4 5EA,None
|
||||
113a Victoria Square,M4 5EA,77211264
|
||||
114a Victoria Square,M4 5EA,77211265
|
||||
115a Victoria Square,M4 5EA,77211266
|
||||
116a Victoria Square,M4 5EA,77211267
|
||||
117a Victoria Square,M4 5EA,None
|
||||
118a Victoria Square,M4 5EA,None
|
||||
119a Victoria Square,M4 5EA,77211270
|
||||
120a Victoria Square,M4 5EA,77211271
|
||||
121a Victoria Square,M4 5EA,77211272
|
||||
122a Victoria Square,M4 5EA,77211273
|
||||
123a Victoria Square,M4 5EA,77211274
|
||||
124a Victoria Square,M4 5EA,None
|
||||
125a Victoria Square,M4 5EA,77211276
|
||||
126a Victoria Square,M4 5EA,77211277
|
||||
127a Victoria Square,M4 5EA,77211278
|
||||
128a Victoria Square,M4 5EA,77211279
|
||||
129a Victoria Square,M4 5EA,77211280
|
||||
130a Victoria Square,M4 5FA,77211281
|
||||
131a Victoria Square,M4 5FA,77211282
|
||||
132a Victoria Square,M4 5FA,77211283
|
||||
133a Victoria Square,M4 5FA,None
|
||||
134a Victoria Square,M4 5FA,77211285
|
||||
135a Victoria Square,M4 5FA,77211286
|
||||
136a Victoria Square,M4 5FA,77211287
|
||||
137a Victoria Square,M4 5FA,77211288
|
||||
138a Victoria Square,M4 5FA,77211289
|
||||
139a Victoria Square,M4 5FA,77211290
|
||||
140a Victoria Square,M4 5FA,77211291
|
||||
141a Victoria Square,M4 5FA,77211292
|
||||
142a Victoria Square,M4 5FA,77211293
|
||||
143a Victoria Square,M4 5FA,77211294
|
||||
144a Victoria Square,M4 5FA,77211295
|
||||
145a Victoria Square,M4 5FA,None
|
||||
146a Victoria Square,M4 5FA,77211297
|
||||
147a Victoria Square,M4 5FA,77211298
|
||||
148a Victoria Square,M4 5FA,77211299
|
||||
149a Victoria Square,M4 5FA,77211300
|
||||
150a Victoria Square,M4 5FA,77211301
|
||||
151a Victoria Square,M4 5FA,None
|
||||
152a Victoria Square,M4 5FA,77211303
|
||||
153a Victoria Square,M4 5FA,None
|
||||
154a Victoria Square,M4 5FA,77211305
|
||||
155a Victoria Square,M4 5FA,None
|
||||
156a Victoria Square,M4 5FA,77211307
|
||||
157a Victoria Square,M4 5FA,77211308
|
||||
158a Victoria Square,M4 5FA,77211309
|
||||
159a Victoria Square,M4 5FA,None
|
||||
160a Victoria Square,M4 5FA,77211311
|
||||
161a Victoria Square,M4 5FA,None
|
||||
162a Victoria Square,M4 5FA,None
|
||||
163a Victoria Square,M4 5FA,77211314
|
||||
164a Victoria Square,M4 5FA,77211315
|
||||
165a Victoria Square,M4 5FA,77211316
|
||||
166a Victoria Square,M4 5FA,None
|
||||
"FLAT 3; 42 MORETON ROAD, SOUTH CROYDON, SURREY",CR2 7DL,None
|
||||
71A Stoneleigh Avenue,NE12 8NP,None
|
||||
71B Stoneleigh Avenue,NE12 8NP,None
|
||||
71 Stoneleigh Avenue,NE12 8NP,47086009
|
||||
|
|
|
@ -29,6 +29,12 @@ class FinaliserTriggerRequest(BaseModel):
|
|||
s3_uri: str # combiner output (combined_output_s3_uri)
|
||||
portfolio_id: int
|
||||
bulk_upload_id: str
|
||||
# v2 (ADR-0006): inputs for the property_overrides write. Forwarded verbatim to
|
||||
# the finaliser Lambda. classifier_s3_uri is null when no classifier columns
|
||||
# were mapped; multi_entry_ordering / column_mapping default empty.
|
||||
classifier_s3_uri: Optional[str] = None
|
||||
multi_entry_ordering: dict[str, List[int]] = {}
|
||||
column_mapping: dict[str, str] = {}
|
||||
|
||||
|
||||
class FlagsSummary(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlmodel import Session, col
|
||||
|
||||
from datatypes.magicplan.domain.models import Floor, Plan
|
||||
from backend.app.db.models.magic_plan import (
|
||||
MagicPlanDoorModel,
|
||||
MagicPlanFloorModel,
|
||||
MagicPlanPlanModel,
|
||||
MagicPlanRoomModel,
|
||||
MagicPlanWindowModel,
|
||||
)
|
||||
|
||||
|
||||
def save_plan(session: Session, plan: Plan, uploaded_file_id: int) -> None:
|
||||
plan_id: int = _upsert_plan(session, plan, uploaded_file_id)
|
||||
_delete_children(session, plan_id)
|
||||
floor_ids: list[int] = _insert_floors(session, plan.floors, plan_id)
|
||||
room_ids: list[int] = _insert_rooms(session, plan.floors, floor_ids)
|
||||
_insert_windows_and_doors(session, plan.floors, room_ids)
|
||||
|
||||
|
||||
def _upsert_plan(session: Session, plan: Plan, uploaded_file_id: int) -> int:
|
||||
stmt = (
|
||||
pg_insert(MagicPlanPlanModel)
|
||||
.values(
|
||||
magic_plan_uid=plan.uid,
|
||||
name=plan.name,
|
||||
address=plan.address,
|
||||
postcode=plan.postcode,
|
||||
uploaded_file_id=uploaded_file_id,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=["magic_plan_uid"],
|
||||
set_={
|
||||
"name": plan.name,
|
||||
"address": plan.address,
|
||||
"postcode": plan.postcode,
|
||||
"uploaded_file_id": uploaded_file_id,
|
||||
},
|
||||
)
|
||||
.returning(col(MagicPlanPlanModel.id))
|
||||
)
|
||||
row_id: int = session.execute(stmt).scalar_one()
|
||||
return row_id
|
||||
|
||||
|
||||
def _delete_children(session: Session, plan_id: int) -> None:
|
||||
floor_subq = (
|
||||
select(col(MagicPlanFloorModel.id))
|
||||
.where(col(MagicPlanFloorModel.magic_plan_plan_id) == plan_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
room_subq = (
|
||||
select(col(MagicPlanRoomModel.id))
|
||||
.where(col(MagicPlanRoomModel.magic_plan_floor_id).in_(floor_subq))
|
||||
.scalar_subquery()
|
||||
)
|
||||
session.execute(
|
||||
delete(MagicPlanWindowModel).where(
|
||||
col(MagicPlanWindowModel.magic_plan_room_id).in_(room_subq)
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
delete(MagicPlanDoorModel).where(
|
||||
col(MagicPlanDoorModel.magic_plan_room_id).in_(room_subq)
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
delete(MagicPlanRoomModel).where(
|
||||
col(MagicPlanRoomModel.magic_plan_floor_id).in_(floor_subq)
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
delete(MagicPlanFloorModel).where(
|
||||
col(MagicPlanFloorModel.magic_plan_plan_id) == plan_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _insert_floors(session: Session, floors: list[Floor], plan_id: int) -> list[int]:
|
||||
rows: list[dict[str, Any]] = [
|
||||
{"magic_plan_plan_id": plan_id, "level": floor.level} for floor in floors
|
||||
]
|
||||
result = session.execute(
|
||||
pg_insert(MagicPlanFloorModel)
|
||||
.values(rows)
|
||||
.returning(col(MagicPlanFloorModel.id))
|
||||
)
|
||||
return cast(list[int], list(result.scalars().all()))
|
||||
|
||||
|
||||
def _insert_rooms(
|
||||
session: Session, floors: list[Floor], floor_ids: list[int]
|
||||
) -> list[int]:
|
||||
rows: list[dict[str, Any]] = [
|
||||
{
|
||||
"magic_plan_floor_id": floor_id,
|
||||
"name": room.name,
|
||||
"width_m": room.width_m,
|
||||
"length_m": room.length_m,
|
||||
"area_m2": room.area_m2,
|
||||
}
|
||||
for floor, floor_id in zip(floors, floor_ids)
|
||||
for room in floor.rooms
|
||||
]
|
||||
result = session.execute(
|
||||
pg_insert(MagicPlanRoomModel).values(rows).returning(col(MagicPlanRoomModel.id))
|
||||
)
|
||||
return cast(list[int], list(result.scalars().all()))
|
||||
|
||||
|
||||
def _insert_windows_and_doors(
|
||||
session: Session, floors: list[Floor], room_ids: list[int]
|
||||
) -> None:
|
||||
all_rooms = [room for floor in floors for room in floor.rooms]
|
||||
|
||||
window_rows: list[dict[str, Any]] = [
|
||||
{
|
||||
"magic_plan_room_id": room_id,
|
||||
"width_m": window.width_m,
|
||||
"height_m": window.height_m,
|
||||
"area_m2": window.area_m2,
|
||||
"opening_type": window.opening_type,
|
||||
}
|
||||
for room, room_id in zip(all_rooms, room_ids)
|
||||
for window in room.windows
|
||||
]
|
||||
door_rows: list[dict[str, Any]] = [
|
||||
{
|
||||
"magic_plan_room_id": room_id,
|
||||
"width_mm": door.width_mm,
|
||||
}
|
||||
for room, room_id in zip(all_rooms, room_ids)
|
||||
for door in room.doors
|
||||
]
|
||||
|
||||
if window_rows:
|
||||
session.execute(pg_insert(MagicPlanWindowModel).values(window_rows))
|
||||
if door_rows:
|
||||
session.execute(pg_insert(MagicPlanDoorModel).values(door_rows))
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
from sqlalchemy import func
|
||||
from backend.app.db.models.recommendations import (
|
||||
PlanModel,
|
||||
PlanRecommendations,
|
||||
Recommendation,
|
||||
ScenarioModel,
|
||||
)
|
||||
|
|
@ -26,11 +25,7 @@ def aggregate_portfolio_recommendations(
|
|||
),
|
||||
func.sum(Recommendation.energy_cost_savings).label("energy_cost_savings"),
|
||||
)
|
||||
.join(
|
||||
PlanRecommendations,
|
||||
PlanRecommendations.recommendation_id == Recommendation.id,
|
||||
)
|
||||
.join(PlanModel, PlanModel.id == PlanRecommendations.plan_id)
|
||||
.join(PlanModel, PlanModel.id == Recommendation.plan_id)
|
||||
.filter(
|
||||
PlanModel.portfolio_id == portfolio_id,
|
||||
PlanModel.scenario_id == scenario_id,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from backend.app.db.models.recommendations import (
|
|||
PlanModel,
|
||||
Recommendation,
|
||||
RecommendationMaterials,
|
||||
PlanRecommendations,
|
||||
ScenarioModel,
|
||||
)
|
||||
from backend.app.db.models.portfolio import PropertyModel
|
||||
|
|
@ -236,23 +235,6 @@ def create_recommendation_material(
|
|||
return new_recommendation_material.id
|
||||
|
||||
|
||||
def create_plan_recommendations(session: Session, plan_id, recommendation_ids):
|
||||
"""
|
||||
This function will create records for the plan_recommendation in the database.
|
||||
:param session: The database session
|
||||
:param plan_id: ID of the plan
|
||||
:param recommendation_ids: list of recommendation IDs
|
||||
"""
|
||||
|
||||
# Prepare a list of dictionaries for bulk insert
|
||||
data = [
|
||||
{"plan_id": plan_id, "recommendation_id": rid} for rid in recommendation_ids
|
||||
]
|
||||
|
||||
# Bulk insert using SQLAlchemy's core API
|
||||
session.execute(insert(PlanRecommendations).values(data))
|
||||
|
||||
|
||||
def upload_recommendations(
|
||||
session: Session, recommendations_to_upload, property_id, new_plan_id
|
||||
):
|
||||
|
|
@ -261,6 +243,7 @@ def upload_recommendations(
|
|||
recommendations_data = [
|
||||
{
|
||||
"property_id": property_id,
|
||||
"plan_id": new_plan_id,
|
||||
"type": rec["type"],
|
||||
"measure_type": rec["measure_type"],
|
||||
"description": rec["description"],
|
||||
|
|
@ -319,10 +302,6 @@ def upload_recommendations(
|
|||
# flush the changes to get the newly created IDs
|
||||
session.flush()
|
||||
|
||||
create_plan_recommendations(
|
||||
session, plan_id=new_plan_id, recommendation_ids=uploaded_recommendation_ids
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
session.commit()
|
||||
|
||||
|
|
@ -347,12 +326,12 @@ def bulk_upload_recommendations_and_materials(
|
|||
# ---------------------------------------------------------
|
||||
recommendation_rows = []
|
||||
parts_by_index = []
|
||||
plan_ids_by_index = []
|
||||
|
||||
for rec in recommendation_payload:
|
||||
recommendation_rows.append(
|
||||
{
|
||||
"property_id": rec["property_id"],
|
||||
"plan_id": rec["plan_id"],
|
||||
"type": rec["type"],
|
||||
"measure_type": rec["measure_type"],
|
||||
"description": rec["description"],
|
||||
|
|
@ -373,7 +352,6 @@ def bulk_upload_recommendations_and_materials(
|
|||
)
|
||||
|
||||
parts_by_index.append(rec["parts"])
|
||||
plan_ids_by_index.append(rec["plan_id"])
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2. Insert recommendations and get IDs
|
||||
|
|
@ -405,18 +383,8 @@ def bulk_upload_recommendations_and_materials(
|
|||
if materials_rows:
|
||||
session.execute(insert(RecommendationMaterials).values(materials_rows))
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 4. Insert plan ↔ recommendation links
|
||||
# ---------------------------------------------------------
|
||||
plan_recommendation_rows = [
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"recommendation_id": recommendation_id,
|
||||
}
|
||||
for plan_id, recommendation_id in zip(plan_ids_by_index, recommendation_ids)
|
||||
]
|
||||
|
||||
session.execute(insert(PlanRecommendations).values(plan_recommendation_rows))
|
||||
# Recommendations carry their plan via recommendation.plan_id (set above) —
|
||||
# the plan_recommendations m2m is retired (ADR-0017 amendment).
|
||||
|
||||
|
||||
def chunked(iterable, size=100):
|
||||
|
|
@ -455,21 +423,6 @@ def delete_property_batch(session: Session, property_ids: list[int]):
|
|||
params,
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# plan_recommendations (via plan)
|
||||
# --------------------------------------------------
|
||||
session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM plan_recommendations pr
|
||||
USING plan p
|
||||
WHERE pr.plan_id = p.id
|
||||
AND p.property_id = ANY(:property_ids)
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# funding_package_measures
|
||||
# --------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
import backend.app.db.models.magic_plan # noqa: F401 — registers MagicPlan models with SQLModel.metadata
|
||||
|
||||
# TODO: promote to backend/app/db/conftest.py once a second DB-touching test directory appears under this tree
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def engine(postgresql):
|
||||
connection_string = (
|
||||
f"postgresql+psycopg://"
|
||||
f"{postgresql.info.user}:"
|
||||
f"{postgresql.info.password}@"
|
||||
f"{postgresql.info.host}:"
|
||||
f"{postgresql.info.port}/"
|
||||
f"{postgresql.info.dbname}"
|
||||
)
|
||||
|
||||
engine = create_engine(connection_string)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
yield engine
|
||||
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db_session(engine):
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = sessionmaker(bind=connection)()
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from datatypes.magicplan.api.response import MagicPlanPlan
|
||||
from datatypes.magicplan.domain.mapper import map_plan
|
||||
from datatypes.magicplan.domain.models import Plan
|
||||
|
||||
from backend.app.db.functions.magic_plan_functions import save_plan
|
||||
from backend.app.db.models.magic_plan import (
|
||||
MagicPlanDoorModel,
|
||||
MagicPlanFloorModel,
|
||||
MagicPlanPlanModel,
|
||||
MagicPlanRoomModel,
|
||||
MagicPlanWindowModel,
|
||||
)
|
||||
|
||||
FIXTURE_DIR = Path(__file__).parents[4] / "magic_plan"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def domain_plan() -> Plan:
|
||||
data = json.loads(
|
||||
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
|
||||
)
|
||||
return map_plan(MagicPlanPlan.model_validate(data["data"]))
|
||||
|
||||
|
||||
def _count(session: Session, model: type[SQLModel]) -> int:
|
||||
return session.execute(select(func.count()).select_from(model)).scalar_one()
|
||||
|
||||
|
||||
def test_plan_row_present_after_save(db_session: Session, domain_plan: Plan) -> None:
|
||||
# Act
|
||||
save_plan(db_session, domain_plan, 1)
|
||||
# Assert
|
||||
assert _count(db_session, MagicPlanPlanModel) == 1
|
||||
|
||||
|
||||
def test_floor_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
|
||||
# Arrange
|
||||
expected = len(domain_plan.floors)
|
||||
# Act
|
||||
save_plan(db_session, domain_plan, 1)
|
||||
# Assert
|
||||
assert _count(db_session, MagicPlanFloorModel) == expected
|
||||
|
||||
|
||||
def test_room_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
|
||||
# Arrange
|
||||
expected = sum(len(f.rooms) for f in domain_plan.floors)
|
||||
# Act
|
||||
save_plan(db_session, domain_plan, 1)
|
||||
# Assert
|
||||
assert _count(db_session, MagicPlanRoomModel) == expected
|
||||
|
||||
|
||||
def test_window_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
|
||||
# Arrange
|
||||
expected = sum(len(r.windows) for f in domain_plan.floors for r in f.rooms)
|
||||
# Act
|
||||
save_plan(db_session, domain_plan, 1)
|
||||
# Assert
|
||||
assert _count(db_session, MagicPlanWindowModel) == expected
|
||||
|
||||
|
||||
def test_door_count_matches_domain(db_session: Session, domain_plan: Plan) -> None:
|
||||
# Arrange
|
||||
expected = sum(len(r.doors) for f in domain_plan.floors for r in f.rooms)
|
||||
# Act
|
||||
save_plan(db_session, domain_plan, 1)
|
||||
# Assert
|
||||
assert _count(db_session, MagicPlanDoorModel) == expected
|
||||
|
||||
|
||||
def test_save_plan_idempotent(db_session: Session, domain_plan: Plan) -> None:
|
||||
# Act — call twice within the same session
|
||||
save_plan(db_session, domain_plan, 1)
|
||||
save_plan(db_session, domain_plan, 1)
|
||||
# Assert — same row counts as a single call
|
||||
assert _count(db_session, MagicPlanPlanModel) == 1
|
||||
assert _count(db_session, MagicPlanFloorModel) == len(domain_plan.floors)
|
||||
assert _count(db_session, MagicPlanRoomModel) == sum(
|
||||
len(f.rooms) for f in domain_plan.floors
|
||||
)
|
||||
assert _count(db_session, MagicPlanWindowModel) == sum(
|
||||
len(r.windows) for f in domain_plan.floors for r in f.rooms
|
||||
)
|
||||
assert _count(db_session, MagicPlanDoorModel) == sum(
|
||||
len(r.doors) for f in domain_plan.floors for r in f.rooms
|
||||
)
|
||||
|
||||
|
||||
def test_uploaded_file_id_stored_after_save(db_session: Session, domain_plan: Plan) -> None:
|
||||
# Act
|
||||
save_plan(db_session, domain_plan, 1)
|
||||
# Assert
|
||||
row = db_session.execute(select(MagicPlanPlanModel)).scalar_one()
|
||||
assert row.uploaded_file_id == 1
|
||||
|
||||
|
||||
def test_save_plan_updates_uploaded_file_id_on_reingest(
|
||||
db_session: Session, domain_plan: Plan
|
||||
) -> None:
|
||||
# Arrange
|
||||
save_plan(db_session, domain_plan, 1)
|
||||
# Act
|
||||
save_plan(db_session, domain_plan, 2)
|
||||
# Assert
|
||||
row = db_session.execute(select(MagicPlanPlanModel)).scalar_one()
|
||||
assert row.uploaded_file_id == 2
|
||||
97
backend/app/db/functions/tests/test_portfolio_functions.py
Normal file
97
backend/app/db/functions/tests/test_portfolio_functions.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Characterisation of the FE-facing portfolio aggregation
|
||||
(`aggregate_portfolio_recommendations`): it sums a Scenario's **default**
|
||||
Recommendations and writes the totals onto the Scenario row.
|
||||
|
||||
This pins the `recommendation.plan_id` linkage the m2m retirement introduced
|
||||
(ADR-0017 amendment): the aggregation joins Recommendation → Plan on
|
||||
`recommendation.plan_id`, so only measures carrying the right `plan_id` (and
|
||||
`default = True`) are summed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from backend.app.db.functions.portfolio_functions import (
|
||||
aggregate_portfolio_recommendations,
|
||||
)
|
||||
from backend.app.db.models.recommendations import (
|
||||
PlanModel,
|
||||
Recommendation,
|
||||
ScenarioModel,
|
||||
)
|
||||
from domain.modelling.portfolio_goal import PortfolioGoal
|
||||
from tests.utilities.floats import assert_float_matches
|
||||
|
||||
|
||||
def _rec(
|
||||
*, plan_id: int, default: bool, cost: float, kwh: float, gbp: float, co2: float
|
||||
) -> Recommendation:
|
||||
return Recommendation(
|
||||
property_id=10,
|
||||
plan_id=plan_id,
|
||||
type="cavity_wall_insulation",
|
||||
measure_type="cavity_wall_insulation",
|
||||
description="Cavity wall insulation",
|
||||
estimated_cost=cost,
|
||||
kwh_savings=kwh,
|
||||
energy_cost_savings=gbp,
|
||||
co2_equivalent_savings=co2,
|
||||
total_work_hours=4.0,
|
||||
default=default,
|
||||
already_installed=False,
|
||||
)
|
||||
|
||||
|
||||
def test_aggregation_sums_default_measures_linked_by_plan_id(
|
||||
db_session: Session,
|
||||
) -> None:
|
||||
# Arrange — one Scenario + Plan, two default measures (summed) plus a
|
||||
# non-default one (excluded), all linked by recommendation.plan_id.
|
||||
db_session.add(
|
||||
ScenarioModel(
|
||||
id=7,
|
||||
portfolio_id=1,
|
||||
goal=PortfolioGoal.INCREASING_EPC,
|
||||
goal_value="C",
|
||||
is_default=True,
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
PlanModel(id=100, portfolio_id=1, property_id=10, scenario_id=7, is_default=True)
|
||||
)
|
||||
db_session.add_all(
|
||||
[
|
||||
_rec(plan_id=100, default=True, cost=1000.0, kwh=500.0, gbp=120.0, co2=0.5),
|
||||
_rec(plan_id=100, default=True, cost=500.0, kwh=300.0, gbp=80.0, co2=0.2),
|
||||
# excluded: not default
|
||||
_rec(plan_id=100, default=False, cost=9.0, kwh=9.0, gbp=9.0, co2=9.0),
|
||||
]
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# Act
|
||||
aggregate_portfolio_recommendations(
|
||||
db_session,
|
||||
portfolio_id=1,
|
||||
scenario_id=7,
|
||||
total_valuation_increase=2500.0,
|
||||
labour_days=3.0,
|
||||
aggregated_data={},
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# Assert — the default measures' sums land on the Scenario row
|
||||
scenario = db_session.query(ScenarioModel).filter_by(id=7).one()
|
||||
assert scenario.cost is not None
|
||||
assert_float_matches(scenario.cost, 1500.0) # 1000 + 500
|
||||
assert scenario.energy_savings is not None
|
||||
assert_float_matches(scenario.energy_savings, 800.0) # Σ kwh_savings
|
||||
assert scenario.energy_cost_savings is not None
|
||||
assert_float_matches(scenario.energy_cost_savings, 200.0) # 120 + 80
|
||||
assert scenario.co2_equivalent_savings is not None
|
||||
assert_float_matches(scenario.co2_equivalent_savings, 0.7) # 0.5 + 0.2
|
||||
assert scenario.total_work_hours is not None
|
||||
assert_float_matches(scenario.total_work_hours, 8.0) # 4 + 4
|
||||
assert scenario.property_valuation_increase == 2500.0
|
||||
assert scenario.labour_days == 3.0
|
||||
|
|
@ -3,7 +3,7 @@ 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 (
|
||||
from infrastructure.postgres.uploaded_file_table import (
|
||||
FileSourceEnum,
|
||||
FileTypeEnum,
|
||||
UploadedFile,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,12 @@ class HubspotDealData(SQLModel, table=True):
|
|||
domna_survey_required: Optional[bool] = Field(default=None)
|
||||
domna_survey_date: Optional[datetime] = Field(default=None)
|
||||
|
||||
date_booking_made: Optional[datetime] = Field(default=None)
|
||||
last_contact_date: Optional[datetime] = Field(default=None)
|
||||
last_outbound_call: Optional[datetime] = Field(default=None)
|
||||
last_outbound_email: Optional[datetime] = Field(default=None)
|
||||
last_submission_date: Optional[datetime] = Field(default=None)
|
||||
|
||||
created_at: Optional[datetime] = Field(
|
||||
sa_column=Column(
|
||||
DateTime(timezone=True),
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class MagicPlanPlanModel(SQLModel, table=True):
|
||||
__tablename__ = "magic_plan_plan"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
magic_plan_uid: Optional[str] = Field(default=None, unique=True, index=True)
|
||||
name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
postcode: Optional[str] = None
|
||||
uploaded_file_id: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
class MagicPlanFloorModel(SQLModel, table=True):
|
||||
__tablename__ = "magic_plan_floor"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
magic_plan_plan_id: int = Field(foreign_key="magic_plan_plan.id")
|
||||
level: Optional[int] = None
|
||||
|
||||
|
||||
class MagicPlanRoomModel(SQLModel, table=True):
|
||||
__tablename__ = "magic_plan_room"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
magic_plan_floor_id: int = Field(foreign_key="magic_plan_floor.id")
|
||||
name: Optional[str] = None
|
||||
width_m: Optional[float] = None
|
||||
length_m: Optional[float] = None
|
||||
area_m2: Optional[float] = None
|
||||
|
||||
|
||||
class MagicPlanWindowModel(SQLModel, table=True):
|
||||
__tablename__ = "magic_plan_window"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
magic_plan_room_id: int = Field(foreign_key="magic_plan_room.id")
|
||||
width_m: Optional[float] = None
|
||||
height_m: Optional[float] = None
|
||||
area_m2: Optional[float] = None
|
||||
opening_type: Optional[str] = None
|
||||
|
||||
|
||||
class MagicPlanDoorModel(SQLModel, table=True):
|
||||
__tablename__ = "magic_plan_door"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
magic_plan_room_id: int = Field(foreign_key="magic_plan_room.id")
|
||||
width_mm: Optional[float] = None
|
||||
type: Optional[str] = None
|
||||
|
|
@ -18,6 +18,11 @@ from backend.app.db.models.users import UserModel # noqa
|
|||
from backend.app.db.models.materials import MaterialType
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
|
||||
# PortfolioGoal moved to the domain layer (ADR-0017 amendment). Re-exported here
|
||||
# so the existing `from backend.app.db.models.portfolio import PortfolioGoal`
|
||||
# callers keep working.
|
||||
from domain.modelling.portfolio_goal import PortfolioGoal # noqa: F401
|
||||
|
||||
|
||||
class PortfolioStatus(enum.Enum):
|
||||
SCOPING = "scoping"
|
||||
|
|
@ -32,14 +37,6 @@ class PortfolioStatus(enum.Enum):
|
|||
NEEDS_REVIEW = "needs review"
|
||||
|
||||
|
||||
class PortfolioGoal(enum.Enum): # TODO: Move to domain?
|
||||
VALUATION_IMPROVEMENT = "Valuation Improvement"
|
||||
INCREASING_EPC = "Increasing EPC"
|
||||
REDUCING_CO2_EMISSIONS = "Reducing CO2 emissions"
|
||||
ENERGY_SAVINGS = "Energy Savings"
|
||||
NONE = "None"
|
||||
|
||||
|
||||
class Portfolio(Base):
|
||||
__tablename__ = "portfolio"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
|
|
|||
|
|
@ -1,289 +1,41 @@
|
|||
import enum
|
||||
from typing import Iterable, List, NamedTuple, Optional, Type
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
BigInteger,
|
||||
String,
|
||||
Float,
|
||||
Boolean,
|
||||
TIMESTAMP,
|
||||
ForeignKey,
|
||||
Enum,
|
||||
"""Re-export shim (ADR-0017 amendment).
|
||||
|
||||
The Modelling-stage persistence models — `plan`, `recommendation`,
|
||||
`recommendation_materials`, `scenario`, `installed_measure` — moved to
|
||||
`infrastructure/postgres/modelling/` as single SQLModel definitions (the
|
||||
`epc_property` pattern). This module re-exports them under their legacy names so
|
||||
the dying `backend/` callers keep working; new code imports from
|
||||
`infrastructure.postgres.modelling` directly. The `plan_recommendations` m2m is
|
||||
retired — measures link to their Plan via `recommendation.plan_id`.
|
||||
"""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
from infrastructure.postgres.modelling import (
|
||||
InstalledMeasureModel,
|
||||
PlanModel,
|
||||
PlanType,
|
||||
RecommendationMaterialModel,
|
||||
RecommendationModel,
|
||||
ScenarioModel,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime
|
||||
|
||||
from backend.app.db.base import Base
|
||||
from backend.app.db.models.portfolio import Portfolio, PortfolioGoal, PropertyModel
|
||||
from backend.app.db.models.materials import Material
|
||||
from datatypes.enums import QuantityUnits
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
# Legacy names → the single SQLModel definitions now in
|
||||
# `infrastructure/postgres/modelling/`.
|
||||
Recommendation = RecommendationModel
|
||||
RecommendationMaterials = RecommendationMaterialModel
|
||||
PlanTypeEnum = PlanType
|
||||
InstalledMeasure = InstalledMeasureModel
|
||||
|
||||
|
||||
def portfolio_goal_values(enum_cls: Type[PortfolioGoal]) -> List[str]:
|
||||
return [e.value for e in enum_cls]
|
||||
|
||||
|
||||
class Recommendation(Base):
|
||||
__tablename__ = "recommendation"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
property_id = Column(BigInteger, ForeignKey(PropertyModel.id), nullable=False)
|
||||
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||
type = Column(String, nullable=False)
|
||||
measure_type = Column(String)
|
||||
description = Column(String, nullable=False)
|
||||
estimated_cost = Column(Float)
|
||||
default = Column(Boolean, nullable=False)
|
||||
starting_u_value = Column(Float)
|
||||
new_u_value = Column(Float)
|
||||
sap_points = Column(Float)
|
||||
heat_demand = Column(Float)
|
||||
kwh_savings = Column(Float)
|
||||
co2_equivalent_savings = Column(Float)
|
||||
energy_savings = Column(Float)
|
||||
energy_cost_savings = Column(Float)
|
||||
property_valuation_increase = Column(Float)
|
||||
rental_yield_increase = Column(Float)
|
||||
total_work_hours = Column(Float)
|
||||
labour_days = Column(Float)
|
||||
already_installed = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class RecommendationMaterials(Base):
|
||||
__tablename__ = "recommendation_materials"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
|
||||
recommendation_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("recommendation.id"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
material_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey(Material.id),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
depth: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
quantity: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
quantity_unit: Mapped[QuantityUnits] = mapped_column(
|
||||
Enum(QuantityUnits, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
estimated_cost: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class PlanTypeEnum(enum.Enum): # TODO: move this to domain?
|
||||
SOLAR_ECO4 = "solar_eco4"
|
||||
SOLAR_HHRSH_ECO4 = "solar_hhrsh_eco4"
|
||||
EMPTY_CAVITY_ECO = "empty_cavity_eco"
|
||||
PARTIAL_CAVITY_ECO = "partial_cavity_eco"
|
||||
EXTRACTION_ECO = "extraction_eco"
|
||||
|
||||
|
||||
class PlanModel(Base):
|
||||
__tablename__ = "plan"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
|
||||
name: Mapped[Optional[str]] = mapped_column(String, nullable=True, default="")
|
||||
|
||||
portfolio_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey(Portfolio.id), nullable=False
|
||||
)
|
||||
|
||||
property_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey(PropertyModel.id), nullable=False
|
||||
)
|
||||
|
||||
scenario_id: Mapped[Optional[int]] = mapped_column(
|
||||
BigInteger, ForeignKey("scenario.id")
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column( # type: ignore
|
||||
TIMESTAMP, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
|
||||
valuation_increase_lower_bound: Mapped[Optional[float]] = mapped_column(Float)
|
||||
valuation_increase_upper_bound: Mapped[Optional[float]] = mapped_column(Float)
|
||||
valuation_increase_average: Mapped[Optional[float]] = mapped_column(Float)
|
||||
|
||||
plan_type: Mapped[Optional[PlanTypeEnum]] = mapped_column(
|
||||
Enum(
|
||||
PlanTypeEnum,
|
||||
name="plan_type",
|
||||
values_callable=lambda e: [m.value for m in e],
|
||||
create_type=False,
|
||||
),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
post_sap_points: Mapped[Optional[float]] = mapped_column(Float)
|
||||
post_epc_rating: Mapped[Optional[Epc]] = mapped_column(Enum(Epc))
|
||||
post_co2_emissions: Mapped[Optional[float]] = mapped_column(Float)
|
||||
co2_savings: Mapped[Optional[float]] = mapped_column(Float)
|
||||
post_energy_bill: Mapped[Optional[float]] = mapped_column(Float)
|
||||
energy_bill_savings: Mapped[Optional[float]] = mapped_column(Float)
|
||||
post_energy_consumption: Mapped[Optional[float]] = mapped_column(Float)
|
||||
energy_consumption_savings: Mapped[Optional[float]] = mapped_column(Float)
|
||||
valuation_post_retrofit: Mapped[Optional[float]] = mapped_column(Float)
|
||||
valuation_increase: Mapped[Optional[float]] = mapped_column(Float)
|
||||
|
||||
# Financial metrics, excluding funding
|
||||
cost_of_works: Mapped[Optional[float]] = mapped_column(Float)
|
||||
contingency_cost: Mapped[Optional[float]] = mapped_column(Float)
|
||||
|
||||
|
||||
class PlanRecommendations(Base):
|
||||
__tablename__ = "plan_recommendations"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
plan_id = Column(BigInteger, ForeignKey("plan.id"), nullable=False)
|
||||
recommendation_id = Column(
|
||||
BigInteger, ForeignKey("recommendation.id"), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ScenarioModel(Base):
|
||||
__tablename__ = "scenario"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
TIMESTAMP, nullable=False, server_default=func.now()
|
||||
)
|
||||
budget: Mapped[Optional[float]] = mapped_column(Float)
|
||||
portfolio_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey(Portfolio.id), nullable=False
|
||||
)
|
||||
housing_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
goal: Mapped[PortfolioGoal] = mapped_column(
|
||||
Enum(PortfolioGoal, values_callable=portfolio_goal_values, name="goal"),
|
||||
nullable=False,
|
||||
)
|
||||
goal_value: Mapped[str] = mapped_column(String, nullable=False)
|
||||
trigger_file_path: Mapped[str] = mapped_column(String, nullable=False)
|
||||
already_installed_file_path: Mapped[Optional[str]] = mapped_column(String)
|
||||
patches_file_path: Mapped[Optional[str]] = mapped_column(String)
|
||||
non_invasive_recommendations_file_path: Mapped[Optional[str]] = mapped_column(
|
||||
String
|
||||
)
|
||||
exclusions: Mapped[Optional[str]] = mapped_column(String)
|
||||
multi_plan: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Add in the fields we need, which were previously sitting at the portfolio level
|
||||
cost: Mapped[Optional[float]] = mapped_column(Float)
|
||||
contingency: Mapped[Optional[float]] = mapped_column(Float)
|
||||
funding: Mapped[Optional[float]] = mapped_column(Float)
|
||||
total_work_hours: Mapped[Optional[float]] = mapped_column(Float)
|
||||
energy_savings: Mapped[Optional[float]] = mapped_column(Float)
|
||||
co2_equivalent_savings: Mapped[Optional[float]] = mapped_column(Float)
|
||||
energy_cost_savings: Mapped[Optional[float]] = mapped_column(Float)
|
||||
epc_breakdown_pre_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
||||
epc_breakdown_post_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
||||
number_of_properties: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
n_units_to_retrofit: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
co2_per_unit_pre_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
||||
co2_per_unit_post_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
||||
energy_bill_per_unit_pre_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
||||
energy_bill_per_unit_post_retrofit: Mapped[Optional[str]] = mapped_column(String)
|
||||
energy_consumption_per_unit_pre_retrofit: Mapped[Optional[str]] = mapped_column(
|
||||
String
|
||||
)
|
||||
energy_consumption_per_unit_post_retrofit: Mapped[Optional[str]] = mapped_column(
|
||||
String
|
||||
)
|
||||
valuation_improvement_per_unit: Mapped[Optional[str]] = mapped_column(String)
|
||||
cost_per_unit: Mapped[Optional[str]] = mapped_column(String)
|
||||
cost_per_co2_saved: Mapped[Optional[str]] = mapped_column(String)
|
||||
cost_per_sap_point: Mapped[Optional[str]] = mapped_column(String)
|
||||
valuation_return_on_investment: Mapped[Optional[str]] = mapped_column(String)
|
||||
property_valuation_increase: Mapped[Optional[float]] = mapped_column(Float)
|
||||
labour_days: Mapped[Optional[float]] = mapped_column(Float)
|
||||
|
||||
|
||||
class MeasureType(enum.Enum):
|
||||
air_source_heat_pump = "air_source_heat_pump"
|
||||
boiler_upgrade = "boiler_upgrade"
|
||||
high_heat_retention_storage_heaters = "high_heat_retention_storage_heaters"
|
||||
secondary_heating = "secondary_heating"
|
||||
|
||||
roomstat_programmer_trvs = "roomstat_programmer_trvs"
|
||||
time_temperature_zone_control = "time_temperature_zone_control"
|
||||
cylinder_thermostat = "cylinder_thermostat"
|
||||
|
||||
cavity_wall_insulation = "cavity_wall_insulation"
|
||||
extension_cavity_wall_insulation = "extension_cavity_wall_insulation"
|
||||
external_wall_insulation = "external_wall_insulation"
|
||||
internal_wall_insulation = "internal_wall_insulation"
|
||||
loft_insulation = "loft_insulation"
|
||||
flat_roof_insulation = "flat_roof_insulation"
|
||||
room_roof_insulation = "room_roof_insulation"
|
||||
solid_floor_insulation = "solid_floor_insulation"
|
||||
suspended_floor_insulation = "suspended_floor_insulation"
|
||||
|
||||
double_glazing = "double_glazing"
|
||||
secondary_glazing = "secondary_glazing"
|
||||
draught_proofing = "draught_proofing"
|
||||
|
||||
mechanical_ventilation = "mechanical_ventilation"
|
||||
low_energy_lighting = "low_energy_lighting"
|
||||
solar_pv = "solar_pv"
|
||||
hot_water_tank_insulation = "hot_water_tank_insulation"
|
||||
sealing_open_fireplace = "sealing_open_fireplace"
|
||||
|
||||
|
||||
class InstalledMeasure(Base):
|
||||
__tablename__ = "installed_measure"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
uprn = Column(BigInteger, nullable=False)
|
||||
measure_type = Column(
|
||||
Enum(
|
||||
MeasureType,
|
||||
name="measure_type",
|
||||
values_callable=lambda e: [m.value for m in e],
|
||||
create_type=False, # <-- critical
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
installed_at = Column(TIMESTAMP)
|
||||
sap_points = Column(Float)
|
||||
carbon_savings = Column(Float)
|
||||
kwh_savings = Column(Float)
|
||||
bill_savings = Column(Float)
|
||||
heat_demand_savings = Column(Float)
|
||||
source = Column(String)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
def enum_values(e: Iterable[PlanTypeEnum]) -> list[str]:
|
||||
return [m.value for m in e]
|
||||
__all__ = [
|
||||
"PlanModel",
|
||||
"ScenarioModel",
|
||||
"Recommendation",
|
||||
"RecommendationMaterials",
|
||||
"InstalledMeasure",
|
||||
"PlanTypeEnum",
|
||||
"PlanPersistence",
|
||||
]
|
||||
|
||||
|
||||
class PlanPersistence(NamedTuple):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
from backend.app.db.models.magic_plan import (
|
||||
from typing import Any, cast
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from infrastructure.postgres.magic_plan_tables import (
|
||||
MagicPlanDoorModel,
|
||||
MagicPlanFloorModel,
|
||||
MagicPlanPlanModel,
|
||||
|
|
@ -6,6 +10,10 @@ from backend.app.db.models.magic_plan import (
|
|||
MagicPlanWindowModel,
|
||||
)
|
||||
|
||||
|
||||
def _table(model: type[Any]) -> sa.Table:
|
||||
return cast(sa.Table, getattr(model, "__table__"))
|
||||
|
||||
# --- MagicPlanPlan ---
|
||||
|
||||
|
||||
|
|
@ -14,20 +22,17 @@ def test_plan_table_name() -> None:
|
|||
|
||||
|
||||
def test_plan_has_magic_plan_uid_column() -> None:
|
||||
assert "magic_plan_uid" in MagicPlanPlanModel.__table__.columns
|
||||
assert "magic_plan_uid" in _table(MagicPlanPlanModel).columns
|
||||
|
||||
|
||||
def test_plan_magic_plan_uid_is_unique() -> None:
|
||||
col = MagicPlanPlanModel.__table__.columns["magic_plan_uid"]
|
||||
assert (
|
||||
any(
|
||||
c.unique
|
||||
for c in MagicPlanPlanModel.__table__.constraints
|
||||
if hasattr(c, "columns")
|
||||
and "magic_plan_uid" in [cc.name for cc in c.columns]
|
||||
)
|
||||
or col.unique
|
||||
t = _table(MagicPlanPlanModel)
|
||||
col = t.columns["magic_plan_uid"]
|
||||
has_unique_constraint = any(
|
||||
isinstance(c, sa.UniqueConstraint) and "magic_plan_uid" in c.columns
|
||||
for c in t.constraints
|
||||
)
|
||||
assert has_unique_constraint or col.unique
|
||||
|
||||
|
||||
def test_plan_instantiation() -> None:
|
||||
|
|
@ -47,7 +52,7 @@ def test_floor_table_name() -> None:
|
|||
|
||||
|
||||
def test_floor_fk_column_name() -> None:
|
||||
assert "magic_plan_plan_id" in MagicPlanFloorModel.__table__.columns
|
||||
assert "magic_plan_plan_id" in _table(MagicPlanFloorModel).columns
|
||||
|
||||
|
||||
def test_floor_has_level() -> None:
|
||||
|
|
@ -63,11 +68,11 @@ def test_room_table_name() -> None:
|
|||
|
||||
|
||||
def test_room_fk_column_name() -> None:
|
||||
assert "magic_plan_floor_id" in MagicPlanRoomModel.__table__.columns
|
||||
assert "magic_plan_floor_id" in _table(MagicPlanRoomModel).columns
|
||||
|
||||
|
||||
def test_room_has_measurement_columns() -> None:
|
||||
cols = MagicPlanRoomModel.__table__.columns
|
||||
cols = _table(MagicPlanRoomModel).columns
|
||||
assert "width_m" in cols
|
||||
assert "length_m" in cols
|
||||
assert "area_m2" in cols
|
||||
|
|
@ -89,15 +94,14 @@ def test_window_table_name() -> None:
|
|||
|
||||
|
||||
def test_window_fk_column_name() -> None:
|
||||
assert "magic_plan_room_id" in MagicPlanWindowModel.__table__.columns
|
||||
assert "magic_plan_room_id" in _table(MagicPlanWindowModel).columns
|
||||
|
||||
|
||||
def test_window_has_measurement_columns() -> None:
|
||||
cols = MagicPlanWindowModel.__table__.columns
|
||||
cols = _table(MagicPlanWindowModel).columns
|
||||
assert "width_m" in cols
|
||||
assert "height_m" in cols
|
||||
assert "area_m2" in cols
|
||||
assert "opening_type" in cols
|
||||
|
||||
|
||||
def test_window_instantiation() -> None:
|
||||
|
|
@ -106,9 +110,8 @@ def test_window_instantiation() -> None:
|
|||
width_m=1.4,
|
||||
height_m=1.2,
|
||||
area_m2=1.68,
|
||||
opening_type="casement",
|
||||
)
|
||||
assert window.opening_type == "casement"
|
||||
assert window.width_m == 1.4
|
||||
|
||||
|
||||
# --- MagicPlanDoor ---
|
||||
|
|
@ -119,16 +122,16 @@ def test_door_table_name() -> None:
|
|||
|
||||
|
||||
def test_door_fk_column_name() -> None:
|
||||
assert "magic_plan_room_id" in MagicPlanDoorModel.__table__.columns
|
||||
assert "magic_plan_room_id" in _table(MagicPlanDoorModel).columns
|
||||
|
||||
|
||||
def test_door_has_width_mm_and_type() -> None:
|
||||
cols = MagicPlanDoorModel.__table__.columns
|
||||
cols = _table(MagicPlanDoorModel).columns
|
||||
assert "width_mm" in cols
|
||||
assert "type" in cols
|
||||
|
||||
|
||||
def test_door_instantiation() -> None:
|
||||
door = MagicPlanDoorModel(magic_plan_room_id=1, width_mm=0.79, type="hinged")
|
||||
assert door.width_mm == 0.79
|
||||
door = MagicPlanDoorModel(magic_plan_room_id=1, width_mm=790.0, type="hinged")
|
||||
assert door.width_mm == 790.0
|
||||
assert door.type == "hinged"
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import enum
|
||||
from sqlalchemy import TIMESTAMP, BigInteger, Column, Text, Enum as SqlEnum
|
||||
|
||||
from backend.app.db.base import Base
|
||||
|
||||
|
||||
class FileTypeEnum(enum.Enum):
|
||||
PHOTO_PACK = "photo_pack"
|
||||
SITE_NOTE = "site_note"
|
||||
RD_SAP_SITE_NOTE = "rd_sap_site_note"
|
||||
PAS_2023_VENTILATION = "pas_2023_ventilation"
|
||||
PAS_2023_CONDITION = "pas_2023_condition"
|
||||
PAS_SIGNIFICANCE = "pas_significance"
|
||||
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"
|
||||
ECMK_SURVEY_XML = "ecmk_survey_xml"
|
||||
MAGIC_PLAN_JSON = "magic_plan_json"
|
||||
IMPROVEMENT_OPTION_EVALUATION = "improvement_option_evaluation"
|
||||
MEDIUM_TERM_IMPROVEMENT_PLAN = "medium_term_improvement_plan"
|
||||
RETROFIT_DESIGN_DOC = "retrofit_design_doc"
|
||||
MCS_COMPLIANCE_CERTIFICATE = "mcs_compliance_certificate"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class FileSourceEnum(enum.Enum):
|
||||
PAS_HUB = "pas hub"
|
||||
COORDINATION_HUB = "coordination_hub"
|
||||
SHAREPOINT = "sharepoint"
|
||||
HUBSPOT = "hubspot"
|
||||
ECMK = "ecmk"
|
||||
MAGIC_PLAN = "magic_plan"
|
||||
|
||||
|
||||
class UploadedFile(Base):
|
||||
__tablename__ = "uploaded_files"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
|
||||
s3_file_bucket = Column(Text, nullable=False)
|
||||
s3_file_key = Column(Text, nullable=False)
|
||||
s3_upload_timestamp = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||
|
||||
landlord_property_id = Column(Text, nullable=True)
|
||||
uprn = Column(BigInteger, nullable=True)
|
||||
hubspot_listing_id = Column(BigInteger, nullable=True)
|
||||
hubspot_deal_id = Column(Text, nullable=True)
|
||||
|
||||
file_type = Column(
|
||||
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,
|
||||
values_callable=lambda enum_cls: [e.value for e in enum_cls],
|
||||
),
|
||||
nullable=True,
|
||||
)
|
||||
|
|
@ -17,6 +17,11 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
COPY utils/ utils/
|
||||
COPY backend/ backend/
|
||||
COPY datatypes/ datatypes/
|
||||
# backend.app.db.models.recommendations imports infrastructure.postgres.modelling,
|
||||
# which imports domain.modelling; without these the lambda fails at init with
|
||||
# "No module named 'infrastructure'" / "'domain'".
|
||||
COPY infrastructure/ infrastructure/
|
||||
COPY domain/ domain/
|
||||
|
||||
COPY backend/bulk_address2uprn_combiner/main.py .
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ COPY utils/ utils/
|
|||
# NOTE: if build is ever slow we can be more specific with which files are copied
|
||||
COPY backend/ backend/
|
||||
COPY datatypes/ datatypes/
|
||||
# backend.app.db.models.recommendations imports infrastructure.postgres.modelling,
|
||||
# which imports domain.modelling; without these the lambda fails at init with
|
||||
# "No module named 'infrastructure'" / "'domain'".
|
||||
COPY infrastructure/ infrastructure/
|
||||
COPY domain/ domain/
|
||||
|
||||
|
||||
# -----------------------------
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ COPY ./utils/ ./utils/
|
|||
# engine.py -> backend.apis.GoogleSolarApi -> infrastructure.solar; without this
|
||||
# the lambda fails at init with "No module named 'infrastructure'".
|
||||
COPY ./infrastructure/ ./infrastructure/
|
||||
# backend.app.db.models.portfolio re-exports PortfolioGoal from
|
||||
# domain.modelling.portfolio_goal (ADR-0017 amendment); without this the lambda
|
||||
# fails at init with "No module named 'domain'".
|
||||
COPY ./domain/ ./domain/
|
||||
COPY ./etl/epc/ ./etl/epc/
|
||||
COPY ./etl/epc_clean/ ./etl/epc_clean/
|
||||
COPY ./etl/bill_savings/ ./etl/bill_savings/
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
|||
cert_to_demand_inputs,
|
||||
cert_to_inputs,
|
||||
)
|
||||
from tests.utilities.floats import assert_float_matches
|
||||
|
||||
|
||||
_CORPUS_ROOT = (
|
||||
|
|
@ -1002,9 +1003,13 @@ def test_oil_6_no_room_thermostat_applies_table_4c2_minus_5pp_space_efficiency()
|
|||
|
||||
# Assert — Table 4b 80% winter less the Table 4c(2) -5pp interlock
|
||||
# penalty = 75% (matches worksheet (210)).
|
||||
assert abs(inputs.main_heating_efficiency - 0.75) <= 1e-9, (
|
||||
f"oil 6 space efficiency {inputs.main_heating_efficiency:.4f} "
|
||||
f"!= 0.75 (Table 4b 0.80 - Table 4c(2) 0.05 interlock penalty)"
|
||||
assert_float_matches(
|
||||
inputs.main_heating_efficiency,
|
||||
0.75,
|
||||
msg=(
|
||||
f"oil 6 space efficiency {inputs.main_heating_efficiency:.4f} "
|
||||
f"!= 0.75 (Table 4b 0.80 - Table 4c(2) 0.05 interlock penalty)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1030,9 +1035,13 @@ def test_oil_6_absent_room_thermostat_applies_table_4f_pump_1_3_multiplier() ->
|
|||
|
||||
# Assert — 41 x 1.3 (circulation pump) + 100 (oil flue fan/pump) =
|
||||
# 153.3 kWh (matches worksheet (231)).
|
||||
assert abs(inputs.pumps_fans_kwh_per_yr - 153.3) <= 1e-9, (
|
||||
f"oil 6 pumps/fans {inputs.pumps_fans_kwh_per_yr:.4f} kWh "
|
||||
f"!= 153.3 (41 x 1.3 absent-room-thermostat pump + 100 oil aux)"
|
||||
assert_float_matches(
|
||||
inputs.pumps_fans_kwh_per_yr,
|
||||
153.3,
|
||||
msg=(
|
||||
f"oil 6 pumps/fans {inputs.pumps_fans_kwh_per_yr:.4f} kWh "
|
||||
f"!= 153.3 (41 x 1.3 absent-room-thermostat pump + 100 oil aux)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
|||
heat_transmission_section_from_cert,
|
||||
)
|
||||
from domain.sap10_ml.rdsap_uvalues import u_party_wall
|
||||
from tests.utilities.floats import assert_float_matches
|
||||
from tests.domain.sap10_calculator.worksheet import (
|
||||
_elmhurst_worksheet_000474 as _w000474,
|
||||
_elmhurst_worksheet_000477 as _w000477,
|
||||
|
|
@ -1851,8 +1852,8 @@ def test_extension_party_wall_type_read_independently_of_as_main_wall() -> None:
|
|||
f"expected Main=4 (CU, U=0.5) + Ext=0 (Unable, U=0.25), got {party_codes}"
|
||||
)
|
||||
# The two map to different SAP party-wall U-values.
|
||||
assert abs(u_party_wall(4) - 0.5) <= 1e-9
|
||||
assert abs(u_party_wall(0) - 0.25) <= 1e-9
|
||||
assert_float_matches(u_party_wall(4), 0.5)
|
||||
assert_float_matches(u_party_wall(0), 0.25)
|
||||
|
||||
|
||||
def test_summary_mapper_raises_on_unmapped_glazing_type_label() -> None:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from backend.app.db.connection import db_session
|
|||
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 infrastructure.postgres.uploaded_file_table import FileSourceEnum, FileTypeEnum
|
||||
from backend.documents_parser.db_writer import save_epc_property_data
|
||||
from backend.documents_parser.parser import parse_site_notes_pdf
|
||||
from backend.ecmk_fetcher.address_list import (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from enum import Enum
|
||||
|
||||
from backend.app.db.models.uploaded_file import FileTypeEnum
|
||||
from infrastructure.postgres.uploaded_file_table import FileTypeEnum
|
||||
|
||||
|
||||
class FileDownloadButtonType(Enum):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from typing import Dict
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from backend.app.db.models.uploaded_file import FileTypeEnum
|
||||
from infrastructure.postgres.uploaded_file_table import FileTypeEnum
|
||||
from backend.ecmk_fetcher.address_list import PropertyRow
|
||||
from backend.ecmk_fetcher.ecmk_service import EcmkService
|
||||
from backend.ecmk_fetcher.reports import FileDownloadButtonType
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from unittest.mock import MagicMock, call, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from backend.app.db.models.uploaded_file import FileTypeEnum
|
||||
from infrastructure.postgres.uploaded_file_table import FileTypeEnum
|
||||
from backend.ecmk_fetcher.upload import upload_file_to_s3_and_record
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import os
|
|||
from typing import cast
|
||||
|
||||
from backend.app.db.connection import db_session
|
||||
from backend.app.db.models.uploaded_file import (
|
||||
from infrastructure.postgres.uploaded_file_table import (
|
||||
FileSourceEnum,
|
||||
FileTypeEnum,
|
||||
UploadedFile,
|
||||
|
|
|
|||
1000
backend/epc_api/json_samples/RdSAP-Schema-17.0/corpus.jsonl
Normal file
1000
backend/epc_api/json_samples/RdSAP-Schema-17.0/corpus.jsonl
Normal file
File diff suppressed because one or more lines are too long
1000
backend/epc_api/json_samples/RdSAP-Schema-17.1/corpus.jsonl
Normal file
1000
backend/epc_api/json_samples/RdSAP-Schema-17.1/corpus.jsonl
Normal file
File diff suppressed because one or more lines are too long
1000
backend/epc_api/json_samples/RdSAP-Schema-18.0/corpus.jsonl
Normal file
1000
backend/epc_api/json_samples/RdSAP-Schema-18.0/corpus.jsonl
Normal file
File diff suppressed because one or more lines are too long
1000
backend/epc_api/json_samples/RdSAP-Schema-19.0/corpus.jsonl
Normal file
1000
backend/epc_api/json_samples/RdSAP-Schema-19.0/corpus.jsonl
Normal file
File diff suppressed because one or more lines are too long
1000
backend/epc_api/json_samples/RdSAP-Schema-20.0.0/corpus.jsonl
Normal file
1000
backend/epc_api/json_samples/RdSAP-Schema-20.0.0/corpus.jsonl
Normal file
File diff suppressed because one or more lines are too long
1000
backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl
Normal file
1000
backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -8,7 +8,6 @@ from collections import defaultdict
|
|||
from backend.app.db.models.recommendations import (
|
||||
Recommendation,
|
||||
PlanModel,
|
||||
PlanRecommendations,
|
||||
RecommendationMaterials,
|
||||
)
|
||||
from backend.app.db.models.portfolio import (
|
||||
|
|
@ -157,13 +156,9 @@ class DbMethods:
|
|||
|
||||
stmt = (
|
||||
select(Recommendation, PlanModel.scenario_id, PlanModel.name)
|
||||
.join(
|
||||
PlanRecommendations,
|
||||
Recommendation.id == PlanRecommendations.recommendation_id,
|
||||
)
|
||||
.join(PlanModel, PlanModel.id == PlanRecommendations.plan_id)
|
||||
.join(PlanModel, PlanModel.id == Recommendation.plan_id)
|
||||
.where(
|
||||
PlanRecommendations.plan_id.in_(plan_ids),
|
||||
Recommendation.plan_id.in_(plan_ids),
|
||||
Recommendation.default.is_(True),
|
||||
Recommendation.already_installed.is_(False),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,17 +25,23 @@ def engine(postgresql):
|
|||
|
||||
engine = create_engine(connection_string)
|
||||
|
||||
# Create tables once per test session
|
||||
Base.metadata.create_all(engine)
|
||||
# Create tables once per test session. SQLModel first: the Modelling tables
|
||||
# (`plan` / `recommendation` / …) are SQLModel definitions, and Base tables
|
||||
# FK them (`funding_package` → `plan`), so they must exist before Base's
|
||||
# create_all runs (ADR-0017 amendment — single model per table).
|
||||
SQLModel.metadata.create_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# Yeild will split this function into two phase. 1) setup and 2) teardown, the latter of which will run after all
|
||||
# tests have completed
|
||||
yield engine
|
||||
|
||||
# Clean-up after entire test session
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
Base.metadata.drop_all(engine)
|
||||
# The `postgresql` fixture is function-scoped — a fresh, throwaway database
|
||||
# per test — so an explicit drop_all is redundant. We skip it: the `epc`
|
||||
# Postgres enum type is now shared across both metadatas (Base `portfolio`
|
||||
# tables and the SQLModel `plan`), and a two-phase metadata drop cannot drop
|
||||
# a cross-metadata type cleanly (ADR-0017 amendment). Disposing the engine
|
||||
# and letting the fixture discard the database is correct and conflict-free.
|
||||
engine.dispose()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ from backend.app.db.models.portfolio import (
|
|||
from backend.app.db.models.recommendations import (
|
||||
PlanModel,
|
||||
Recommendation,
|
||||
PlanRecommendations,
|
||||
RecommendationMaterials,
|
||||
)
|
||||
from backend.app.db.models.materials import Material
|
||||
|
|
@ -171,13 +170,17 @@ def test_default_export_integration(db_session):
|
|||
# 5) Insert recommendation
|
||||
# ----------------------------------------
|
||||
|
||||
rec_to_plan = dict(
|
||||
zip(plan_recs_df["recommendation_id"], plan_recs_df["plan_id"])
|
||||
)
|
||||
recs = [
|
||||
Recommendation(
|
||||
plan_id=rec_to_plan.get(row["id"]),
|
||||
**{
|
||||
col: row[col]
|
||||
for col in Recommendation.__table__.columns.keys()
|
||||
if col in row
|
||||
}
|
||||
if col in row and col != "plan_id"
|
||||
},
|
||||
)
|
||||
for _, row in recommendations_df.iterrows()
|
||||
]
|
||||
|
|
@ -185,18 +188,9 @@ def test_default_export_integration(db_session):
|
|||
db_session.bulk_save_objects(recs)
|
||||
db_session.flush()
|
||||
|
||||
# ----------------------------------------
|
||||
# 6) Insert PlanRecommendations
|
||||
# ----------------------------------------
|
||||
links = [
|
||||
PlanRecommendations(
|
||||
plan_id=row.plan_id,
|
||||
recommendation_id=row.recommendation_id,
|
||||
)
|
||||
for row in plan_recs_df.itertuples(index=False)
|
||||
]
|
||||
|
||||
db_session.bulk_save_objects(links)
|
||||
# Recommendations are linked to their plan by recommendation.plan_id (set
|
||||
# above from plan_recs_df) — the plan_recommendations m2m is retired
|
||||
# (ADR-0017 amendment).
|
||||
db_session.commit()
|
||||
logger.info("Inserted all data in %.2f seconds", time.perf_counter() - db_load_t0)
|
||||
|
||||
|
|
@ -607,9 +601,11 @@ def test_solar_with_battery_example(db_session):
|
|||
# -------------------------------------------------
|
||||
recommendations_df.loc[0, "measure_type"] = "solar_pv"
|
||||
|
||||
rec_to_plan = dict(zip(plan_recs_df.recommendation_id, plan_recs_df.plan_id))
|
||||
for row in recommendations_df.itertuples(index=False):
|
||||
rec = Recommendation(
|
||||
id=row.id,
|
||||
plan_id=rec_to_plan.get(row.id),
|
||||
property_id=row.property_id,
|
||||
measure_type=row.measure_type,
|
||||
estimated_cost=row.estimated_cost,
|
||||
|
|
@ -622,17 +618,8 @@ def test_solar_with_battery_example(db_session):
|
|||
db_session.add(rec)
|
||||
db_session.flush()
|
||||
|
||||
# -------------------------------------------------
|
||||
# Link Plan -> Recommendation
|
||||
# -------------------------------------------------
|
||||
for row in plan_recs_df.itertuples(index=False):
|
||||
db_session.add(
|
||||
PlanRecommendations(
|
||||
plan_id=row.plan_id,
|
||||
recommendation_id=row.recommendation_id,
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
# Plan ↔ Recommendation link is recommendation.plan_id (set above) — the
|
||||
# plan_recommendations m2m is retired (ADR-0017 amendment).
|
||||
|
||||
# -------------------------------------------------
|
||||
# Insert Material (includes_battery=True)
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
from typing import Any
|
||||
|
||||
from backend.app.config import get_settings
|
||||
from backend.magic_plan.magic_plan_client import MagicPlanClient
|
||||
from backend.magic_plan.magic_plan_service import MagicPlanService
|
||||
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
|
||||
from datatypes.magicplan.domain.models import Plan
|
||||
from backend.app.db.models.tasks import SourceEnum
|
||||
from backend.utils.subtasks import task_handler
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
@task_handler(task_source="magic_plan", source=SourceEnum.HUBSPOT_DEAL)
|
||||
def handler(body: dict[str, Any], context: Any) -> str:
|
||||
settings = get_settings()
|
||||
payload = MagicPlanTriggerRequest.model_validate(body)
|
||||
client = MagicPlanClient(
|
||||
customer_id=settings.MAGICPLAN_CUSTOMER_ID,
|
||||
api_key=settings.MAGICPLAN_API_KEY,
|
||||
)
|
||||
# TODO: read s3_bucket from env var so staging/prod use the correct bucket
|
||||
plan: Plan = MagicPlanService(
|
||||
client, s3_bucket="retrofit-energy-assessments-dev"
|
||||
).run(payload)
|
||||
logger.info("Saved MagicPlan plan uid=%s", plan.uid)
|
||||
return plan.uid
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
event = {
|
||||
"Records": [
|
||||
{
|
||||
"body": '{"address": "2 Laburnum Way Bromley BR2 8BZ", "hubspot_deal_id": "local-test-deal"}',
|
||||
}
|
||||
]
|
||||
}
|
||||
handler(event, None)
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
FROM public.ecr.aws/lambda/python:3.11
|
||||
|
||||
WORKDIR /var/task
|
||||
|
||||
COPY backend/magic_plan/handler/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY utils/ utils/
|
||||
COPY backend/ backend/
|
||||
COPY datatypes/ datatypes/
|
||||
|
||||
CMD ["backend.magic_plan.handler.handler"]
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
#!/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": [
|
||||
{
|
||||
"messageId": "test-message-id",
|
||||
"body": json.dumps(
|
||||
# {
|
||||
# "address": "2 Laburnum Way, Rombley, BR2 8BZ | Retrofit Assessment",
|
||||
# "hubspot_deal_id": "500262906061",
|
||||
# }
|
||||
{"address": "33 Wallaby Way, Sydney", "hubspot_deal_id": "123456789"}
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(LAMBDA_URL, json=payload)
|
||||
|
||||
print("Status code:", response.status_code)
|
||||
print("Response:")
|
||||
print(response.text)
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import gzip
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, cast
|
||||
|
||||
from datatypes.magicplan.api.response import MagicPlanPlan, PlanSummary
|
||||
from datatypes.magicplan.domain.mapper import map_plan
|
||||
from datatypes.magicplan.domain.models import Plan
|
||||
|
||||
from backend.app.db.connection import db_session
|
||||
from backend.app.db.functions.magic_plan_functions import save_plan
|
||||
from backend.app.db.models.uploaded_file import (
|
||||
FileSourceEnum,
|
||||
FileTypeEnum,
|
||||
UploadedFile,
|
||||
)
|
||||
from backend.magic_plan.address_matcher import find_matching_plan
|
||||
from backend.magic_plan.magic_plan_client import MagicPlanClient
|
||||
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
|
||||
from utils.logger import setup_logger
|
||||
from utils.s3 import save_data_to_s3
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
class MagicPlanService:
|
||||
def __init__(self, client: MagicPlanClient, s3_bucket: str) -> None:
|
||||
self._client = client
|
||||
self._s3_bucket = s3_bucket
|
||||
|
||||
def run(self, request: MagicPlanTriggerRequest) -> Plan:
|
||||
address = request.address
|
||||
uprn = request.uprn
|
||||
|
||||
if uprn is not None:
|
||||
logger.info("MagicPlanService.run uprn=%s", uprn)
|
||||
|
||||
plans: list[PlanSummary] = self._client.get_plans()
|
||||
matched: Optional[PlanSummary] = find_matching_plan(plans, address)
|
||||
|
||||
if matched is None:
|
||||
raise ValueError(f"No MagicPlan found for address: {address!r}")
|
||||
|
||||
raw_bytes: bytes = self._client.get_plan_raw(matched.id)
|
||||
magic_plan: MagicPlanPlan = MagicPlanPlan.model_validate(
|
||||
json.loads(raw_bytes)["data"]
|
||||
)
|
||||
plan: Plan = map_plan(magic_plan)
|
||||
|
||||
uploaded_file: UploadedFile = self._upload_raw_plan_json(
|
||||
plan_id=matched.id,
|
||||
raw_bytes=raw_bytes,
|
||||
uprn=uprn,
|
||||
hubspot_deal_id=request.hubspot_deal_id,
|
||||
)
|
||||
|
||||
with db_session() as session:
|
||||
session.add(uploaded_file)
|
||||
session.flush()
|
||||
save_plan(session, plan, cast(int, uploaded_file.id))
|
||||
|
||||
return plan
|
||||
|
||||
def _upload_raw_plan_json(
|
||||
self,
|
||||
plan_id: str,
|
||||
raw_bytes: bytes,
|
||||
uprn: Optional[str],
|
||||
hubspot_deal_id: str,
|
||||
) -> UploadedFile:
|
||||
compressed = gzip.compress(raw_bytes)
|
||||
if uprn is not None:
|
||||
s3_key = f"documents/uprn/{uprn}/magic_plan_{plan_id}.json.gz"
|
||||
else:
|
||||
s3_key = f"documents/hubspot_deal_id/{hubspot_deal_id}/magic_plan_{plan_id}.json.gz"
|
||||
save_data_to_s3(compressed, self._s3_bucket, s3_key)
|
||||
return UploadedFile(
|
||||
s3_file_bucket=self._s3_bucket,
|
||||
s3_file_key=s3_key,
|
||||
s3_upload_timestamp=datetime.now(timezone.utc),
|
||||
uprn=int(uprn) if uprn is not None else None,
|
||||
hubspot_deal_id=hubspot_deal_id,
|
||||
file_source=FileSourceEnum.MAGIC_PLAN.value,
|
||||
file_type=FileTypeEnum.MAGIC_PLAN_JSON.value,
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,109 +0,0 @@
|
|||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.magic_plan.handler import handler
|
||||
|
||||
ADDRESS = "2 Laburnum Way Bromley BR2 8BZ"
|
||||
PLAN_UID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
|
||||
|
||||
|
||||
def _make_settings(**overrides: str) -> MagicMock:
|
||||
settings = MagicMock()
|
||||
settings.MAGICPLAN_CUSTOMER_ID = overrides.get("customer_id", "cust-123")
|
||||
settings.MAGICPLAN_API_KEY = overrides.get("api_key", "key-abc")
|
||||
return settings
|
||||
|
||||
|
||||
def _call_handler(body: dict[str, Any]) -> Any:
|
||||
return handler.__wrapped__(body, None) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_plan() -> MagicMock:
|
||||
plan = MagicMock()
|
||||
plan.uid = PLAN_UID
|
||||
return plan
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_service(mock_plan: MagicMock) -> MagicMock:
|
||||
service = MagicMock()
|
||||
service.run.return_value = mock_plan
|
||||
return service
|
||||
|
||||
|
||||
# --- request validation ---
|
||||
|
||||
|
||||
def test_handler_raises_on_missing_address(mock_plan: MagicMock) -> None:
|
||||
# Arrange
|
||||
body: dict[str, Any] = {}
|
||||
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
|
||||
patch("backend.magic_plan.handler.MagicPlanClient"), \
|
||||
patch("backend.magic_plan.handler.MagicPlanService"):
|
||||
# Act / Assert
|
||||
with pytest.raises(ValidationError):
|
||||
_call_handler(body)
|
||||
|
||||
|
||||
# --- client construction ---
|
||||
|
||||
|
||||
def test_handler_constructs_client_from_settings(mock_service: MagicMock) -> None:
|
||||
# Arrange
|
||||
body = {"address": ADDRESS, "hubspot_deal_id": "deal-123"}
|
||||
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings(customer_id="cust-xyz", api_key="key-xyz")), \
|
||||
patch("backend.magic_plan.handler.MagicPlanClient") as MockClient, \
|
||||
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
|
||||
# Act
|
||||
_call_handler(body)
|
||||
# Assert
|
||||
MockClient.assert_called_once_with(customer_id="cust-xyz", api_key="key-xyz")
|
||||
|
||||
|
||||
# --- service orchestration ---
|
||||
|
||||
|
||||
def test_handler_calls_service_run_with_address(mock_service: MagicMock) -> None:
|
||||
# Arrange
|
||||
body = {"address": ADDRESS, "hubspot_deal_id": "deal-123"}
|
||||
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
|
||||
patch("backend.magic_plan.handler.MagicPlanClient"), \
|
||||
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
|
||||
# Act
|
||||
_call_handler(body)
|
||||
# Assert
|
||||
mock_service.run.assert_called_once()
|
||||
request = mock_service.run.call_args.args[0]
|
||||
assert request.address == ADDRESS
|
||||
assert request.uprn is None
|
||||
|
||||
|
||||
def test_handler_passes_uprn_to_service(mock_service: MagicMock) -> None:
|
||||
# Arrange
|
||||
body = {"address": ADDRESS, "uprn": "100023336956", "hubspot_deal_id": "deal-123"}
|
||||
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
|
||||
patch("backend.magic_plan.handler.MagicPlanClient"), \
|
||||
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
|
||||
# Act
|
||||
_call_handler(body)
|
||||
# Assert
|
||||
mock_service.run.assert_called_once()
|
||||
request = mock_service.run.call_args.args[0]
|
||||
assert request.address == ADDRESS
|
||||
assert request.uprn == "100023336956"
|
||||
|
||||
|
||||
def test_handler_returns_plan_uid(mock_service: MagicMock) -> None:
|
||||
# Arrange
|
||||
body = {"address": ADDRESS, "hubspot_deal_id": "deal-123"}
|
||||
with patch("backend.magic_plan.handler.get_settings", return_value=_make_settings()), \
|
||||
patch("backend.magic_plan.handler.MagicPlanClient"), \
|
||||
patch("backend.magic_plan.handler.MagicPlanService", return_value=mock_service):
|
||||
# Act
|
||||
result = _call_handler(body)
|
||||
# Assert
|
||||
assert result == PLAN_UID
|
||||
|
|
@ -19,6 +19,11 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
COPY utils/ utils/
|
||||
COPY backend/ backend/
|
||||
COPY datatypes/ datatypes/
|
||||
# backend.app.db.models.recommendations imports infrastructure.postgres.modelling,
|
||||
# which imports domain.modelling; without these the lambda fails at init with
|
||||
# "No module named 'infrastructure'" / "'domain'".
|
||||
COPY infrastructure/ infrastructure/
|
||||
COPY domain/ domain/
|
||||
|
||||
# Lambda handler
|
||||
CMD ["backend/ordnanceSurvey/main.handler"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from backend.app.db.models.uploaded_file import FileTypeEnum
|
||||
from infrastructure.postgres.uploaded_file_table import FileTypeEnum
|
||||
|
||||
|
||||
class CoreFiles(Enum):
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
|||
from typing import Callable, List, NamedTuple, Optional, cast
|
||||
|
||||
from backend.app.db.connection import db_session
|
||||
from backend.app.db.models.uploaded_file import (
|
||||
from infrastructure.postgres.uploaded_file_table import (
|
||||
FileSourceEnum,
|
||||
FileTypeEnum,
|
||||
UploadedFile,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import Any, Callable, Optional
|
|||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
|
||||
from backend.app.db.models.uploaded_file import FileSourceEnum, FileTypeEnum
|
||||
from infrastructure.postgres.uploaded_file_table import FileSourceEnum, FileTypeEnum
|
||||
from backend.pashub_fetcher.pashub_client import (
|
||||
DownloadedFile,
|
||||
DownloadedFiles,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
COPY utils/ utils/
|
||||
COPY backend/ backend/
|
||||
COPY datatypes/ datatypes/
|
||||
# backend.app.db.models.recommendations imports infrastructure.postgres.modelling,
|
||||
# which imports domain.modelling; without these the lambda fails at init with
|
||||
# "No module named 'infrastructure'" / "'domain'".
|
||||
COPY infrastructure/ infrastructure/
|
||||
COPY domain/ domain/
|
||||
|
||||
# Copy the handler
|
||||
COPY backend/postcode_splitter/main.py .
|
||||
|
|
|
|||
126
backlog/ventilation-audit-generator.md
Normal file
126
backlog/ventilation-audit-generator.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# PRD: Ventilation Audit Generator from MagicPlan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When a surveyor completes a MagicPlan survey for a property, the resulting floor plan data (rooms, windows, doors, ventilation measurements) needs to be transformed into a structured ventilation audit spreadsheet. Currently this transformation is manual — someone must extract plan data and populate a report by hand, which is slow and error-prone.
|
||||
|
||||
## Solution
|
||||
|
||||
An AWS Lambda (`audit-generator`) triggered via SQS receives a HubSpot deal ID, fetches the parsed MagicPlan `Plan` from the database, populates a pre-formatted `.xlsx` template with plan data, uploads the result to S3, and records it in `uploaded_files`. The populated spreadsheet is then accessible to the UI so the user knows an audit file exists for that deal.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a coordinator, I want clicking a button in the UI to trigger generation of a ventilation audit spreadsheet, so that I do not have to manually populate it from the floor plan.
|
||||
2. As a coordinator, I want the audit spreadsheet to be automatically populated with room, window, and door data from the MagicPlan survey, so that the data entry step is eliminated.
|
||||
3. As a coordinator, I want the system to use a pre-formatted `.xlsx` template when generating the audit, so that conditional formatting and layout are preserved without requiring code changes.
|
||||
4. As a coordinator, I want the UI to indicate whether a ventilation audit already exists for a deal, so that I avoid triggering duplicate generation unnecessarily.
|
||||
5. As a coordinator, I want re-triggering generation to overwrite the previous audit file, so that I can regenerate after a corrected survey is uploaded.
|
||||
6. As an engineer, I want the lambda to raise a clear error if no MagicPlan JSON has been uploaded for the deal, so that misconfigured triggers are diagnosed quickly.
|
||||
7. As an engineer, I want the lambda to raise a distinct error if a MagicPlan JSON exists but has not yet been parsed into the database, so that timing issues are distinguishable from missing data.
|
||||
8. As an engineer, I want the generated spreadsheet recorded in `uploaded_files` with a `VENTILATION_AUDIT` file type, so that the UI and other systems can query for its existence.
|
||||
9. As an engineer, I want the lambda to follow the `@subtask_handler()` pattern, so that it integrates with the task orchestration system and benefits from standard error handling and observability.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
- **Lambda pattern**: `@subtask_handler()` decorator. Trigger body contains `task_id`, `sub_task_id`, and `hubspot_deal_id`.
|
||||
|
||||
- **MAGIC_PLAN_JSON lookup**: Query `uploaded_files` filtered by `hubspot_deal_id` and `file_type = MAGIC_PLAN_JSON`, ordered by `s3_upload_timestamp DESC`, taking the most recent row. Rationale: a re-upload supersedes the earlier file.
|
||||
|
||||
- **Plan retrieval**: Use the existing `MagicPlanPostgresRepository.get_plan_by_uploaded_file_id` to fetch the parsed domain `Plan` from postgres. The lambda does not re-parse from S3 — that is the magic_plan lambda's responsibility.
|
||||
|
||||
- **Error handling — two distinct cases**:
|
||||
- No `uploaded_files` row found → raise with message indicating no MagicPlan has been uploaded for this deal.
|
||||
- Row found but `get_plan_by_uploaded_file_id` returns `None` → raise with message indicating the plan has been uploaded but not yet parsed.
|
||||
- Both use the same exception type; distinct messages enable diagnosis in CloudWatch.
|
||||
|
||||
- **Spreadsheet generation**:
|
||||
- Format: `.xlsx` via `openpyxl`.
|
||||
- The template `d1_ventilation_template.xlsx` is bundled with the lambda at `applications/audit-generator/d1_ventilation_template.xlsx` and loaded from the deployment package via `importlib.resources` or a path relative to the handler file. No S3 round-trip for the template.
|
||||
- The template is loaded with `openpyxl.load_workbook(path)` (default `data_only=False` to preserve formulas), populated, and serialised to bytes via `BytesIO` for upload.
|
||||
- Cell targeting uses fixed column letters (see Spreadsheet Layout below). Named ranges are not defined in the template.
|
||||
- The template has formulas in columns J (`=H*I`), N (`=J*M`), S (`=Q*R`), and Y (`=W*X`) — the lambda does not write to these cells; they are calculated by Excel/Sheets when the file is opened.
|
||||
- The template has 50 data rows (rows 6–55), extended programmatically. The footer merge sits at A56:Z56; legend rows at 57–60.
|
||||
|
||||
- **Output S3 key**: `documents/hubspot_deal_id/{hubspot_deal_id}/ventilation_audit.xlsx`. Re-running the lambda overwrites the previous file.
|
||||
|
||||
- **Operation order**: S3 upload first, then `uploaded_files` DB insert. An orphaned S3 file on DB failure is harmless and will be overwritten on retry. A DB record pointing to a non-existent file is worse.
|
||||
|
||||
- **New enum values** (added to `FileTypeEnum` and `FileSourceEnum`):
|
||||
- `FileTypeEnum.VENTILATION_AUDIT = "ventilation_audit"`
|
||||
- `FileSourceEnum.AUDIT_GENERATOR = "audit_generator"`
|
||||
|
||||
- **DDD migration of `UploadedFile`**: The existing `backend/app/db/models/uploaded_file.py` (SQLAlchemy `Base`) is replaced by `infrastructure/postgres/uploaded_file_table.py` (SQLModel). `FileTypeEnum`, `FileSourceEnum`, and `UploadedFile` all move there. The class name `UploadedFile` is kept (no `Model` suffix — there is no domain counterpart). All seven consumers update their import path; `backend/app/db/models/uploaded_file.py` is deleted. Because `UploadedFile` is now registered on `SQLModel.metadata`, the shared `tests/conftest.py` `db_engine` fixture must emit `CREATE TYPE IF NOT EXISTS` for `file_type` and `file_source` via raw SQL before calling `SQLModel.metadata.create_all(engine)` — otherwise the table creation fails for all integration tests. The dedicated per-test conftest approach (Question 6) is therefore superseded.
|
||||
|
||||
- **New `UploadedFileRepository`**: A new repository (`UploadedFilePostgresRepository`) is introduced with a `get_latest_by_hubspot_deal_id(hubspot_deal_id: str, file_type: FileTypeEnum) -> Optional[UploadedFile]` method. Queries `uploaded_files` filtered by `hubspot_deal_id` and `file_type`, ordered by `s3_upload_timestamp DESC`, returning the most recent row.
|
||||
|
||||
- **Session management**: A dedicated `AuditGeneratorUnitOfWork` context manager (standalone — does not inherit from `PostgresUnitOfWork` or `UnitOfWork`) holds `uploaded_file: UploadedFilePostgresRepository` and `magic_plan: MagicPlanPostgresRepository`, both bound to the same session. Opens the session on `__enter__`, rolls back and closes on `__exit__`, exposes `commit()`. The handler holds a module-scoped engine (reused across warm Lambda invocations) and passes a `session_factory` callable to `AuditGeneratorUnitOfWork` — the session is created fresh per invocation and never long-lived.
|
||||
|
||||
- **Idempotency**: No duplicate guard. `uploaded_files` is append-only — the lambda always inserts a new row; rows are never updated or deleted. The S3 file is always overwritten at the fixed key. The UI and any future queries treat the most recent row by `s3_upload_timestamp` as authoritative.
|
||||
|
||||
- **Environment variables**:
|
||||
- `S3_BUCKET_NAME` (shared convention)
|
||||
- `DATABASE_URL` (shared convention)
|
||||
|
||||
- **Trigger**: The SQS message is sent by a UI action in a separate repo. No SQS publishing client is required in this PR.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
Good tests assert observable outputs given controlled inputs — they do not assert on internal call sequences or implementation details. Prefer mocking at the boundary of the system under test, not inside it.
|
||||
|
||||
**Handler tests** (`tests/applications/audit_generator/test_audit_generator_handler.py`):
|
||||
- Test that an invalid trigger body raises `ValidationError`.
|
||||
- Test that the orchestrator is constructed with values derived from env vars and the trigger body.
|
||||
- Test that the handler returns the expected value on success.
|
||||
- Use `handler.__wrapped__` to bypass the `@subtask_handler` decorator (prior art: `test_magic_plan_handler.py`).
|
||||
|
||||
**Orchestrator tests** (`tests/orchestration/audit_generator/test_audit_generator_orchestrator.py`):
|
||||
- Mock `S3Client` with `MagicMock(spec=S3Client)`. Mock the `AuditGeneratorUnitOfWork` factory: the factory returns a mock UoW whose `__enter__` returns itself and whose `.uploaded_file` and `.magic_plan` attributes are mock repos.
|
||||
- Test happy path: correct S3 key used for output upload; `uploaded_files` insert called with correct `file_type` and `file_source`; `uow.commit()` called.
|
||||
- Test error path: raises with appropriate message when `uploaded_file_repo.get_latest_by_hubspot_deal_id` returns `None`.
|
||||
- Test error path: raises with appropriate message when `magic_plan_repo.get_plan_by_uploaded_file_id` returns `None`.
|
||||
|
||||
**Repository tests** (`tests/repositories/uploaded_file/test_uploaded_file_postgres_repository.py`):
|
||||
- Integration tests using the shared `db_engine` fixture. The fixture already calls `SQLModel.metadata.create_all(engine)`; after the DDD migration `UploadedFile` is in `SQLModel.metadata`, so no dedicated conftest is needed. The shared `tests/conftest.py` must emit `CREATE TYPE IF NOT EXISTS` for `file_type` and `file_source` before `create_all`.
|
||||
- Test that `get_latest_by_hubspot_deal_id` returns the most recent row by `s3_upload_timestamp` when multiple rows with the same `file_type` exist.
|
||||
- Test that it returns `None` when no matching row exists.
|
||||
- Test that it filters correctly by `file_type` (a row with a different `file_type` is not returned).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- The SQS trigger — the UI button that sends the SQS message lives in a separate repo.
|
||||
- Any ventilation calculation or compliance logic — the spreadsheet is populated with raw plan data only.
|
||||
|
||||
## Spreadsheet Layout
|
||||
|
||||
Sheet name: `D1 Ventilation`. Data starts at row 6. The three series run in parallel columns — each row may contain room data, window data, and door data independently; the longest series determines the last row used.
|
||||
|
||||
| Column | Content | Source |
|
||||
|--------|---------|--------|
|
||||
| B | Room name | `Room.name` |
|
||||
| D | Room area (m²) | `Room.area_m2` |
|
||||
| G | Window location (room name) | `Room.name` (parent room) |
|
||||
| H | Window width (m) | `Window.width_m` |
|
||||
| I | Window height (m) | `Window.height_m` |
|
||||
| J | Window area (m²) | **formula** `=H*I` — do not write |
|
||||
| K | Opening type | `WindowVentilation.opening_type` |
|
||||
| L | Number of openings | `WindowVentilation.num_openings` |
|
||||
| M | % of window (decimal) | `WindowVentilation.pct_openable / 100` |
|
||||
| N | Total opening area (m²) | **formula** `=J*M` — do not write |
|
||||
| O | Blocked | leave blank (visual check by auditor) |
|
||||
| P | Pictured | leave blank (visual check by auditor) |
|
||||
| Q | Trickle vent effective area per vent (mm²) | `WindowVentilation.trickle_vent_area_mm2` |
|
||||
| R | Number of trickle vents | `WindowVentilation.num_trickle_vents` |
|
||||
| S | Total trickle vent area (mm²) | **formula** `=Q*R` — do not write |
|
||||
| V | Door location (room name) | `Room.name` (parent room) |
|
||||
| W | Door width (mm) | `Door.width_mm` |
|
||||
| X | Door undercut (mm) | `DoorVentilation.undercut_mm` |
|
||||
| Y | Door area (mm²) | **formula** `=W*X` — do not write |
|
||||
|
||||
Internal doors appear once per room they connect (typically twice). `WindowVentilation` and `DoorVentilation` fields are `Optional`; write `0` when `None` so formula cells (J, N, S, Y) do not produce `#VALUE!` errors.
|
||||
|
||||
## Further Notes
|
||||
|
||||
- The `audit-generator` application scaffold already exists at `applications/audit-generator/` with empty `handler.py` and `audit_generator_trigger_request.py` files.
|
||||
- The `MagicPlanPostgresRepository.get_plan_by_uploaded_file_id` method is the correct entry point for fetching the parsed plan — no S3 re-parsing is needed.
|
||||
- The `openpyxl` library must be added to `applications/audit-generator/handler/requirements.txt`.
|
||||
- The template (`d1_ventilation_template.xlsx`) has 50 data rows (rows 6–55) with formulas in columns J, N, S, Y. If a property exceeds 50 windows, rooms, or doors the lambda should raise a clear error rather than silently truncating.
|
||||
|
|
@ -31,3 +31,18 @@ class Epc(Enum):
|
|||
if score >= 21:
|
||||
return cls.F
|
||||
return cls.G
|
||||
|
||||
def sap_lower_bound(self) -> int:
|
||||
"""The minimum SAP rating in this band — the inverse of
|
||||
`from_sap_score` (A → 92, B → 81, C → 69, D → 55, E → 39, F → 21,
|
||||
G → 1). Used as an optimisation target, e.g. "reach band C" → 69."""
|
||||
bounds: dict["Epc", int] = {
|
||||
Epc.A: 92,
|
||||
Epc.B: 81,
|
||||
Epc.C: 69,
|
||||
Epc.D: 55,
|
||||
Epc.E: 39,
|
||||
Epc.F: 21,
|
||||
Epc.G: 1,
|
||||
}
|
||||
return bounds[self]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from typing import Final, List, Optional, Union
|
|||
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
|
||||
|
||||
_API_EXTENSION = re.compile(r"^Extension\s+(\d+)$")
|
||||
|
||||
|
||||
|
|
@ -36,9 +35,7 @@ class BuildingPartIdentifier(Enum):
|
|||
OTHER = "other"
|
||||
|
||||
@classmethod
|
||||
def from_api_string(
|
||||
cls, api_identifier: Optional[str]
|
||||
) -> "BuildingPartIdentifier":
|
||||
def from_api_string(cls, api_identifier: Optional[str]) -> "BuildingPartIdentifier":
|
||||
"""Map a gov-EPC API `BuildingPart.identifier` to its canonical
|
||||
member. "Main Dwelling" → MAIN; "Extension N" → EXTENSION_N
|
||||
(for N in 1..4). `None` (permitted by the 21_0_1 schema) and
|
||||
|
|
@ -76,6 +73,7 @@ class Addendum:
|
|||
Present on ~43% of real RdSAP certs (stone-walls / system-build / a list of
|
||||
numeric improvement codes the assessor wanted to call out).
|
||||
"""
|
||||
|
||||
stone_walls: Optional[bool] = None
|
||||
system_build: Optional[bool] = None
|
||||
addendum_numbers: Optional[List[int]] = None
|
||||
|
|
@ -188,10 +186,12 @@ class SapVentilation:
|
|||
flueless_gas_fires_count: Optional[int] = None
|
||||
ventilation_in_pcdf_database: Optional[bool] = None
|
||||
# SAP10.2 §2 cert lodgements not previously surfaced on this type.
|
||||
sheltered_sides: Optional[int] = None # (19) — cert assessor lodge, 0..4
|
||||
sheltered_sides: Optional[int] = None # (19) — cert assessor lodge, 0..4
|
||||
has_suspended_timber_floor: Optional[bool] = None # (12) gate
|
||||
suspended_timber_floor_sealed: Optional[bool] = None
|
||||
has_draught_lobby: Optional[bool] = None # (13) gate (overrides .draught_lobby for §2 cascade)
|
||||
has_draught_lobby: Optional[bool] = (
|
||||
None # (13) gate (overrides .draught_lobby for §2 cascade)
|
||||
)
|
||||
# SAP 10.2 §2 (17a) — air permeability at 4 Pa from the low-pressure
|
||||
# Pulse pressure test, m³/h per m² of envelope area. When present the
|
||||
# cascade routes (18) via the AP4 formula `0.263 × AP4^0.924 + (8)`.
|
||||
|
|
@ -306,10 +306,11 @@ class PhotovoltaicArray:
|
|||
measured PV configuration; `photovoltaic_supply` carries the fallback
|
||||
`percent_roof_area` estimate when the surveyor could not confirm details.
|
||||
"""
|
||||
|
||||
peak_power: float
|
||||
pitch: int
|
||||
orientation: int
|
||||
overshading: int
|
||||
orientation: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -536,7 +537,9 @@ class SapBuildingPart:
|
|||
floor_u_value_known: Optional[bool] = None
|
||||
|
||||
roof_construction: Optional[int] = None
|
||||
roof_construction_type: Optional[str] = None # str from site notes e.g. "PS Pitched, sloping ceiling"
|
||||
roof_construction_type: Optional[str] = (
|
||||
None # str from site notes e.g. "PS Pitched, sloping ceiling"
|
||||
)
|
||||
# RdSAP 10 §5.1 — the assessor's lodged roof U-value (W/m²K). The gov-EPC
|
||||
# API surfaces it as `roof_u_value`; it is the RdSAP-assessed output for
|
||||
# the roof and overrides the §5.11 construction-default cascade in
|
||||
|
|
@ -619,6 +622,7 @@ class RenewableHeatIncentive:
|
|||
baseline `space_heating_kwh` and `hot_water_kwh` for SAP10 properties (used as ML
|
||||
training targets per ADR-0007).
|
||||
"""
|
||||
|
||||
space_heating_kwh: float
|
||||
water_heating_kwh: float
|
||||
impact_of_loft_insulation_kwh: Optional[float] = None
|
||||
|
|
@ -628,6 +632,16 @@ class RenewableHeatIncentive:
|
|||
|
||||
@dataclass
|
||||
class EpcPropertyData:
|
||||
"""The cert aggregate every downstream stage reads.
|
||||
|
||||
Currently **loosely typed** (`Union[int, str]` fuel/emitter fields, raw
|
||||
`Optional[int]` codes, `str` fallbacks) and filled by three mappers — EPC
|
||||
API, Elmhurst site notes, pashub — with different conventions, so
|
||||
normalization happens *downstream* (e.g. fuel resolution in the calculator's
|
||||
`cert_to_inputs`). The direction is to push normalization to the mappers and
|
||||
make this a strict type — see docs/adr/0015-mappers-own-cert-normalization.md.
|
||||
"""
|
||||
|
||||
# General
|
||||
dwelling_type: str # TODO: make enum?
|
||||
inspection_date: date
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -307,6 +307,36 @@ class TestFromRdSapSchema21_0_0:
|
|||
# photovoltaic_supply is None when the measured shape is present
|
||||
assert result.sap_energy_source.photovoltaic_supply is None
|
||||
|
||||
def test_photovoltaic_array_orientation_nd_nulls_only_that_field(self) -> None:
|
||||
# Arrange — a 3-array dwelling where the middle array lodges the RdSAP
|
||||
# 'ND' ("Not Defined") sentinel for orientation. Regression for the
|
||||
# real 21.0.1 cert 5236-4425-7600-0474-2292: 'ND' must null ONLY that
|
||||
# array's orientation, not crash the int() coercion and drop every
|
||||
# array (which happened when 'ND' was handled in the shared
|
||||
# _measurement_value coercer instead of field-scoped _pv_orientation).
|
||||
data = load("21_0_0.json")
|
||||
data["sap_energy_source"]["photovoltaic_supply"] = [
|
||||
[{"pitch": 3, "peak_power": 2.0, "orientation": 3, "overshading": 1}],
|
||||
[{"pitch": 1, "peak_power": 2.0, "orientation": "ND", "overshading": 1}],
|
||||
[{"pitch": 3, "peak_power": 2.0, "orientation": 7, "overshading": 1}],
|
||||
]
|
||||
schema = from_dict(RdSapSchema21_0_0, data)
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_rdsap_schema_21_0_0(schema)
|
||||
|
||||
# Assert — all three arrays survive; only the 'ND' orientation is None,
|
||||
# and its sibling fields + the other arrays keep their real values.
|
||||
arrays = result.sap_energy_source.photovoltaic_arrays
|
||||
assert arrays is not None
|
||||
assert len(arrays) == 3
|
||||
assert [a.orientation for a in arrays] == [3, None, 7]
|
||||
nd_array = arrays[1]
|
||||
assert nd_array.orientation is None
|
||||
assert nd_array.peak_power == 2.0
|
||||
assert nd_array.pitch == 1
|
||||
assert nd_array.overshading == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema 21.0.1 (most comprehensive — full field coverage)
|
||||
|
|
@ -1269,3 +1299,805 @@ class TestApiCascadeGlazingCodeDivergentRemap:
|
|||
# Act / Assert
|
||||
assert _api_cascade_glazing_type(14) == 14
|
||||
assert _api_cascade_glazing_type(9) == 9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema 20.0.0 — Reduced-Field Synthesis (ADR-0027)
|
||||
#
|
||||
# RdSAP 20.0.0 is a pre-SAP10 reduced-data schema: it records as categories or
|
||||
# aggregates the measured fields the calculator needs (a glazed_area *band*, not
|
||||
# window m²; bath/shower *room counts*, not bath counts). The mapper synthesises
|
||||
# the measured form from the cert alone (no neighbour data). Each test name
|
||||
# encodes the synthesis ASSUMPTION it pins, because a pre-SAP10 cert has no
|
||||
# same-spec lodged figure to validate against (Validation-Cohort rule).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CORPUS_20_0_0 = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../../../backend/epc_api/json_samples/RdSAP-Schema-20.0.0/corpus.jsonl",
|
||||
)
|
||||
|
||||
|
||||
def _load_20_0_0_corpus() -> list[Dict[str, Any]]:
|
||||
if not os.path.exists(_CORPUS_20_0_0):
|
||||
return []
|
||||
with open(_CORPUS_20_0_0) as f:
|
||||
return [json.loads(line) for line in f if line.strip()]
|
||||
|
||||
|
||||
class TestRdSap20_0_0ReducedFieldSynthesis:
|
||||
|
||||
def test_cert_omitting_sap_windows_maps_without_missing_required_field(
|
||||
self,
|
||||
) -> None:
|
||||
# Arrange — 993/1000 corpus certs omit `sap_windows` entirely; the
|
||||
# placeholder schema declared it required, so every one failed to parse.
|
||||
# Required→optional (default []) must let them through.
|
||||
corpus = _load_20_0_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
|
||||
cert = next((c for c in corpus if "sap_windows" not in c), None)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert omits sap_windows")
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, EpcPropertyData)
|
||||
|
||||
def test_band_normal_synthesises_total_glazing_at_0_148_of_floor_area(
|
||||
self,
|
||||
) -> None:
|
||||
# Arrange — ADR-0027 assumption: 20.0.0 lodges only a glazed_area *band*
|
||||
# (1 = Normal), not window m². For Normal, synthesised total glazing =
|
||||
# 0.148 x total_floor_area (the median glazing/floor ratio measured from
|
||||
# the 1000 real 21.0.1 certs). A band-1 cert with no per-window array.
|
||||
corpus = _load_20_0_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and c.get("glazed_area") == 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no band-1 corpus cert without sap_windows")
|
||||
tfa = float(cert["total_floor_area"])
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert — 4 windows (N/E/S/W avg-orientation split), each height 1.0,
|
||||
# total width-sum (= total area, height=1) at 0.148 x TFA.
|
||||
assert len(result.sap_windows) == 4
|
||||
assert all(w.window_height == 1.0 for w in result.sap_windows)
|
||||
assert sorted(w.orientation for w in result.sap_windows) == [1, 3, 5, 7]
|
||||
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
|
||||
assert total_area == pytest.approx(0.148 * tfa)
|
||||
|
||||
def test_band_more_than_typical_scales_glazing_by_1_25(self) -> None:
|
||||
# Arrange — ADR-0027: glazed_area band scales the synthesised area off
|
||||
# the Normal ratio. Band 2 ("More than typical") = P75/P50 = 1.25, fit
|
||||
# from the same 21.0.1 ratio distribution as the 0.148 median.
|
||||
corpus = _load_20_0_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and c.get("glazed_area") == 2
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no band-2 corpus cert without sap_windows")
|
||||
tfa = float(cert["total_floor_area"])
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
|
||||
assert total_area == pytest.approx(0.148 * tfa * 1.25)
|
||||
|
||||
def test_synthesised_glazing_type_routed_through_cascade(self) -> None:
|
||||
# Arrange — ADR-0027: multiple_glazing_type uses the same code space as
|
||||
# 21.0.1, so route it through `_api_cascade_glazing_type` (as the working
|
||||
# 21.0.1 path does), NOT raw — else the calculator mis-reads code 1
|
||||
# ("double pre-2002") as single. A cert lodging multiple_glazing_type=1.
|
||||
corpus = _load_20_0_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and c.get("multiple_glazing_type") == 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with multiple_glazing_type=1")
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert — cascade remaps 1 ("DG pre-2002") -> 2 (double), not raw 1.
|
||||
assert all(w.glazing_type == 2 for w in result.sap_windows)
|
||||
|
||||
def test_lighting_counts_incandescent_remainder_and_low_energy_as_lel(
|
||||
self,
|
||||
) -> None:
|
||||
# Arrange — ADR-0027: 20.0.0 gives total + low-energy OUTLET counts, not
|
||||
# an LED/CFL/incandescent split. The non-low-energy remainder is
|
||||
# incandescent (else lighting energy is understated for the 439/1000
|
||||
# certs that have any); low-energy → the calculator's LEL path (unknown
|
||||
# LED/CFL split). A cert with some incandescent outlets.
|
||||
corpus = _load_20_0_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows")
|
||||
and (c.get("fixed_lighting_outlets_count") or 0)
|
||||
> (c.get("low_energy_fixed_lighting_outlets_count") or 0)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with incandescent lighting")
|
||||
total = cert["fixed_lighting_outlets_count"]
|
||||
low = cert["low_energy_fixed_lighting_outlets_count"]
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert result.incandescent_fixed_lighting_bulbs_count == total - low
|
||||
assert result.low_energy_fixed_lighting_bulbs_count == low
|
||||
assert result.led_fixed_lighting_bulbs_count == 0
|
||||
assert result.cfl_fixed_lighting_bulbs_count == 0
|
||||
|
||||
def test_ventilation_maps_chimneys_draughtproofing_and_sheltered_sides(
|
||||
self,
|
||||
) -> None:
|
||||
# Arrange — ADR-0027: 20.0.0 lodges open_fireplaces_count (currently
|
||||
# dropped → -80 m³/h/chimney for 53 certs), percent_draughtproofed, and
|
||||
# built_form. Build sap_ventilation with sheltered_sides from built_form
|
||||
# (else the calculator defaults every dwelling to mid-terrace=2). A cert
|
||||
# with an open fireplace.
|
||||
from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
corpus = _load_20_0_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with an open fireplace")
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert result.open_chimneys_count == cert["open_fireplaces_count"]
|
||||
assert result.percent_draughtproofed == cert["percent_draughtproofed"]
|
||||
assert result.sap_ventilation is not None
|
||||
assert result.sap_ventilation.sheltered_sides == _api_sheltered_sides(
|
||||
cert["built_form"]
|
||||
)
|
||||
|
||||
def test_hot_water_derives_bath_and_mixer_counts_from_room_counts(self) -> None:
|
||||
# Arrange — ADR-0027: 20.0.0's instantaneous_wwhrs carries bath/shower
|
||||
# ROOM counts (a false-friend for the WWHR device index). Derive
|
||||
# number_baths and mixer_shower_count from them so HW demand isn't pinned
|
||||
# to the calculator's modal 1-bath default (496/1000 have ≠1 bath).
|
||||
corpus = _load_20_0_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows")
|
||||
and c.get("sap_heating", {}).get("instantaneous_wwhrs")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with instantaneous_wwhrs")
|
||||
iw = cert["sap_heating"]["instantaneous_wwhrs"]
|
||||
expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[
|
||||
"rooms_with_bath_and_mixer_shower"
|
||||
]
|
||||
expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[
|
||||
"rooms_with_bath_and_mixer_shower"
|
||||
]
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert result.sap_heating.number_baths == expected_baths
|
||||
assert result.sap_heating.mixer_shower_count == expected_mixers
|
||||
|
||||
def test_conservatory_building_part_maps_without_missing_required_field(
|
||||
self,
|
||||
) -> None:
|
||||
# Arrange — ADR-0027: 17/1000 certs lodge a conservatory-shaped
|
||||
# sap_building_part carrying only {double_glazed, floor_area,
|
||||
# glazed_perimeter, room_height} — NOT the wall/roof/floor construction
|
||||
# fields. The placeholder schema declared identifier (and the
|
||||
# construction fields) required, so all 17 failed to parse. Following
|
||||
# the 21.0.1 precedent, every SapBuildingPart field is Optional and the
|
||||
# conservatory's effect is carried separately by conservatory_type, so
|
||||
# the all-None part flows through harmlessly.
|
||||
corpus = _load_20_0_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if any(
|
||||
"identifier" not in part
|
||||
for part in c.get("sap_building_parts", [])
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with a conservatory building part")
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, EpcPropertyData)
|
||||
|
||||
def test_rich_cert_uses_lodged_window_area_for_geometry(self) -> None:
|
||||
# Arrange — ADR-0027: 7/1000 certs DO lodge a per-window sap_windows
|
||||
# array (window_area as a Measurement). Those windows must use their
|
||||
# lodged area as geometry (width = area, height = 1.0) rather than being
|
||||
# synthesised — and must NOT be modelled windowless (width=height=0,
|
||||
# the prior placeholder behaviour for the certs that actually carry the
|
||||
# richest window data).
|
||||
corpus = _load_20_0_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
|
||||
cert = next((c for c in corpus if c.get("sap_windows")), None)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert lodging sap_windows")
|
||||
lodged = cert["sap_windows"]
|
||||
expected_total = sum(w["window_area"]["value"] for w in lodged)
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert — one domain window per lodged window, total glazed area
|
||||
# (width x height, height=1) preserved from the lodged measurement.
|
||||
assert len(result.sap_windows) == len(lodged)
|
||||
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
|
||||
assert total_area == pytest.approx(expected_total)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RdSAP 18.0 Reduced-Field Synthesis (ADR-0028 — inherit-and-validate). 18.0 is
|
||||
# the same pre-SAP10 reduced family as 20.0.0: glazed_area *band* not window m²,
|
||||
# bath/shower *room counts* not bath counts, lighting OUTLET counts not bulbs.
|
||||
# The mapper synthesises the measured form from the cert alone (no neighbour
|
||||
# data), reusing 20.0.0's coefficients (validated against 18.0's own band-4 rich
|
||||
# certs: observed 0.223 ≈ 0.148 × 1.51). Each test name pins one assumption,
|
||||
# because a pre-SAP10 cert has no same-spec lodged figure (Validation-Cohort).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CORPUS_18_0 = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../../../backend/epc_api/json_samples/RdSAP-Schema-18.0/corpus.jsonl",
|
||||
)
|
||||
|
||||
|
||||
def _load_18_0_corpus() -> list[Dict[str, Any]]:
|
||||
if not os.path.exists(_CORPUS_18_0):
|
||||
return []
|
||||
with open(_CORPUS_18_0) as f:
|
||||
return [json.loads(line) for line in f if line.strip()]
|
||||
|
||||
|
||||
class TestRdSap18_0ReducedFieldSynthesis:
|
||||
|
||||
def test_cert_dispatches_and_maps_without_missing_required_field(self) -> None:
|
||||
# Arrange — the placeholder 18.0 schema was generated from one example, so
|
||||
# 986/1000 corpus certs fail to parse (over-constrained required fields),
|
||||
# and `from_api_response` never dispatched RdSAP-Schema-18.0 at all.
|
||||
# Dispatch + required→optional must let a real cert through end-to-end.
|
||||
corpus = _load_18_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-18.0 corpus harvested")
|
||||
cert = corpus[0]
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, EpcPropertyData)
|
||||
|
||||
def test_rich_cert_uses_lodged_window_area_for_geometry(self) -> None:
|
||||
# Arrange — ADR-0028: 10/1000 18.0 certs lodge a per-window sap_windows
|
||||
# array (window_area as a Measurement), all band-4 ("much more glazed").
|
||||
# The placeholder 18.0 schema had NO sap_windows field, so this richest
|
||||
# window data was dropped at parse and the cert modelled windowless. Those
|
||||
# windows must use their lodged area as geometry (width = area, height =
|
||||
# 1.0), not be synthesised.
|
||||
corpus = _load_18_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-18.0 corpus harvested")
|
||||
cert = next((c for c in corpus if c.get("sap_windows")), None)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert lodging sap_windows")
|
||||
lodged = cert["sap_windows"]
|
||||
expected_total = sum(w["window_area"]["value"] for w in lodged)
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert — one domain window per lodged window, total glazed area
|
||||
# (width x height, height=1) preserved from the lodged measurement.
|
||||
assert len(result.sap_windows) == len(lodged)
|
||||
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
|
||||
assert total_area == pytest.approx(expected_total)
|
||||
|
||||
def test_band_normal_synthesises_total_glazing_at_0_148_of_floor_area(
|
||||
self,
|
||||
) -> None:
|
||||
# Arrange — ADR-0028 (inherit-and-validate): 18.0 lodges only a
|
||||
# glazed_area *band* (1 = Normal, 958/1000), not window m². The inherited
|
||||
# 20.0.0 coefficient — synthesised total glazing = 0.148 x total_floor_area
|
||||
# — is reused unchanged; validated against 18.0's own band-4 rich certs
|
||||
# (observed 0.223 ~ 0.148 x 1.51). A band-1 cert with no per-window array.
|
||||
corpus = _load_18_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-18.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and c.get("glazed_area") == 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no band-1 corpus cert without sap_windows")
|
||||
tfa = float(cert["total_floor_area"])
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert — 4 windows (N/E/S/W avg-orientation split), each height 1.0,
|
||||
# total width-sum (= total area, height=1) at 0.148 x TFA.
|
||||
assert len(result.sap_windows) == 4
|
||||
assert all(w.window_height == 1.0 for w in result.sap_windows)
|
||||
assert sorted(w.orientation for w in result.sap_windows) == [1, 3, 5, 7]
|
||||
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
|
||||
assert total_area == pytest.approx(0.148 * tfa)
|
||||
|
||||
def test_band_more_than_typical_scales_glazing_by_1_25(self) -> None:
|
||||
# Arrange — ADR-0028: the glazed_area band scales synthesised area off the
|
||||
# Normal ratio. Band 2 ("More than typical") = 1.25, the inherited 20.0.0
|
||||
# multiplier (18.0's 26 band-2 windowless certs can't re-fit it — no
|
||||
# measured band-2 windows — so it is reused, not re-derived). A band-2
|
||||
# cert with no per-window array.
|
||||
corpus = _load_18_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-18.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and c.get("glazed_area") == 2
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no band-2 corpus cert without sap_windows")
|
||||
tfa = float(cert["total_floor_area"])
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
|
||||
assert total_area == pytest.approx(0.148 * tfa * 1.25)
|
||||
|
||||
def test_synthesised_glazing_type_routed_through_cascade(self) -> None:
|
||||
# Arrange — ADR-0028: 18.0 multiple_glazing_type shares 20.0.0's code
|
||||
# space (verified vs epc_codes.csv), so route it through the verified
|
||||
# cascade — code 1 ("DG pre-2002") must remap to 2, not be read as single.
|
||||
# A windowless cert lodging multiple_glazing_type=1.
|
||||
corpus = _load_18_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-18.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and c.get("multiple_glazing_type") == 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with multiple_glazing_type=1")
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert — cascade remaps 1 ("DG pre-2002") -> 2 (double), not raw 1.
|
||||
assert all(w.glazing_type == 2 for w in result.sap_windows)
|
||||
|
||||
def test_synthesised_glazing_type_handles_not_defined_code(self) -> None:
|
||||
# Arrange — ADR-0028: 69/1000 18.0 certs lodge multiple_glazing_type "ND"
|
||||
# (Not Defined), a string the int-keyed cascade cannot map. The
|
||||
# synthesised window must carry a valid INTEGER glazing_type (treated as
|
||||
# DG-modal, matching the calculator's _G_LIGHT_DEFAULT), never the raw
|
||||
# "ND" string on an int field. A windowless cert lodging "ND".
|
||||
corpus = _load_18_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-18.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and c.get("multiple_glazing_type") == "ND"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no windowless corpus cert with multiple_glazing_type ND")
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert — every synthesised window has an int glazing_type, not "ND".
|
||||
assert result.sap_windows
|
||||
assert all(isinstance(w.glazing_type, int) for w in result.sap_windows)
|
||||
|
||||
def test_lighting_counts_incandescent_remainder_and_low_energy_as_lel(
|
||||
self,
|
||||
) -> None:
|
||||
# Arrange — ADR-0028: 18.0 gives total + low-energy OUTLET counts, not an
|
||||
# LED/CFL/incandescent split (matches 20.0.0). The non-low-energy
|
||||
# remainder is incandescent (else lighting energy is understated); the
|
||||
# low-energy outlets feed the calculator's LEL path. A cert with some
|
||||
# incandescent (non-low-energy) outlets.
|
||||
corpus = _load_18_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-18.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if (c.get("fixed_lighting_outlets_count") or 0)
|
||||
> (c.get("low_energy_fixed_lighting_outlets_count") or 0)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with incandescent lighting")
|
||||
total = cert["fixed_lighting_outlets_count"]
|
||||
low = cert["low_energy_fixed_lighting_outlets_count"]
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert result.incandescent_fixed_lighting_bulbs_count == total - low
|
||||
assert result.low_energy_fixed_lighting_bulbs_count == low
|
||||
|
||||
def test_ventilation_maps_chimneys_draughtproofing_and_sheltered_sides(
|
||||
self,
|
||||
) -> None:
|
||||
# Arrange — ADR-0028: 18.0 lodges open_fireplaces_count (else dropped),
|
||||
# percent_draughtproofed, and built_form. Build sap_ventilation with
|
||||
# sheltered_sides from built_form (else the calculator defaults every
|
||||
# dwelling to mid-terrace=2). A cert with an open fireplace.
|
||||
from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
corpus = _load_18_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-18.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with an open fireplace")
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert result.open_chimneys_count == cert["open_fireplaces_count"]
|
||||
assert result.percent_draughtproofed == cert["percent_draughtproofed"]
|
||||
assert result.sap_ventilation is not None
|
||||
assert result.sap_ventilation.sheltered_sides == _api_sheltered_sides(
|
||||
cert["built_form"]
|
||||
)
|
||||
|
||||
def test_hot_water_derives_bath_and_mixer_counts_from_room_counts(self) -> None:
|
||||
# Arrange — ADR-0028: 18.0's instantaneous_wwhrs carries bath/shower ROOM
|
||||
# counts (a false-friend for the WWHR device index), populated 1000/1000.
|
||||
# Derive number_baths and mixer_shower_count so HW demand isn't pinned to
|
||||
# the calculator's modal 1-bath default.
|
||||
corpus = _load_18_0_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-18.0 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows")
|
||||
and c.get("sap_heating", {}).get("instantaneous_wwhrs")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with instantaneous_wwhrs")
|
||||
iw = cert["sap_heating"]["instantaneous_wwhrs"]
|
||||
expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[
|
||||
"rooms_with_bath_and_mixer_shower"
|
||||
]
|
||||
expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[
|
||||
"rooms_with_bath_and_mixer_shower"
|
||||
]
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert result.sap_heating.number_baths == expected_baths
|
||||
assert result.sap_heating.mixer_shower_count == expected_mixers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RdSAP 17.1 Reduced-Field Synthesis (ADR-0028 — inherit-and-validate). 17.1 is
|
||||
# the same pre-SAP10 reduced family as 18.0/20.0.0 and reuses the same inherited
|
||||
# 20.0.0 coefficients via the shared `_synthesise_reduced_field_windows` core;
|
||||
# its own band-4 rich certs validate the transfer. Each test name pins one
|
||||
# synthesis assumption (Validation-Cohort rule: no same-spec lodged figure).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CORPUS_17_1 = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../../../backend/epc_api/json_samples/RdSAP-Schema-17.1/corpus.jsonl",
|
||||
)
|
||||
|
||||
|
||||
def _load_17_1_corpus() -> list[Dict[str, Any]]:
|
||||
if not os.path.exists(_CORPUS_17_1):
|
||||
return []
|
||||
with open(_CORPUS_17_1) as f:
|
||||
return [json.loads(line) for line in f if line.strip()]
|
||||
|
||||
|
||||
class TestRdSap17_1ReducedFieldSynthesis:
|
||||
|
||||
def test_cert_dispatches_and_maps_without_missing_required_field(self) -> None:
|
||||
# Arrange — the placeholder 17.1 schema over-constrains (only 4/1000
|
||||
# parse) and `from_api_response` never dispatched RdSAP-Schema-17.1.
|
||||
# Dispatch + required→optional must let a real cert through end-to-end.
|
||||
corpus = _load_17_1_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
|
||||
cert = corpus[0]
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, EpcPropertyData)
|
||||
|
||||
def test_rich_cert_uses_lodged_window_area_for_geometry(self) -> None:
|
||||
# ADR-0028: 14/1000 17.1 certs lodge a per-window sap_windows array
|
||||
# (band-4); use lodged window_area as geometry, not synthesised.
|
||||
corpus = _load_17_1_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
|
||||
cert = next((c for c in corpus if c.get("sap_windows")), None)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert lodging sap_windows")
|
||||
lodged = cert["sap_windows"]
|
||||
expected_total = sum(w["window_area"]["value"] for w in lodged)
|
||||
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
assert len(result.sap_windows) == len(lodged)
|
||||
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
|
||||
assert total_area == pytest.approx(expected_total)
|
||||
|
||||
def test_band_normal_synthesises_total_glazing_at_0_148_of_floor_area(
|
||||
self,
|
||||
) -> None:
|
||||
# ADR-0028: band-1 (969/1000) synthesises total glazing = 0.148 x TFA,
|
||||
# the inherited 20.0.0 coefficient (validated vs 17.1's band-4 rich certs).
|
||||
corpus = _load_17_1_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
|
||||
cert = next(
|
||||
(c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 1),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no band-1 corpus cert without sap_windows")
|
||||
tfa = float(cert["total_floor_area"])
|
||||
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
assert len(result.sap_windows) == 4
|
||||
assert all(w.window_height == 1.0 for w in result.sap_windows)
|
||||
assert sorted(w.orientation for w in result.sap_windows) == [1, 3, 5, 7]
|
||||
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
|
||||
assert total_area == pytest.approx(0.148 * tfa)
|
||||
|
||||
def test_band_more_than_typical_scales_glazing_by_1_25(self) -> None:
|
||||
# ADR-0028: band 2 ("More than typical") = inherited 1.25 multiplier.
|
||||
corpus = _load_17_1_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
|
||||
cert = next(
|
||||
(c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 2),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no band-2 corpus cert without sap_windows")
|
||||
tfa = float(cert["total_floor_area"])
|
||||
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
|
||||
assert total_area == pytest.approx(0.148 * tfa * 1.25)
|
||||
|
||||
def test_synthesised_glazing_type_routed_through_cascade(self) -> None:
|
||||
# ADR-0028: multiple_glazing_type shares 20.0.0's code space — route
|
||||
# through the cascade so code 1 ("DG pre-2002") remaps to 2, not single.
|
||||
corpus = _load_17_1_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and c.get("multiple_glazing_type") == 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with multiple_glazing_type=1")
|
||||
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
assert all(w.glazing_type == 2 for w in result.sap_windows)
|
||||
|
||||
def test_synthesised_glazing_type_handles_not_defined_code(self) -> None:
|
||||
# ADR-0028: "ND" (56/1000) maps to a valid INTEGER glazing_type (DG-modal),
|
||||
# never the raw string on an int field.
|
||||
corpus = _load_17_1_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and c.get("multiple_glazing_type") == "ND"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no windowless corpus cert with multiple_glazing_type ND")
|
||||
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
assert result.sap_windows
|
||||
assert all(isinstance(w.glazing_type, int) for w in result.sap_windows)
|
||||
|
||||
def test_lighting_counts_incandescent_remainder_and_low_energy_as_lel(
|
||||
self,
|
||||
) -> None:
|
||||
# ADR-0028: total + low-energy OUTLET counts -> incandescent remainder +
|
||||
# low-energy as LEL.
|
||||
corpus = _load_17_1_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if (c.get("fixed_lighting_outlets_count") or 0)
|
||||
> (c.get("low_energy_fixed_lighting_outlets_count") or 0)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with incandescent lighting")
|
||||
total = cert["fixed_lighting_outlets_count"]
|
||||
low = cert["low_energy_fixed_lighting_outlets_count"]
|
||||
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
assert result.incandescent_fixed_lighting_bulbs_count == total - low
|
||||
assert result.low_energy_fixed_lighting_bulbs_count == low
|
||||
|
||||
def test_ventilation_maps_chimneys_draughtproofing_and_sheltered_sides(
|
||||
self,
|
||||
) -> None:
|
||||
# ADR-0028: open_fireplaces_count -> chimneys, percent_draughtproofed,
|
||||
# sheltered_sides from built_form.
|
||||
from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
corpus = _load_17_1_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with an open fireplace")
|
||||
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
assert result.open_chimneys_count == cert["open_fireplaces_count"]
|
||||
assert result.percent_draughtproofed == cert["percent_draughtproofed"]
|
||||
assert result.sap_ventilation is not None
|
||||
assert result.sap_ventilation.sheltered_sides == _api_sheltered_sides(
|
||||
cert["built_form"]
|
||||
)
|
||||
|
||||
def test_hot_water_derives_bath_and_mixer_counts_from_room_counts(self) -> None:
|
||||
# ADR-0028: instantaneous_wwhrs ROOM counts -> number_baths/mixer_shower.
|
||||
corpus = _load_17_1_corpus()
|
||||
if not corpus:
|
||||
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
|
||||
cert = next(
|
||||
(
|
||||
c
|
||||
for c in corpus
|
||||
if not c.get("sap_windows")
|
||||
and c.get("sap_heating", {}).get("instantaneous_wwhrs")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cert is None:
|
||||
pytest.skip("no corpus cert with instantaneous_wwhrs")
|
||||
iw = cert["sap_heating"]["instantaneous_wwhrs"]
|
||||
expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[
|
||||
"rooms_with_bath_and_mixer_shower"
|
||||
]
|
||||
expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[
|
||||
"rooms_with_bath_and_mixer_shower"
|
||||
]
|
||||
|
||||
result = EpcPropertyDataMapper.from_api_response(cert)
|
||||
|
||||
assert result.sap_heating.number_baths == expected_baths
|
||||
assert result.sap_heating.mixer_shower_count == expected_mixers
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from .common import CostAmount, DescriptionV1, Measurement
|
||||
|
|
@ -29,6 +29,10 @@ class MainHeatingDetail:
|
|||
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
|
||||
|
||||
|
||||
|
|
@ -40,8 +44,13 @@ class SapHeating:
|
|||
instantaneous_wwhrs: Optional[InstantaneousWwhrs]
|
||||
main_heating_details: List[MainHeatingDetail]
|
||||
immersion_heating_type: Union[int, str]
|
||||
cylinder_insulation_type: int
|
||||
has_fixed_air_conditioning: str
|
||||
# ADR-0028: cylinder_insulation_type is absent in 308/1000 17.0 certs.
|
||||
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
|
||||
|
|
@ -52,7 +61,8 @@ class PhotovoltaicSupplyNoneOrNoDetails:
|
|||
|
||||
@dataclass
|
||||
class PhotovoltaicSupply:
|
||||
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
|
||||
# ADR-0028 data-driven required→optional: 3/1000 omit none_or_no_details.
|
||||
none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -69,27 +79,47 @@ class SapFloorDimension:
|
|||
floor: int
|
||||
room_height: Measurement
|
||||
total_floor_area: Measurement
|
||||
party_wall_length: Measurement
|
||||
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
|
||||
class SapRoomInRoof:
|
||||
"""Room-in-roof details. floor_area is usually a Measurement but some certs
|
||||
lodge a plain number (ADR-0028) — read via `_measurement_value`."""
|
||||
|
||||
floor_area: Union[Measurement, int, float]
|
||||
insulation: str
|
||||
roof_room_connected: str
|
||||
construction_age_band: str
|
||||
party_wall_construction: Union[int, str]
|
||||
wall_thickness_measured: str
|
||||
roof_insulation_location: Union[int, str]
|
||||
roof_insulation_thickness: str
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class SapBuildingPart:
|
||||
# Data-driven required→optional (ADR-0028): a conservatory-shaped part can
|
||||
# carry only a subset of fields. Every field is Optional (the
|
||||
# 21.0.1/20.0.0/18.0 precedent). 17.0 corpus: 2/1000 omit identifier,
|
||||
# wall_thickness, or roof_insulation_thickness.
|
||||
identifier: Optional[str] = None
|
||||
wall_dry_lined: Optional[str] = None
|
||||
wall_thickness: Optional[int] = None
|
||||
floor_heat_loss: Optional[int] = None
|
||||
roof_construction: Optional[int] = None
|
||||
wall_construction: Optional[int] = None
|
||||
building_part_number: Optional[int] = None
|
||||
sap_floor_dimensions: Optional[List[SapFloorDimension]] = None
|
||||
wall_insulation_type: Optional[int] = None
|
||||
construction_age_band: Optional[str] = None
|
||||
party_wall_construction: Optional[Union[int, str]] = None
|
||||
wall_thickness_measured: Optional[str] = None
|
||||
roof_insulation_location: Optional[Union[int, str]] = None
|
||||
roof_insulation_thickness: Optional[Union[str, int]] = None
|
||||
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
|
||||
|
|
@ -117,15 +147,17 @@ class SuggestedImprovement:
|
|||
environmental_impact_rating: int
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class AlternativeImprovement:
|
||||
sequence: int
|
||||
typical_saving: CostAmount
|
||||
improvement_type: str
|
||||
improvement_details: ImprovementDetails
|
||||
improvement_category: int
|
||||
energy_performance_rating: int
|
||||
environmental_impact_rating: int
|
||||
# ADR-0028: some certs lodge a reduced alternative-improvement shape (only
|
||||
# improvement_details/-type). Parse-only — every field is Optional.
|
||||
sequence: Optional[int] = None
|
||||
typical_saving: Optional[CostAmount] = None
|
||||
improvement_type: Optional[str] = None
|
||||
improvement_details: Optional[ImprovementDetails] = None
|
||||
improvement_category: Optional[int] = None
|
||||
energy_performance_rating: Optional[int] = None
|
||||
environmental_impact_rating: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -137,6 +169,20 @@ class RenewableHeatIncentive:
|
|||
|
||||
|
||||
@dataclass
|
||||
class SapWindow:
|
||||
"""Per-window geometry. ADR-0028: only 10/1000 17.0 certs lodge this array;
|
||||
window_area arrives as a Measurement and is read via `_measurement_value`.
|
||||
Mirrors the 20.0.0/18.0 SapWindow shape. This is the per-spec Validation
|
||||
Cohort — its lodged geometry is used directly, never synthesised over."""
|
||||
|
||||
orientation: int
|
||||
window_area: float
|
||||
window_type: int
|
||||
glazing_type: int
|
||||
window_location: int
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class RdSapSchema17_0:
|
||||
uprn: int
|
||||
roofs: List[EnergyElement]
|
||||
|
|
@ -152,7 +198,8 @@ class RdSapSchema17_0:
|
|||
built_form: int
|
||||
door_count: int
|
||||
glazed_area: int
|
||||
glazing_gap: str
|
||||
# ADR-0028: glazing_gap lodged as int, str, or omitted (482/1000) — widen.
|
||||
glazing_gap: Optional[Union[int, str]] = None
|
||||
region_code: int
|
||||
report_type: int
|
||||
sap_heating: SapHeating
|
||||
|
|
@ -161,7 +208,9 @@ class RdSapSchema17_0:
|
|||
uprn_source: str
|
||||
country_code: str
|
||||
main_heating: List[EnergyElement]
|
||||
dwelling_type: DescriptionV1
|
||||
# ADR-0028: 182/1000 lodge dwelling_type as a plain str, not a localised
|
||||
# DescriptionV1 object. Widen so both shapes parse.
|
||||
dwelling_type: Union[str, DescriptionV1]
|
||||
language_code: int
|
||||
property_type: int
|
||||
address_line_1: str
|
||||
|
|
@ -174,11 +223,13 @@ class RdSapSchema17_0:
|
|||
transaction_type: int
|
||||
conservatory_type: int
|
||||
heated_room_count: int
|
||||
pvc_window_frames: str
|
||||
# ADR-0028: missing in 343/1000 — widen + default.
|
||||
pvc_window_frames: Optional[str] = None
|
||||
registration_date: str
|
||||
sap_energy_source: SapEnergySource
|
||||
secondary_heating: EnergyElement
|
||||
lzc_energy_sources: List[int]
|
||||
# ADR-0028: present in only 95/1000 — default to empty.
|
||||
lzc_energy_sources: List[int] = field(default_factory=list)
|
||||
sap_building_parts: List[SapBuildingPart]
|
||||
low_energy_lighting: int
|
||||
solar_water_heating: str
|
||||
|
|
@ -190,14 +241,17 @@ class RdSapSchema17_0:
|
|||
energy_rating_current: int
|
||||
lighting_cost_current: CostAmount
|
||||
main_heating_controls: List[EnergyElement]
|
||||
multiple_glazing_type: int
|
||||
# ADR-0028: int code (1-7) or the string "ND" (Not Defined, 54/1000) — widen
|
||||
# so both parse; the synthesis maps "ND" to a default.
|
||||
multiple_glazing_type: Union[int, str]
|
||||
open_fireplaces_count: int
|
||||
has_hot_water_cylinder: str
|
||||
# ADR-0028: a handful of 17.0 certs omit these boolean flags — default them.
|
||||
has_hot_water_cylinder: Optional[str] = None
|
||||
heating_cost_potential: CostAmount
|
||||
hot_water_cost_current: CostAmount
|
||||
mechanical_ventilation: int
|
||||
percent_draughtproofed: int
|
||||
suggested_improvements: List[SuggestedImprovement]
|
||||
suggested_improvements: List[SuggestedImprovement] = field(default_factory=list)
|
||||
co2_emissions_potential: float
|
||||
energy_rating_potential: int
|
||||
lighting_cost_potential: CostAmount
|
||||
|
|
@ -205,7 +259,7 @@ class RdSapSchema17_0:
|
|||
hot_water_cost_potential: CostAmount
|
||||
renewable_heat_incentive: RenewableHeatIncentive
|
||||
energy_consumption_current: int
|
||||
has_fixed_air_conditioning: str
|
||||
has_fixed_air_conditioning: Optional[str] = None
|
||||
multiple_glazed_proportion: int
|
||||
calculation_software_version: str
|
||||
energy_consumption_potential: int
|
||||
|
|
@ -213,10 +267,14 @@ class RdSapSchema17_0:
|
|||
fixed_lighting_outlets_count: int
|
||||
current_energy_efficiency_band: str
|
||||
environmental_impact_potential: int
|
||||
has_heated_separate_conservatory: str
|
||||
has_heated_separate_conservatory: Optional[str] = None
|
||||
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
|
||||
# ADR-0028: additive — the placeholder schema omitted sap_windows entirely.
|
||||
# The 10 rich certs use lodged window_area directly; the windowless majority
|
||||
# synthesise from the glazed_area band.
|
||||
sap_windows: List[SapWindow] = field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from .common import CostAmount, DescriptionV1, Measurement
|
||||
|
|
@ -36,7 +36,7 @@ class MainHeatingDetail:
|
|||
sap_main_heating_code: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class SapHeating:
|
||||
cylinder_size: int
|
||||
water_heating_code: int
|
||||
|
|
@ -44,8 +44,9 @@ class SapHeating:
|
|||
instantaneous_wwhrs: Optional[InstantaneousWwhrs]
|
||||
main_heating_details: List[MainHeatingDetail]
|
||||
immersion_heating_type: Union[int, str]
|
||||
cylinder_insulation_type: int
|
||||
has_fixed_air_conditioning: str
|
||||
# ADR-0028: 325/1000 omit cylinder_insulation_type — default it.
|
||||
cylinder_insulation_type: Optional[int] = None
|
||||
cylinder_thermostat: Optional[str] = None
|
||||
secondary_fuel_type: Optional[int] = None
|
||||
secondary_heating_type: Optional[int] = None
|
||||
|
|
@ -60,7 +61,7 @@ class PhotovoltaicSupplyNoneOrNoDetails:
|
|||
|
||||
@dataclass
|
||||
class PhotovoltaicSupply:
|
||||
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
|
||||
none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -84,23 +85,26 @@ class SapFloorDimension:
|
|||
floor_construction: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
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]
|
||||
# ADR-0028: 17/1000 lodge a conservatory-shaped part with none of the
|
||||
# construction fields. Every field is Optional (the 18.0/20.0.0 precedent);
|
||||
# the all-None part flows through harmlessly.
|
||||
identifier: Optional[str] = None
|
||||
wall_dry_lined: Optional[str] = None
|
||||
wall_thickness: Optional[int] = None
|
||||
floor_heat_loss: Optional[int] = None
|
||||
roof_construction: Optional[int] = None
|
||||
wall_construction: Optional[int] = None
|
||||
building_part_number: Optional[int] = None
|
||||
sap_floor_dimensions: Optional[List[SapFloorDimension]] = None
|
||||
wall_insulation_type: Optional[int] = None
|
||||
construction_age_band: Optional[str] = None
|
||||
party_wall_construction: Optional[Union[int, str]] = None
|
||||
wall_thickness_measured: Optional[str] = None
|
||||
roof_insulation_location: Optional[Union[int, str]] = None
|
||||
# Can be a thickness string (e.g. "100mm") or 0 for uninsulated flat roofs
|
||||
roof_insulation_thickness: Union[str, int]
|
||||
roof_insulation_thickness: Optional[Union[str, int]] = None
|
||||
wall_insulation_thickness: Optional[str] = None
|
||||
|
||||
|
||||
|
|
@ -129,15 +133,17 @@ class SuggestedImprovement:
|
|||
environmental_impact_rating: int
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class AlternativeImprovement:
|
||||
sequence: int
|
||||
typical_saving: CostAmount
|
||||
improvement_type: str
|
||||
improvement_details: ImprovementDetails
|
||||
improvement_category: int
|
||||
energy_performance_rating: int
|
||||
environmental_impact_rating: int
|
||||
# ADR-0028: parse-only (the mapper does not read alternative_improvements);
|
||||
# a reduced shape lodges only some fields, so every field is Optional.
|
||||
sequence: Optional[int] = None
|
||||
typical_saving: Optional[CostAmount] = None
|
||||
improvement_type: Optional[str] = None
|
||||
improvement_details: Optional[ImprovementDetails] = None
|
||||
improvement_category: Optional[int] = None
|
||||
energy_performance_rating: Optional[int] = None
|
||||
environmental_impact_rating: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -149,6 +155,19 @@ class RenewableHeatIncentive:
|
|||
|
||||
|
||||
@dataclass
|
||||
class SapWindow:
|
||||
"""Per-window geometry. ADR-0028: only 14/1000 17.1 certs lodge this array
|
||||
(all band-4); window_area arrives as a Measurement and is read via
|
||||
`_measurement_value`. The extra pvc_frame/glazing_gap keys are ignored."""
|
||||
|
||||
orientation: int
|
||||
window_area: float
|
||||
window_type: int
|
||||
glazing_type: int
|
||||
window_location: int
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class RdSapSchema17_1:
|
||||
uprn: int
|
||||
roofs: List[EnergyElement]
|
||||
|
|
@ -164,7 +183,8 @@ class RdSapSchema17_1:
|
|||
built_form: int
|
||||
door_count: int
|
||||
glazed_area: int
|
||||
glazing_gap: str
|
||||
# ADR-0028: lodged as int, str, or omitted (478/1000) — widen + default.
|
||||
glazing_gap: Optional[Union[int, str]] = None
|
||||
region_code: int
|
||||
report_type: int
|
||||
sap_heating: SapHeating
|
||||
|
|
@ -173,7 +193,8 @@ class RdSapSchema17_1:
|
|||
uprn_source: str
|
||||
country_code: str
|
||||
main_heating: List[EnergyElement]
|
||||
dwelling_type: DescriptionV1
|
||||
# ADR-0028: 259/1000 lodge dwelling_type as a plain str, not DescriptionV1.
|
||||
dwelling_type: Union[str, DescriptionV1]
|
||||
language_code: int
|
||||
property_type: int
|
||||
address_line_1: str
|
||||
|
|
@ -186,11 +207,11 @@ class RdSapSchema17_1:
|
|||
transaction_type: int
|
||||
conservatory_type: int
|
||||
heated_room_count: int
|
||||
pvc_window_frames: str
|
||||
pvc_window_frames: Optional[str] = None
|
||||
registration_date: str
|
||||
sap_energy_source: SapEnergySource
|
||||
secondary_heating: EnergyElement
|
||||
lzc_energy_sources: List[int]
|
||||
lzc_energy_sources: List[int] = field(default_factory=list)
|
||||
sap_building_parts: List[SapBuildingPart]
|
||||
low_energy_lighting: int
|
||||
solar_water_heating: str
|
||||
|
|
@ -202,14 +223,15 @@ class RdSapSchema17_1:
|
|||
energy_rating_current: int
|
||||
lighting_cost_current: CostAmount
|
||||
main_heating_controls: List[EnergyElement]
|
||||
multiple_glazing_type: int
|
||||
# ADR-0028: int code (1-7) or "ND" (Not Defined, 56/1000) — widen.
|
||||
multiple_glazing_type: Union[int, str]
|
||||
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]
|
||||
suggested_improvements: List[SuggestedImprovement] = field(default_factory=list)
|
||||
co2_emissions_potential: float
|
||||
energy_rating_potential: int
|
||||
lighting_cost_potential: CostAmount
|
||||
|
|
@ -232,3 +254,6 @@ class RdSapSchema17_1:
|
|||
sap_flat_details: Optional[SapFlatDetails] = None
|
||||
address_line_2: Optional[str] = None
|
||||
alternative_improvements: Optional[List[AlternativeImprovement]] = None
|
||||
# ADR-0028: additive — the placeholder schema omitted sap_windows, dropping
|
||||
# the 14 rich certs' lodged geometry. Default [] = windowless (synthesised).
|
||||
sap_windows: List[SapWindow] = field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from .common import CostAmount, DescriptionV1, Measurement
|
||||
|
|
@ -60,7 +60,7 @@ class PhotovoltaicSupplyNoneOrNoDetails:
|
|||
|
||||
@dataclass
|
||||
class PhotovoltaicSupply:
|
||||
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
|
||||
none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -85,30 +85,38 @@ class SapFloorDimension:
|
|||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
"""Room-in-roof details. floor_area is a Measurement object in schema 18.0."""
|
||||
"""Room-in-roof details. floor_area is usually a Measurement object in 18.0,
|
||||
but 6/1000 certs lodge it as a plain number (ADR-0028) — read via
|
||||
`_measurement_value`, which coerces both shapes."""
|
||||
|
||||
floor_area: Measurement
|
||||
floor_area: Union[Measurement, int, float]
|
||||
insulation: str
|
||||
roof_room_connected: str
|
||||
construction_age_band: str
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
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]
|
||||
# Data-driven required→optional (ADR-0028): 17/1000 certs lodge a
|
||||
# conservatory-shaped part carrying only {double_glazed, floor_area,
|
||||
# glazed_perimeter, room_height} — none of the construction fields. Every
|
||||
# field is Optional (the 21.0.1/20.0.0 precedent); the all-None part flows
|
||||
# through harmlessly because the conservatory's effect is carried separately
|
||||
# by conservatory_type.
|
||||
identifier: Optional[str] = None
|
||||
wall_dry_lined: Optional[str] = None
|
||||
wall_thickness: Optional[int] = None
|
||||
floor_heat_loss: Optional[int] = None
|
||||
roof_construction: Optional[int] = None
|
||||
wall_construction: Optional[int] = None
|
||||
building_part_number: Optional[int] = None
|
||||
sap_floor_dimensions: Optional[List[SapFloorDimension]] = None
|
||||
wall_insulation_type: Optional[int] = None
|
||||
construction_age_band: Optional[str] = None
|
||||
party_wall_construction: Optional[Union[int, str]] = None
|
||||
wall_thickness_measured: Optional[str] = None
|
||||
roof_insulation_location: Optional[Union[int, str]] = None
|
||||
roof_insulation_thickness: Optional[Union[str, int]] = None
|
||||
sap_room_in_roof: Optional[SapRoomInRoof] = None
|
||||
wall_insulation_thickness: Optional[str] = None
|
||||
floor_insulation_thickness: Optional[str] = None
|
||||
|
|
@ -140,15 +148,18 @@ class SuggestedImprovement:
|
|||
environmental_impact_rating: int
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class AlternativeImprovement:
|
||||
sequence: int
|
||||
typical_saving: CostAmount
|
||||
improvement_type: str
|
||||
improvement_details: ImprovementDetails
|
||||
improvement_category: int
|
||||
energy_performance_rating: int
|
||||
environmental_impact_rating: int
|
||||
# ADR-0028: 165/1000 lodge a reduced alternative-improvement shape (only
|
||||
# improvement_details/-type). Parse-only — the mapper does not read
|
||||
# alternative_improvements — so every field is Optional.
|
||||
sequence: Optional[int] = None
|
||||
typical_saving: Optional[CostAmount] = None
|
||||
improvement_type: Optional[str] = None
|
||||
improvement_details: Optional[ImprovementDetails] = None
|
||||
improvement_category: Optional[int] = None
|
||||
energy_performance_rating: Optional[int] = None
|
||||
environmental_impact_rating: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -160,6 +171,19 @@ class RenewableHeatIncentive:
|
|||
|
||||
|
||||
@dataclass
|
||||
class SapWindow:
|
||||
"""Per-window geometry. ADR-0028: only 10/1000 18.0 certs lodge this array
|
||||
(all band-4); window_area arrives as a Measurement and is read via
|
||||
`_measurement_value`. Mirrors the 20.0.0 SapWindow shape."""
|
||||
|
||||
orientation: int
|
||||
window_area: float
|
||||
window_type: int
|
||||
glazing_type: int
|
||||
window_location: int
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class RdSapSchema18_0:
|
||||
uprn: int
|
||||
roofs: List[EnergyElement]
|
||||
|
|
@ -175,8 +199,9 @@ class RdSapSchema18_0:
|
|||
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
|
||||
# ADR-0028: glazing_gap is lodged as int (e.g. 12 mm), str ("16+"), or
|
||||
# omitted across the corpus (433/1000) — widen + default, not int-required.
|
||||
glazing_gap: Optional[Union[int, str]] = None
|
||||
region_code: int
|
||||
report_type: int
|
||||
sap_heating: SapHeating
|
||||
|
|
@ -185,7 +210,9 @@ class RdSapSchema18_0:
|
|||
uprn_source: str
|
||||
country_code: str
|
||||
main_heating: List[EnergyElement]
|
||||
dwelling_type: DescriptionV1
|
||||
# ADR-0028: 392/1000 lodge dwelling_type as a plain str, not a localised
|
||||
# DescriptionV1 object (matches 20.0.0). Widen so both shapes parse.
|
||||
dwelling_type: Union[str, DescriptionV1]
|
||||
language_code: int
|
||||
property_type: int
|
||||
address_line_1: str
|
||||
|
|
@ -198,11 +225,11 @@ class RdSapSchema18_0:
|
|||
transaction_type: int
|
||||
conservatory_type: int
|
||||
heated_room_count: int
|
||||
pvc_window_frames: str
|
||||
pvc_window_frames: Optional[str] = None
|
||||
registration_date: str
|
||||
sap_energy_source: SapEnergySource
|
||||
secondary_heating: EnergyElement
|
||||
lzc_energy_sources: List[int]
|
||||
lzc_energy_sources: List[int] = field(default_factory=list)
|
||||
sap_building_parts: List[SapBuildingPart]
|
||||
low_energy_lighting: int
|
||||
solar_water_heating: str
|
||||
|
|
@ -214,14 +241,16 @@ class RdSapSchema18_0:
|
|||
energy_rating_current: int
|
||||
lighting_cost_current: CostAmount
|
||||
main_heating_controls: List[EnergyElement]
|
||||
multiple_glazing_type: int
|
||||
# ADR-0028: lodged as an int code (1-7) or the string "ND" (Not Defined,
|
||||
# 69/1000) — widen so both parse; the synthesis maps "ND" to a default.
|
||||
multiple_glazing_type: Union[int, str]
|
||||
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]
|
||||
suggested_improvements: List[SuggestedImprovement] = field(default_factory=list)
|
||||
co2_emissions_potential: float
|
||||
energy_rating_potential: int
|
||||
lighting_cost_potential: CostAmount
|
||||
|
|
@ -244,3 +273,8 @@ class RdSapSchema18_0:
|
|||
sap_flat_details: Optional[SapFlatDetails] = None
|
||||
address_line_2: Optional[str] = None
|
||||
alternative_improvements: Optional[List[AlternativeImprovement]] = None
|
||||
# ADR-0028: additive — the placeholder schema omitted sap_windows entirely,
|
||||
# silently dropping the 10 rich certs' lodged per-window geometry. Capture it
|
||||
# so the mapper can use lodged window_area directly (default [] = windowless,
|
||||
# synthesised from the glazed_area band).
|
||||
sap_windows: List[SapWindow] = field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from .common import CostAmount, DescriptionV1, Measurement
|
||||
|
|
@ -60,7 +60,9 @@ class PhotovoltaicSupplyNoneOrNoDetails:
|
|||
|
||||
@dataclass
|
||||
class PhotovoltaicSupply:
|
||||
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
|
||||
# ADR-0028 data-driven required→optional: the photovoltaic_supply block can
|
||||
# arrive without its none_or_no_details child (matches 18.0).
|
||||
none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -85,28 +87,37 @@ class SapFloorDimension:
|
|||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
floor_area: Measurement
|
||||
"""Room-in-roof details. floor_area is usually a Measurement object but some
|
||||
certs lodge it as a plain number (ADR-0028, as in 18.0) — read via
|
||||
`_measurement_value`, which coerces both shapes."""
|
||||
|
||||
floor_area: Union[Measurement, int, float]
|
||||
insulation: str
|
||||
roof_room_connected: str
|
||||
construction_age_band: str
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
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]
|
||||
# Data-driven required→optional (ADR-0028): a conservatory-shaped part can
|
||||
# carry only a subset of fields (none of the construction fields). Every
|
||||
# field is Optional (the 21.0.1/20.0.0/18.0 precedent); the sparse part flows
|
||||
# through harmlessly. 19.0 corpus: 6/1000 omit roof_insulation_thickness,
|
||||
# 2/1000 omit identifier.
|
||||
identifier: Optional[str] = None
|
||||
wall_dry_lined: Optional[str] = None
|
||||
wall_thickness: Optional[int] = None
|
||||
floor_heat_loss: Optional[int] = None
|
||||
roof_construction: Optional[int] = None
|
||||
wall_construction: Optional[int] = None
|
||||
building_part_number: Optional[int] = None
|
||||
sap_floor_dimensions: Optional[List[SapFloorDimension]] = None
|
||||
wall_insulation_type: Optional[int] = None
|
||||
construction_age_band: Optional[str] = None
|
||||
party_wall_construction: Optional[Union[int, str]] = None
|
||||
wall_thickness_measured: Optional[str] = None
|
||||
roof_insulation_location: Optional[Union[int, str]] = None
|
||||
roof_insulation_thickness: Optional[Union[str, int]] = None
|
||||
sap_room_in_roof: Optional[SapRoomInRoof] = None
|
||||
wall_insulation_thickness: Optional[str] = None
|
||||
floor_insulation_thickness: Optional[str] = None
|
||||
|
|
@ -145,15 +156,18 @@ class SuggestedImprovement:
|
|||
environmental_impact_rating: int
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class AlternativeImprovement:
|
||||
sequence: int
|
||||
typical_saving: CostAmount
|
||||
improvement_type: str
|
||||
improvement_details: ImprovementDetails
|
||||
improvement_category: int
|
||||
energy_performance_rating: int
|
||||
environmental_impact_rating: int
|
||||
# ADR-0028: some certs lodge a reduced alternative-improvement shape (only
|
||||
# improvement_details/-type). Parse-only — the mapper does not read
|
||||
# alternative_improvements — so every field is Optional.
|
||||
sequence: Optional[int] = None
|
||||
typical_saving: Optional[CostAmount] = None
|
||||
improvement_type: Optional[str] = None
|
||||
improvement_details: Optional[ImprovementDetails] = None
|
||||
improvement_category: Optional[int] = None
|
||||
energy_performance_rating: Optional[int] = None
|
||||
environmental_impact_rating: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -165,6 +179,20 @@ class RenewableHeatIncentive:
|
|||
|
||||
|
||||
@dataclass
|
||||
class SapWindow:
|
||||
"""Per-window geometry. ADR-0028: only 6/1000 19.0 certs lodge this array;
|
||||
window_area arrives as a Measurement and is read via `_measurement_value`.
|
||||
Mirrors the 20.0.0/18.0 SapWindow shape. This is the per-spec Validation
|
||||
Cohort — its lodged geometry is used directly, never synthesised over."""
|
||||
|
||||
orientation: int
|
||||
window_area: float
|
||||
window_type: int
|
||||
glazing_type: int
|
||||
window_location: int
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class RdSapSchema19_0:
|
||||
uprn: int
|
||||
roofs: List[EnergyElement]
|
||||
|
|
@ -180,6 +208,9 @@ class RdSapSchema19_0:
|
|||
built_form: int
|
||||
door_count: int
|
||||
glazed_area: int
|
||||
# ADR-0028: glazing_gap is lodged as int (162/1000), str (357/1000), or
|
||||
# omitted (481/1000) — widen + default, not int-required.
|
||||
glazing_gap: Optional[Union[int, str]] = None
|
||||
region_code: int
|
||||
report_type: int
|
||||
sap_heating: SapHeating
|
||||
|
|
@ -188,7 +219,9 @@ class RdSapSchema19_0:
|
|||
uprn_source: str
|
||||
country_code: str
|
||||
main_heating: List[EnergyElement]
|
||||
dwelling_type: DescriptionV1
|
||||
# ADR-0028: 503/1000 lodge dwelling_type as a plain str, not a localised
|
||||
# DescriptionV1 object (matches 20.0.0/18.0). Widen so both shapes parse.
|
||||
dwelling_type: Union[str, DescriptionV1]
|
||||
language_code: int
|
||||
property_type: int
|
||||
address_line_1: str
|
||||
|
|
@ -201,11 +234,13 @@ class RdSapSchema19_0:
|
|||
transaction_type: int
|
||||
conservatory_type: int
|
||||
heated_room_count: int
|
||||
pvc_window_frames: str
|
||||
# ADR-0028: missing in 314/1000 — widen + default.
|
||||
pvc_window_frames: Optional[str] = None
|
||||
registration_date: str
|
||||
sap_energy_source: SapEnergySource
|
||||
secondary_heating: EnergyElement
|
||||
lzc_energy_sources: List[int]
|
||||
# ADR-0028: present in only 35/1000 — default to empty.
|
||||
lzc_energy_sources: List[int] = field(default_factory=list)
|
||||
sap_building_parts: List[SapBuildingPart]
|
||||
low_energy_lighting: int
|
||||
solar_water_heating: str
|
||||
|
|
@ -217,21 +252,24 @@ class RdSapSchema19_0:
|
|||
energy_rating_current: int
|
||||
lighting_cost_current: CostAmount
|
||||
main_heating_controls: List[EnergyElement]
|
||||
multiple_glazing_type: int
|
||||
# ADR-0028: lodged as an int code (1-7) or the string "ND" (Not Defined,
|
||||
# 50/1000) — widen so both parse; the synthesis maps "ND" to a default.
|
||||
multiple_glazing_type: Union[int, str]
|
||||
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]
|
||||
suggested_improvements: List[SuggestedImprovement] = field(default_factory=list)
|
||||
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
|
||||
# 19.0-specific block, absent in 713/1000 — Optional + default.
|
||||
windows_transmission_details: Optional[WindowsTransmissionDetails] = None
|
||||
energy_consumption_current: int
|
||||
has_fixed_air_conditioning: str
|
||||
multiple_glazed_proportion: int
|
||||
|
|
@ -247,5 +285,9 @@ class RdSapSchema19_0:
|
|||
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
|
||||
# ADR-0028: additive — the placeholder schema omitted sap_windows entirely,
|
||||
# silently dropping the 6 rich certs' lodged per-window geometry. Capture it
|
||||
# so the mapper can use lodged window_area directly (default [] = windowless,
|
||||
# synthesised from the glazed_area band).
|
||||
sap_windows: List[SapWindow] = field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,24 @@
|
|||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from .common import Measurement
|
||||
from .common import DescriptionV1, Measurement
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnergyElement:
|
||||
# description is a plain string in schema 20.0.0 onwards (no longer a localised object)
|
||||
description: str
|
||||
# ADR-0027: the corpus lodges description as EITHER a plain str OR a
|
||||
# localised {value,language} dict (DescriptionV1) — not str-only as a
|
||||
# one-example placeholder assumed. Union so _coerce builds the right one.
|
||||
description: Union[str, DescriptionV1]
|
||||
energy_efficiency_rating: int
|
||||
environmental_efficiency_rating: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Addendum:
|
||||
addendum_numbers: List[int]
|
||||
# ADR-0027: an addendum block can lodge only stone_walls/system_build flags
|
||||
# with no numbers list → optional.
|
||||
addendum_numbers: List[int] = field(default_factory=list)
|
||||
stone_walls: Optional[str] = None
|
||||
system_build: Optional[str] = None
|
||||
|
||||
|
|
@ -68,7 +72,9 @@ class PhotovoltaicSupplyNoneOrNoDetails:
|
|||
|
||||
@dataclass
|
||||
class PhotovoltaicSupply:
|
||||
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
|
||||
# ADR-0027: a photovoltaic_supply block can lodge measured-array detail
|
||||
# instead of the none_or_no_details summary → optional (absent on ~10 certs).
|
||||
none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -122,19 +128,26 @@ class SapAlternativeWall:
|
|||
|
||||
@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]
|
||||
# ADR-0027: 17/1000 certs lodge a CONSERVATORY-shaped building part carrying
|
||||
# only {double_glazed, floor_area, glazed_perimeter, room_height} — none of
|
||||
# the wall/roof/floor construction fields below. Following the 21.0.1
|
||||
# precedent every field is Optional, so a conservatory part parses to an
|
||||
# all-None SapBuildingPart; its thermal effect is carried separately by the
|
||||
# cert-level conservatory_type, so the empty part flows through harmlessly.
|
||||
identifier: Optional[str] = None
|
||||
wall_dry_lined: Optional[str] = None
|
||||
floor_heat_loss: Optional[int] = None
|
||||
roof_construction: Optional[int] = None
|
||||
wall_construction: Optional[int] = None
|
||||
building_part_number: Optional[int] = None
|
||||
sap_floor_dimensions: Optional[List[SapFloorDimension]] = None
|
||||
wall_insulation_type: Optional[int] = None
|
||||
construction_age_band: Optional[str] = None
|
||||
party_wall_construction: Optional[Union[int, str]] = None
|
||||
wall_thickness_measured: Optional[str] = None
|
||||
roof_insulation_location: Optional[Union[int, str]] = None
|
||||
# ADR-0027: absent on 254/1506 building parts (flat-roof / no-loft) → optional.
|
||||
roof_insulation_thickness: Optional[Union[str, int]] = None
|
||||
sap_room_in_roof: Optional[SapRoomInRoof] = None
|
||||
wall_thickness: Optional[int] = None
|
||||
wall_insulation_thickness: Optional[str] = None
|
||||
|
|
@ -194,7 +207,12 @@ class RenewableHeatIncentive:
|
|||
impact_of_solid_wall_insulation: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
# ADR-0027: 20.0.0 is a reduced-data schema generated from a single example, so
|
||||
# it over-constrains — fields the corpus routinely omits were declared required,
|
||||
# failing 993/1000 certs at parse. Required→optional is data-driven (any field
|
||||
# present in <100% of the corpus gets a default); `kw_only=True` lifts the
|
||||
# dataclass non-default-after-default ordering rule so defaults can sit inline.
|
||||
@dataclass(kw_only=True)
|
||||
class RdSapSchema20_0_0:
|
||||
uprn: int
|
||||
roofs: List[EnergyElement]
|
||||
|
|
@ -214,13 +232,14 @@ class RdSapSchema20_0_0:
|
|||
report_type: int
|
||||
sap_heating: SapHeating
|
||||
sap_version: float
|
||||
sap_windows: List[SapWindow]
|
||||
# ADR-0027: 993/1000 omit this; synthesised by Reduced-Field Synthesis.
|
||||
sap_windows: List[SapWindow] = field(default_factory=list)
|
||||
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
|
||||
# ADR-0027: mixed str / localised-dict in the corpus (see EnergyElement).
|
||||
dwelling_type: Union[str, DescriptionV1]
|
||||
language_code: int
|
||||
property_type: int
|
||||
address_line_1: str
|
||||
|
|
@ -236,7 +255,7 @@ class RdSapSchema20_0_0:
|
|||
registration_date: str
|
||||
sap_energy_source: SapEnergySource
|
||||
secondary_heating: EnergyElement
|
||||
lzc_energy_sources: List[int]
|
||||
lzc_energy_sources: List[int] = field(default_factory=list)
|
||||
sap_building_parts: List[SapBuildingPart]
|
||||
low_energy_lighting: int
|
||||
solar_water_heating: str
|
||||
|
|
@ -252,24 +271,25 @@ class RdSapSchema20_0_0:
|
|||
open_fireplaces_count: int
|
||||
heating_cost_potential: float
|
||||
hot_water_cost_current: float
|
||||
insulated_door_u_value: float
|
||||
insulated_door_u_value: Optional[float] = None
|
||||
mechanical_ventilation: int
|
||||
percent_draughtproofed: int
|
||||
suggested_improvements: List[SuggestedImprovement]
|
||||
suggested_improvements: List[SuggestedImprovement] = field(default_factory=list)
|
||||
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
|
||||
# ADR-0027: cert-level U/g present in 687/1000; Table-24 default otherwise.
|
||||
windows_transmission_details: Optional[WindowsTransmissionDetails] = None
|
||||
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]
|
||||
multiple_glazed_proportion_nr: Optional[str] = None
|
||||
current_energy_efficiency_band: str
|
||||
environmental_impact_potential: int
|
||||
potential_energy_efficiency_band: str
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
from typing import Optional
|
||||
|
||||
import datatypes.magicplan.api.response as api
|
||||
from datatypes.magicplan.api.response import MagicPlanPlan
|
||||
from datatypes.magicplan.domain.models import Plan, Floor, Room, Window, Door
|
||||
|
||||
|
||||
def map_plan(mp: MagicPlanPlan) -> Plan:
|
||||
return Plan(
|
||||
uid=mp.plan.id,
|
||||
name=mp.plan.name,
|
||||
address=_map_address(mp.plan.address),
|
||||
postcode=mp.plan.address.postal_code if mp.plan.address else None,
|
||||
floors=[_map_floor(f) for f in mp.plan_detail.plan.floors],
|
||||
)
|
||||
|
||||
|
||||
def _map_address(addr: Optional[api.Address]) -> Optional[str]:
|
||||
if addr is None:
|
||||
return None
|
||||
street = " ".join(p for p in [addr.street_number, addr.street] if p) or None
|
||||
parts = [p for p in [street, addr.city, addr.country] if p]
|
||||
return ", ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _map_floor(f: api.Floor) -> Floor:
|
||||
return Floor(
|
||||
level=f.level,
|
||||
name=f.name,
|
||||
rooms=[_map_room(r) for r in f.rooms],
|
||||
)
|
||||
|
||||
|
||||
def _map_room(r: api.Room) -> Room:
|
||||
width, length = _parse_dimensions(r.dimensions)
|
||||
return Room(
|
||||
name=r.name,
|
||||
width_m=width,
|
||||
length_m=length,
|
||||
area_m2=round(r.area, 2),
|
||||
windows=[
|
||||
_map_window(wi) for wi in r.wall_items if wi.symbol.id.startswith("window")
|
||||
],
|
||||
doors=[_map_door(wi) for wi in r.wall_items if wi.symbol.id.startswith("door")],
|
||||
)
|
||||
|
||||
|
||||
def _parse_dimensions(dimensions: Optional[str]) -> tuple[float, float]:
|
||||
if not dimensions:
|
||||
return 0.0, 0.0
|
||||
parts = dimensions.split(" x ")
|
||||
width = round(float(parts[0].split(" m")[0]), 2)
|
||||
length = round(float(parts[1].split(" m")[0]), 2)
|
||||
return width, length
|
||||
|
||||
|
||||
def _map_window(wi: api.WallItem) -> Window:
|
||||
return Window(
|
||||
width_m=round(wi.size.x, 2),
|
||||
height_m=round(wi.size.z, 2),
|
||||
area_m2=round(wi.size.x * wi.size.z, 2),
|
||||
opening_type=wi.symbol.id.removeprefix("window"),
|
||||
)
|
||||
|
||||
|
||||
def _map_door(wi: api.WallItem) -> Door:
|
||||
return Door(width_mm=round(wi.size.x, 2))
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from datatypes.magicplan.api.response import MagicPlanPlan
|
||||
from datatypes.magicplan.domain.mapper import map_plan
|
||||
from datatypes.magicplan.domain.models import Plan
|
||||
|
||||
FIXTURE_DIR = Path(__file__).parents[4] / "backend" / "magic_plan"
|
||||
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
|
||||
PLAN_ID_2 = "9f9889ff-793e-4e9a-a6f0-e22f5b0f5365"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def raw_data() -> dict[str, Any]:
|
||||
payload = json.loads(
|
||||
(FIXTURE_DIR / "magicplan_api_plan_response_example.json").read_text()
|
||||
)
|
||||
return payload["data"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mp(raw_data: dict[str, Any]) -> MagicPlanPlan:
|
||||
return MagicPlanPlan.model_validate(raw_data)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def plan(mp: MagicPlanPlan) -> Plan:
|
||||
return map_plan(mp)
|
||||
|
||||
|
||||
def test_plan_uid(plan: Plan):
|
||||
assert plan.uid == PLAN_ID
|
||||
|
||||
|
||||
def test_floor_count(plan: Plan):
|
||||
assert len(plan.floors) == 2
|
||||
|
||||
|
||||
def test_first_room_name(plan: Plan):
|
||||
assert plan.floors[0].rooms[0].name == "Kitchen"
|
||||
|
||||
|
||||
def test_room_dimensions_are_floats(plan: Plan):
|
||||
room = plan.floors[0].rooms[0]
|
||||
assert isinstance(room.width_m, float)
|
||||
assert isinstance(room.length_m, float)
|
||||
assert isinstance(room.area_m2, float)
|
||||
|
||||
|
||||
def test_room_area_rounded_to_2dp(plan: Plan):
|
||||
room = plan.floors[0].rooms[0]
|
||||
assert room.area_m2 == 7.95
|
||||
|
||||
|
||||
def test_room_dimensions_parsed_from_string(plan: Plan):
|
||||
room = plan.floors[0].rooms[0]
|
||||
assert room.width_m == pytest.approx(2.67)
|
||||
assert room.length_m == pytest.approx(2.98)
|
||||
|
||||
|
||||
def test_kitchen_has_windows(plan: Plan):
|
||||
room = plan.floors[0].rooms[0]
|
||||
assert len(room.windows) >= 1
|
||||
|
||||
|
||||
def test_window_fields_are_floats(plan: Plan):
|
||||
window = plan.floors[0].rooms[0].windows[0]
|
||||
assert isinstance(window.width_m, float)
|
||||
assert isinstance(window.height_m, float)
|
||||
assert isinstance(window.area_m2, float)
|
||||
|
||||
|
||||
def test_window_opening_type_prefix_stripped(plan: Plan):
|
||||
window = plan.floors[0].rooms[0].windows[0]
|
||||
assert not window.opening_type.startswith("window")
|
||||
assert window.opening_type == "casement"
|
||||
|
||||
|
||||
def test_window_area_is_width_times_height(plan: Plan):
|
||||
window = plan.floors[0].rooms[0].windows[0]
|
||||
assert window.area_m2 == pytest.approx(window.width_m * window.height_m, rel=1e-2)
|
||||
|
||||
|
||||
def test_window_dimensions_rounded_to_2dp(plan: Plan):
|
||||
window = plan.floors[0].rooms[0].windows[0]
|
||||
assert window.width_m == 1.40
|
||||
assert window.height_m == 1.20
|
||||
assert window.area_m2 == 1.68
|
||||
|
||||
|
||||
def test_door_width_rounded_to_2dp(plan: Plan):
|
||||
door = plan.floors[0].rooms[0].doors[0]
|
||||
assert door.width_mm == 0.79
|
||||
|
||||
|
||||
def test_kitchen_has_doors(plan: Plan):
|
||||
room = plan.floors[0].rooms[0]
|
||||
assert len(room.doors) >= 1
|
||||
|
||||
|
||||
def test_door_width_is_float(plan: Plan):
|
||||
door = plan.floors[0].rooms[0].doors[0]
|
||||
assert isinstance(door.width_mm, float)
|
||||
|
||||
|
||||
# --- Fixture 2: magicplan_api_plan_response_example_2.json ---
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def raw_data_2() -> dict[str, Any]:
|
||||
payload = json.loads(
|
||||
(FIXTURE_DIR / "magicplan_api_plan_response_example_2.json").read_text()
|
||||
)
|
||||
return payload["data"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def plan2(raw_data_2: dict[str, Any]) -> Plan:
|
||||
return map_plan(MagicPlanPlan.model_validate(raw_data_2))
|
||||
|
||||
|
||||
def test_plan2_uid(plan2: Plan):
|
||||
assert plan2.uid == PLAN_ID_2
|
||||
|
||||
|
||||
def test_plan2_floor_count(plan2: Plan):
|
||||
assert len(plan2.floors) == 3
|
||||
|
||||
|
||||
def test_plan2_first_room_name(plan2: Plan):
|
||||
assert plan2.floors[0].rooms[0].name == "Toilet"
|
||||
|
||||
|
||||
def test_plan2_room_area_rounded_to_2dp(plan2: Plan):
|
||||
room = plan2.floors[0].rooms[0]
|
||||
assert room.area_m2 == 0.96
|
||||
|
||||
|
||||
def test_plan2_room_dimensions_parsed_from_string(plan2: Plan):
|
||||
room = plan2.floors[0].rooms[0]
|
||||
assert room.width_m == pytest.approx(1.12)
|
||||
assert room.length_m == pytest.approx(0.86)
|
||||
|
||||
|
||||
def test_plan2_room_with_no_windows(plan2: Plan):
|
||||
hall = plan2.floors[0].rooms[1]
|
||||
assert hall.name == "Hall"
|
||||
assert hall.windows == []
|
||||
|
||||
|
||||
def test_plan2_window_dimensions_rounded_to_2dp(plan2: Plan):
|
||||
window = plan2.floors[0].rooms[0].windows[0]
|
||||
assert window.width_m == 0.39
|
||||
assert window.height_m == 0.67
|
||||
assert window.area_m2 == 0.26
|
||||
|
||||
|
||||
def test_plan2_window_opening_type_casement(plan2: Plan):
|
||||
window = plan2.floors[0].rooms[0].windows[0]
|
||||
assert window.opening_type == "casement"
|
||||
|
||||
|
||||
def test_plan2_window_opening_type_hung(plan2: Plan):
|
||||
bathroom1 = plan2.floors[1].rooms[1]
|
||||
assert bathroom1.name == "Bathroom 1"
|
||||
assert bathroom1.windows[0].opening_type == "hung"
|
||||
|
||||
|
||||
def test_plan2_door_width_rounded_to_2dp(plan2: Plan):
|
||||
door = plan2.floors[0].rooms[0].doors[0]
|
||||
assert door.width_mm == 0.71
|
||||
|
||||
|
||||
# --- Address and postcode fields ---
|
||||
|
||||
|
||||
def test_plan_postcode(plan: Plan):
|
||||
assert plan.postcode == "BR2 8BZ"
|
||||
|
||||
|
||||
def test_plan_address(plan: Plan):
|
||||
assert plan.address == "2 Laburnum Way, Bromley, GB"
|
||||
|
||||
|
||||
def test_plan2_postcode(plan2: Plan):
|
||||
assert plan2.postcode == "BR1 3LP"
|
||||
|
||||
|
||||
def test_plan2_address(plan2: Plan):
|
||||
assert plan2.address == "11 Station Road, Bromley, GB"
|
||||
|
||||
|
||||
# --- Fixture 3: street_number set, city absent ---
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def plan3() -> Plan:
|
||||
payload = json.loads(
|
||||
(FIXTURE_DIR / "magicplan_api_plan_response_example_3.json").read_text()
|
||||
)
|
||||
return map_plan(MagicPlanPlan.model_validate(payload["data"]))
|
||||
|
||||
|
||||
def test_plan3_address_uses_street_number_and_omits_city(plan3: Plan):
|
||||
assert plan3.address == "2 Laburnum Way, GB"
|
||||
|
||||
|
||||
def test_plan3_postcode(plan3: Plan):
|
||||
assert plan3.postcode == "BR2 8BZ"
|
||||
|
||||
|
||||
# --- Fixture 4: street_number set, street absent ---
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def plan4() -> Plan:
|
||||
payload = json.loads(
|
||||
(FIXTURE_DIR / "magicplan_api_plan_response_example_4.json").read_text()
|
||||
)
|
||||
return map_plan(MagicPlanPlan.model_validate(payload["data"]))
|
||||
|
||||
|
||||
def test_plan4_address_uses_street_number_when_street_absent(plan4: Plan):
|
||||
assert plan4.address == "2, Bromley, GB"
|
||||
45
deployment/terraform/lambda/audit_generator/main.tf
Normal file
45
deployment/terraform/lambda/audit_generator/main.tf
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
data "terraform_remote_state" "shared" {
|
||||
backend = "s3"
|
||||
config = {
|
||||
bucket = "assessment-model-terraform-state"
|
||||
key = "env:/${var.stage}/terraform.tfstate"
|
||||
region = "eu-west-2"
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_secretsmanager_secret_version" "db_credentials" {
|
||||
secret_id = "${var.stage}/assessment_model/db_credentials"
|
||||
}
|
||||
|
||||
locals {
|
||||
db_credentials = jsondecode(data.aws_secretsmanager_secret_version.db_credentials.secret_string)
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "audit_generator_s3_write" {
|
||||
role = module.lambda.role_name
|
||||
policy_arn = data.terraform_remote_state.shared.outputs.energy_assessments_s3_write_arn
|
||||
}
|
||||
|
||||
module "lambda" {
|
||||
source = "../../modules/lambda_with_sqs"
|
||||
|
||||
name = "audit_generator"
|
||||
stage = var.stage
|
||||
|
||||
image_uri = local.image_uri
|
||||
|
||||
maximum_concurrency = var.maximum_concurrency
|
||||
reserved_concurrent_executions = var.reserved_concurrent_executions
|
||||
batch_size = var.batch_size
|
||||
|
||||
environment = {
|
||||
STAGE = var.stage
|
||||
LOG_LEVEL = "info"
|
||||
S3_BUCKET_NAME = data.terraform_remote_state.shared.outputs.retrofit_energy_assessments_bucket_name
|
||||
POSTGRES_USERNAME = local.db_credentials.db_assessment_model_username
|
||||
POSTGRES_PASSWORD = local.db_credentials.db_assessment_model_password
|
||||
POSTGRES_HOST = var.db_host
|
||||
POSTGRES_DATABASE = var.db_name
|
||||
POSTGRES_PORT = var.db_port
|
||||
}
|
||||
}
|
||||
9
deployment/terraform/lambda/audit_generator/outputs.tf
Normal file
9
deployment/terraform/lambda/audit_generator/outputs.tf
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
output "audit_generator_queue_url" {
|
||||
value = module.lambda.queue_url
|
||||
description = "URL of the Audit Generator SQS queue"
|
||||
}
|
||||
|
||||
output "audit_generator_queue_arn" {
|
||||
value = module.lambda.queue_arn
|
||||
description = "ARN of the Audit Generator SQS queue"
|
||||
}
|
||||
16
deployment/terraform/lambda/audit_generator/provider.tf
Normal file
16
deployment/terraform/lambda/audit_generator/provider.tf
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
bucket = "audit-generator-terraform-state"
|
||||
key = "terraform.tfstate"
|
||||
region = "eu-west-2"
|
||||
}
|
||||
|
||||
required_version = ">= 1.2.0"
|
||||
}
|
||||
58
deployment/terraform/lambda/audit_generator/variables.tf
Normal file
58
deployment/terraform/lambda/audit_generator/variables.tf
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
variable "stage" {
|
||||
description = "Deployment stage (e.g. dev, prod)"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "lambda_name" {
|
||||
description = "Lambda function name (passed by the deploy workflow)"
|
||||
type = string
|
||||
default = "audit_generator"
|
||||
}
|
||||
|
||||
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 = null
|
||||
}
|
||||
|
||||
variable "reserved_concurrent_executions" {
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
variable "db_host" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "db_name" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "db_port" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
|
@ -37,10 +37,10 @@ module "lambda" {
|
|||
LOG_LEVEL = "info"
|
||||
MAGICPLAN_CUSTOMER_ID = var.magicplan_customer_id
|
||||
MAGICPLAN_API_KEY = var.magicplan_api_key
|
||||
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
|
||||
POSTGRES_USERNAME = local.db_credentials.db_assessment_model_username
|
||||
POSTGRES_PASSWORD = local.db_credentials.db_assessment_model_password
|
||||
POSTGRES_HOST = var.db_host
|
||||
POSTGRES_DATABASE = var.db_name
|
||||
POSTGRES_PORT = var.db_port
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ resource "aws_security_group" "allow_db" {
|
|||
resource "aws_db_instance" "default" {
|
||||
allocated_storage = var.allocated_storage
|
||||
engine = "postgres"
|
||||
engine_version = "14.17"
|
||||
engine_version = "14.22"
|
||||
instance_class = var.instance_class
|
||||
db_name = var.database_name
|
||||
username = jsondecode(data.aws_secretsmanager_secret_version.db_credentials.secret_string)["db_assessment_model_username"]
|
||||
|
|
@ -572,8 +572,9 @@ module "bulk_upload_finaliser_registry" {
|
|||
stage = var.stage
|
||||
}
|
||||
|
||||
# The finaliser only reads the combiner output (bulk_final_outputs) to insert
|
||||
# property rows; it writes to Postgres, not S3.
|
||||
# The finaliser reads the combiner output (bulk_final_outputs) to insert property
|
||||
# rows, and — for v2 (ADR-0006) — the classifier CSV (bulk_onboarding_inputs) to
|
||||
# populate property_overrides. It writes to Postgres, not S3.
|
||||
module "bulk_upload_finaliser_s3_read" {
|
||||
source = "../modules/s3_iam_policy"
|
||||
|
||||
|
|
@ -581,7 +582,7 @@ module "bulk_upload_finaliser_s3_read" {
|
|||
policy_description = "Allow bulk_upload_finaliser Lambda to read combiner output from retrofit-data bucket"
|
||||
bucket_arns = ["arn:aws:s3:::retrofit-data-${var.stage}"]
|
||||
actions = ["s3:GetObject", "s3:ListBucket"]
|
||||
resource_paths = ["/bulk_final_outputs/*"]
|
||||
resource_paths = ["/bulk_final_outputs/*", "/bulk_onboarding_inputs/*"]
|
||||
}
|
||||
|
||||
output "bulk_upload_finaliser_s3_read_arn" {
|
||||
|
|
@ -829,3 +830,17 @@ module "magic_plan_client_registry" {
|
|||
stage = var.stage
|
||||
}
|
||||
|
||||
################################################
|
||||
# Audit Generator – Lambda
|
||||
################################################
|
||||
module "audit_generator_state_bucket" {
|
||||
source = "../modules/tf_state_bucket"
|
||||
bucket_name = "audit-generator-terraform-state"
|
||||
}
|
||||
|
||||
module "audit_generator_registry" {
|
||||
source = "../modules/container_registry"
|
||||
name = "audit-generator"
|
||||
stage = var.stage
|
||||
}
|
||||
|
||||
|
|
|
|||
84
docs/HANDOVER_API_FETCH_AND_REPORT.md
Normal file
84
docs/HANDOVER_API_FETCH_AND_REPORT.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# HANDOVER — EPC API fetch + property inspection report
|
||||
|
||||
**Branch:** `feature/bill-derivation` (worktree `/workspaces/home/hestia-worktrees/model-assemble-new-backend`). **HEAD:** `7be4d83f`.
|
||||
**Prior phase (DONE this session):** DB-less offline Modelling harness + `material_id` + Valuation Uplift + fuel-rate proxies. See "What already exists" below.
|
||||
|
||||
## The goal (this phase)
|
||||
|
||||
Fetch real EPCs **from the live EPC API**, run them through the offline Modelling harness, and **save a per-property report** covering three things:
|
||||
|
||||
1. **Calculator error** — for each property, compare the **lodged SAP** on the API response against **our calculator's** SAP; flag where `|lodged − calculated| > 0.5`.
|
||||
2. **Plans + costings** — the optimised Plan: measures, cost of works + contingency, SAP/band transition, bill & CO₂ savings, valuation uplift.
|
||||
3. **Individual recommended measures + the property attributes that triggered them** — for each fired measure, show the EPC field(s) and value(s) that caused the generator to recommend it (the "why").
|
||||
|
||||
## FIRST: read these
|
||||
|
||||
1. This file (the API client + the three report ingredients are mapped below — load-bearing).
|
||||
2. `docs/HANDOVER_MODELLING.md` + auto-memory `project_modelling_stage_state` — full Modelling state.
|
||||
3. `CONTEXT.md` — glossary, esp. **Calculated SAP10 Performance**, **Validation Cohort**, **Lodged Performance** (the calculator-divergence concept behind report #1), and Plan / Plan Measure / Recommendation.
|
||||
4. ADR-0010/0013 (calculator shadow-validation), ADR-0014 (bills), ADR-0016 (scoring), ADR-0018 (valuation).
|
||||
|
||||
## What already exists (build ON this, don't rebuild)
|
||||
|
||||
- **Offline harness (no DB, no network for modelling):**
|
||||
- `harness/console.py::run_modelling(epc, goal_band="C", current_market_value=None, print_table=True) -> Plan` — runs ONLY the Modelling stage (no Ingestion/Baseline), so it needs no lodged-performance/RHI and works on any calculator-scorable EPC. (`run_one` is the full pipeline; use `run_modelling` for inspection.)
|
||||
- `harness/cohort.py::run_cohort(paths) -> list[CertResult]` + `format_cohort_summary` + `format_cohort_csv`. `CertResult` carries the `Plan` (+ flat `measures`/`baseline_sap`/`post_sap`). Errors are captured per-cert, never abort the sweep.
|
||||
- `scripts/run_modelling_cohort.py` — CLI over a directory of API JSONs (prints tables + summary, writes `modelling_cohort.csv`, gitignored).
|
||||
- `harness/plan_table.py::format_plan_table(plan)` — the sense-check table.
|
||||
- `harness/sample_catalogue.json` — prices all 5 generator measure types (cavity/loft/solid-floor/suspended-floor/ventilation).
|
||||
- In-memory `FakeUnitOfWork` etc. in `tests/orchestration/fakes.py`.
|
||||
- **Proven offline:** the 57 golden API certs (`tests/domain/sap10_calculator/rdsap/fixtures/golden/*.json`, schema 21.0.1, API-shaped) run **57/57, 0 errors** after the fuel-rate proxies landed.
|
||||
|
||||
## Report ingredient #1 — EPC API client (the user's "can't find the file")
|
||||
|
||||
- **Client:** `infrastructure/epc_client/epc_client_service.py::EpcClientService`.
|
||||
- Base URL `https://api.get-energy-performance-data.communities.gov.uk`; **Bearer token** in the constructor.
|
||||
- **Env var:** the bulk-fetch script reads `OPEN_EPC_API_TOKEN` (`scripts/fetch_cohort2_api_jsons.py:49`); CONTEXT.md's glossary names the New-EPC-API token `EPC_AUTH_TOKEN`. **Confirm which is set in `backend/.env` before relying on either.**
|
||||
- Methods: `get_by_uprn(uprn) -> Optional[EpcPropertyData]`, `get_by_certificate_number(cert) -> EpcPropertyData`, `search_by_postcode(postcode) -> list[EpcSearchResult]`. Internally hits `/api/certificate` + `/api/domestic/search`, unwraps `data`, maps via `EpcPropertyDataMapper.from_api_response`. Handles 404/429 + retry.
|
||||
- **Working example to copy:** `scripts/fetch_cohort2_api_jsons.py` bulk-fetches raw API JSON and writes one file per cert (it calls the client's certificate fetch via a retry wrapper). Mirror it to fetch the user's target set (by UPRN list / postcode) into a dump dir, then feed that dir to `run_cohort`.
|
||||
- **NOTE:** the API returns the cert as raw JSON identical to the committed golden fixtures, so the **same `from_api_response` path** the harness already uses applies. The raw JSON (not just the mapped EPC) is what report #1 needs — keep both (raw for the lodged SAP, mapped for the calculator + generators).
|
||||
|
||||
## Report ingredient #2 — lodged vs calculated SAP (calculator error > 0.5)
|
||||
|
||||
- **Calculated:** `domain/sap10_calculator/calculator.py::Sap10Calculator().calculate(epc) -> SapResult`; use `SapResult.sap_score_continuous` (un-rounded) — `sap_score` is the rounded int.
|
||||
- **Lodged:** `EpcPropertyData.energy_rating_current` (mapped from the API response; SAP points 0–100). (Confirm it is populated for live certs — some samples leave it blank; the API response itself carries `current-energy-efficiency`.)
|
||||
- **Divergence:** `error = epc.energy_rating_current − calculate(epc).sap_score_continuous`; flag `abs(error) > 0.5`. This is exactly the **Validation Cohort / shadow-validation** idea (ADR-0010/0013) — the calculator runs alongside the lodged figure and logs divergence.
|
||||
- **Existing scaffolding:** `domain/sap10_calculator/validation/parity_report.py` — `ParityCase(certificate_number, actual_sap, predicted_sap, is_typical)` + `build_parity_report(...) -> ParityReport` (MAE / RMSE / bias / worst-N). The 0.5 is a **design target, not a hardcoded filter** — you implement the per-property flag. Consider reusing `ParityCase`/`build_parity_report` for the cohort-level stats in the report.
|
||||
- **Gotcha:** the calculator can **raise** on an un-mapped cert (UnmappedSapCode / UnmappedApiCode) — catch per-cert (like `run_cohort` does) so one bad cert doesn't abort the report; record the raise as the "error" for that property.
|
||||
|
||||
## Report ingredient #3 — measures + the attributes that triggered them
|
||||
|
||||
Each generator reads `epc.sap_building_parts` filtered to `BuildingPartIdentifier.MAIN` (ventilation is whole-dwelling). The exact trigger fields (so the report can say "fired because X = Y"):
|
||||
|
||||
| Measure | Trigger fields (on `SapBuildingPart` unless noted) | Fires when |
|
||||
|---|---|---|
|
||||
| **cavity_wall_insulation** | `wall_construction`, `wall_insulation_type` | `wall_construction == 4` (cavity) AND `wall_insulation_type == 4` (as-built/uninsulated) — `wall_recommendation.py:42` |
|
||||
| **loft_insulation** | `roof_insulation_thickness` | `== 0` (uninsulated loft) — `roof_recommendation.py:41` |
|
||||
| **{suspended,solid}_floor_insulation** | `floor_insulation_thickness`, `floor_construction_type` | thickness None/blank/"0" AND construction contains "suspended"/"solid" — `floor_recommendation.py:64` |
|
||||
| **mechanical_ventilation** | `epc.sap_ventilation.mechanical_ventilation_kind` (whole-dwelling) | `sap_ventilation is None` OR `mechanical_ventilation_kind is None` (not already mechanically ventilated); only injected when a wall is selected (Measure Dependency) — `ventilation_recommendation.py:41` |
|
||||
|
||||
To produce report #3, run each generator on the EPC (or read the Plan's `PlanMeasure.measure_type`) and, for each fired measure, surface the above field values from `epc.sap_building_parts[MAIN]` (and `sap_ventilation`). The generators currently only return the Recommendation — you may add a small "explain" helper that returns the trigger fields, or read them directly off the EPC in the report builder.
|
||||
|
||||
## Suggested shape (grill the owner first if unsure)
|
||||
|
||||
Extend `harness/cohort.py` / a new `harness/report.py`:
|
||||
- Enrich `CertResult` with `lodged_sap`, `calculated_sap`, `sap_error`, `sap_error_exceeds_0_5` (report #1), and a per-measure `[(measure_type, {trigger_field: value})]` list (report #3). Plan/costings (report #2) already on `CertResult.plan`.
|
||||
- A `format_report` (Markdown and/or CSV) with the three sections; the script writes it to a file (gitignore the artifact).
|
||||
- A live-fetch entrypoint: a script that takes a UPRN list / postcode, fetches via `EpcClientService` into a dump dir (raw JSON), then runs the report. Keep the raw JSON so #1 has the lodged figure.
|
||||
|
||||
## Critical gotchas (carry these)
|
||||
|
||||
- **Worktree import trap** — run via `pytest` / `python -m` from the worktree root, NOT `python /tmp/foo.py` (imports `/workspaces/model`).
|
||||
- **`mip`/CBC broken on aarch64; `moto` not installed** — `--ignore tests/orchestration/test_postcode_splitter_orchestrator.py` + `tests/repositories/unstandardised_address/` when sweeping. Run tests `python -m pytest <path> -q` (NOT `-p no:cov`).
|
||||
- **Don't edit `heat_transmission.py`** (another agent owns it). Per-element U-values still aren't surfaced in `SapResult` (deferred — a request to that owner).
|
||||
- **Live API calls hit the network + rate limits (429)** — the client retries; for a big fetch, throttle and cache raw JSON to disk (mirror `fetch_cohort2_api_jsons.py`), then run the report offline against the cached dump.
|
||||
- **Fuel proxies:** COAL + HEAT_NETWORK are documented **estimates** (see `repositories/fuel_rates/data/fuel_rates_2026_q2.json` `_note`/`_gaps`); coal/heat-network bills are indicative.
|
||||
- **Many certs yield 0 measures** — they're already efficient; that's correct, not a bug. Report #1 (calculator error) is independent of whether measures fire.
|
||||
|
||||
## Conventions
|
||||
|
||||
Stay on `feature/bill-derivation`; one TDD slice = one commit; conventional-commit ending `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`; AAA test headers; assert `abs(x - y) <= tol` (not `pytest.approx`); pyright strict zero errors; annotate call-return locals.
|
||||
|
||||
## How to start
|
||||
|
||||
Confirm the API token env var + that you can fetch one cert (`EpcClientService(...).get_by_uprn(<uprn>)`). Then decide with the owner: report format (Markdown report + CSV?), the property set (UPRN list / postcode / the user's dump), and whether the calculator-error section is per-property flags + a cohort ParityReport. Then TDD the report builder on the committed golden certs (offline) before pointing it at the live API.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue