import json import polars as pl import pytest from pyproj import Transformer from pipeline.transform.crime_spatial import transform_crime_spatial _TO_WGS84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True) _CSV_HEADER = ( "Crime ID,Month,Reported by,Falls within,Longitude,Latitude,Location," "LSOA code,LSOA name,Crime type,Last outcome category,Context" ) # Average-annual-count crime column name for a window (the filterable feature). def _raw(t: str, window: str = "7y") -> str: return f"{t} (/yr, {window})" def _bng_to_wgs84(x: float, y: float) -> tuple[float, float]: lon, lat = _TO_WGS84.transform(x, y) return lon, lat def _square_feature(postcode: str, x0: float, y0: float, x1: float, y1: float) -> dict: ring = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] coords = [list(_bng_to_wgs84(x, y)) for x, y in ring] return { "type": "Feature", "properties": {"postcodes": postcode, "mapit_code": postcode.replace(" ", "")}, "geometry": {"type": "Polygon", "coordinates": [coords]}, } def _write_boundaries(units_dir, features_by_district: dict[str, list[dict]]) -> None: units_dir.mkdir(parents=True) for district, features in features_by_district.items(): collection = {"type": "FeatureCollection", "features": features} (units_dir / f"{district}.geojson").write_text(json.dumps(collection)) def _crime_row(month: str, x, y, crime_type: str, location="On or near X", outcome="U") -> str: if x is None or y is None: lon, lat = "", "" else: lon, lat = _bng_to_wgs84(x, y) return f",{month},F,F,{lon},{lat},{location},E01000001,L,{crime_type},{outcome}," def _write_month( crime_dir, month: str, rows: list[str], force: str = "test-force" ) -> None: """Write one force's monthly CSV; an empty ``rows`` list still creates the file, which counts as published coverage for that (force, month).""" month_dir = crime_dir / month month_dir.mkdir(parents=True, exist_ok=True) body = "\n".join([_CSV_HEADER, *rows]) + "\n" (month_dir / f"{month}-{force}-street.csv").write_text(body) def _run(tmp_path, crime, units, **kwargs): """Run the transform and return (crime, by_year, records) DataFrames. The crime table carries the average-annual-count columns ("{type} (/yr, …)"), i.e. the raw, absolute number of recorded incidents per year. """ crime_out = tmp_path / "crime_by_postcode.parquet" by_year = tmp_path / "crime_by_postcode_by_year.parquet" records = tmp_path / "crime_records.parquet" transform_crime_spatial( crime, units, crime_out, by_year, records, buffer_m=50.0, **kwargs ) return ( pl.read_parquet(crime_out), pl.read_parquet(by_year), pl.read_parquet(records), ) def test_buffer_overlap_counts_for_each_postcode(tmp_path): units = tmp_path / "units" # A and B sit 70m apart; their +50m buffers overlap in x in [1030, 1060]. _write_boundaries( units, { "AB1": [ _square_feature("AB1 1AA", 1000, 1000, 1010, 1010), _square_feature("AB1 1AB", 1080, 1000, 1090, 1010), _square_feature("AB1 1AC", 5000, 5000, 5010, 5010), ] }, ) crime = tmp_path / "crime" _write_month( crime, "2024-01", [ # In the overlap: 35m east of A, 35m west of B -> counts for both. _crime_row("2024-01", 1045, 1005, "Burglary"), # 49m east of C's edge -> inside C's buffer. _crime_row("2024-01", 5059, 5005, "Robbery"), # 51m east of C's edge -> outside every buffer. _crime_row("2024-01", 5061, 5005, "Robbery"), # No coordinate -> dropped entirely. _crime_row("2024-01", None, None, "Anti-social behaviour"), ], ) raw_df, _, _ = _run(tmp_path, crime, units) rows = {r["postcode"]: r for r in raw_df.to_dicts()} # Single covered month -> pooled raw rate x12. assert rows["AB1 1AA"][_raw("Burglary")] == 12.0 assert rows["AB1 1AB"][_raw("Burglary")] == 12.0 assert rows["AB1 1AA"][_raw("Robbery")] == 0.0 # Only the 49m robbery counts for C; the 51m one and the blank row do not. assert rows["AB1 1AC"][_raw("Robbery")] == 12.0 assert rows["AB1 1AC"][_raw("Burglary")] == 0.0 # Anti-social behaviour had no coordinate -> nobody gets it. assert all(r[_raw("Anti-social behaviour")] == 0.0 for r in rows.values()) def test_counts_are_not_area_normalised(tmp_path): # Three postcodes of very different footprint, each with exactly one incident # in its buffer. The raw count must be 12/yr for ALL of them: area # normalisation has been removed, so footprint no longer changes the number. units = tmp_path / "units" _write_boundaries( units, { "AB1": [ _square_feature("AB1 1AA", 1000, 1000, 1010, 1010), # 10x10 _square_feature("AB1 1AB", 3000, 3000, 3010, 3020), # 10x20 _square_feature("AB1 1AC", 5000, 5000, 5040, 5040), # 40x40 ] }, ) crime = tmp_path / "crime" _write_month( crime, "2024-01", [ _crime_row("2024-01", 1005, 1005, "Burglary"), _crime_row("2024-01", 3005, 3010, "Burglary"), _crime_row("2024-01", 5020, 5020, "Burglary"), ], ) raw_df, _, _ = _run(tmp_path, crime, units, min_bar_months=1) rows = {r["postcode"]: r for r in raw_df.to_dicts()} for pc in ("AB1 1AA", "AB1 1AB", "AB1 1AC"): assert rows[pc][_raw("Burglary")] == pytest.approx(12.0, abs=0.05) def test_windows_pool_only_recent_years(tmp_path): # 2-year window vs 7-year window. An incident in the latest year sits in both # windows; one 6 years back sits only in the 7-year window. units = tmp_path / "units" _write_boundaries( units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} ) crime = tmp_path / "crime" # 12 covered months in 2019 (1 burglary), 12 in 2025 (1 burglary). Latest = # 2025: 7y window = 2019..2025 (both), 2y window = 2024..2025 (only 2025). for month in range(1, 13): ym19 = f"2019-{month:02d}" ym25 = f"2025-{month:02d}" _write_month(crime, ym19, [_crime_row(ym19, 1005, 1005, "Burglary")] if month == 1 else []) _write_month(crime, ym25, [_crime_row(ym25, 1005, 1005, "Burglary")] if month == 1 else []) raw_df, _, _ = _run(tmp_path, crime, units) row = raw_df.row(0, named=True) # 7y: 2 incidents over 24 covered months -> 1/yr. assert row[_raw("Burglary", "7y")] == pytest.approx(1.0, abs=0.05) # 2y: 1 incident over 12 covered months -> 1/yr (the 2019 one is excluded). assert row[_raw("Burglary", "2y")] == pytest.approx(1.0, abs=0.05) def test_by_year_annualises_and_rolls_up(tmp_path): units = tmp_path / "units" _write_boundaries( units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} ) crime = tmp_path / "crime" _write_month( crime, "2023-01", [ _crime_row("2023-01", 1005, 1005, "Burglary"), _crime_row("2023-01", 1005, 1005, "Robbery"), ], ) _write_month(crime, "2024-01", [_crime_row("2024-01", 1005, 1005, "Burglary")]) _write_month( crime, "2024-02", [ _crime_row("2024-02", 1005, 1005, "Burglary"), _crime_row("2024-02", 1005, 1005, "Anti-social behaviour"), ], ) _, by_year_df, _ = _run(tmp_path, crime, units, min_bar_months=1) assert by_year_df.height == 1 cols = set(by_year_df.columns) assert {"Burglary (by year)", "Serious crime (by year)", "Minor crime (by year)"} <= cols row = by_year_df.row(0, named=True) burglary = sorted(row["Burglary (by year)"], key=lambda r: r["year"]) # 2023: 1 burglary in 1 covered month -> 12/yr; 2024: 2 in 2 months -> 12/yr. assert burglary == [ {"year": 2023, "count": 12.0}, {"year": 2024, "count": 12.0}, ] serious = {p["year"]: p["count"] for p in row["Serious crime (by year)"]} # 2023 serious = Burglary(12) + Robbery(12) = 24; 2024 = Burglary(12). assert serious[2023] == 24.0 assert serious[2024] == 12.0 coverage = {c["year"]: c["months"] for c in row["covered_years"]} assert coverage == {2023: 1, 2024: 2} def test_raw_is_pooled_rate_over_covered_months(tmp_path): # Uneven month coverage: 2023 has 1 month (2 incidents), 2024 has 2 months # (2 incidents). The raw figure is the POOLED annualised rate over all covered # months: 4 incidents / 3 months * 12 = 16/yr. units = tmp_path / "units" _write_boundaries( units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} ) crime = tmp_path / "crime" _write_month( crime, "2023-01", [ _crime_row("2023-01", 1005, 1005, "Burglary"), _crime_row("2023-01", 1005, 1005, "Burglary"), ], ) _write_month(crime, "2024-01", [_crime_row("2024-01", 1005, 1005, "Burglary")]) _write_month(crime, "2024-02", [_crime_row("2024-02", 1005, 1005, "Burglary")]) raw_df, by_year_df, _ = _run(tmp_path, crime, units, min_bar_months=1) assert raw_df.row(0, named=True)[_raw("Burglary")] == pytest.approx(16.0, abs=0.05) # Bars remain per-year annualised: 2023 -> 24/yr (x12), 2024 -> 12/yr (x6). row = by_year_df.row(0, named=True) bars = {p["year"]: p["count"] for p in row["Burglary (by year)"]} assert bars == {2023: pytest.approx(24.0, abs=0.05), 2024: pytest.approx(12.0, abs=0.05)} def test_sporadic_type_is_not_inflated_by_years_present(tmp_path): # A single robbery in a 24-covered-month window must read as ~0.5/yr (the # long-run pooled rate), NOT 12/yr. units = tmp_path / "units" _write_boundaries( units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} ) crime = tmp_path / "crime" for year in (2023, 2024): for month in range(1, 13): rows = [] if (year, month) == (2023, 6): rows = [_crime_row(f"{year}-{month:02d}", 1005, 1005, "Robbery")] _write_month(crime, f"{year}-{month:02d}", rows) raw_df, by_year_df, _ = _run(tmp_path, crime, units) # 1 incident over 24 covered months -> 0.5/yr. assert raw_df.row(0, named=True)[_raw("Robbery")] == pytest.approx(0.5, abs=0.05) row = by_year_df.row(0, named=True) bars = {p["year"]: p["count"] for p in row["Robbery (by year)"]} assert bars == {2023: pytest.approx(1.0, abs=0.05)} coverage = {c["year"]: c["months"] for c in row["covered_years"]} assert coverage == {2023: 12, 2024: 12} def test_force_gap_years_are_excluded_not_zeroed(tmp_path): # Two postcodes policed by different forces. force-a publishes 2023+2024; # force-b publishes only 2023 (a 2024 gap). The b-postcode's raw figure must # pool over force-b's 12 covered months only. units = tmp_path / "units" _write_boundaries( units, { "AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)], "CD1": [_square_feature("CD1 1AA", 9000, 9000, 9010, 9010)], }, ) crime = tmp_path / "crime" for month in range(1, 13): ym23 = f"2023-{month:02d}" ym24 = f"2024-{month:02d}" _write_month(crime, ym23, [], force="force-a") _write_month( crime, ym24, [_crime_row(ym24, 1005, 1005, "Burglary")], force="force-a" ) _write_month( crime, ym23, [_crime_row(ym23, 9005, 9005, "Burglary")], force="force-b" ) raw_df, by_year_df, _ = _run(tmp_path, crime, units) rows = {r["postcode"]: r for r in raw_df.to_dicts()} # force-a postcode: 12 burglaries over 24 covered months -> 6/yr. assert rows["AB1 1AA"][_raw("Burglary")] == pytest.approx(6.0, abs=0.05) # force-b postcode: 12 burglaries over 12 covered months -> 12/yr. assert rows["CD1 1AA"][_raw("Burglary")] == pytest.approx(12.0, abs=0.05) by_rows = {r["postcode"]: r for r in by_year_df.to_dicts()} b_coverage = {c["year"]: c["months"] for c in by_rows["CD1 1AA"]["covered_years"]} assert b_coverage == {2023: 12} b_bars = {p["year"]: p["count"] for p in by_rows["CD1 1AA"]["Burglary (by year)"]} assert set(b_bars) == {2023} a_coverage = {c["year"]: c["months"] for c in by_rows["AB1 1AA"]["covered_years"]} assert a_coverage == {2023: 12, 2024: 12} def test_partial_years_below_min_bar_months_get_no_bar(tmp_path): # 2023 fully covered; 2024 has only 2 published months. With the default # 6-month minimum, 2024 must produce no bar -- but its incidents and months # still count toward the pooled raw figure. units = tmp_path / "units" _write_boundaries( units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} ) crime = tmp_path / "crime" for month in range(1, 13): ym = f"2023-{month:02d}" _write_month(crime, ym, [_crime_row(ym, 1005, 1005, "Burglary")]) for month in (1, 2): ym = f"2024-{month:02d}" _write_month(crime, ym, [_crime_row(ym, 1005, 1005, "Burglary")]) raw_df, by_year_df, _ = _run(tmp_path, crime, units) # Pooled: 14 incidents over 14 covered months -> 12/yr. assert raw_df.row(0, named=True)[_raw("Burglary")] == pytest.approx(12.0, abs=0.05) row = by_year_df.row(0, named=True) bars = {p["year"]: p["count"] for p in row["Burglary (by year)"]} assert set(bars) == {2023} coverage = {c["year"]: c["months"] for c in row["covered_years"]} assert coverage == {2023: 12} def test_by_year_output_is_dense_with_coverage(tmp_path): # A postcode with zero incidents still gets a by-year row carrying its # coverage calendar, so "covered and crime-free" is distinguishable from # "no data" downstream. units = tmp_path / "units" _write_boundaries( units, { "AB1": [ _square_feature("AB1 1AA", 1000, 1000, 1010, 1010), _square_feature("AB1 1AB", 5000, 5000, 5010, 5010), ] }, ) crime = tmp_path / "crime" _write_month(crime, "2024-01", [_crime_row("2024-01", 1005, 1005, "Burglary")]) raw_df, by_year_df, _ = _run(tmp_path, crime, units, min_bar_months=1) assert by_year_df.height == 2 quiet = by_year_df.filter(pl.col("postcode") == "AB1 1AB").row(0, named=True) assert quiet["Burglary (by year)"] is None assert [c["year"] for c in quiet["covered_years"]] == [2024] # The raw figure for the covered, crime-free postcode is a genuine 0, not null. quiet_raw = raw_df.filter(pl.col("postcode") == "AB1 1AB").row(0, named=True) assert quiet_raw[_raw("Burglary")] == 0.0 def test_serious_rollup_equals_sum_of_components(tmp_path): # Burglary only in 2023, Robbery only in 2024 (one incident each, 2 covered # months total, both inside the 7-year window). Components pool over the same # covered window (each 1 x 12 / 2 = 6/yr) and the rollup equals their sum. units = tmp_path / "units" _write_boundaries( units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} ) crime = tmp_path / "crime" _write_month(crime, "2023-01", [_crime_row("2023-01", 1005, 1005, "Burglary")]) _write_month(crime, "2024-01", [_crime_row("2024-01", 1005, 1005, "Robbery")]) raw_df, by_year_df, _ = _run(tmp_path, crime, units, min_bar_months=1) row = raw_df.row(0, named=True) assert row[_raw("Burglary")] == pytest.approx(6.0, abs=0.05) assert row[_raw("Robbery")] == pytest.approx(6.0, abs=0.05) assert row[_raw("Serious crime")] == pytest.approx(12.0, abs=0.05) assert row[_raw("Serious crime")] == pytest.approx( row[_raw("Burglary")] + row[_raw("Robbery")], abs=0.05 ) serious_bars = { p["year"]: p["count"] for p in by_year_df.row(0, named=True)["Serious crime (by year)"] } assert serious_bars == { 2023: pytest.approx(12.0, abs=0.05), 2024: pytest.approx(12.0, abs=0.05), } def test_records_capture_each_counted_incident(tmp_path): # Each (incident, postcode) match within the records window becomes a record # row, carrying month/type/location/outcome/coords. A boundary incident # counted for two postcodes appears once per postcode. units = tmp_path / "units" _write_boundaries( units, { "AB1": [ _square_feature("AB1 1AA", 1000, 1000, 1010, 1010), _square_feature("AB1 1AB", 1080, 1000, 1090, 1010), ] }, ) crime = tmp_path / "crime" _write_month( crime, "2024-03", [ # In the buffer overlap -> recorded for both postcodes. _crime_row("2024-03", 1045, 1005, "Burglary", location="On or near High St", outcome="Under investigation"), # Only in AB1 1AA's buffer; null outcome (police.uk leaves ASB blank). _crime_row("2024-03", 1005, 1005, "Anti-social behaviour", location="On or near Mill Ln", outcome=""), ], ) _, _, records_df = _run(tmp_path, crime, units, min_bar_months=1) assert set(records_df.columns) == { "postcode", "month_index", "crime_type", "location", "outcome", "lat", "lon" } # Sorted by postcode. assert records_df["postcode"].is_sorted() # Burglary appears for BOTH postcodes (boundary multiplicity); ASB only for AA. by_pc = records_df.group_by("postcode").agg(pl.col("crime_type").sort()) counts = {r["postcode"]: r["crime_type"] for r in by_pc.to_dicts()} assert counts["AB1 1AA"] == ["Anti-social behaviour", "Burglary"] assert counts["AB1 1AB"] == ["Burglary"] # month_index = year*12 + (month-1) for 2024-03. assert set(records_df["month_index"].to_list()) == {2024 * 12 + 2} # Null outcome round-trips as null, not the string "". asb = records_df.filter(pl.col("crime_type") == "Anti-social behaviour").row(0, named=True) assert asb["outcome"] is None assert asb["location"] == "On or near Mill Ln" def test_records_window_aligns_to_the_headline_calendar_window(tmp_path): # Records must cover exactly the longest (7y) headline window, which is # calendar-year based. With a mid-year latest month (2025-06) the 7y window # is calendar years 2019..2025, so an incident in 2018-09 -- which the # headline excludes -- must also be excluded from the records, even though a # naive rolling 84-month span (ending 2025-06) would wrongly include it. The # first month of the earliest window year (2019-01) is kept. units = tmp_path / "units" _write_boundaries( units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} ) crime = tmp_path / "crime" _write_month(crime, "2018-09", [_crime_row("2018-09", 1005, 1005, "Burglary")]) _write_month(crime, "2019-01", [_crime_row("2019-01", 1005, 1005, "Burglary")]) _write_month(crime, "2025-06", [_crime_row("2025-06", 1005, 1005, "Burglary")]) _, _, records_df = _run(tmp_path, crime, units, min_bar_months=1) # 2018-09 (year*12+8) is in the rolling 84-month span but NOT the 7y calendar # window, so it is excluded; 2019-01 and 2025-06 are kept. assert set(records_df["month_index"].to_list()) == {2019 * 12 + 0, 2025 * 12 + 5} def test_unknown_crime_type_is_dropped_with_warning(tmp_path, capsys): units = tmp_path / "units" _write_boundaries( units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} ) crime = tmp_path / "crime" _write_month( crime, "2024-01", [ _crime_row("2024-01", 1005, 1005, "Burglary"), _crime_row("2024-01", 1005, 1005, "Cyber fraud"), ], ) raw_df, _, _ = _run(tmp_path, crime, units) columns = raw_df.columns assert _raw("Cyber fraud") not in columns assert _raw("Burglary") in columns err = capsys.readouterr().err assert "Cyber fraud" in err assert "WARNING" in err def test_legacy_crime_types_are_mapped(tmp_path): """Pre-2014 crime-type names are aliased to current equivalents in the spatial transform instead of being dropped as unknown types.""" units = tmp_path / "units" _write_boundaries( units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} ) crime = tmp_path / "crime" _write_month( crime, "2013-01", [ _crime_row("2013-01", 1005, 1005, "Violent crime"), _crime_row("2013-01", 1005, 1005, "Public disorder and weapons"), ], ) raw_df, by_year_df, _ = _run(tmp_path, crime, units, min_bar_months=1) row = raw_df.to_dicts()[0] # Single covered month (relative to a 2013-latest window) -> x12. assert row[_raw("Violence and sexual offences")] == 12.0 assert row[_raw("Public order")] == 12.0 by_year_row = by_year_df.row(0, named=True) assert by_year_row["Violence and sexual offences (by year)"] == [ {"year": 2013, "count": 12.0} ] assert by_year_row["Public order (by year)"] == [{"year": 2013, "count": 12.0}]