Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

74 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

England & Wales Housing Decision Support

A tested dbt + DuckDB pipeline that turns nine authoritative/open-data sources into explainable, documented neighbourhood indicators — with published lineage, 228 data tests + 2 dbt unit tests, and a reproducible fixture-to-full build.

The engine rolls fragmented public housing data up to a consistent MSOA grain (7,264 England & Wales neighbourhoods) and derives five transparent 0–100 indicators: affordability, recorded crime, energy, flood resilience and convenience. Each is kept beside the raw figure it came from, with per-area evidence quality driven by coverage, provenance and source grain. Missing data lowers evidence quality; it never silently becomes a zero. The "where to live" framing is the vehicle. The substance is the pipeline, the dimensional and decision modelling, the tests, and the explainability layer.

Scope. A reference analytics-engineering project, not a product — the UK area-data space is already well served (CrystalRoof, Plumplot, PostcodeCheck, …). It's an end-to-end data stack built over official open data.

This is not positioned on transparency of scoring. Transparent 0–100 neighbourhood composites already exist, and at least one publishes its full component weights and gives the report away free. What is uncommon here is narrower and purely a matter of format: a multi-domain, small-area-grain, open-code, reproducible composite — the whole thing rebuilds from source with one command and is tested at every layer. The closest open comparator, AHAH (v5.1, OGL, open code, 43,064 small areas across Great Britain), is scoped to health. That is an absence of a format, not of a served need, and it is not a claim that anyone is underserved.

The website and repository now share one descriptive identity: England & Wales Housing Decision Support. The repository slug is england-wales-housing-decision-support.

CI License: MIT

England & Wales Housing Decision Support homepage — every 0–100 score shown beside the raw figure behind it, laid out like a surveyor's trade-off receipt

One of three UK open-data builds on my profile — siblings tfl-data-engineering (Spark/Airflow/MCP at scale) and community-energy-flex (a decision system with LP/MILP optimisation and a forecast-vs-actual retro). Full project map → profile.

Live

Surface URL
🌐 Housing decision-support website (Next.js / Vercel) https://uk-housing-decision-support.vercel.app
⚙️ API (FastAPI / Fly.io) — OpenAPI docs https://uk-housing-decision-support-api.fly.dev/docs
📊 dbt docs (lineage + column catalogue) https://rosscyking1115.github.io/england-wales-housing-decision-support/

Architecture

The dbt + DuckDB warehouse is the centre of gravity. It builds a small read-only decision.duckdb extract that a thin FastAPI service serves; a Next.js website is one HTTP client of it. The clients exist to show the modelling is frontend-agnostic and consumable; they are not the point.

  dbt + DuckDB engine  ──►  data/decision.duckdb  ──►  API (FastAPI, api/)
  (9 source families)          (slim extract)            │  /v2 + OpenAPI
                                                         │
                                                  Website (web/, Next.js)
                                                  — a thin demo client

The transformation is specified by a versioned machine-readable contract and golden cases under contracts/. Four runtimes compute the overall score — the dbt mart, api/scoring.py, the website's web/src/lib/reweight.ts, and scripts/rescore_extract.py — and each is bound to contracts/neighbourhood-scoring-v2.json: three read that file directly, and the dbt weight bounds are tied to it by a parity test rather than retyped. A fifth copy that read neither the contract nor the golden cases would be a silent divergence risk, so tests/test_scoring_single_definition.py fails the build if the formula turns up in an undeclared file. (A Streamlit MVP and an Expo mobile client were also built and are now parked; the maintenance policy closes the feature roadmap.)

Market metric catalogue

Metric Declared owner and grain Meaning and caveat
Area sale context rpt_area_profile_mvp — one MSOA area_id Latest configured-year matched-sale median, count, year and confidence state. It is area context, not valuation.
Regional price change rpt_price_yoy_by_region — region × transferred_year Same-region change between consecutive calendar-year medians; absent prior year remains null. Analytical reporting only.
Regional new-build premium rpt_new_build_premium — region × transferred_year New-build versus existing median for the same regional year; null when the existing-sale denominator is missing or zero. Analytical reporting only.

The metrics intentionally stay at their source-supported grains. The reference build does not infer an MSOA YoY rate or a property price from regional figures. The grains, denominators, null semantics and permitted consumers are fixed in ADR-001: Area-market metric contract.

That ADR also records the project's most deliberate omission. Its "SCD2 decision: rejected" section refuses to put a slowly-changing dimension over dim_area: the geography loader recreates the postcode table from one normalised source file and retains no prior releases, so no real geography history exists in this repository to record. Deriving valid-from/valid-to rows from repeated builds, file timestamps or the CI fixture would manufacture history rather than record it, and the ADR forbids it. It then names the five conditions that would have to hold before SCD2 is reconsidered — retained authoritative releases, an attributable effective date per release with a documented source-effective vs ingestion-effective choice, a stable business key or official crosswalk policy, a change-bearing attribute with a documented "as-of" use, and an ingestion path that appends releases instead of replacing the current table. Until then: current-state geography plus source-snapshot metadata.

Worked area-contract example. The API golden case for MSOA E02006959 contains a matched-sale median of £267,295, from 417 matched sales in the configured 2025 reference year, with a reliable confidence state. That is evidence about the area's recorded-sale context in the test fixture — not a valuation of a home, forecast, or promise about a live extract refresh.

Repository layout

Path What
models/ dbt models: sources → staging → intermediate → marts (the engine).
seeds/, macros/, analyses/ dbt seeds (fixtures + name lookups), reusable macros (haversine_km, median_anchored), ad-hoc analyses.
scripts/ Data prep/load scripts for the real (non-fixture) sources.
orchestration/ Dagster asset graph over the monthly refresh: ingestion (+ a pre-dbt data-quality gate) → dbt → decision extract.
tests/ 29 singular dbt tests + Python/API regression suites. The other 199 data tests are generic tests declared in the models/ and seeds/ schema files; 228 in total.
api/ FastAPI service over the decision marts (resolve / search / listing-check / areas index / meta). Dockerfile + fly.toml.
web/ Housing decision-support website — Next.js. Search, compare, listing checker, and ~7k programmatic area/town/region/rent pages. See web/README.md and web/DESIGN_BRIEF.md.
data/ Local DuckDB warehouse + the committed decision.duckdb extract the API ships.
docs/ Architecture decision records (docs/adr/) and the evidence inventories behind them.
DEPLOY.md Runbook for deploying the API (Fly.io) and website (Vercel).

The website's search map uses MapLibre GL JS with the keyless OpenFreeMap tile service, so it has no usage-based map API bill. The provider is free and open-source but offers no availability SLA; all search results remain usable as an ordinary list if map tiles are unavailable.

Two reference docs at the repo root carry the modelling detail: HOUSING_AREA_PROFILE_CONTRACT.md (the per-area output contract) and HOUSING_DECISION_SUPPORT_DATA_SOURCES.md (every source, its licence, and its coverage).

The data engine

A complete, tested analytics-engineering pipeline is the project's credibility: sources → staging → intermediate → marts (dimensions / facts / reporting), tested at every layer, with lineage and column-level docs published to GitHub Pages on every push. Nine authoritative/open-data source families support the output.

Signal Coverage Notes
Sale-market context (HM Land Registry) 4.99M transactions, 2021–2025 Long-term market layer; median sale price per area.
Geography (ONSPD) 2.73M postcodes → 7,264 E&W MSOAs 99.999% Land Registry coverage; readable area/LA/region names.
Rent + affordability (ONS PIPR) 100% of MSOAs Local-authority rent incl. per-bedroom (1/2/3/4+).
Energy (EPC) 100% (23.5M certificates) Per-area median EPC band.
Crime (Police API) 99.6% (17.1M crimes) Approx. monthly rate per 1,000 — indicator only; locations are snapped, see below.
Population (ONS mid-year estimates) 7,264 MSOA 2021 areas Compatible mid-2024 denominator for the monthly crime rate.
Planning constraints (Planning Data Platform) England only Wales is explicitly not covered; no favourable zero default.
Flood (Environment Agency zones via Planning Data Platform) England only Share of postcodes intersecting a zone; Wales is explicitly not covered.
Convenience (OpenStreetMap) 100% (437k amenities) Nearest supermarket/school/GP/park/station + walkable count.

Why the crime figure is an indicator and not a measurement. Police.uk does not publish where a crime happened. Before publication each crime is moved to the nearest point on a fixed anonymisation grid — a master list of roughly 779,000 points built in 2012 and refreshed in 2022, each covering a catchment of at least eight postal addresses. Police.uk's own documentation states that inconsistent force geocoding policies mean the published location cannot be relied on as fully accurate or consistent, and puts geocoding accuracy across forces in a range from 60% to 97%. An area rate built from snapped points therefore carries locational error that varies by force and cannot be corrected downstream. This is why the indicator is published as a rate beside its period and denominator, and never as a safety judgement — and it is why that caveat is not softened anywhere in this project.

Each external source is fixture-default for fast, reproducible CI, with a real-data toggle (--vars '<source>: …') for production builds. Explainable scoring (rpt_neighbourhood_score) turns these into five 0–100 component scores, a weighted overall, per-area evidence quality, and a "why this area" line. Potential additions such as door-to-door commute time are deliberately outside the active maintenance scope; station proximity remains the published transport indicator.

Geography, the source toggles, and the full per-source prep commands are detailed in HOUSING_DECISION_SUPPORT_DATA_SOURCES.md and the Source attribution section.

How a recommendation is explained

The score is a transformation you can read top to bottom, not a black box — implemented in rpt_neighbourhood_score:

  1. Per-indicator normalisation → 0–100. Continuous indicators (rent-to-income ratio, crime rate, station distance) use a median-anchored, winsorised min-max via the median_anchored macro: clip to the 2nd/98th percentile, then map p2→0, median→50, p98→100. This keeps magnitude (unlike a pure percentile rank, which forces a uniform spread and makes every area look extreme). Categorical/absolute indicators use fixed anchors — EPC band (A=100 … G=0), flood = share of postcodes in a flood zone.
  2. Overall = weighted geometric mean of the indicators an area actually has (floored at 1), so one excellent pillar can't mask a poor one. Weights are configurable via dbt vars; a client can re-weight from the stored component scores without recomputing the marts.
  3. Missing indicators are dropped, never zeroed — an absent signal lowers the area's evidence-quality level (strong/mixed/limited) instead of silently penalising it.
  4. Every score ships beside its raw figure (rent, crime rate, EPC band) and a generated why_this_area sentence, so the output is auditable.

Because it's one SQL transformation, the logic is covered by the same data tests as everything else (score bounds 0–100, coherence, and coverage/evidence rules).

Orchestration (Dagster)

Land Registry data refreshes monthly, so the refresh is modelled as a Dagster asset graph (orchestration/) rather than a sequence of hand-run scripts: six ingestion assets (the Land Registry spine is downloaded automatically; five reference sources load from locally prepared files) feed the whole dbt project and end at decision_extract, the slim DuckDB file the API ships. The dbt project loads via dagster-dbt, so every model is an asset and every dbt test an asset check in the same lineage.

Dagster asset lineage: six ingestion sources feeding the dbt transform layer and the decision extract

Two design points worth reading the code for:

  • Data-quality gates before dbt, with named thresholds. dbt tests run after load; every ingestion asset is gated before it. The raw Land Registry parquet must clear three assertions in orchestration/checks.py: a row-count floor of 3,000,000 (a full 2021–2025 drop is around 5M, so anything far below it is a truncated or partial file), zero null-or-empty prices and zero null transfer dates, and a malformed-postcode rate of ≤ 1% — non-empty postcodes failing a UK postcode pattern, as a share of all rows, since genuinely empty postcodes are normal in older records. Each of the five reference sources then carries its own prepared_file_is_sane check (row-count floor, required columns, non-null business key) evaluated before its drop-and-recreate load, so a truncated prepared file cannot replace a good warehouse table. All of these are Dagster asset checks with blocking=True: a failed gate halts the graph at the front door instead of propagating into the marts.
  • The orchestrated build is the real refresh. It parses and builds dbt with the real-source vars, while plain dbt build keeps the fixture-seed default for fast, reproducible CI. One full_refresh job runs the whole pipeline (steps serialized — DuckDB is a single-writer file on Windows).

Why Dagster and not Airflow: this is a set of data assets with lineage, not a task DAG — the asset/materialization model fits, and dbt lineage flows into the same graph. Freshness is declared (35-day warn on the warehouse spine and the extract, mirroring dbt's source freshness) and the monthly cadence is defined in code — cron_schedule="0 9 28 * *", aligned to Land Registry's publication around the 20th working day — but it ships with default_status=DefaultScheduleStatus.STOPPED, and the code comment beside it says why: the reference-source archives are large, licensed and fetched by hand, so pretending an unattended cron runs in production "would be theatre". The schedule documents the intended SLA; the job runs on demand (dagster dev -m orchestration.definitions). Details and trade-offs in orchestration/README.md.

Running locally

1. The engine (dbt + DuckDB)

git clone https://github.com/rosscyking1115/england-wales-housing-decision-support.git
cd england-wales-housing-decision-support
python -m venv .venv
# Windows: .\.venv\Scripts\Activate.ps1   |  macOS/Linux: source .venv/bin/activate
python -m pip install --upgrade pip && pip install -r requirements.txt
dbt deps

mkdir -p ~/.dbt && cp profiles.yml.example ~/.dbt/profiles.yml   # one-time

python scripts/download_raw.py     # --sample for a faster 2-year run
python scripts/load_to_duckdb.py
dbt seed
dbt build --threads 1              # full warehouse + 228 data tests, < 5 min on a laptop

2. The API

.venv/Scripts/python -m uvicorn api.main:app --reload   # http://127.0.0.1:8000/docs

3. The housing decision-support website

cd web
cp .env.example .env.local          # points at http://127.0.0.1:8000
npm install && npm run dev          # http://localhost:3000

The website needs the API running. Full web docs in web/README.md; deploying both services is covered in DEPLOY.md.

Testing & CI

ci.yml runs on every PR and gates main via branch protection: Python unit tests (incl. the API suite), dbt build --threads 1 with 228 data tests + 2 unit tests, source-freshness, sqlfluff, and the web lint/test/build checks.

Layer Count What it catches
Source freshness 1 Stale upstream data (warn if nothing newer than 35 days).
Built-in row-shape 163 Schema bugs, FK orphans, enum drift, contract violations.
dbt-utils 22 Sign/range invariants, multi-column uniqueness, score bounds.
dbt-expectations 14 Type-cast bugs, statistical drift, format regressions.
Singular (tests/assert_*.sql) 29 Domain anomalies, coverage, coherence, and cross-runtime golden cases.
dbt data-test total 228 All passing on every dbt build.
dbt unit tests 2 Model logic on mock inputs: enrichment (postcode parse + region join + filter) and the scoring maths (median-anchored min-max, geometric-mean floor, missing-component rule).
API (tests/test_api.py) 14 Versioned OpenAPI contract, neutral comparison fields, search validation/re-rank, missing-data and jurisdiction coverage, mocked postcodes.io.

Two CI steps are gates rather than counts, and they are the two worth reading ci.yml for:

  • A negative test that proves the scoring contract still refuses bad input. One step runs dbt compile --select rpt_neighbourhood_score --vars '{score_weight_affordability: 6}' and fails the build if that command succeeds. Scoring weights are bounded 0.0–5.0 by allowed_weight in contracts/neighbourhood-scoring-v2.json, enforced at compile time by the validated_score_weight macro, so a weight of 6 must raise a compilation error. A green test suite only proves valid weights work; this step proves invalid ones are rejected, so the guard cannot rot unnoticed.
  • sqlfluff lint over models/ is a hard merge gate, not an advisory job. A style violation in model SQL fails the same required build-and-test check as a failing data test.

Modelling & scoring principles

  • Explain trade-offs; never hide behind one opaque score.
  • No "safe"/"unsafe" labels — measured indicators and caveats only.
  • No red-to-green good/bad colouring of scores; the EPC A–G bands are the one official exception.
  • Official and open data first; no portal scraping. Listing comparison is user-entered.
  • Area-level guidance over individual-property claims unless the source supports it.
  • Missing data lowers evidence quality — it never silently becomes a zero.

Maintenance status

The project is complete as a portfolio reference implementation and has no active feature roadmap. Maintenance is limited to source breakages, security and dependency updates, correctness defects, and documentation that keeps published claims aligned with the implementation. The Streamlit MVP and Expo mobile client remain parked. See MAINTENANCE.md for the acceptance policy.

Source attribution

All sources are public and used under the Open Government Licence v3.0 (OpenStreetMap under the Open Database Licence). Per-source access and prep commands are catalogued in HOUSING_DECISION_SUPPORT_DATA_SOURCES.md.

  • HM Land Registry Price Paid Data — sale-market context. Contains HM Land Registry data © Crown copyright and database right.
  • ONS Postcode Directory — postcode → MSOA/LSOA/LA/region geography and name lookups. Contains OS, ONS, Royal Mail and NRS data © Crown copyright and database right.
  • ONS Price Index of Private Rents — local-authority rent (incl. per bedroom). Values may be provisional and revised.
  • Energy Performance Certificates — per-area median EPC band (23.5M certificates). Certificates may be expired or superseded.
  • Police street-level crime — approximate monthly crime rate per 1,000 (LSOA→MSOA), as an indicator only. Locations are snapped to an anonymisation grid before publication and force geocoding accuracy is stated by the publisher as ranging from 60% to 97%.
  • ONS mid-year population estimates — compatible mid-2024 MSOA population denominator for the recorded-crime rate.
  • Planning Data Platform — per-MSOA planning-constraint count via spatial point-in-polygon.
  • Environment Agency flood-risk zones — per-MSOA flood-risk band from postcode intersections, distributed through the Planning Data Platform.
  • OpenStreetMap (via Geofabrik) — nearest-amenity + walkable count. © OpenStreetMap contributors, Open Database Licence.

License

MIT.

About

Where-to-live decision support from nine official open-data sources: five explainable indicators across 7,264 neighbourhoods, evidence beside every score.

Topics

Resources

Security policy

Stars

Watchers

Forks

Releases

Contributors

Languages