diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx
index d738f37..1f59fd2 100644
--- a/frontend/src/components/map/HoverCard.tsx
+++ b/frontend/src/components/map/HoverCard.tsx
@@ -10,6 +10,7 @@ import { getElectionVoteShareFeatureName } from '../../lib/election-filter';
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
import { getQualificationFeatureName } from '../../lib/qualification-filter';
import { getTenureFeatureName } from '../../lib/tenure-filter';
+import { getCouncilFeatureName } from '../../lib/council-filter';
import {
POI_DISTANCE_FILTER_NAME,
getPoiDistanceFeatureName,
@@ -60,6 +61,7 @@ export default memo(function HoverCard({
const ethnicityFeatureName = getEthnicityFeatureName(name);
const qualificationFeatureName = getQualificationFeatureName(name);
const tenureFeatureName = getTenureFeatureName(name);
+ const councilFeatureName = getCouncilFeatureName(name);
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
const backendName =
schoolBackendName ??
@@ -69,6 +71,7 @@ export default memo(function HoverCard({
ethnicityFeatureName ??
qualificationFeatureName ??
tenureFeatureName ??
+ councilFeatureName ??
poiDistanceFeatureName ??
name;
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
diff --git a/frontend/src/components/map/LocationSearch.tsx b/frontend/src/components/map/LocationSearch.tsx
index e858356..ee8eee0 100644
--- a/frontend/src/components/map/LocationSearch.tsx
+++ b/frontend/src/components/map/LocationSearch.tsx
@@ -231,7 +231,7 @@ export default function LocationSearch({
return;
}
- // Postcode — fetch geometry. Unlike place/address results, a postcode
+ // Postcode: fetch geometry. Unlike place/address results, a postcode
// result carries no coordinates, so the camera move genuinely depends on
// this response and stays gated by isCurrentLookup.
try {
diff --git a/frontend/src/components/map/MapErrorBoundary.tsx b/frontend/src/components/map/MapErrorBoundary.tsx
index fbade47..a52d878 100644
--- a/frontend/src/components/map/MapErrorBoundary.tsx
+++ b/frontend/src/components/map/MapErrorBoundary.tsx
@@ -9,11 +9,11 @@ import { MapFallback } from './map-page/Fallbacks';
*
* deck.gl runs in interleaved mode, sharing MapLibre's WebGL context. When that
* shared context gets into a bad state (lost context, a buffer/texture finalized
- * while still referenced — the "bufferSubData: no buffer" / "bindTexture: deleted
+ * while still referenced, the "bufferSubData: no buffer" / "bindTexture: deleted
* object" storm), the next interleaved draw can throw. deck.gl's own animation
* loop swallows render errors via its `onError` prop, but interleaved draws fire
- * from MapLibre's synchronous `render` event, which can run inside a React commit
- * — so the throw bubbles to the nearest React error boundary.
+ * from MapLibre's synchronous `render` event, which can run inside a React commit,
+ * so the throw bubbles to the nearest React error boundary.
*
* Without this, that throw reaches the single top-level boundary and blanks the
* entire app ("Something went wrong / Refresh the page"). This boundary contains
@@ -26,7 +26,7 @@ const MAX_AUTO_RECOVERIES = 2;
// Remount after a short delay so a transient GL state can settle first.
const RECOVERY_DELAY_MS = 400;
// Crashes spaced further apart than this are treated as independent, so the
-// auto-recovery budget resets — only a tight crash loop escalates to manual retry.
+// auto-recovery budget resets. Only a tight crash loop escalates to manual retry.
const STABILITY_WINDOW_MS = 30_000;
interface MapErrorBoundaryProps {
diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx
index 18cd292..95e106d 100644
--- a/frontend/src/components/map/MobileDrawer.tsx
+++ b/frontend/src/components/map/MobileDrawer.tsx
@@ -113,7 +113,7 @@ export default function MobileDrawer({
>
{
300,
fmt
)!;
- const low = layout.items.find((i) => i.kind === 'national')!; // 30 — lowest
- const high = layout.items.find((i) => i.kind === 'area')!; // 50 — highest
+ const low = layout.items.find((i) => i.kind === 'national')!; // 30: lowest
+ const high = layout.items.find((i) => i.kind === 'area')!; // 50: highest
expect(low.tickX).toBeCloseTo(layout.plotLeft, 5);
expect(high.tickX).toBeCloseTo(layout.plotRight, 5);
});
diff --git a/frontend/src/components/map/NumberLine.tsx b/frontend/src/components/map/NumberLine.tsx
index aac6ad5..2e88162 100644
--- a/frontend/src/components/map/NumberLine.tsx
+++ b/frontend/src/components/map/NumberLine.tsx
@@ -120,7 +120,7 @@ export function computeNumberLineLayout(
* spanning the lowest→highest value, so the selection can be read against its
* reference points (national / outcode / sector) at a glance. Labels are spread
* to avoid overlap
- * — common since the nested area/sector/outcode values cluster — and connected
+ * (common since the nested area/sector/outcode values cluster) and connected
* back to their tick with a thin leader line.
*/
export default function NumberLine({ points, format }: NumberLineProps) {
diff --git a/frontend/src/components/map/map-page/derivedState.ts b/frontend/src/components/map/map-page/derivedState.ts
index 040fb98..06a4b3a 100644
--- a/frontend/src/components/map/map-page/derivedState.ts
+++ b/frontend/src/components/map/map-page/derivedState.ts
@@ -11,6 +11,7 @@ import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
import { getQualificationFeatureName } from '../../../lib/qualification-filter';
import { getTenureFeatureName } from '../../../lib/tenure-filter';
+import { getCouncilFeatureName } from '../../../lib/council-filter';
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
@@ -31,6 +32,7 @@ export function getMapPageBackendFeatureName(featureName: string): string {
getEthnicityFeatureName(featureName) ??
getQualificationFeatureName(featureName) ??
getTenureFeatureName(featureName) ??
+ getCouncilFeatureName(featureName) ??
getPoiDistanceFeatureName(featureName) ??
featureName
);
diff --git a/frontend/src/components/map/map-page/lazyComponents.ts b/frontend/src/components/map/map-page/lazyComponents.ts
index 98d6c91..12d3050 100644
--- a/frontend/src/components/map/map-page/lazyComponents.ts
+++ b/frontend/src/components/map/map-page/lazyComponents.ts
@@ -4,6 +4,7 @@ export const Map = lazy(() => import('../Map'));
export const Filters = lazy(() => import('../Filters'));
export const POIPane = lazy(() => import('../POIPane'));
export const OverlayPane = lazy(() => import('../OverlayPane'));
+export const ListingPane = lazy(() => import('../ListingPane'));
export const AreaPane = lazy(() => import('../AreaPane'));
export const PropertiesPane = lazy(() =>
import('../PropertiesPane').then((module) => ({ default: module.PropertiesPane }))
diff --git a/pipeline/check_school_cutoffs.py b/pipeline/check_school_cutoffs.py
index de1cd3f..fb551e1 100644
--- a/pipeline/check_school_cutoffs.py
+++ b/pipeline/check_school_cutoffs.py
@@ -107,7 +107,7 @@ def match_schools(truth: pl.DataFrame, gias: pl.DataFrame) -> pl.DataFrame:
.alias("_pc"),
).with_row_index("_row_id")
- # 1. Exact postcode match (unique postcodes only — site-sharing schools
+ # 1. Exact postcode match (unique postcodes only: site-sharing schools
# would mismatch phases otherwise; those fall through to name matching).
pc_unique = gias.filter(pl.col("_pc").is_not_null()).unique(
subset="_pc", keep="none"
@@ -291,7 +291,7 @@ def main() -> None:
print(f"\nWrote matched rows to {args.matched_out}")
if binding.is_empty():
- raise SystemExit("No binding, matchable cutoffs — nothing to calibrate on")
+ raise SystemExit("No binding, matchable cutoffs: nothing to calibrate on")
if __name__ == "__main__":
diff --git a/pipeline/check_travel_times.py b/pipeline/check_travel_times.py
index 6771e2d..08bc90b 100644
--- a/pipeline/check_travel_times.py
+++ b/pipeline/check_travel_times.py
@@ -5,11 +5,11 @@ computation failed or was interrupted, leaving either zero rows or only
the origin postcode. We detect this by an absolute, structural criterion:
a file is corrupt only when it is unreadable or has a row count at or below
CORRUPT_ROW_FLOOR. Per-mode percentile/median/range figures are reported
-for context only — they never drive the deletable set, so repeated runs
+for context only. They never drive the deletable set, so repeated runs
(including with --delete) are idempotent and never erode legitimate
small-catchment (rural/island) origins.
-Duplicates arise when places.parquet is rebuilt between R5 runs — each
+Duplicates arise when places.parquet is rebuilt between R5 runs. Each
place gets a new numeric index prefix, so the skip-completed logic
doesn't recognize previous results. --dedup keeps only the largest
file per slug and removes the rest.
@@ -105,7 +105,7 @@ def find_bad_files(base_dir: Path) -> tuple[list[BadFile], dict[str, dict]]:
if not row_counts:
continue
- # Reporting statistics only — these never decide what gets deleted.
+ # Reporting statistics only; these never decide what gets deleted.
p5 = percentile(row_counts, 5)
median = percentile(row_counts, 50)
diff --git a/pipeline/download/broadband.py b/pipeline/download/broadband.py
index dc2b18b..631d052 100644
--- a/pipeline/download/broadband.py
+++ b/pipeline/download/broadband.py
@@ -16,7 +16,7 @@ PERFORMANCE_URL = "https://www.ofcom.org.uk/siteassets/resources/documents/resea
# Pre-staged file path. Ofcom put the entire ofcom.org.uk domain behind
# Cloudflare's Managed Challenge in 2026, which requires a JS-executing
-# browser to pass — no amount of User-Agent / TLS-impersonation spoofing
+# browser to pass. No amount of User-Agent / TLS-impersonation spoofing
# (curl_cffi chrome120..131, safari17, firefox133, chrome_android) gets
# past it. When the automated download fails, the user must download the
# zip manually from the Source URL above and place it at this path.
diff --git a/pipeline/download/census_population.py b/pipeline/download/census_population.py
index bb481d0..c7bbc80 100644
--- a/pipeline/download/census_population.py
+++ b/pipeline/download/census_population.py
@@ -63,7 +63,7 @@ def _fetch_csv(
"""Download a CSV, defending against silent truncation.
The NOMIS bulk files are served with chunked transfer encoding and no
- Content-Length, so a dropped connection ends the stream without raising —
+ Content-Length, so a dropped connection ends the stream without raising,
yielding a short file. We retry until the parsed row count reaches the
file's known approximate size, keeping the largest parse seen.
"""
@@ -72,7 +72,7 @@ def _fetch_csv(
try:
download(url, dest)
df = pl.read_csv(dest)
- except Exception as exc: # noqa: BLE001 — retry any transport/parse error
+ except Exception as exc: # noqa: BLE001: retry any transport/parse error
print(f" {url} attempt {attempt}: error {exc}")
time.sleep(3)
continue
diff --git a/pipeline/download/crime.py b/pipeline/download/crime.py
index 18a9198..3cf43f7 100644
--- a/pipeline/download/crime.py
+++ b/pipeline/download/crime.py
@@ -193,7 +193,7 @@ def select_coverage_archives(
adjacent one) matters when the adjacent snapshot is missing from the index:
skipping it would leave a multi-month hole, while overlap only costs
download time because extraction skips already-extracted months. A hole no
- archive can bridge is a publication gap in the source — a hard error unless
+ archive can bridge is a publication gap in the source: a hard error unless
``allow_gaps``, since the run would otherwise be stamped complete with
artificial dips in every crime-over-time series.
"""
diff --git a/pipeline/download/development_sites.py b/pipeline/download/development_sites.py
index 1c73344..7124de9 100644
--- a/pipeline/download/development_sites.py
+++ b/pipeline/download/development_sites.py
@@ -143,8 +143,8 @@ def _has_dwellings(min_d: int | None, max_d: int | None) -> bool:
def _brownfield_url(entity) -> str | None:
"""Canonical per-site page on the Planning Data platform.
- The register's own ``site-plan-url`` is unreliable — many LPAs point every
- row at a single generic register landing page — so we link to the stable,
+ The register's own ``site-plan-url`` is unreliable (many LPAs point every
+ row at a single generic register landing page), so we link to the stable,
always-per-site entity page instead.
"""
entity_id = _to_int(entity)
diff --git a/pipeline/download/education.py b/pipeline/download/education.py
index 5dd4f10..f52f9d6 100644
--- a/pipeline/download/education.py
+++ b/pipeline/download/education.py
@@ -10,13 +10,13 @@ rather than a single headline number.
We give the ONS bands colloquial labels (the census's "Level 1/2/3/4+" jargon
means little to a homebuyer): No qualifications / Some GCSEs / Good GCSEs /
Apprenticeship / A-levels / Degree or higher / Other qualifications. NOTE the
-census does NOT split undergraduate from postgraduate — "Level 4 and above" is a
+census does NOT split undergraduate from postgraduate. "Level 4 and above" is a
single bucket ("Degree or higher").
The join key downstream (merge.py) is `lsoa21`, the same key used for ethnicity,
median age, and IoD.
-Source: NOMIS (ONS Census 2021 — TS067 dataset, NM_2084_1)
+Source: NOMIS (ONS Census 2021, TS067 dataset, NM_2084_1)
License: Open Government Licence v3.0
"""
@@ -42,7 +42,7 @@ BASE_URL = (
)
# Map each canonical NOMIS C2021_HIQUAL_8 band to our colloquial output bucket.
-# 1:1 mapping (no folding) — keyed on the exact NOMIS label so a relabelled or
+# 1:1 mapping (no folding): keyed on the exact NOMIS label so a relabelled or
# missing band fails loudly in validation instead of silently dropping people.
BAND_MAP = {
"No qualifications": "No qualifications",
diff --git a/pipeline/download/election_results.py b/pipeline/download/election_results.py
index aef4da9..2a6f6b4 100644
--- a/pipeline/download/election_results.py
+++ b/pipeline/download/election_results.py
@@ -5,7 +5,7 @@ import httpx
import polars as pl
# UK Parliament publishes candidate-level results for the 2024 General Election.
-# One row per candidate per constituency — we aggregate to per-constituency stats.
+# One row per candidate per constituency. We aggregate to per-constituency stats.
URL = "https://electionresults.parliament.uk/general-elections/6/candidacies.csv"
# Map party names to a smaller set for vote share columns.
diff --git a/pipeline/download/england_boundary.py b/pipeline/download/england_boundary.py
index f4f8897..79ed874 100644
--- a/pipeline/download/england_boundary.py
+++ b/pipeline/download/england_boundary.py
@@ -9,7 +9,7 @@ from pathlib import Path
import httpx
-# ArcGIS REST API — query for England only, generalised (BGC) resolution
+# ArcGIS REST API: query for England only, generalised (BGC) resolution
URL = (
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Countries_December_2024_Boundaries_UK_BGC/FeatureServer/0/query"
diff --git a/pipeline/download/ethnicity.py b/pipeline/download/ethnicity.py
index 0748c1a..8d31d3e 100644
--- a/pipeline/download/ethnicity.py
+++ b/pipeline/download/ethnicity.py
@@ -11,7 +11,7 @@ different neighbourhoods in one borough no longer share an identical ethnicity
profile. The join key downstream (merge.py) is `lsoa21`, the same key already
used for median age and IoD.
-Source: NOMIS (ONS Census 2021 — TS021 dataset, NM_2041_1)
+Source: NOMIS (ONS Census 2021, TS021 dataset, NM_2041_1)
License: Open Government Licence v3.0
"""
diff --git a/pipeline/download/gias.py b/pipeline/download/gias.py
index 67d9990..81f9dd0 100644
--- a/pipeline/download/gias.py
+++ b/pipeline/download/gias.py
@@ -4,12 +4,12 @@ GIAS is the DfE register of all educational establishments in England, updated
daily. The CSV is generated on-demand via a four-step interaction with the
public Downloads page (there is no static URL):
-1. GET /Downloads — extract anti-forgery token, the `all.edubase.data` tag,
+1. GET /Downloads: extract anti-forgery token, the `all.edubase.data` tag,
and the FileGeneratedDate that the server expects for that tag today.
-2. POST /Downloads/Collate — submit the form to start file generation. The
+2. POST /Downloads/Collate: submit the form to start file generation. The
redirect URL contains a generation UUID.
3. Poll /Downloads/GenerateAjax/{id} until status:true.
-4. GET the Azure blob URL with ?id={id} — returns a ZIP containing
+4. GET the Azure blob URL with ?id={id}: returns a ZIP containing
`edubasealldataYYYYMMDD.csv`.
The CSV is cp1252-encoded with 135 columns. We keep the fields useful for a
@@ -262,7 +262,7 @@ def transform(zip_bytes: bytes) -> pl.DataFrame:
.cast(pl.Float32, strict=False),
)
- # Drop rows without coordinates — a small number of historic/dummy entries
+ # Drop rows without coordinates: a small number of historic/dummy entries
# have Easting=0 which would map to the Atlantic.
df = df.filter(
pl.col("Easting").is_not_null()
diff --git a/pipeline/download/greenspace_water.py b/pipeline/download/greenspace_water.py
index cea71c1..51c4daf 100644
--- a/pipeline/download/greenspace_water.py
+++ b/pipeline/download/greenspace_water.py
@@ -21,7 +21,7 @@ from tqdm import tqdm
logger = logging.getLogger(__name__)
-MIN_AREA_SQM = 5_000 # ~70m x 70m — skip pocket parks and small ponds
+MIN_AREA_SQM = 5_000 # ~70m x 70m: skip pocket parks and small ponds
# OSM tags that indicate non-residential green/water areas
GREENSPACE_TAGS = {
@@ -108,7 +108,7 @@ class GreenspaceHandler(osmium.SimpleHandler):
return
# Invalid geometries are often the largest, most complex park/water
- # multipolygons (self-touching rings from OSM) — repair like pois.py
+ # multipolygons (self-touching rings from OSM): repair like pois.py
# rather than silently dropping them. make_valid may return a
# GeometryCollection with stray lines/points; keep only the polygons.
if not geom.is_valid:
@@ -161,7 +161,7 @@ def main():
df.write_parquet(args.output)
print(f"Saved to {args.output}")
else:
- print("No geometries found — skipping output")
+ print("No geometries found, skipping output")
if __name__ == "__main__":
diff --git a/pipeline/download/lsoa_children.py b/pipeline/download/lsoa_children.py
index e93cf99..75f8b85 100644
--- a/pipeline/download/lsoa_children.py
+++ b/pipeline/download/lsoa_children.py
@@ -1,6 +1,6 @@
"""Download Census 2021 children by five-year age band per LSOA.
-Source: NOMIS (ONS Census 2021 — TS007A dataset, age by five-year bands)
+Source: NOMIS (ONS Census 2021, TS007A dataset, age by five-year bands)
License: Open Government Licence v3.0
Used to estimate how many primary-age (4-10) and secondary-age (11-15)
diff --git a/pipeline/download/lsoa_population.py b/pipeline/download/lsoa_population.py
index 2f27eeb..546be64 100644
--- a/pipeline/download/lsoa_population.py
+++ b/pipeline/download/lsoa_population.py
@@ -1,6 +1,6 @@
"""Download Census 2021 usual resident population by LSOA.
-Source: NOMIS (ONS Census 2021 — TS001 dataset)
+Source: NOMIS (ONS Census 2021, TS001 dataset)
License: Open Government Licence v3.0
"""
diff --git a/pipeline/download/test_tenure.py b/pipeline/download/test_tenure.py
index 294a388..4a2deb5 100644
--- a/pipeline/download/test_tenure.py
+++ b/pipeline/download/test_tenure.py
@@ -76,7 +76,7 @@ def test_tenure_lives_rent_free_rolls_into_private_rent():
def test_tenure_percentages_independent_per_lsoa():
- """Two LSOAs get independent profiles — the LSOA granularity is the point."""
+ """Two LSOAs get independent profiles: the LSOA granularity is the point."""
df = pl.concat(
[
pl.DataFrame(_long_rows("E01000010", {"Owned: Owns outright": 100})),
diff --git a/pipeline/download/test_transit_network.py b/pipeline/download/test_transit_network.py
index 4317853..7f62abe 100644
--- a/pipeline/download/test_transit_network.py
+++ b/pipeline/download/test_transit_network.py
@@ -76,7 +76,7 @@ def test_clean_national_rail_gtfs_orders_by_stop_sequence_not_file_order(
"""dtd2mysql exports happen to be ordered by stop_sequence within each
trip, but nothing guarantees it. Rows arriving out of order must be sorted
by their original stop_sequence before the backwards-time check and the
- 0-based renumbering — file order would flag the trip as backwards and drop
+ 0-based renumbering. File order would flag the trip as backwards and drop
it (or scramble the stop order)."""
src = tmp_path / "in.zip"
dst = tmp_path / "out.zip"
diff --git a/pipeline/transform/area_crime_averages.py b/pipeline/transform/area_crime_averages.py
index 2b86b0d..26a6883 100644
--- a/pipeline/transform/area_crime_averages.py
+++ b/pipeline/transform/area_crime_averages.py
@@ -5,7 +5,7 @@ The right pane shows each crime metric next to its area context: the mean
average-annual count (``"X (/yr, 7y)"``) across the selection's postcode sector (e.g.
``"E14 2"``), its outcode (e.g. ``"E14"``), and the nation. Crime is constant
within a postcode (the merge keys it on the postcode), so each postcode
-contributes its single value weighted by how many properties sit in it — keeping
+contributes its single value weighted by how many properties sit in it, keeping
every scope on the same property-weighted basis as the per-selection mean, so the
four numbers (this selection / sector / outcode / nation) are directly
comparable. The national figure here is an EXACT property-weighted mean, which is
@@ -18,7 +18,7 @@ crime values from ``postcode.parquet`` and the per-postcode property weights fro
``properties.parquet`` mirrors exactly the two inputs the server loads, so the
result matches what the server used to compute (minus its u16 quantization loss).
-Output schema — one row per area:
+Output schema, one row per area:
scope : ``"national"`` | ``"outcode"`` | ``"sector"``
area : the outcode (``"E14"``) / sector (``"E14 2"``);
@@ -43,7 +43,7 @@ SCOPE_NATIONAL = "national"
SCOPE_OUTCODE = "outcode"
SCOPE_SECTOR = "sector"
-# Area label on the national row — it spans the whole country, so it has no code.
+# Area label on the national row. It spans the whole country, so it has no code.
NATIONAL_AREA = ""
# Both merge outputs key on the canonical NSPL `pcds` postcode (spaced, e.g.
@@ -71,7 +71,7 @@ def _weighted_mean(column: str) -> pl.Expr:
A null crime value is a genuine gap (the postcode's police force published no
usable data), not zero crime, so it must dilute neither the numerator nor the
- denominator — exactly as the server's former estimator skipped NaN values.
+ denominator, exactly as the server's former estimator skipped NaN values.
Yields null when no postcode in the group has data for this type.
"""
weight = pl.col(_WEIGHT_COLUMN)
diff --git a/pipeline/transform/crime.py b/pipeline/transform/crime.py
index 81570c3..b1d6566 100644
--- a/pipeline/transform/crime.py
+++ b/pipeline/transform/crime.py
@@ -126,7 +126,7 @@ def transform_crime(
# Sum per-incident weights directly: a 2021 LSOA can receive incidents
# carrying different `_weight`s in the same month (split 2011 parent at
# 1/N alongside an unsplit one at 1), so `_weight.first() * len` would
- # apply one row's weight to all of them — and nondeterministically so,
+ # apply one row's weight to all of them, and nondeterministically so,
# since `first` after a join has no ordering guarantee.
filtered.group_by("LSOA code", "year", "Crime type")
.agg(pl.col("_weight").sum().alias("count"))
@@ -194,7 +194,7 @@ def _write_crime_by_year(
)
yearly_per_type = (
- # Per-incident weight sum, not `_weight.first() * len` — see the
+ # Per-incident weight sum, not `_weight.first() * len`. See the
# matching comment in transform_crime.
filtered.group_by("LSOA code", "Crime type", "year")
.agg(pl.col("_weight").sum().alias("count"))
diff --git a/pipeline/transform/crime_spatial.py b/pipeline/transform/crime_spatial.py
index e500b1d..82afcf5 100644
--- a/pipeline/transform/crime_spatial.py
+++ b/pipeline/transform/crime_spatial.py
@@ -92,7 +92,7 @@ STREET_CSV_NAME_RE = re.compile(r"^(\d{4}-\d{2})-(.+)-street\.csv$")
# Trailing-window definitions, in (label, years) form. Each window's average is
# pooled over the force's covered months inside the window; one average-annual-
-# count column (`(/yr,