hexseg 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hexseg-0.1.0/LICENSE +21 -0
- hexseg-0.1.0/PKG-INFO +20 -0
- hexseg-0.1.0/README.md +2 -0
- hexseg-0.1.0/pyproject.toml +26 -0
- hexseg-0.1.0/setup.cfg +4 -0
- hexseg-0.1.0/src/hexseg/__init__.py +11 -0
- hexseg-0.1.0/src/hexseg/core.py +687 -0
- hexseg-0.1.0/src/hexseg.egg-info/PKG-INFO +20 -0
- hexseg-0.1.0/src/hexseg.egg-info/SOURCES.txt +11 -0
- hexseg-0.1.0/src/hexseg.egg-info/dependency_links.txt +1 -0
- hexseg-0.1.0/src/hexseg.egg-info/requires.txt +8 -0
- hexseg-0.1.0/src/hexseg.egg-info/top_level.txt +1 -0
- hexseg-0.1.0/tests/test_core.py +13 -0
hexseg-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 routineactivity
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
hexseg-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hexseg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spatial crime segmentation utilities
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pandas
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: geopandas
|
|
12
|
+
Requires-Dist: h3
|
|
13
|
+
Requires-Dist: shapely
|
|
14
|
+
Requires-Dist: scikit-learn
|
|
15
|
+
Requires-Dist: networkx
|
|
16
|
+
Requires-Dist: folium
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# hexseg
|
|
20
|
+
Spatial functions for analysing crime by hexagons and street segments
|
hexseg-0.1.0/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=65.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hexseg"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Spatial crime segmentation utilities"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [ { name="Your Name", email="you@example.com" } ]
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pandas",
|
|
14
|
+
"numpy",
|
|
15
|
+
"geopandas",
|
|
16
|
+
"h3",
|
|
17
|
+
"shapely",
|
|
18
|
+
"scikit-learn",
|
|
19
|
+
"networkx",
|
|
20
|
+
"folium"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
include = ["hexseg*"]
|
|
26
|
+
exclude = ["data*", "notebooks*"]
|
hexseg-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
import math
|
|
4
|
+
import geopandas as gpd
|
|
5
|
+
import h3
|
|
6
|
+
from shapely.geometry import Polygon, MultiPolygon
|
|
7
|
+
from shapely.ops import unary_union
|
|
8
|
+
from sklearn.neighbors import NearestNeighbors
|
|
9
|
+
import networkx as nx
|
|
10
|
+
import folium
|
|
11
|
+
|
|
12
|
+
################
|
|
13
|
+
# Troubleshoot #
|
|
14
|
+
################
|
|
15
|
+
|
|
16
|
+
## using this space to test and troubleshoot functions
|
|
17
|
+
|
|
18
|
+
################
|
|
19
|
+
## Function 1 ##
|
|
20
|
+
################
|
|
21
|
+
|
|
22
|
+
def get_hexagons(gdf_polygons: gpd.GeoDataFrame,
|
|
23
|
+
name_col: str,
|
|
24
|
+
resolution: int = 9) -> gpd.GeoDataFrame:
|
|
25
|
+
"""
|
|
26
|
+
For each polygon in `gdf_polygons`, generate all H3 hexagons at `resolution`,
|
|
27
|
+
then assign each hex to the polygon with which it has the largest intersection.
|
|
28
|
+
Returns a GeoDataFrame with columns ['hex_id', 'geo_boundary', 'geometry']
|
|
29
|
+
in the same CRS as `gdf_polygons`.
|
|
30
|
+
|
|
31
|
+
Parameters:
|
|
32
|
+
----------
|
|
33
|
+
gdf_polygons : GeoDataFrame
|
|
34
|
+
|
|
35
|
+
name_col : str
|
|
36
|
+
Police force, district or other geography name
|
|
37
|
+
resolution : int
|
|
38
|
+
Uber hexagon resolution, see: https://h3geo.org/docs/3.x/core-library/restable/
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
--------
|
|
42
|
+
hexes = get_hexagons(gdf_districts, name_col="lad21nm", resolution=9)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Validate and reproject to WGS84 for H3
|
|
46
|
+
assert gdf_polygons.crs, "Input must have a valid CRS"
|
|
47
|
+
wgs = gdf_polygons.to_crs("EPSG:4326")
|
|
48
|
+
|
|
49
|
+
records = []
|
|
50
|
+
# Loop over features, handle Polygons & MultiPolygons
|
|
51
|
+
for _, row in wgs.iterrows():
|
|
52
|
+
geom = row.geometry
|
|
53
|
+
boundary_name = row[name_col]
|
|
54
|
+
parts = geom.geoms if isinstance(geom, MultiPolygon) else [geom]
|
|
55
|
+
|
|
56
|
+
# collect all hex IDs
|
|
57
|
+
hex_ids = set()
|
|
58
|
+
for part in parts:
|
|
59
|
+
geoif = part.__geo_interface__
|
|
60
|
+
if hasattr(h3, "geo_to_cells"):
|
|
61
|
+
ids = h3.geo_to_cells(geoif, resolution)
|
|
62
|
+
elif hasattr(h3, "polygon_to_cells"):
|
|
63
|
+
ids = h3.polygon_to_cells(geoif, resolution, geo_json_conformant=True)
|
|
64
|
+
else:
|
|
65
|
+
ids = h3.polyfill(geoif, resolution, geo_json_conformant=True)
|
|
66
|
+
hex_ids.update(ids)
|
|
67
|
+
|
|
68
|
+
# build polygons: **always** use lat/lon tuples flipped to lon/lat
|
|
69
|
+
#for h in hex_ids:
|
|
70
|
+
# # this returns a list of (lat, lon) tuples
|
|
71
|
+
# coords = h3.h3_to_geo_boundary(h)
|
|
72
|
+
# # flip to (x, y) = (lon, lat)
|
|
73
|
+
# pts = [(lng, lat) for lat, lng in coords]
|
|
74
|
+
# records.append((h, boundary_name, Polygon(pts)))
|
|
75
|
+
|
|
76
|
+
for h in hex_ids:
|
|
77
|
+
# get the hex boundary as a list of (lat, lon)
|
|
78
|
+
if hasattr(h3, "h3_to_geo_boundary"):
|
|
79
|
+
coords = h3.h3_to_geo_boundary(h)
|
|
80
|
+
elif hasattr(h3, "cell_to_boundary"):
|
|
81
|
+
coords = h3.cell_to_boundary(h)
|
|
82
|
+
else:
|
|
83
|
+
raise AttributeError("h3 module has no cell boundary function")
|
|
84
|
+
|
|
85
|
+
pts = [(lng, lat) for lat, lng in coords]
|
|
86
|
+
records.append((h, boundary_name, Polygon(pts)))
|
|
87
|
+
|
|
88
|
+
# Assemble and reproject back to the original CRS
|
|
89
|
+
out = (
|
|
90
|
+
gpd.GeoDataFrame(
|
|
91
|
+
records,
|
|
92
|
+
columns=["hex_id", "geo_boundary", "geometry"],
|
|
93
|
+
crs="EPSG:4326"
|
|
94
|
+
)
|
|
95
|
+
.to_crs(gdf_polygons.crs)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Spatial‐join purely to get index_right for overlap area
|
|
99
|
+
join_polys = gdf_polygons[["geometry"]]
|
|
100
|
+
joined = gpd.sjoin(out, join_polys, how="left", predicate="intersects")
|
|
101
|
+
|
|
102
|
+
# Map back to original polygon geometry and compute overlap
|
|
103
|
+
joined["poly_geom"] = joined["index_right"].map(gdf_polygons.geometry)
|
|
104
|
+
joined["overlap"] = joined.geometry.intersection(joined["poly_geom"]).area
|
|
105
|
+
|
|
106
|
+
# Pick the best overlap per hex_id
|
|
107
|
+
best = (
|
|
108
|
+
joined
|
|
109
|
+
.sort_values("overlap", ascending=False)
|
|
110
|
+
.drop_duplicates("hex_id")
|
|
111
|
+
.loc[:, ["hex_id", "geo_boundary", "geometry"]]
|
|
112
|
+
.reset_index(drop=True)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Return as GeoDataFrame in original CRS
|
|
116
|
+
return gpd.GeoDataFrame(best, geometry="geometry", crs=gdf_polygons.crs)
|
|
117
|
+
|
|
118
|
+
################
|
|
119
|
+
## Function 2 ##
|
|
120
|
+
################
|
|
121
|
+
|
|
122
|
+
def summarise_by_hex(hexes_gdf: gpd.GeoDataFrame,
|
|
123
|
+
crimes_gdf: gpd.GeoDataFrame,
|
|
124
|
+
count_col: str = None,
|
|
125
|
+
weight_col: str = None) -> gpd.GeoDataFrame:
|
|
126
|
+
"""
|
|
127
|
+
Spatially join crime points to H3 hexagons and summarise by hex.
|
|
128
|
+
|
|
129
|
+
Parameters:
|
|
130
|
+
----------
|
|
131
|
+
hexes_gdf : GeoDataFrame
|
|
132
|
+
Must contain 'hex_id' and geometry.
|
|
133
|
+
crimes_gdf : GeoDataFrame
|
|
134
|
+
Point GeoDataFrame. If CRS differs, it will be reprojected.
|
|
135
|
+
count_col : str, optional
|
|
136
|
+
If not None, counts all points in each hex (column name is ignored).
|
|
137
|
+
Outputs 'crime_count'.
|
|
138
|
+
weight_col : str, optional
|
|
139
|
+
If not None, sums this field for points in each hex.
|
|
140
|
+
Outputs 'crime_weight'.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
-------
|
|
144
|
+
GeoDataFrame
|
|
145
|
+
Copy of hexes_gdf with added 'crime_count' and/or 'crime_weight'.
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
--------
|
|
149
|
+
hex_both = summarise_by_hex(
|
|
150
|
+
hexes_gdf=hexes,
|
|
151
|
+
crimes_gdf=gdf_crimes,
|
|
152
|
+
count_col='any',
|
|
153
|
+
weight_col='pseudo_harm'
|
|
154
|
+
)
|
|
155
|
+
"""
|
|
156
|
+
# Ensure both are in the same CRS
|
|
157
|
+
if crimes_gdf.crs != hexes_gdf.crs:
|
|
158
|
+
crimes = crimes_gdf.to_crs(hexes_gdf.crs)
|
|
159
|
+
else:
|
|
160
|
+
crimes = crimes_gdf
|
|
161
|
+
|
|
162
|
+
# Spatial join points to hexes (brings in hex_id on each crime)
|
|
163
|
+
joined = gpd.sjoin(
|
|
164
|
+
crimes,
|
|
165
|
+
hexes_gdf[['hex_id', 'geometry']],
|
|
166
|
+
how='inner',
|
|
167
|
+
predicate='within'
|
|
168
|
+
)
|
|
169
|
+
# joined now has a 'hex_id' column for each crime
|
|
170
|
+
|
|
171
|
+
# Prepare output
|
|
172
|
+
out = hexes_gdf.copy()
|
|
173
|
+
|
|
174
|
+
# Count crimes if requested
|
|
175
|
+
if count_col is not None:
|
|
176
|
+
counts = (
|
|
177
|
+
joined
|
|
178
|
+
.groupby('hex_id')
|
|
179
|
+
.size()
|
|
180
|
+
.rename('crime_count')
|
|
181
|
+
)
|
|
182
|
+
out = out.merge(counts, on='hex_id', how='left')
|
|
183
|
+
out['crime_count'] = out['crime_count'].fillna(0).astype(int)
|
|
184
|
+
|
|
185
|
+
# Sum weights if requested
|
|
186
|
+
if weight_col is not None:
|
|
187
|
+
if weight_col not in crimes.columns:
|
|
188
|
+
raise KeyError(f"Weight column '{weight_col}' not found in crimes_gdf")
|
|
189
|
+
weights = (
|
|
190
|
+
joined
|
|
191
|
+
.groupby('hex_id')[weight_col]
|
|
192
|
+
.sum()
|
|
193
|
+
.rename('crime_weight')
|
|
194
|
+
)
|
|
195
|
+
out = out.merge(weights, on='hex_id', how='left')
|
|
196
|
+
out['crime_weight'] = out['crime_weight'].fillna(0)
|
|
197
|
+
|
|
198
|
+
# Require at least one
|
|
199
|
+
if count_col is None and weight_col is None:
|
|
200
|
+
raise ValueError("Must pass at least one of count_col or weight_col")
|
|
201
|
+
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
################
|
|
205
|
+
## Function 3 ##
|
|
206
|
+
################
|
|
207
|
+
|
|
208
|
+
def add_spatial_lag(hexes_gdf: gpd.GeoDataFrame,
|
|
209
|
+
count_col: str = None,
|
|
210
|
+
weight_col: str = None,
|
|
211
|
+
k: int = 6) -> gpd.GeoDataFrame:
|
|
212
|
+
"""
|
|
213
|
+
Given a GeoDataFrame of hexagons (with 'hex_id' and geometry),
|
|
214
|
+
computes K-nearest neighbours (by centroid) and adds lag features.
|
|
215
|
+
|
|
216
|
+
Parameters:
|
|
217
|
+
----------
|
|
218
|
+
hexes_gdf : GeoDataFrame
|
|
219
|
+
Must be in a projected CRS (so distances are planar).
|
|
220
|
+
count_col : str, optional
|
|
221
|
+
If provided, name of the integer column to count. Adds:
|
|
222
|
+
- 'lag_sum_count'
|
|
223
|
+
- 'lag_mean_count'
|
|
224
|
+
- 'count_plus_sum'
|
|
225
|
+
- 'count_plus_mean'
|
|
226
|
+
weight_col : str, optional
|
|
227
|
+
If provided, name of the numeric column to sum. Adds:
|
|
228
|
+
- 'lag_sum_weight'
|
|
229
|
+
- 'lag_mean_weight'
|
|
230
|
+
- 'weight_plus_sum_sqrt'
|
|
231
|
+
- 'weight_plus_mean_sqrt'
|
|
232
|
+
k : int
|
|
233
|
+
Number of neighbours (default = 6).
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
-------
|
|
237
|
+
GeoDataFrame
|
|
238
|
+
A copy of `hexes_gdf` with the new lag columns appended.
|
|
239
|
+
|
|
240
|
+
Example:
|
|
241
|
+
-------
|
|
242
|
+
|
|
243
|
+
hex_lagged = add_spatial_lag(
|
|
244
|
+
hexes_gdf=hex_both,
|
|
245
|
+
count_col='crime_count',
|
|
246
|
+
weight_col='crime_weight',
|
|
247
|
+
k=6
|
|
248
|
+
)
|
|
249
|
+
"""
|
|
250
|
+
# Build centroid coordinate array
|
|
251
|
+
pts = np.array([
|
|
252
|
+
(geom.centroid.x, geom.centroid.y)
|
|
253
|
+
for geom in hexes_gdf.geometry
|
|
254
|
+
])
|
|
255
|
+
|
|
256
|
+
# Fit KNN (including self at position 0)
|
|
257
|
+
knn = NearestNeighbors(n_neighbors=k+1, algorithm='auto').fit(pts)
|
|
258
|
+
_, nbrs = knn.kneighbors(pts)
|
|
259
|
+
nbrs = nbrs[:, 1:] # drop self
|
|
260
|
+
|
|
261
|
+
out = hexes_gdf.copy()
|
|
262
|
+
|
|
263
|
+
# Spatial lag for counts
|
|
264
|
+
if count_col:
|
|
265
|
+
counts = out[count_col].to_numpy()
|
|
266
|
+
sum_nb = np.array([counts[ids].sum() for ids in nbrs])
|
|
267
|
+
out['lag_sum_count'] = sum_nb
|
|
268
|
+
out['lag_mean_count'] = sum_nb / k
|
|
269
|
+
out['count_plus_sum'] = out[count_col] + sum_nb
|
|
270
|
+
out['count_plus_mean']= out[count_col] + (sum_nb / k)
|
|
271
|
+
|
|
272
|
+
# Spatial lag for weights
|
|
273
|
+
if weight_col:
|
|
274
|
+
weights = out[weight_col].to_numpy()
|
|
275
|
+
wsum_nb = np.array([weights[ids].sum() for ids in nbrs])
|
|
276
|
+
out['lag_sum_weight'] = wsum_nb
|
|
277
|
+
out['lag_mean_weight'] = wsum_nb / k
|
|
278
|
+
out['weight_plus_sum_sqrt'] = np.sqrt(out[weight_col] + wsum_nb)
|
|
279
|
+
out['weight_plus_mean_sqrt']= np.sqrt(out[weight_col] + (wsum_nb / k))
|
|
280
|
+
|
|
281
|
+
if not (count_col or weight_col):
|
|
282
|
+
raise ValueError("Must specify at least one of count_col or weight_col")
|
|
283
|
+
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
################
|
|
287
|
+
## Function 4 ##
|
|
288
|
+
################
|
|
289
|
+
|
|
290
|
+
def add_spatial_stats(hex_gdf: gpd.GeoDataFrame,
|
|
291
|
+
col: str,
|
|
292
|
+
group_col: str) -> gpd.GeoDataFrame:
|
|
293
|
+
"""
|
|
294
|
+
Given a GeoDataFrame with numeric column `col` and a grouping column `group_col`,
|
|
295
|
+
add four new columns:
|
|
296
|
+
- '{col}_zscore' : global z-score of col
|
|
297
|
+
- '{col}_rank' : global rank (1 = highest)
|
|
298
|
+
- '{col}_zscore_by_{group_col}' : z-score within each group
|
|
299
|
+
- '{col}_rank_by_{group_col}' : rank within each group (1 = highest)
|
|
300
|
+
|
|
301
|
+
Returns a new GeoDataFrame with these columns appended.
|
|
302
|
+
|
|
303
|
+
Example:
|
|
304
|
+
-------
|
|
305
|
+
hex_stats = add_spatial_stats(hex_lagged, col='weight_plus_mean_sqrt', group_col='name')
|
|
306
|
+
|
|
307
|
+
"""
|
|
308
|
+
df = hex_gdf.copy()
|
|
309
|
+
|
|
310
|
+
# Global z-score and rank
|
|
311
|
+
mean_all = df[col].mean()
|
|
312
|
+
std_all = df[col].std(ddof=0) if df[col].std(ddof=0) != 0 else 1
|
|
313
|
+
df[f"{col}_zscore"] = (df[col] - mean_all) / std_all
|
|
314
|
+
df[f"{col}_rank"] = df[col].rank(ascending=False, method='min').astype(int)
|
|
315
|
+
|
|
316
|
+
# Grouped z-score and rank
|
|
317
|
+
# Z-score within group
|
|
318
|
+
df[f"{col}_zscore_by_{group_col}"] = df.groupby(group_col)[col] \
|
|
319
|
+
.transform(lambda x: (x - x.mean()) / (x.std(ddof=0) if x.std(ddof=0) != 0 else 1))
|
|
320
|
+
# Rank within group
|
|
321
|
+
df[f"{col}_rank_by_{group_col}"] = df.groupby(group_col)[col] \
|
|
322
|
+
.transform(lambda x: x.rank(ascending=False, method='min').astype(int))
|
|
323
|
+
|
|
324
|
+
return gpd.GeoDataFrame(df, geometry=hex_gdf.geometry, crs=hex_gdf.crs)
|
|
325
|
+
|
|
326
|
+
################
|
|
327
|
+
## Function 5 ##
|
|
328
|
+
################
|
|
329
|
+
|
|
330
|
+
def count_crimes_by_nearest_road(crimes_gdf: gpd.GeoDataFrame,
|
|
331
|
+
roads_gdf: gpd.GeoDataFrame,
|
|
332
|
+
max_dist: float = 75.0) -> gpd.GeoDataFrame:
|
|
333
|
+
"""
|
|
334
|
+
Snap each crime to the nearest road segment within max_dist via sjoin_nearest,
|
|
335
|
+
then count how many crimes fell on each segment.
|
|
336
|
+
|
|
337
|
+
Parameters:
|
|
338
|
+
----------
|
|
339
|
+
crimes_gdf : GeoDataFrame (points)
|
|
340
|
+
Crime locations. Must be in a projected CRS.
|
|
341
|
+
roads_gdf : GeoDataFrame (lines)
|
|
342
|
+
Road segments, same CRS as crimes_gdf.
|
|
343
|
+
max_dist : float
|
|
344
|
+
Maximum snapping distance in CRS units (default 75 m).
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
-------
|
|
348
|
+
GeoDataFrame
|
|
349
|
+
Copy of roads_gdf with new column 'crime_count' (int).
|
|
350
|
+
|
|
351
|
+
Example:
|
|
352
|
+
-------
|
|
353
|
+
|
|
354
|
+
roads_with_counts = count_crimes_by_nearest_road(
|
|
355
|
+
crimes_gdf=gdf_crimes,
|
|
356
|
+
roads_gdf=gdf_roads,
|
|
357
|
+
max_dist=75
|
|
358
|
+
)
|
|
359
|
+
"""
|
|
360
|
+
# Ensure same CRS
|
|
361
|
+
if crimes_gdf.crs != roads_gdf.crs:
|
|
362
|
+
crimes = crimes_gdf.to_crs(roads_gdf.crs)
|
|
363
|
+
else:
|
|
364
|
+
crimes = crimes_gdf
|
|
365
|
+
|
|
366
|
+
# Tag roads with an explicit ID
|
|
367
|
+
roads = roads_gdf.copy()
|
|
368
|
+
roads["road_id"] = roads.index
|
|
369
|
+
|
|
370
|
+
# Nearest join: each crime gets the 'road_id' of its nearest road (within max_dist)
|
|
371
|
+
joined = gpd.sjoin_nearest(
|
|
372
|
+
crimes,
|
|
373
|
+
roads[["road_id", "geometry"]],
|
|
374
|
+
how="inner",
|
|
375
|
+
max_distance=max_dist
|
|
376
|
+
)
|
|
377
|
+
# joined now has a 'road_id' column
|
|
378
|
+
|
|
379
|
+
# Count crimes per road_id
|
|
380
|
+
counts = (
|
|
381
|
+
joined
|
|
382
|
+
.groupby("road_id")
|
|
383
|
+
.size()
|
|
384
|
+
.rename("crime_count")
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Merge counts back onto the original roads GeoDataFrame
|
|
388
|
+
out = roads_gdf.copy()
|
|
389
|
+
out["crime_count"] = out.index.map(counts).fillna(0).astype(int)
|
|
390
|
+
|
|
391
|
+
return out
|
|
392
|
+
|
|
393
|
+
################
|
|
394
|
+
## Function 6 ##
|
|
395
|
+
################
|
|
396
|
+
|
|
397
|
+
def build_adj_graph(roads_gdf: gpd.GeoDataFrame,
|
|
398
|
+
fid_col: str = None,
|
|
399
|
+
crime_count_col: str = 'crime_count') -> nx.Graph:
|
|
400
|
+
"""
|
|
401
|
+
Build a contiguous adjacency graph of road segments.
|
|
402
|
+
|
|
403
|
+
Parameters:
|
|
404
|
+
----------
|
|
405
|
+
roads_gdf : GeoDataFrame
|
|
406
|
+
Must contain:
|
|
407
|
+
- geometry: LineString segments
|
|
408
|
+
- crime_count_col: numeric attribute on each segment
|
|
409
|
+
fid_col : str or None
|
|
410
|
+
Name of the unique ID column (default None). If None or not found,
|
|
411
|
+
a new integer 'fid' index will be created.
|
|
412
|
+
crime_count_col : str
|
|
413
|
+
Name of the crime count column (default 'crime_count').
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
-------
|
|
417
|
+
G : networkx.Graph
|
|
418
|
+
Undirected graph where:
|
|
419
|
+
- nodes are segment IDs (from fid_col or generated), each with attribute 'crime_count'
|
|
420
|
+
- edges connect segments whose geometries touch
|
|
421
|
+
|
|
422
|
+
Example:
|
|
423
|
+
-------
|
|
424
|
+
G = build_adj_graph(roads_with_counts,
|
|
425
|
+
fid_col=None,
|
|
426
|
+
crime_count_col='crime_count')
|
|
427
|
+
"""
|
|
428
|
+
# Copy to avoid modifying original
|
|
429
|
+
df = roads_gdf.copy()
|
|
430
|
+
|
|
431
|
+
# If no fid_col provided or missing, generate one
|
|
432
|
+
if fid_col is None or fid_col not in df.columns:
|
|
433
|
+
df = df.reset_index(drop=True)
|
|
434
|
+
df['fid'] = df.index.astype(int)
|
|
435
|
+
fid_col_internal = 'fid'
|
|
436
|
+
else:
|
|
437
|
+
fid_col_internal = fid_col
|
|
438
|
+
|
|
439
|
+
# Ensure crime_count exists
|
|
440
|
+
if crime_count_col not in df.columns:
|
|
441
|
+
raise KeyError(f"Crime count column '{crime_count_col}' not found")
|
|
442
|
+
|
|
443
|
+
# Prepare DataFrame indexed by fid
|
|
444
|
+
df_idx = df[[fid_col_internal, 'geometry', crime_count_col]].set_index(fid_col_internal)
|
|
445
|
+
|
|
446
|
+
# Spatial index for quick bbox queries
|
|
447
|
+
sindex = df_idx.sindex
|
|
448
|
+
|
|
449
|
+
# Initialise graph and add nodes
|
|
450
|
+
G = nx.Graph()
|
|
451
|
+
for fid in df_idx.index:
|
|
452
|
+
G.add_node(fid, crime_count=df_idx.at[fid, crime_count_col])
|
|
453
|
+
|
|
454
|
+
# Add edges between touching segments
|
|
455
|
+
for fid, geom in df_idx.geometry.items():
|
|
456
|
+
candidate_pos = list(sindex.intersection(geom.bounds))
|
|
457
|
+
candidate_fids = df_idx.iloc[candidate_pos].index
|
|
458
|
+
for nbr in candidate_fids:
|
|
459
|
+
if nbr == fid:
|
|
460
|
+
continue
|
|
461
|
+
if geom.touches(df_idx.at[nbr, 'geometry']):
|
|
462
|
+
G.add_edge(fid, nbr)
|
|
463
|
+
|
|
464
|
+
return G
|
|
465
|
+
|
|
466
|
+
################
|
|
467
|
+
## Function 7 ##
|
|
468
|
+
################
|
|
469
|
+
|
|
470
|
+
def segment_clusters(G, min_size=2, max_size=10, min_crimes=10):
|
|
471
|
+
"""
|
|
472
|
+
G: NetworkX graph with node attribute 'crime_count'.
|
|
473
|
+
Returns a list of dicts:
|
|
474
|
+
- 'cluster_id': sequential ID
|
|
475
|
+
- 'nodes': set of node-IDs (fids)
|
|
476
|
+
- 'crime_sum': total crime_count in the cluster
|
|
477
|
+
|
|
478
|
+
Example:
|
|
479
|
+
-------
|
|
480
|
+
clusters = segment_clusters(G, min_size=2, max_size=10, min_crimes=24)
|
|
481
|
+
"""
|
|
482
|
+
seeds = sorted(G.nodes, key=lambda n: G.nodes[n]['crime_count'], reverse=True)
|
|
483
|
+
used = set()
|
|
484
|
+
clusters = []
|
|
485
|
+
cluster_id = 1
|
|
486
|
+
|
|
487
|
+
for seed in seeds:
|
|
488
|
+
if seed in used:
|
|
489
|
+
continue
|
|
490
|
+
|
|
491
|
+
cluster = {seed}
|
|
492
|
+
frontier = set(G.neighbors(seed))
|
|
493
|
+
|
|
494
|
+
while frontier and len(cluster) < max_size:
|
|
495
|
+
nxt = max(frontier, key=lambda n: G.nodes[n]['crime_count'])
|
|
496
|
+
frontier.remove(nxt)
|
|
497
|
+
if nxt in cluster:
|
|
498
|
+
continue
|
|
499
|
+
cluster.add(nxt)
|
|
500
|
+
used.add(nxt)
|
|
501
|
+
frontier |= set(G.neighbors(nxt)) - cluster
|
|
502
|
+
|
|
503
|
+
total = sum(G.nodes[n]['crime_count'] for n in cluster)
|
|
504
|
+
if len(cluster) >= min_size and total >= min_crimes:
|
|
505
|
+
clusters.append({
|
|
506
|
+
'cluster_id': cluster_id,
|
|
507
|
+
'nodes': cluster,
|
|
508
|
+
'crime_sum': total
|
|
509
|
+
})
|
|
510
|
+
used |= cluster
|
|
511
|
+
cluster_id += 1
|
|
512
|
+
|
|
513
|
+
return clusters
|
|
514
|
+
|
|
515
|
+
################
|
|
516
|
+
## Function 8 ##
|
|
517
|
+
################
|
|
518
|
+
|
|
519
|
+
def clusters_to_gdf(clusters, G, df, fid_col='fid', crime_count_col='crime_count', crs=None):
|
|
520
|
+
"""
|
|
521
|
+
Convert cluster dicts into a GeoDataFrame.
|
|
522
|
+
|
|
523
|
+
Parameters:
|
|
524
|
+
----------
|
|
525
|
+
clusters : list of dict
|
|
526
|
+
Each dict from greedy_clusters must have keys:
|
|
527
|
+
- 'cluster_id': int
|
|
528
|
+
- 'nodes': iterable of fid values
|
|
529
|
+
- 'crime_sum': total crime count for the cluster
|
|
530
|
+
G : networkx.Graph
|
|
531
|
+
Graph used to generate clusters, with node attribute crime_count_col.
|
|
532
|
+
df : GeoDataFrame
|
|
533
|
+
Original roads GeoDataFrame indexed by fid_col or containing fid_col.
|
|
534
|
+
fid_col : str
|
|
535
|
+
Column name or index in df that matches nodes in clusters.
|
|
536
|
+
crime_count_col : str
|
|
537
|
+
Name of crime count attribute in G and/or df.
|
|
538
|
+
crs : dict or string, optional
|
|
539
|
+
Coordinate reference system to set on the output GeoDataFrame.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
-------
|
|
543
|
+
GeoDataFrame
|
|
544
|
+
Each row is one segment in a cluster, with columns:
|
|
545
|
+
- cluster_id
|
|
546
|
+
- fid
|
|
547
|
+
- cluster_crime_sum
|
|
548
|
+
- crime_count
|
|
549
|
+
- geometry
|
|
550
|
+
|
|
551
|
+
Example:
|
|
552
|
+
------
|
|
553
|
+
gdf_clusters = clusters_to_gdf(clusters, G, roads_with_counts, fid_col='fid', crime_count_col='crime_count')
|
|
554
|
+
|
|
555
|
+
"""
|
|
556
|
+
# Prepare df index
|
|
557
|
+
#df_orig = df.copy()
|
|
558
|
+
#if fid_col in df_orig.columns:
|
|
559
|
+
# df_idx = df_orig.set_index(fid_col)
|
|
560
|
+
# id_name = fid_col
|
|
561
|
+
#else:
|
|
562
|
+
# df_idx = df_orig.copy()
|
|
563
|
+
# df_idx.index.name = fid_col
|
|
564
|
+
# id_name = fid_col
|
|
565
|
+
|
|
566
|
+
# Build record dicts
|
|
567
|
+
records = []
|
|
568
|
+
for cl in clusters:
|
|
569
|
+
cid = cl['cluster_id']
|
|
570
|
+
total = cl['crime_sum']
|
|
571
|
+
for fid in cl['nodes']:
|
|
572
|
+
# if fid not in df_idx.index:
|
|
573
|
+
# continue
|
|
574
|
+
# geom = df_idx.geometry.loc[fid]
|
|
575
|
+
# count = G.nodes[fid].get(crime_count_col, 0)
|
|
576
|
+
records.append({
|
|
577
|
+
'cluster_id' : cid,
|
|
578
|
+
'fid' : fid,
|
|
579
|
+
'cluster_crime_sum': total,
|
|
580
|
+
'crime_count' : G.nodes[fid]['crime_count'],
|
|
581
|
+
'geometry' : df.at[fid, 'geometry']
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
# Turn into a GeoDataFrame, explicitly setting the geometry column
|
|
585
|
+
gdf = gpd.GeoDataFrame(
|
|
586
|
+
records,
|
|
587
|
+
crs=df.crs
|
|
588
|
+
)
|
|
589
|
+
return gdf
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
################
|
|
593
|
+
## Function 9 ##
|
|
594
|
+
################
|
|
595
|
+
|
|
596
|
+
def create_folium_map(hex_gdf=None, hex_query=None,
|
|
597
|
+
seg_gdf=None, seg_query=None,
|
|
598
|
+
district_gdf=None, district_query=None,
|
|
599
|
+
zoom_start=12):
|
|
600
|
+
"""
|
|
601
|
+
Create a Folium map with optional layers:
|
|
602
|
+
- hexagons (outline only, filtered by hex_query)
|
|
603
|
+
- road segments (filtered by seg_query)
|
|
604
|
+
- district boundaries (outline only, filtered by district_query)
|
|
605
|
+
|
|
606
|
+
Parameters:
|
|
607
|
+
----------
|
|
608
|
+
hex_gdf: GeoDataFrame of hex polygons
|
|
609
|
+
hex_query: string query for hex_gdf (e.g. "rank <= 100")
|
|
610
|
+
seg_gdf: GeoDataFrame of line segments
|
|
611
|
+
seg_query: string query for seg_gdf (e.g. "cluster_crime_sum > 50")
|
|
612
|
+
district_gdf: GeoDataFrame of polygons
|
|
613
|
+
district_query: string query for district_gdf
|
|
614
|
+
zoom_start: initial zoom level (default 12)
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
-------
|
|
618
|
+
folium.Map object with layer control and OSM/Positron basemaps.
|
|
619
|
+
|
|
620
|
+
Example:
|
|
621
|
+
-------
|
|
622
|
+
m = create_folium_map(
|
|
623
|
+
hex_gdf=hex_lagged,
|
|
624
|
+
hex_query="rank <= 100",
|
|
625
|
+
seg_gdf=gdf_clusters,
|
|
626
|
+
seg_query="cluster_crime_sum > 50",
|
|
627
|
+
district_gdf=gdf_districts,
|
|
628
|
+
district_query=None)
|
|
629
|
+
|
|
630
|
+
m
|
|
631
|
+
"""
|
|
632
|
+
# Determine map centre from first non-empty layer (projected to WGS84)
|
|
633
|
+
center = [0, 0]
|
|
634
|
+
for gdf in (hex_gdf, seg_gdf, district_gdf):
|
|
635
|
+
if gdf is not None and not gdf.empty:
|
|
636
|
+
wgs = gdf.to_crs("EPSG:4326")
|
|
637
|
+
merged = wgs.geometry.union_all()
|
|
638
|
+
ctr = merged.centroid
|
|
639
|
+
center = [ctr.y, ctr.x]
|
|
640
|
+
break
|
|
641
|
+
|
|
642
|
+
# Initialise Folium map with no default tiles
|
|
643
|
+
m = folium.Map(location=center,
|
|
644
|
+
zoom_start=zoom_start,
|
|
645
|
+
tiles=None)
|
|
646
|
+
|
|
647
|
+
# Add base layers
|
|
648
|
+
folium.TileLayer('OpenStreetMap', name='OSM', control=True).add_to(m)
|
|
649
|
+
folium.TileLayer('CartoDB Positron', name='CartoDB Positron', control=True).add_to(m)
|
|
650
|
+
|
|
651
|
+
# Hexagon outlines layer
|
|
652
|
+
if hex_gdf is not None:
|
|
653
|
+
hex_sel = hex_gdf.query(hex_query) if hex_query else hex_gdf
|
|
654
|
+
hex_wgs = hex_sel.to_crs("EPSG:4326")
|
|
655
|
+
fg_hex = folium.FeatureGroup(name='Hexagons', show=True)
|
|
656
|
+
folium.GeoJson(
|
|
657
|
+
hex_wgs,
|
|
658
|
+
style_function=lambda f: {'fillOpacity': 0, 'color': 'black', 'weight': 1}
|
|
659
|
+
).add_to(fg_hex)
|
|
660
|
+
fg_hex.add_to(m)
|
|
661
|
+
|
|
662
|
+
# Segment clusters layer
|
|
663
|
+
if seg_gdf is not None:
|
|
664
|
+
seg_sel = seg_gdf.query(seg_query) if seg_query else seg_gdf
|
|
665
|
+
seg_wgs = seg_sel.to_crs("EPSG:4326")
|
|
666
|
+
fg_seg = folium.FeatureGroup(name='Segments', show=False)
|
|
667
|
+
folium.GeoJson(
|
|
668
|
+
seg_wgs,
|
|
669
|
+
style_function=lambda f: {'color': 'black', 'weight': 2}
|
|
670
|
+
).add_to(fg_seg)
|
|
671
|
+
fg_seg.add_to(m)
|
|
672
|
+
|
|
673
|
+
# District boundary layer
|
|
674
|
+
if district_gdf is not None:
|
|
675
|
+
dist_sel = district_gdf.query(district_query) if district_query else district_gdf
|
|
676
|
+
dist_wgs = dist_sel.to_crs("EPSG:4326")
|
|
677
|
+
fg_dist = folium.FeatureGroup(name='District Boundary', show=False)
|
|
678
|
+
folium.GeoJson(
|
|
679
|
+
dist_wgs,
|
|
680
|
+
style_function=lambda f: {'fillOpacity': 0, 'color': 'black', 'weight': 2}
|
|
681
|
+
).add_to(fg_dist)
|
|
682
|
+
fg_dist.add_to(m)
|
|
683
|
+
|
|
684
|
+
# Layer control
|
|
685
|
+
folium.LayerControl(collapsed=False).add_to(m)
|
|
686
|
+
|
|
687
|
+
return m
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hexseg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spatial crime segmentation utilities
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pandas
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: geopandas
|
|
12
|
+
Requires-Dist: h3
|
|
13
|
+
Requires-Dist: shapely
|
|
14
|
+
Requires-Dist: scikit-learn
|
|
15
|
+
Requires-Dist: networkx
|
|
16
|
+
Requires-Dist: folium
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# hexseg
|
|
20
|
+
Spatial functions for analysing crime by hexagons and street segments
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/hexseg/__init__.py
|
|
5
|
+
src/hexseg/core.py
|
|
6
|
+
src/hexseg.egg-info/PKG-INFO
|
|
7
|
+
src/hexseg.egg-info/SOURCES.txt
|
|
8
|
+
src/hexseg.egg-info/dependency_links.txt
|
|
9
|
+
src/hexseg.egg-info/requires.txt
|
|
10
|
+
src/hexseg.egg-info/top_level.txt
|
|
11
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hexseg
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import geopandas as gpd
|
|
2
|
+
from shapely.geometry import Polygon
|
|
3
|
+
from hexseg import get_hexagons
|
|
4
|
+
|
|
5
|
+
def test_get_hexagons_empty():
|
|
6
|
+
# minimal GeoDataFrame with a single small polygon
|
|
7
|
+
gdf = gpd.GeoDataFrame({
|
|
8
|
+
'district': [1],
|
|
9
|
+
'geometry': [Polygon([(0,0),(0,1),(1,1),(1,0)])]
|
|
10
|
+
}, crs="EPSG:4326")
|
|
11
|
+
hexes = get_hexagons(gdf, name_col="district", resolution=7)
|
|
12
|
+
assert "hex_id" in hexes.columns
|
|
13
|
+
assert not hexes.empty
|