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 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,2 @@
1
+ # hexseg
2
+ Spatial functions for analysing crime by hexagons and street segments
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ from .core import (
2
+ get_hexagons,
3
+ summarise_by_hex,
4
+ add_spatial_lag,
5
+ add_spatial_stats,
6
+ count_crimes_by_nearest_road,
7
+ build_adj_graph,
8
+ segment_clusters,
9
+ clusters_to_gdf,
10
+ create_folium_map,
11
+ )
@@ -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,8 @@
1
+ pandas
2
+ numpy
3
+ geopandas
4
+ h3
5
+ shapely
6
+ scikit-learn
7
+ networkx
8
+ folium
@@ -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