logo Netzschleuder network catalog, repository, and centrifuge

History of national_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] Add the date when each entry was added, and remove icon_hash by Tiago de Paula Peixoto beff7c9
The dates are those of the commits that first added the entries' files
(2020-08-11 for the entries of the initial commit); renamed entries keep
the date of their first name.

Changed: none
diff --git a/entries/national_public_transport/__init__.py b/entries/national_public_transport/__init__.py
index 8aa5133..d07f578 100644
--- a/entries/national_public_transport/__init__.py
+++ b/entries/national_public_transport/__init__.py
@@ -62,7 +62,7 @@ citation = [('gtfs.de, DELFI e.V., "GTFS for Germany: local, regional and long-d
             ('National Transport Authority, "Transport for Ireland GTFS (all operators)." Dataset, Mobility Database copy mdb-2364 of October 28 (2026)', 'https://www.transportforireland.ie/transitData/PT_Data.html')]
 upstream_license = ("Germany and Ireland: CC BY 4.0; Netherlands: CC0 1.0; Norway: Norwegian Licence for Open Government Data (NLOD); Switzerland: terms of use of opentransportdata.swiss",
                     "https://creativecommons.org/licenses/by/4.0/")
-icon_hash = None
+date_added = '2026-10-31'
 
 # The national GTFS feeds, pinned to the dated copies of the Mobility
 # Database (the feeds of the producers are replaced in place every day)
[entries] Prefix the properties in the descriptions with (Node), (Edge) or (Graph) by Tiago de Paula Peixoto bc3dbbd
The lists of properties at the end of the descriptions marked edge
properties with "Edge" but left node properties unmarked, or grouped
them in "Nodes: `a`, ...; `b`, ..." items, so the kind of a property
was often unclear. Every item now starts with (Node), (Edge) or (Graph),
followed by the property names, a colon and the explanation, one item
per property or group of properties; qualifications (kinds of nodes,
networks) follow the names in parentheses. Properties that were only
named got a short explanation, and a few items that named the wrong
kind of property were corrected.
diff --git a/entries/national_public_transport/__init__.py b/entries/national_public_transport/__init__.py
index 9949cd2..8aa5133 100644
--- a/entries/national_public_transport/__init__.py
+++ b/entries/national_public_transport/__init__.py
@@ -38,9 +38,18 @@ The stops (or stations) without any trip are kept as isolated nodes. The network
 
 The networks have the following properties:
 
-- Nodes of `<country>_stops`: `stop_id`, the id of the stop in the feed; `name`; `lat` and `lon`, its position; `parent_station`, the id of its station (empty if none); `platform_code`, the platform (empty if not given).
-- Nodes of `<country>_stations`: `station_id`, the id of the station (or of the stop, for stops without a station); `name`; `lat` and `lon`; `num_stops`, the number of stops of the station (0 for stops without a station).
-- Edges: `route_type`, the mode of transport, as a GTFS route type (e.g. 0 tram, 1 subway, 2 rail, 3 bus, 4 ferry, 5 cable tram, 6 aerial lift, 7 funicular, or the extended route types, e.g. 100-117 railway services, 700-717 bus services, 900 tram, 1000 water transport, 1300 aerial lift; -1 if not given); `n_trips`, the number of trips of the feed that travel between the two stops (each trip is a pattern of service, which can run on many days); `n_departures`, the number of departures in the period of validity of the feed (the trips multiplied by the number of days on which they run, from the calendars of the feed); `duration_avg`, the average travel time between the two stops (seconds, from the departure at the first stop to the arrival at the second, over the trips; NaN if not given); `distance`, the straight-line distance between the two stops (meters)."""
+- (Node) `stop_id` (`<country>_stops`): the id of the stop in the feed.
+- (Node) `station_id` (`<country>_stations`): the id of the station (or of the stop, for stops without a station).
+- (Node) `name`: the name of the stop or station.
+- (Node) `lat`, `lon`: its position.
+- (Node) `parent_station` (`<country>_stops`): the id of its station (empty if none).
+- (Node) `platform_code` (`<country>_stops`): the platform (empty if not given).
+- (Node) `num_stops` (`<country>_stations`): the number of stops of the station (0 for stops without a station).
+- (Edge) `route_type`: the mode of transport, as a GTFS route type (e.g. 0 tram, 1 subway, 2 rail, 3 bus, 4 ferry, 5 cable tram, 6 aerial lift, 7 funicular, or the extended route types, e.g. 100-117 railway services, 700-717 bus services, 900 tram, 1000 water transport, 1300 aerial lift; -1 if not given).
+- (Edge) `n_trips`: the number of trips of the feed that travel between the two stops (each trip is a pattern of service, which can run on many days).
+- (Edge) `n_departures`: the number of departures in the period of validity of the feed (the trips multiplied by the number of days on which they run, from the calendars of the feed).
+- (Edge) `duration_avg`: the average travel time between the two stops (seconds, from the departure at the first stop to the arrival at the second, over the trips; NaN if not given).
+- (Edge) `distance`: the straight-line distance between the two stops (meters)."""
 tags = ['Transportation', 'Public Transport', 'Spatial', 'Multilayer', 'Weighted', 'Multigraph', 'Metadata']
 url = ['https://mobilitydatabase.org', 'https://files.mobilitydatabase.org',
        'https://gtfs.de/en/feeds/', 'https://gtfs.ovapi.nl',
[entries] New entry: national_public_transport by Tiago de Paula Peixoto bcde6fa
diff --git a/entries/national_public_transport/__init__.py b/entries/national_public_transport/__init__.py
new file mode 100644
index 0000000..9949cd2
--- /dev/null
+++ b/entries/national_public_transport/__init__.py
@@ -0,0 +1,262 @@
+#! /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/>.
+
+from .. import *
+import shutil
+from collections import Counter
+
+title = "National public transport networks (2026)"
+description = """Public transport networks of five European countries (Germany, the Netherlands, Norway, Switzerland and Ireland), built from the national timetables that their transport authorities or aggregators publish in the General Transit Feed Specification (GTFS) format: all the scheduled trips of buses, trams, subways, trains, ferries, cable cars, etc. in the country (and the cross-border trips). The feeds are replaced every day; the networks are built from the dated copies kept by the Mobility Database:
+
+- `de`: the three feeds of gtfs.de, based on the data of DELFI e.V. (local public transport, regional rail and long-distance rail, which share their stop ids), of October 25, 2026, valid from October 24 to November 23, 2026;
+- `nl`: the feed of OVapi, of October 28, 2026, valid from October 26 to December 12, 2026;
+- `no`: the national feed of Entur, of October 28, 2026 (whose calendars extend, for some services, from 2019 to 2029);
+- `ch`: the feed of the 2026 timetable of opentransportdata.swiss (Systemaufgaben Kundeninformation SKI+), in its version of May 27, 2026, valid from December 14, 2025 to December 12, 2026 (the copy of the Mobility Database was not updated afterwards);
+- `ie`: the feed of all operators of Transport for Ireland (National Transport Authority), of October 28, 2026, valid from October 26, 2026 to October 27, 2027.
+
+Each network is the "space of stops" (L-space) of the timetable: a directed edge connects two stops if at least one trip travels from the first to the second without intermediate stops. There is one edge for each pair of consecutive stops and each GTFS route type (mode of transport), so that two stops served by several modes are connected by parallel edges, one per mode, as in `kujala_public_transport`. For each country there are two networks (only `ie_stops` for Ireland, whose feed has no stations):
+
+- `<country>_stops` (e.g. `de_stops`): the nodes are the stops (or platforms) where the vehicles stop (GTFS stops of location type 0), and the stations that are directly served;
+- `<country>_stations` (e.g. `de_stations`): the stops are grouped into their parent stations (GTFS stops of location type 1), where the feed gives them, and the other stops are kept as they are. Consecutive stops of a trip within the same station become self-loops.
+
+The stops (or stations) without any trip are kept as isolated nodes. The networks are not connected: e.g. in the Netherlands, the stops of different operators are rarely grouped into common stations, so that the network of each operator is mostly a component of its own (the largest component of `nl_stations` has 6,894 of its 38,548 nodes). The frequency-based trips of the Swiss feed (`frequencies.txt`) are counted as single trips. Entrances, generic nodes and boarding areas of the stations, transfers, shapes, fares and the agencies and routes themselves are not included.
+
+The networks have the following properties:
+
+- Nodes of `<country>_stops`: `stop_id`, the id of the stop in the feed; `name`; `lat` and `lon`, its position; `parent_station`, the id of its station (empty if none); `platform_code`, the platform (empty if not given).
+- Nodes of `<country>_stations`: `station_id`, the id of the station (or of the stop, for stops without a station); `name`; `lat` and `lon`; `num_stops`, the number of stops of the station (0 for stops without a station).
+- Edges: `route_type`, the mode of transport, as a GTFS route type (e.g. 0 tram, 1 subway, 2 rail, 3 bus, 4 ferry, 5 cable tram, 6 aerial lift, 7 funicular, or the extended route types, e.g. 100-117 railway services, 700-717 bus services, 900 tram, 1000 water transport, 1300 aerial lift; -1 if not given); `n_trips`, the number of trips of the feed that travel between the two stops (each trip is a pattern of service, which can run on many days); `n_departures`, the number of departures in the period of validity of the feed (the trips multiplied by the number of days on which they run, from the calendars of the feed); `duration_avg`, the average travel time between the two stops (seconds, from the departure at the first stop to the arrival at the second, over the trips; NaN if not given); `distance`, the straight-line distance between the two stops (meters)."""
+tags = ['Transportation', 'Public Transport', 'Spatial', 'Multilayer', 'Weighted', 'Multigraph', 'Metadata']
+url = ['https://mobilitydatabase.org', 'https://files.mobilitydatabase.org',
+       'https://gtfs.de/en/feeds/', 'https://gtfs.ovapi.nl',
+       'https://developer.entur.org/stops-and-timetable-data',
+       'https://opentransportdata.swiss/en/', 'https://www.transportforireland.ie/transitData/PT_Data.html']
+citation = [('gtfs.de, DELFI e.V., "GTFS for Germany: local, regional and long-distance public transport (de_nv, de_rv, de_fv)." Dataset, Mobility Database copies mdb-1090, mdb-1089, mdb-768 of October 25 (2026)', 'https://gtfs.de/en/feeds/'),
+            ('OVapi, "GTFS Netherlands." Dataset, Mobility Database copy mdb-1077 of October 28 (2026)', 'https://gtfs.ovapi.nl'),
+            ('Entur, "Norway aggregated GTFS." Dataset, Mobility Database copy mdb-1078 of October 28 (2026)', 'https://developer.entur.org/stops-and-timetable-data'),
+            ('Systemaufgaben Kundeninformation SKI+, "Timetable 2026 (GTFS2020)." opentransportdata.swiss, dataset, Mobility Database copy mdb-2898 of May 29 (2026)', 'https://opentransportdata.swiss/en/'),
+            ('National Transport Authority, "Transport for Ireland GTFS (all operators)." Dataset, Mobility Database copy mdb-2364 of October 28 (2026)', 'https://www.transportforireland.ie/transitData/PT_Data.html')]
+upstream_license = ("Germany and Ireland: CC BY 4.0; Netherlands: CC0 1.0; Norway: Norwegian Licence for Open Government Data (NLOD); Switzerland: terms of use of opentransportdata.swiss",
+                    "https://creativecommons.org/licenses/by/4.0/")
+icon_hash = None
+
+# The national GTFS feeds, pinned to the dated copies of the Mobility
+# Database (the feeds of the producers are replaced in place every day)
+feeds = {"de": ["mdb-1090-202610250019", "mdb-1089-202610250144",
+                "mdb-768-202610250038"],
+         "nl": ["mdb-1077-202610280127"],
+         "no": ["mdb-1078-202610280119"],
+         "ch": ["mdb-2898-202605290027"],
+         "ie": ["mdb-2364-202610280136"]}
+archived_urls = {f"{d}.zip": "https://files.mobilitydatabase.org/{}/{}/{}.zip"
+                 .format(d.rsplit("-", 1)[0], d, d)
+                 for ds in feeds.values() for d in ds}
+files = []
+for cc, ds in feeds.items():
+    # the Irish feed has no parent stations
+    for kind in (["stops"] if cc == "ie" else ["stations", "stops"]):
+        files.append((tuple(f"{d}.zip" for d in ds), f"{cc}_{kind}", None))
+
+def fetch_upstream(force=False):
+    return fetch_archived_files(__name__.split(".")[-1], archived_urls, force,
+                                tries=3)
+
+def _read(name, zname, member, columns=None, required=True):
+    "A table of a GTFS feed, as strings (None if absent and not required)."
+    import polars as pl
+    try:
+        with open_upstream_file(name, f"{zname}:{member}", "rb") as f:
+            data = f.read()
+    except KeyError:
+        if required:
+            raise
+        return None
+    df = pl.read_csv(data, infer_schema_length=0, encoding="utf8-lossy")
+    df = df.rename({c: c.strip().lstrip("\ufeff") for c in df.columns})
+    if columns is not None:
+        df = df.select([pl.col(c) if c in df.columns else
+                        pl.lit(None, pl.String).alias(c) for c in columns])
+    return df
+
+def _seconds(col):
+    "GTFS times (H:MM:SS, possibly beyond 24:00:00) in seconds."
+    import polars as pl
+    parts = pl.col(col).str.strip_chars().str.split(":")
+    return (parts.list.get(0, null_on_oob=True).cast(pl.Int64, strict=False) * 3600 +
+            parts.list.get(1, null_on_oob=True).cast(pl.Int64, strict=False) * 60 +
+            parts.list.get(2, null_on_oob=True).cast(pl.Int64, strict=False))
+
+def _service_days(name, zname):
+    """The number of days on which each service runs in the period of the
+    feed, from calendar.txt and calendar_dates.txt."""
+    import polars as pl
+    days = []
+    cal = _read(name, zname, "calendar.txt", required=False)
+    if cal is not None and len(cal) > 0:
+        wd = ["monday", "tuesday", "wednesday", "thursday", "friday",
+              "saturday", "sunday"]
+        cal = cal.with_columns(pl.col("start_date").str.strptime(pl.Date, "%Y%m%d"),
+                               pl.col("end_date").str.strptime(pl.Date, "%Y%m%d"),
+                               *[pl.col(d).str.strip_chars() == "1" for d in wd])
+        d = (cal.with_columns(date=pl.date_ranges("start_date", "end_date"))
+             .explode("date")
+             .with_columns(w=pl.col("date").dt.weekday())
+             .filter(pl.any_horizontal([(pl.col("w") == i + 1) & pl.col(x)
+                                        for i, x in enumerate(wd)]))
+             .select("service_id", "date"))
+        days.append(d)
+    cd = _read(name, zname, "calendar_dates.txt", required=False)
+    if cd is not None and len(cd) > 0:
+        cd = cd.with_columns(pl.col("date").str.strptime(pl.Date, "%Y%m%d"),
+                             pl.col("exception_type").str.strip_chars())
+        days.append(cd.filter(pl.col("exception_type") == "1")
+                    .select("service_id", "date"))
+        removed = cd.filter(pl.col("exception_type") == "2").select("service_id", "date")
+    else:
+        removed = None
+    days = pl.concat(days).unique()
+    if removed is not None:
+        days = days.join(removed, on=["service_id", "date"], how="anti")
+    return days.group_by("service_id").agg(n_days=pl.len())
+
+def _connections(name, zname):
+    """The pairs of consecutive stops of the trips of a feed, by route type,
+    with the numbers of trips and of departures (trips times the days on
+    which they run) and the sum and number of the travel times."""
+    import polars as pl
+    routes = _read(name, zname, "routes.txt", ["route_id", "route_type"])
+    trips = _read(name, zname, "trips.txt", ["trip_id", "route_id", "service_id"])
+    sdays = _service_days(name, zname)
+    trips = (trips.join(routes, on="route_id", how="left")
+             .join(sdays, on="service_id", how="left")
+             .select("trip_id", pl.col("route_type").cast(pl.Int64, strict=False),
+                     pl.col("n_days").fill_null(0)))
+    os.makedirs("cache/temp", exist_ok=True)
+    with tempfile.NamedTemporaryFile(dir="cache/temp", suffix=".csv") as tmp:
+        with open_upstream_file(name, f"{zname}:stop_times.txt", "rb") as f:
+            shutil.copyfileobj(f, tmp, 1 << 24)
+        tmp.flush()
+        st = (pl.scan_csv(tmp.name, infer_schema=False, encoding="utf8-lossy")
+              .rename(lambda c: c.strip().lstrip("\ufeff"))
+              .select(pl.col("trip_id").cast(pl.Categorical),
+                      pl.col("stop_id").cast(pl.Categorical),
+                      pl.col("stop_sequence").str.strip_chars().cast(pl.Int64),
+                      _seconds("arrival_time").alias("arr"),
+                      _seconds("departure_time").alias("dep"))
+              .collect(engine="streaming"))
+    st = st.sort("trip_id", "stop_sequence")
+    same = pl.col("trip_id") == pl.col("trip_id").shift(-1)
+    conn = (st.with_columns(v=pl.col("stop_id").shift(-1),
+                            duration=pl.col("arr").shift(-1) - pl.col("dep"))
+            .filter(same)
+            .select("trip_id", pl.col("stop_id").alias("u"), "v", "duration"))
+    del st
+    trips = trips.with_columns(pl.col("trip_id").cast(pl.Categorical))
+    conn = conn.join(trips, on="trip_id", how="left")
+    return _aggregate(conn.group_by("u", "v", "route_type")
+                      .agg(n_trips=pl.len(), n_departures=pl.col("n_days").sum(),
+                           duration_sum=pl.col("duration").sum(),
+                           duration_n=pl.col("duration").count())
+                      .with_columns(pl.col("u").cast(pl.String),
+                                    pl.col("v").cast(pl.String)))
+
+def _aggregate(df):
+    "Sum the connections of the same stops and route type."
+    import polars as pl
+    return (df.group_by("u", "v", "route_type")
+            .agg(pl.col(["n_trips", "n_departures", "duration_sum",
+                         "duration_n"]).sum()))
+
+def _haversine(lat1, lon1, lat2, lon2):
+    lat1, lon1, lat2, lon2 = map(numpy.radians, (lat1, lon1, lat2, lon2))
+    a = (numpy.sin((lat2 - lat1) / 2) ** 2 + numpy.cos(lat1) * numpy.cos(lat2) *
+         numpy.sin((lon2 - lon1) / 2) ** 2)
+    return 2 * 6371008.8 * numpy.arcsin(numpy.sqrt(a))
+
+@cache_network()
+@coerce_props()
+@annotate()
+def parse(alts=None):
+    import polars as pl
+    name = __name__.split(".")[-1]
+    cache = {}
+    for fnames, alt, fmt in files:
+        if alts is not None and alt not in alts:
+            continue
+        cc, kind = alt.split("_")
+        if cache.get("cc") != cc:
+            cache.clear()
+            cols = ["stop_id", "stop_name", "stop_lat", "stop_lon",
+                    "location_type", "parent_station", "platform_code"]
+            # the feeds of a country (Germany) share the stop ids
+            stops = pl.concat([_read(name, z, "stops.txt", cols) for z in fnames])
+            stops = stops.unique("stop_id", keep="first", maintain_order=True)
+            conn = _aggregate(pl.concat([_connections(name, z) for z in fnames]))
+            cache.update(cc=cc, stops=stops, conn=conn)
+        stops, conn = cache["stops"], cache["conn"]
+        lt = pl.col("location_type").str.strip_chars().fill_null("")
+        served = set(conn["u"].unique()) | set(conn["v"].unique())
+        if kind == "stops":
+            nodes = stops.filter(lt.is_in(["", "0"]) | pl.col("stop_id").is_in(served))
+            c = conn
+        else:
+            # stops are grouped into their parent stations
+            ids = set(stops["stop_id"])
+            parent = {s: p for s, p in zip(stops["stop_id"], stops["parent_station"])
+                      if p and p in ids}
+            nodes = stops.filter((lt == "1") |
+                                 (lt.is_in(["", "0"]) &
+                                  ~pl.col("stop_id").is_in(list(parent))))
+            nchild = Counter(parent.values())
+            c = conn.with_columns(pl.col("u").replace(parent),
+                                  pl.col("v").replace(parent))
+        idx = {s: i for i, s in enumerate(nodes["stop_id"])}
+        missing = (set(c["u"].unique()) | set(c["v"].unique())) - set(idx)
+        if missing:
+            raise ValueError(f"{len(missing)} stops of the trips missing in {alt}")
+        agg = (_aggregate(c)
+               .with_columns(duration_avg=pl.col("duration_sum") /
+                             pl.col("duration_n"))
+               .sort("u", "v", "route_type"))
+        g = Graph(directed=True)
+        g.add_vertex(len(nodes))
+        g.add_edge_list(numpy.array([[idx[x] for x in agg["u"]],
+                                     [idx[x] for x in agg["v"]]]).T)
+        g.vp[kind[:-1] + "_id"] = g.new_vp("string", vals=nodes["stop_id"])
+        g.vp.name = g.new_vp("string", vals=nodes["stop_name"].fill_null(""))
+        lat = nodes["stop_lat"].cast(pl.Float64, strict=False).fill_null(numpy.nan).to_numpy()
+        lon = nodes["stop_lon"].cast(pl.Float64, strict=False).fill_null(numpy.nan).to_numpy()
+        g.vp.lat = g.new_vp("double", vals=lat)
+        g.vp.lon = g.new_vp("double", vals=lon)
+        if kind == "stops":
+            g.vp.parent_station = g.new_vp("string", vals=nodes["parent_station"].fill_null(""))
+            g.vp.platform_code = g.new_vp("string", vals=nodes["platform_code"].fill_null(""))
+        else:
+            g.vp.num_stops = g.new_vp("int", vals=[nchild.get(s, 0) for s in nodes["stop_id"]])
+        g.ep.route_type = g.new_ep("int", vals=agg["route_type"].fill_null(-1))
+        g.ep.n_trips = g.new_ep("int64_t", vals=agg["n_trips"])
+        g.ep.n_departures = g.new_ep("int64_t", vals=agg["n_departures"])
+        g.ep.duration_avg = g.new_ep("double", vals=agg["duration_avg"].fill_null(numpy.nan))
+        u, v = g.get_edges().T
+        g.ep.distance = g.new_ep("double", vals=_haversine(lat[u], lon[u], lat[v], lon[v]))
+        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)