History of kujala_public_transport
The changes to the source code of this entry in the repository, newest first, each with the differences it made to the entry's files. They are also listed on GitLab.
[entries] New entry: kujala_public_transport 85fbde4
diff --git a/entries/kujala_public_transport/__init__.py b/entries/kujala_public_transport/__init__.py
new file mode 100644
index 0000000..d2cffe0
--- /dev/null
+++ b/entries/kujala_public_transport/__init__.py
@@ -0,0 +1,272 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2026 Tiago de Paula Peixoto <tiago@skewed.de>
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import ast
+import csv
+import sqlite3
+
+import pandas
+
+from .. import *
+
+cities = ["adelaide", "belfast", "berlin", "bordeaux", "brisbane", "canberra",
+ "detroit", "dublin", "grenoble", "helsinki", "kuopio", "lisbon",
+ "luxembourg", "melbourne", "nantes", "palermo", "paris", "prague",
+ "rennes", "rome", "sydney", "toulouse", "turku", "venice", "winnipeg"]
+
+title = "Public transport networks of 27 cities (2014-2017)"
+description = r"""Public transport networks of 27 cities around the world (Adelaide, Antofagasta, Athens, Belfast, Berlin, Bordeaux, Brisbane, Canberra, Detroit, Dublin, Grenoble, Helsinki, Kuopio, Lisbon, Luxembourg, Melbourne, Nantes, Palermo, Paris, Prague, Rennes, Rome, Sydney, Toulouse, Turku, Venice and Winnipeg), compiled by Kujala, Weckström, Darst, Mladenović and Saramäki from the timetables that the public transport agencies published in the General Transit Feed Specification (GTFS) format, downloaded in 2016 and 2017. The timetables were curated for errors, restricted to a circular area around each city center (of radius 10 to 50 km, depending on the city) and to the trips of one day (a typical Monday within the period covered by each timetable: in most cities in December 2016, and between September 2014 and November 2017 in the others; the date is stored in the network property `extract_date`), and complemented with walking distances between stops, computed from OpenStreetMap. The data of 25 cities come from version 1.2 of the data set, in which the day extends from 03:00 on the Monday to 03:00 on the next day. Antofagasta and Athens, whose data may not be used commercially, were removed from the data set after its version 1.0, from which they are taken here (in that version, the day is delimited slightly differently). The networks of the other 25 cities in version 1.0, which differ from those of version 1.2 in this delimitation of the day, are also included, as networks with the suffix `_v1.0` (e.g. `helsinki_v1.0`, `helsinki_temporal_v1.0`); their files are identical in version 1.1, which only removed Antofagasta and Athens. The walking networks of version 1.0 are included only for the 13 cities where they (and the lists of stops) differ from those of version 1.2 (Bordeaux, Brisbane, Canberra, Detroit, Grenoble, Helsinki, Lisbon, Melbourne, Nantes, Paris, Prague, Rome and Toulouse; e.g. `helsinki_walk_v1.0`); in the other cities, they are identical.
+
+There are three networks for each city (four for the 25 cities of version 1.2), whose nodes are the public transport stops of the city:
+
+- `<city>` (e.g. `helsinki`): the combined (multimodal) static network: a directed edge connects two stops if at least one vehicle travels from the first to the second without intermediate stops during the day. There is one edge for each pair of consecutive stops and each mode of transport (bus, tram, subway, rail, ferry, ...), so that two stops served by several modes are connected by parallel edges, one per mode (the layers of a multilayer network). A few networks contain self-loops (up to 13, in `adelaide`), as in the source.
+- `<city>_temporal` (e.g. `helsinki_temporal`): the temporal network of the day: each directed edge is a single vehicle traveling between two consecutive stops (a "connection"), with its departure and arrival times.
+- `<city>_temporal_week` (e.g. `helsinki_temporal_week`, only for the 25 cities of version 1.2): the temporal network of the whole week (Monday to Sunday) from which the day was extracted, in the same format. These networks also include the stops that are served during the week but not on the day of the stop list, whose names and positions are taken from the timetable database of the source.
+- `<city>_walk` (e.g. `helsinki_walk`): the walking network: an undirected edge connects two stops within walking distance of each other (up to 1 km, measured along the street network), with the walking distance. These networks also include the stops that are referenced by the walking network of the source but not in its list of stops (e.g. parent stations, or stops not served on that day), whose names and positions are taken from the timetable database of the source.
+
+The stops that are not served during the day are kept as isolated nodes. The temporal networks of the week have about 73 million connections in all (11.5 million in Paris).
+
+The data of each city come with their own license: Creative Commons Attribution 4.0 (Adelaide, Canberra, Dublin, Helsinki, Kuopio, Melbourne, Palermo, Sydney, Turku), Creative Commons Attribution 3.0 Germany (Berlin), Australia (Brisbane) or Italy (Rome, Venice), the Open Database License (Belfast, Bordeaux, Grenoble, Nantes, Paris, Rennes, Toulouse), CC0 (Detroit, Lisbon, Luxembourg, Prague), the ODC Public Domain Dedication and License (Winnipeg), and Creative Commons Attribution-NonCommercial 2.0 (Antofagasta) or 4.0 (Athens). The walking distances are derived from OpenStreetMap data (Open Database License). The license of each city is stored in the network property `license`.
+
+The networks have the following properties:
+
+- `license`: the license of the city's data, as given in the source.
+- `extract_date`: the date of the Monday whose trips are included (YYYY-MM-DD); for the networks of the week, the first day of the week.
+- Nodes: `stop_id`, the stop index of the source (`stop_I`); `name`, the name of the stop; `lat` and `lon`, the latitude and longitude of the stop.
+- Edges of `<city>`: `route_type`, the mode of transport, as a GTFS route type: $0$ (tram), $1$ (subway), $2$ (rail), $3$ (bus), $4$ (ferry), $5$ (cable car), $6$ (gondola) or $7$ (funicular); `distance`, the straight-line distance between the stops, in meters; `duration_avg`, the average travel time between the stops, in seconds; `n_vehicles`, the number of vehicles that traveled between the stops during the day; `route_counts`, the number of vehicles of each route, as a comma-separated list of `route:count` pairs (with the route indices of the source).
+- Edges of `<city>_temporal` and `<city>_temporal_week`: `dep_time` and `arr_time`, the departure and arrival times (Unix time); `route_type`, the mode of transport, as above; `trip`, the index of the vehicle's trip; `seq`, the position of the connection in the trip; `route`, the index of the route.
+- Edges of `<city>_walk`: `distance`, the straight-line distance between the stops, in meters; `walk_distance`, the walking distance, in meters."""
+tags = ['Transportation', 'Public Transport', 'Spatial', 'Multilayer', 'Weighted', 'Multigraph', 'Timestamps', 'Metadata']
+url = ['https://doi.org/10.5281/zenodo.1186215',
+ 'https://doi.org/10.5281/zenodo.1133858']
+citation = [('R. Kujala, C. Weckström, R. K. Darst, M. N. Mladenović, J. Saramäki, "A collection of public transport network data sets for 25 cities." Scientific Data 5, 180089 (2018)', 'https://doi.org/10.1038/sdata.2018.89')]
+icon_hash = None
+upstream_license = ("Per city: CC BY 4.0, CC BY 3.0 (Germany, Australia, Italy), ODbL 1.0, CC0, PDDL, or CC BY-NC 2.0/4.0 (Antofagasta, Athens) (see the license.txt file of each city)",
+ "https://doi.org/10.5281/zenodo.1186215")
+# Version 1.2 of the data set (Zenodo record, not the concept record); the
+# files of the 25 cities in version 1.1 (record 1136378) are identical to those
+# of version 1.0, which are used
+upstream_prefix = 'https://zenodo.org/records/1186215/files'
+
+# Antofagasta and Athens, whose data may not be used commercially, are only in
+# version 1.0 of the data set (a single archive of all cities)
+v10_prefix = 'https://zenodo.org/records/1133858/files'
+v10_cities = ["antofagasta", "athens"]
+
+def city_files(archive, c):
+ base = [f"{archive}:{c}/network_nodes.csv", f"{archive}:{c}/license.txt",
+ f"{archive}:{c}/stats.csv"]
+ temporal = ("network_temporal.csv" if archive == "all.zip" else
+ "network_temporal_day.csv")
+ return [(tuple(base + [f"{archive}:{c}/network_combined.csv"]), c, None),
+ (tuple(base + [f"{archive}:{c}/network_walk.csv",
+ f"{archive}:{c}/week.sqlite"]), f"{c}_walk", None),
+ (tuple(base + [f"{archive}:{c}/{temporal}"]), f"{c}_temporal", None)]
+
+# The cities whose walking network (and list of stops) differ between
+# versions 1.0 and 1.2
+walk_differs = ["bordeaux", "brisbane", "canberra", "detroit", "grenoble",
+ "helsinki", "lisbon", "melbourne", "nantes", "paris", "prague",
+ "rome", "toulouse"]
+
+def v10_copies(c):
+ # The networks of version 1.0 of a city that is also in version 1.2
+ fs = city_files("all.zip", c)
+ return [(fnames, f"{alt}_v1.0", fmt) for fnames, alt, fmt in fs
+ if not alt.endswith("_walk") or c in walk_differs]
+
+def week_files(c):
+ archive = f"{c}.zip"
+ return [((f"{archive}:{c}/network_nodes.csv", f"{archive}:{c}/license.txt",
+ f"{archive}:{c}/stats.csv",
+ f"{archive}:{c}/network_temporal_week.csv",
+ f"{archive}:{c}/week.sqlite"),
+ f"{c}_temporal_week", None)]
+
+v12_files = [f for c in cities for f in city_files(f"{c}.zip", c) + week_files(c)]
+v10_files = ([f for c in v10_cities for f in city_files("all.zip", c)] +
+ [f for c in cities for f in v10_copies(c)])
+files = sorted(v12_files + v10_files, key=lambda f: f[1])
+
+def network_kind(alt):
+ """The kind of network: "combined", "walk", "temporal" or "temporal_week"."""
+ base = alt.removesuffix("_v1.0")
+ for kind in ["temporal_week", "temporal", "walk"]:
+ if base.endswith("_" + kind):
+ return kind
+ return "combined"
+
+def fetch_upstream(force=False):
+ name = __name__.split(".")[-1]
+ fetch_upstream_files(name, upstream_prefix, v12_files, force)
+ return fetch_upstream_files(name, v10_prefix, v10_files, force)
+
+def read_csv(name, fname, header, delimiter=";"):
+ with open_upstream_file(name, fname, "r") as f:
+ reader = csv.reader(f, delimiter=delimiter)
+ h = next(reader)
+ assert h == header, (fname, h)
+ yield from reader
+
+def read_stops(name, fname, stops):
+ "The given stops (stop_I, name, lat, lon) from the SQLite database fname."
+ os.makedirs("cache/temp", exist_ok=True)
+ with tempfile.NamedTemporaryFile(dir="cache/temp", suffix=".sqlite") as tmp:
+ with open_upstream_file(name, fname, "rb") as f:
+ shutil.copyfileobj(f, tmp, 1 << 24)
+ tmp.flush()
+ con = sqlite3.connect(f"file:{tmp.name}?mode=ro", uri=True)
+ try:
+ rows = con.execute("SELECT stop_I, name, lat, lon FROM stops").fetchall()
+ finally:
+ con.close()
+ rows = [r for r in rows if r[0] in stops]
+ assert len(rows) == len(stops), (fname, len(rows), len(stops))
+ return sorted(rows)
+
+@cache_network()
+@coerce_props()
+@annotate()
+def parse(alts=None):
+ global files
+ name = __name__.split(".")[-1]
+ for fnames, alt, fmt in files:
+ if alts is not None and alt not in alts:
+ continue
+ kind = network_kind(alt)
+ g = Graph(directed=(kind != "walk"))
+ g.vp.stop_id = g.new_vp("int")
+ g.vp.name = g.new_vp("string")
+ g.vp.lat = g.new_vp("double")
+ g.vp.lon = g.new_vp("double")
+ idx = {}
+ for stop, lat, lon, sname in read_csv(name, fnames[0],
+ ["stop_I", "lat", "lon", "name"]):
+ v = g.add_vertex()
+ idx[stop] = int(v)
+ g.vp.stop_id[v] = int(stop)
+ g.vp.lat[v] = float(lat)
+ g.vp.lon[v] = float(lon)
+ g.vp.name[v] = sname
+
+ v10 = fnames[3].startswith("all.zip:")
+ if kind == "combined" and v10:
+ # Version 1.0: the attributes of each link as a Python dict
+ rows = read_csv(name, fnames[3],
+ ["from_stop_I", "to_stop_I", "attr_dict"])
+ eprops = [("route_type", "int"), ("distance", "double"),
+ ("duration_avg", "double"), ("n_vehicles", "int"),
+ ("route_counts", "string")]
+ def v10_edges():
+ for u, v, attrs in rows:
+ a = ast.literal_eval(attrs)
+ counts = ",".join(f"{r}:{c}" for r, c in
+ a["route_I_counts"].items())
+ yield (idx[u], idx[v], int(a["route_type"]), float(a["d"]),
+ float(a["duration_avg"]), int(a["n_vehicles"]),
+ counts)
+ edges = v10_edges()
+ elif kind == "combined":
+ rows = read_csv(name, fnames[3],
+ ["from_stop_I", "to_stop_I", "d", "duration_avg",
+ "n_vehicles", "route_I_counts", "route_type"])
+ eprops = [("route_type", "int"), ("distance", "double"),
+ ("duration_avg", "double"), ("n_vehicles", "int"),
+ ("route_counts", "string")]
+ edges = ((idx[u], idx[v], int(rtype), float(d), float(dur),
+ int(nv), counts)
+ for u, v, d, dur, nv, counts, rtype in rows)
+ elif kind == "walk":
+ rows = list(read_csv(name, fnames[3],
+ ["from_stop_I", "to_stop_I", "d", "d_walk"]))
+ # The walking network includes stops that are not in the stop
+ # list (e.g. parent stations, or stops not served on that day):
+ # they are taken from the database of the source
+ missing = {int(x) for r in rows for x in r[:2] if x not in idx}
+ if missing:
+ for stop, sname, lat, lon in read_stops(name, fnames[4], missing):
+ v = g.add_vertex()
+ idx[str(stop)] = int(v)
+ g.vp.stop_id[v] = stop
+ g.vp.lat[v] = lat
+ g.vp.lon[v] = lon
+ g.vp.name[v] = sname
+ eprops = [("distance", "double"), ("walk_distance", "double")]
+ edges = ((idx[u], idx[v], float(d), float(dw))
+ for u, v, d, dw in rows)
+ elif kind == "temporal_week":
+ # About 73 million connections in all (11.5 million in Paris): read
+ # as arrays
+ eprops = [("dep_time", "int64_t"), ("arr_time", "int64_t"),
+ ("route_type", "int"), ("trip", "int"), ("seq", "int"),
+ ("route", "int")]
+ cols = ["from_stop_I", "to_stop_I", "dep_time_ut", "arr_time_ut",
+ "route_type", "trip_I", "seq", "route_I"]
+ with open_upstream_file(name, fnames[3], "rb") as f:
+ d = pandas.read_csv(f, sep=";", dtype="int64", engine="c")
+ assert list(d.columns) == cols, (fnames[3], list(d.columns))
+ edges = d.to_numpy()
+ del d
+ # The connections of the week also involve stops that are not
+ # served on the day of the stop list: they are taken from the
+ # database of the source
+ stops = numpy.unique(edges[:, :2])
+ missing = {int(x) for x in stops if str(x) not in idx}
+ if missing:
+ for stop, sname, lat, lon in read_stops(name, fnames[4], missing):
+ v = g.add_vertex()
+ idx[str(stop)] = int(v)
+ g.vp.stop_id[v] = stop
+ g.vp.lat[v] = lat
+ g.vp.lon[v] = lon
+ g.vp.name[v] = sname
+ lut = numpy.full(max(int(x) for x in idx) + 1, -1, dtype="int64")
+ for stop, v in idx.items():
+ lut[int(stop)] = v
+ for j in range(2):
+ assert edges[:, j].max() < len(lut)
+ edges[:, j] = lut[edges[:, j]]
+ assert edges[:, j].min() >= 0
+ else:
+ rows = read_csv(name, fnames[3],
+ ["from_stop_I", "to_stop_I", "dep_time_ut",
+ "arr_time_ut", "route_type", "trip_I", "seq",
+ "route_I"], delimiter="," if v10 else ";")
+ eprops = [("dep_time", "int64_t"), ("arr_time", "int64_t"),
+ ("route_type", "int"), ("trip", "int"), ("seq", "int"),
+ ("route", "int")]
+ edges = ((idx[u], idx[v], int(dep), int(arr), int(rtype),
+ int(trip), int(seq), int(route))
+ for u, v, dep, arr, rtype, trip, seq, route in rows)
+ eps = [g.new_ep(t) for k, t in eprops]
+ g.add_edge_list(edges, eprops=eps)
+ for (k, t), ep in zip(eprops, eps):
+ g.ep[k] = ep
+
+ with open_upstream_file(name, fnames[1], "r") as f:
+ g.gp.license = g.new_gp("string", f.read().strip())
+ with open_upstream_file(name, fnames[2], "r") as f:
+ stats = next(csv.DictReader(f, delimiter=";"))
+ g.gp.extract_date = g.new_gp("string", stats["extract_start_date"])
+ yield alt, g
+
+
+def get_pos(g, sbm_fit):
+ # Spatial network: laid out with the default parameters of cfd_layout(),
+ # without the partition of the SBM fit
+ return cfd_layout(g)