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:
Khalim Conn-Kowlessar 2026-06-13 22:20:15 +00:00
commit 5b2cf5edc7
429 changed files with 33890 additions and 413077 deletions

View file

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

View file

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

View file

@ -42,9 +42,9 @@
"containerEnv": {
"PYTHONFLAGS": "-Xfrozen_modules=off"
},
"forwardPorts": [8000],
"forwardPorts": ["model-backend:8000"],
"portsAttributes": {
"8000": {
"model-backend:8000": {
"label": "FastAPI",
"onAutoForward": "notify"
}

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View 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()

View 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"]

View 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

View file

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

View file

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

View file

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

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

View 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"]

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
1 User Input Postcode Manual UPRN Code
2 47 The Fairway OX16 0RR 100120771697
3 11 REGENT COURT SL1 3LG 100081041562
4 3/137a Windmill Road TW8 9NH 100021516998
5 Flat 33 SW18 4BE 100023328943
6 FLAT 1 Brendon Grove N2 8JE 200013412
7 Flat 15 KT8 2NE 100062123759
8 FLAT 5 Stonehill Road W4 3AH 100021589829
9 10 Douglas Court SL7 1UQ 100081278099
10 1 Windmill Road HP17 8JA 766034606
11 31 Denewood HP13 7LH 100081095964
12 10, Greenways Drive TW4 5DD 10091597009
13 Flat 10 W4 3AH 100021589834
14 Flat 11 TW4 5DD 10091597010
15 Flat 11 W4 3AH 100021589835
16 12, Greenways Drive TW4 5DD 10091597011
17 Flat 12, Forbes House W4 3AH 100021589836
18 FLAT 1 Goodstone Court HA1 4FL 10070269053
19 Flat 13 TW4 5DD 10091597012
20 Flat 13 W4 3AH 100021589837
21 Flat 14 TW4 5DD 10091597013
22 Flat 14 W4 3AH 100021589838
23 Flat 15 TW4 5DD 10091597014
24 Flat 15 W4 3AH 100021589839
25 Flat 16 TW4 5DD 10091597015
26 Flat 16 W4 3AH 100021589840
27 Flat 17 TW4 5DD 10091597016
28 Flat 17 W4 3AH 100021589841
29 Flat 18 TW4 5DD 10091597017
30 Flat 19 W4 3AH 100021589843
31 Flat 20 W4 3AH 100021589844
32 Flat 21 W4 3AH 100021589845
33 Flat 22 W4 3AH 100021589846
34 FLAT 2 Goodstone Court HA1 4FL 10070269054
35 Flat 23 W4 3AH 100021589847
36 Flat 24 W4 3AH 100021589848
37 30c, Bosanquet Close UB8 3PE 100021475316
38 30e, Bosanquet Close UB8 3PE 100021475318
39 FLAT 3 Goodstone Court HA1 4FL 10070269055
40 FLAT 4 Goodstone Court HA1 4FL 10070269056
41 FLAT 5 Goodstone Court HA1 4FL 10070269057
42 FLAT 6 Goodstone Court HA1 4FL 10070269058
43 FLAT 7 Goodstone Court HA1 4FL 10070269059
44 FLAT 8 Goodstone Court HA1 4FL 10070269060
45 FLAT 9 Goodstone Court HA1 4FL 10070269061
46 FLAT 10 Goodstone Court HA1 4FL 10070269062
47 FLAT 11 Goodstone Court HA1 4FL 10070269063
48 FLAT 12 Goodstone Court HA1 4FL 10070269064
49 FLAT 13 Goodstone Court HA1 4FL 10070269065
50 FLAT 14 Goodstone Court HA1 4FL 10070269066
51 FLAT 15 Goodstone Court HA1 4FL 10070269067
52 FLAT 16 Goodstone Court HA1 4FL 10070269068
53 FLAT 17 Goodstone Court HA1 4FL 10070269069
54 FLAT 18 Goodstone Court HA1 4FL 10070269070
55 FLAT 19 Goodstone Court HA1 4FL 10070269071
56 FLAT 20 Goodstone Court HA1 4FL 10070269072
57 FLAT 21 Goodstone Court HA1 4FL 10070269073
58 FLAT 22 Goodstone Court HA1 4FL 10070269074
59 FLAT 23 Goodstone Court HA1 4FL 10070269075
60 FLAT 24 Goodstone Court HA1 4FL 10070269076
61 FLAT 25 Goodstone Court HA1 4FL 10070269077
62 FLAT 26 Goodstone Court HA1 4FL 10070269078
63 FLAT 27 Goodstone Court HA1 4FL 10070269079
64 FLAT 28 Goodstone Court HA1 4FL 10070269080
65 FLAT 29 Goodstone Court HA1 4FL 10070269081
66 FLAT 30 Goodstone Court HA1 4FL 10070269082
67 FLAT 31 Goodstone Court HA1 4FL 10070269083
68 FLAT 32 Goodstone Court HA1 4FL 10070269084
69 FLAT 33 Goodstone Court HA1 4FL 10070269085
70 FLAT 34 Goodstone Court HA1 4FL 10070269086
71 FLAT 35 Goodstone Court HA1 4FL 10070269087
72 FLAT 36 Goodstone Court HA1 4FL 10070269088
73 FLAT 37 Goodstone Court HA1 4FL 10070269089
74 FLAT 38 Goodstone Court HA1 4FL 10070269090
75 FLAT 39 Goodstone Court HA1 4FL 10070269091
76 FLAT 40 Goodstone Court HA1 4FL 10070269092
77 FLAT 41 Goodstone Court HA1 4FL 10070269093
78 FLAT 42 Goodstone Court HA1 4FL 10070269094
79 FLAT 43 Goodstone Court HA1 4FL 10070269095
80 13 Stubwick Court, Old Saw Mill Place HP6 6FF 10013778260
81 14 Stubwick Court, Old Saw Mill Place HP6 6FF 10013778259
82 15 Stubwick Court, Old Saw Mill Place HP6 6FF 10013778258
83 16 Stubwick Court, Old Saw Mill Place HP6 6FF 10013778263
84 17 Stubwick Court, Old Saw Mill Place HP6 6FF 10013778262
85 18 Stubwick Court, Old Saw Mill Place HP6 6FF 10013778261
86 19 Stubwick Court, Old Saw Mill Place HP6 6FF 10013778266
87 20 Stubwick Court, Old Saw Mill Place HP6 6FF 10013778265
88 21 Stubwick Court, Old Saw Mill Place HP6 6FF 10013778264
89 90a Murray Road W5 4DA 12135293
90 Flat 1, 6 Wolverton Gardens W5 3LJ 12119972
91 1, Monsted House UB1 1FG 12189944
92 10, Monsted House UB1 1FG 12189953
93 20, Monsted House UB1 1FG 12189963
94 2, Monsted House UB1 1FG 12189945
95 3, Monsted House UB1 1FG 12189946
96 4, Monsted House UB1 1FG 12189947
97 5, Monsted House UB1 1FG 12189948
98 6, Monsted House UB1 1FG 12189949
99 7, Monsted House UB1 1FG 12189950
100 8, Monsted House UB1 1FG 12189951
101 9, Monsted House UB1 1FG 12189952
102 1 Cullis House, 1, Accolade Avenue UB1 1FH 12189904
103 2 Cullis House, 1, Accolade Avenue UB1 1FH 12189905
104 3 Cullis House, 1, Accolade Avenue UB1 1FH 12189906
105 4 Cullis House, 1, Accolade Avenue UB1 1FH 12189907
106 5 Cullis House, 1, Accolade Avenue UB1 1FH 12189908
107 6 Cullis House, 1, Accolade Avenue UB1 1FH 12189909
108 1 Genteel House Samara Drive UB1 1FJ 12189835
109 2 Genteel House Samara Drive UB1 1FJ 12189836
110 3 Genteel House Samara Drive UB1 1FJ 12189837
111 4 Genteel House Samara Drive UB1 1FJ 12189838
112 5 Genteel House Samara Drive UB1 1FJ 12189839
113 6 Genteel House Samara Drive UB1 1FJ 12189840
114 7 Genteel House Samara Drive UB1 1FJ 12189841
115 8 Genteel House Samara Drive UB1 1FJ 12189842
116 9 Genteel House Samara Drive UB1 1FJ 12189843
117 10 Genteel House Samara Drive UB1 1FJ 12189844
118 1 ASH TREE HOUSE SE5 0TE None
119 Flat 1 Ash Tree House, 2, Thompson Avenue SE5 0TE 10009803979
120 3 ASH TREE HOUSE SE5 0TE None
121 Flat 3 ASH TREE HOUSE SE5 0TE 10009803981
122 5 ASH TREE HOUSE SE5 0TE None
123 Flat 5 ASH TREE HOUSE SE5 0TE 10009803983
124 Flat 8 ASH TREE HOUSE SE5 0TE 10009803986
125 8 ASH TREE HOUSE SE5 0TE None
126 Flat 12 ASH TREE HOUSE SE5 0TE 10009803990
127 12 ASH TREE HOUSE SE5 0TE None
128 FLAT 1 599 HARROW ROAD W10 4RA 217113930
129 FLAT 2 599 HARROW ROAD W10 4RA 217113931
130 FLAT 3 599 HARROW ROAD W10 4RA None
131 FLAT 4 599 HARROW ROAD W10 4RA None
132 FLAT 5 599 HARROW ROAD W10 4RA 217113934
133 FLAT 6 599 HARROW ROAD W10 4RA None
134 FLAT 7 599 HARROW ROAD W10 4RA None
135 FLAT 8 599 HARROW ROAD W10 4RA None
136 Flat 1, Ohio Building SE13 7RX 10023226256
137 Flat 2, Ohio Building SE13 7RX 10023226257
138 Apartment 1 Block B, 105, Benwell Road N7 7BW 10012792307
139 Apartment 2 Block B, 105, Benwell Road N7 7BW 10012792308
140 Apartment 3 Block B, 105, Benwell Road N7 7BW 10012792309
141 Apartment 4 Block B, 105, Benwell Road N7 7BW 10012792310
142 Apartment 5 Block B, 105, Benwell Road N7 7BW 10012792311
143 Apartment 6 Block B, 105, Benwell Road N7 7BW 10012792312
144 Apartment 7 Block B, 105, Benwell Road N7 7BW 10012792313
145 Apartment 8 Block B, 105, Benwell Road N7 7BW 10012792314
146 Apartment 9 Block B, 105, Benwell Road N7 7BW 10012792315
147 Apartment 10 Block B, 105, Benwell Road N7 7BW 10012792316
148 Apartment 11 Block B, 105, Benwell Road N7 7BW 10012792317
149 Apartment 12 Block B, 105, Benwell Road N7 7BW 10012792318
150 Apartment 13 Block B, 105, Benwell Road N7 7BW 10012792319
151 Apartment 1 Block D, 32, Hornsey Road N7 7AT 10012792366
152 Apartment 2 Block D, 32, Hornsey Road N7 7AT 10012792367
153 Apartment 3 Block D, 32, Hornsey Road N7 7AT 10012792368
154 Apartment 4 Block D, 32, Hornsey Road N7 7AT 10012792369
155 Apartment 5 Block D, 32, Hornsey Road N7 7AT 10012792370
156 Apartment 6 Block D, 32, Hornsey Road N7 7AT 10012792371
157 Apartment 7 Block D, 32, Hornsey Road N7 7AT 10012792372
158 Apartment 8 Block D, 32, Hornsey Road N7 7AT 10012792373
159 Apartment 9 Block D, 32, Hornsey Road N7 7AT 10012792374
160 Apartment 10 Block D, 32, Hornsey Road N7 7AT 10012792375
161 Apartment 11 Block D, 32, Hornsey Road N7 7AT 10012792376
162 Apartment 12 Block D, 32, Hornsey Road N7 7AT 10012792377
163 Apartment 13 Block D, 32, Hornsey Road N7 7AT 10012792378
164 Apartment 14 Block D, 32, Hornsey Road N7 7AT 10012792379
165 Apartment 15 Block D, 32, Hornsey Road N7 7AT 10012792380
166 Apartment 16 Block D, 32, Hornsey Road N7 7AT 10012792381
167 Apartment 17Block D, 32, Hornsey Road N7 7AT 10012792382
168 Apartment 18 Block D, 32, Hornsey Road N7 7AT 10012792383
169 24b Honley Road SE6 2HZ None
170 FLAT B 158 LEAHURST ROAD SE13 5NL 100021976974
171 2 COLLEGE HOUSE CM7 1JS None
172 3 COLLEGE HOUSE CM7 1JS None
173 1 Anita Street M4 5DU None
174 2 Anita Street M4 5DU 77123061
175 5 Anita Street M4 5DU 77123081
176 6 Anita Street M4 5DU 77123082
177 8 Anita Street M4 5DU None
178 9 Anita Street M4 5DU None
179 10 Anita Street M4 5DU 77123051
180 12 Anita Street M4 5DU 77123053
181 19 Anita Street M4 5DU None
182 22 Anita Street M4 5DU None
183 26 Anita Street M4 5DU 77123068
184 28 Anita Street M4 5DU None
185 30 Anita Street M4 5DU None
186 32 Anita Street M4 5DU None
187 33 Anita Street M4 5DU 77123076
188 34 Anita Street M4 5DU None
189 35 Anita Street M4 5DU 77123078
190 36 Anita Street M4 5DU 77123079
191 23 George Leigh Street M4 5DR 77123171
192 25 George Leigh Street M4 5DR None
193 35 George Leigh Street M4 5DR 77123177
194 39 George Leigh Street M4 5DR 77123179
195 41 George Leigh Street M4 5DR None
196 43 George Leigh Street M4 5DR None
197 49 George Leigh Street M4 5DR None
198 51 George Leigh Street M4 5DR 77123185
199 55 George Leigh Street M4 5DR None
200 57 George Leigh Street M4 5DR None
201 1a, Victoria Square M4 5DX 77211153
202 2a Victoria Square M4 5DX None
203 4a, Victoria Square M4 5DX 77211155
204 5a Victoria Square M4 5DX 77211156
205 6a Victoria Square M4 5DX 77211157
206 7a Victoria Square M4 5DX 77211158
207 8a Victoria Square M4 5DX 77211159
208 9a Victoria Square M4 5DX 77211160
209 10a Victoria Square M4 5DX 77211161
210 11a Victoria Square M4 5DX 77211162
211 12a Victoria Square M4 5DX 77211163
212 13a Victoria Square M4 5DX 77211164
213 14a Victoria Square M4 5DX 77211165
214 15a Victoria Square M4 5DX 77211166
215 16a Victoria Square M4 5DX 77211167
216 17a Victoria Square M4 5DX 77211168
217 18a Victoria Square M4 5DX 77211169
218 19a Victoria Square M4 5DX 77211170
219 20a Victoria Square M4 5DX 77211171
220 21a Victoria Square M4 5DY 77211172
221 22a Victoria Square M4 5DY None
222 23a Victoria Square M4 5DY 77211174
223 24a Victoria Square M4 5DY 77211175
224 25a Victoria Square M4 5DY 77211176
225 26a Victoria Square M4 5DY 77211177
226 27a Victoria Square M4 5DY 77211178
227 28a Victoria Square M4 5DY None
228 29a Victoria Square M4 5DY 77211180
229 30a Victoria Square M4 5DY 77211181
230 31a Victoria Square M4 5DY 77211182
231 32a Victoria Square M4 5DY 77211183
232 33a Victoria Square M4 5DY 77211184
233 34a Victoria Square M4 5DY 77211185
234 35a Victoria Square M4 5DY None
235 36a Victoria Square M4 5DY 77211187
236 37a Victoria Square M4 5DY 77211188
237 38a Victoria Square M4 5DY 77211189
238 39a Victoria Square M4 5DY 77211190
239 40a Victoria Square M4 5DY None
240 41a Victoria Square M4 5DY 77211192
241 42a Victoria Square M4 5DY 77211193
242 43a Victoria Square M4 5DY 77211194
243 44a Victoria Square M4 5DY 77211195
244 45a Victoria Square M4 5DY 77211196
245 46a Victoria Square M4 5DY 77211197
246 47a Victoria Square M4 5DY 77211198
247 48a Victoria Square M4 5DY 77211199
248 49a Victoria Square M4 5DY 77211200
249 50a Victoria Square M4 5DY 77211201
250 51a Victoria Square M4 5DY 77211202
251 52a Victoria Square M4 5DY 77211203
252 53a Victoria Square M4 5DY 77211204
253 54a Victoria Square M4 5DY 77211205
254 55a Victoria Square M4 5DY 77211206
255 56a Victoria Square M4 5DZ 77211207
256 57a Victoria Square M4 5DZ None
257 58a Victoria Square M4 5DZ 77211209
258 59a Victoria Square M4 5DZ 77211210
259 60a Victoria Square M4 5DZ 77211211
260 61a Victoria Square M4 5DZ 77211212
261 62a Victoria Square M4 5DZ 77211213
262 63a Victoria Square M4 5DZ None
263 64a Victoria Square M4 5DZ 77211215
264 65a Victoria Square M4 5DZ 77211216
265 66a Victoria Square M4 5DZ None
266 67a Victoria Square M4 5DZ None
267 68a Victoria Square M4 5DZ 77211219
268 69a Victoria Square M4 5DZ 77211220
269 70a Victoria Square M4 5DZ 77211221
270 71a Victoria Square M4 5DZ 77211222
271 72a Victoria Square M4 5DZ 77211223
272 73a Victoria Square M4 5DZ 77211224
273 74a Victoria Square M4 5DZ None
274 75a Victoria Square M4 5DZ 77211226
275 76a Victoria Square M4 5DZ 77211227
276 77a Victoria Square M4 5DZ None
277 78a Victoria Square M4 5DZ 77211229
278 79a Victoria Square M4 5DZ 77211230
279 80a Victoria Square M4 5DZ 77211231
280 81a Victoria Square M4 5DZ 77211232
281 82 Victoria Square M4 5DZ None
282 82a Victoria Square M4 5DZ 77211233
283 83a Victoria Square M4 5DZ 77211234
284 84a Victoria Square M4 5DZ None
285 85a Victoria Square M4 5DZ 77211236
286 86a Victoria Square M4 5DZ 77211237
287 87a Victoria Square M4 5DZ 77211238
288 88a Victoria Square M4 5DZ None
289 89a Victoria Square M4 5DZ 77211240
290 90a Victoria Square M4 5DZ 77211241
291 91a Victoria Square M4 5DZ 77211242
292 92a Victoria Square M4 5DZ 77211243
293 93a Victoria Square M4 5EA 77211244
294 94a Victoria Square M4 5EA None
295 95a Victoria Square M4 5EA 77211246
296 96a Victoria Square M4 5EA 77211247
297 97a Victoria Square M4 5EA 77211248
298 98a Victoria Square M4 5EA 77211249
299 99a Victoria Square M4 5EA 77211250
300 100a Victoria Square M4 5EA 77211251
301 101a Victoria Square M4 5EA None
302 102a Victoria Square M4 5EA None
303 103a Victoria Square M4 5EA 77211254
304 104a Victoria Square M4 5EA 77211255
305 105a Victoria Square M4 5EA None
306 106a Victoria Square M4 5EA 77211257
307 107a Victoria Square M4 5EA 77211258
308 108a Victoria Square M4 5EA 77211259
309 109a Victoria Square M4 5EA 77211260
310 110a Victoria Square M4 5EA 77211261
311 111a Victoria Square M4 5EA 77211262
312 112a Victoria Square M4 5EA None
313 113a Victoria Square M4 5EA 77211264
314 114a Victoria Square M4 5EA 77211265
315 115a Victoria Square M4 5EA 77211266
316 116a Victoria Square M4 5EA 77211267
317 117a Victoria Square M4 5EA None
318 118a Victoria Square M4 5EA None
319 119a Victoria Square M4 5EA 77211270
320 120a Victoria Square M4 5EA 77211271
321 121a Victoria Square M4 5EA 77211272
322 122a Victoria Square M4 5EA 77211273
323 123a Victoria Square M4 5EA 77211274
324 124a Victoria Square M4 5EA None
325 125a Victoria Square M4 5EA 77211276
326 126a Victoria Square M4 5EA 77211277
327 127a Victoria Square M4 5EA 77211278
328 128a Victoria Square M4 5EA 77211279
329 129a Victoria Square M4 5EA 77211280
330 130a Victoria Square M4 5FA 77211281
331 131a Victoria Square M4 5FA 77211282
332 132a Victoria Square M4 5FA 77211283
333 133a Victoria Square M4 5FA None
334 134a Victoria Square M4 5FA 77211285
335 135a Victoria Square M4 5FA 77211286
336 136a Victoria Square M4 5FA 77211287
337 137a Victoria Square M4 5FA 77211288
338 138a Victoria Square M4 5FA 77211289
339 139a Victoria Square M4 5FA 77211290
340 140a Victoria Square M4 5FA 77211291
341 141a Victoria Square M4 5FA 77211292
342 142a Victoria Square M4 5FA 77211293
343 143a Victoria Square M4 5FA 77211294
344 144a Victoria Square M4 5FA 77211295
345 145a Victoria Square M4 5FA None
346 146a Victoria Square M4 5FA 77211297
347 147a Victoria Square M4 5FA 77211298
348 148a Victoria Square M4 5FA 77211299
349 149a Victoria Square M4 5FA 77211300
350 150a Victoria Square M4 5FA 77211301
351 151a Victoria Square M4 5FA None
352 152a Victoria Square M4 5FA 77211303
353 153a Victoria Square M4 5FA None
354 154a Victoria Square M4 5FA 77211305
355 155a Victoria Square M4 5FA None
356 156a Victoria Square M4 5FA 77211307
357 157a Victoria Square M4 5FA 77211308
358 158a Victoria Square M4 5FA 77211309
359 159a Victoria Square M4 5FA None
360 160a Victoria Square M4 5FA 77211311
361 161a Victoria Square M4 5FA None
362 162a Victoria Square M4 5FA None
363 163a Victoria Square M4 5FA 77211314
364 164a Victoria Square M4 5FA 77211315
365 165a Victoria Square M4 5FA 77211316
366 166a Victoria Square M4 5FA None
367 FLAT 3; 42 MORETON ROAD, SOUTH CROYDON, SURREY CR2 7DL None
368 71A Stoneleigh Avenue NE12 8NP None
369 71B Stoneleigh Avenue NE12 8NP None
370 71 Stoneleigh Avenue NE12 8NP 47086009

View file

@ -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
1 User Input Postcode Manual UPRN Code
3 11 REGENT COURT SL1 3LG 100081041562
4 3/137a Windmill Road TW8 9NH 100021516998
5 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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 655), extended programmatically. The footer merge sits at A56:Z56; legend rows at 5760.
- **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 655) 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
}
}

View 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"
}

View 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"
}

View 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
}

View file

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

View file

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

View 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 0100). (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