Upload 16 files
#1
by LalithMukund - opened
- .gitattributes +1 -0
- README.md +173 -0
- TASK2_REPORT.html +0 -0
- Task 2 Boundaries and Grids.pdf +3 -0
- __init__.py +1 -0
- boundaries.py +133 -0
- config.json +27 -0
- conventions.md +120 -0
- geomutils.py +53 -0
- grid.py +222 -0
- requirements.txt +18 -0
- task1_crime.py +114 -0
- task2_boundaries_grids.py +225 -0
- task2_log.txt +144 -0
- task2_qgis.py +782 -0
- task6_experiment.py +634 -0
- test_task6_experiment.py +462 -0
.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()
|