"""Aggregate police.uk street crime to postcodes by spatial proximity. Instead of attributing each incident to its published LSOA code, this transform counts the anonymised incident *points* that fall within ``buffer_m`` (default 50m) of each postcode's boundary polygon (the polygon buffered outward). A point inside several overlapping buffers counts for each postcode -- the same multiplicity the tree-density filter uses for features near more than one postcode. The 50m buffer deliberately smooths police.uk's snap-to-grid coordinates, which would otherwise make the count hypersensitive to which side of a narrow line a shared "map point" anchor happened to land on. One figure is produced for every postcode and crime type, averaged over the **last 7 years** and the **last 2 years**: * the **average annual incident count** -- ``sum(counts in covered window) * 12 / covered_months`` -- the raw, absolute number of recorded incidents per year, with no per-area or per-capita normalisation. A covered postcode with no incidents of a type gets ``0``; a postcode whose force never published in the window, or whose geometry is unusable, gets a *null* (unknown, never a zero). This figure is what the server exposes as the filterable crime feature -- the headline metric in the right pane. (An earlier version divided it by an ambient daytime population to get a per-1,000-people rate; that was hard to read, so the absolute per-year count is now used directly.) **Force-coverage calendar.** police.uk has multi-year publication gaps for whole forces (Greater Manchester has published nothing between 2019-07 and the present except 2022-08; BTP, Gloucestershire, Devon & Cornwall and others have shorter gaps). A missing month is *no data*, not zero crime, so every figure here is computed against the months the postcode's own force actually published: * Each postcode is assigned a home force by majority vote of the incidents that matched it (BTP, which reports nationwide, is excluded from the vote); postcodes with no incidents inherit their outcode's majority force, then the national modal force. * A window's average pools the counts over the force's *covered* months inside that window and annualises by those months, so a coverage gap shrinks the data rather than reading as a low-crime dip. * The by-year series only emits bars for years with at least ``min_bar_months`` covered months (default 6). * ``covered_years`` (list[struct{year, months}]) is written for every postcode so the server can tell "covered, zero crime" from "no data". * Postcodes whose boundary buffer is unusable (broken geometry) get null figures and an empty ``covered_years`` -- unknown, not zero. Outputs, all keyed on ``postcode``: * ``crime_by_postcode.parquet`` -- ``"{type} (/yr, 7y)"`` / ``"{type} (/yr, 2y)"`` average-annual-count columns (the filterable crime features). * ``crime_by_postcode_by_year.parquet`` -- ``covered_years`` + nested ``"{type} (by year)"`` ``list[struct{year, count}]`` per-year raw counts. * ``crime_records.parquet`` -- one row per counted incident over the last 7 years (``postcode`` + month/type/location/outcome/lat/lon), sorted by postcode so the server can slice each postcode's incidents directly. Caveat: police.uk coordinates are snapped to a fixed set of anonymous "map points", not true locations, and a share of rows have no coordinate at all (dropped here). Spatial totals are therefore fuzzier than the old LSOA-tagged counts -- by design, not a regression. """ from __future__ import annotations import argparse import re import sys import tempfile from pathlib import Path import numpy as np import polars as pl import shapely from pyproj import Transformer from pipeline.transform.crime import ( LEGACY_CRIME_TYPE_ALIASES, MINOR_CRIME_TYPES, SERIOUS_CRIME_TYPES, find_street_crime_csvs, ) from pipeline.transform.postcode_boundaries.loader import load_postcode_polygons # Serious types first so column order is stable and self-documenting. ALL_CRIME_TYPES: tuple[str, ...] = SERIOUS_CRIME_TYPES + MINOR_CRIME_TYPES # Output type axis = the 14 leaf types plus the two rollups, in that order. ROLLUP_TYPES: tuple[str, ...] = ("Serious crime", "Minor crime") ALL_OUTPUT_TYPES: tuple[str, ...] = ALL_CRIME_TYPES + ROLLUP_TYPES DEFAULT_BUFFER_M = 50.0 MONTH_DIR_RE = re.compile(r"^\d{4}-\d{2}$") 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,