.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ Task[[:space:]]2[[:space:]]Boundaries[[:space:]]and[[:space:]]Grids.pdf filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Task 6: Incomplete Reporting Removal-Experiment Scaffold
2
+
3
+ This directory contains the simulation scaffold for **Task 6**, designed to evaluate the robustness of crime hotspot identification under incomplete crime reporting / underreporting.
4
+
5
+ ---
6
+
7
+ ## 1. Python Environment Status
8
+
9
+ The committed `.venv` in this repository was created on another computer with a hardcoded path to:
10
+ ```
11
+ C:\Users\padma\Anaconda3\python.exe
12
+ ```
13
+ When invoked on this workstation, `.venv\Scripts\python.exe` fails with:
14
+ ```
15
+ No Python at '"C:\Users\padma\Anaconda3\python.exe'
16
+ ```
17
+
18
+ ### Available & Verified Python Installation:
19
+ - **Executable**: `py` (Windows Python Launcher) -> `Python 3.14.0` at:
20
+ `C:\Users\mukun\AppData\Local\Python\pythoncore-3.14-64\python.exe`
21
+ - **Installed Geospatial Stack**:
22
+ - `geopandas 1.2.0`
23
+ - `shapely 2.2.0`
24
+ - `pyproj 3.8.0`
25
+ - `pyogrio 0.13.0`
26
+ - `pandas 3.0.6`
27
+ - `numpy 2.5.3`
28
+
29
+ All commands and tests should be run using `py` (or by recreating a local virtual environment pointing to the available Python installation).
30
+
31
+ ---
32
+
33
+ ## 2. Running the Synthetic Test Suite
34
+
35
+ The test suite validates all removal mechanics, coordinate math, validations, and edge cases using synthetic grids and points. No real crime data is required or modified.
36
+
37
+ ### Run Tests:
38
+ ```powershell
39
+ py test_task6_experiment.py -v
40
+ ```
41
+ or via standard `unittest`:
42
+ ```powershell
43
+ py -m unittest test_task6_experiment.py -v
44
+ ```
45
+
46
+ ### Test Coverage (12 Comprehensive Tests):
47
+ 1. `test_zero_removal`: Verifies that a removal probability $p = 0.0$ retains 100% of crimes, produces identical recounts before/after, and retains 100% of original top cells.
48
+ 2. `test_complete_removal`: Verifies that $p = 1.0$ removes 100% of crimes, sets all cell counts to 0, and deterministically tie-breaks by ascending `cell_id`.
49
+ 3. `test_uneven_removal_changes_ranking`: Verifies that cell-specific removal with differential rates alters the hotspot ranking and reduces the retention fraction.
50
+ 4. `test_boundary_assignment`: Verifies boundary coordinate assignment using the lower-left half-open interval rule $[x_{\min}, x_{\min} + 500) \times [y_{\min}, y_{\min} + 500)$, ensuring exact vertices and edges are assigned to exactly one cell.
51
+ 5. `test_invalid_rates`: Verifies rejection of negative rates, rates $> 1.0$, `NaN`, `Inf`, non-numeric types, incomplete cell rate mappings, and extraneous cell IDs.
52
+ 6. `test_repeatability`: Verifies that identical random seeds produce identical simulations and results, whereas different seeds generate distinct draws.
53
+ 7. `test_crs_validation`: Verifies strict enforcement of `EPSG:27700` and rejection of missing CRS or non-27700 layers.
54
+ 8. `test_points_outside_city_grid`: Verifies that crime points falling outside the city grid are rejected by default with `ValueError` (or optionally dropped if `allow_outside=True`).
55
+ 9. `test_grid_id_validation`: Verifies unique `cell_id` values, correct format `"<x_min>_<y_min>"`, and alignment to 500 m grid multiples.
56
+ 10. `test_recount_includes_zero_crime_cells`: Verifies that every cell in the grid is preserved in the recount before and after removal, with count 0 for cells without incidents.
57
+ 11. `test_top_cells_selection_and_deterministic_ties`: Verifies $k = \lceil 0.10 \times N \rceil$ calculation and deterministic secondary sorting on `cell_id` (ascending).
58
+ 12. `test_partially_clipped_edge_cell_validation`: Verifies that a point located inside the full 500 m unclipped square of an edge cell but outside its clipped city geometry is rejected with `ValueError` when `allow_outside=False`, and is counted and dropped when `allow_outside=True`.
59
+
60
+ ---
61
+
62
+ ## 3. How to Use the Module with Task 3 Leeds Crime Layer
63
+
64
+ Once Task 3 outputs the city-assigned crime layer (e.g. `data/interim/crime/leeds_crime_EPSG27700.gpkg`), the experiment can be executed either programmatically or via CLI.
65
+
66
+ ### Option A: Command-Line Interface (CLI)
67
+
68
+ #### 1. Uniform Removal Rate (e.g., 20% underreporting):
69
+ ```powershell
70
+ py task6_experiment.py `
71
+ --crime data/interim/crime/leeds_crime_EPSG27700.gpkg `
72
+ --grid data/interim/grids/leeds_grid500_EPSG27700.gpkg `
73
+ --uniform-rate 0.20 `
74
+ --seed 42 `
75
+ --output-json outputs/task6_leeds_uniform_0.20.json
76
+ ```
77
+
78
+ #### 2. Cell-Specific Removal Rates:
79
+ The module strictly requires a removal rate for **every grid cell** (e.g., all 2,390 cells in the Leeds 500 m grid). Incomplete mappings or extra cell IDs are rejected by the validator.
80
+
81
+ You can generate a complete rates JSON or CSV directly from the grid file:
82
+ ```python
83
+ import json, geopandas as gpd
84
+
85
+ grid = gpd.read_file("data/interim/grids/leeds_grid500_EPSG27700.gpkg")
86
+ # Example: 40% removal for edge cells, 15% removal for interior full cells
87
+ cell_rates = {
88
+ cid: (0.40 if is_edge else 0.15)
89
+ for cid, is_edge in zip(grid["cell_id"], grid["is_edge"])
90
+ }
91
+ with open("leeds_cell_rates.json", "w") as f:
92
+ json.dump(cell_rates, f, indent=2)
93
+ ```
94
+
95
+ The resulting `leeds_cell_rates.json` covers all 2,390 cells:
96
+ ```json
97
+ {
98
+ "428500_422500": 0.40,
99
+ "429000_422500": 0.40,
100
+ "429500_422500": 0.40,
101
+ ...
102
+ "444500_446000": 0.15
103
+ }
104
+ ```
105
+
106
+ Run with the rates file:
107
+ ```powershell
108
+ py task6_experiment.py `
109
+ --crime data/interim/crime/leeds_crime_EPSG27700.gpkg `
110
+ --grid data/interim/grids/leeds_grid500_EPSG27700.gpkg `
111
+ --cell-rates leeds_cell_rates.json `
112
+ --seed 42
113
+ ```
114
+
115
+ ---
116
+
117
+ ### Option B: Python API
118
+
119
+ ```python
120
+ import geopandas as gpd
121
+ from task6_experiment import run_removal_experiment
122
+
123
+ # 1. Load layers
124
+ crime_layer = "data/interim/crime/leeds_crime_EPSG27700.gpkg"
125
+ grid_layer = "data/interim/grids/leeds_grid500_EPSG27700.gpkg"
126
+
127
+ # 2. Run uniform removal experiment (30% underreporting)
128
+ result_uniform = run_removal_experiment(
129
+ points=crime_layer,
130
+ grid=grid_layer,
131
+ removal=0.30,
132
+ seed=42,
133
+ top_fraction=0.10, # evaluates top ceil(10% of cells)
134
+ )
135
+
136
+ # 3. View summary
137
+ result_uniform.print_summary()
138
+ print(f"Top 10% hotspot cells retained: {result_uniform.fraction_retained:.2%}")
139
+ print(f"Retained top cells: {result_uniform.retained_top_cells}")
140
+ print(f"Lost top cells: {result_uniform.lost_top_cells}")
141
+
142
+ # 4. Access full cell recount Series
143
+ counts_before = result_uniform.counts_before # pd.Series indexed by cell_id
144
+ counts_after = result_uniform.counts_after # pd.Series indexed by cell_id
145
+
146
+ # 5. Run cell-specific removal experiment
147
+ grid = gpd.read_file(grid_layer)
148
+ # Example: Vary removal probability by edge vs interior cells
149
+ cell_rates = {
150
+ cid: (0.40 if is_edge else 0.15)
151
+ for cid, is_edge in zip(grid["cell_id"], grid["is_edge"])
152
+ }
153
+
154
+ result_cell = run_removal_experiment(
155
+ points=crime_layer,
156
+ grid=grid_layer,
157
+ removal=cell_rates,
158
+ seed=42,
159
+ )
160
+ result_cell.print_summary()
161
+ ```
162
+
163
+ ---
164
+
165
+ ## 4. Key Implementation Rules & Conventions
166
+
167
+ - **Working CRS**: `EPSG:27700` (British National Grid, metres). Both crime points and grid must be in `EPSG:27700`.
168
+ - **Lower-Left Assignment Rule**:
169
+ $$\text{x\_min} = \lfloor x / 500 \rfloor \times 500, \quad \text{y\_min} = \lfloor y / 500 \rfloor \times 500$$
170
+ $$\text{cell\_id} = \text{"<x\_min>\_<y\_min>"}$$
171
+ This half-open box rule $[x_{\min}, x_{\min} + 500) \times [y_{\min}, y_{\min} + 500)$ ensures every point is mapped to exactly one cell.
172
+ - **Hotspot Selection**: Top $k = \lceil 0.10 \times N_{\text{cells}} \rceil$.
173
+ - **Tie-Breaking Rule**: When crime counts are tied, cells are ordered deterministically by ascending `cell_id` (alphabetical order of `"<x_min>_<y_min>"`).
TASK2_REPORT.html ADDED
The diff for this file is too large to render. See raw diff
 
Task 2 Boundaries and Grids.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:55ab7fe83b91be67ea0ce08baec5d16ae52908fbf222d15ee630d9918a3e6c68
3
+ size 840404
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Reusable project code. Task 2: geomutils, boundaries, grid."""
boundaries.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task 2a: read an ONS Local Authority District (LAD) file and cut out one boundary per city."""
2
+ import re
3
+ from pathlib import Path
4
+
5
+ import geopandas as gpd
6
+ import pyogrio
7
+ import shapely
8
+ from pyproj import CRS
9
+
10
+ from src.geomutils import crs_tag, polygonal_part
11
+
12
+ # ONS boundary resolution codes, as they appear in ONS file and layer names.
13
+ RESOLUTIONS = {
14
+ "BFC": "full resolution, clipped to the coastline",
15
+ "BFE": "full resolution, extent of the realm",
16
+ "BGC": "generalised (20 m), clipped to the coastline",
17
+ "BSC": "super generalised (200 m), clipped to the coastline",
18
+ "BUC": "ultra generalised (500 m), clipped to the coastline",
19
+ }
20
+ MONTHS = ("JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC")
21
+ BOUNDARY_COLUMNS = ["city", "lad_code", "lad_name", "area_m2"]
22
+
23
+
24
+ def boundary_filename(city, crs):
25
+ """leeds, EPSG:27700 -> 'leeds_boundary_EPSG27700.gpkg'."""
26
+ return f"{city}_boundary_{crs_tag(crs)}.gpkg"
27
+
28
+
29
+ def describe_lad_file(path):
30
+ """Print and return what an ONS LAD file contains and which version it is.
31
+
32
+ The version comes from the file and layer names (month, year, resolution code
33
+ such as BFC) and from the code column name (LAD25CD means the 2025 code set).
34
+ """
35
+ path = Path(path)
36
+ layers = [name for name, _ in pyogrio.list_layers(path)]
37
+ if len(layers) != 1:
38
+ raise ValueError(f"{path.name}: expected exactly one layer, found {layers}")
39
+ info = pyogrio.read_info(path, layer=layers[0])
40
+ columns = list(info["fields"])
41
+ tokens = set(re.split(r"[^A-Z0-9]+", f"{path.stem}_{layers[0]}".upper()))
42
+ desc = {
43
+ "file": path.name,
44
+ "layer": layers[0],
45
+ "columns": columns,
46
+ "crs": info["crs"],
47
+ "geometry_type": info["geometry_type"],
48
+ "features": int(info["features"]),
49
+ "resolution": sorted(tokens & RESOLUTIONS.keys()),
50
+ "month": sorted(tokens & set(MONTHS)),
51
+ "year": sorted(t for t in tokens if re.fullmatch(r"(19|20)\d\d", t)),
52
+ "code_fields": [c for c in columns if re.fullmatch(r"LAD\d\dCD", c)],
53
+ "name_fields": [c for c in columns if re.fullmatch(r"LAD\d\dNM", c)],
54
+ }
55
+ print(f"file: {desc['file']}")
56
+ print(f"layers: {layers}")
57
+ print(f"columns: {columns}")
58
+ print(f"CRS: {desc['crs']}")
59
+ print(f"geometry type: {desc['geometry_type']}")
60
+ print(f"features: {desc['features']}")
61
+ print(f"version: month={desc['month']} year={desc['year']} "
62
+ f"resolution={desc['resolution']} code column={desc['code_fields']}")
63
+ return desc
64
+
65
+
66
+ def require_bfc(desc):
67
+ """Stop unless the file is unambiguously the full resolution clipped (BFC) version."""
68
+ if desc["resolution"] != ["BFC"]:
69
+ found = ", ".join(f"{r} ({RESOLUTIONS[r]})" for r in desc["resolution"]) or "no resolution code"
70
+ raise ValueError(f"{desc['file']} is not the BFC version: found {found}")
71
+
72
+
73
+ def read_lad_features(path, layer, code_field, codes):
74
+ """Read only the LAD features whose code is in `codes`, sorted by code."""
75
+ for code in codes:
76
+ if not re.fullmatch(r"[EWSN]\d{8}", code):
77
+ raise ValueError(f"not a valid GSS LAD code: {code!r}")
78
+ where = f"{code_field} IN ({', '.join(repr(c) for c in codes)})"
79
+ lad = gpd.read_file(path, layer=layer, where=where, engine="pyogrio")
80
+ return lad.sort_values(code_field, kind="stable").reset_index(drop=True)
81
+
82
+
83
+ def to_target_crs(gdf, target_crs):
84
+ """Return (gdf in target_crs, reprojected?) using a real to_crs reprojection.
85
+
86
+ Never relabels the CRS. A layer with no CRS is an error, because its
87
+ coordinates cannot be interpreted.
88
+ """
89
+ if gdf.crs is None:
90
+ raise ValueError("boundary layer has no CRS defined")
91
+ if CRS.from_user_input(gdf.crs).equals(CRS.from_user_input(target_crs)):
92
+ return gdf, False
93
+ return gdf.to_crs(target_crs), True
94
+
95
+
96
+ def city_boundary(lad, city, lad_code, expected_name, code_field, name_field):
97
+ """Cut one city out of the LAD layer as a single valid (Multi)Polygon.
98
+
99
+ Several features with the same code are dissolved into one. An invalid
100
+ geometry is repaired with shapely.make_valid and only its polygon parts kept.
101
+ Returns (GeoDataFrame with BOUNDARY_COLUMNS + geometry, report dict).
102
+ """
103
+ rows = lad[lad[code_field] == lad_code]
104
+ if rows.empty:
105
+ raise ValueError(f"{city}: LAD code {lad_code} not found in the boundary file")
106
+ names = sorted(set(rows[name_field]))
107
+ if names != [expected_name]:
108
+ raise ValueError(f"{city}: {lad_code} is named {names} in the file, expected '{expected_name}'")
109
+
110
+ geom = rows.geometry.union_all() if len(rows) > 1 else rows.geometry.iloc[0]
111
+ was_valid = bool(shapely.is_valid(geom))
112
+ invalid_reason = None if was_valid else shapely.is_valid_reason(geom)
113
+ if not was_valid:
114
+ geom = shapely.make_valid(geom)
115
+ geom = polygonal_part(geom)
116
+ if geom.is_empty or not geom.is_valid:
117
+ raise ValueError(f"{city}: boundary is empty or still invalid after repair")
118
+
119
+ out = gpd.GeoDataFrame(
120
+ {"city": [city], "lad_code": [lad_code], "lad_name": [names[0]], "area_m2": [geom.area]},
121
+ geometry=[geom],
122
+ crs=lad.crs,
123
+ )
124
+ report = {
125
+ "city": city,
126
+ "n_features": len(rows),
127
+ "dissolved": len(rows) > 1,
128
+ "was_valid": was_valid,
129
+ "invalid_reason": invalid_reason,
130
+ "repaired": not was_valid,
131
+ "n_polygons": len(geom.geoms),
132
+ }
133
+ return out, report
config.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project_year": "2025",
3
+ "forces": {
4
+ "west-yorkshire": "Leeds",
5
+ "south-yorkshire": "Sheffield",
6
+ "northumbria": "Newcastle upon Tyne",
7
+ "nottinghamshire": "Nottingham"
8
+ },
9
+ "boundary_type": "Local Authority District (BFC)",
10
+ "primary_crime_category": "Burglary",
11
+ "crs": "EPSG:27700",
12
+ "grid_size_m": 500,
13
+ "data_dir": ".",
14
+ "osm_junction_tolerance_m": 12,
15
+ "cities": {
16
+ "leeds": {"force": "west-yorkshire", "lad_code": "E08000035"},
17
+ "sheffield": {"force": "south-yorkshire", "lad_code": "E08000039"},
18
+ "newcastle": {"force": "northumbria", "lad_code": "E08000021"},
19
+ "nottingham": {"force": "nottinghamshire", "lad_code": "E06000018"}
20
+ },
21
+ "boundary_file": "data/raw/boundaries/LAD_DEC_2025_UK_BFC.gpkg",
22
+ "boundary_code_field": "LAD25CD",
23
+ "boundary_name_field": "LAD25NM",
24
+ "boundaries_dir": "data/interim/boundaries",
25
+ "grids_dir": "data/interim/grids",
26
+ "qa_dir": "outputs/qa"
27
+ }
conventions.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Conventions
2
+
3
+ ## General Settings
4
+ - **Year**: 2025 (January to December)
5
+ - **Police Forces**: West Yorkshire (Leeds), South Yorkshire (Sheffield), Northumbria (Newcastle), Nottinghamshire (Nottingham)
6
+ - **Boundary Type**: Local Authority District (LAD) using ONS full resolution clipped boundaries (BFC).
7
+ - **Working CRS**: EPSG:27700 (British National Grid), units in metres.
8
+ - **Grid**: 500m x 500m. The origin is strictly snapped down to a multiple of 500 in BNG coordinates, ensuring consistent grid alignment across cities and years.
9
+ - **Grid Origin Coordinates per City**: [To be populated by Task 2]
10
+ - **Data Location**: the project folder, i.e. the folder that holds `config.json` (`data_dir` = `.`). All paths are relative to it: Task 1 crime layers in the project folder itself, raw boundary file in `data/raw/boundaries/`, city boundaries in `data/interim/boundaries/`, grids in `data/interim/grids/`, QA outputs in `outputs/qa/`.
11
+ - **File Naming Rule**: Intermediate outputs are saved as GeoPackages (`.gpkg`) or Parquet. Every spatial layer filename includes the CRS (e.g., `crime_west-yorkshire_EPSG27700.gpkg`).
12
+
13
+ ## Crime Data Rules
14
+ - **Exact Crime Categories Used**: [To be confirmed after Task 1 exploration, placeholder: "Burglary"]
15
+ - **Anti-Social Behaviour**: "Anti-social behaviour" is excluded from all-crime totals but reported separately in the data quality table.
16
+ - **File Filters**: Only merge files ending in `-street.csv`. Outcome and stop-and-search files are explicitly ignored.
17
+ - **Rows Without Coordinates**: Rows with no coordinates are counted and reported in the data quality table, then excluded from all spatial analysis. 2025 counts: West Yorkshire 2,162, South Yorkshire 8,069, Northumbria 0, Nottinghamshire 806.
18
+
19
+ ## OSM Data Rules
20
+ - **Exact OSM Tag Filters Used**: [To be recorded in Task 4 after inspecting raw columns]
21
+ - **Roads Kept**: Kept only roads used by vehicles and general traffic. (Excluded: footway, path, cycleway, steps, track, service roads).
22
+ - **Junction Extraction**: Computed by building a graph from shared vertices of road lines. A true junction is defined as a node with 3 or more connections. Nodes within a **12 metre** tolerance are merged into a single point to handle complex intersections like roundabouts and dual carriageways.
23
+ - **POI Deduplication**: Point POIs that fall completely inside a polygon POI of the exact same category are dropped. Remaining polygons are then converted to a single point using `representative_point()`, ensuring no double counting.
24
+
25
+ ## Task 2: City Boundaries and Analysis Grid (completed 2026-10-09)
26
+ - **Boundary file**: `data/raw/boundaries/LAD_DEC_2025_UK_BFC.gpkg`. This is ONS *Local Authority Districts (December 2025) Boundaries UK BFC*, the full-resolution version clipped to the coastline. It has one layer (`LAD_DEC_2025_UK_BFC`) of 361 MultiPolygon features in EPSG:27700. The code column is `LAD25CD` (2025 code set) and the name column is `LAD25NM`. It was downloaded on 2026-10-09 from the ONS Open Geography Portal (item `92150c7aa60540c5814abe3b26bce6d0`). SHA-256: `29ba41a24d7943db1e8ab50c73506cb486c555f8c0e9bc945b24aece068d3602`. The May 2025 (V2) BFC release has identical areas for all four cities (they differ by less than 1 m²).
27
+ - **City keys and LAD codes** (stored in `config.json` under `cities`):
28
+
29
+ | City key | LAD code | LAD name in file | Area (km²) |
30
+ |---|---|---|---|
31
+ | leeds | E08000035 | Leeds | 551.70 |
32
+ | sheffield | E08000039 | Sheffield | 368.00 |
33
+ | newcastle | E08000021 | Newcastle upon Tyne | 113.45 |
34
+ | nottingham | E06000018 | Nottingham | 74.64 |
35
+
36
+ - Sheffield LAD code is E08000039 (code in use since 2023). The older code E08000019 must not be used.
37
+ - Newcastle-under-Lyme (E07000195) is in the file and was not selected.
38
+ - **Reprojection**: not needed, because the file is already EPSG:27700.
39
+ - **Geometry repair**: not needed. Each city is one valid feature with one polygon, so no dissolve was needed either. The code would dissolve multiple features and repair invalid geometry with `shapely.make_valid` if a future file needed it.
40
+ - **Boundary outputs**: `data/interim/boundaries/<city>_boundary_EPSG27700.gpkg`, with columns `city, lad_code, lad_name, area_m2, geometry`.
41
+ - **Grid rule** (`src/grid.py`, `make_grid(boundary_geom, cell_size, city, crs=...)`):
42
+ - **Origin**: snapped down to a multiple of the cell size, using integer arithmetic only:
43
+ - `x_start = floor(minx / size) * size`, `y_start = floor(miny / size) * size`
44
+ - The far edges are snapped up with `ceil`.
45
+ - **Selection**: square cells covering that box are kept if they intersect the boundary. The test uses an STRtree spatial index.
46
+ - **Clipping**:
47
+ - Cells fully inside the boundary keep their exact square.
48
+ - The other cells are clipped to the boundary, and only the polygon parts are kept.
49
+ - Cells whose clipped area is exactly 0 are dropped. At 500 m no cells were dropped in any city.
50
+ - **Edge cells and slivers**:
51
+ - Partial edge cells, including tiny slivers, are kept.
52
+ - `is_edge = area_share < 0.9999`, where `area_share = area_m2 / size²`.
53
+ - **Order**: rows run south to north, then west to east.
54
+ - **cell_id rule**:
55
+ - The format is `"<x_min>_<y_min>"`: the lower-left corner of the full, unclipped cell, in whole metres in EPSG:27700. Example: `413000_422500`.
56
+ - `x_min` and `y_min` hold the same corner as integers.
57
+ - IDs are unique within a city and stable across reruns. A grid built from the same boundary and cell size always gives the same cells and IDs.
58
+ - A cell_id only identifies a cell together with its `cell_size_m`, because a 250 m grid reuses some of the same corner values.
59
+ - **Grid outputs**: `data/interim/grids/<city>_grid<size>_EPSG27700.gpkg`, with columns `cell_id, x_min, y_min, cell_size_m, area_m2, area_share, is_edge, city, is_sliver, geometry` (MultiPolygon).
60
+ - **Grid origin and cell counts per city, 500 m** (this fills in "Grid Origin Coordinates per City" under General Settings):
61
+
62
+ | City | Origin (x_start, y_start) | Cells | Full | Edge | Edge cells <5% | <10% | <25% |
63
+ |---|---|---|---|---|---|---|---|
64
+ | leeds | (413000, 422500) | 2,390 | 2,034 | 356 | 52 | 79 | 114 |
65
+ | sheffield | (413000, 378500) | 1,626 | 1,326 | 300 | 39 | 57 | 102 |
66
+ | newcastle | (414000, 562500) | 526 | 384 | 142 | 22 | 31 | 47 |
67
+ | nottingham | (450500, 332500) | 366 | 234 | 132 | 22 | 25 | 41 |
68
+
69
+ - The sum of cell areas equals the boundary area in every city (difference at most 5e-13 %).
70
+ - The smallest slivers are 0.16 m² (Leeds `419500_439000`) and 0.11 m² (Sheffield `433000_379500`). Density per area will be extreme for these cells; the later analysis must decide how to handle them.
71
+ - **Sliver rule (PROPOSED TEAM DECISION, not yet agreed)**: Edge cells with area_share below 0.10 are flagged as slivers. They keep their counts, so city totals stay complete. They are excluded from density maps, density rankings and the concentration table. The number of crimes falling in slivers is reported per city.
72
+ - The grid files carry this flag as the `is_sliver` column (`area_share < 0.10`), added 2026-10-09. Slivers at 500 m: leeds 79, sheffield 57, newcastle 31, nottingham 25 (the same as the "<10%" column above).
73
+ - **Reproducibility**:
74
+ - Software: Python 3.11.14, geopandas 1.2.0, shapely 2.2.0 (GEOS 3.14.1), pyproj 3.7.2 (PROJ 9.5.1), pyogrio 0.13.0 (GDAL 3.12.4). The full pinned list is in `requirements.txt`.
75
+ - Each grid has a SHA-256 fingerprint over its rows and geometry, stored in `outputs/qa/task2_grid500_checks.csv`. Two separate runs gave identical fingerprints.
76
+ - To rebuild: `python task2_boundaries_grids.py`. For the 250 m rerun: `python task2_boundaries_grids.py --cell-size 250`.
77
+
78
+ ### Task 2 reproduced in QGIS
79
+ - **Software**: QGIS 3.44.12 'Solothurn' (LTR) with bundled Python 3.12.13, GDAL 3.13.1, GEOS 3.14.1 and PROJ 9.8.1.
80
+ - **Script**: `task2_qgis.py`. Run it in the QGIS Python Console (Show Editor > Open Script > Run). Set `PROJECT_ROOT` at the top. All settings are read from `config.json`.
81
+ - **Algorithms, in order, per city** (each one a `processing.run()` call to a native QGIS algorithm; the full parameters of every call are in `logs/task2_qgis_log.txt`):
82
+ 1. `native:extractbyattribute`: LAD code from `config.json`, operator `=`.
83
+ 2. `native:checkvalidity`: GEOS method.
84
+ 3. `native:fixgeometries`: only if a geometry is invalid. Not needed for any city.
85
+ 4. `native:dissolve`: one feature per city.
86
+ 5. `native:refactorfields`: boundary schema `city, lad_code, lad_name, area_m2`. `area_m2` uses the planar `area($geometry)`, not `$area`.
87
+ 6. `native:creategrid`: rectangles, 500 m, extent snapped outward to multiples of 500 m.
88
+ 7. `native:clip`: grid clipped by the city boundary.
89
+ 8. `native:extractbyexpression`: `area($geometry) > 0`.
90
+ 9. `native:refactorfields`: the same grid schema as the Python files (names, types and order):
91
+ - `cell_id = concat(to_int(round("left")), '_', to_int(round("bottom")))`
92
+ - `x_min`, `y_min`: the same corner as integers
93
+ - `area_share = area($geometry) / 250000`
94
+ - `is_edge`: `area_share < 0.9999`
95
+ - `is_sliver`: `area_share < 0.10`
96
+ - `native:reprojectlayer` exists in the script but was not needed, because the LAD file is already EPSG:27700.
97
+ - **Comparison with the Python outputs** (`outputs/qa/qgis/task2_qgis_vs_python.csv`; QGIS Python Console run, 2026-10-09 19:27, identical to two earlier headless test runs):
98
+
99
+ | City | QGIS cells | Python cells | cell_id sets identical | Max per-cell area difference (m²) | Boundary area difference (m²) | Result |
100
+ |---|---|---|---|---|---|---|
101
+ | leeds | 2,390 | 2,390 | yes | 9.6e-08 | 2.8e-05 | match |
102
+ | sheffield | 1,626 | 1,626 | yes | 4.0e-08 | 1.9e-05 | match |
103
+ | newcastle | 526 | 526 | yes | 5.2e-08 | 8.0e-07 | match |
104
+ | nottingham | 366 | 366 | yes | 7.4e-08 | 2.9e-06 | match |
105
+
106
+ - Grid origins, edge counts, sliver counts and the `is_edge`/`is_sliver` flags of every cell are identical to Python.
107
+ - No zero-area cells were dropped.
108
+ - The Python files were checksum-verified as unchanged.
109
+ - **Where the outputs are**:
110
+ - Boundaries: `data/interim/qgis/boundaries/<city>_boundary_EPSG27700_qgis.gpkg`
111
+ - Grids: `data/interim/qgis/grids/<city>_grid500_EPSG27700_qgis.gpkg`
112
+ - Every intermediate: `data/interim/qgis/steps/<city>/`
113
+ - QA maps from print layouts: `outputs/qa/qgis/<city>_grid_qgis.png` (200 dpi)
114
+ - Log: `logs/task2_qgis_log.txt`
115
+ - **Team project**: `QGIS/crimegeo_vgi.qgz`.
116
+ - Project CRS EPSG:27700, saved with relative paths.
117
+ - One layer group per city.
118
+ - OpenStreetMap XYZ basemap at the bottom of the layer list.
119
+ - Rule-based grid style: Full cell / Edge cell / Sliver, under 10% of a cell.
120
+ - Four editable print layouts named "<City> grid QA".
geomutils.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small geometry and file helpers shared by the Task 2 modules."""
2
+ import hashlib
3
+ from pathlib import Path
4
+
5
+ from shapely.geometry import GeometryCollection, MultiPolygon, Polygon
6
+
7
+
8
+ def crs_tag(crs):
9
+ """'EPSG:27700' -> 'EPSG27700', the CRS label used in every spatial filename."""
10
+ return str(crs).replace(":", "")
11
+
12
+
13
+ def polygonal_part(geom):
14
+ """Keep only the polygon parts of a geometry, always returned as a MultiPolygon.
15
+
16
+ Lines and points (for example where a grid cell only touches the boundary)
17
+ are discarded, so a geometry with no polygon parts becomes an empty MultiPolygon.
18
+ """
19
+ return MultiPolygon(_polygons(geom))
20
+
21
+
22
+ def _polygons(geom):
23
+ if geom is None or geom.is_empty:
24
+ return []
25
+ if isinstance(geom, Polygon):
26
+ return [geom]
27
+ if isinstance(geom, (MultiPolygon, GeometryCollection)):
28
+ return [p for part in geom.geoms for p in _polygons(part)]
29
+ return []
30
+
31
+
32
+ def write_gpkg(gdf, path):
33
+ """Write gdf as the only layer (named after the file) of a fresh GeoPackage."""
34
+ path = Path(path)
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ if path.exists():
37
+ path.unlink()
38
+ gdf.to_file(path, layer=path.stem, driver="GPKG", engine="pyogrio")
39
+ return path
40
+
41
+
42
+ def fingerprint(gdf, columns):
43
+ """SHA-256 over the listed columns and the geometry WKB, row by row, in row order.
44
+
45
+ Identical layers give identical digests, so a teammate can confirm that a rerun
46
+ (or a copy of the file) matches the shared output exactly.
47
+ """
48
+ h = hashlib.sha256()
49
+ wkbs = gdf.geometry.to_wkb(hex=False, byte_order=1)
50
+ for values, wkb in zip(gdf[columns].itertuples(index=False, name=None), wkbs):
51
+ h.update("|".join(str(v) for v in values).encode())
52
+ h.update(wkb)
53
+ return h.hexdigest()
grid.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task 2b: square analysis grids clipped to a city boundary.
2
+
3
+ Cell corners are integer multiples of the cell size in the projected CRS (metres),
4
+ so grids for different cities, years or runs line up, and the same boundary and
5
+ cell size always give the same cells with the same cell_ids.
6
+ """
7
+ import math
8
+
9
+ import geopandas as gpd
10
+ import numpy as np
11
+ import shapely
12
+ from pyproj import CRS
13
+
14
+ from src.geomutils import crs_tag, polygonal_part
15
+
16
+ EDGE_SHARE_THRESHOLD = 0.9999 # area_share below this -> is_edge
17
+ SLIVER_SHARE_THRESHOLD = 0.10 # area_share below this -> is_sliver (proposed team decision)
18
+ SLIVER_THRESHOLDS = (0.05, 0.10, 0.25)
19
+ MAX_AREA_DIFF_PCT = 0.01 # sum of cell areas vs boundary area
20
+ GRID_COLUMNS = ["cell_id", "x_min", "y_min", "cell_size_m", "area_m2", "area_share", "is_edge", "city", "is_sliver"]
21
+
22
+
23
+ def grid_filename(city, cell_size, crs):
24
+ """leeds, 500, EPSG:27700 -> 'leeds_grid500_EPSG27700.gpkg'."""
25
+ return f"{city}_grid{cell_size}_{crs_tag(crs)}.gpkg"
26
+
27
+
28
+ def _as_cell_size(cell_size):
29
+ if isinstance(cell_size, bool) or cell_size <= 0 or int(cell_size) != cell_size:
30
+ raise ValueError(f"cell_size must be a positive whole number of metres, got {cell_size!r}")
31
+ return int(cell_size)
32
+
33
+
34
+ def grid_extent(bounds, cell_size):
35
+ """Snap a bounding box outwards to multiples of cell_size, as integers.
36
+
37
+ x_start = floor(minx / cell_size) * cell_size, x_end = ceil(maxx / cell_size) * cell_size,
38
+ and the same for y. Computed as floor(minx) // cell_size * cell_size, which is the
39
+ same value for a whole-number cell size but uses only integer arithmetic.
40
+ """
41
+ cell_size = _as_cell_size(cell_size)
42
+ minx, miny, maxx, maxy = bounds
43
+ x_start = math.floor(minx) // cell_size * cell_size
44
+ y_start = math.floor(miny) // cell_size * cell_size
45
+ x_end = -(-math.ceil(maxx) // cell_size) * cell_size
46
+ y_end = -(-math.ceil(maxy) // cell_size) * cell_size
47
+ return x_start, y_start, x_end, y_end
48
+
49
+
50
+ def make_grid(boundary_geom, cell_size, city, *, crs, verbose=True):
51
+ """Build a square grid over boundary_geom, clipped to it.
52
+
53
+ boundary_geom: valid (Multi)Polygon in `crs`, a projected CRS in metres.
54
+ Returns a GeoDataFrame with GRID_COLUMNS + geometry (MultiPolygon), one row per
55
+ cell with a clipped area > 0, ordered south to north, then west to east.
56
+ cell_id, x_min and y_min refer to the lower-left corner of the full, unclipped cell.
57
+ Grid origin and counts are stored in .attrs.
58
+ """
59
+ cell_size = _as_cell_size(cell_size)
60
+ crs = CRS.from_user_input(crs)
61
+ if not crs.is_projected or crs.axis_info[0].unit_name not in ("metre", "meter"):
62
+ raise ValueError(f"make_grid needs a projected CRS in metres, got {crs.to_string()}")
63
+ if boundary_geom is None or boundary_geom.is_empty or not boundary_geom.is_valid:
64
+ raise ValueError(f"{city}: boundary geometry is empty or invalid")
65
+
66
+ x_start, y_start, x_end, y_end = grid_extent(boundary_geom.bounds, cell_size)
67
+ xs = np.arange(x_start, x_end, cell_size, dtype=np.int64)
68
+ ys = np.arange(y_start, y_end, cell_size, dtype=np.int64)
69
+ x_min, y_min = (a.ravel() for a in np.meshgrid(xs, ys))
70
+ cells = shapely.box(x_min, y_min, x_min + cell_size, y_min + cell_size)
71
+
72
+ # Spatial index: only cells that intersect the boundary. Sorting the hits keeps
73
+ # the south-to-north, west-to-east order no matter how the tree returns them.
74
+ shapely.prepare(boundary_geom)
75
+ hit = np.sort(shapely.STRtree(cells).query(boundary_geom, predicate="intersects"))
76
+ x_min, y_min, cells = x_min[hit], y_min[hit], cells[hit]
77
+
78
+ # Cells fully inside keep their exact square; the rest are clipped to the boundary.
79
+ inside = shapely.covers(boundary_geom, cells)
80
+ clipped = cells.copy()
81
+ clipped[~inside] = shapely.intersection(cells[~inside], boundary_geom)
82
+ geoms = np.array([polygonal_part(g) for g in clipped], dtype=object)
83
+ area = shapely.area(geoms)
84
+
85
+ keep = area > 0
86
+ n_dropped = int((~keep).sum())
87
+ x_min, y_min, geoms, area = x_min[keep], y_min[keep], geoms[keep], area[keep]
88
+ area_share = area / cell_size**2
89
+
90
+ grid = gpd.GeoDataFrame(
91
+ {
92
+ "cell_id": [f"{x}_{y}" for x, y in zip(x_min.tolist(), y_min.tolist())],
93
+ "x_min": x_min,
94
+ "y_min": y_min,
95
+ "cell_size_m": np.full(len(area), cell_size, dtype=np.int64),
96
+ "area_m2": area,
97
+ "area_share": area_share,
98
+ "is_edge": area_share < EDGE_SHARE_THRESHOLD,
99
+ "city": city,
100
+ "is_sliver": area_share < SLIVER_SHARE_THRESHOLD,
101
+ },
102
+ geometry=list(geoms),
103
+ crs=crs,
104
+ )
105
+ grid.attrs.update(
106
+ x_start=x_start, y_start=y_start, x_end=x_end, y_end=y_end,
107
+ n_bbox_cells=len(xs) * len(ys), n_intersecting=len(hit),
108
+ n_inside=int(inside.sum()), n_clipped=int((~inside).sum()), n_dropped_zero_area=n_dropped,
109
+ )
110
+ if verbose:
111
+ print(f"[{city}] {cell_size} m grid: snapped box ({x_start}, {y_start}) to ({x_end}, {y_end}), "
112
+ f"{len(xs)} x {len(ys)} = {len(xs) * len(ys):,} candidate cells")
113
+ print(f"[{city}] intersecting the boundary (STRtree): {len(hit):,} "
114
+ f"(fully inside {int(inside.sum()):,}, clipped {int((~inside).sum()):,})")
115
+ print(f"[{city}] dropped, clipped area exactly 0 (touch only at a line/point): {n_dropped}")
116
+ print(f"[{city}] cells kept: {len(grid):,}")
117
+ return grid
118
+
119
+
120
+ def check_grid(grid, boundary_geom):
121
+ """Run the Task 2 grid checks on a grid built by make_grid (or read back from file).
122
+
123
+ Returns (results dict, list of failure messages). An empty list means every check passed.
124
+ """
125
+ cs = int(grid["cell_size_m"].iloc[0])
126
+ x_start, y_start, _, _ = grid_extent(boundary_geom.bounds, cs)
127
+ x_min = grid["x_min"].to_numpy(np.int64)
128
+ y_min = grid["y_min"].to_numpy(np.int64)
129
+ edge_share = grid.loc[grid["is_edge"], "area_share"]
130
+ cells_area = float(grid["area_m2"].sum())
131
+ boundary_area = float(boundary_geom.area)
132
+ b = grid.geometry.bounds
133
+ tol = 1e-6
134
+
135
+ r = {
136
+ "city": grid["city"].iloc[0],
137
+ "cell_size_m": cs,
138
+ "x_start": x_start,
139
+ "y_start": y_start,
140
+ "origin_multiple": x_start % cs == 0 and y_start % cs == 0,
141
+ "origin_matches_cells": int(x_min.min()) == x_start and int(y_min.min()) == y_start,
142
+ "n_cells": len(grid),
143
+ "n_full": int((~grid["is_edge"]).sum()),
144
+ "n_edge": int(grid["is_edge"].sum()),
145
+ **{f"n_edge_lt_{round(t * 100)}pct": int((edge_share < t).sum()) for t in SLIVER_THRESHOLDS},
146
+ "n_sliver": int(grid["is_sliver"].sum()),
147
+ "sliver_flag_ok": bool((grid["is_sliver"] == (grid["area_share"] < SLIVER_SHARE_THRESHOLD)).all()
148
+ and not (grid["is_sliver"] & ~grid["is_edge"]).any()),
149
+ "min_area_share": float(grid["area_share"].min()),
150
+ "min_area_m2": float(grid["area_m2"].min()),
151
+ "cells_area_m2": cells_area,
152
+ "boundary_area_m2": boundary_area,
153
+ "area_diff_pct": abs(cells_area - boundary_area) / boundary_area * 100,
154
+ "cell_id_unique": bool(grid["cell_id"].is_unique),
155
+ "cell_id_matches_corner": bool((grid["cell_id"] == [f"{x}_{y}" for x, y in zip(x_min.tolist(), y_min.tolist())]).all()),
156
+ "xy_multiple": bool((x_min % cs == 0).all() and (y_min % cs == 0).all()),
157
+ "geoms_inside_own_square": bool(((b.minx >= x_min - tol) & (b.maxx <= x_min + cs + tol)
158
+ & (b.miny >= y_min - tol) & (b.maxy <= y_min + cs + tol)).all()),
159
+ "geoms_valid_nonempty": bool(grid.geometry.is_valid.all() and not grid.geometry.is_empty.any()),
160
+ "crs": grid.crs.to_string() if grid.crs else None,
161
+ }
162
+
163
+ failures = []
164
+ if not r["origin_multiple"]:
165
+ failures.append(f"origin ({x_start}, {y_start}) is not a multiple of {cs}")
166
+ if not r["origin_matches_cells"]:
167
+ failures.append("lowest cell corner does not equal the snapped origin")
168
+ if r["area_diff_pct"] >= MAX_AREA_DIFF_PCT:
169
+ failures.append(f"cell areas differ from boundary area by {r['area_diff_pct']:.6f}% (limit {MAX_AREA_DIFF_PCT}%)")
170
+ for key in ("cell_id_unique", "cell_id_matches_corner", "xy_multiple", "geoms_inside_own_square", "geoms_valid_nonempty",
171
+ "sliver_flag_ok"):
172
+ if not r[key]:
173
+ failures.append(f"check failed: {key}")
174
+ if (grid["area_share"] > 1 + 1e-9).any():
175
+ failures.append("a cell has area_share above 1")
176
+ return r, failures
177
+
178
+
179
+ def plot_grid_qa(grid, boundary, path, title, subtitle):
180
+ """Save a QA map: full cells, edge cells in a second colour, boundary outline."""
181
+ import matplotlib
182
+
183
+ matplotlib.use("Agg")
184
+ import matplotlib.pyplot as plt
185
+ from matplotlib.lines import Line2D
186
+ from matplotlib.patches import Patch
187
+ from matplotlib.ticker import FuncFormatter
188
+
189
+ surface, ink, muted = "#fcfcfb", "#0b0b0b", "#52514e"
190
+ full_colour, edge_colour = "#2a78d6", "#eb6834" # categorical slots 1 and 2
191
+
192
+ full = grid[~grid["is_edge"]]
193
+ edge = grid[grid["is_edge"]]
194
+ fig, ax = plt.subplots(figsize=(9, 9), dpi=150, facecolor=surface)
195
+ ax.set_facecolor(surface)
196
+ full.plot(ax=ax, facecolor=full_colour, edgecolor=surface, linewidth=0.5)
197
+ edge.plot(ax=ax, facecolor=edge_colour, edgecolor=surface, linewidth=0.5)
198
+ boundary.boundary.plot(ax=ax, color=ink, linewidth=1.0)
199
+
200
+ ax.set_aspect("equal")
201
+ fmt = FuncFormatter(lambda v, _: f"{v:,.0f}")
202
+ ax.xaxis.set_major_formatter(fmt)
203
+ ax.yaxis.set_major_formatter(fmt)
204
+ ax.tick_params(colors=muted, labelsize=7)
205
+ for spine in ax.spines.values():
206
+ spine.set_color("#c9c8c3")
207
+ ax.set_xlabel("Easting (m)", color=muted, fontsize=8)
208
+ ax.set_ylabel("Northing (m)", color=muted, fontsize=8)
209
+
210
+ handles = [
211
+ Patch(facecolor=full_colour, label=f"Full cell ({len(full):,})"),
212
+ Patch(facecolor=edge_colour, label=f"Edge cell, clipped ({len(edge):,})"),
213
+ Line2D([0], [0], color=ink, linewidth=1.0, label="City boundary"),
214
+ ]
215
+ ax.legend(handles=handles, loc="lower left", bbox_to_anchor=(0, 1.0), ncol=3,
216
+ frameon=False, fontsize=8, labelcolor=ink, borderaxespad=0.4)
217
+ fig.text(0.02, 0.975, title, fontsize=13, fontweight="bold", color=ink, va="top")
218
+ fig.text(0.02, 0.945, subtitle, fontsize=8.5, color=muted, va="top")
219
+ fig.tight_layout(rect=(0, 0, 1, 0.93))
220
+ fig.savefig(path, facecolor=surface)
221
+ plt.close(fig)
222
+ return path
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ certifi==2026.7.22
2
+ contourpy==1.3.3
3
+ cycler==0.12.1
4
+ fonttools==4.66.1
5
+ geopandas==1.2.0
6
+ kiwisolver==1.5.1
7
+ matplotlib==3.11.2
8
+ numpy==2.4.6
9
+ packaging==26.3
10
+ pandas==3.0.6
11
+ pillow==12.3.0
12
+ pyogrio==0.13.0
13
+ pyparsing==3.3.3
14
+ pyproj==3.7.2
15
+ python-dateutil==2.9.0.post0
16
+ shapely==2.2.0
17
+ six==1.17.0
18
+ tzdata==2026.5
task1_crime.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import pandas as pd
4
+ import geopandas as gpd
5
+
6
+ with open("config.json", "r") as f:
7
+ config = json.load(f)
8
+
9
+ data_dir = config["data_dir"]
10
+ crime_dir = os.path.join(data_dir, "Crime_data_uk_cities")
11
+ forces = list(config["forces"].keys())
12
+ crs = config["crs"]
13
+
14
+ months = [f"2025-{str(m).zfill(2)}" for m in range(1, 13)]
15
+
16
+ # a. Verify files
17
+ print("--- TASK 1a: Verifying Files ---")
18
+ all_street_files = {force: [] for force in forces}
19
+ other_files = []
20
+
21
+ for month in months:
22
+ month_dir = os.path.join(crime_dir, month)
23
+ if not os.path.exists(month_dir):
24
+ print(f"Directory missing: {month_dir}")
25
+ continue
26
+
27
+ for file in os.listdir(month_dir):
28
+ if file.endswith("-street.csv"):
29
+ matched_force = None
30
+ for force in forces:
31
+ if force in file:
32
+ all_street_files[force].append(os.path.join(month_dir, file))
33
+ matched_force = force
34
+ break
35
+ if not matched_force:
36
+ other_files.append(os.path.join(month, file))
37
+ elif file.endswith(".csv"):
38
+ other_files.append(os.path.join(month, file))
39
+
40
+ for force in forces:
41
+ found_months = []
42
+ for fpath in all_street_files[force]:
43
+ basename = os.path.basename(fpath)
44
+ found_months.append(basename[:7])
45
+
46
+ missing = set(months) - set(found_months)
47
+ if missing:
48
+ print(f"Force '{force}' is missing months: {sorted(list(missing))}")
49
+ else:
50
+ print(f"Force '{force}' has all 12 months.")
51
+
52
+ print(f"\nFound {len(other_files)} non-street files or files for other forces.")
53
+ if other_files:
54
+ print(f"Example first 5: {other_files[:5]}")
55
+
56
+ print("\n--- TASK 1b & 1c: Combine and Count ---")
57
+ crime_dfs = {}
58
+
59
+ for force in forces:
60
+ print(f"\nProcessing {force}...")
61
+ if not all_street_files[force]:
62
+ print(f"No files found for {force}. Skipping.")
63
+ continue
64
+
65
+ dfs = []
66
+ for file in all_street_files[force]:
67
+ df = pd.read_csv(file)
68
+ dfs.append(df)
69
+
70
+ combined_df = pd.concat(dfs, ignore_index=True)
71
+
72
+ total_rows = len(combined_df)
73
+ missing_coords = combined_df['Longitude'].isna() | combined_df['Latitude'].isna()
74
+ missing_coords_count = missing_coords.sum()
75
+
76
+ print(f"Total rows: {total_rows}")
77
+ print(f"Rows with missing coordinates: {missing_coords_count} ({missing_coords_count/total_rows*100:.2f}%)")
78
+
79
+ print("\nRows per month:")
80
+ print(combined_df['Month'].value_counts().sort_index())
81
+
82
+ print("\nRows per crime category:")
83
+ cats = combined_df['Crime type'].value_counts()
84
+ print(cats.to_string())
85
+
86
+ asb_count = cats.get("Anti-social behaviour", 0)
87
+ all_crime_total = total_rows - asb_count
88
+ print(f"\nTotal excluding ASB: {all_crime_total}")
89
+ print(f"ASB Total: {asb_count}")
90
+
91
+ crime_dfs[force] = combined_df
92
+
93
+ print("\n--- TASK 1d: Spatial Conversion & Reprojection ---")
94
+ for force, df in crime_dfs.items():
95
+ print(f"\nConverting {force} to spatial layer...")
96
+ df_valid_coords = df.dropna(subset=['Longitude', 'Latitude']).copy()
97
+
98
+ gdf = gpd.GeoDataFrame(
99
+ df_valid_coords,
100
+ geometry=gpd.points_from_xy(df_valid_coords.Longitude, df_valid_coords.Latitude),
101
+ crs="EPSG:4326"
102
+ )
103
+
104
+ print("Reprojecting to EPSG:27700...")
105
+ gdf_27700 = gdf.to_crs("EPSG:27700")
106
+
107
+ print("Sample coordinates (first 3):")
108
+ for geom in gdf_27700.geometry.head(3):
109
+ print(f"X: {geom.x:.1f}, Y: {geom.y:.1f}")
110
+
111
+ output_path = os.path.join(data_dir, f"crime_{force}_EPSG27700.gpkg")
112
+ print(f"Saving to {output_path}...")
113
+ gdf_27700.to_file(output_path, driver="GPKG")
114
+ print("Saved.")
task2_boundaries_grids.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task 2: extract the four city boundaries (2a) and build the analysis grids (2b).
2
+
3
+ Run from the project folder:
4
+ .venv\\Scripts\\python.exe task2_boundaries_grids.py # grid_size_m from config.json
5
+ .venv\\Scripts\\python.exe task2_boundaries_grids.py --cell-size 250 # e.g. the later 250 m rerun
6
+
7
+ All settings come from config.json; paths in it are relative to the project folder.
8
+ Any failed check stops the run with an error (no workarounds).
9
+ """
10
+ import argparse
11
+ import json
12
+ import platform
13
+ import sys
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ import geopandas as gpd
18
+ import pandas as pd
19
+ import pyogrio
20
+ import pyproj
21
+ import shapely
22
+
23
+ from src.boundaries import (BOUNDARY_COLUMNS, boundary_filename, city_boundary, describe_lad_file,
24
+ read_lad_features, require_bfc, to_target_crs)
25
+ from src.geomutils import fingerprint, write_gpkg
26
+ from src.grid import (EDGE_SHARE_THRESHOLD, GRID_COLUMNS, SLIVER_SHARE_THRESHOLD, SLIVER_THRESHOLDS, check_grid,
27
+ grid_filename, make_grid, plot_grid_qa)
28
+
29
+ ROOT = Path(__file__).resolve().parent
30
+ CONFIG = ROOT / "config.json"
31
+
32
+ # Sanity-check areas from the Task 2 brief (km2); a city must be within 5 percent.
33
+ APPROX_AREA_KM2 = {"leeds": 552, "sheffield": 368, "newcastle": 113, "nottingham": 75}
34
+ AREA_TOLERANCE = 0.05
35
+
36
+
37
+ class Tee:
38
+ """Send printed output to the console and to a run log file."""
39
+
40
+ def __init__(self, *streams):
41
+ self.streams = streams
42
+
43
+ def write(self, text):
44
+ for s in self.streams:
45
+ if not s.closed:
46
+ s.write(text)
47
+
48
+ def flush(self):
49
+ for s in self.streams:
50
+ if not s.closed:
51
+ s.flush()
52
+
53
+
54
+ def fail(message):
55
+ print(f"\nSTOP: {message}")
56
+ sys.exit(1)
57
+
58
+
59
+ def section(title):
60
+ print(f"\n{'=' * 78}\n{title}\n{'=' * 78}")
61
+
62
+
63
+ def main():
64
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
65
+ parser.add_argument("--cell-size", type=int, help="override grid_size_m from config.json")
66
+ args = parser.parse_args()
67
+
68
+ cfg = json.loads(CONFIG.read_text())
69
+ crs = cfg["crs"]
70
+ cell_size = args.cell_size or cfg["grid_size_m"]
71
+ code_field, name_field = cfg["boundary_code_field"], cfg["boundary_name_field"]
72
+ lad_path = ROOT / cfg["boundary_file"]
73
+ boundaries_dir = ROOT / cfg["boundaries_dir"]
74
+ grids_dir = ROOT / cfg["grids_dir"]
75
+ qa_dir = ROOT / cfg["qa_dir"]
76
+ qa_dir.mkdir(parents=True, exist_ok=True)
77
+
78
+ run_log = open(qa_dir / f"task2_run_grid{cell_size}.txt", "w", encoding="utf-8")
79
+ sys.stdout = Tee(sys.__stdout__, run_log)
80
+ print(f"Task 2 run {datetime.now():%Y-%m-%d %H:%M:%S}")
81
+ print(f"python {platform.python_version()} | geopandas {gpd.__version__} | shapely {shapely.__version__} "
82
+ f"(GEOS {shapely.geos_version_string}) | pyproj {pyproj.__version__} (PROJ {pyproj.proj_version_str}) "
83
+ f"| pyogrio {pyogrio.__version__} (GDAL {pyogrio.__gdal_version_string__})")
84
+ print(f"config: crs={crs} cell_size={cell_size} m (config grid_size_m={cfg['grid_size_m']})")
85
+
86
+ # ---------------------------------------------------------------- Step 3a / 3b
87
+ section("STEP 3a/3b: ONS LAD boundary file")
88
+ if not lad_path.exists():
89
+ fail(f"boundary file not found: {lad_path}")
90
+ desc = describe_lad_file(lad_path)
91
+ if desc["crs"] is None:
92
+ fail("boundary file has no CRS defined")
93
+ if desc["code_fields"] != [code_field] or name_field not in desc["name_fields"]:
94
+ fail(f"config fields {code_field}/{name_field} do not match file columns {desc['columns']}")
95
+ try:
96
+ require_bfc(desc)
97
+ except ValueError as e:
98
+ fail(str(e))
99
+ print(f"-> ONS LAD {' '.join(desc['month'])} {' '.join(desc['year'])}, {code_field} code set; "
100
+ f"resolution BFC (full resolution, clipped) confirmed")
101
+
102
+ # ---------------------------------------------------------------- Step 3c / 3d
103
+ section("STEP 3c/3d: select cities by LAD code, check CRS")
104
+ cities = cfg["cities"]
105
+ all_names = pyogrio.read_dataframe(lad_path, layer=desc["layer"], columns=[code_field, name_field],
106
+ read_geometry=False)
107
+ lad = read_lad_features(lad_path, desc["layer"], code_field, [c["lad_code"] for c in cities.values()])
108
+ print(f"features read for the {len(cities)} configured codes: {len(lad)}")
109
+ for city, spec in cities.items():
110
+ expected = cfg["forces"][spec["force"]]
111
+ first_word = expected.split()[0]
112
+ lookalikes = all_names[all_names[name_field].str.startswith(first_word)
113
+ & (all_names[name_field] != expected)]
114
+ if len(lookalikes):
115
+ others = ", ".join(f"{n} ({c})" for c, n in lookalikes[[code_field, name_field]].itertuples(index=False))
116
+ print(f" {city}: similar names in file NOT selected: {others}")
117
+
118
+ lad, reprojected = to_target_crs(lad, crs)
119
+ print(f"file CRS: {desc['crs']} -> " + (f"reprojected to {crs} with to_crs" if reprojected
120
+ else f"already {crs}, no reprojection needed"))
121
+
122
+ # ---------------------------------------------------------------- Step 3c table / 3e / 3f
123
+ section("STEP 3c/3e/3f: city boundaries (dissolve, validity, save)")
124
+ boundaries, reports = {}, []
125
+ for city, spec in cities.items():
126
+ expected = cfg["forces"][spec["force"]]
127
+ try:
128
+ b, rep = city_boundary(lad, city, spec["lad_code"], expected, code_field, name_field)
129
+ except ValueError as e:
130
+ fail(str(e))
131
+ area_km2 = b["area_m2"].iloc[0] / 1e6
132
+ approx = APPROX_AREA_KM2[city]
133
+ rep.update(lad_code=spec["lad_code"], lad_name=b["lad_name"].iloc[0], name_ok=b["lad_name"].iloc[0] == expected,
134
+ area_km2=area_km2, approx_km2=approx, area_dev_pct=(area_km2 - approx) / approx * 100)
135
+ if abs(area_km2 - approx) / approx > AREA_TOLERANCE:
136
+ fail(f"{city}: area {area_km2:.1f} km2 is more than {AREA_TOLERANCE:.0%} from the expected ~{approx} km2")
137
+ if rep["repaired"]:
138
+ print(f" {city}: geometry was INVALID ({rep['invalid_reason']}); repaired with shapely.make_valid")
139
+ path = write_gpkg(b[BOUNDARY_COLUMNS + ["geometry"]], boundaries_dir / boundary_filename(city, crs))
140
+ saved = gpd.read_file(path, engine="pyogrio")
141
+ saved_crs = pyogrio.read_info(path)["crs"]
142
+ if saved_crs != crs or len(saved) != 1:
143
+ fail(f"{path.name}: saved CRS {saved_crs}, rows {len(saved)}")
144
+ rep.update(file=path.name, saved_crs=saved_crs,
145
+ fingerprint=fingerprint(saved, BOUNDARY_COLUMNS))
146
+ boundaries[city] = saved
147
+ reports.append(rep)
148
+
149
+ btable = pd.DataFrame(reports)
150
+ print(btable[["city", "lad_code", "lad_name", "name_ok", "area_km2", "approx_km2", "area_dev_pct"]]
151
+ .to_string(index=False, float_format=lambda v: f"{v:,.2f}"))
152
+ print()
153
+ print(btable[["city", "n_features", "dissolved", "was_valid", "repaired", "n_polygons", "file", "saved_crs"]]
154
+ .to_string(index=False))
155
+ btable.to_csv(qa_dir / "task2_boundaries_summary.csv", index=False)
156
+
157
+ # ---------------------------------------------------------------- Step 5 / 6
158
+ section(f"STEP 5/6: {cell_size} m grids and checks")
159
+ results = []
160
+ for city, boundary in boundaries.items():
161
+ geom = boundary.geometry.iloc[0] # the boundary exactly as saved and shared
162
+ grid = make_grid(geom, cell_size, city, crs=crs)
163
+ rerun = make_grid(geom, cell_size, city, crs=crs, verbose=False)
164
+ fp = fingerprint(grid, GRID_COLUMNS)
165
+ deterministic = fp == fingerprint(rerun, GRID_COLUMNS)
166
+
167
+ path = write_gpkg(grid, grids_dir / grid_filename(city, cell_size, crs))
168
+ saved = gpd.read_file(path, engine="pyogrio")
169
+ saved_crs = pyogrio.read_info(path)["crs"]
170
+ roundtrip = fingerprint(saved, GRID_COLUMNS) == fp
171
+
172
+ r, failures = check_grid(saved, geom)
173
+ r.update(n_dropped_zero_area=grid.attrs["n_dropped_zero_area"], saved_crs=saved_crs,
174
+ deterministic=deterministic, file_roundtrip_identical=roundtrip, file=path.name, fingerprint=fp)
175
+ if saved_crs != crs:
176
+ failures.append(f"saved file CRS is {saved_crs}, not {crs}")
177
+ if not deterministic:
178
+ failures.append("two runs of make_grid gave different output")
179
+ if not roundtrip:
180
+ failures.append("grid read back from file differs from the grid written")
181
+
182
+ png = qa_dir / f"{city}_grid{cell_size}_qa.png"
183
+ name = boundary["lad_name"].iloc[0]
184
+ plot_grid_qa(saved, boundary, png,
185
+ title=f"{name}: {cell_size} m analysis grid ({crs})",
186
+ subtitle=(f"{r['n_cells']:,} cells, origin ({r['x_start']:,}, {r['y_start']:,}); "
187
+ f"edge = area_share < {EDGE_SHARE_THRESHOLD}; "
188
+ f"slivers <5%: {r['n_edge_lt_5pct']}, <10%: {r['n_edge_lt_10pct']}, "
189
+ f"<25%: {r['n_edge_lt_25pct']}"))
190
+ r["qa_png"] = png.name
191
+ results.append(r)
192
+
193
+ print(f"[{city}] a. origin ({r['x_start']}, {r['y_start']}), multiples of {cell_size}: {r['origin_multiple']}, "
194
+ f"equals lowest cell corner: {r['origin_matches_cells']}")
195
+ print(f"[{city}] b. cells {r['n_cells']:,} = full {r['n_full']:,} + edge {r['n_edge']:,}")
196
+ print(f"[{city}] c. edge cells with area_share <5%: {r['n_edge_lt_5pct']}, <10%: {r['n_edge_lt_10pct']}, "
197
+ f"<25%: {r['n_edge_lt_25pct']} (smallest: {r['min_area_share']:.6f} = {r['min_area_m2']:,.2f} m2)")
198
+ print(f"[{city}] is_sliver (area_share < {SLIVER_SHARE_THRESHOLD}): {r['n_sliver']}, "
199
+ f"flag consistent and only on edge cells: {r['sliver_flag_ok']}")
200
+ print(f"[{city}] d. cell area sum {r['cells_area_m2']:,.3f} m2 vs boundary {r['boundary_area_m2']:,.3f} m2, "
201
+ f"diff {r['area_diff_pct']:.2e} %")
202
+ print(f"[{city}] e. cell_id unique: {r['cell_id_unique']}, matches corner: {r['cell_id_matches_corner']}")
203
+ print(f"[{city}] f. all x_min/y_min multiples of {cell_size}: {r['xy_multiple']}")
204
+ print(f"[{city}] g. saved CRS: {saved_crs}")
205
+ print(f"[{city}] h. QA map: {png.relative_to(ROOT)}")
206
+ print(f"[{city}] deterministic rerun identical: {deterministic}; file round-trip identical: {roundtrip}; "
207
+ f"geometries valid: {r['geoms_valid_nonempty']}; inside own square: {r['geoms_inside_own_square']}")
208
+ print(f"[{city}] saved {path.relative_to(ROOT)} ({len(saved):,} rows), fingerprint {fp[:16]}...")
209
+ if failures:
210
+ fail(f"{city} grid checks failed: " + "; ".join(failures))
211
+ print()
212
+
213
+ gtable = pd.DataFrame(results)
214
+ gtable.to_csv(qa_dir / f"task2_grid{cell_size}_checks.csv", index=False)
215
+ section("SUMMARY")
216
+ cols = ["city", "x_start", "y_start", "n_cells", "n_full", "n_edge", "n_dropped_zero_area",
217
+ *[f"n_edge_lt_{round(t * 100)}pct" for t in SLIVER_THRESHOLDS], "n_sliver", "area_diff_pct"]
218
+ print(gtable[cols].to_string(index=False, float_format=lambda v: f"{v:.2e}"))
219
+ print("\nALL TASK 2 CHECKS PASSED")
220
+ sys.stdout = sys.__stdout__
221
+ run_log.close()
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
task2_log.txt ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TASK 2 LOG: city boundaries and 500 m analysis grids
2
+ Date: 2026-10-09
3
+ Machine: the copied project on a new laptop (the original Task 0/1 work was done on another computer)
4
+
5
+ WHAT WAS DONE
6
+ 1. Environment
7
+ - Python 3.11.14 (Anaconda). There was no requirements.txt, so I created .venv in the
8
+ project folder and installed pandas, geopandas, shapely, pyproj, pyogrio, numpy and
9
+ matplotlib.
10
+ - Versions: geopandas 1.2.0, shapely 2.2.0 (GEOS 3.14.1), pyproj 3.7.2 (PROJ 9.5.1),
11
+ pyogrio 0.13.0 (GDAL 3.12.4).
12
+ - I then wrote requirements.txt (pip freeze) so teammates can install the same versions.
13
+
14
+ 2. Checks on the copied project
15
+ - config.json line 13: "data_dir" changed from
16
+ "c:\\Users\\mukun\\Documents\\SEM_3\\6.SI\\MID_project"
17
+ to ".". Nothing else was changed at that step.
18
+ - Task 1 outputs load correctly. All four layers are in EPSG:27700, with six-figure
19
+ coordinates in metres, and each covers 2025-01 to 2025-12.
20
+ - Row counts in the .gpkg files compared with the expected totals:
21
+ west-yorkshire 298,385 vs 300,547 (2,162 rows with no coordinates)
22
+ south-yorkshire 164,140 vs 172,209 (8,069 rows with no coordinates)
23
+ northumbria 169,796 vs 169,796 (no rows without coordinates)
24
+ nottinghamshire 120,552 vs 121,358 (806 rows with no coordinates)
25
+ The expected totals equal the raw 2025 street CSV row counts exactly. Each gap equals
26
+ the number of rows with no coordinates, which task1_crime.py drops (line 96) before
27
+ saving. Conclusion: the copy is complete, and the expected totals counted rows before
28
+ that drop. The user confirmed to continue.
29
+
30
+ 3. Task 2a: boundaries
31
+ - No LAD boundary file was in the copied folder (checked every folder and all 9 zip
32
+ archives). config.json also had no LAD codes or boundary path.
33
+ - With the user's permission I downloaded the ONS "Local Authority Districts (December
34
+ 2025) Boundaries UK BFC" GeoPackage from the ONS Open Geography Portal
35
+ (item 92150c7aa60540c5814abe3b26bce6d0) into data/raw/boundaries/LAD_DEC_2025_UK_BFC.gpkg.
36
+ SHA-256: 29ba41a24d7943db1e8ab50c73506cb486c555f8c0e9bc945b24aece068d3602
37
+ Contents: 1 layer, 361 MultiPolygon features, CRS EPSG:27700.
38
+ Columns: LAD25CD, LAD25NM, LAD25NMW, BNG_E, BNG_N, LONG, LAT, GlobalID.
39
+ Version: December 2025, BFC (full resolution, clipped), LAD25 code set.
40
+ - I chose December 2025 over May 2025 (V2) because it is the latest release and shows
41
+ boundaries at the end of the study year. Both releases give the same areas for the
42
+ four cities (difference under 1 m2).
43
+ - Added to config.json: "cities" (city key -> force and LAD code), "boundary_file",
44
+ "boundary_code_field", "boundary_name_field", "boundaries_dir", "grids_dir", "qa_dir".
45
+ Existing keys were not changed.
46
+ - Cities matched by code. Areas are compared with the brief's approximate values:
47
+ leeds E08000035 Leeds 551.70 km2 (brief ~552, -0.05%)
48
+ sheffield E08000039 Sheffield 368.00 km2 (brief ~368, 0.00%)
49
+ newcastle E08000021 Newcastle upon Tyne 113.45 km2 (brief ~113, +0.40%)
50
+ nottingham E06000018 Nottingham 74.64 km2 (brief ~75, -0.47%)
51
+ Newcastle-under-Lyme (E07000195) is in the file and was not selected.
52
+ - Reprojection was not needed (the file is already EPSG:27700).
53
+ - No dissolve was needed (one feature per city). All four geometries were valid, so no
54
+ make_valid repair was needed.
55
+ - Saved data/interim/boundaries/<city>_boundary_EPSG27700.gpkg
56
+ (columns city, lad_code, lad_name, area_m2, geometry).
57
+
58
+ 4. Task 2b: grids
59
+ - Code: src/grid.py (make_grid, grid_extent, check_grid, plot_grid_qa),
60
+ src/boundaries.py, src/geomutils.py, and the runner task2_boundaries_grids.py.
61
+ - Built 500 m grids (grid_size_m from config.json) and saved
62
+ data/interim/grids/<city>_grid500_EPSG27700.gpkg.
63
+
64
+ 5. Checks (all passed)
65
+ city origin cells full edge dropped <5% <10% <25% area diff
66
+ leeds (413000, 422500) 2,390 2,034 356 0 52 79 114 4.5e-13 %
67
+ sheffield (413000, 378500) 1,626 1,326 300 0 39 57 102 1.1e-13 %
68
+ newcastle (414000, 562500) 526 384 142 0 22 31 47 9.2e-14 %
69
+ nottingham (450500, 332500) 366 234 132 0 22 25 41 0
70
+ - Origins and all x_min/y_min values are multiples of 500.
71
+ - cell_id is unique and equals "<x_min>_<y_min>".
72
+ - Every clipped cell lies inside its own 500 m square, and all geometries are valid.
73
+ - The saved files are EPSG:27700.
74
+ - Running make_grid twice gave identical output, and the files read back identical to
75
+ what was written. A full second run of the script also gave the same fingerprints:
76
+ leeds 43f0b4f5addd131a1c6ca37b7cb3227b450748a59371d183e4f754023ba40366
77
+ sheffield e2eaab7f9ee18c4485851b0e4dd3cf8ce40741d91efc8af6d925672ed122d061
78
+ newcastle 0c7f42dbd48911f71014fd3a1e00723fd41f39120439eb344cd6ab1352ac573f
79
+ nottingham 2c231069a277ad73d8cce6f3a077255a816afa00fae314804a4288f2e127d040
80
+ - QA maps: outputs/qa/<city>_grid500_qa.png. Full run output:
81
+ outputs/qa/task2_run_grid500.txt. Tables: outputs/qa/task2_boundaries_summary.csv
82
+ and outputs/qa/task2_grid500_checks.csv.
83
+
84
+ 6. Records: added a Task 2 section at the end of conventions.md (no other section changed).
85
+
86
+ THINGS THAT LOOKED SUSPICIOUS OR NEED A DECISION LATER
87
+ - Rows without coordinates: Task 1 silently drops crime rows with no coordinates
88
+ (South Yorkshire 4.7% of rows, West Yorkshire 0.7%, Nottinghamshire 0.7%). This rule is
89
+ not written in conventions.md, so the Task 1 owner should add it and put the counts in
90
+ the data quality table.
91
+ - Crimes placed outside the force area: the first three South Yorkshire records sit at
92
+ (440514, 350921), which is outside South Yorkshire. Police forces record some crimes
93
+ outside their own area. Clipping to the city boundary in Task 3 will remove them, but
94
+ the number removed should be reported.
95
+ - Brief vs reality: the brief said config.json held LAD codes and paths, but it only had
96
+ data_dir. The boundary file was also missing from the copy. Either the teammate never
97
+ had one, or it was not copied.
98
+ - Sheffield's LAD code is E08000039 (changed in 2023). Older lists and older boundary files
99
+ use E08000019, so anyone joining other data by LAD code must use the 2023+ code.
100
+ - Tiny slivers are kept as instructed:
101
+ Leeds 419500_439000 = 0.16 m2
102
+ Sheffield 433000_379500 = 0.11 m2
103
+ 23 edge cells under 1% of a cell in both Leeds and Sheffield
104
+ Densities (crime per km2) for these cells will be extreme. Tasks 3 and later need a rule,
105
+ for example a minimum area_share for density maps.
106
+ - One Leeds cell (442500_439500) is clipped very slightly (area_share 0.99998) but counts
107
+ as full under the 0.9999 rule. This is expected behaviour, noted for transparency.
108
+ - conventions.md "Data Location" (General Settings) still shows the old absolute path
109
+ c:\Users\mukun\... . It was left unchanged because Task 2 must not edit other sections.
110
+ config.json now uses ".".
111
+ - The project folder also holds 2024 crime data and 2024 OSM extracts. Task 1 used 2025
112
+ only, which I verified from the month ranges.
113
+ - My script's first full run finished every step and passed every check, then raised a
114
+ harmless error while closing its own log file (exit code 120). I fixed it and reran:
115
+ all outputs were identical (same fingerprints) and the run exited cleanly.
116
+ - .venv is inside the OneDrive folder, so OneDrive syncs thousands of package files.
117
+ Consider excluding .venv from sync. Teammates should build their own from
118
+ requirements.txt rather than copy it.
119
+ - The raw LAD file (122 MB) is not in the handoff folder because of its size. Teammates can
120
+ re-download it (see the item ID above) and check the SHA-256, or copy it from
121
+ data/raw/boundaries/.
122
+
123
+ ADDENDUM 2026-10-09: is_sliver column and conventions.md edits
124
+ - conventions.md edits:
125
+ - Data Location now uses the relative project paths from config.json.
126
+ - Added the "Rows Without Coordinates" rule to the crime data section.
127
+ - In the Task 2 section:
128
+ - The Sheffield code sentence now reads "E08000039 ... E08000019 must not be used".
129
+ - Added the sliver rule, marked PROPOSED TEAM DECISION (not yet agreed).
130
+ - Added is_sliver to the grid column list.
131
+ - Grid files: added is_sliver = area_share < 0.10. It is the last column before geometry,
132
+ so all existing columns keep their positions.
133
+ - Made in make_grid (src/grid.py, SLIVER_SHARE_THRESHOLD = 0.10), so reruns (including
134
+ the 250 m grid) include it automatically. check_grid also verifies the flag.
135
+ - Slivers: leeds 79, sheffield 57, newcastle 31, nottingham 25.
136
+ - Checked against the original files: cell counts, total areas, every original column,
137
+ every cell_id and every geometry (WKB bytes) are identical. The boundary files are
138
+ unchanged in content.
139
+ - All Task 2 checks passed again. Two separate runs gave identical fingerprints.
140
+ - The fingerprints now include is_sliver, so they replace the ones listed above:
141
+ leeds 5e6d3d67fd9e7836d1f1cdd2ffaec91b33dd968c217cad3b2e4c4a425a4718bf
142
+ sheffield 2b56dc075f491a1adf1230d40d5fadb948ca41138fe2d2d849bf33ca5a61a6fb
143
+ newcastle 95ad4c0669bd220069d8b29a88b95d2e8e145affc162a3a8d183ce2ede1cc68f
144
+ nottingham 85f36bc798012451f3eaff6dc2895f707600c1575a34d2dc429fb016629b0cd9
task2_qgis.py ADDED
@@ -0,0 +1,782 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task 2 reproduced in QGIS: city boundaries (2a) and analysis grids (2b) with native Processing tools.
2
+
3
+ HOW TO RUN (QGIS 3.44 LTR)
4
+ 1. Start QGIS and create a NEW, empty project (Project > New).
5
+ 2. Plugins > Python Console, then click "Show Editor" in the console toolbar.
6
+ 3. In the editor click "Open Script..." and choose this file (task2_qgis.py).
7
+ 4. Check that PROJECT_ROOT below is the project folder, then click "Run Script".
8
+ 5. Wait for "TASK 2 IN QGIS: FINISHED" in the console (a few minutes; QGIS is busy meanwhile).
9
+
10
+ Every geoprocessing step is a processing.run() call to a native QGIS algorithm (the same tools as the
11
+ Processing Toolbox). Every call, with its full parameters and output feature counts, is written to
12
+ logs/task2_qgis_log.txt so it can be repeated by hand.
13
+
14
+ Writes only to:
15
+ data/interim/qgis/boundaries/<city>_boundary_EPSG27700_qgis.gpkg
16
+ data/interim/qgis/grids/<city>_grid500_EPSG27700_qgis.gpkg
17
+ data/interim/qgis/steps/<city>/NN_<step>.gpkg every intermediate result
18
+ outputs/qa/qgis/ print layout PNGs and task2_qgis_vs_python.csv
19
+ logs/task2_qgis_log.txt
20
+ QGIS/crimegeo_vgi.qgz team project (relative paths, print layouts)
21
+ The Python Task 2 outputs are only read, and their checksums are verified before and after the run.
22
+ """
23
+ # ---------------------------------------------------------------------------------------------------
24
+ # EDIT THIS: absolute path of the project folder (the folder that holds config.json).
25
+ PROJECT_ROOT = r"C:\Users\padma\OneDrive\Desktop\SEM 7\SI (Spatial Informatics)\Project\MidPresentation\MID_project"
26
+ # ---------------------------------------------------------------------------------------------------
27
+
28
+ import csv
29
+ import hashlib
30
+ import json
31
+ import math
32
+ import os
33
+ import re
34
+ import time
35
+ import traceback
36
+ import zipfile
37
+ from datetime import datetime
38
+
39
+ import processing
40
+ from qgis.core import (Qgis, QgsCoordinateReferenceSystem, QgsExpression, QgsFeatureRequest, QgsFillSymbol,
41
+ QgsLayoutExporter, QgsLayoutItemLabel, QgsLayoutItemLegend, QgsLayoutItemMap,
42
+ QgsLayoutItemPage, QgsLayoutItemPicture, QgsLayoutItemScaleBar, QgsLayoutPoint,
43
+ QgsLayoutSize, QgsPrintLayout, QgsProcessingContext, QgsProject, QgsRasterLayer,
44
+ QgsRectangle, QgsRuleBasedRenderer, QgsTextFormat, QgsVectorLayer)
45
+ from qgis.PyQt.QtCore import QCoreApplication, QVariant
46
+ from qgis.PyQt.QtGui import QFont
47
+
48
+ # Grid rules: the same values as src/grid.py and conventions.md.
49
+ EDGE_SHARE_THRESHOLD = 0.9999
50
+ SLIVER_SHARE_THRESHOLD = 0.10
51
+ SLIVER_REPORT_THRESHOLDS = (0.05, 0.10, 0.25)
52
+ MAX_AREA_DIFF_PCT = 0.01 # sum of cell areas vs boundary area
53
+ MAX_CELL_AREA_DIFF_M2 = 0.01 # QGIS vs Python, per cell
54
+ # Python results at 500 m, from task2_log.txt: (x_start, y_start, cells).
55
+ EXPECTED_PYTHON_500 = {"leeds": (413000, 422500, 2390), "sheffield": (413000, 378500, 1626),
56
+ "newcastle": (414000, 562500, 526), "nottingham": (450500, 332500, 366)}
57
+
58
+ BASEMAP_NAME = "OpenStreetMap"
59
+ BASEMAP_URI = "type=xyz&url=https://tile.openstreetmap.org/{z}/{x}/{y}.png&zmin=0&zmax=19"
60
+ SOURCE_LINE = "Boundary: ONS LAD December 2025 BFC. Basemap: \u00a9 OpenStreetMap contributors."
61
+ NORTH_ARROW_SVG = ":/images/north_arrows/layout_default_north_arrow.svg"
62
+
63
+ TEXT, BOOL, INT64, DOUBLE = int(QVariant.String), int(QVariant.Bool), int(QVariant.LongLong), int(QVariant.Double)
64
+ TYPE_NAMES = {TEXT: "text", BOOL: "boolean", INT64: "int8", DOUBLE: "double precision"}
65
+
66
+
67
+ def project_path(*parts):
68
+ return os.path.normpath(os.path.join(PROJECT_ROOT, *parts))
69
+
70
+
71
+ if not os.path.isfile(project_path("config.json")):
72
+ raise FileNotFoundError(f"config.json not found: set PROJECT_ROOT at the top of the script (now {PROJECT_ROOT})")
73
+ with open(project_path("config.json"), encoding="utf-8") as _f:
74
+ CFG = json.load(_f)
75
+
76
+ CRS = CFG["crs"]
77
+ CS = int(CFG["grid_size_m"])
78
+ TAG = CRS.replace(":", "")
79
+ CODE_FIELD, NAME_FIELD = CFG["boundary_code_field"], CFG["boundary_name_field"]
80
+ LAD_PATH = project_path(*CFG["boundary_file"].split("/"))
81
+ OUT_DIR = project_path("data", "interim", "qgis")
82
+ BND_DIR = os.path.join(OUT_DIR, "boundaries")
83
+ GRID_DIR = os.path.join(OUT_DIR, "grids")
84
+ STEP_DIR = os.path.join(OUT_DIR, "steps")
85
+ QA_DIR = project_path("outputs", "qa", "qgis")
86
+ LOG_PATH = project_path("logs", "task2_qgis_log.txt")
87
+ CSV_PATH = os.path.join(QA_DIR, "task2_qgis_vs_python.csv")
88
+ PROJECT_FILE = project_path("QGIS", "crimegeo_vgi.qgz")
89
+ PY_BND_DIR = project_path(*CFG["boundaries_dir"].split("/"))
90
+ PY_GRID_DIR = project_path(*CFG["grids_dir"].split("/"))
91
+ PROTECTED = ([os.path.join(PY_BND_DIR, f"{c}_boundary_{TAG}.gpkg") for c in CFG["cities"]]
92
+ + [os.path.join(PY_GRID_DIR, f"{c}_grid{CS}_{TAG}.gpkg") for c in CFG["cities"]]
93
+ + [project_path("src", "grid.py"), project_path("src", "boundaries.py"),
94
+ project_path("task2_boundaries_grids.py")])
95
+
96
+
97
+ class CheckFailed(Exception):
98
+ pass
99
+
100
+
101
+ class Log:
102
+ """Print to the console and write the same text to logs/task2_qgis_log.txt."""
103
+
104
+ def __init__(self, path):
105
+ os.makedirs(os.path.dirname(path), exist_ok=True)
106
+ self.file = open(path, "w", encoding="utf-8")
107
+
108
+ def __call__(self, message=""):
109
+ print(message)
110
+ self.file.write(message + "\n")
111
+ self.file.flush()
112
+
113
+ def close(self):
114
+ self.file.close()
115
+
116
+
117
+ LOG = None
118
+ CALLS = []
119
+
120
+
121
+ # ------------------------------------------------------------------------------------------ helpers
122
+ def boundary_path(city):
123
+ return os.path.join(BND_DIR, f"{city}_boundary_{TAG}_qgis.gpkg")
124
+
125
+
126
+ def grid_path(city):
127
+ return os.path.join(GRID_DIR, f"{city}_grid{CS}_{TAG}_qgis.gpkg")
128
+
129
+
130
+ def step_path(city, name):
131
+ return os.path.join(STEP_DIR, city, f"{name}.gpkg")
132
+
133
+
134
+ def city_outputs(city):
135
+ steps = ["01_extract_by_code", "02_valid", "02_invalid", "02_validity_errors", "03_fixed", "04_dissolved",
136
+ "05_grid_full_extent", "06_clipped", "07_area_gt_0", "07_dropped_zero_area"]
137
+ return [step_path(city, s) for s in steps] + [boundary_path(city), grid_path(city),
138
+ os.path.join(QA_DIR, f"{city}_grid_qgis.png")]
139
+
140
+
141
+ def sha256(path):
142
+ h = hashlib.sha256()
143
+ with open(path, "rb") as f:
144
+ for block in iter(lambda: f.read(1 << 20), b""):
145
+ h.update(block)
146
+ return h.hexdigest()
147
+
148
+
149
+ def open_layer(path, name="layer"):
150
+ """Open a vector file read-only (never modifies it)."""
151
+ options = QgsVectorLayer.LayerOptions()
152
+ options.forceReadOnly = True
153
+ options.loadDefaultStyle = False
154
+ layer = QgsVectorLayer(path, name, "ogr", options)
155
+ if not layer.isValid():
156
+ raise RuntimeError(f"cannot open {path}")
157
+ return layer
158
+
159
+
160
+ def feature_count(path):
161
+ layer = open_layer(path)
162
+ n = layer.featureCount()
163
+ del layer
164
+ return n
165
+
166
+
167
+ def make_context():
168
+ """Processing context: validity is checked explicitly with native:checkvalidity, so no silent filtering."""
169
+ context = QgsProcessingContext()
170
+ context.setProject(QgsProject.instance())
171
+ no_check = (Qgis.InvalidGeometryCheck.NoCheck if hasattr(Qgis, "InvalidGeometryCheck")
172
+ else QgsFeatureRequest.GeometryNoCheck)
173
+ context.setInvalidGeometryCheck(no_check)
174
+ return context
175
+
176
+
177
+ def run_alg(city, step, alg_id, params):
178
+ """processing.run() one native algorithm and log ID, full parameters and output feature counts."""
179
+ LOG(f"\n[{city}] {step}\n algorithm: {alg_id}")
180
+ LOG(" parameters: " + json.dumps(params, indent=2, ensure_ascii=False).replace("\n", "\n "))
181
+ result = processing.run(alg_id, params, context=make_context())
182
+ counts = {}
183
+ for key, value in result.items():
184
+ if isinstance(value, str) and value.split("|")[0].lower().endswith(".gpkg"):
185
+ counts[key] = feature_count(value.split("|")[0])
186
+ elif key.endswith("_COUNT"):
187
+ counts[key] = int(value)
188
+ LOG(" output feature counts: " + ", ".join(f"{k}={v}" for k, v in counts.items()))
189
+ CALLS.append({"city": city, "step": step, "algorithm": alg_id, "counts": counts})
190
+ return result, counts
191
+
192
+
193
+ def fmap(name, qtype, expression):
194
+ """One entry of a native:refactorfields FIELDS_MAPPING."""
195
+ return {"name": name, "type": qtype, "type_name": TYPE_NAMES[qtype], "sub_type": 0, "length": 0,
196
+ "precision": 0, "expression": expression, "alias": "", "comment": ""}
197
+
198
+
199
+ def remove_files(paths, wait_seconds=75):
200
+ """Delete old outputs before a rerun. Waits for QGIS to release files it still holds open."""
201
+ pending = [p for p in paths if os.path.exists(p)]
202
+ deadline = time.time() + wait_seconds
203
+ while pending:
204
+ still = []
205
+ for p in pending:
206
+ for extra in ("", "-wal", "-shm", "-journal"):
207
+ if os.path.exists(p + extra):
208
+ try:
209
+ os.remove(p + extra)
210
+ except PermissionError:
211
+ still.append(p)
212
+ break
213
+ pending = still
214
+ if pending:
215
+ if time.time() > deadline:
216
+ raise RuntimeError("these old outputs are still open in QGIS and cannot be replaced: "
217
+ f"{pending}. Restart QGIS, create a new project, and run the script again.")
218
+ QCoreApplication.processEvents()
219
+ time.sleep(1)
220
+
221
+
222
+ def clear_previous_run(project):
223
+ """Remove layers, groups and layouts that an earlier run of this script added to the open project."""
224
+ out = os.path.normcase(OUT_DIR)
225
+ labels = [CFG["forces"][s["force"]] for s in CFG["cities"].values()]
226
+ for layer in list(project.mapLayers().values()):
227
+ source = os.path.normcase(os.path.normpath(layer.source().split("|")[0]))
228
+ if source.startswith(out) or layer.name() == BASEMAP_NAME:
229
+ project.removeMapLayer(layer.id())
230
+ root = project.layerTreeRoot()
231
+ for label in labels:
232
+ group = root.findGroup(label)
233
+ if group is not None and not group.children():
234
+ root.removeChildNode(group)
235
+ layout = project.layoutManager().layoutByName(f"{label} grid QA")
236
+ if layout is not None:
237
+ project.layoutManager().removeLayout(layout)
238
+ others = [layer.name() for layer in project.mapLayers().values()]
239
+ if others:
240
+ LOG(f"WARNING: the open project already contains other layers {others}; they will be saved in "
241
+ f"{os.path.basename(PROJECT_FILE)} too. Start from Project > New for a clean team project.")
242
+
243
+
244
+ def read_grid(path):
245
+ layer = open_layer(path)
246
+ cells, duplicates = {}, 0
247
+ for f in layer.getFeatures():
248
+ cid = f["cell_id"]
249
+ if cid in cells:
250
+ duplicates += 1
251
+ cells[cid] = {"x_min": int(f["x_min"]), "y_min": int(f["y_min"]), "area_m2": float(f["area_m2"]),
252
+ "area_share": float(f["area_share"]), "is_edge": bool(f["is_edge"]),
253
+ "is_sliver": bool(f["is_sliver"])}
254
+ schema = [(fld.name(), fld.typeName()) for fld in layer.fields() if fld.name() != "fid"]
255
+ info = {"cells": cells, "n": layer.featureCount(), "duplicates": duplicates, "schema": schema,
256
+ "crs": layer.crs().authid()}
257
+ del layer
258
+ return info
259
+
260
+
261
+ def read_boundary(path):
262
+ layer = open_layer(path)
263
+ feats = list(layer.getFeatures())
264
+ info = {"n": len(feats), "crs": layer.crs().authid(), "area_attr": float(feats[0]["area_m2"]),
265
+ "area_geom": feats[0].geometry().area(), "bbox": feats[0].geometry().boundingBox()}
266
+ del layer
267
+ return info
268
+
269
+
270
+ # ------------------------------------------------------------------------------------- Step 1 (2a)
271
+ def inspect_lad_file():
272
+ """STEP 1a/1b: load the ONS LAD file, report it, check the CRS (reproject once if needed)."""
273
+ LOG(f"\n{'=' * 90}\nSTEP 1a/1b: ONS LAD boundary file\n{'=' * 90}")
274
+ if not os.path.isfile(LAD_PATH):
275
+ raise CheckFailed(f"boundary file not found: {LAD_PATH}")
276
+ lad = open_layer(LAD_PATH, "LAD")
277
+ fields = [f.name() for f in lad.fields()]
278
+ LOG(f"file: {LAD_PATH}")
279
+ LOG(f"CRS: {lad.crs().authid() or '(none)'} valid={lad.crs().isValid()}")
280
+ LOG(f"features: {lad.featureCount()}")
281
+ LOG(f"fields: {fields}")
282
+ code_fields = [f for f in fields if re.fullmatch(r"LAD\d\dCD", f)]
283
+ name_fields = [f for f in fields if re.fullmatch(r"LAD\d\dNM", f)]
284
+ LOG(f"LAD code field: {code_fields}, name field: {name_fields} (config: {CODE_FIELD}, {NAME_FIELD})")
285
+ if code_fields != [CODE_FIELD] or NAME_FIELD not in name_fields:
286
+ raise CheckFailed("LAD code/name fields in the file do not match config.json")
287
+ if not lad.crs().isValid():
288
+ raise CheckFailed("the LAD file has no CRS defined")
289
+ source = LAD_PATH
290
+ if lad.crs().authid() != CRS:
291
+ LOG(f"CRS is {lad.crs().authid()}, not {CRS}: reprojecting with native:reprojectlayer")
292
+ res, _ = run_alg("all", "STEP 1b reproject LAD file", "native:reprojectlayer", {
293
+ "INPUT": LAD_PATH, "TARGET_CRS": CRS, "CONVERT_CURVED_GEOMETRIES": False,
294
+ "OUTPUT": os.path.join(STEP_DIR, "00_lad_reprojected.gpkg")})
295
+ source = res["OUTPUT"]
296
+ else:
297
+ LOG(f"CRS is already {CRS}: no reprojection needed (native:reprojectlayer skipped)")
298
+ del lad
299
+ return source
300
+
301
+
302
+ def build_boundary(city, spec, lad_source):
303
+ """STEP 1c-1f for one city. Returns the path of the dissolved layer and the final boundary."""
304
+ label, code = CFG["forces"][spec["force"]], spec["lad_code"]
305
+
306
+ res, n = run_alg(city, "STEP 1c extract by LAD code", "native:extractbyattribute", {
307
+ "INPUT": lad_source, "FIELD": CODE_FIELD, "OPERATOR": 0, "VALUE": code,
308
+ "OUTPUT": step_path(city, "01_extract_by_code")})
309
+ if n["OUTPUT"] != 1:
310
+ raise CheckFailed(f"{code}: expected exactly 1 feature, got {n['OUTPUT']}")
311
+ extracted = open_layer(res["OUTPUT"])
312
+ name = next(extracted.getFeatures())[NAME_FIELD]
313
+ del extracted
314
+ if name != label:
315
+ raise CheckFailed(f"{code} is named '{name}' in the file, expected '{label}'")
316
+ LOG(f" name in file: '{name}' = expected '{label}'")
317
+
318
+ res_v, nv = run_alg(city, "STEP 1d check validity (GEOS)", "native:checkvalidity", {
319
+ "INPUT_LAYER": res["OUTPUT"], "METHOD": 2, "IGNORE_RING_SELF_INTERSECTION": False,
320
+ "VALID_OUTPUT": step_path(city, "02_valid"), "INVALID_OUTPUT": step_path(city, "02_invalid"),
321
+ "ERROR_OUTPUT": step_path(city, "02_validity_errors")})
322
+ to_dissolve, repaired = res["OUTPUT"], nv["INVALID_COUNT"] > 0
323
+ if repaired:
324
+ LOG(f" {nv['INVALID_COUNT']} INVALID geometry -> repairing with native:fixgeometries")
325
+ res_f, _ = run_alg(city, "STEP 1d fix geometries", "native:fixgeometries", {
326
+ "INPUT": res["OUTPUT"], "METHOD": 0, "OUTPUT": step_path(city, "03_fixed")})
327
+ to_dissolve = res_f["OUTPUT"]
328
+ else:
329
+ LOG(" geometry valid -> native:fixgeometries skipped")
330
+
331
+ res_d, nd = run_alg(city, "STEP 1e dissolve to one feature", "native:dissolve", {
332
+ "INPUT": to_dissolve, "FIELD": [], "SEPARATE_DISJOINT": False, "OUTPUT": step_path(city, "04_dissolved")})
333
+ if nd["OUTPUT"] != 1:
334
+ raise CheckFailed(f"dissolve gave {nd['OUTPUT']} features, expected 1")
335
+
336
+ run_alg(city, "STEP 1f boundary schema (planar area)", "native:refactorfields", {
337
+ "INPUT": res_d["OUTPUT"],
338
+ "FIELDS_MAPPING": [fmap("city", TEXT, QgsExpression.quotedString(city)),
339
+ fmap("lad_code", TEXT, QgsExpression.quotedColumnRef(CODE_FIELD)),
340
+ fmap("lad_name", TEXT, QgsExpression.quotedColumnRef(NAME_FIELD)),
341
+ fmap("area_m2", DOUBLE, "area($geometry)")],
342
+ "OUTPUT": boundary_path(city)})
343
+ return res_d["OUTPUT"], repaired
344
+
345
+
346
+ # ------------------------------------------------------------------------------------- Step 2 (2b)
347
+ def build_grid(city, dissolved_path, python_origin):
348
+ """STEP 2a-2f for one city. Returns the snapped origin and the number of zero-area cells dropped."""
349
+ dissolved = open_layer(dissolved_path)
350
+ bbox = next(dissolved.getFeatures()).geometry().boundingBox()
351
+ del dissolved
352
+ # floor(xmin / CS) * CS etc., computed with integers only so there is no floating point drift.
353
+ x0 = math.floor(bbox.xMinimum()) // CS * CS
354
+ y0 = math.floor(bbox.yMinimum()) // CS * CS
355
+ x1 = -(-math.ceil(bbox.xMaximum()) // CS) * CS
356
+ y1 = -(-math.ceil(bbox.yMaximum()) // CS) * CS
357
+ LOG(f"\n[{city}] STEP 2a snap extent: boundary bbox ({bbox.xMinimum():.3f}, {bbox.yMinimum():.3f}) - "
358
+ f"({bbox.xMaximum():.3f}, {bbox.yMaximum():.3f}) -> ({x0}, {y0}) - ({x1}, {y1})")
359
+ if (x0, y0) != python_origin:
360
+ raise CheckFailed(f"snapped origin ({x0}, {y0}) differs from the Python origin {python_origin}")
361
+ LOG(f" origin ({x0}, {y0}) = Python origin {python_origin}")
362
+
363
+ res_g, ng = run_alg(city, "STEP 2b create grid", "native:creategrid", {
364
+ "TYPE": 2, "EXTENT": f"{x0},{x1},{y0},{y1} [{CRS}]", "HSPACING": CS, "VSPACING": CS,
365
+ "HOVERLAY": 0, "VOVERLAY": 0, "CRS": CRS, "OUTPUT": step_path(city, "05_grid_full_extent")})
366
+ expected = ((x1 - x0) // CS) * ((y1 - y0) // CS)
367
+ if ng["OUTPUT"] != expected:
368
+ raise CheckFailed(f"create grid made {ng['OUTPUT']} cells, expected {expected}")
369
+
370
+ res_c, _ = run_alg(city, "STEP 2c clip grid to boundary", "native:clip", {
371
+ "INPUT": res_g["OUTPUT"], "OVERLAY": boundary_path(city), "OUTPUT": step_path(city, "06_clipped")})
372
+
373
+ res_e, ne = run_alg(city, "STEP 2d drop zero-area cells", "native:extractbyexpression", {
374
+ "INPUT": res_c["OUTPUT"], "EXPRESSION": "area($geometry) > 0",
375
+ "OUTPUT": step_path(city, "07_area_gt_0"), "FAIL_OUTPUT": step_path(city, "07_dropped_zero_area")})
376
+ LOG(f" cells dropped (clipped area 0, touch the boundary only at a line or point): {ne['FAIL_OUTPUT']}")
377
+
378
+ full = CS * CS
379
+ run_alg(city, "STEP 2e grid schema (same as the Python grid)", "native:refactorfields", {
380
+ "INPUT": res_e["OUTPUT"],
381
+ "FIELDS_MAPPING": [
382
+ fmap("cell_id", TEXT, "concat(to_int(round(\"left\")), '_', to_int(round(\"bottom\")))"),
383
+ fmap("x_min", INT64, "to_int(round(\"left\"))"),
384
+ fmap("y_min", INT64, "to_int(round(\"bottom\"))"),
385
+ fmap("cell_size_m", INT64, str(CS)),
386
+ fmap("area_m2", DOUBLE, "area($geometry)"),
387
+ fmap("area_share", DOUBLE, f"area($geometry) / {full}"),
388
+ fmap("is_edge", BOOL, f"area($geometry) / {full} < {EDGE_SHARE_THRESHOLD}"),
389
+ fmap("city", TEXT, QgsExpression.quotedString(city)),
390
+ fmap("is_sliver", BOOL, f"area($geometry) / {full} < {SLIVER_SHARE_THRESHOLD}")],
391
+ "OUTPUT": grid_path(city)})
392
+ return (x0, y0), ne["FAIL_OUTPUT"]
393
+
394
+
395
+ # ------------------------------------------------------------------------------- Step 3 and Step 4
396
+ def check_city(city, origin):
397
+ """STEP 3: checks on the saved QGIS boundary and grid."""
398
+ b = read_boundary(boundary_path(city))
399
+ g = read_grid(grid_path(city))
400
+ cells = list(g["cells"].values())
401
+ edge = [c for c in cells if c["is_edge"]]
402
+ area_sum = sum(c["area_m2"] for c in cells)
403
+ r = {
404
+ "n_cells": g["n"], "n_full": len(cells) - len(edge), "n_edge": len(edge),
405
+ **{f"edge_lt_{round(t * 100)}pct": sum(c["area_share"] < t for c in edge) for t in SLIVER_REPORT_THRESHOLDS},
406
+ "n_sliver": sum(c["is_sliver"] for c in cells),
407
+ "area_sum": area_sum, "boundary_area": b["area_attr"],
408
+ "area_diff_pct": abs(area_sum - b["area_attr"]) / b["area_attr"] * 100,
409
+ "unique": g["duplicates"] == 0 and len(g["cells"]) == g["n"],
410
+ "multiples": all(c["x_min"] % CS == 0 and c["y_min"] % CS == 0 for c in cells),
411
+ "id_matches_corner": all(cid == f"{c['x_min']}_{c['y_min']}" for cid, c in g["cells"].items()),
412
+ "origin_ok": (min(c["x_min"] for c in cells), min(c["y_min"] for c in cells)) == origin,
413
+ "flags_ok": all(c["is_edge"] == (c["area_share"] < EDGE_SHARE_THRESHOLD)
414
+ and c["is_sliver"] == (c["area_share"] < SLIVER_SHARE_THRESHOLD) for c in cells),
415
+ "crs_boundary": b["crs"], "crs_grid": g["crs"], "boundary_features": b["n"],
416
+ }
417
+ LOG(f"\n[{city}] STEP 3 checks")
418
+ LOG(f" a. cells {r['n_cells']:,} = full {r['n_full']:,} + edge {r['n_edge']:,}; edge cells with area_share "
419
+ f"<5%: {r['edge_lt_5pct']}, <10%: {r['edge_lt_10pct']}, <25%: {r['edge_lt_25pct']}; is_sliver: {r['n_sliver']}")
420
+ LOG(f" b. sum of area_m2 {area_sum:,.3f} m2 vs boundary {b['area_attr']:,.3f} m2: diff {r['area_diff_pct']:.2e} %")
421
+ LOG(f" c. cell_id unique: {r['unique']}; equals '<x_min>_<y_min>': {r['id_matches_corner']}; "
422
+ f"x_min/y_min multiples of {CS}: {r['multiples']}; lowest corner = origin: {r['origin_ok']}; "
423
+ f"is_edge/is_sliver flags consistent: {r['flags_ok']}")
424
+ LOG(f" d. CRS boundary file: {b['crs']}, grid file: {g['crs']}")
425
+ problems = []
426
+ if r["area_diff_pct"] >= MAX_AREA_DIFF_PCT:
427
+ problems.append(f"area difference {r['area_diff_pct']:.6f}% is not below {MAX_AREA_DIFF_PCT}%")
428
+ for key in ("unique", "multiples", "id_matches_corner", "origin_ok", "flags_ok"):
429
+ if not r[key]:
430
+ problems.append(f"check failed: {key}")
431
+ if b["crs"] != CRS or g["crs"] != CRS:
432
+ problems.append(f"saved CRS is boundary {b['crs']}, grid {g['crs']}, expected {CRS}")
433
+ if b["n"] != 1:
434
+ problems.append(f"boundary file has {b['n']} features, expected 1")
435
+ if problems:
436
+ raise CheckFailed("; ".join(problems))
437
+ LOG(" all STEP 3 checks passed")
438
+ return r, g, b
439
+
440
+
441
+ def compare_with_python(city, label, g, b, origin):
442
+ """STEP 4: compare the QGIS grid and boundary with the Python outputs (read-only)."""
443
+ py_grid_file = os.path.join(PY_GRID_DIR, f"{city}_grid{CS}_{TAG}.gpkg")
444
+ py_bnd_file = os.path.join(PY_BND_DIR, f"{city}_boundary_{TAG}.gpkg")
445
+ p, pb = read_grid(py_grid_file), read_boundary(py_bnd_file)
446
+ q_ids, p_ids = set(g["cells"]), set(p["cells"])
447
+ common = sorted(q_ids & p_ids)
448
+ diffs = {cid: abs(g["cells"][cid]["area_m2"] - p["cells"][cid]["area_m2"]) for cid in common}
449
+ worst = max(diffs, key=diffs.get) if diffs else ""
450
+ p_cells = list(p["cells"].values())
451
+ row = {
452
+ "city": city, "city_name": label,
453
+ "qgis_cells": g["n"], "python_cells": p["n"],
454
+ "qgis_edge": sum(c["is_edge"] for c in g["cells"].values()), "python_edge": sum(c["is_edge"] for c in p_cells),
455
+ "qgis_slivers": sum(c["is_sliver"] for c in g["cells"].values()),
456
+ "python_slivers": sum(c["is_sliver"] for c in p_cells),
457
+ "cell_ids_identical": q_ids == p_ids,
458
+ "n_missing_in_qgis": len(p_ids - q_ids), "n_missing_in_python": len(q_ids - p_ids),
459
+ "missing_in_qgis": ";".join(sorted(p_ids - q_ids)), "missing_in_python": ";".join(sorted(q_ids - p_ids)),
460
+ "max_abs_area_diff_m2": diffs[worst] if diffs else float("nan"), "worst_cell_id": worst,
461
+ "edge_flag_mismatches": sum(g["cells"][c]["is_edge"] != p["cells"][c]["is_edge"] for c in common),
462
+ "sliver_flag_mismatches": sum(g["cells"][c]["is_sliver"] != p["cells"][c]["is_sliver"] for c in common),
463
+ "schema_identical": g["schema"] == p["schema"],
464
+ "qgis_grid_area_m2": sum(c["area_m2"] for c in g["cells"].values()),
465
+ "python_grid_area_m2": sum(c["area_m2"] for c in p_cells),
466
+ "qgis_boundary_area_m2": b["area_attr"], "python_boundary_area_m2": pb["area_attr"],
467
+ "boundary_area_diff_m2": b["area_attr"] - pb["area_attr"],
468
+ "qgis_origin": f"{origin[0]}_{origin[1]}",
469
+ "python_origin": f"{min(c['x_min'] for c in p_cells)}_{min(c['y_min'] for c in p_cells)}",
470
+ }
471
+ row["match"] = (row["cell_ids_identical"] and row["qgis_cells"] == row["python_cells"]
472
+ and row["qgis_edge"] == row["python_edge"] and row["qgis_slivers"] == row["python_slivers"]
473
+ and diffs != {} and diffs[worst] < MAX_CELL_AREA_DIFF_M2 and row["edge_flag_mismatches"] == 0
474
+ and row["sliver_flag_mismatches"] == 0 and row["schema_identical"]
475
+ and row["qgis_origin"] == row["python_origin"])
476
+ LOG(f"\n[{city}] STEP 4 comparison with the Python outputs (read-only)")
477
+ LOG(f" cells QGIS {row['qgis_cells']:,} vs Python {row['python_cells']:,}; edge {row['qgis_edge']} vs "
478
+ f"{row['python_edge']}; slivers {row['qgis_slivers']} vs {row['python_slivers']}")
479
+ LOG(f" cell_id sets identical: {row['cell_ids_identical']} (missing in QGIS: {row['n_missing_in_qgis']}, "
480
+ f"missing in Python: {row['n_missing_in_python']})")
481
+ if row["missing_in_qgis"] or row["missing_in_python"]:
482
+ LOG(f" missing in QGIS: {row['missing_in_qgis'] or '-'}")
483
+ LOG(f" missing in Python: {row['missing_in_python'] or '-'}")
484
+ LOG(f" max |area_m2 difference| per cell: {row['max_abs_area_diff_m2']!r} m2 (cell {worst}); "
485
+ f"flag mismatches: is_edge {row['edge_flag_mismatches']}, is_sliver {row['sliver_flag_mismatches']}")
486
+ LOG(f" schema identical (names, types, order): {row['schema_identical']}")
487
+ if not row["schema_identical"]:
488
+ LOG(f" QGIS: {g['schema']}\n Python: {p['schema']}")
489
+ LOG(f" boundary area QGIS {b['area_attr']:,.6f} vs Python {pb['area_attr']:,.6f} m2: "
490
+ f"diff {row['boundary_area_diff_m2']:.6f} m2")
491
+ LOG(f" -> {'MATCH' if row['match'] else 'MISMATCH'}")
492
+ if city in EXPECTED_PYTHON_500 and CS == 500 and p["n"] != EXPECTED_PYTHON_500[city][2]:
493
+ LOG(f" WARNING: Python grid has {p['n']} cells, task2_log.txt says {EXPECTED_PYTHON_500[city][2]}")
494
+ return row
495
+
496
+
497
+ def python_origin(city):
498
+ """Grid origin of the existing Python grid (lowest cell corner), cross-checked with task2_log.txt."""
499
+ p = read_grid(os.path.join(PY_GRID_DIR, f"{city}_grid{CS}_{TAG}.gpkg"))
500
+ origin = (min(c["x_min"] for c in p["cells"].values()), min(c["y_min"] for c in p["cells"].values()))
501
+ if CS == 500 and city in EXPECTED_PYTHON_500 and origin != EXPECTED_PYTHON_500[city][:2]:
502
+ raise CheckFailed(f"Python grid origin {origin} differs from task2_log.txt {EXPECTED_PYTHON_500[city][:2]}")
503
+ return origin
504
+
505
+
506
+ # ------------------------------------------------------------------------------------ Step 5 and 6
507
+ def simple_fill(**props):
508
+ return QgsFillSymbol.createSimple({k: str(v) for k, v in props.items()})
509
+
510
+
511
+ def style_boundary(layer):
512
+ symbol = simple_fill(style="no", outline_color="#0b0b0b", outline_width=0.8, outline_width_unit="MM")
513
+ layer.renderer().setSymbol(symbol)
514
+
515
+
516
+ GRID_RULES = [ # (legend label, filter expression, fill properties)
517
+ ("Full cell", 'NOT "is_edge"', dict(style="no", outline_color="#8c8b87", outline_width=0.15)),
518
+ ("Edge cell", '"is_edge" AND NOT "is_sliver"',
519
+ dict(style="solid", color="#f7b98e", outline_color="#8c8b87", outline_width=0.15)),
520
+ ("Sliver, under 10% of a cell", '"is_sliver"',
521
+ dict(style="solid", color="#d62728", outline_color="#8c8b87", outline_width=0.15)),
522
+ ]
523
+
524
+
525
+ def style_grid(layer):
526
+ root = QgsRuleBasedRenderer.Rule(None)
527
+ for label, expression, props in GRID_RULES:
528
+ root.appendChild(QgsRuleBasedRenderer.Rule(simple_fill(**props), 0, 0, expression, label))
529
+ layer.setRenderer(QgsRuleBasedRenderer(root))
530
+
531
+
532
+ def rule_counts(layer):
533
+ return {label: sum(1 for _ in layer.getFeatures(QgsFeatureRequest(QgsExpression(expression))))
534
+ for label, expression, _ in GRID_RULES}
535
+
536
+
537
+ def text_format(size, bold=False):
538
+ font = QFont("Arial")
539
+ font.setBold(bold)
540
+ fmt = QgsTextFormat()
541
+ fmt.setFont(font)
542
+ fmt.setSize(size)
543
+ fmt.setSizeUnit(Qgis.RenderUnit.Points)
544
+ return fmt
545
+
546
+
547
+ def add_label(layout, text, size, x, y, w, h, bold=False):
548
+ label = QgsLayoutItemLabel(layout)
549
+ label.setText(text)
550
+ label.setTextFormat(text_format(size, bold))
551
+ layout.addLayoutItem(label)
552
+ label.attemptMove(QgsLayoutPoint(x, y))
553
+ label.attemptResize(QgsLayoutSize(w, h))
554
+ return label
555
+
556
+
557
+ def build_layout(project, label, boundary_layer, grid_layer, basemap, png_path):
558
+ """STEP 6: A4 landscape layout with map, title, legend, scale bar (km), north arrow, source line."""
559
+ manager = project.layoutManager()
560
+ name = f"{label} grid QA"
561
+ old = manager.layoutByName(name)
562
+ if old is not None:
563
+ manager.removeLayout(old)
564
+ layout = QgsPrintLayout(project)
565
+ layout.initializeDefaults()
566
+ layout.setName(name)
567
+ layout.pageCollection().page(0).setPageSize("A4", QgsLayoutItemPage.Landscape)
568
+
569
+ add_label(layout, f"{label}: {CS} m analysis grid ({CRS})", 18, 10, 7, 277, 11, bold=True)
570
+
571
+ map_item = QgsLayoutItemMap(layout)
572
+ layout.addLayoutItem(map_item)
573
+ map_item.attemptMove(QgsLayoutPoint(10, 21))
574
+ map_item.attemptResize(QgsLayoutSize(205, 176))
575
+ map_item.setCrs(QgsCoordinateReferenceSystem(CRS))
576
+ map_item.setLayers([boundary_layer, grid_layer] + ([basemap] if basemap else []))
577
+ map_item.setKeepLayerSet(True)
578
+ extent = QgsRectangle(boundary_layer.extent())
579
+ extent.scale(1.06) # small margin around the city
580
+ map_item.zoomToExtent(extent)
581
+ map_item.setFrameEnabled(True)
582
+
583
+ legend = QgsLayoutItemLegend(layout)
584
+ legend.setTitle("Legend")
585
+ legend.setLinkedMap(map_item)
586
+ layout.addLayoutItem(legend)
587
+ legend.setAutoUpdateModel(False)
588
+ tree = legend.model().rootGroup()
589
+ tree.removeAllChildren()
590
+ for layer, title in ((boundary_layer, "City boundary"), (grid_layer, f"Grid cells ({CS} m)")):
591
+ node = tree.addLayer(layer)
592
+ node.setCustomProperty("legend/title-label", title)
593
+ legend.model().refreshLayerLegend(node)
594
+ legend.updateLegend()
595
+ legend.attemptMove(QgsLayoutPoint(222, 21))
596
+
597
+ arrow = QgsLayoutItemPicture(layout)
598
+ arrow.setPicturePath(NORTH_ARROW_SVG)
599
+ layout.addLayoutItem(arrow)
600
+ arrow.setLinkedMap(map_item)
601
+ arrow.setNorthMode(QgsLayoutItemPicture.GridNorth)
602
+ arrow.attemptMove(QgsLayoutPoint(224, 128))
603
+ arrow.attemptResize(QgsLayoutSize(12, 18))
604
+
605
+ scalebar = QgsLayoutItemScaleBar(layout)
606
+ scalebar.setStyle("Single Box")
607
+ scalebar.setLinkedMap(map_item)
608
+ layout.addLayoutItem(scalebar)
609
+ scalebar.applyDefaultSize(Qgis.DistanceUnit.Kilometers)
610
+ scalebar.setUnitLabel("km")
611
+ scalebar.setNumberOfSegmentsLeft(0)
612
+ scalebar.attemptMove(QgsLayoutPoint(222, 155))
613
+ scalebar.update()
614
+
615
+ add_label(layout, SOURCE_LINE, 8, 10, 200, 277, 6)
616
+
617
+ manager.addLayout(layout)
618
+ settings = QgsLayoutExporter.ImageExportSettings()
619
+ settings.dpi = 200
620
+ # QGIS then tries to georeference the image in place; GDAL cannot update a PNG, so it prints a
621
+ # harmless "ERROR 6: The PNG driver does not support update access" message. The PNG is complete.
622
+ result = QgsLayoutExporter(layout).exportToImage(png_path, settings)
623
+ if result != QgsLayoutExporter.Success:
624
+ raise RuntimeError(f"layout export failed for {label} (code {result})")
625
+ LOG(f" layout '{name}' saved in the project; exported {os.path.relpath(png_path, PROJECT_ROOT)} at 200 dpi")
626
+
627
+
628
+ def build_project(done, project):
629
+ """STEP 5 and 6: team project with groups, styles, basemap, layouts; saved with relative paths."""
630
+ LOG(f"\n{'=' * 90}\nSTEP 5: team QGIS project\n{'=' * 90}")
631
+ project.setCrs(QgsCoordinateReferenceSystem(CRS))
632
+ LOG(f"project CRS: {project.crs().authid()}")
633
+ root = project.layerTreeRoot()
634
+ layers = {}
635
+ for city, info in done.items():
636
+ label = info["label"]
637
+ group = root.findGroup(label)
638
+ if group is None:
639
+ group = root.addGroup(label)
640
+ boundary = QgsVectorLayer(boundary_path(city), f"{label} boundary (QGIS)", "ogr")
641
+ grid = QgsVectorLayer(grid_path(city), f"{label} grid {CS} m (QGIS)", "ogr")
642
+ if not (boundary.isValid() and grid.isValid()):
643
+ raise RuntimeError(f"cannot load the QGIS outputs for {label}")
644
+ style_boundary(boundary)
645
+ style_grid(grid)
646
+ project.addMapLayer(boundary, False)
647
+ project.addMapLayer(grid, False)
648
+ group.addLayer(boundary)
649
+ group.addLayer(grid)
650
+ counts = rule_counts(grid)
651
+ expected = {"Full cell": info["checks"]["n_full"],
652
+ "Edge cell": info["checks"]["n_edge"] - info["checks"]["n_sliver"],
653
+ "Sliver, under 10% of a cell": info["checks"]["n_sliver"]}
654
+ LOG(f" group '{label}': boundary + grid; rule counts {counts} "
655
+ f"({'match the checks' if counts == expected else 'DO NOT MATCH ' + str(expected)})")
656
+ if counts != expected:
657
+ raise CheckFailed(f"{label}: grid style rules do not cover the cells as expected")
658
+ layers[city] = (boundary, grid)
659
+
660
+ basemap = QgsRasterLayer(BASEMAP_URI, BASEMAP_NAME, "wms")
661
+ if basemap.isValid():
662
+ project.addMapLayer(basemap, False)
663
+ root.addLayer(basemap) # appended last = bottom of the layer list
664
+ LOG(f" basemap '{BASEMAP_NAME}' added at the bottom of the layer list")
665
+ else:
666
+ basemap = None
667
+ LOG(" WARNING: OpenStreetMap basemap could not be created; layouts are drawn without it")
668
+
669
+ LOG(f"\n{'=' * 90}\nSTEP 6: print layouts and QA maps\n{'=' * 90}")
670
+ for city, (boundary, grid) in layers.items():
671
+ build_layout(project, done[city]["label"], boundary, grid, basemap,
672
+ os.path.join(QA_DIR, f"{city}_grid_qgis.png"))
673
+
674
+ project.setTitle("crimegeo_vgi: Task 2 boundaries and grids")
675
+ project.setFilePathStorage(Qgis.FilePathType.Relative)
676
+ if not project.write(PROJECT_FILE):
677
+ raise RuntimeError(f"could not save the project: {project.error()}")
678
+ with zipfile.ZipFile(PROJECT_FILE) as z:
679
+ qgs = z.read([n for n in z.namelist() if n.endswith(".qgs")][0]).decode("utf-8")
680
+ sources = re.findall(r"<datasource>([^<]*)</datasource>", qgs)
681
+ local = [s for s in sources if ".gpkg" in s]
682
+ absolute = [s for s in local if re.match(r"^[A-Za-z]:[\\/]|^/", s)]
683
+ LOG(f"\nproject saved: {os.path.relpath(PROJECT_FILE, PROJECT_ROOT)} ({len(local)} GeoPackage sources, "
684
+ f"{len(local) - len(absolute)} relative, {len(absolute)} absolute; "
685
+ f"{len(project.layoutManager().layouts())} layouts)")
686
+ if absolute:
687
+ raise CheckFailed(f"project has absolute data paths: {absolute}")
688
+ return layers
689
+
690
+
691
+ # ------------------------------------------------------------------------------------------- main
692
+ def main():
693
+ global LOG
694
+ LOG = Log(LOG_PATH)
695
+ CALLS.clear()
696
+ started = datetime.now()
697
+ project = QgsProject.instance()
698
+ try:
699
+ LOG(f"TASK 2 IN QGIS - run started {started:%Y-%m-%d %H:%M:%S}")
700
+ LOG(f"QGIS {Qgis.version()} | PROJECT_ROOT {PROJECT_ROOT}")
701
+ LOG(f"config: crs={CRS}, grid_size_m={CS}, boundary_file={CFG['boundary_file']}")
702
+ LOG("processing context: invalid feature filtering = do not filter "
703
+ "(validity is checked explicitly with native:checkvalidity)")
704
+
705
+ missing = [p for p in PROTECTED if not os.path.isfile(p)]
706
+ if missing:
707
+ raise CheckFailed(f"Python Task 2 outputs missing: {missing}")
708
+ hashes_before = {p: sha256(p) for p in PROTECTED}
709
+ LOG(f"recorded SHA-256 of {len(PROTECTED)} Python Task 2 files (they are only read)")
710
+
711
+ clear_previous_run(project)
712
+ remove_files([p for c in CFG["cities"] for p in city_outputs(c)]
713
+ + [CSV_PATH, os.path.join(STEP_DIR, "00_lad_reprojected.gpkg")])
714
+ for d in (BND_DIR, GRID_DIR, QA_DIR):
715
+ os.makedirs(d, exist_ok=True)
716
+ for c in CFG["cities"]:
717
+ os.makedirs(os.path.join(STEP_DIR, c), exist_ok=True)
718
+
719
+ lad_source = inspect_lad_file()
720
+
721
+ done, failures, rows = {}, {}, []
722
+ for city, spec in CFG["cities"].items():
723
+ label = CFG["forces"][spec["force"]]
724
+ LOG(f"\n{'=' * 90}\n{label} ({city}), LAD code {spec['lad_code']}\n{'=' * 90}")
725
+ try:
726
+ origin_py = python_origin(city)
727
+ dissolved, repaired = build_boundary(city, spec, lad_source)
728
+ origin, dropped = build_grid(city, dissolved, origin_py)
729
+ checks, g, b = check_city(city, origin)
730
+ row = compare_with_python(city, label, g, b, origin)
731
+ row.update(dropped_zero_area=dropped, repaired=repaired)
732
+ rows.append(row)
733
+ done[city] = {"label": label, "checks": checks, "origin": origin, "row": row}
734
+ except Exception as e: # one city failing must not hide the others
735
+ failures[city] = f"{type(e).__name__}: {e}"
736
+ LOG(f"\n[{city}] FAILED: {failures[city]}\n{traceback.format_exc()}")
737
+
738
+ if rows:
739
+ with open(CSV_PATH, "w", newline="", encoding="utf-8") as f:
740
+ writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
741
+ writer.writeheader()
742
+ writer.writerows(rows)
743
+ LOG(f"\ncomparison written: {os.path.relpath(CSV_PATH, PROJECT_ROOT)}")
744
+
745
+ if failures:
746
+ LOG(f"\nSTOPPED: {len(failures)} city/cities failed, so the team project and layouts were NOT built:")
747
+ for city, message in failures.items():
748
+ LOG(f" {city}: {message}")
749
+ else:
750
+ build_project(done, project)
751
+
752
+ hashes_after = {p: sha256(p) for p in PROTECTED}
753
+ changed = [os.path.relpath(p, PROJECT_ROOT) for p in PROTECTED if hashes_after[p] != hashes_before[p]]
754
+ LOG(f"\nPython Task 2 files unchanged: {not changed}" + (f" CHANGED: {changed}" if changed else ""))
755
+
756
+ LOG(f"\n{'=' * 90}\nSUMMARY\n{'=' * 90}")
757
+ LOG(f"{'city':<12}{'QGIS cells':>11}{'Python cells':>13}{'edge':>6}{'slivers':>8}{'ids same':>9}"
758
+ f"{'max area diff m2':>18}{'boundary diff m2':>18} result")
759
+ for r in rows:
760
+ LOG(f"{r['city']:<12}{r['qgis_cells']:>11,}{r['python_cells']:>13,}{r['qgis_edge']:>6}{r['qgis_slivers']:>8}"
761
+ f"{'yes' if r['cell_ids_identical'] else 'NO':>9}{r['max_abs_area_diff_m2']:>18.3e}"
762
+ f"{r['boundary_area_diff_m2']:>18.3e} {'MATCH' if r['match'] else 'MISMATCH'}")
763
+ LOG(f"\nalgorithm calls: {len(CALLS)}; IDs used in order (first city): "
764
+ + " -> ".join(dict.fromkeys(c["algorithm"] for c in CALLS if c["city"] == next(iter(CFG["cities"])))))
765
+ ok = not failures and not changed and all(r["match"] for r in rows)
766
+ LOG(f"\nTASK 2 IN QGIS: FINISHED in {(datetime.now() - started).seconds} s - "
767
+ + ("all checks passed, QGIS matches Python" if ok else "SEE PROBLEMS ABOVE"))
768
+
769
+ iface = globals().get("iface")
770
+ if iface is not None and done:
771
+ first = next(iter(done))
772
+ extent = QgsRectangle(read_boundary(boundary_path(first))["bbox"])
773
+ extent.scale(1.1)
774
+ iface.mapCanvas().setExtent(extent)
775
+ iface.mapCanvas().refresh()
776
+ except Exception as e:
777
+ LOG(f"\nSTOPPED: {type(e).__name__}: {e}\n{traceback.format_exc()}")
778
+ finally:
779
+ LOG.close()
780
+
781
+
782
+ main()
task6_experiment.py ADDED
@@ -0,0 +1,634 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task 6: Incomplete reporting removal-experiment scaffold for urban crime analysis.
2
+
3
+ This module provides a reproducible simulation scaffold to evaluate the stability
4
+ of crime hotspot identification under incomplete reporting / underreporting.
5
+
6
+ Key features:
7
+ 1. Accepts a city crime point layer and a 500 m grid, both in EPSG:27700.
8
+ 2. Assigns each point to exactly one grid cell using the full square's lower-left
9
+ coordinates (cell_id: "<x_min>_<y_min>").
10
+ 3. Simulates two forms of incomplete reporting:
11
+ - Uniform removal probability across all crime records.
12
+ - Supplied removal probability for each cell.
13
+ 4. Recounts crimes in every grid cell, explicitly preserving zero-crime cells.
14
+ 5. Identifies the top ceil(10% of cells) before and after removal, breaking count ties
15
+ deterministically by cell_id (ascending), and reports the fraction of original top
16
+ cells retained.
17
+ 6. Validates CRS (EPSG:27700), rates (0.0 to 1.0), grid IDs, and points outside the grid.
18
+ 7. Accepts a random seed for full reproducibility.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import math
26
+ import sys
27
+ from dataclasses import asdict, dataclass
28
+ from pathlib import Path
29
+ from typing import Any, Mapping, Sequence
30
+
31
+ import geopandas as gpd
32
+ import numpy as np
33
+ import pandas as pd
34
+ import pyogrio
35
+ import shapely
36
+ from pyproj import CRS
37
+
38
+ EXPECTED_CRS = "EPSG:27700"
39
+ DEFAULT_CELL_SIZE = 500
40
+ DEFAULT_TOP_FRACTION = 0.10
41
+
42
+
43
+ @dataclass
44
+ class ExperimentResult:
45
+ """Structured result of a Task 6 removal experiment."""
46
+
47
+ fraction_retained: float
48
+ n_top_cells: int
49
+ top_cells_before: list[str]
50
+ top_cells_after: list[str]
51
+ retained_top_cells: list[str]
52
+ lost_top_cells: list[str]
53
+ new_top_cells: list[str]
54
+ counts_before: pd.Series
55
+ counts_after: pd.Series
56
+ initial_crime_count: int
57
+ retained_crime_count: int
58
+ removed_crime_count: int
59
+ removal_type: str
60
+ seed: int | None
61
+ top_fraction: float
62
+ total_grid_cells: int
63
+
64
+ def summary(self) -> dict[str, Any]:
65
+ """Return a serializable dictionary summary of key metrics."""
66
+ return {
67
+ "removal_type": self.removal_type,
68
+ "seed": self.seed,
69
+ "top_fraction": self.top_fraction,
70
+ "total_grid_cells": self.total_grid_cells,
71
+ "n_top_cells": self.n_top_cells,
72
+ "initial_crime_count": self.initial_crime_count,
73
+ "retained_crime_count": self.retained_crime_count,
74
+ "removed_crime_count": self.removed_crime_count,
75
+ "crime_retention_rate": (
76
+ self.retained_crime_count / self.initial_crime_count
77
+ if self.initial_crime_count > 0
78
+ else 1.0
79
+ ),
80
+ "fraction_retained": self.fraction_retained,
81
+ "n_retained_top_cells": len(self.retained_top_cells),
82
+ "n_lost_top_cells": len(self.lost_top_cells),
83
+ "n_new_top_cells": len(self.new_top_cells),
84
+ "top_cells_before_head": self.top_cells_before[:10],
85
+ "top_cells_after_head": self.top_cells_after[:10],
86
+ }
87
+
88
+ def print_summary(self) -> None:
89
+ """Print an interpretable summary to standard output."""
90
+ s = self.summary()
91
+ print("\n" + "=" * 65)
92
+ print("TASK 6 REMOVAL EXPERIMENT SUMMARY")
93
+ print("=" * 65)
94
+ print(f"Removal type: {s['removal_type']}")
95
+ print(f"Random seed: {s['seed']}")
96
+ print(f"Total grid cells: {s['total_grid_cells']:,}")
97
+ print(f"Top cells evaluated (k): {s['n_top_cells']} (ceil {s['top_fraction']*100:.1f}%)")
98
+ print(f"Initial crimes: {s['initial_crime_count']:,}")
99
+ print(f"Retained crimes: {s['retained_crime_count']:,} ({s['crime_retention_rate']*100:.2f}%)")
100
+ print(f"Removed crimes: {s['removed_crime_count']:,}")
101
+ print("-" * 65)
102
+ print(f"Fraction top cells retained: {s['fraction_retained']:.4f} ({s['fraction_retained']*100:.2f}%)")
103
+ print(f"Retained top cells count: {s['n_retained_top_cells']} / {s['n_top_cells']}")
104
+ print(f"Lost top cells count: {s['n_lost_top_cells']}")
105
+ print(f"New entrant top cells count: {s['n_new_top_cells']}")
106
+ print("=" * 65 + "\n")
107
+
108
+
109
+ def validate_crs(gdf: gpd.GeoDataFrame, layer_name: str, expected_epsg: int = 27700) -> None:
110
+ """Validate that the GeoDataFrame has a valid projected CRS matching expected EPSG."""
111
+ if not isinstance(gdf, gpd.GeoDataFrame):
112
+ raise TypeError(f"{layer_name} must be a GeoDataFrame, got {type(gdf).__name__}")
113
+ if gdf.crs is None:
114
+ raise ValueError(f"{layer_name} has no CRS defined; expected EPSG:{expected_epsg}")
115
+
116
+ try:
117
+ crs_obj = CRS.from_user_input(gdf.crs)
118
+ except Exception as e:
119
+ raise ValueError(f"{layer_name} has invalid CRS '{gdf.crs}': {e}") from e
120
+
121
+ epsg = crs_obj.to_epsg()
122
+ if epsg != expected_epsg:
123
+ raise ValueError(
124
+ f"{layer_name} CRS must be EPSG:{expected_epsg}, but got '{crs_obj.to_string()}' (EPSG:{epsg})"
125
+ )
126
+
127
+
128
+ def validate_grid(grid_gdf: gpd.GeoDataFrame, cell_size: int = DEFAULT_CELL_SIZE) -> None:
129
+ """Validate grid structure, unique cell_id values, and format."""
130
+ validate_crs(grid_gdf, "Grid layer")
131
+
132
+ if len(grid_gdf) == 0:
133
+ raise ValueError("Grid layer is empty (0 cells)")
134
+
135
+ if "cell_id" not in grid_gdf.columns:
136
+ raise ValueError("Grid layer must contain a 'cell_id' column")
137
+
138
+ cids = grid_gdf["cell_id"]
139
+ if cids.isna().any():
140
+ raise ValueError("Grid contains null/NaN 'cell_id' values")
141
+
142
+ if not cids.is_unique:
143
+ duplicates = cids[cids.duplicated()].unique().tolist()
144
+ raise ValueError(f"Grid 'cell_id' values must be unique; found duplicate(s): {duplicates[:5]}")
145
+
146
+ # Validate cell_id format "<x_min>_<y_min>" and alignment to cell_size
147
+ malformed = []
148
+ misaligned = []
149
+ for cid in cids:
150
+ parts = str(cid).split("_")
151
+ if len(parts) != 2:
152
+ malformed.append(cid)
153
+ continue
154
+ try:
155
+ x_val, y_val = int(parts[0]), int(parts[1])
156
+ if x_val % cell_size != 0 or y_val % cell_size != 0:
157
+ misaligned.append(cid)
158
+ except ValueError:
159
+ malformed.append(cid)
160
+
161
+ if malformed:
162
+ raise ValueError(f"Found malformed cell_id(s) not in '<x_min>_<y_min>' integer format: {malformed[:5]}")
163
+ if misaligned:
164
+ raise ValueError(
165
+ f"Found cell_id(s) where corner coordinates are not multiples of cell_size {cell_size}: {misaligned[:5]}"
166
+ )
167
+
168
+
169
+ def validate_removal_rate(removal_rate: float) -> float:
170
+ """Validate that a uniform removal rate is a valid probability in [0.0, 1.0]."""
171
+ if isinstance(removal_rate, bool) or not isinstance(removal_rate, (int, float)):
172
+ raise TypeError(f"Removal rate must be a numeric float/int, got {type(removal_rate).__name__}")
173
+ if math.isnan(removal_rate) or math.isinf(removal_rate):
174
+ raise ValueError(f"Removal rate must be a finite number, got {removal_rate}")
175
+ if not (0.0 <= removal_rate <= 1.0):
176
+ raise ValueError(f"Removal rate must be between 0.0 and 1.0 inclusive, got {removal_rate}")
177
+ return float(removal_rate)
178
+
179
+
180
+ def validate_cell_removal_rates(
181
+ cell_rates: Mapping[str, float] | pd.Series,
182
+ grid_cell_ids: Sequence[str] | set[str],
183
+ ) -> dict[str, float]:
184
+ """Validate cell-specific removal rates against the grid's cell IDs."""
185
+ if isinstance(cell_rates, pd.Series):
186
+ rates_dict = cell_rates.to_dict()
187
+ elif isinstance(cell_rates, Mapping):
188
+ rates_dict = dict(cell_rates)
189
+ else:
190
+ raise TypeError(
191
+ f"cell_rates must be a dict or pandas Series, got {type(cell_rates).__name__}"
192
+ )
193
+
194
+ grid_set = set(grid_cell_ids)
195
+ rates_set = set(rates_dict.keys())
196
+
197
+ # Check for invalid rate values
198
+ invalid_rates = {}
199
+ for cid, rate in rates_dict.items():
200
+ try:
201
+ rate_val = validate_removal_rate(rate)
202
+ rates_dict[cid] = rate_val
203
+ except (ValueError, TypeError) as e:
204
+ invalid_rates[cid] = (rate, str(e))
205
+
206
+ if invalid_rates:
207
+ sample = list(invalid_rates.items())[:3]
208
+ raise ValueError(f"Invalid removal rates in cell_rates: {sample}")
209
+
210
+ # Check coverage of grid cells
211
+ missing = grid_set - rates_set
212
+ if missing:
213
+ sample_missing = sorted(list(missing))[:5]
214
+ raise ValueError(
215
+ f"cell_rates is missing {len(missing)} grid cells (e.g., {sample_missing}). "
216
+ "Rates must be supplied for all grid cells."
217
+ )
218
+
219
+ # Check for extraneous cell IDs not present in the grid
220
+ extraneous = rates_set - grid_set
221
+ if extraneous:
222
+ sample_extra = sorted(list(extraneous))[:5]
223
+ raise ValueError(
224
+ f"cell_rates contains {len(extraneous)} cell IDs not present in the grid: {sample_extra}"
225
+ )
226
+
227
+ return rates_dict
228
+
229
+
230
+ def compute_point_cell_id(
231
+ x: float | np.ndarray | Sequence[float],
232
+ y: float | np.ndarray | Sequence[float],
233
+ cell_size: int = DEFAULT_CELL_SIZE,
234
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
235
+ """Assign point coordinates to full square lower-left corners and cell_id.
236
+
237
+ Following conventions.md and Task 2 grid rules:
238
+ x_min = floor(x / cell_size) * cell_size
239
+ y_min = floor(y / cell_size) * cell_size
240
+ cell_id = "<x_min>_<y_min>"
241
+
242
+ Returns (x_min, y_min, cell_id) as numpy arrays.
243
+ """
244
+ x_arr = np.atleast_1d(np.asarray(x, dtype=np.float64))
245
+ y_arr = np.atleast_1d(np.asarray(y, dtype=np.float64))
246
+
247
+ if x_arr.shape != y_arr.shape:
248
+ raise ValueError(f"x and y coordinate arrays must have matching shape, got {x_arr.shape} vs {y_arr.shape}")
249
+
250
+ if np.isnan(x_arr).any() or np.isnan(y_arr).any():
251
+ raise ValueError("Crime point coordinates contain NaN values")
252
+
253
+ # Math floor in integer arithmetic:
254
+ # Uses floor(coord / cell_size) * cell_size
255
+ x_min = np.floor(x_arr / cell_size).astype(np.int64) * cell_size
256
+ y_min = np.floor(y_arr / cell_size).astype(np.int64) * cell_size
257
+
258
+ cell_ids = np.array([f"{xi}_{yi}" for xi, yi in zip(x_min, y_min)], dtype=object)
259
+ return x_min, y_min, cell_ids
260
+
261
+
262
+ # Alias for flexible import naming
263
+ create_point_cell_id = compute_point_cell_id
264
+
265
+
266
+ def assign_points_to_grid(
267
+ points_gdf: gpd.GeoDataFrame,
268
+ grid_gdf: gpd.GeoDataFrame,
269
+ cell_size: int = DEFAULT_CELL_SIZE,
270
+ allow_outside: bool = False,
271
+ ) -> gpd.GeoDataFrame:
272
+ """Assign each crime point to exactly one grid cell.
273
+
274
+ Validates CRS, computes the full square's lower-left corner, and checks
275
+ that all points fall inside the city grid cells.
276
+
277
+ If allow_outside is False and any point falls outside the grid, raises ValueError.
278
+ If allow_outside is True, outside points are dropped from the returned GeoDataFrame.
279
+ """
280
+ validate_crs(points_gdf, "Crime points layer")
281
+ validate_grid(grid_gdf, cell_size)
282
+
283
+ if len(points_gdf) == 0:
284
+ assigned = points_gdf.copy()
285
+ assigned["x_min"] = pd.Series(dtype=np.int64)
286
+ assigned["y_min"] = pd.Series(dtype=np.int64)
287
+ assigned["cell_id"] = pd.Series(dtype=str)
288
+ return assigned
289
+
290
+ # Validate geometries are valid Points
291
+ if points_gdf.geometry.isna().any() or points_gdf.geometry.is_empty.any():
292
+ raise ValueError("Crime points layer contains null or empty geometries")
293
+
294
+ geom_types = points_gdf.geometry.geom_type.unique()
295
+ if any(t != "Point" for t in geom_types):
296
+ raise ValueError(f"Crime layer must contain Point geometries only, found {geom_types}")
297
+
298
+ x_coords = points_gdf.geometry.x.to_numpy()
299
+ y_coords = points_gdf.geometry.y.to_numpy()
300
+ point_geoms = points_gdf.geometry.to_numpy()
301
+
302
+ x_min, y_min, cids = compute_point_cell_id(x_coords, y_coords, cell_size)
303
+
304
+ grid_geom_map = dict(zip(grid_gdf["cell_id"].astype(str), grid_gdf.geometry))
305
+
306
+ # 1. Check if the computed cell_id exists in the grid
307
+ in_grid_mask = np.array([cid in grid_geom_map for cid in cids], dtype=bool)
308
+
309
+ # 2. For points whose cell_id exists in the grid, verify intersection with the clipped cell geometry
310
+ intersects_mask = np.zeros(len(points_gdf), dtype=bool)
311
+ if in_grid_mask.any():
312
+ valid_indices = np.where(in_grid_mask)[0]
313
+ valid_point_geoms = point_geoms[valid_indices]
314
+ valid_cell_geoms = [grid_geom_map[cids[i]] for i in valid_indices]
315
+ intersects_mask[valid_indices] = shapely.intersects(
316
+ valid_point_geoms,
317
+ np.array(valid_cell_geoms, dtype=object),
318
+ )
319
+
320
+ # A point is truly valid if it maps to a grid cell AND intersects its clipped geometry
321
+ valid_mask = in_grid_mask & intersects_mask
322
+ n_outside = int((~valid_mask).sum())
323
+
324
+ if n_outside > 0:
325
+ n_outside_grid = int((~in_grid_mask).sum())
326
+ n_outside_clipped = int((in_grid_mask & ~intersects_mask).sum())
327
+ outside_sample_cids = cids[~valid_mask][:5].tolist()
328
+ msg = (
329
+ f"Found {n_outside} crime point(s) outside the city grid "
330
+ f"({n_outside_grid} outside grid boundaries, {n_outside_clipped} in cell square but outside clipped geometry; "
331
+ f"sample cell_ids: {outside_sample_cids})."
332
+ )
333
+ if not allow_outside:
334
+ raise ValueError(
335
+ f"{msg} Set allow_outside=True if you wish to drop points outside the city grid."
336
+ )
337
+
338
+ assigned = points_gdf.copy()
339
+ assigned["x_min"] = x_min
340
+ assigned["y_min"] = y_min
341
+ assigned["cell_id"] = cids.astype(str)
342
+
343
+ if n_outside > 0 and allow_outside:
344
+ assigned = assigned[valid_mask].copy()
345
+
346
+ return assigned
347
+
348
+
349
+ def recount_crimes_per_cell(
350
+ grid_gdf: gpd.GeoDataFrame,
351
+ assigned_points_gdf: gpd.GeoDataFrame,
352
+ ) -> pd.Series:
353
+ """Recount crimes in every grid cell, including cells with zero crimes.
354
+
355
+ Returns a pandas Series indexed by cell_id (in the grid's original row order),
356
+ with integer counts >= 0 for every cell.
357
+ """
358
+ grid_cids = grid_gdf["cell_id"].astype(str).tolist()
359
+ counts = pd.Series(0, index=pd.Index(grid_cids, name="cell_id"), dtype=np.int64)
360
+
361
+ if len(assigned_points_gdf) > 0 and "cell_id" in assigned_points_gdf.columns:
362
+ point_counts = assigned_points_gdf["cell_id"].astype(str).value_counts()
363
+ counts = counts.add(point_counts, fill_value=0).astype(np.int64)
364
+
365
+ return counts
366
+
367
+
368
+ def select_top_cells(
369
+ counts: pd.Series,
370
+ top_fraction: float = DEFAULT_TOP_FRACTION,
371
+ ) -> list[str]:
372
+ """Select the top ceil(top_fraction * total_cells) cells.
373
+
374
+ Ties in crime count are broken deterministically by cell_id in ascending order.
375
+ Returns a list of the top k cell_id strings.
376
+ """
377
+ if len(counts) == 0:
378
+ return []
379
+
380
+ if not (0.0 < top_fraction <= 1.0):
381
+ raise ValueError(f"top_fraction must be in (0.0, 1.0], got {top_fraction}")
382
+
383
+ total_cells = len(counts)
384
+ k = math.ceil(top_fraction * total_cells)
385
+ k = min(k, total_cells)
386
+
387
+ df = pd.DataFrame({
388
+ "cell_id": counts.index.astype(str),
389
+ "count": counts.values,
390
+ })
391
+
392
+ # Sort descending by count, then ascending by cell_id for deterministic tie-breaking
393
+ sorted_df = df.sort_values(
394
+ by=["count", "cell_id"],
395
+ ascending=[False, True],
396
+ kind="mergesort", # stable sort
397
+ )
398
+
399
+ return sorted_df.head(k)["cell_id"].tolist()
400
+
401
+
402
+ def calculate_retention_fraction(
403
+ top_cells_before: Sequence[str],
404
+ top_cells_after: Sequence[str],
405
+ ) -> float:
406
+ """Calculate the fraction of original top cells retained after removal."""
407
+ if len(top_cells_before) == 0:
408
+ return 1.0
409
+
410
+ orig_set = set(top_cells_before)
411
+ post_set = set(top_cells_after)
412
+ retained_set = orig_set.intersection(post_set)
413
+
414
+ return len(retained_set) / len(orig_set)
415
+
416
+
417
+ def simulate_uniform_removal(
418
+ points_gdf: gpd.GeoDataFrame,
419
+ removal_rate: float,
420
+ seed: int | None = None,
421
+ ) -> gpd.GeoDataFrame:
422
+ """Simulate incomplete reporting with uniform removal probability per crime record."""
423
+ rate = validate_removal_rate(removal_rate)
424
+
425
+ if len(points_gdf) == 0:
426
+ return points_gdf.copy()
427
+
428
+ if rate == 0.0:
429
+ return points_gdf.copy()
430
+ if rate == 1.0:
431
+ return points_gdf.iloc[0:0].copy()
432
+
433
+ rng = np.random.default_rng(seed)
434
+ # Draw uniform random values u in [0, 1)
435
+ # A record is removed if u < rate; retained if u >= rate
436
+ u = rng.uniform(0.0, 1.0, size=len(points_gdf))
437
+ retained_mask = u >= rate
438
+ return points_gdf[retained_mask].copy()
439
+
440
+
441
+ def simulate_cell_removal(
442
+ points_gdf: gpd.GeoDataFrame,
443
+ cell_rates: Mapping[str, float] | pd.Series,
444
+ seed: int | None = None,
445
+ ) -> gpd.GeoDataFrame:
446
+ """Simulate incomplete reporting with cell-specific removal probability."""
447
+ if len(points_gdf) == 0:
448
+ return points_gdf.copy()
449
+
450
+ if "cell_id" not in points_gdf.columns:
451
+ raise ValueError("points_gdf must have 'cell_id' column assigned before cell removal")
452
+
453
+ # Map each crime point's cell_id to its removal probability
454
+ point_cids = points_gdf["cell_id"].astype(str)
455
+ probs = point_cids.map(cell_rates).to_numpy(dtype=np.float64)
456
+
457
+ if np.isnan(probs).any():
458
+ missing_cids = point_cids[np.isnan(probs)].unique().tolist()
459
+ raise ValueError(f"Some points belong to cells missing from cell_rates: {missing_cids[:5]}")
460
+
461
+ rng = np.random.default_rng(seed)
462
+ u = rng.uniform(0.0, 1.0, size=len(points_gdf))
463
+ retained_mask = u >= probs
464
+ return points_gdf[retained_mask].copy()
465
+
466
+
467
+ def run_removal_experiment(
468
+ points: gpd.GeoDataFrame | str | Path,
469
+ grid: gpd.GeoDataFrame | str | Path,
470
+ removal: float | Mapping[str, float] | pd.Series,
471
+ *,
472
+ seed: int | None = None,
473
+ cell_size: int = DEFAULT_CELL_SIZE,
474
+ top_fraction: float = DEFAULT_TOP_FRACTION,
475
+ allow_outside: bool = False,
476
+ ) -> ExperimentResult:
477
+ """Run a complete Task 6 removal experiment.
478
+
479
+ Parameters
480
+ ----------
481
+ points : GeoDataFrame or path to GPKG/spatial file
482
+ City crime point layer in EPSG:27700.
483
+ grid : GeoDataFrame or path to GPKG/spatial file
484
+ City grid layer (e.g. 500 m) in EPSG:27700 with 'cell_id'.
485
+ removal : float or Mapping/Series
486
+ Either a uniform removal probability (0.0 to 1.0) or
487
+ a mapping of cell_id -> removal probability for each cell.
488
+ seed : int or None, optional
489
+ Random seed for reproducibility.
490
+ cell_size : int, optional
491
+ Cell dimension in metres (default 500).
492
+ top_fraction : float, optional
493
+ Fraction of top hotspot cells to evaluate (default 0.10).
494
+ allow_outside : bool, optional
495
+ Whether to permit and drop points outside the city grid (default False).
496
+
497
+ Returns
498
+ -------
499
+ ExperimentResult
500
+ Structured experiment result including counts, top cells, and retention fraction.
501
+ """
502
+ # Load layers if paths were provided
503
+ if isinstance(points, (str, Path)):
504
+ points_gdf = gpd.read_file(points, engine="pyogrio")
505
+ else:
506
+ points_gdf = points
507
+
508
+ if isinstance(grid, (str, Path)):
509
+ grid_gdf = gpd.read_file(grid, engine="pyogrio")
510
+ else:
511
+ grid_gdf = grid
512
+
513
+ validate_crs(points_gdf, "Crime points layer")
514
+ validate_grid(grid_gdf, cell_size)
515
+
516
+ # 1. Assign points to grid cells
517
+ assigned_points = assign_points_to_grid(
518
+ points_gdf,
519
+ grid_gdf,
520
+ cell_size=cell_size,
521
+ allow_outside=allow_outside,
522
+ )
523
+
524
+ # 2. Recount initial crimes per cell (including zero-crime cells)
525
+ counts_before = recount_crimes_per_cell(grid_gdf, assigned_points)
526
+
527
+ # 3. Select top ceil(top_fraction * total_cells) before removal
528
+ top_before = select_top_cells(counts_before, top_fraction=top_fraction)
529
+
530
+ # 4. Simulate removal
531
+ if isinstance(removal, (int, float)) and not isinstance(removal, bool):
532
+ removal_type = "uniform"
533
+ validate_removal_rate(removal)
534
+ retained_points = simulate_uniform_removal(assigned_points, removal, seed=seed)
535
+ elif isinstance(removal, (Mapping, pd.Series)):
536
+ removal_type = "cell_specific"
537
+ validated_rates = validate_cell_removal_rates(removal, grid_gdf["cell_id"].tolist())
538
+ retained_points = simulate_cell_removal(assigned_points, validated_rates, seed=seed)
539
+ else:
540
+ raise TypeError(
541
+ f"removal must be float (uniform) or dict/Series (cell-specific), got {type(removal).__name__}"
542
+ )
543
+
544
+ # 5. Recount crimes per cell after removal (including zero-crime cells)
545
+ counts_after = recount_crimes_per_cell(grid_gdf, retained_points)
546
+
547
+ # 6. Select top cells after removal
548
+ top_after = select_top_cells(counts_after, top_fraction=top_fraction)
549
+
550
+ # 7. Compute retention fraction and cell sets
551
+ retention_fraction = calculate_retention_fraction(top_before, top_after)
552
+ set_before = set(top_before)
553
+ set_after = set(top_after)
554
+
555
+ retained_top = [cid for cid in top_before if cid in set_after]
556
+ lost_top = [cid for cid in top_before if cid not in set_after]
557
+ new_top = [cid for cid in top_after if cid not in set_before]
558
+
559
+ return ExperimentResult(
560
+ fraction_retained=retention_fraction,
561
+ n_top_cells=len(top_before),
562
+ top_cells_before=top_before,
563
+ top_cells_after=top_after,
564
+ retained_top_cells=retained_top,
565
+ lost_top_cells=lost_top,
566
+ new_top_cells=new_top,
567
+ counts_before=counts_before,
568
+ counts_after=counts_after,
569
+ initial_crime_count=len(assigned_points),
570
+ retained_crime_count=len(retained_points),
571
+ removed_crime_count=len(assigned_points) - len(retained_points),
572
+ removal_type=removal_type,
573
+ seed=seed,
574
+ top_fraction=top_fraction,
575
+ total_grid_cells=len(grid_gdf),
576
+ )
577
+
578
+
579
+ def main() -> None:
580
+ """Command-line interface for running Task 6 removal experiments."""
581
+ parser = argparse.ArgumentParser(
582
+ description="Run Task 6 removal experiment on a city crime layer and grid."
583
+ )
584
+ parser.add_argument("--crime", type=Path, required=True, help="Path to crime point layer (GPKG)")
585
+ parser.add_argument("--grid", type=Path, required=True, help="Path to city 500m grid (GPKG)")
586
+ parser.add_argument("--uniform-rate", type=float, default=None, help="Uniform removal rate in [0.0, 1.0]")
587
+ parser.add_argument("--cell-rates", type=Path, default=None, help="Path to JSON/CSV with cell_id -> rate")
588
+ parser.add_argument("--seed", type=int, default=42, help="Random seed for reproducibility (default: 42)")
589
+ parser.add_argument("--cell-size", type=int, default=DEFAULT_CELL_SIZE, help="Cell size in metres (default: 500)")
590
+ parser.add_argument("--top-fraction", type=float, default=DEFAULT_TOP_FRACTION, help="Top hotspot fraction (default: 0.10)")
591
+ parser.add_argument("--allow-outside", action="store_true", help="Drop points outside grid instead of failing")
592
+ parser.add_argument("--output-json", type=Path, default=None, help="Optional output JSON path for results")
593
+
594
+ args = parser.parse_args()
595
+
596
+ if args.uniform_rate is None and args.cell_rates is None:
597
+ parser.error("Must specify either --uniform-rate or --cell-rates")
598
+ if args.uniform_rate is not None and args.cell_rates is not None:
599
+ parser.error("Specify either --uniform-rate or --cell-rates, not both")
600
+
601
+ if args.uniform_rate is not None:
602
+ removal = args.uniform_rate
603
+ else:
604
+ rates_path = args.cell_rates
605
+ if rates_path.suffix.lower() == ".json":
606
+ with open(rates_path, "r", encoding="utf-8") as f:
607
+ removal = json.load(f)
608
+ elif rates_path.suffix.lower() == ".csv":
609
+ df_rates = pd.read_csv(rates_path)
610
+ removal = dict(zip(df_rates["cell_id"].astype(str), df_rates["removal_rate"]))
611
+ else:
612
+ parser.error("Cell rates file must be .json or .csv")
613
+
614
+ res = run_removal_experiment(
615
+ points=args.crime,
616
+ grid=args.grid,
617
+ removal=removal,
618
+ seed=args.seed,
619
+ cell_size=args.cell_size,
620
+ top_fraction=args.top_fraction,
621
+ allow_outside=args.allow_outside,
622
+ )
623
+
624
+ res.print_summary()
625
+
626
+ if args.output_json:
627
+ summary_data = res.summary()
628
+ with open(args.output_json, "w", encoding="utf-8") as f:
629
+ json.dump(summary_data, f, indent=2)
630
+ print(f"Results saved to {args.output_json}")
631
+
632
+
633
+ if __name__ == "__main__":
634
+ main()
test_task6_experiment.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic tests for Task 6 removal experiment scaffold.
2
+
3
+ Tests all required edge cases and behaviors:
4
+ 1. Zero removal (retains 100% of crimes and original top cells).
5
+ 2. Complete removal (drops 100% of crimes, recount all 0, deterministic tie-breaking).
6
+ 3. Uneven removal changing the hotspot ranking.
7
+ 4. Boundary coordinate assignment (lower-left half-open interval rule).
8
+ 5. Validation of invalid rates (negative, > 1.0, NaN, missing cell rates).
9
+ 6. Repeatability and determinism with random seed.
10
+ 7. Validation of CRS (must be EPSG:27700).
11
+ 8. Validation of points falling outside the city grid.
12
+ 9. Validation of grid IDs (uniqueness, integer multiples of 500m).
13
+ 10. Explicit recount preserving zero-crime cells.
14
+ 11. Top ceil(10%) selection and deterministic tie-breaking.
15
+ """
16
+
17
+ import math
18
+ import unittest
19
+ from typing import Sequence
20
+
21
+ import geopandas as gpd
22
+ import numpy as np
23
+ import pandas as pd
24
+ import shapely
25
+ from pyproj import CRS
26
+
27
+ from task6_experiment import (
28
+ DEFAULT_CELL_SIZE,
29
+ EXPECTED_CRS,
30
+ assign_points_to_grid,
31
+ compute_point_cell_id,
32
+ recount_crimes_per_cell,
33
+ run_removal_experiment,
34
+ select_top_cells,
35
+ simulate_cell_removal,
36
+ simulate_uniform_removal,
37
+ validate_cell_removal_rates,
38
+ validate_crs,
39
+ validate_grid,
40
+ validate_removal_rate,
41
+ )
42
+
43
+
44
+ def create_synthetic_grid(
45
+ x_coords: Sequence[int],
46
+ y_coords: Sequence[int],
47
+ cell_size: int = DEFAULT_CELL_SIZE,
48
+ crs: str = EXPECTED_CRS,
49
+ ) -> gpd.GeoDataFrame:
50
+ """Helper to generate a synthetic square grid conforming to Task 2 structure."""
51
+ rows = []
52
+ for x in x_coords:
53
+ for y in y_coords:
54
+ cid = f"{x}_{y}"
55
+ poly = shapely.box(x, y, x + cell_size, y + cell_size)
56
+ rows.append({
57
+ "cell_id": cid,
58
+ "x_min": x,
59
+ "y_min": y,
60
+ "cell_size_m": cell_size,
61
+ "area_m2": float(cell_size * cell_size),
62
+ "area_share": 1.0,
63
+ "is_edge": False,
64
+ "city": "synthetic_city",
65
+ "is_sliver": False,
66
+ "geometry": poly,
67
+ })
68
+ return gpd.GeoDataFrame(rows, crs=crs)
69
+
70
+
71
+ def create_synthetic_points(
72
+ coords: Sequence[tuple[float, float]],
73
+ crs: str = EXPECTED_CRS,
74
+ ) -> gpd.GeoDataFrame:
75
+ """Helper to generate a synthetic crime points GeoDataFrame."""
76
+ geoms = [shapely.Point(x, y) for x, y in coords]
77
+ return gpd.GeoDataFrame({"geometry": geoms}, crs=crs)
78
+
79
+
80
+ class TestTask6Experiment(unittest.TestCase):
81
+ """Test suite covering Task 6 removal experiment scaffold."""
82
+
83
+ def setUp(self) -> None:
84
+ """Set up standard synthetic 2x5 grid (10 cells, ceil(10% * 10) = 1 top cell)."""
85
+ # 10 cells in total: xs=[400000, 400500], ys=[500000, 500500, 501000, 501500, 502000]
86
+ self.xs = [400000, 400500]
87
+ self.ys = [500000, 500500, 501000, 501500, 502000]
88
+ self.grid = create_synthetic_grid(self.xs, self.ys)
89
+ self.total_cells = len(self.grid) # 10 cells
90
+
91
+ def test_zero_removal(self) -> None:
92
+ """Requirement: Zero removal (p=0.0) retains all crimes and 100% of top cells."""
93
+ # Place 10 points in cell 400000_500000, 5 points in 400500_500000
94
+ coords = [(400100.0, 500100.0)] * 10 + [(400600.0, 500100.0)] * 5
95
+ points = create_synthetic_points(coords)
96
+
97
+ res = run_removal_experiment(
98
+ points=points,
99
+ grid=self.grid,
100
+ removal=0.0,
101
+ seed=42,
102
+ )
103
+
104
+ self.assertEqual(res.initial_crime_count, 15)
105
+ self.assertEqual(res.retained_crime_count, 15)
106
+ self.assertEqual(res.removed_crime_count, 0)
107
+ self.assertEqual(res.fraction_retained, 1.0)
108
+ self.assertEqual(res.n_top_cells, 1) # ceil(10% of 10) = 1
109
+ self.assertEqual(res.top_cells_before, ["400000_500000"])
110
+ self.assertEqual(res.top_cells_after, ["400000_500000"])
111
+ self.assertEqual(res.lost_top_cells, [])
112
+ self.assertTrue((res.counts_before == res.counts_after).all())
113
+
114
+ def test_complete_removal(self) -> None:
115
+ """Requirement: Complete removal (p=1.0) drops all crimes, recounts all 0, tie-breaks deterministically."""
116
+ coords = [(400100.0, 500100.0)] * 20 + [(400600.0, 502100.0)] * 10
117
+ points = create_synthetic_points(coords)
118
+
119
+ res = run_removal_experiment(
120
+ points=points,
121
+ grid=self.grid,
122
+ removal=1.0,
123
+ seed=42,
124
+ )
125
+
126
+ self.assertEqual(res.initial_crime_count, 30)
127
+ self.assertEqual(res.retained_crime_count, 0)
128
+ self.assertEqual(res.removed_crime_count, 30)
129
+ # All post-removal counts are zero
130
+ self.assertEqual(int(res.counts_after.sum()), 0)
131
+ self.assertTrue((res.counts_after == 0).all())
132
+
133
+ # Prior top cell was 400000_500000 (20 crimes)
134
+ self.assertEqual(res.top_cells_before, ["400000_500000"])
135
+ # Post removal: all cells tied at 0, tie-broken by cell_id ascending:
136
+ # First cell_id alphabetically is "400000_500000"
137
+ self.assertEqual(res.top_cells_after, ["400000_500000"])
138
+ self.assertEqual(res.fraction_retained, 1.0)
139
+
140
+ # Now test where original top cell is NOT the first alphabetically
141
+ coords_alt = [(400500.0, 502100.0)] * 20 # Cell 400500_502000 has all crimes
142
+ points_alt = create_synthetic_points(coords_alt)
143
+ res_alt = run_removal_experiment(
144
+ points=points_alt,
145
+ grid=self.grid,
146
+ removal=1.0,
147
+ seed=42,
148
+ )
149
+ self.assertEqual(res_alt.top_cells_before, ["400500_502000"])
150
+ # All 0 count, first alphabetically is "400000_500000"
151
+ self.assertEqual(res_alt.top_cells_after, ["400000_500000"])
152
+ self.assertEqual(res_alt.fraction_retained, 0.0)
153
+ self.assertEqual(res_alt.lost_top_cells, ["400500_502000"])
154
+ self.assertEqual(res_alt.new_top_cells, ["400000_500000"])
155
+
156
+ def test_uneven_removal_changes_ranking(self) -> None:
157
+ """Requirement: Uneven removal changes cell ranking and reduces retention fraction."""
158
+ # Initial: Cell A (400000_500000) has 50 crimes, Cell B (400500_500000) has 20 crimes.
159
+ # Top 1 cell is Cell A.
160
+ coords = [(400100.0, 500100.0)] * 50 + [(400600.0, 500100.0)] * 20
161
+ points = create_synthetic_points(coords)
162
+
163
+ # Cell A gets 95% removal probability, Cell B gets 0% removal probability.
164
+ # All other cells get 0% removal.
165
+ rates = {cid: 0.0 for cid in self.grid["cell_id"]}
166
+ rates["400000_500000"] = 0.95
167
+ rates["400500_500000"] = 0.0
168
+
169
+ res = run_removal_experiment(
170
+ points=points,
171
+ grid=self.grid,
172
+ removal=rates,
173
+ seed=42,
174
+ )
175
+
176
+ self.assertEqual(res.top_cells_before, ["400000_500000"])
177
+ # Cell B retained all 20 crimes, Cell A retained ~2-3 crimes
178
+ self.assertEqual(res.counts_after["400500_500000"], 20)
179
+ self.assertLess(res.counts_after["400000_500000"], 10)
180
+ # Cell B is now top cell!
181
+ self.assertEqual(res.top_cells_after, ["400500_500000"])
182
+ self.assertEqual(res.fraction_retained, 0.0)
183
+ self.assertEqual(res.lost_top_cells, ["400000_500000"])
184
+ self.assertEqual(res.new_top_cells, ["400500_500000"])
185
+
186
+ def test_boundary_assignment(self) -> None:
187
+ """Requirement: Boundary assignment using lower-left half-open interval rule."""
188
+ # A cell with lower-left (400000, 500000) spans [400000, 400500) x [500000, 500500)
189
+ # Test exact boundary coordinates:
190
+ # 1. Exact lower-left vertex (400000.0, 500000.0) -> must map to 400000_500000
191
+ # 2. Left edge (400000.0, 500250.0) -> must map to 400000_500000
192
+ # 3. Bottom edge (400250.0, 500000.0) -> must map to 400000_500000
193
+ # 4. Interior point (400499.99, 500499.99) -> must map to 400000_500000
194
+ # 5. Right boundary line (400500.0, 500250.0) -> must map to 400500_500000 (next cell east)
195
+ # 6. Top boundary line (400250.0, 500500.0) -> must map to 400000_500500 (next cell north)
196
+ # 7. Top-right vertex (400500.0, 500500.0) -> must map to 400500_500500 (diagonal cell)
197
+
198
+ test_cases = [
199
+ ((400000.0, 500000.0), "400000_500000"),
200
+ ((400000.0, 500250.0), "400000_500000"),
201
+ ((400250.0, 500000.0), "400000_500000"),
202
+ ((400499.99, 500499.99), "400000_500000"),
203
+ ((400500.0, 500250.0), "400500_500000"),
204
+ ((400250.0, 500500.0), "400000_500500"),
205
+ ((400500.0, 500500.0), "400500_500500"),
206
+ ]
207
+
208
+ for (x, y), expected_cid in test_cases:
209
+ x_min, y_min, cid = compute_point_cell_id(x, y, cell_size=500)
210
+ self.assertEqual(cid[0], expected_cid, f"Point ({x}, {y}) mapped to {cid[0]}, expected {expected_cid}")
211
+
212
+ # Check in GeoDataFrame assignment
213
+ points = create_synthetic_points([pt for pt, _ in test_cases])
214
+ assigned = assign_points_to_grid(points, self.grid, cell_size=500)
215
+ self.assertEqual(len(assigned), len(test_cases))
216
+ for i, (_, expected_cid) in enumerate(test_cases):
217
+ self.assertEqual(assigned["cell_id"].iloc[i], expected_cid)
218
+
219
+ def test_invalid_rates(self) -> None:
220
+ """Requirement: Validate removal rates (reject negative, >1.0, non-numeric, incomplete)."""
221
+ # Negative rate
222
+ with self.assertRaises(ValueError):
223
+ validate_removal_rate(-0.1)
224
+
225
+ # Rate > 1.0
226
+ with self.assertRaises(ValueError):
227
+ validate_removal_rate(1.0001)
228
+
229
+ # NaN and inf
230
+ with self.assertRaises(ValueError):
231
+ validate_removal_rate(float("nan"))
232
+ with self.assertRaises(ValueError):
233
+ validate_removal_rate(float("inf"))
234
+
235
+ # Non-numeric
236
+ with self.assertRaises(TypeError):
237
+ validate_removal_rate("0.5") # type: ignore
238
+ with self.assertRaises(TypeError):
239
+ validate_removal_rate(True) # bool is not allowed
240
+
241
+ # Incomplete cell rates dictionary
242
+ partial_rates = {"400000_500000": 0.5} # missing other 9 cells
243
+ with self.assertRaises(ValueError):
244
+ validate_cell_removal_rates(partial_rates, self.grid["cell_id"].tolist())
245
+
246
+ # Cell rates with invalid value
247
+ bad_rates = {cid: 0.1 for cid in self.grid["cell_id"]}
248
+ bad_rates["400000_500000"] = 1.5
249
+ with self.assertRaises(ValueError):
250
+ validate_cell_removal_rates(bad_rates, self.grid["cell_id"].tolist())
251
+
252
+ # Cell rates with extra non-grid cell_id
253
+ extra_rates = {cid: 0.1 for cid in self.grid["cell_id"]}
254
+ extra_rates["999000_999000"] = 0.2
255
+ with self.assertRaises(ValueError):
256
+ validate_cell_removal_rates(extra_rates, self.grid["cell_id"].tolist())
257
+
258
+ def test_repeatability(self) -> None:
259
+ """Requirement: Accept random seed and guarantee reproducible results."""
260
+ # 100 crimes scattered across cells
261
+ rng = np.random.default_rng(999)
262
+ xs = rng.uniform(400000, 401000, size=100)
263
+ ys = rng.uniform(500000, 502500, size=100)
264
+ points = create_synthetic_points(list(zip(xs, ys)))
265
+
266
+ res1 = run_removal_experiment(points=points, grid=self.grid, removal=0.4, seed=12345)
267
+ res2 = run_removal_experiment(points=points, grid=self.grid, removal=0.4, seed=12345)
268
+ res3 = run_removal_experiment(points=points, grid=self.grid, removal=0.4, seed=54321)
269
+
270
+ # Identical seed -> exactly identical results
271
+ self.assertEqual(res1.retained_crime_count, res2.retained_crime_count)
272
+ self.assertEqual(res1.fraction_retained, res2.fraction_retained)
273
+ self.assertEqual(res1.top_cells_after, res2.top_cells_after)
274
+ self.assertTrue((res1.counts_after == res2.counts_after).all())
275
+
276
+ # Different seed -> distinct random simulation draw
277
+ # (With 100 points and p=0.4, exact same retained counts vector across different seed is infinitesimal)
278
+ counts_differ = not (res1.counts_after == res3.counts_after).all()
279
+ self.assertTrue(counts_differ, "Different seeds should produce different random removal draws")
280
+
281
+ def test_crs_validation(self) -> None:
282
+ """Requirement: Validate CRS is EPSG:27700 on both layers."""
283
+ coords = [(400100.0, 500100.0)]
284
+ pts_wgs84 = create_synthetic_points(coords, crs="EPSG:4326")
285
+ grid_27700 = self.grid
286
+
287
+ with self.assertRaises(ValueError) as ctx:
288
+ run_removal_experiment(points=pts_wgs84, grid=grid_27700, removal=0.1)
289
+ self.assertIn("EPSG:27700", str(ctx.exception))
290
+
291
+ # Test missing CRS on grid
292
+ grid_no_crs = grid_27700.set_crs(None, allow_override=True)
293
+ pts_27700 = create_synthetic_points(coords, crs="EPSG:27700")
294
+
295
+ with self.assertRaises(ValueError) as ctx2:
296
+ run_removal_experiment(points=pts_27700, grid=grid_no_crs, removal=0.1)
297
+ self.assertIn("no CRS", str(ctx2.exception))
298
+
299
+ def test_points_outside_city_grid(self) -> None:
300
+ """Requirement: Validate points falling outside the city grid."""
301
+ # Cell outside synthetic grid: 450000, 600000
302
+ outside_coords = [(450100.0, 600100.0), (400100.0, 500100.0)]
303
+ points = create_synthetic_points(outside_coords)
304
+
305
+ # Strict validation (allow_outside=False) must raise ValueError
306
+ with self.assertRaises(ValueError) as ctx:
307
+ run_removal_experiment(points=points, grid=self.grid, removal=0.1, allow_outside=False)
308
+ self.assertIn("outside the city grid", str(ctx.exception))
309
+
310
+ # Permissive validation (allow_outside=True) drops the outside point
311
+ res = run_removal_experiment(points=points, grid=self.grid, removal=0.0, allow_outside=True)
312
+ self.assertEqual(res.initial_crime_count, 1) # Only 1 point inside
313
+
314
+ def test_grid_id_validation(self) -> None:
315
+ """Requirement: Validate grid IDs (uniqueness, correct lower-left format)."""
316
+ # Duplicate cell_id
317
+ bad_grid = self.grid.copy()
318
+ bad_grid.loc[1, "cell_id"] = bad_grid.loc[0, "cell_id"]
319
+ with self.assertRaises(ValueError) as ctx:
320
+ validate_grid(bad_grid)
321
+ self.assertIn("unique", str(ctx.exception))
322
+
323
+ # Malformed cell_id
324
+ bad_grid2 = self.grid.copy()
325
+ bad_grid2.loc[0, "cell_id"] = "cell_one"
326
+ with self.assertRaises(ValueError) as ctx2:
327
+ validate_grid(bad_grid2)
328
+ self.assertIn("malformed", str(ctx2.exception))
329
+
330
+ # Misaligned cell_id (not multiple of 500)
331
+ bad_grid3 = self.grid.copy()
332
+ bad_grid3.loc[0, "cell_id"] = "400123_500000"
333
+ with self.assertRaises(ValueError) as ctx3:
334
+ validate_grid(bad_grid3)
335
+ self.assertIn("multiples of cell_size", str(ctx3.exception))
336
+
337
+ def test_recount_includes_zero_crime_cells(self) -> None:
338
+ """Requirement: Recount crimes in every grid cell, including cells with zero crimes."""
339
+ # Only put crimes in 1 of the 10 cells
340
+ coords = [(400100.0, 500100.0)] * 5
341
+ points = create_synthetic_points(coords)
342
+
343
+ counts = recount_crimes_per_cell(self.grid, assign_points_to_grid(points, self.grid))
344
+
345
+ # All 10 cells must be present in the recount series
346
+ self.assertEqual(len(counts), 10)
347
+ self.assertEqual(counts["400000_500000"], 5)
348
+ # Remaining 9 cells must be exactly 0
349
+ zero_cells = counts[counts == 0]
350
+ self.assertEqual(len(zero_cells), 9)
351
+
352
+ def test_top_cells_selection_and_deterministic_ties(self) -> None:
353
+ """Requirement: Top ceil(10% of cells) selection and deterministic tie-breaking by cell_id."""
354
+ # Create grid with 15 cells -> ceil(0.10 * 15) = ceil(1.5) = 2 top cells
355
+ xs = [400000, 400500, 401000]
356
+ ys = [500000, 500500, 501000, 501500, 502000]
357
+ grid15 = create_synthetic_grid(xs, ys)
358
+ self.assertEqual(len(grid15), 15)
359
+
360
+ # All cells have count 0
361
+ counts_zero = pd.Series(0, index=grid15["cell_id"])
362
+ top_2_zero = select_top_cells(counts_zero, top_fraction=0.10)
363
+ self.assertEqual(len(top_2_zero), 2)
364
+ # Tied at 0, must pick lowest cell_id alphabetically
365
+ expected_sorted = sorted(grid15["cell_id"])[:2]
366
+ self.assertEqual(top_2_zero, expected_sorted)
367
+
368
+ # Now set cell counts: one clear top cell (count 10), and three tied cells with count 5
369
+ counts_custom = counts_zero.copy()
370
+ counts_custom["401000_502000"] = 10 # Highest
371
+ # Tied at 5:
372
+ counts_custom["400500_501000"] = 5
373
+ counts_custom["400000_502000"] = 5
374
+ counts_custom["400500_500500"] = 5
375
+
376
+ # We want top 2: #1 is 401000_502000 (10).
377
+ # #2 must be chosen from the three tied at 5 by cell_id ascending:
378
+ # "400000_502000" vs "400500_500500" vs "400500_501000"
379
+ # Alphabetically, "400000_502000" < "400500_500500" < "400500_501000".
380
+ # So top 2 must be ["401000_502000", "400000_502000"]
381
+ top_2_custom = select_top_cells(counts_custom, top_fraction=0.10)
382
+ self.assertEqual(top_2_custom, ["401000_502000", "400000_502000"])
383
+
384
+ def test_partially_clipped_edge_cell_validation(self) -> None:
385
+ """Requirement: Point in full 500m square but outside clipped city polygon must be rejected or dropped.
386
+
387
+ Verifies that:
388
+ 1. A point inside the clipped geometry of an edge cell is accepted.
389
+ 2. A point inside the full 500m square of that same cell, but outside the clipped polygon,
390
+ is rejected with ValueError when allow_outside=False.
391
+ 3. The outside point is counted and dropped when allow_outside=True.
392
+ """
393
+ # Create a synthetic grid with 1 full cell and 1 partially clipped edge cell
394
+ # Cell 1 (full cell): [400500, 401000] x [500000, 500500]
395
+ # Cell 2 (edge cell): [400000, 400500] x [500000, 500500], but clipped to y <= 500250 (bottom half)
396
+ edge_poly = shapely.box(400000.0, 500000.0, 400500.0, 500250.0)
397
+ full_poly = shapely.box(400500.0, 500000.0, 401000.0, 500500.0)
398
+
399
+ grid_df = gpd.GeoDataFrame(
400
+ [
401
+ {
402
+ "cell_id": "400000_500000",
403
+ "x_min": 400000,
404
+ "y_min": 500000,
405
+ "cell_size_m": 500,
406
+ "area_m2": 125000.0,
407
+ "area_share": 0.5,
408
+ "is_edge": True,
409
+ "city": "synthetic_city",
410
+ "is_sliver": False,
411
+ "geometry": edge_poly,
412
+ },
413
+ {
414
+ "cell_id": "400500_500000",
415
+ "x_min": 400500,
416
+ "y_min": 500000,
417
+ "cell_size_m": 500,
418
+ "area_m2": 250000.0,
419
+ "area_share": 1.0,
420
+ "is_edge": False,
421
+ "city": "synthetic_city",
422
+ "is_sliver": False,
423
+ "geometry": full_poly,
424
+ },
425
+ ],
426
+ crs=EXPECTED_CRS,
427
+ )
428
+
429
+ # Point inside the clipped polygon of the edge cell
430
+ pt_inside_edge = (400250.0, 500100.0)
431
+ # Point in the full 500m square of the edge cell, but in the unclipped area (outside clipped polygon)
432
+ pt_outside_edge = (400250.0, 500400.0)
433
+
434
+ pts = create_synthetic_points([pt_inside_edge, pt_outside_edge])
435
+
436
+ # 1. When allow_outside=False: must raise ValueError detailing the outside clipped point
437
+ with self.assertRaises(ValueError) as ctx:
438
+ assign_points_to_grid(pts, grid_df, cell_size=500, allow_outside=False)
439
+ self.assertIn("outside clipped geometry", str(ctx.exception))
440
+
441
+ # 2. When allow_outside=True: must drop the outside point and keep the inside point
442
+ assigned = assign_points_to_grid(pts, grid_df, cell_size=500, allow_outside=True)
443
+ self.assertEqual(len(assigned), 1)
444
+ self.assertEqual(assigned["cell_id"].iloc[0], "400000_500000")
445
+ self.assertEqual(assigned.geometry.iloc[0].x, pt_inside_edge[0])
446
+ self.assertEqual(assigned.geometry.iloc[0].y, pt_inside_edge[1])
447
+
448
+ # 3. Test through run_removal_experiment
449
+ res = run_removal_experiment(
450
+ points=pts,
451
+ grid=grid_df,
452
+ removal=0.0,
453
+ allow_outside=True,
454
+ top_fraction=0.50,
455
+ )
456
+ self.assertEqual(res.initial_crime_count, 1)
457
+ self.assertEqual(res.counts_before["400000_500000"], 1)
458
+ self.assertEqual(res.counts_before["400500_500000"], 0)
459
+
460
+
461
+ if __name__ == "__main__":
462
+ unittest.main()