pyquity 1.0.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.
- pyquity-1.0.0/PKG-INFO +28 -0
- pyquity-1.0.0/README.md +8 -0
- pyquity-1.0.0/pyproject.toml +17 -0
- pyquity-1.0.0/pyquity/__init__.py +4 -0
- pyquity-1.0.0/pyquity/equity.py +125 -0
- pyquity-1.0.0/pyquity/graph.py +215 -0
- pyquity-1.0.0/pyquity/plot.py +40 -0
- pyquity-1.0.0/pyquity/utils.py +28 -0
- pyquity-1.0.0/pyquity.egg-info/PKG-INFO +28 -0
- pyquity-1.0.0/pyquity.egg-info/SOURCES.txt +12 -0
- pyquity-1.0.0/pyquity.egg-info/dependency_links.txt +1 -0
- pyquity-1.0.0/pyquity.egg-info/requires.txt +9 -0
- pyquity-1.0.0/pyquity.egg-info/top_level.txt +1 -0
- pyquity-1.0.0/setup.cfg +4 -0
pyquity-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyquity
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: PyQuity is a compact Python toolkit for building and analyzing multimodal street and transit networks with a focus on accessibility and distributive equity
|
|
5
|
+
Author: pannawatr
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pannawatr/pyquity
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: osmnx>=2.0.0
|
|
12
|
+
Requires-Dist: geopandas>=1.0.0
|
|
13
|
+
Requires-Dist: shapely>=2.0.0
|
|
14
|
+
Requires-Dist: networkx>=3.2
|
|
15
|
+
Requires-Dist: numpy>=1.23
|
|
16
|
+
Requires-Dist: pandas>=2.0
|
|
17
|
+
Requires-Dist: scipy>=1.10
|
|
18
|
+
Requires-Dist: matplotlib>=3.7
|
|
19
|
+
Requires-Dist: pyproj>=3.5
|
|
20
|
+
|
|
21
|
+
## PyQuity
|
|
22
|
+
PyQuity is a compact Python toolkit for building and analyzing multimodal street and transit networks with a focus on accessibility and distributive equity. Quickly generate graphs and grids, attach POIs/GTFS, compute route-based accessibility, and evaluate equity (sufficientarianism, egalitarianism) with seamless GeoPandas/NetworkX integration.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
PyQuity can be installed via PyPI:
|
|
26
|
+
```bash
|
|
27
|
+
pip install pyquity
|
|
28
|
+
```
|
pyquity-1.0.0/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## PyQuity
|
|
2
|
+
PyQuity is a compact Python toolkit for building and analyzing multimodal street and transit networks with a focus on accessibility and distributive equity. Quickly generate graphs and grids, attach POIs/GTFS, compute route-based accessibility, and evaluate equity (sufficientarianism, egalitarianism) with seamless GeoPandas/NetworkX integration.
|
|
3
|
+
|
|
4
|
+
## Installation
|
|
5
|
+
PyQuity can be installed via PyPI:
|
|
6
|
+
```bash
|
|
7
|
+
pip install pyquity
|
|
8
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyquity"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "PyQuity is a compact Python toolkit for building and analyzing multimodal street and transit networks with a focus on accessibility and distributive equity"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name="pannawatr"}]
|
|
13
|
+
classifiers = ["Programming Language :: Python :: 3.9"]
|
|
14
|
+
dependencies = ["osmnx>=2.0.0", "geopandas>=1.0.0", "shapely>=2.0.0", "networkx>=3.2", "numpy>=1.23", "pandas>=2.0", "scipy>=1.10", "matplotlib>=3.7", "pyproj>=3.5"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/pannawatr/pyquity"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import pyquity
|
|
2
|
+
import osmnx as ox
|
|
3
|
+
import numpy as np
|
|
4
|
+
import networkx as nx
|
|
5
|
+
|
|
6
|
+
class Equity:
|
|
7
|
+
def __init__(self, G_walk, G_micromobility, grid, amenity):
|
|
8
|
+
self.G_walk = G_walk
|
|
9
|
+
self.G_micromobility = G_micromobility
|
|
10
|
+
self.grid = grid
|
|
11
|
+
self.amenity = amenity
|
|
12
|
+
|
|
13
|
+
# Ensure amenities are point geometries
|
|
14
|
+
if not all(self.amenity.geometry.geom_type == "Point"):
|
|
15
|
+
self.amenity["geometry"] = self.amenity.geometry.centroid
|
|
16
|
+
|
|
17
|
+
def sufficientarianism(self, served_time: int = 15):
|
|
18
|
+
# Map amenities to nearest nodes and map grid centroids to nearest nodes with micromobility
|
|
19
|
+
self.micromobility_amenity_nodes = ox.distance.nearest_nodes(self.G_micromobility, self.amenity.geometry.centroid.x.values, self.amenity.geometry.centroid.y.values)
|
|
20
|
+
self.micromobility_grid_nodes = ox.distance.nearest_nodes(self.G_micromobility, self.grid.geometry.centroid.x.values, self.grid.geometry.centroid.y.values)
|
|
21
|
+
|
|
22
|
+
# Map amenities to nearest nodes and map grid centroids to nearest nodes with walk
|
|
23
|
+
self.walk_amenity_nodes = ox.distance.nearest_nodes(self.G_walk, self.amenity.geometry.centroid.x.values, self.amenity.geometry.centroid.y.values)
|
|
24
|
+
self.walk_grid_nodes = ox.distance.nearest_nodes(self.G_walk, self.grid.geometry.centroid.x.values, self.grid.geometry.centroid.y.values)
|
|
25
|
+
|
|
26
|
+
# Assign the nearest network node ID to each grid cell based on micromobility count
|
|
27
|
+
self.grid["grid_id"] = np.where(self.grid['micromobility_count'] > 0, self.micromobility_grid_nodes, self.walk_grid_nodes)
|
|
28
|
+
self.grid["served"] = 0
|
|
29
|
+
|
|
30
|
+
# Iterate over each grid row to calculate serviceability
|
|
31
|
+
for idx, grid_row in self.grid.iterrows():
|
|
32
|
+
grid_node = grid_row['grid_id']
|
|
33
|
+
|
|
34
|
+
# Check if the grid has micromobility services or not and select the appropriate graph and nodes
|
|
35
|
+
if grid_row['micromobility_count'] > 0:
|
|
36
|
+
G_current = self.G_micromobility
|
|
37
|
+
amenity_nodes = self.micromobility_amenity_nodes
|
|
38
|
+
else:
|
|
39
|
+
G_current = self.G_walk
|
|
40
|
+
amenity_nodes = self.walk_amenity_nodes
|
|
41
|
+
|
|
42
|
+
# Compute shortest paths from the selected grid node to all other nodes
|
|
43
|
+
paths = nx.single_source_dijkstra_path(G_current, source=grid_node, weight='length', cutoff=served_time * (22 * 1000 / 3600) * 60)
|
|
44
|
+
|
|
45
|
+
# Iterate over each amenity node to check if it's reachable within the time limit
|
|
46
|
+
for amenity_node in amenity_nodes:
|
|
47
|
+
# If this grid node is already served, no need to check further amenities
|
|
48
|
+
if self.grid.loc[self.grid["grid_id"] == grid_node, "served"].values[0] == 1:
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
# Check if the amenity is reachable in the computed paths
|
|
52
|
+
if amenity_node in paths:
|
|
53
|
+
try:
|
|
54
|
+
# Get the route as a list of node IDs and calculate distance and travel time using pyquity
|
|
55
|
+
route = [int(node) for node in paths[int(amenity_node)]]
|
|
56
|
+
distance, travel_time = pyquity.route_length_by_mode(G_current, route)
|
|
57
|
+
total_time = sum(travel_time.values())
|
|
58
|
+
|
|
59
|
+
# If total travel time is within the served_time threshold
|
|
60
|
+
if total_time <= served_time:
|
|
61
|
+
self.grid.loc[self.grid["grid_id"] == grid_node, "served"] = 1
|
|
62
|
+
break
|
|
63
|
+
except:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
# Return GeoDataFrame of grid
|
|
67
|
+
return self.grid
|
|
68
|
+
|
|
69
|
+
def egalitarianism(self, grid):
|
|
70
|
+
# Extract 'served' data and compute Lorenz curve and Gini coefficient
|
|
71
|
+
served_data = grid['served'].values
|
|
72
|
+
|
|
73
|
+
# Calculate Lorenz curve and Gini coefficient
|
|
74
|
+
lorenz_curve = self._lorenz_curve(served_data)
|
|
75
|
+
gini_index = self._gini_coefficient(lorenz_curve)
|
|
76
|
+
return gini_index, lorenz_curve
|
|
77
|
+
|
|
78
|
+
def _lorenz_curve(self, data):
|
|
79
|
+
# Calculate Lorenz curve
|
|
80
|
+
sorted_data = np.sort(data)
|
|
81
|
+
lorenz_curve = np.insert((np.cumsum(sorted_data) / np.sum(sorted_data)), 0, 0)
|
|
82
|
+
return lorenz_curve
|
|
83
|
+
|
|
84
|
+
def _gini_coefficient(self, lorenz_curve):
|
|
85
|
+
# Calculate Gini coefficient from Lorenz curve
|
|
86
|
+
gini_index = ((0.5 - (np.sum(lorenz_curve) / (len(lorenz_curve) - 1))) * 2)
|
|
87
|
+
return gini_index
|
|
88
|
+
|
|
89
|
+
def utilitarianism(self, served_time, network_type: str='walk'):
|
|
90
|
+
# Initialize a column to track how many amenities serve each grid cell
|
|
91
|
+
self.grid["count_served"] = 0
|
|
92
|
+
|
|
93
|
+
# Select the correct network, nodes, and grid ID mapping depending on travel mode
|
|
94
|
+
if network_type == 'walk':
|
|
95
|
+
self.grid['grid_id'] = self.walk_grid_nodes
|
|
96
|
+
G_current = self.G_walk
|
|
97
|
+
amenity_nodes = self.walk_amenity_nodes
|
|
98
|
+
elif network_type == 'bike':
|
|
99
|
+
self.grid['grid_id'] = self.micromobility_grid_nodes
|
|
100
|
+
G_current = self.G_micromobility
|
|
101
|
+
amenity_nodes = self.micromobility_amenity_nodes
|
|
102
|
+
|
|
103
|
+
# Iterate over each grid cell
|
|
104
|
+
for idx, grid_row in self.grid.iterrows():
|
|
105
|
+
grid_node = grid_row['grid_id']
|
|
106
|
+
|
|
107
|
+
# Compute shortest paths (Dijkstra) from the grid node to all reachable nodes
|
|
108
|
+
paths = nx.single_source_dijkstra_path(G_current, source=grid_node, weight='length', cutoff=served_time * (22 * 1000 / 3600) * 60)
|
|
109
|
+
|
|
110
|
+
# Check each amenity node to see if it is reachable
|
|
111
|
+
for amenity_node in amenity_nodes:
|
|
112
|
+
if amenity_node in paths:
|
|
113
|
+
try:
|
|
114
|
+
# Get the route as a list of node IDs and calculate distance and travel time using pyquity
|
|
115
|
+
route = [int(node) for node in paths[int(amenity_node)]]
|
|
116
|
+
distance, travel_time = pyquity.route_length_by_mode(G_current, route)
|
|
117
|
+
total_time = sum(travel_time.values())
|
|
118
|
+
|
|
119
|
+
# If total travel time is within the served_time threshold
|
|
120
|
+
if total_time <= served_time:
|
|
121
|
+
self.grid.loc[self.grid["grid_id"] == grid_node, "count_served"] += 1
|
|
122
|
+
except:
|
|
123
|
+
continue
|
|
124
|
+
# Return the updated grid with count_served values
|
|
125
|
+
return self.grid
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import osmnx as ox
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import networkx as nx
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
import partridge as ptg
|
|
7
|
+
from shapely import ops as sops
|
|
8
|
+
from scipy.spatial import cKDTree
|
|
9
|
+
from geopy.distance import distance
|
|
10
|
+
from shapely.geometry import Point, Polygon, LineString
|
|
11
|
+
|
|
12
|
+
def multimodal_graph(G_osm: nx.MultiDiGraph, G_gtfs: nx.MultiDiGraph, k: int=1):
|
|
13
|
+
# Relabel GTFS nodes with unique integers to avoid ID collisions with OSM graph
|
|
14
|
+
G_gtfs = nx.relabel_nodes(G_gtfs, {node: i for i, node in enumerate(G_gtfs.nodes())}, copy=True)
|
|
15
|
+
|
|
16
|
+
# Get OSM node coordinates
|
|
17
|
+
osm_nodes, _ = ox.graph_to_gdfs(G_osm)
|
|
18
|
+
osm_coords = np.array(list(zip(osm_nodes["y"], osm_nodes["x"])))
|
|
19
|
+
|
|
20
|
+
# Get GTFS stop coordinates
|
|
21
|
+
gtfs_nodes = [(n, data["x"], data["y"]) for n, data in G_gtfs.nodes(data=True)]
|
|
22
|
+
gtfs_coords = np.array([(y, x) for _, x, y in gtfs_nodes])
|
|
23
|
+
|
|
24
|
+
# Build KD-tree for nearest-neighbor search
|
|
25
|
+
tree = cKDTree(osm_coords)
|
|
26
|
+
|
|
27
|
+
# Combine OSM and GTFS graphs
|
|
28
|
+
G = nx.compose(G_osm, G_gtfs)
|
|
29
|
+
|
|
30
|
+
# Connect each GTFS stop to its nearest OSM node(s)
|
|
31
|
+
for (stop_id, x, y), (dist, idx) in zip(gtfs_nodes, zip(*tree.query(gtfs_coords, k=k))):
|
|
32
|
+
osm_node = osm_nodes.iloc[idx].name
|
|
33
|
+
point_u = (y, x)
|
|
34
|
+
point_v = (osm_nodes.iloc[idx].y, osm_nodes.iloc[idx].x)
|
|
35
|
+
|
|
36
|
+
length = distance(point_u, point_v).m
|
|
37
|
+
G.add_edge(stop_id, osm_node, mode="transfer", length=length)
|
|
38
|
+
G.add_edge(osm_node, stop_id, mode="transfer", length=length)
|
|
39
|
+
|
|
40
|
+
# Return the graph
|
|
41
|
+
G.graph["crs"] = "EPSG:4326"
|
|
42
|
+
return G
|
|
43
|
+
|
|
44
|
+
def graph_from_gtfs(gtfs: str) -> nx.MultiDiGraph:
|
|
45
|
+
# Read available service dates
|
|
46
|
+
date = ptg.read_service_ids_by_date(gtfs)
|
|
47
|
+
if not date:
|
|
48
|
+
raise ValueError("No valid service date found in GTFS.")
|
|
49
|
+
|
|
50
|
+
# Select the first available service date
|
|
51
|
+
target_date = sorted(date.keys())[0]
|
|
52
|
+
feed = ptg.load_geo_feed(gtfs, view={'trips.txt': {'service_id': date[target_date]}, 'shapes.txt': {}})
|
|
53
|
+
|
|
54
|
+
# Extract GTFS tables
|
|
55
|
+
stop_times = feed.stop_times
|
|
56
|
+
trips = feed.trips
|
|
57
|
+
stops = feed.stops
|
|
58
|
+
shapes = feed.shapes
|
|
59
|
+
|
|
60
|
+
# Initialize a multidirected graph
|
|
61
|
+
G = nx.MultiDiGraph()
|
|
62
|
+
|
|
63
|
+
# Add each transit stop as a node in the graph
|
|
64
|
+
for _, row in stops.iterrows():
|
|
65
|
+
G.add_node(row['stop_id'], name=row['stop_name'], x=row['geometry'].x, y=row['geometry'].y)
|
|
66
|
+
|
|
67
|
+
# Ensure all shape geometries are LineString
|
|
68
|
+
shapes['geometry'] = shapes['geometry'].apply( lambda geoms: geoms if isinstance(geoms, LineString) else LineString(geoms))
|
|
69
|
+
shapes = shapes.set_index('shape_id').geometry
|
|
70
|
+
|
|
71
|
+
# Create edges from trips, grouped by shape_id
|
|
72
|
+
for shape_id, group in trips.groupby('shape_id'):
|
|
73
|
+
if shape_id not in shapes.index or shapes[shape_id] is None:
|
|
74
|
+
continue
|
|
75
|
+
geoms = shapes[shape_id]
|
|
76
|
+
|
|
77
|
+
# Take the first trip in the group as representative
|
|
78
|
+
trip_id = group.iloc[0]['trip_id']
|
|
79
|
+
|
|
80
|
+
# Get the stop sequence for the trip
|
|
81
|
+
stop_sequence = stop_times[stop_times.trip_id == trip_id].sort_values('stop_sequence')
|
|
82
|
+
stop_id = stop_sequence['stop_id'].tolist()
|
|
83
|
+
|
|
84
|
+
# Add edges between consecutive stops
|
|
85
|
+
for i in range(len(stop_id) - 1):
|
|
86
|
+
u, v = stop_id[i], stop_id[i + 1]
|
|
87
|
+
|
|
88
|
+
# Retrieve the coordinates of the two stops
|
|
89
|
+
point_u = stops.loc[stops.stop_id == u, 'geometry'].values[0]
|
|
90
|
+
point_v = stops.loc[stops.stop_id == v, 'geometry'].values[0]
|
|
91
|
+
length = distance((point_u.y, point_u.x), (point_v.y, point_v.x)).m
|
|
92
|
+
|
|
93
|
+
# Get geometry for the edge
|
|
94
|
+
try:
|
|
95
|
+
orig = geoms.project(point_u)
|
|
96
|
+
dest = geoms.project(point_v)
|
|
97
|
+
low, high = sorted([orig, dest])
|
|
98
|
+
segment = sops.substring(geoms, low, high, normalized=False)
|
|
99
|
+
if segment.is_empty or segment.length == 0:
|
|
100
|
+
segment = LineString([pu, pv])
|
|
101
|
+
except:
|
|
102
|
+
LineString([point_u, point_v])
|
|
103
|
+
|
|
104
|
+
# Add edge only if it does not already exist, and add geometry
|
|
105
|
+
if not G.has_edge(u, v, key=trip_id):
|
|
106
|
+
G.add_edge(
|
|
107
|
+
u, v,
|
|
108
|
+
trip_id=trip_id,
|
|
109
|
+
geometry=segment,
|
|
110
|
+
length=length,
|
|
111
|
+
mode='transit'
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Return the graph
|
|
115
|
+
G.graph['crs'] = "EPSG:4326"
|
|
116
|
+
return G
|
|
117
|
+
|
|
118
|
+
def graph_from_place(place_name: str, network_type: str):
|
|
119
|
+
# Return a graph from OSMnx
|
|
120
|
+
G = ox.graph_from_place(place_name, network_type=network_type)
|
|
121
|
+
|
|
122
|
+
# Add mode to each edge in the graph
|
|
123
|
+
for u, v, k, data in G.edges(keys=True, data=True):
|
|
124
|
+
data['mode'] = network_type
|
|
125
|
+
|
|
126
|
+
return G
|
|
127
|
+
|
|
128
|
+
def grid_from_place(place_name: str, grid_size: float) -> gpd.GeoDataFrame:
|
|
129
|
+
# Read boundary from OpenStreetMap(OSM)
|
|
130
|
+
boundary = ox.geocode_to_gdf(place_name)
|
|
131
|
+
boundary = boundary.to_crs(epsg=3857) # Project boundary to Web Mercator(EPSG:3857) from default(EPSG:4326)
|
|
132
|
+
minx, miny, maxx, maxy = boundary.total_bounds
|
|
133
|
+
|
|
134
|
+
# Initialize a grid and Add into GeoDataFrame
|
|
135
|
+
polygons = [Polygon([(x, y), (x + grid_size, y), (x + grid_size, y + grid_size), (x, y + grid_size)]) for x in np.arange(minx, maxx, grid_size) for y in np.arange(miny, maxy, grid_size)]
|
|
136
|
+
gdf = gpd.GeoDataFrame(geometry=polygons, crs=boundary.crs)
|
|
137
|
+
grid = gpd.overlay(gdf, boundary, how='intersection')
|
|
138
|
+
|
|
139
|
+
# Reproject the grid back to WGS84 (EPSG:4326) and return it
|
|
140
|
+
return grid.to_crs(epsg=4326)
|
|
141
|
+
|
|
142
|
+
def amenity_from_place(place_name: str, amenity_type: str) -> gpd.GeoDataFrame:
|
|
143
|
+
# Define categories of amenities with their corresponding OSM tags
|
|
144
|
+
amenity = {
|
|
145
|
+
'education': ['college', 'dancing_school', 'driving_school', 'first_aid_school', 'kindergarten', 'language_school', 'library', 'surf_school', 'toy_library', 'research_institute', 'training', 'music_school', 'school', 'traffic_park', 'university'],
|
|
146
|
+
'financial': ['atm', 'bank', 'bureau_de_change'],
|
|
147
|
+
'healthcare': ['baby_hatch', 'clinic', 'dentist', 'doctors', 'hospital', 'nursing_home', 'pharmacy', 'social_facility', 'veterinary' ]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Get the list of amenity tags for the selected amenity type
|
|
151
|
+
amenity = amenity[amenity_type.lower()]
|
|
152
|
+
|
|
153
|
+
# Query OpenStreetMap for features matching the selected amenity tags within the specified place
|
|
154
|
+
gdf = ox.features.features_from_place(place_name, tags={'amenity': amenity})
|
|
155
|
+
|
|
156
|
+
# Return the resulting GeoDataFrame
|
|
157
|
+
return gdf
|
|
158
|
+
|
|
159
|
+
def amenity_in_grid(grid: gpd.GeoDataFrame, amenity: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
|
|
160
|
+
# Reproject amenities to Web Mercator (EPSG:3857) and convert geometry to centroids (points)
|
|
161
|
+
amenity = amenity.to_crs(epsg=3857)
|
|
162
|
+
amenity['geometry'] = amenity.geometry.centroid
|
|
163
|
+
|
|
164
|
+
# Reproject grid to Web Mercator
|
|
165
|
+
grid = grid.to_crs(epsg=3857)
|
|
166
|
+
|
|
167
|
+
# Ensure both layers have the same CRS before spatial operations
|
|
168
|
+
if grid.crs == amenity.crs:
|
|
169
|
+
grid['amenity_count'] = 0 # Initialize 'amenity_count' column in the grid
|
|
170
|
+
|
|
171
|
+
# Count amenities within each grid cell
|
|
172
|
+
for index, x in grid.iterrows():
|
|
173
|
+
for _, y in amenity.iterrows():
|
|
174
|
+
if x.geometry.contains(y.geometry):
|
|
175
|
+
grid.at[index, 'amenity_count'] += 1
|
|
176
|
+
|
|
177
|
+
# Reproject the grid back to WGS84 (EPSG:4326) and return it
|
|
178
|
+
return grid.to_crs(epsg=4326)
|
|
179
|
+
|
|
180
|
+
def micromobility_in_grid(grid: gpd.GeoDataFrame, amenity: gpd.GeoDataFrame, micromobility_size: int, alpha: float = 1.0) -> gpd.GeoDataFrame:
|
|
181
|
+
# If amenity counts are not present, initialize counts and return zeros
|
|
182
|
+
if 'amenity_count' not in grid.columns:
|
|
183
|
+
grid['micromobility_count'] = 0
|
|
184
|
+
return grid
|
|
185
|
+
|
|
186
|
+
# Prepare counts and ensure numeric
|
|
187
|
+
counts = grid['amenity_count'].fillna(0).astype(float)
|
|
188
|
+
|
|
189
|
+
# Compute weights: raise counts to the power of alpha to control emphasis
|
|
190
|
+
weights = counts ** alpha
|
|
191
|
+
|
|
192
|
+
total_weight = weights.sum()
|
|
193
|
+
# If there are no amenities at all, assign zero everywhere
|
|
194
|
+
if total_weight == 0 or micromobility_size <= 0:
|
|
195
|
+
grid['micromobility_count'] = 0
|
|
196
|
+
return grid
|
|
197
|
+
|
|
198
|
+
# Normalize weights to form a probability distribution
|
|
199
|
+
probs = weights / total_weight
|
|
200
|
+
|
|
201
|
+
# Expected (real-valued) allocation per cell
|
|
202
|
+
expected = probs * micromobility_size
|
|
203
|
+
|
|
204
|
+
# Assign integer allocations by flooring expected values
|
|
205
|
+
grid['micromobility_count'] = np.floor(expected).astype(int)
|
|
206
|
+
|
|
207
|
+
# Distribute the remaining units by largest fractional parts (prioritizes high-amenity cells)
|
|
208
|
+
remainder = int(micromobility_size - grid['micromobility_count'].sum())
|
|
209
|
+
if remainder > 0:
|
|
210
|
+
fractional = expected - np.floor(expected)
|
|
211
|
+
indices = fractional.sort_values(ascending=False).index[:remainder]
|
|
212
|
+
for idx in indices:
|
|
213
|
+
grid.at[idx, 'micromobility_count'] += 1
|
|
214
|
+
|
|
215
|
+
return grid
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import osmnx as ox
|
|
2
|
+
import networkx as nx
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
|
|
5
|
+
def plot_grid(grid, ax=None, linewidth: float=0.5, figsize: tuple[float, float]=(10, 10), legend: bool=True, show: bool=True, **kwargs):
|
|
6
|
+
# Create a new figure and axes if none provided
|
|
7
|
+
if ax is None:
|
|
8
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
9
|
+
|
|
10
|
+
# Plot the GeoDataFrame on the axes
|
|
11
|
+
ax = grid.plot(ax=ax, edgecolor='k', color='white', linewidth=linewidth, legend=legend, **kwargs)
|
|
12
|
+
|
|
13
|
+
# Display the plot if requested
|
|
14
|
+
if show:
|
|
15
|
+
plt.show()
|
|
16
|
+
|
|
17
|
+
return ax
|
|
18
|
+
|
|
19
|
+
def plot_graph_route_by_mode(G, route, ax=None, show: bool=True, legend: bool=True, node_size: float=0, edge_color: str='lightgray'):
|
|
20
|
+
mode_colors = {'walk': 'green', 'transit': 'red', 'bike': 'blue', 'transfer': 'yellow'}
|
|
21
|
+
|
|
22
|
+
# Convert the route into a GeoDataFrame (one row per edge) using OSMnx
|
|
23
|
+
gdf = ox.routing.route_to_gdf(G, route)
|
|
24
|
+
|
|
25
|
+
# Create a new figure and axes if none provided
|
|
26
|
+
if ax is None:
|
|
27
|
+
fig, ax = ox.plot_graph(G, show=False, close=False, node_size=node_size, edge_color=edge_color)
|
|
28
|
+
|
|
29
|
+
# Plot each segment of the route, color-coded by its mode
|
|
30
|
+
for mode, data in gdf.groupby('mode'):
|
|
31
|
+
color = mode_colors.get(mode, 'gray')
|
|
32
|
+
data.plot(ax=ax, linewidth=3.5, edgecolor=color, label=mode)
|
|
33
|
+
|
|
34
|
+
# Show the legend and plot if requested
|
|
35
|
+
if show:
|
|
36
|
+
plt.show()
|
|
37
|
+
if legend:
|
|
38
|
+
ax.legend()
|
|
39
|
+
|
|
40
|
+
return ax
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import osmnx as ox
|
|
2
|
+
import geopandas as gpd
|
|
3
|
+
|
|
4
|
+
def route_length_by_mode(G, route, verbose: bool = True):
|
|
5
|
+
# Convert route to GeoDataFrame and project to metric CRS
|
|
6
|
+
gdf = ox.routing.route_to_gdf(G, route)
|
|
7
|
+
gdf = gdf.to_crs(epsg=3857)
|
|
8
|
+
|
|
9
|
+
# Calculate total distance by mode (km)
|
|
10
|
+
distance = gdf.groupby('mode')['length'].sum() / 1000
|
|
11
|
+
distance = round(distance, 2).to_dict()
|
|
12
|
+
|
|
13
|
+
# Average speeds by mode (m/s)
|
|
14
|
+
speed = {
|
|
15
|
+
'walk': 5 * 1000 / 3600,
|
|
16
|
+
'bike': 20 * 1000 / 3600,
|
|
17
|
+
'transfer': 5 * 1000 / 3600,
|
|
18
|
+
'transit': 22 * 1000 / 3600
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Calculate travel time by mode (minutes)
|
|
22
|
+
travel_time = {}
|
|
23
|
+
for mode, dist_km in distance.items():
|
|
24
|
+
if mode in speed:
|
|
25
|
+
time_sec = (dist_km * 1000) / speed[mode]
|
|
26
|
+
travel_time[mode] = round(time_sec / 60, 2)
|
|
27
|
+
|
|
28
|
+
return distance, travel_time
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyquity
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: PyQuity is a compact Python toolkit for building and analyzing multimodal street and transit networks with a focus on accessibility and distributive equity
|
|
5
|
+
Author: pannawatr
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pannawatr/pyquity
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: osmnx>=2.0.0
|
|
12
|
+
Requires-Dist: geopandas>=1.0.0
|
|
13
|
+
Requires-Dist: shapely>=2.0.0
|
|
14
|
+
Requires-Dist: networkx>=3.2
|
|
15
|
+
Requires-Dist: numpy>=1.23
|
|
16
|
+
Requires-Dist: pandas>=2.0
|
|
17
|
+
Requires-Dist: scipy>=1.10
|
|
18
|
+
Requires-Dist: matplotlib>=3.7
|
|
19
|
+
Requires-Dist: pyproj>=3.5
|
|
20
|
+
|
|
21
|
+
## PyQuity
|
|
22
|
+
PyQuity is a compact Python toolkit for building and analyzing multimodal street and transit networks with a focus on accessibility and distributive equity. Quickly generate graphs and grids, attach POIs/GTFS, compute route-based accessibility, and evaluate equity (sufficientarianism, egalitarianism) with seamless GeoPandas/NetworkX integration.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
PyQuity can be installed via PyPI:
|
|
26
|
+
```bash
|
|
27
|
+
pip install pyquity
|
|
28
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
pyquity/__init__.py
|
|
4
|
+
pyquity/equity.py
|
|
5
|
+
pyquity/graph.py
|
|
6
|
+
pyquity/plot.py
|
|
7
|
+
pyquity/utils.py
|
|
8
|
+
pyquity.egg-info/PKG-INFO
|
|
9
|
+
pyquity.egg-info/SOURCES.txt
|
|
10
|
+
pyquity.egg-info/dependency_links.txt
|
|
11
|
+
pyquity.egg-info/requires.txt
|
|
12
|
+
pyquity.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyquity
|
pyquity-1.0.0/setup.cfg
ADDED