Geographical question: What can aggregated origin–destination connections tell us about movement, and what do their lines not represent?
Learning objectives: inspect node and OD tables, select a time window, aggregate flows, check foreign-key integrity and prepare a flow-map export. Related reading: Part 1, geovisualisation and geographical storytelling.
This is a self-contained worked demonstration with fictional stops and synthetic flow counts. It is not an analysis of observed Singapore bus ridership. The original course flowmap.gl notebook remains in the repository pending an audit of its missing input files.
Prerequisites: pandas and matplotlib. No Google Drive or external API is needed.
1. Construct and inspect a miniature OD dataset¶
Every stop has a unique stable ID. A movement record represents an OD count within an hour, not a physical route geometry.
import pandas as pd
import matplotlib.pyplot as plt
from pathlib import Path
stops = pd.DataFrame({
"id":["A","B","C","D","E"],
"name":["Central","East","North","West","South"],
"lat":[1.300,1.322,1.351,1.317,1.281],
"lon":[103.82,103.91,103.85,103.77,103.86]
})
flows = pd.DataFrame([
("weekday",7,"A","B",30),("weekday",7,"B","A",12),
("weekday",8,"A","B",40),("weekday",8,"A","C",21),
("weekday",8,"C","D",16),("weekday",9,"D","E",5),
("weekend",8,"A","B",8),("weekend",8,"B","E",14)
],columns=["day_type","hour","origin","dest","count"])
assert stops["id"].is_unique
assert (flows["count"]>=0).all()
print(stops)
print(flows)2. Filter and aggregate¶
Compare a weekday morning window (hours 7 and 8, end-exclusive 9) with other times. Counts for the same OD pair are summed.
subset = flows[(flows.day_type=="weekday") & flows.hour.between(7,8)]
od = subset.groupby(["origin","dest"],as_index=False)["count"].sum()
print(od.sort_values("count",ascending=False))
valid = set(stops["id"])
assert set(od["origin"]).issubset(valid)
assert set(od["dest"]).issubset(valid)
print("Total synthetic OD count:",od["count"].sum())3. Plot an explanatory schematic¶
Straight lines connecting stop coordinates are not a transit route or individual trajectories. They are a deliberately simplified display of OD relationships.
p=stops.set_index("id")
fig,ax=plt.subplots(figsize=(7,5))
ax.scatter(stops.lon,stops.lat,color="black",s=30)
for _,stop in stops.iterrows():
ax.annotate(stop["id"],(stop["lon"],stop["lat"]),xytext=(4,3),textcoords="offset points")
for _,f in od.iterrows():
u,v=p.loc[f.origin],p.loc[f.dest]
ax.plot([u.lon,v.lon],[u.lat,v.lat],alpha=.55,linewidth=.4+f["count"]/16)
ax.set(xlabel="Longitude",ylabel="Latitude",title="Synthetic OD associations (not route paths)")
plt.show()4. Prepare reusable files¶
The two tables below match the basic structure expected by many flow mapping tools: locations with IDs and coordinates, and OD pairs with weights. Exact flowmap.gl import field names and platform settings should be checked against the current tool before publication as a ready-made preset.
locations = stops[["id","name","lat","lon"]].copy()
flow_export = od.rename(columns={"origin":"origin","dest":"dest","count":"count"})
assert locations["id"].is_unique
assert set(flow_export.origin).union(flow_export.dest).issubset(set(locations.id))
# Uncomment to create files in the current working directory:
# locations.to_csv("flow-locations.csv",index=False)
# flow_export.to_csv("flow-connections.csv",index=False)
print(locations.head())
print(flow_export.head())Interpretation and reflection¶
What is the observational unit: the origin, the destination, or a counted OD pair?
Would this map justify a claim about the route vehicles or passengers travelled? Why not?
How would changing the time window change which connections are emphasised?
Which additional data would be necessary to map actual routes rather than OD associations?
Extension: Try a different time window and compare its strongest OD pair with the weekday 07:00–09:00 example.