Skip to content

Commit 8da437d

Browse files
committed
Harden the two-axis analysis: uncertainty, spatial-robust SEs, tests, CI
Statistics. Report LAD-clustered 95% CIs on every headline ratio, with a delta-method and cluster-bootstrap layer in the new stats/inference.py; switch the access Poisson from freq_weights to var_weights so effective N is the OA count. Add common-support companions to the pure-type contrasts, the rate range (harmonised and circularity-robust variants beside the headline), and heat-DV and travel-elasticity sensitivities. Recast the size decomposition as a descriptive covariate adjustment ("account for"), not causal mediation. Point estimates are unchanged; this adds uncertainty and disclosure around them. Paper and documents. Fold the CIs into PAPER.md; correct Table 2 so the total column is a genuine per-OA median of household energy (a median is not additive) and caption it as such. De-stale ROADMAP/README/CLAUDE/REPRODUCTION (drop the retracted 6.3x rate and the superseded lock-in figures). Repair references.bib and engage the counter-literature. Regenerate paper/results_snapshot.txt end-to-end. Engineering. Add a pytest suite (26 tests, data-free) and GitHub Actions CI; validate every data-layer join with match-rate floors that raise; make EPC dedup, boundary-postcode resolution, and NTS latest-year selection deterministic; factor shared fetch/text helpers; add data/manifest.json, .env.example, and stronger multi-output done-detection in the pipeline orchestrator.
1 parent e2a4395 commit 8da437d

48 files changed

Lines changed: 2422 additions & 623 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Copy this file to `.env` at the repo root and set the path for your machine.
2+
# The .env is gitignored; only this example is committed.
3+
#
4+
# URBAN_ENERGY_DATA_DIR is the base directory for every downloaded and derived
5+
# dataset (see CLAUDE.md "Storage layout"). Point it at a disk with roughly
6+
# 80+ GB free: the raw EPC certificates (~55 GB) and OS Open UPRN dominate the
7+
# footprint. Caches live under `$URBAN_ENERGY_DATA_DIR/cache`.
8+
#
9+
# Use an absolute path.
10+
URBAN_ENERGY_DATA_DIR=/absolute/path/to/urban-energy-data

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
lint-type-test:
9+
runs-on: ubuntu-latest
10+
env:
11+
# The data layer is not downloaded in CI; tests use synthetic frames only.
12+
# A placeholder keeps urban_energy.paths importable (it requires the var).
13+
URBAN_ENERGY_DATA_DIR: ${{ runner.temp }}/urban-energy-data
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Install uv
18+
uses: astral-sh/setup-uv@v6
19+
with:
20+
# cityseer has no wheel for Python 3.14 yet, so the project pins <3.14.
21+
python-version: "3.13"
22+
enable-cache: true
23+
24+
- name: Install dependencies
25+
run: uv sync
26+
27+
- name: Ruff lint
28+
run: uv run ruff check .
29+
30+
- name: Ruff format check
31+
run: uv run ruff format --check .
32+
33+
- name: Type check (ty)
34+
# Non-blocking. ty reports a single known pandas-stubs false positive (the
35+
# Series.sort_values overload in data/download_fsa.py) and nothing else; the
36+
# check runs for visibility but does not gate the build because that one
37+
# diagnostic cannot be suppressed without a stub fix. Gate it once the stub
38+
# is fixed upstream.
39+
run: uv run ty check
40+
continue-on-error: true
41+
42+
- name: Tests
43+
run: uv run pytest

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ dmypy.json
7171
*.json
7272
!package.json
7373
!*.config.json
74+
# data-source manifest (tracked; verified by src/urban_energy/fetch.py)
75+
!data/manifest.json
7476
*.kml
7577
*.kmz
7678
*.gml

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ urban-energy/
3535
│ ├── lock_in.py # Residual energy gap after best fabric + full EV
3636
│ ├── form_size_decomposition.py # Heat vs dwelling/household-size decomposition
3737
│ ├── argument_figures.py # The two paper/summary.md figures
38-
│ ├── figures/{oa,nepi}/ # Legacy three-surface PNGs (referenced only by the deferred PAPER)
38+
│ ├── figures/{oa,nepi}/ # Legacy three-surface PNGs (not referenced by the current two-axis PAPER)
3939
│ └── archive/ # Archived LSOA analysis scripts
4040
├── tests/ # pytest framework configured, tests pending
4141
├── temp/ # Default $URBAN_ENERGY_DATA_DIR (gitignored)
@@ -174,7 +174,9 @@ are run on demand rather than wired as pipeline stages.
174174
> **⏸ Pending.** The earlier three-surface / A–G scorecard, the empirical access-penalty model,
175175
> and the Atlas (XGBoost planning models + static site) were removed from the tree in the
176176
> two-axis migration (git history holds them). The Atlas scoring + models are **pending
177-
> reevaluation** for the two-axis frame; the paper is deferred. See
177+
> reevaluation** for the two-axis frame; the paper ([PAPER.md](PAPER.md)) is now drafted on that
178+
> frame, with the old three-surface draft archived at
179+
> [`paper/archive/PAPER_three_surface_deferred.md`](paper/archive/PAPER_three_surface_deferred.md). See
178180
> [`paper/summary.md`](paper/summary.md) and [ROADMAP.md](ROADMAP.md).
179181
180182
---

PAPER.md

Lines changed: 43 additions & 37 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ computed from open data.
99
removed pending a fresh two-axis rebuild — see below).*
1010

1111
> **⏸ Current focus.** The live work is the **[argument](paper/summary.md)** (the canonical
12-
> two-axis statement) and the **data + analysis pipeline**. The **paper ([PAPER.md](PAPER.md)) is
13-
> deferred**, and the **Atlas is pending** — its scoring and the XGBoost planning models are to
12+
> two-axis statement) and the **data + analysis pipeline**. The **paper ([PAPER.md](PAPER.md))** is now
13+
> drafted on the two-axis frame (finalisation pending), and the **Atlas is pending** — its scoring and the XGBoost planning models are to
1414
> be reevaluated for the two-axis frame (that code lives in git history). The theory + headline
1515
> below are the current two-axis frame.
1616
@@ -68,7 +68,7 @@ less access per Joule.** This is the carbon/infrastructure lock-in (Seto et al.
6868
| --- | ---: | ---: | ---: |
6969
| Heat (metered) | 10,194 | 15,020 | 1.6× |
7070
| Car travel (NTS-anchored) | 3,240 | 9,272 | 3.1× |
71-
| **Total energy** | **13,674** | **23,832** | **2.1×** |
71+
| **Total energy** (per-OA median) | **13,674** | **23,832** | **2.1×** |
7272

7373
The Flat/Detached columns are observed medians; the gap is the compositional flat-to-detached
7474
estimate per dwelling, so it is not the column quotient. Energy is modelled per dwelling with
@@ -103,7 +103,7 @@ homes and longer trips, while the access deficit is **100% unchanged**. Built fo
103103

104104
### ⏸ Pending (next phase)
105105

106-
1. **The paper** — deferred ([PAPER.md](PAPER.md)).
106+
1. **The paper** ([PAPER.md](PAPER.md)): drafted on the two-axis frame; finalisation and submission pending.
107107
2. **The NEPI Atlas + planning tool** — pending: reevaluate the place-scoring and the XGBoost
108108
planning models for the two-axis frame (their code lives in git history).
109109

@@ -114,7 +114,7 @@ homes and longer trips, while the access deficit is **100% unchanged**. Built fo
114114
| Path | Purpose |
115115
| ---- | ------- |
116116
| [paper/summary.md](paper/summary.md) | **The argument** — canonical two-axis statement (single source of truth) |
117-
| [PAPER.md](PAPER.md) | The formal IMRaD paper **⏸ deferred** (old three-surface framing) |
117+
| [PAPER.md](PAPER.md) | The formal IMRaD paper (current two-axis draft; finalisation pending). Old three-surface draft archived at [paper/archive/PAPER_three_surface_deferred.md](paper/archive/PAPER_three_surface_deferred.md) |
118118
| [CLAUDE.md](CLAUDE.md) | **Technical brief** — codebase layout, data, architecture, conventions |
119119
| [REPRODUCTION.md](REPRODUCTION.md) | **How to rebuild** — orchestrator-driven recipe, manual downloads |
120120
| [ROADMAP.md](ROADMAP.md) | **Status, scope & open work** — incl. the methodology decisions |
@@ -163,7 +163,7 @@ from the tree in the migration (in git history, pending reevaluation).
163163

164164
**Current focus:** keeping the argument + processing pipeline watertight.
165165

166-
**⏸ Pending (next phase):** the paper ([PAPER.md](PAPER.md)); reevaluating the Atlas scoring +
166+
**⏸ Pending (next phase):** finalising the paper ([PAPER.md](PAPER.md)) (drafted on the two-axis frame); reevaluating the Atlas scoring +
167167
planning models for the two-axis frame.
168168

169169
---

REPRODUCTION.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ Stages skip when their declared outputs already exist (`--force` to rebuild).
5656

5757
```bash
5858
uv run python stats/oa_network_access.py # build network-access cache (cityseer, ~12 min)
59-
uv run python stats/lock_in.py # energy 2.0× → optimised 1.5× per household (1.5× → 1.15× per person)
60-
uv run python stats/access_profile.py # network 6.3× access per kWh + on-foot gap ~24×
59+
uv run python stats/lock_in.py # energy per dwelling 2.12× → optimised 1.51× (equal family size 1.71× → 1.18×)
60+
uv run python stats/access_profile.py # network 3.6× access per kWh + on-foot gap ~24×
6161
uv run python stats/form_size_decomposition.py # heat vs dwelling/household size
6262
```
6363

ROADMAP.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Single source of truth for status, open work, and methodology decisions.
44
Updated 2026-06-15 (two-axis reframe; paper + Atlas deferred).
55

66
> **⏸ Current focus.** The live work is the **[argument](paper/summary.md)** and the
7-
> **data + analysis pipeline**. The **paper ([PAPER.md](PAPER.md)) is deferred**; the **Atlas is
7+
> **data + analysis pipeline**. The **paper ([PAPER.md](PAPER.md))** is drafted on the two-axis frame (finalisation pending); the **Atlas is
88
> pending** — its place-scoring and XGBoost planning models are to be reevaluated for the
99
> two-axis frame (that code lives in git history).
1010
@@ -19,7 +19,9 @@ Updated 2026-06-15 (two-axis reframe; paper + Atlas deferred).
1919

2020
### ⏸ Pending (next phase)
2121

22-
1. **The paper** — deferred ([PAPER.md](PAPER.md)).
22+
1. **The paper** ([PAPER.md](PAPER.md)): drafted on the two-axis frame and matching the canonical
23+
`summary.md` numbers; finalisation and submission pending. The old three-surface draft is archived
24+
at [paper/archive/PAPER_three_surface_deferred.md](paper/archive/PAPER_three_surface_deferred.md).
2325
2. **The NEPI Atlas + planning tool** — pending: reevaluate the place-scoring and the XGBoost
2426
planning models for the two-axis frame (code in git history).
2527

@@ -46,11 +48,12 @@ The rebuild targets only what the two-axis analysis consumes:
4648

4749
- **National OA dataset** — assembled in the stats layer.
4850
- **Network access** (`stats/oa_network_access.py`): cityseer over OS Open Roads, national network
49-
built **once** and queried per catchment band, each OA at its own NTS car-trip catchment;
50-
rate **6.3× access per kWh**, validated to ~2% of a literal per-OA computation (~12 min).
51+
built **once** and queried per catchment band, each OA at its own NTS car-trip catchment,
52+
the built-once counts matching a literal per-OA computation to ~2% (~12 min); the rate is
53+
**3.6× access per kWh** (access advantage × energy saving, `access_profile.py`).
5154
- **Two-axis analysis** ([paper/summary.md](paper/summary.md)): NTS-anchored
52-
car-travel energy, lock-in (per household 2.0× → 1.5×; per person 1.5× → 1.15×), network access
53-
rate (6.3× access per kWh) + on-foot gap (~24×), heat-vs-size decomposition all on the shared
55+
car-travel energy, lock-in (per dwelling 2.12× → 1.51×; at equal family size 1.71× → 1.18×), network
56+
access rate (3.6× access per kWh) + on-foot gap (~24×), heat-vs-size decomposition, all on the shared
5457
`stats/oa_data.py` core.
5558
- **Two-axis migration cleanup:** stripped the retired three-surface / A–G code and
5659
the old Atlas; unified the EPC→OA aggregation (`data/aggregate_epc_oa.py`); lean
@@ -78,8 +81,9 @@ These are the contestable scientific choices; none gate acquisition.
7881
- **Under-recording robustness.** Flat metered energy omits communal/bulk gas (district
7982
heating); detached omits off-gas. Re-add an under-recording check before publishing the
8083
heat gradient (the old `form_bias` flags were removed with the pipeline).
81-
- **Spatial autocorrelation.** Consider spatial error / lag models (or spatially-clustered SEs)
82-
on the form/size regression.
84+
- **Spatial autocorrelation.** LAD-clustered SEs are now the delivered primary inference
85+
(every headline ratio carries a LAD-clustered 95% CI; composites carry cluster-bootstrap
86+
CIs). What remains open is an optional spatial error / lag model as a cross-check.
8387

8488
### Forward work (out of current scope)
8589
- **Atlas (pending):** reevaluate the place-scoring and the XGBoost planning models for the
@@ -90,5 +94,5 @@ These are the contestable scientific choices; none gate acquisition.
9094

9195
### Paper / repo
9296
- Finalise `paper/references.bib`.
93-
- Expand the pytest suite (form-bias tests landed; pipeline/stats coverage next).
97+
- (Re)establish the pytest suite: none currently (the earlier form-bias tests were removed); pipeline and stats coverage pending.
9498
- Pre-submission cover-letter framing for the target journal.

data/aggregate_energy_oa.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919
import pandas as pd
2020

21+
from urban_energy.checks import assert_match_rate
2122
from urban_energy.paths import DATA_DIR
23+
from urban_energy.text import normalise_postcode as _normalise_postcode
2224

2325
OUTPUT_DIR = DATA_DIR / "statistics"
2426

@@ -33,8 +35,10 @@ def normalise_postcode(s: pd.Series) -> pd.Series:
3335
"""
3436
Normalise postcode format for matching.
3537
36-
Strips whitespace, converts to uppercase. Does NOT remove the internal
37-
space — both DESNZ and Code-Point use the standard "AB10 1AU" format.
38+
Strips whitespace, converts to uppercase. Keeps the internal space — both
39+
DESNZ and the Code-Point-derived lookup use the standard "AB10 1AU" format,
40+
so both sides of the join must retain the space. Thin wrapper over
41+
:func:`urban_energy.text.normalise_postcode` with ``keep_space=True``.
3842
3943
Parameters
4044
----------
@@ -46,7 +50,7 @@ def normalise_postcode(s: pd.Series) -> pd.Series:
4650
pd.Series
4751
Normalised postcode strings.
4852
"""
49-
return s.str.strip().str.upper()
53+
return _normalise_postcode(s, keep_space=True)
5054

5155

5256
def aggregate_postcode_to_oa(
@@ -75,13 +79,13 @@ def aggregate_postcode_to_oa(
7579
energy["Postcode"] = normalise_postcode(energy["Postcode"])
7680
lookup["Postcode"] = normalise_postcode(lookup["Postcode"])
7781

78-
# Join energy to OA lookup
82+
# Join energy to OA lookup. m:1 — the lookup holds one OA per postcode, so a
83+
# duplicate-postcode lookup can never fan out the energy rows.
7984
lookup_cols = ["Postcode", "OA21CD", "LSOA21CD"]
80-
merged = energy.merge(lookup[lookup_cols], on="Postcode", how="inner")
81-
n_matched = len(merged)
82-
n_total = len(energy)
83-
pct = n_matched / n_total
84-
print(f" Matched {n_matched:,}/{n_total:,} postcodes ({pct:.1%})")
85+
merged = energy.merge(
86+
lookup[lookup_cols], on="Postcode", how="inner", validate="m:1"
87+
)
88+
assert_match_rate(len(energy), len(merged), name="energy postcode ↔ OA lookup")
8589

8690
# Compute meter counts per postcode (use max of gas/elec meters)
8791
elec_meters = pd.to_numeric(
@@ -117,8 +121,14 @@ def aggregate_postcode_to_oa(
117121
# Count metrics
118122
results["oa_num_meters"] = merged.groupby("OA21CD")["_meters"].sum()
119123
results["oa_num_postcodes"] = merged.groupby("OA21CD")["Postcode"].count()
120-
# Per-fuel meter counts (feed the Form under-recording flags, TODO #6):
121-
# low gas-meter coverage flags off-gas / communal-gas OAs.
124+
# Per-fuel meter counts. NOTE (2026-07-08): these once fed the Form-surface
125+
# "under-recording" flags (off-gas / communal-gas OAs, methodology item #6),
126+
# removed with the form_bias module in the two-axis migration (git 624c50d).
127+
# The counts are still emitted because low gas-meter coverage relative to
128+
# electricity is a useful off-gas signal. A re-add would derive, per OA:
129+
# gas-meter coverage = oa_gas_num_meters / oa_elec_num_meters, plus the
130+
# Census TS044 flat share, and flag OAs below a coverage floor as likely
131+
# under-recording metered gas (see the archived tests/test_form_bias.py).
122132
results["oa_elec_num_meters"] = elec_meters.groupby(merged["OA21CD"]).sum()
123133
results["oa_gas_num_meters"] = gas_meters.groupby(merged["OA21CD"]).sum()
124134

data/aggregate_epc_oa.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131

3232
import pandas as pd
3333

34+
from urban_energy.checks import assert_match_rate
3435
from urban_energy.paths import DATA_DIR
36+
from urban_energy.text import normalise_postcode
3537

3638
EPC_PATH = DATA_DIR / "epc" / "epc_domestic_spatial.parquet"
3739
LOOKUP_PATH = DATA_DIR / "statistics" / "postcode_oa_lookup.parquet"
@@ -45,7 +47,25 @@
4547

4648

4749
def _band_to_year(band: object) -> float:
48-
"""Midpoint construction year from an EPC age band (NaN if unparseable)."""
50+
"""
51+
Midpoint construction year from an EPC age band.
52+
53+
Closed bands ("1900-1929") return the arithmetic midpoint. Open bands return
54+
their single boundary year: "2007 onwards" gives 2007, and "before 1900" gives
55+
1900 (its upper bound, a conservative lower-bound proxy for the age of a
56+
pre-1900 dwelling). This preserves the old-housing signal in the build-year
57+
confound rather than discarding it. A band with no parseable year returns NaN.
58+
59+
Parameters
60+
----------
61+
band : object
62+
Raw EPC ``CONSTRUCTION_AGE_BAND`` value.
63+
64+
Returns
65+
-------
66+
float
67+
Representative year, or NaN when no year is parseable.
68+
"""
4969
years = [int(y) for y in re.findall(r"\b(1[89]\d{2}|20\d{2})\b", str(band))]
5070
return sum(years) / len(years) if years else float("nan")
5171

@@ -64,7 +84,7 @@ def main() -> None:
6484
"CONSTRUCTION_AGE_BAND",
6585
],
6686
)
67-
epc["POSTCODE"] = epc["POSTCODE"].astype(str).str.strip().str.upper()
87+
epc["POSTCODE"] = normalise_postcode(epc["POSTCODE"], keep_space=True)
6888
floor = pd.to_numeric(epc["TOTAL_FLOOR_AREA"], errors="coerce")
6989
cur = pd.to_numeric(epc["ENERGY_CONSUMPTION_CURRENT"], errors="coerce")
7090
pot = pd.to_numeric(epc["ENERGY_CONSUMPTION_POTENTIAL"], errors="coerce")
@@ -76,9 +96,13 @@ def main() -> None:
7696
)
7797

7898
lookup = pd.read_parquet(LOOKUP_PATH, columns=["Postcode", "OA21CD"])
79-
lookup["Postcode"] = lookup["Postcode"].astype(str).str.strip().str.upper()
80-
merged = epc.merge(lookup, left_on="POSTCODE", right_on="Postcode", how="inner")
81-
print(f" {len(merged):,} certificates matched to an OA")
99+
lookup["Postcode"] = normalise_postcode(lookup["Postcode"], keep_space=True)
100+
# m:1 — many certificates share a postcode, but the lookup holds one OA per
101+
# postcode, so the lookup cannot fan out the certificate rows.
102+
merged = epc.merge(
103+
lookup, left_on="POSTCODE", right_on="Postcode", how="inner", validate="m:1"
104+
)
105+
assert_match_rate(len(epc), len(merged), name="EPC postcode ↔ OA lookup")
82106

83107
# Floor area: median over valid-floor certs, OA kept only if ≥ MIN_EPC_PER_OA.
84108
floor_oa = (
@@ -112,15 +136,29 @@ def main() -> None:
112136
.rename(columns={"year": "oa_median_build_year"})
113137
)
114138

139+
# Outer merge — each column has its own OA coverage. Floor area is gated by
140+
# MIN_EPC_PER_OA (≥5 certs), while intensity and build year keep any OA with
141+
# at least one valid cert. So an OA can carry an intensity/build-year value
142+
# but a NaN floor area (fewer than 5 floor certs). Downstream consumers must
143+
# treat the columns as independently populated, not row-complete.
115144
oa = (
116-
floor_oa.merge(pot_oa, on="OA21CD", how="outer")
117-
.merge(cur_oa, on="OA21CD", how="outer")
118-
.merge(year_oa, on="OA21CD", how="outer")
145+
floor_oa.merge(pot_oa, on="OA21CD", how="outer", validate="1:1")
146+
.merge(cur_oa, on="OA21CD", how="outer", validate="1:1")
147+
.merge(year_oa, on="OA21CD", how="outer", validate="1:1")
119148
)
120149
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
121150
oa.to_parquet(OUTPUT_PATH, index=False)
122151

123152
print(f" wrote {len(oa):,} OAs → {OUTPUT_PATH}")
153+
print(" per-column coverage (populated / total OAs):")
154+
for col in (
155+
"oa_median_floor_area_m2",
156+
"epc_current_kwh_m2",
157+
"epc_potential_kwh_m2",
158+
"oa_median_build_year",
159+
):
160+
n_pop = int(oa[col].notna().sum())
161+
print(f" {col:<24s} {n_pop:,} / {len(oa):,} ({n_pop / len(oa):.1%})")
124162
print(f" floor median {oa['oa_median_floor_area_m2'].median():.0f} m²")
125163
print(f" current median {oa['epc_current_kwh_m2'].median():.0f} kWh/m²/yr")
126164
print(f" potential median {oa['epc_potential_kwh_m2'].median():.0f} kWh/m²/yr")

0 commit comments

Comments
 (0)