The transport module starts from a GTFS feed, the standard format agencies publish timetables in. load_gtfs() opens the zip and exposes it as queryable DuckDB tables, adding geometry to stops and shapes without loading anything into memory, which is what makes million-row feeds workable inside Blender. travel_summary_graph() then aggregates the scheduled trips into a stop-to-stop network whose edges carry service frequency and mean travel time for the requested period.
Those edges are scheduled service, not physical connection: two adjacent stops with no line joining them have no edge, however close they are. The notebook also documents where c2g_get_od_pairs diverges from the library, using its own SQL that assumes consecutive stop_sequence values and so returns zero pairs on feeds numbered 0, 10, 20.
No network access needed; it uses the São Paulo feed shipped in the repository.
14 · Public transport (GTFS)
city2graph’s transport module turns a GTFS feed into a network:
load_gtfs() opens the zip as queryable tables, geometry on stops and shapes.
travel_summary_graph() aggregates scheduled trips into stop→stop edges with a service frequency and a mean travel time for the period asked for.
Those edges are scheduled service, not physical connection: two adjacent stops with no line joining them have no edge, however close. No internet needed: the São Paulo feed ships with the repository.
0 · Setup
import pathlibimport sysdef find_repo():"""Locate the SciGraphs repository, without relying on the cwd. In a normal Jupyter the kernel starts per notebook with the cwd set to its folder. Not here: the kernel lives inside Blender and existed before you opened anything, so the cwd is wherever Blender was launched from. What the extension does know is which folder it is serving to JupyterLab. """ candidates = []try:import bpy prefs = bpy.context.preferences.addons["bl_ext.user_default.jupyter_blender"].preferences candidates.append(pathlib.Path(bpy.path.abspath(prefs.notebook_dir)))exceptException:pass candidates.append(pathlib.Path.cwd())for base in candidates:for directory in (base, *base.parents):if (directory /"SciGraphs"/"api"/"graphs.py").is_file():return directoryraiseRuntimeError("Cannot find the SciGraphs repository. Point at it by hand:\n"" sys.path.insert(0, '/path/to/SciGraphs-1/notebooks/tools')")sys.path.insert(0, str(find_repo() /"notebooks"/"tools"))import bpyimport city2graph as c2g# `nb` must import first: it puts this repository on sys.path, so the# `SciGraphs` import below reads this tree, not the copy Blender installed.import nbfrom nb import checkfrom SciGraphs import api as sgFEED = nb.repo("examples", "GTFS", "sao_paulo.zip")print("feed:", nb.rel(FEED), f"({FEED.stat().st_size /1e6:.0f} MB)")check("the feed exists", FEED.exists())
feed: examples/GTFS/sao_paulo.zip (14 MB)
[PASS] the feed exists
True
1 · Load the feed
load_gtfs() returns a DuckDB connection. Nothing is read into memory, so million-row feeds stay queryable in Blender.
con = c2g.load_gtfs(FEED)tables = [row[0] for row in con.execute("SHOW TABLES").fetchall()]print(f"{len(tables)} tables:\n")for table in tables: n = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]print(f" {table:<18}{n:>9,}")
built in 0.6 s
stops 22,143, segments 10,311
edge columns: ['frequency', 'geometry', 'travel_time_sec']
edge index : ['from_stop_id', 'to_stop_id']
Many more stops than segments: most have no service in this window and end up isolated. That is what a relation defined by service, not proximity, looks like.
import numpy as npdegree = np.zeros(len(stops_gdf), dtype=int)position = {sid: i for i, sid inenumerate(stops_gdf.index)}for source, target in segments_gdf.index:if source in position: degree[position[source]] +=1if target in position: degree[position[target]] +=1isolated =int((degree ==0).sum())print(f"stops with service 06:00-10:00 : {len(stops_gdf) - isolated:,}")print(f"stops isolated in this window : {isolated:,}")print("\nfrequency per segment:")print(segments_gdf["frequency"].describe().to_string())print("\ntravel time (s):")print(segments_gdf["travel_time_sec"].describe().to_string())
stops with service 06:00-10:00 : 9,386
stops isolated in this window : 12,757
frequency per segment:
count 1.031100e+04
mean 1.364147e+05
std 1.642531e+05
min 8.270000e+02
25% 3.937200e+04
50% 8.106000e+04
75% 1.609620e+05
max 1.317607e+06
travel time (s):
count 10311.000000
mean 119.782488
std 42.971214
min 53.000000
25% 97.000000
50% 109.000000
75% 130.295819
max 560.000000
4 · Into Blender
Keep the component with service: 22,000 loose vertices add nothing to the viewport and do slow Geometry Nodes.
with_service = stops_gdf[degree >0].copy()valid_segments = segments_gdf[ segments_gdf.index.get_level_values(0).isin(with_service.index)& segments_gdf.index.get_level_values(1).isin(with_service.index)].copy()print(f"{len(with_service):,} stops, {len(valid_segments):,} segments")sg.graphs.clear_scene(keep_anchor=False)anchor = sg.graphs.anchor_from(with_service, scale=0.001, name="Anchor_GTFS")obj_network = sg.graphs.from_gdf( with_service, valid_segments, name="GTFS_Service_06_10", ref=anchor, coll="C2G_Transport", markers={"graph_type": "travel_summary_graph","gtfs_feed": FEED.name,"window": "06:00-10:00"})print(sg.graphs.summary(obj_network))check("the service network is in the scene", obj_network isnotNone)
9,386 stops, 10,311 segments
{'object': 'GTFS_Service_06_10', 'type': 'MESH', 'num_nodes': 9386, 'num_edges': 10308, 'is_directed': False, 'vertices': 9386, 'mesh_edges': 10308, 'attributes': ['position', '.edge_verts', '.corner_vert', '.corner_edge', 'node_id', 'edge_frequency', 'edge_travel_time_sec'], 'graph_type': 'travel_summary_graph'}
[PASS] the service network is in the scene
True
EEVEE figures: real geometry and materials, composable with the rest of the scene. Unlike the neighborhood notebooks, nothing is composed under this one. At tens of kilometers across a building footprint is under a pixel, several hundred thousand of them would have to be downloaded and extruded for a uniform gray wash, and a flat plane adds a dark rectangle and no information.
Orthographic, straight down (render_eevee’s default), look='ink' as in notebooks 00 to 07: near-black backdrop, turbo ramp on edge_frequency, buses per hour in the 06:00-10:00 window, dark blue for the quiet lines to red for the busiest.
clip_high_pct=98: frequency is heavily skewed, so a few central segments carrying many times the median would take the top of the ramp and collapse every other line into the bottom stop. The 98th percentile spends the scale on the other 98 %.
Numeric columns become mesh attributes: node_* on POINT, edge_* on EDGE.
for name, domain, dtype in sg.graphs.attributes(obj_network):print(f" {name:<28}{domain:<7}{dtype}")
position POINT FLOAT_VECTOR
.edge_verts EDGE INT32_2D
.corner_vert CORNER INT
.corner_edge CORNER INT
node_id POINT INT
edge_frequency EDGE FLOAT
edge_travel_time_sec EDGE FLOAT
edge_frequency is on the EDGE domain, so the colormap lands on the tubes. color_graph() re-domains it onto the points at the head of the Geometry Nodes tree, since it cannot survive Mesh to Curve → Curve to Mesh.
Promoted 2 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Info: Attribute -> edge_frequency · edge_frequency -> inferno [log] [827 … 6.889e+05] on Point
edge_frequency -> inferno
bpy.data.objects['GTFS_Service_06_10']
6 · Origin-destination pairs: get_od_pairs()
The other output: pairs of stops connected by the same trip rather than consecutive segments. Feeds notebook 15’s flow analysis.
t0 = time.time()od = c2g.get_od_pairs( con, start_date=str(date_range[0]), end_date=str(date_range[0]), # a single day: keeps the size manageable include_geometry=True, directed=False,)print(f"{time.time() - t0:.1f} s")# Unlike travel_summary_graph(), this returns one GeoDataFrame of pairs, not# the (nodes, edges) tuple.print(f"OD pairs: {len(od):,}")print("columns:", list(od.columns))od.head(3)
6.5 s
OD pairs: 77,374
columns: ['trip_id', 'service_id', 'orig_stop_id', 'dest_stop_id', 'date', 'departure_ts', 'arrival_ts', 'travel_time_sec', 'geometry']
trip_id
service_id
orig_stop_id
dest_stop_id
date
departure_ts
arrival_ts
travel_time_sec
geometry
0
1012-10-0
USD
30003038
301646
2023-10-01
2023-10-01 07:28:00
2023-10-01 07:26:40
80.0
LINESTRING (-46.80332 -23.44653, -46.80044 -23...
1
1012-10-0
USD
301671
301701
2023-10-01
2023-10-01 07:38:40
2023-10-01 07:40:00
80.0
LINESTRING (-46.80574 -23.43718, -46.80415 -23...
2
1012-10-0
USD
301672
301768
2023-10-01
2023-10-01 07:36:00
2023-10-01 07:37:20
80.0
LINESTRING (-46.80704 -23.43923, -46.80593 -23...
Note.bpy.ops.scigraphs.c2g_get_od_pairs() does not call get_od_pairs(); its own SQL query, for memory reasons documented in the code, joins on stop_sequence = stop_sequence + 1 and so assumes consecutive numbering. The GTFS spec only requires stop_sequence to increase, and many feeds number 0, 10, 20… With one of those the operator returns zero pairs while the direct call works. This feed numbers one by one, so the two agree.
7 · Compare with the SciGraphs operator
The operators read scene.city2graph, so write there first.
Loading GTFS data from examples/GTFS/sao_paulo.zip
GTFS loaded with tables: agency, calendar, fare_attributes, fare_rules, frequencies, routes, shapes, stop_times, stops, trips
Stops: 22143
Routes: 1352
Info: Loading GTFS data from examples/GTFS/sao_paulo.zip
Info: GTFS data loaded successfully
c2g_import_gtfs -> {'FINISHED'}
scene['c2g_gtfs_loaded'] = True
registered tables = ['agency', 'calendar', 'fare_attributes', 'fare_rules', 'frequencies', 'routes', 'shapes', 'stop_times', 'stops', 'trips']
# These are enums over the dates the import wrote into# scene["c2g_gtfs_dates"], so only those values assign. Feeds whose calendar# has no empty option raise an enum TypeError on "".dates =list(scene.get("c2g_gtfs_dates") or [])print(f"{len(dates)} dates available: {dates[0]} … {dates[-1]}")props.gtfs_calendar_start = dates[0]props.gtfs_calendar_end = dates[min(6, len(dates) -1)] # one weekresult = bpy.ops.scigraphs.c2g_travel_summary_graph()print("c2g_travel_summary_graph ->", result)objs_gtfs = [o for o in bpy.data.objects if o.get("is_travel_graph")]for obj in objs_gtfs:print(" ", sg.graphs.summary(obj))check("the operator produced a travel graph", len(objs_gtfs) >0)
367 dates available: 20231001 … 20241001
Creating travel summary graph (start=None, end=None, cal_start=20231001, cal_end=20231007)...
Creating graph object with 22,143 nodes...
Coordinates applied for 22143/22143 nodes in 0.01s
Vertices created in 0.02s
Creating edges: 34% (10,000/29,618)
Creating edges: 68% (20,000/29,618)
29,602 edges created in 0.02s
16 self-loops removed
Mesh conversion in 0.00s
Object created in 0.00s
DataFrame columns: ['source', 'target', 'frequency', 'travel_time_sec']
DataFrame shape: (29618, 4)
Source column: source, Target column: target
Number of mesh vertices: 22143
Number of mesh edges: 29602
Processing column 'frequency': int64
Created edge attribute 'edge_frequency'
Created vertex attributes for 'frequency': sum, mean, min, max, count
Processing column 'travel_time_sec': float64
Created edge attribute 'edge_travel_time_sec'
Created vertex attributes for 'travel_time_sec': sum, mean, min, max, count
Imported 2 attribute columns
Attributes imported in 1.11s
Total graph creation time: 1.16s
Travel summary graph: 22143 stops, 29618 edges, 2 edge attrs
Info: Creating travel summary graph...
Info: Travel summary graph created successfully
c2g_travel_summary_graph -> {'FINISHED'}
{'object': 'Travel_Summary_Graph', 'type': 'MESH', 'num_nodes': 22143, 'num_edges': 29602, 'is_directed': False, 'vertices': 22143, 'mesh_edges': 29602, 'attributes': ['position', '.edge_verts', '.corner_vert', '.corner_edge', 'edge_frequency', 'vertex_frequency_sum', 'vertex_frequency_mean', 'vertex_frequency_min', 'vertex_frequency_max', 'vertex_frequency_count', 'edge_travel_time_sec', 'vertex_travel_time_sec_sum', 'vertex_travel_time_sec_mean', 'vertex_travel_time_sec_min', 'vertex_travel_time_sec_max', 'vertex_travel_time_sec_count', 'node_id']}
[PASS] the operator produced a travel graph
True
Next to the graph from cell 4. Same look, same attribute where the operator wrote one: a comparison in which the palette also changes is not a comparison. The frequency column does not always come through, so it is looked up; absent, the look’s neutral applies.
for obj in objs_gtfs[:1]: attribute = ("edge_frequency"if sg.render.attribute_domain(obj, "edge_frequency") elseNone)print("coloring by:", attribute or"(nothing: the operator wrote no frequency)") nb.figure(obj, f"renders/14_gtfs/2_operator_{obj.name}", look='ink', color_attribute=attribute, clip_high_pct=98)
coloring by: edge_frequency
Promoted 2 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Info: Attribute -> edge_frequency · edge_frequency -> turbo [log] [5 … 6172] on Point
[PASS] 2_operator_Travel_Summary_Graph.png legible — 8.8% ink (healthy range 0.5–60%)
The counts need not match cell 3: start_time/end_time are not exposed in the panel, only the calendar range, so no time-of-day filter here. A difference of parameters, not of implementation.
Change FEED in cell 0 and re-run. A feed using calendar_dates.txt rather than calendar.txt leaves the calendar cell empty; the graph builds anyway.
con.close()print("connection closed")
connection closed
Rendering
Notebook 13 covers draw_eevee(). PNGs land in notebooks/out/renders/.
Vulkan warning, for the SciGraphs engine notebooks 17 and 06 keep for edge sparsification: on Blender’s Vulkan backend (the default on Linux) gpu.state.point_size_set does nothing for the add-on’s shaders, so POINT and DISK nodes come out 1 pixel wide. Start Blender with --gpu-backend opengl for any render you mean to look at.