Skip to content

Commit 3ba53de

Browse files
committed
Merge branch 'main' of github.com:hasadna/open-bus-stride-client
2 parents 2987747 + 0fa692c commit 3ba53de

13 files changed

+593
-0
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@ venv
44
.idea
55
dist
66
__pycache__
7+
.data
78
VERSION.txt
9+
logs
10+
data

README.md

+19
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ For a very quickstart without any installation, you can open the notebooks onlin
1515
* [getting all arrivals to all stops of a given line on a given day](https://mybinder.org/v2/gh/hasadna/open-bus-stride-client/HEAD?labpath=notebooks%2Fgetting%20all%20arrivals%20to%20all%20stops%20of%20a%20given%20line%20in%20a%20given%20day.ipynb)
1616
* [load siri vehicle locations to pandas dataframe](https://mybinder.org/v2/gh/hasadna/open-bus-stride-client/main?labpath=notebooks%2Fload%20siri%20vehicle%20locations%20to%20pandas%20dataframe.ipynb)
1717
* [load gtfs timetable to pandas dataframe](https://mybinder.org/v2/gh/hasadna/open-bus-stride-client/HEAD?labpath=notebooks%2Fload%20gtfs%20timetable%20to%20pandas%20dataframe.ipynb)
18+
* [siri accessibility analysis using UrbanAccess](https://mybinder.org/v2/gh/hasadna/open-bus-stride-client/HEAD?labpath=notebooks%2Fsiri%20accessibility%20analysis%20using%20UrbanAccess.ipynb)
1819

1920
## Local Usage
2021

@@ -47,3 +48,21 @@ See the CLI help message for details:
4748
stride --help
4849
```
4950

51+
### SIRI Accessibility Analysis using UrbanAccess
52+
53+
[UDST/urbanaccess](https://github.com/UDST/urbanaccess/blob/dev/README.rst) is a tool for running accessibility
54+
analysis. The stride client provides methods which allow to generate UrbanAccess accesibility graphs for the SIRI data.
55+
56+
Install:
57+
58+
```
59+
pip install --upgrade open-bus-stride-client[cli,urbanaccess]
60+
```
61+
62+
See the notebook for example usage: [siri accessibility analysis using UrbanAccess](https://mybinder.org/v2/gh/hasadna/open-bus-stride-client/HEAD?labpath=notebooks%2Fsiri%20accessibility%20analysis%20using%20UrbanAccess.ipynb)
63+
64+
See the CLI help messages for available functionality via the CLI:
65+
66+
```
67+
stride urbanaccess --help
68+
```

notebooks/.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
data
2+
logs

notebooks/siri accessibility analysis using UrbanAccess.ipynb

+316
Large diffs are not rendered by default.

setup.py

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
extras_cli = ['click==8.1.3']
1414
extras_jupyter = ['jupyterlab', 'ipywidgets']
1515
extras_notebooks = ['pandas>=1.3<1.4']
16+
extras_urbanaccess = ['urbanaccess==0.2.2', 'geopandas==0.10.2']
1617

1718

1819
setup(
@@ -24,10 +25,12 @@
2425
extras_require={
2526
'cli': extras_cli,
2627
'notebooks': extras_notebooks,
28+
'urbanaccess': extras_urbanaccess,
2729
'all': [
2830
*extras_cli,
2931
*extras_jupyter,
3032
*extras_notebooks,
33+
*extras_urbanaccess,
3134
]
3235
},
3336
entry_points={

stride/cli.py

+5
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ def main():
99
pass
1010

1111

12+
from .urbanaccess.cli import urbanaccess
13+
main.add_command(urbanaccess)
14+
15+
1216
@main.command()
1317
@click.argument('PATH')
1418
@click.argument('PARAMS_JSON', default='{}')
@@ -25,6 +29,7 @@ def get(path, params_json):
2529
def iterate(path, params_json, limit):
2630
"""Iterate over an API list path with optional json params, print one item per line"""
2731
from . import streaming
32+
i = -1
2833
for i, item in enumerate(streaming.iterate(path, json.loads(params_json), limit)):
2934
print(item)
3035
print(f"Got {i+1} results")

stride/common.py

+36
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import json
23
import datetime
34
import urllib.parse
@@ -85,3 +86,38 @@ def get(path, params=None, pre_requests_callback=None):
8586
res_status_code, res_text,
8687
msg="Failure response from Stride API ({}): {}".format(res_status_code, parse_error_res(res))
8788
)
89+
90+
91+
def now():
92+
return datetime.datetime.now(datetime.timezone.utc)
93+
94+
95+
def create_unique_path(base_path, path_prefix=''):
96+
os.makedirs(base_path, exist_ok=True)
97+
for _ in range(5):
98+
path_part = '{}{}'.format(path_prefix, now().strftime('%Y-%m-%dT%H%M%S.%f'))
99+
path = os.path.join(base_path, path_part)
100+
try:
101+
os.mkdir(path)
102+
except FileExistsError:
103+
continue
104+
return path
105+
raise Exception("Failed to create unique path")
106+
107+
108+
def is_None(val):
109+
# due to a problem with airflow dag initialization, in some cases we get
110+
# the actual string 'None' which we need to handle as None
111+
return val is None or val == 'None'
112+
113+
114+
def parse_date_str(date, num_days=None):
115+
"""Parses a date string in format %Y-%m-%d with default of today if empty
116+
if num_days is not None - will use a default of today minus given num_days
117+
"""
118+
if isinstance(date, datetime.date):
119+
return date
120+
elif not date or is_None(date):
121+
return datetime.date.today() if num_days is None else datetime.date.today() - datetime.timedelta(days=int(num_days))
122+
else:
123+
return datetime.datetime.strptime(date, '%Y-%m-%d').date()

stride/config.py

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33

44
STRIDE_API_BASE_URL = (os.environ.get('STRIDE_API_BASE_URL') or 'https://open-bus-stride-api.hasadna.org.il').rstrip('/')
5+
URBANACCESS_DATA_PATH = os.environ.get('URBANACCESS_DATA_PATH') or '.data/urbanaccess'

stride/urbanaccess/__init__.py

Whitespace-only changes.

stride/urbanaccess/cli.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import click
2+
3+
4+
@click.group()
5+
def urbanaccess():
6+
"""Run accessibility analysis using UrbanAccess"""
7+
pass
8+
9+
10+
@urbanaccess.command()
11+
@click.option('--target-path', help="Target path to save the fake gtfs data to. "
12+
"If not provided will create a unique path in local directory.")
13+
@click.option('--date', help="Date string in format %Y-%m-%d, for example: \"2022-06-15\"", required=True)
14+
@click.option('--start-hour', type=int, required=True, help="UTC Hour")
15+
@click.option('--end-hour', type=int, required=True, help="UTC Hour")
16+
@click.option('--bbox', help='comma-separated square bounding box values: min_lon, min_lat, max_lon, max_lat. '
17+
'For example: "34.8, 31.96, 34.81, 31.97". '
18+
'Can get it from https://boundingbox.klokantech.com/ - csv export',
19+
required=True)
20+
def create_fake_gtfs(**kwargs):
21+
"""Create fake GTFS data from the siri data to use as input to UrbanAccess"""
22+
from .create_fake_gtfs import main
23+
main(**kwargs)
24+
25+
26+
@urbanaccess.command()
27+
@click.option('--target-path')
28+
@click.option('--fake-gtfs-path', help='path to output of create-fake-gtfs task. '
29+
'If provided, the other fake gtfs arguments are not needed.')
30+
@click.option('--date', help="To create fake gtfs data - date string in format %Y-%m-%d, for example: \"2022-06-15\"")
31+
@click.option('--start-hour', type=int, help="To create fake gtfs data - UTC Hour")
32+
@click.option('--end-hour', type=int, help="To create fake gtfs data - UTC Hour")
33+
@click.option('--bbox', help='To create fake gtfs data - comma-separated square bounding box values: min_lon, min_lat, max_lon, max_lat. '
34+
'For example: "34.8, 31.96, 34.81, 31.97". '
35+
'Can get it from https://boundingbox.klokantech.com/ - csv export')
36+
def create_network(**kwargs):
37+
"""Create UrbanAccess accessibility network from the fake gtfs data"""
38+
from .create_network import main
39+
main(**kwargs)
+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import os
2+
import json
3+
import datetime
4+
from pprint import pprint
5+
from textwrap import dedent
6+
from collections import defaultdict
7+
from contextlib import contextmanager
8+
9+
from .. import config, iterate
10+
from ..common import create_unique_path, parse_date_str
11+
12+
13+
def gtfs_escape(val):
14+
return val.replace(',', '').replace('\n', ' ')
15+
16+
17+
def create_calendar(target_path, date: datetime.date):
18+
service_id = '1'
19+
with open(os.path.join(target_path, 'calendar.txt'), 'w') as f:
20+
f.writelines([
21+
'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date\n',
22+
f'{service_id},1,1,1,1,1,1,1,{date.strftime("%Y%m%d")},{date.strftime("%Y%m%d")}\n'
23+
])
24+
return service_id
25+
26+
27+
@contextmanager
28+
def open_files(target_path):
29+
with open(os.path.join(target_path, 'stops.txt'), 'w') as f_stops:
30+
with open(os.path.join(target_path, 'routes.txt'), 'w') as f_routes:
31+
with open(os.path.join(target_path, 'trips.txt'), 'w') as f_trips:
32+
with open(os.path.join(target_path, 'stop_times.txt'), 'w') as f_stop_times:
33+
yield f_stops, f_routes, f_trips, f_stop_times
34+
35+
36+
def create_data(stats, target_path, service_id, date, start_hour, end_hour, min_lon, min_lat, max_lon, max_lat):
37+
added_stop_ids = set()
38+
added_route_ids = set()
39+
added_trip_ids = set()
40+
with open_files(target_path) as (f_stops, f_routes, f_trips, f_stop_times):
41+
f_stops.write('stop_id,stop_name,stop_lat,stop_lon,location_type\n',)
42+
f_routes.write('route_id,route_short_name,route_type\n')
43+
f_trips.write('route_id,service_id,trip_id\n')
44+
f_stop_times.write('trip_id,arrival_time,departure_time,stop_id,stop_sequence\n')
45+
recorded_at_time_from = datetime.datetime.combine(date, datetime.time(start_hour), datetime.timezone.utc)
46+
recorded_at_time_to = datetime.datetime.combine(date, datetime.time(end_hour, 59, 59), datetime.timezone.utc)
47+
for item in iterate('/siri_ride_stops/list', {
48+
'gtfs_stop__lat__greater_or_equal': min_lat,
49+
'gtfs_stop__lat__lower_or_equal': max_lat,
50+
'gtfs_stop__lon__greater_or_equal': min_lon,
51+
'gtfs_stop__lon__lower_or_equal': max_lon,
52+
'gtfs_date_from': date,
53+
'gtfs_date_to': date,
54+
'siri_vehicle_location__recorded_at_time_from': recorded_at_time_from,
55+
'siri_vehicle_location__recorded_at_time_to': recorded_at_time_to,
56+
'siri_ride__scheduled_start_time_from': recorded_at_time_from - datetime.timedelta(hours=10),
57+
'siri_ride__scheduled_start_time_to': recorded_at_time_to + datetime.timedelta(hours=10),
58+
'limit': -1,
59+
}, limit=None):
60+
svl_recorded_at_time = item['nearest_siri_vehicle_location__recorded_at_time'].strftime("%H:%M:%S")
61+
gs_name = gtfs_escape(f'{item["gtfs_stop__city"]}: {item["gtfs_stop__name"]}')
62+
gs_id = item['gtfs_stop_id']
63+
if gs_id not in added_stop_ids:
64+
added_stop_ids.add(gs_id)
65+
f_stops.write(f'{gs_id},{gs_name},{item["gtfs_stop__lat"]},{item["gtfs_stop__lon"]},0\n')
66+
stats['stops'] += 1
67+
grt_id = item['gtfs_ride__gtfs_route_id']
68+
if grt_id not in added_route_ids:
69+
added_route_ids.add(grt_id)
70+
f_routes.write(f'{grt_id},{gtfs_escape(item["gtfs_route__route_short_name"])},3\n')
71+
stats['routes'] += 1
72+
gr_id = item['siri_ride__gtfs_ride_id']
73+
if gr_id not in added_trip_ids:
74+
added_trip_ids.add(gr_id)
75+
f_trips.write(f'{grt_id},{service_id},{gr_id}\n')
76+
stats['trips'] += 1
77+
f_stop_times.write(f'{gr_id},{svl_recorded_at_time},{svl_recorded_at_time},{gs_id},{item["order"]}\n')
78+
stats['stop_times'] += 1
79+
if stats["stop_times"] > 1 and stats["stop_times"] % 1000 == 0:
80+
print(f'saved {stats["stop_times"]} stop times...')
81+
82+
83+
def main(date, start_hour, end_hour, bbox, target_path=None):
84+
if not target_path:
85+
target_path = create_unique_path(os.path.join(config.URBANACCESS_DATA_PATH, 'fake_gtfs'))
86+
target_path_feed = os.path.join(target_path, 'siri_feed')
87+
os.makedirs(target_path_feed)
88+
date = parse_date_str(date)
89+
start_hour = int(start_hour)
90+
end_hour = int(end_hour)
91+
min_lon, min_lat, max_lon, max_lat = [float(v.strip()) for v in bbox.split(',')]
92+
print(dedent(f'''
93+
creating fake gtfs data
94+
target_path={target_path}
95+
date: {date}
96+
hours: {start_hour} - {end_hour}
97+
bbox: {min_lon},{min_lat} - {max_lon},{max_lat}
98+
'''))
99+
stats = defaultdict(int)
100+
service_id = create_calendar(target_path_feed, date)
101+
create_data(stats, target_path_feed, service_id, date, start_hour, end_hour, min_lon, min_lat, max_lon, max_lat)
102+
with open(os.path.join(target_path, 'metadata.json'), 'w') as f:
103+
json.dump({
104+
'start_hour': start_hour,
105+
'end_hour': end_hour,
106+
'bbox': [min_lon, min_lat, max_lon, max_lat]
107+
}, f)
108+
pprint(dict(stats))
109+
print(f'Fake gtfs data successfully stored at "{target_path}"')
110+
return target_path

stride/urbanaccess/create_network.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import os
2+
import json
3+
from textwrap import dedent
4+
5+
6+
import urbanaccess.gtfs.load
7+
import urbanaccess.gtfs.network
8+
import urbanaccess.osm.load
9+
import urbanaccess.osm.network
10+
import urbanaccess.network
11+
import urbanaccess.plot
12+
13+
14+
from .. import config
15+
from ..common import create_unique_path
16+
17+
18+
def main(fake_gtfs_path=None, target_path=None, date=None, start_hour=None, end_hour=None, bbox=None):
19+
if fake_gtfs_path:
20+
assert not date and not start_hour and not end_hour and not bbox
21+
else:
22+
assert date and start_hour and end_hour and bbox
23+
from .create_fake_gtfs import main
24+
fake_gtfs_path = main(date=date, start_hour=start_hour, end_hour=end_hour, bbox=bbox)
25+
if not target_path:
26+
target_path = create_unique_path(os.path.join(config.URBANACCESS_DATA_PATH, 'network'))
27+
assert os.path.exists(os.path.join(fake_gtfs_path, 'siri_feed', 'stop_times.txt'))
28+
assert os.path.exists(os.path.join(fake_gtfs_path, 'metadata.json'))
29+
print(dedent(f"""
30+
Creating urbanaccess network
31+
fake_gtfs_path={fake_gtfs_path}
32+
target_path={target_path}
33+
"""))
34+
with open(os.path.join(fake_gtfs_path, 'metadata.json')) as f:
35+
fake_gtfs_metadata = json.load(f)
36+
start_hour = fake_gtfs_metadata['start_hour']
37+
end_hour = fake_gtfs_metadata['end_hour']
38+
bbox = tuple(fake_gtfs_metadata['bbox'])
39+
loaded_feeds = urbanaccess.gtfs.load.gtfsfeed_to_df(gtfsfeed_path=fake_gtfs_path)
40+
urbanaccess_net = urbanaccess.gtfs.network.create_transit_net(
41+
gtfsfeeds_dfs=loaded_feeds,
42+
day='tuesday', # day doesn't matter because the fake gtfs data has service enabled for all days
43+
timerange=[f'{start_hour:02}:00:00', f'{end_hour:02}:00:00']
44+
)
45+
nodes, edges = urbanaccess.osm.load.ua_network_from_bbox(bbox=bbox, remove_lcn=True)
46+
urbanaccess.osm.network.create_osm_net(osm_edges=edges, osm_nodes=nodes, travel_speed_mph=3)
47+
urbanaccess.network.integrate_network(urbanaccess_network=urbanaccess_net, headways=False)
48+
urbanaccess.network.save_network(urbanaccess_network=urbanaccess_net, dir=target_path, filename='final_net.h5',
49+
overwrite_key=True)
50+
network_path = os.path.join(target_path, "final_net.h5")
51+
print(f'Successfully stored UrbanAccess network at "{network_path}"')
52+
return network_path

stride/urbanaccess/helpers.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import urbanaccess.network
2+
3+
4+
def load_network(network_path):
5+
*dir, filename = network_path.split('/')
6+
dir = '/'.join(dir)
7+
return urbanaccess.network.load_network(dir=dir, filename=filename)

0 commit comments

Comments
 (0)